Files
dataxl/cmd/dataxl/path.go

318 lines
8.8 KiB
Go

package main
import (
"encoding/json"
"fmt"
"strconv"
"strings"
)
type parsedColumn struct {
header string
index int
tokens []pathToken
}
// tableToRecords interprets non-empty headers as path expressions. A blank
// header is safe only when every cell below it is also blank; otherwise there
// is no key under which the value can be preserved.
func tableToRecords(t table) ([]map[string]any, error) {
columns := make([]parsedColumn, 0, len(t.Header))
seen := make(map[string]int)
for columnIndex, header := range t.Header {
if header == "" {
for rowIndex, row := range t.Rows {
if columnIndex < len(row) && row[columnIndex] != "" {
return nil, fmt.Errorf(
"row %d column %d contains data but its header is blank",
rowIndex+2, columnIndex+1,
)
}
}
continue
}
if firstIndex, exists := seen[header]; exists {
return nil, fmt.Errorf(
"duplicate header %q in columns %d and %d",
header, firstIndex+1, columnIndex+1,
)
}
seen[header] = columnIndex
tokens, err := parsePath(header)
if err != nil {
return nil, fmt.Errorf("invalid header %q in column %d: %w", header, columnIndex+1, err)
}
columns = append(columns, parsedColumn{header: header, index: columnIndex, tokens: tokens})
}
records := make([]map[string]any, 0, len(t.Rows))
for rowIndex, row := range t.Rows {
record := make(map[string]any)
for _, column := range columns {
cell := ""
if column.index < len(row) {
cell = row[column.index]
}
if err := setPathTokens(record, column.tokens, parseCell(cell)); err != nil {
return nil, fmt.Errorf("row %d header %q: %w", rowIndex+2, column.header, err)
}
}
records = append(records, record)
}
return records, nil
}
// parseCell deliberately performs narrow inference. In particular, strings
// with surrounding whitespace and zero-padded identifiers remain strings.
func parseCell(s string) any {
if s == "" {
return ""
}
if s == "true" {
return true
}
if s == "false" {
return false
}
// Preserve zero-padded identifiers such as postal codes and product codes.
leadingZero := len(s) > 1 && s[0] == '0'
negativeLeadingZero := len(s) > 2 && s[0] == '-' && s[1] == '0'
if i, err := strconv.ParseInt(s, 10, 64); err == nil && !leadingZero && !negativeLeadingZero {
return i
}
if f, err := strconv.ParseFloat(s, 64); err == nil && strings.ContainsAny(s, ".eE") {
return f
}
return s
}
type pathToken struct {
key string
index int
isIndex bool
}
const (
maxPathTokens = 256
maxArrayIndex = 10_000
)
// parsePath accepts dotted keys, zero-based array indices, and JSON-quoted map
// keys in brackets. Quoted keys make delimiters and empty keys unambiguous:
// user.name, items[0].sku, and settings["build.target"] are all valid.
func parsePath(path string) ([]pathToken, error) {
if path == "" {
return nil, fmt.Errorf("path is empty")
}
var tokens []pathToken
var err error
position := 0
if strings.HasPrefix(path, `["`) {
var key string
key, position, err = parseQuotedKey(path, position)
if err != nil {
return nil, err
}
tokens = append(tokens, pathToken{key: key})
} else {
var key string
key, position, err = parseBareKey(path, position)
if err != nil {
return nil, err
}
tokens = append(tokens, pathToken{key: key})
}
for position < len(path) {
switch path[position] {
case '.':
position++
key, next, err := parseBareKey(path, position)
if err != nil {
return nil, err
}
tokens = append(tokens, pathToken{key: key})
position = next
case '[':
if position+1 < len(path) && path[position+1] == '"' {
key, next, err := parseQuotedKey(path, position)
if err != nil {
return nil, err
}
tokens = append(tokens, pathToken{key: key})
position = next
continue
}
index, next, err := parseIndex(path, position)
if err != nil {
return nil, err
}
tokens = append(tokens, pathToken{index: index, isIndex: true})
position = next
default:
return nil, fmt.Errorf("unexpected character %q at byte %d", path[position], position)
}
}
if len(tokens) > maxPathTokens {
return nil, fmt.Errorf("path has %d elements; maximum is %d", len(tokens), maxPathTokens)
}
return tokens, nil
}
func parseBareKey(path string, position int) (string, int, error) {
start := position
for position < len(path) && !strings.ContainsRune(".[]", rune(path[position])) {
position++
}
if position == start {
return "", position, fmt.Errorf("map key is empty at byte %d", start)
}
return path[start:position], position, nil
}
func parseQuotedKey(path string, position int) (string, int, error) {
if position+1 >= len(path) || path[position] != '[' || path[position+1] != '"' {
return "", position, fmt.Errorf("quoted key expected at byte %d", position)
}
quoteStart := position + 1
escaped := false
quoteEnd := -1
for i := quoteStart + 1; i < len(path); i++ {
switch {
case escaped:
escaped = false
case path[i] == '\\':
escaped = true
case path[i] == '"':
quoteEnd = i
i = len(path)
}
}
if quoteEnd == -1 {
return "", position, fmt.Errorf("unterminated quoted key at byte %d", position)
}
if quoteEnd+1 >= len(path) || path[quoteEnd+1] != ']' {
return "", position, fmt.Errorf("quoted key at byte %d is missing closing ]", position)
}
var key string
if err := json.Unmarshal([]byte(path[quoteStart:quoteEnd+1]), &key); err != nil {
return "", position, fmt.Errorf("invalid quoted key at byte %d: %w", position, err)
}
return key, quoteEnd + 2, nil
}
func parseIndex(path string, position int) (int, int, error) {
digitsStart := position + 1
digitsEnd := digitsStart
for digitsEnd < len(path) && path[digitsEnd] >= '0' && path[digitsEnd] <= '9' {
digitsEnd++
}
if digitsEnd == digitsStart {
return 0, position, fmt.Errorf("array index is empty or invalid at byte %d", position)
}
if digitsEnd >= len(path) || path[digitsEnd] != ']' {
return 0, position, fmt.Errorf("array index at byte %d is missing closing ]", position)
}
index, err := strconv.Atoi(path[digitsStart:digitsEnd])
if err != nil {
return 0, position, fmt.Errorf("array index at byte %d is out of range: %w", position, err)
}
if index > maxArrayIndex {
return 0, position, fmt.Errorf("array index %d exceeds maximum %d", index, maxArrayIndex)
}
return index, digitsEnd + 1, nil
}
// appendPathKey adds a map key to a column path. Keys containing path
// delimiters (and the empty key) use JSON bracket notation so round trips do
// not confuse literal key content with structure.
func appendPathKey(prefix, key string) string {
segment := key
quoted := key == "" || strings.ContainsAny(key, ".[]")
if quoted {
encoded, _ := json.Marshal(key) // Go strings always have a JSON representation.
segment = "[" + string(encoded) + "]"
return prefix + segment
}
if prefix == "" {
return segment
}
return prefix + "." + segment
}
// setPath creates maps and slices while walking a dotted/indexed header.
// Conflicting shapes return an error instead of discarding a column value.
func setPath(root map[string]any, path string, value any) error {
tokens, err := parsePath(path)
if err != nil {
return err
}
return setPathTokens(root, tokens, value)
}
func setPathTokens(root map[string]any, tokens []pathToken, value any) error {
if len(tokens) == 0 || tokens[0].isIndex {
return fmt.Errorf("path must start with a map key")
}
_, err := setPathValue(root, tokens, value)
return err
}
func setPathValue(current any, tokens []pathToken, value any) (any, error) {
if len(tokens) == 0 {
return value, nil
}
token := tokens[0]
if token.isIndex {
var values []any
switch typed := current.(type) {
case nil:
values = []any{}
case []any:
values = typed
default:
return nil, fmt.Errorf("expected an array at index [%d], found %T", token.index, current)
}
for len(values) <= token.index {
values = append(values, nil)
}
if len(tokens) == 1 {
if values[token.index] != nil {
return nil, fmt.Errorf("path conflicts with an existing array value at index [%d]", token.index)
}
values[token.index] = value
return values, nil
}
child, err := setPathValue(values[token.index], tokens[1:], value)
if err != nil {
return nil, err
}
values[token.index] = child
return values, nil
}
var object map[string]any
switch typed := current.(type) {
case nil:
object = make(map[string]any)
case map[string]any:
object = typed
default:
return nil, fmt.Errorf("expected an object before key %q, found %T", token.key, current)
}
if len(tokens) == 1 {
if _, exists := object[token.key]; exists {
return nil, fmt.Errorf("path conflicts with an existing value at key %q", token.key)
}
object[token.key] = value
return object, nil
}
child, err := setPathValue(object[token.key], tokens[1:], value)
if err != nil {
return nil, err
}
object[token.key] = child
return object, nil
}