318 lines
8.8 KiB
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
|
|
}
|