2 Commits

Author SHA1 Message Date
2e96d174f2 Document conversion internals and limits
All checks were successful
CI / test (pull_request) Successful in 26s
2026-07-18 16:25:50 +09:00
3ea4d31fd0 Refactor conversion pipeline by responsibility 2026-07-18 16:25:50 +09:00
10 changed files with 726 additions and 537 deletions

View File

@@ -111,6 +111,26 @@ id items[0].qty items[0].sku user.name
逆方向の変換では、`user.name``items[0].sku` のような列名から入れ子の 逆方向の変換では、`user.name``items[0].sku` のような列名から入れ子の
map/arrayを復元します。 map/arrayを復元します。
列パスは `.` でmapのキー、`[n]` で0始まりの配列indexを表します。たとえば
`orders[0].items[1].sku` は、最初の注文に含まれる2番目の商品の `sku` です。
同じ行に `user``user.name` のような競合する列がある場合、先に読み込まれた
値を保持し、後続列で型を上書きしません。曖昧な復元を避けるため、親要素と子要素を
同時に列として置かないでください。
## セル値の型推定
CSV、TSV、XLSXから構造化形式へ戻す際は、セル文字列を次の順で推定します。
- 空文字は空文字列
- `true` / `false` はboolean
- 通常の整数は64-bit integer
- 小数点または指数表記を含む数値は64-bit floating point
- それ以外は文字列
郵便番号や商品コードを想定し、`00123``-01` のようなゼロ埋め値は文字列のまま
保持します。日付、時刻、`null` は自動推定しません。
## CLIオプション ## CLIオプション
- `-i`: 入力ファイル。省略時はstdin。 - `-i`: 入力ファイル。省略時はstdin。
@@ -125,6 +145,8 @@ map/arrayを復元します。
- 表形式では1行目をヘッダーとして扱います。 - 表形式では1行目をヘッダーとして扱います。
- XLSXは指定した1シートのみ読み書きします。 - XLSXは指定した1シートのみ読み書きします。
- セル値の型推定は、空文字、真偽値、整数、小数、文字列の範囲です。 - セル値の型推定は、空文字、真偽値、整数、小数、文字列の範囲です。
- 空のmap/arrayは表側で `{}` / `[]` と表示されますが、逆変換時は文字列になります。
- mapキーに `.``[``]` を含む場合のescape記法は未対応です。
- 複雑なExcel書式や数式の保持は目的外です。 - 複雑なExcel書式や数式の保持は目的外です。
## 開発者向け情報 ## 開発者向け情報

71
cmd/dataxl/conversion.go Normal file
View File

@@ -0,0 +1,71 @@
package main
import (
"fmt"
"path/filepath"
"strings"
)
// convert routes data through either the structured-value or table internal
// representation. Keeping this matrix in one place makes format adapters
// independent from CLI and file I/O concerns.
func convert(input []byte, from, to, sheet string, pretty bool) ([]byte, error) {
switch {
case isTabular(from) && isTabular(to):
t, err := parseTable(input, from, sheet)
if err != nil {
return nil, err
}
return encodeTable(t, to, sheet)
case isTabular(from):
t, err := parseTable(input, from, sheet)
if err != nil {
return nil, err
}
return encodeStructured(tableToRecords(t), to, pretty)
case isTabular(to):
value, err := parseStructured(input, from)
if err != nil {
return nil, err
}
return encodeTable(valueToTable(value), to, sheet)
default:
value, err := parseStructured(input, from)
if err != nil {
return nil, err
}
return encodeStructured(value, to, pretty)
}
}
func normalizeFormat(format string) string {
format = strings.ToLower(strings.TrimPrefix(strings.TrimSpace(format), "."))
switch format {
case "yml":
return "yaml"
case "xlsm", "xls":
return "xlsx"
default:
return format
}
}
func inferFormat(path string) string {
if path == "" || path == "-" {
return ""
}
return normalizeFormat(filepath.Ext(path))
}
func validateFormat(format string) error {
switch format {
case "json", "yaml", "toml", "csv", "tsv", "xlsx":
return nil
default:
return fmt.Errorf("unsupported format %q", format)
}
}
func isTabular(format string) bool {
return format == "csv" || format == "tsv" || format == "xlsx"
}

7
cmd/dataxl/doc.go Normal file
View File

@@ -0,0 +1,7 @@
// Package main implements the dataxl command-line converter.
//
// Conversion uses two internal representations: structured Go values for
// JSON/YAML/TOML and table for CSV/TSV/XLSX. Nested structured values cross the
// boundary through dotted and indexed column paths such as user.name and
// items[0].sku.
package main

View File

@@ -1,25 +1,15 @@
package main package main
import ( import (
"bytes"
"encoding/csv"
"encoding/json"
"errors"
"flag" "flag"
"fmt" "fmt"
"io" "io"
"os" "os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings" "strings"
"github.com/BurntSushi/toml"
"github.com/xuri/excelize/v2"
"gopkg.in/yaml.v3"
) )
// options contains CLI concerns only. Conversion functions receive explicit
// arguments so they can be reused and tested without constructing a FlagSet.
type options struct { type options struct {
inFile string inFile string
outFile string outFile string
@@ -29,11 +19,6 @@ type options struct {
pretty bool pretty bool
} }
type table struct {
Header []string
Rows [][]string
}
func main() { func main() {
if err := run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr); err != nil { if err := run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr); err != nil {
fmt.Fprintln(os.Stderr, "dataxl:", err) fmt.Fprintln(os.Stderr, "dataxl:", err)
@@ -42,6 +27,26 @@ func main() {
} }
func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error { func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error {
opt, err := parseOptions(args, stderr)
if err != nil {
return err
}
if err := opt.resolveFormats(); err != nil {
return err
}
input, err := readInput(opt.inFile, stdin)
if err != nil {
return err
}
output, err := convert(input, opt.from, opt.to, opt.sheet, opt.pretty)
if err != nil {
return err
}
return writeOutput(opt.outFile, stdout, output)
}
func parseOptions(args []string, stderr io.Writer) (options, error) {
var opt options var opt options
fs := flag.NewFlagSet("dataxl", flag.ContinueOnError) fs := flag.NewFlagSet("dataxl", flag.ContinueOnError)
fs.SetOutput(stderr) fs.SetOutput(stderr)
@@ -66,16 +71,15 @@ Notes:
converting back to json/yaml/toml.`) converting back to json/yaml/toml.`)
} }
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return err return options{}, err
} }
if fs.NArg() != 0 { if fs.NArg() != 0 {
return fmt.Errorf("unexpected arguments: %s", strings.Join(fs.Args(), " ")) return options{}, fmt.Errorf("unexpected arguments: %s", strings.Join(fs.Args(), " "))
} }
return opt, nil
}
input, err := readInput(opt.inFile, stdin) func (opt *options) resolveFormats() error {
if err != nil {
return err
}
opt.from = normalizeFormat(opt.from) opt.from = normalizeFormat(opt.from)
opt.to = normalizeFormat(opt.to) opt.to = normalizeFormat(opt.to)
if opt.from == "" { if opt.from == "" {
@@ -85,60 +89,12 @@ Notes:
opt.to = inferFormat(opt.outFile) opt.to = inferFormat(opt.outFile)
} }
if opt.from == "" || opt.to == "" { if opt.from == "" || opt.to == "" {
return errors.New("both -from and -to are required when a format cannot be inferred from file names") return fmt.Errorf("both -from and -to are required when a format cannot be inferred from file names")
} }
if err := validateFormat(opt.from); err != nil { if err := validateFormat(opt.from); err != nil {
return err return err
} }
if err := validateFormat(opt.to); err != nil { return validateFormat(opt.to)
return err
}
var out []byte
// Use table as the common representation whenever either side is a
// spreadsheet-like format. Structured formats can then share the same
// flatten/restore path for CSV, TSV, and XLSX.
if isTabular(opt.from) && isTabular(opt.to) {
t, err := parseTable(input, opt.from, opt.sheet)
if err != nil {
return err
}
out, err = encodeTable(t, opt.to, opt.sheet)
if err != nil {
return err
}
} else if isTabular(opt.from) {
t, err := parseTable(input, opt.from, opt.sheet)
if err != nil {
return err
}
records := tableToRecords(t)
out, err = encodeStructured(records, opt.to, opt.pretty)
if err != nil {
return err
}
} else if isTabular(opt.to) {
value, err := parseStructured(input, opt.from)
if err != nil {
return err
}
t := valueToTable(value)
out, err = encodeTable(t, opt.to, opt.sheet)
if err != nil {
return err
}
} else {
value, err := parseStructured(input, opt.from)
if err != nil {
return err
}
out, err = encodeStructured(value, opt.to, opt.pretty)
if err != nil {
return err
}
}
return writeOutput(opt.outFile, stdout, out)
} }
func readInput(path string, stdin io.Reader) ([]byte, error) { func readInput(path string, stdin io.Reader) ([]byte, error) {
@@ -155,459 +111,3 @@ func writeOutput(path string, stdout io.Writer, data []byte) error {
} }
return os.WriteFile(path, data, 0o644) return os.WriteFile(path, data, 0o644)
} }
func normalizeFormat(format string) string {
format = strings.ToLower(strings.TrimPrefix(strings.TrimSpace(format), "."))
switch format {
case "yml":
return "yaml"
case "xlsm", "xls":
return "xlsx"
default:
return format
}
}
func inferFormat(path string) string {
if path == "" || path == "-" {
return ""
}
return normalizeFormat(strings.TrimPrefix(filepath.Ext(path), "."))
}
func validateFormat(format string) error {
switch format {
case "json", "yaml", "toml", "csv", "tsv", "xlsx":
return nil
default:
return fmt.Errorf("unsupported format %q", format)
}
}
func isTabular(format string) bool {
return format == "csv" || format == "tsv" || format == "xlsx"
}
func parseStructured(input []byte, format string) (any, error) {
var value any
switch format {
case "json":
dec := json.NewDecoder(bytes.NewReader(input))
dec.UseNumber()
if err := dec.Decode(&value); err != nil {
return nil, err
}
case "yaml":
if err := yaml.Unmarshal(input, &value); err != nil {
return nil, err
}
value = normalizeYAML(value)
case "toml":
var m map[string]any
if err := toml.Unmarshal(input, &m); err != nil {
return nil, err
}
value = m
default:
return nil, fmt.Errorf("format %q is not structured", format)
}
return value, nil
}
func encodeStructured(value any, format string, pretty bool) ([]byte, error) {
switch format {
case "json":
if pretty {
return json.MarshalIndent(value, "", " ")
}
return json.Marshal(value)
case "yaml":
return yaml.Marshal(value)
case "toml":
m, ok := value.(map[string]any)
if !ok {
m = map[string]any{"rows": value}
}
var b bytes.Buffer
err := toml.NewEncoder(&b).Encode(m)
return b.Bytes(), err
default:
return nil, fmt.Errorf("format %q is not structured", format)
}
}
func normalizeYAML(value any) any {
switch v := value.(type) {
case map[string]any:
out := make(map[string]any, len(v))
for key, child := range v {
out[key] = normalizeYAML(child)
}
return out
case map[any]any:
out := make(map[string]any, len(v))
for key, child := range v {
out[fmt.Sprint(key)] = normalizeYAML(child)
}
return out
case []any:
for i := range v {
v[i] = normalizeYAML(v[i])
}
}
return value
}
func parseTable(input []byte, format, sheet string) (table, error) {
switch format {
case "csv":
return readDelimited(input, ',')
case "tsv":
return readDelimited(input, '\t')
case "xlsx":
f, err := excelize.OpenReader(bytes.NewReader(input))
if err != nil {
return table{}, err
}
defer f.Close()
rows, err := f.GetRows(sheet)
if err != nil {
return table{}, err
}
return rowsToTable(rows), nil
default:
return table{}, fmt.Errorf("format %q is not tabular", format)
}
}
func readDelimited(input []byte, comma rune) (table, error) {
r := csv.NewReader(bytes.NewReader(input))
r.Comma = comma
r.FieldsPerRecord = -1
r.TrimLeadingSpace = comma == ','
rows, err := r.ReadAll()
if err != nil {
return table{}, err
}
return rowsToTable(rows), nil
}
func rowsToTable(rows [][]string) table {
if len(rows) == 0 {
return table{}
}
width := 0
for _, row := range rows {
if len(row) > width {
width = len(row)
}
}
header := padRow(rows[0], width)
body := make([][]string, 0, len(rows)-1)
for _, row := range rows[1:] {
body = append(body, padRow(row, width))
}
return table{Header: header, Rows: body}
}
func padRow(row []string, width int) []string {
out := make([]string, width)
copy(out, row)
return out
}
func encodeTable(t table, format, sheet string) ([]byte, error) {
switch format {
case "csv":
return writeDelimited(t, ',')
case "tsv":
return writeDelimited(t, '\t')
case "xlsx":
return writeXLSX(t, sheet)
default:
return nil, fmt.Errorf("format %q is not tabular", format)
}
}
func writeDelimited(t table, comma rune) ([]byte, error) {
var b bytes.Buffer
w := csv.NewWriter(&b)
w.Comma = comma
if err := w.Write(t.Header); err != nil {
return nil, err
}
for _, row := range t.Rows {
if err := w.Write(row); err != nil {
return nil, err
}
}
w.Flush()
return b.Bytes(), w.Error()
}
func writeXLSX(t table, sheet string) ([]byte, error) {
f := excelize.NewFile()
defaultSheet := f.GetSheetName(0)
if sheet == "" {
sheet = "Sheet1"
}
if defaultSheet != sheet {
if err := f.SetSheetName(defaultSheet, sheet); err != nil {
return nil, err
}
}
rows := append([][]string{t.Header}, t.Rows...)
for r, row := range rows {
for c, value := range row {
cell, err := excelize.CoordinatesToCellName(c+1, r+1)
if err != nil {
return nil, err
}
if err := f.SetCellValue(sheet, cell, value); err != nil {
return nil, err
}
}
}
if len(t.Header) > 0 {
// The generated workbook is meant for editing, so keep the header row
// visible and visually distinct.
end, _ := excelize.CoordinatesToCellName(len(t.Header), 1)
style, _ := f.NewStyle(&excelize.Style{Font: &excelize.Font{Bold: true}})
_ = f.SetCellStyle(sheet, "A1", end, style)
_ = f.SetPanes(sheet, &excelize.Panes{
Freeze: true,
Split: false,
XSplit: 0,
YSplit: 1,
TopLeftCell: "A2",
ActivePane: "bottomLeft",
})
}
var b bytes.Buffer
if err := f.Write(&b); err != nil {
return nil, err
}
return b.Bytes(), nil
}
func valueToTable(value any) table {
records := recordsFromValue(value)
flatRows := make([]map[string]string, 0, len(records))
seen := map[string]bool{}
var header []string
for _, record := range records {
flat := map[string]string{}
flatten("", record, flat)
for key := range flat {
if !seen[key] {
seen[key] = true
header = append(header, key)
}
}
flatRows = append(flatRows, flat)
}
// Stable column ordering keeps generated CSV/TSV/XLSX diffs predictable.
sort.Strings(header)
rows := make([][]string, 0, len(flatRows))
for _, flat := range flatRows {
row := make([]string, len(header))
for i, key := range header {
row[i] = flat[key]
}
rows = append(rows, row)
}
return table{Header: header, Rows: rows}
}
func recordsFromValue(value any) []any {
switch v := value.(type) {
case []any:
return v
case map[string]any:
// Common wrapper keys let TOML and object-shaped inputs represent a
// table without adding a format-specific flag.
for _, key := range []string{"rows", "records", "items"} {
if rows, ok := v[key].([]any); ok {
return rows
}
}
return []any{v}
default:
return []any{v}
}
}
// flatten converts nested values into spreadsheet-safe column paths such as
// user.name and items[0].sku.
func flatten(prefix string, value any, out map[string]string) {
switch v := value.(type) {
case map[string]any:
if len(v) == 0 && prefix != "" {
out[prefix] = "{}"
return
}
keys := make([]string, 0, len(v))
for key := range v {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
childPrefix := key
if prefix != "" {
childPrefix = prefix + "." + key
}
flatten(childPrefix, v[key], out)
}
case []any:
if len(v) == 0 && prefix != "" {
out[prefix] = "[]"
return
}
for i, child := range v {
flatten(fmt.Sprintf("%s[%d]", prefix, i), child, out)
}
default:
if prefix == "" {
prefix = "value"
}
out[prefix] = scalarString(v)
}
}
func scalarString(value any) string {
switch v := value.(type) {
case nil:
return ""
case json.Number:
return v.String()
case string:
return v
case bool:
return strconv.FormatBool(v)
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
return fmt.Sprint(v)
default:
data, err := json.Marshal(v)
if err != nil {
return fmt.Sprint(v)
}
return string(data)
}
}
// tableToRecords restores each row by interpreting header cells as path
// expressions. Empty headers are ignored so spare spreadsheet columns are safe.
func tableToRecords(t table) []map[string]any {
var records []map[string]any
for _, row := range t.Rows {
record := map[string]any{}
for i, header := range t.Header {
header = strings.TrimSpace(header)
if header == "" || i >= len(row) {
continue
}
setPath(record, header, parseCell(row[i]))
}
records = append(records, record)
}
return records
}
// parseCell keeps spreadsheet round trips useful while avoiding broad type
// inference that could surprise users editing IDs or codes.
func parseCell(s string) any {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
if s == "true" {
return true
}
if s == "false" {
return false
}
if i, err := strconv.ParseInt(s, 10, 64); err == nil {
return i
}
if f, err := strconv.ParseFloat(s, 64); err == nil && strings.ContainsAny(s, ".eE") {
return f
}
return s
}
var pathTokenRE = regexp.MustCompile(`([^\.\[\]]+)|\[(\d+)\]`)
// setPath creates maps and slices as needed for dotted and indexed header
// paths. Invalid intermediate shapes are left unchanged instead of guessing.
func setPath(root map[string]any, path string, value any) {
tokens := parsePath(path)
if len(tokens) == 0 {
return
}
var cur any = root
for i, token := range tokens {
last := i == len(tokens)-1
nextIsIndex := !last && tokens[i+1].isIndex
if token.isIndex {
continue
}
m, ok := cur.(map[string]any)
if !ok {
return
}
if last {
m[token.key] = value
return
}
if _, ok := m[token.key]; !ok {
if nextIsIndex {
m[token.key] = []any{}
} else {
m[token.key] = map[string]any{}
}
}
if nextIsIndex {
slice, _ := m[token.key].([]any)
index := tokens[i+1].index
for len(slice) <= index {
if i+2 < len(tokens) && !tokens[i+2].isIndex {
slice = append(slice, map[string]any{})
} else {
slice = append(slice, nil)
}
}
m[token.key] = slice
if i+2 == len(tokens) {
slice[index] = value
return
}
if slice[index] == nil {
slice[index] = map[string]any{}
}
cur = slice[index]
i++
continue
}
cur = m[token.key]
}
}
type pathToken struct {
key string
index int
isIndex bool
}
func parsePath(path string) []pathToken {
matches := pathTokenRE.FindAllStringSubmatch(path, -1)
tokens := make([]pathToken, 0, len(matches))
for _, m := range matches {
if m[1] != "" {
tokens = append(tokens, pathToken{key: m[1]})
continue
}
index, _ := strconv.Atoi(m[2])
tokens = append(tokens, pathToken{index: index, isIndex: true})
}
return tokens
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"strings" "strings"
"testing" "testing"
) )
@@ -88,6 +89,76 @@ func TestJSONToXLSXAndBack(t *testing.T) {
} }
} }
func TestResolveFormatsFromFileExtensions(t *testing.T) {
opt := options{inFile: "input.YML", outFile: "output.XLSM"}
if err := opt.resolveFormats(); err != nil {
t.Fatal(err)
}
if opt.from != "yaml" || opt.to != "xlsx" {
t.Fatalf("resolved formats = %q -> %q, want yaml -> xlsx", opt.from, opt.to)
}
}
func TestConvertStructuredToStructured(t *testing.T) {
got, err := convert([]byte("name: Alice\nactive: true\n"), "yaml", "json", "Sheet1", false)
if err != nil {
t.Fatal(err)
}
if string(got) != `{"active":true,"name":"Alice"}` {
t.Fatalf("JSON = %s", got)
}
}
func TestRowsToTablePadsRaggedRows(t *testing.T) {
got := rowsToTable([][]string{{"a", "b"}, {"1"}, {"2", "3", "4"}})
want := table{
Header: []string{"a", "b", ""},
Rows: [][]string{{"1", "", ""}, {"2", "3", "4"}},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("table = %#v, want %#v", got, want)
}
}
func TestParseCellConservativeInference(t *testing.T) {
tests := map[string]any{
"": "",
"true": true,
"42": int64(42),
"3.14": 3.14,
"00123": "00123",
"1e3": 1000.0,
"Alice": "Alice",
}
for input, want := range tests {
if got := parseCell(input); !reflect.DeepEqual(got, want) {
t.Errorf("parseCell(%q) = %#v, want %#v", input, got, want)
}
}
}
func TestSetPathDoesNotOverwriteConflictingShape(t *testing.T) {
record := map[string]any{}
setPath(record, "user", "Alice")
setPath(record, "user.name", "Bob")
if got := record["user"]; got != "Alice" {
t.Fatalf("user = %#v, want original scalar", got)
}
}
func TestSetPathRestoresNestedArrays(t *testing.T) {
record := map[string]any{}
setPath(record, "orders[0].items[1].sku", "B-002")
orders := record["orders"].([]any)
order := orders[0].(map[string]any)
items := order["items"].([]any)
item := items[1].(map[string]any)
if item["sku"] != "B-002" {
t.Fatalf("nested sku = %#v, want B-002", item["sku"])
}
}
type ioDiscard struct{} type ioDiscard struct{}
func (ioDiscard) Write(p []byte) (int, error) { return len(p), nil } func (ioDiscard) Write(p []byte) (int, error) { return len(p), nil }

134
cmd/dataxl/path.go Normal file
View File

@@ -0,0 +1,134 @@
package main
import (
"regexp"
"strconv"
"strings"
)
// tableToRecords interprets non-empty headers as path expressions. Blank
// headers are intentionally ignored so spare spreadsheet columns are harmless.
func tableToRecords(t table) []map[string]any {
records := make([]map[string]any, 0, len(t.Rows))
for _, row := range t.Rows {
record := make(map[string]any)
for i, header := range t.Header {
header = strings.TrimSpace(header)
if header == "" || i >= len(row) {
continue
}
setPath(record, header, parseCell(row[i]))
}
records = append(records, record)
}
return records
}
// parseCell deliberately performs narrow inference. In particular, strings
// such as 00123 remain strings because converting identifiers is surprising.
func parseCell(s string) any {
s = strings.TrimSpace(s)
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
}
var pathTokenRE = regexp.MustCompile(`([^\.\[\]]+)|\[(\d+)\]`)
type pathToken struct {
key string
index int
isIndex bool
}
func parsePath(path string) []pathToken {
matches := pathTokenRE.FindAllStringSubmatch(path, -1)
tokens := make([]pathToken, 0, len(matches))
for _, match := range matches {
if match[1] != "" {
tokens = append(tokens, pathToken{key: match[1]})
continue
}
index, _ := strconv.Atoi(match[2]) // regexp guarantees decimal digits.
tokens = append(tokens, pathToken{index: index, isIndex: true})
}
return tokens
}
// setPath creates maps and slices while walking a dotted/indexed header. The
// supported grammar is a sequence of map keys with optional array indices,
// e.g. "orders[0].items[1].sku". Conflicting intermediate shapes are left
// unchanged rather than silently overwriting data from an earlier column.
func setPath(root map[string]any, path string, value any) {
tokens := parsePath(path)
if len(tokens) == 0 {
return
}
var current any = root
for i := 0; i < len(tokens); i++ {
token := tokens[i]
last := i == len(tokens)-1
nextIsIndex := !last && tokens[i+1].isIndex
if token.isIndex {
continue
}
m, ok := current.(map[string]any)
if !ok {
return
}
if last {
m[token.key] = value
return
}
if _, exists := m[token.key]; !exists {
if nextIsIndex {
m[token.key] = []any{}
} else {
m[token.key] = map[string]any{}
}
}
if !nextIsIndex {
current = m[token.key]
continue
}
slice, ok := m[token.key].([]any)
if !ok {
return
}
index := tokens[i+1].index
for len(slice) <= index {
if i+2 < len(tokens) && !tokens[i+2].isIndex {
slice = append(slice, map[string]any{})
} else {
slice = append(slice, nil)
}
}
m[token.key] = slice
if i+2 == len(tokens) {
slice[index] = value
return
}
if slice[index] == nil {
slice[index] = map[string]any{}
}
current = slice[index]
i++ // The array index was consumed together with its key.
}
}

85
cmd/dataxl/structured.go Normal file
View File

@@ -0,0 +1,85 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"github.com/BurntSushi/toml"
"gopkg.in/yaml.v3"
)
// parseStructured decodes a format into maps, slices and scalar Go values.
// json.Number is retained so large JSON integers do not pass through float64.
func parseStructured(input []byte, format string) (any, error) {
var value any
switch format {
case "json":
dec := json.NewDecoder(bytes.NewReader(input))
dec.UseNumber()
if err := dec.Decode(&value); err != nil {
return nil, err
}
case "yaml":
if err := yaml.Unmarshal(input, &value); err != nil {
return nil, err
}
value = normalizeYAML(value)
case "toml":
var m map[string]any
if err := toml.Unmarshal(input, &m); err != nil {
return nil, err
}
value = m
default:
return nil, fmt.Errorf("format %q is not structured", format)
}
return value, nil
}
func encodeStructured(value any, format string, pretty bool) ([]byte, error) {
switch format {
case "json":
if pretty {
return json.MarshalIndent(value, "", " ")
}
return json.Marshal(value)
case "yaml":
return yaml.Marshal(value)
case "toml":
m, ok := value.(map[string]any)
if !ok {
// TOML has no top-level array, so preserve it under a documented key.
m = map[string]any{"rows": value}
}
var b bytes.Buffer
err := toml.NewEncoder(&b).Encode(m)
return b.Bytes(), err
default:
return nil, fmt.Errorf("format %q is not structured", format)
}
}
// normalizeYAML converts yaml.v3's possible map[any]any values into the
// string-keyed maps used by the rest of the conversion pipeline.
func normalizeYAML(value any) any {
switch v := value.(type) {
case map[string]any:
out := make(map[string]any, len(v))
for key, child := range v {
out[key] = normalizeYAML(child)
}
return out
case map[any]any:
out := make(map[string]any, len(v))
for key, child := range v {
out[fmt.Sprint(key)] = normalizeYAML(child)
}
return out
case []any:
for i := range v {
v[i] = normalizeYAML(v[i])
}
}
return value
}

265
cmd/dataxl/table.go Normal file
View File

@@ -0,0 +1,265 @@
package main
import (
"bytes"
"encoding/csv"
"encoding/json"
"fmt"
"sort"
"strconv"
"github.com/xuri/excelize/v2"
)
// table is the common representation for CSV, TSV and XLSX. Rows are padded
// to Header width when read, which keeps subsequent conversions rectangular.
type table struct {
Header []string
Rows [][]string
}
func parseTable(input []byte, format, sheet string) (table, error) {
switch format {
case "csv":
return readDelimited(input, ',')
case "tsv":
return readDelimited(input, '\t')
case "xlsx":
f, err := excelize.OpenReader(bytes.NewReader(input))
if err != nil {
return table{}, err
}
defer f.Close()
rows, err := f.GetRows(sheet)
if err != nil {
return table{}, err
}
return rowsToTable(rows), nil
default:
return table{}, fmt.Errorf("format %q is not tabular", format)
}
}
func readDelimited(input []byte, comma rune) (table, error) {
r := csv.NewReader(bytes.NewReader(input))
r.Comma = comma
r.FieldsPerRecord = -1
r.TrimLeadingSpace = comma == ','
rows, err := r.ReadAll()
if err != nil {
return table{}, err
}
return rowsToTable(rows), nil
}
func rowsToTable(rows [][]string) table {
if len(rows) == 0 {
return table{}
}
width := 0
for _, row := range rows {
width = max(width, len(row))
}
header := padRow(rows[0], width)
body := make([][]string, 0, len(rows)-1)
for _, row := range rows[1:] {
body = append(body, padRow(row, width))
}
return table{Header: header, Rows: body}
}
func padRow(row []string, width int) []string {
out := make([]string, width)
copy(out, row)
return out
}
func encodeTable(t table, format, sheet string) ([]byte, error) {
switch format {
case "csv":
return writeDelimited(t, ',')
case "tsv":
return writeDelimited(t, '\t')
case "xlsx":
return writeXLSX(t, sheet)
default:
return nil, fmt.Errorf("format %q is not tabular", format)
}
}
func writeDelimited(t table, comma rune) ([]byte, error) {
var b bytes.Buffer
w := csv.NewWriter(&b)
w.Comma = comma
if err := w.Write(t.Header); err != nil {
return nil, err
}
for _, row := range t.Rows {
if err := w.Write(row); err != nil {
return nil, err
}
}
w.Flush()
return b.Bytes(), w.Error()
}
func writeXLSX(t table, sheet string) ([]byte, error) {
f := excelize.NewFile()
defer f.Close()
defaultSheet := f.GetSheetName(0)
if sheet == "" {
sheet = "Sheet1"
}
if defaultSheet != sheet {
if err := f.SetSheetName(defaultSheet, sheet); err != nil {
return nil, err
}
}
rows := append([][]string{t.Header}, t.Rows...)
for rowIndex, row := range rows {
for columnIndex, value := range row {
cell, err := excelize.CoordinatesToCellName(columnIndex+1, rowIndex+1)
if err != nil {
return nil, err
}
if err := f.SetCellValue(sheet, cell, value); err != nil {
return nil, err
}
}
}
if err := styleHeader(f, sheet, len(t.Header)); err != nil {
return nil, err
}
var b bytes.Buffer
if err := f.Write(&b); err != nil {
return nil, err
}
return b.Bytes(), nil
}
// styleHeader applies editing conveniences without trying to preserve or
// emulate arbitrary workbook formatting.
func styleHeader(f *excelize.File, sheet string, width int) error {
if width == 0 {
return nil
}
end, err := excelize.CoordinatesToCellName(width, 1)
if err != nil {
return err
}
style, err := f.NewStyle(&excelize.Style{Font: &excelize.Font{Bold: true}})
if err != nil {
return err
}
if err := f.SetCellStyle(sheet, "A1", end, style); err != nil {
return err
}
return f.SetPanes(sheet, &excelize.Panes{
Freeze: true, YSplit: 1, TopLeftCell: "A2", ActivePane: "bottomLeft",
})
}
func valueToTable(value any) table {
records := recordsFromValue(value)
flatRows := make([]map[string]string, 0, len(records))
seen := make(map[string]bool)
var header []string
for _, record := range records {
flat := make(map[string]string)
flatten("", record, flat)
for key := range flat {
if !seen[key] {
seen[key] = true
header = append(header, key)
}
}
flatRows = append(flatRows, flat)
}
// Stable column ordering makes generated files and their diffs predictable.
sort.Strings(header)
rows := make([][]string, 0, len(flatRows))
for _, flat := range flatRows {
row := make([]string, len(header))
for i, key := range header {
row[i] = flat[key]
}
rows = append(rows, row)
}
return table{Header: header, Rows: rows}
}
func recordsFromValue(value any) []any {
switch v := value.(type) {
case []any:
return v
case map[string]any:
// Wrapper keys let object-shaped formats such as TOML carry row arrays.
for _, key := range []string{"rows", "records", "items"} {
if rows, ok := v[key].([]any); ok {
return rows
}
}
return []any{v}
default:
return []any{v}
}
}
// flatten converts nested values to column paths such as user.name and
// items[0].sku. Empty maps and arrays use explicit textual markers; these are
// visible to editors but currently return as strings when converted back.
func flatten(prefix string, value any, out map[string]string) {
switch v := value.(type) {
case map[string]any:
if len(v) == 0 && prefix != "" {
out[prefix] = "{}"
return
}
keys := make([]string, 0, len(v))
for key := range v {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
childPrefix := key
if prefix != "" {
childPrefix = prefix + "." + key
}
flatten(childPrefix, v[key], out)
}
case []any:
if len(v) == 0 && prefix != "" {
out[prefix] = "[]"
return
}
for i, child := range v {
flatten(fmt.Sprintf("%s[%d]", prefix, i), child, out)
}
default:
if prefix == "" {
prefix = "value"
}
out[prefix] = scalarString(v)
}
}
func scalarString(value any) string {
switch v := value.(type) {
case nil:
return ""
case json.Number:
return v.String()
case string:
return v
case bool:
return strconv.FormatBool(v)
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
return fmt.Sprint(v)
default:
data, err := json.Marshal(v)
if err != nil {
return fmt.Sprint(v)
}
return string(data)
}
}

View File

@@ -12,17 +12,22 @@
- table -> structured - table -> structured
- table -> table - table -> table
## Format Adapters ## Source Layout
各形式の処理`cmd/dataxl/main.go` の以下の関数に集約しています。 実装`cmd/dataxl` package内で責務別に分割しています。
- `parseStructured` - `main.go`: CLI option、stdin/stdout、ファイル入出力
- `encodeStructured` - `conversion.go`: 変換経路の選択、形式名の正規化・推定
- `parseTable` - `structured.go`: JSON/YAML/TOML adapter
- `encodeTable` - `table.go`: CSV/TSV/XLSX adapter、table model、flatten
- `path.go`: セル値の型推定、列パスのparse、unflatten
現在はCLIが小さいため単一ファイルに置いています。形式やオプションが増えたら、 `convert` はファイル入出力から独立しているため、CLIを経由せず変換matrixをテストできます。
`internal/format` `internal/table` へ分割する余地があります。 形式固有処理は `parseStructured` / `encodeStructured` または
`parseTable` / `encodeTable` に閉じ込めます。
現状は単一commandだけが利用するため同じpackageに置いています。別commandやlibrary APIから
再利用する段階になったら、安定させたい境界を見極めたうえで `internal` packageへ移します。
## Table Model ## Table Model
@@ -79,6 +84,21 @@ items[0].sku
- 小数または指数表記: float64 - 小数または指数表記: float64
- その他: string - その他: string
ゼロ埋め整数は、IDやコードを壊さないためstringとして保持します。複数列が同じパスで
異なる中間型を要求する場合は、先に構築された値を後続列で上書きしません。
### Path grammar
現在の列パスは次の要素を扱います。
```text
path = key, { ".", key | "[", index, "]" };
index = digit, { digit };
```
実例は `user.name``items[0].sku``orders[0].items[1].qty` です。
区切り文字を含むmap keyのescapeは未対応です。
## TOML Output ## TOML Output
TOMLはトップレベル配列を直接表せないため、表からTOMLへ出力する場合など、 TOMLはトップレベル配列を直接表せないため、表からTOMLへ出力する場合など、
@@ -91,3 +111,9 @@ TOMLはトップレベル配列を直接表せないため、表からTOMLへ出
- `github.com/BurntSushi/toml`: TOML読み書き - `github.com/BurntSushi/toml`: TOML読み書き
Go 1.24以上を前提にしています。 Go 1.24以上を前提にしています。
## Error handling
- 未対応形式、decode失敗、workbook/sheet操作失敗は呼び出し元へerrorを返します。
- path復元中の型競合は既存値を保護するため、その列の適用を中止します。
- XLSXのstyle・pane設定も通常の変換errorとして扱い、不完全なworkbookを成功扱いしません。

View File

@@ -51,6 +51,11 @@ The current tests cover:
- YAML -> TSV flattening - YAML -> TSV flattening
- TSV -> JSON path restoration - TSV -> JSON path restoration
- JSON -> XLSX -> JSON round trip - JSON -> XLSX -> JSON round trip
- structured -> structured conversion without CLI/file I/O
- extension normalization and format inference
- ragged table row padding
- conservative cell type inference, including zero-padded identifiers
- conflicting unflatten paths
When adding a new format or path rule, add tests around both directions where When adding a new format or path rule, add tests around both directions where
possible. possible.
@@ -96,3 +101,6 @@ go install git.rumginger.org/agent/dataxl/cmd/dataxl@v0.1.0
- Preserve headers as the contract between spreadsheet data and structured data. - Preserve headers as the contract between spreadsheet data and structured data.
- Prefer explicit errors over silent best-effort conversion when a format is unsupported. - Prefer explicit errors over silent best-effort conversion when a format is unsupported.
- Keep dependencies small unless a format needs a mature parser/writer. - Keep dependencies small unless a format needs a mature parser/writer.
- Keep CLI/file I/O in `main.go`; conversion behavior should remain testable through `convert`.
- Keep format-specific behavior in its structured or table adapter.
- Document round-trip limitations when a representation cannot preserve a value exactly.