diff --git a/AGENTS.md b/AGENTS.md index 75d511e..28cf18d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,11 +8,3 @@ - A standing choice a reader would relitigate goes under Decisions in the README, not in a comment. - A README example is a `json` block that loads and renders as a category; `readme_test.go` runs every one. - Cyclomatic complexity is gated at 14: the table-shaped dispatches (`eachToken`, `calc.factor`, `walkPath`) sit at 13–14 and stay whole; anything else that reaches 14 is decomposed. - -# Deferred - -- **Shipped-data de-duplication (2026-09-02).** `email.json`'s `local` is a - drifted copy of `username.json`, and no shipped file yet uses a held path, an - operand or a reference. Fixed in the data fill before the first tag, when the - shipped set is rewritten anyway; premise: nothing depends on the shipped data's - shape until then. Not raised in review before that. diff --git a/README.md b/README.md index ab1d65d..95f444d 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ brace, a bracket or a quote, so the two cannot collide (see | `-s`, `--seed N` | reproducible output | | `-n`, `--repeat N` | render the value N times (up to 1048576), each an independent draw, streamed | | `--separator S` | between repeated values (default a newline) | +| `--format F` | `text` (default), `json`, `ndjson`, `csv` or `sql` — a record's columns, one record per row (json frames them as an array) | +| `--table T` | the INSERT target for `--format sql` (default: the path's last segment, or `records` for an inline template) | | `--list` | print every path, then exit | | `--version`, `-h`, `--help` | print, then exit | @@ -73,6 +75,67 @@ fejkdata --seed 1 --data-path ./mydata sql fejkdata --repeat 100 --data-path ./mydata sql > seed.sql ``` +That `format` string is the free-form spelling — you hand-write the whole row. +For structured output a record writes the row for you. + +### Records + +A record is a template seen as columns: its fields are the columns, its `format` +the whole. `--format json|ndjson|csv|sql` writes the records; the library's +`Record` (below) hands back the columns. Every column is a string — typed scalars +are on the release checklist, see [`todo.md`](todo.md). Save +`mydata/users.json`: + +```json +{ + "format": "{first} {last}", + "first": ["Ada", "Bo"], + "last": ["Lovelace", "Ek"] +} +``` + +```sh +fejkdata --seed 1 --data-path ./mydata --format json users # [{"first":"Bo","last":"Lovelace"}] +fejkdata --seed 1 --data-path ./mydata --format ndjson users # {"first":"Bo","last":"Lovelace"} +fejkdata --seed 1 --data-path ./mydata --format csv users # first,last → Bo,Lovelace +fejkdata --seed 1 --data-path ./mydata --format sql users # INSERT INTO "users" ("first", "last") VALUES ('Bo', 'Lovelace'); +fejkdata --seed 1 --data-path ./mydata --format sql --table people users # INSERT into another table +``` + +`--repeat` streams that many records — `json` frames them as one array document, +`ndjson` writes one object per line, `csv` a row after a header, `sql` one INSERT +per line. Only a category-level template is a record; a field, choice or folder +errors, and so does a `repeat` on +the template itself, which composes the format into one string rather than +projecting columns — ask for more records with `--repeat`. A `repeat` on a column +is fine. + +A column carrying a newline keeps it inside the quoted CSV field or the SQL string +literal, so a row can span physical lines: read the stream with a CSV or SQL +parser rather than splitting it on newlines. + +The SQL is ANSI — identifiers in double quotes, a literal quote doubled (`''`), +backslashes passed through — so a hyphenated field like `postal-code` stays a +valid identifier. PostgreSQL and SQLite take it as written; MySQL and MariaDB need +`ANSI_QUOTES` and `NO_BACKSLASH_ESCAPES` set first, or they read `"users"` as a +string and a backslash as an escape. + +A record written only to emit columns still needs a `format` — the grammar's one +required key — so `"format": ""` carries the fields with an inert format: it +renders nothing by `Fake`, and is compiled only so the tree's fences still run. +The columns are the point, and their facts stay together: two columns that read a +path into one category — `{/currency.code}` and `{/currency.symbol}` — share one +draw of it, so the record is internally consistent. A bare `{/currency}` names no +field, so it keeps drawing on its own. That one draw is also why two +columns may not read overlapping reference *paths* — `{/cat.a}` beside +`{/cat.a.b}` is refused, naming the fields to write instead, as +[One draw, one spelling](#one-draw-one-spelling) refuses that pair inside a single +format. A column may not reference the record it belongs to by any spelling: `{/users.first}` +or a bare `{/users}` inside `users` describes a draw other than the columns beside +it, so put a value two columns share in its own category and reference that. A field hold, transform or +operand ties fields together within one column as always (see +[Correlated fields](#correlated-fields) and [Decisions](#decisions)). + ## Library ```sh @@ -89,6 +152,9 @@ paths := f.List() // every path Fake accepts, sorted v, err = f.FakeTemplate("name: {/sv_SE.person.last}") // compile + render in one call t, err := f.NewTemplate(`{"format":"name: {x}","x":["bosse","lina"]}`) // compile once v = t.Fake() // render many times, no re-parse +r, err := f.Record("users") // one record: each field a column +s := r.JSON() // {"first":"Ada","last":"Lovelace"} +r, err = f.FakeRecord(`{"format":"{x}","x":["a","b"]}`) // compile + render inline ``` | Option | | @@ -98,6 +164,11 @@ v = t.Fake() // render many times, | `WithDataFS(fsys)` | layer an `fs.FS`, such as your own `embed.FS` | | `WithoutShippedData()` | load only what you give | +A `*Record` carries its columns via `Columns()`, and serializes them with `JSON()` +(one object), `CSVHeader()`/`CSVLine()`, or `SQLInsert(table)` — the shapes the +CLI's `--format` writes. `Record` and `FakeRecord` take a record; a path or +template that is not one — a bare string, a choice, or a folder — errors. + A `*Generator` is safe for concurrent use; a seeded sequence is reproducible only when drawn from one goroutine. Changing how a value is composed shifts the seeded stream for that value and everything drawn after it. @@ -454,6 +525,38 @@ tokens add cost in proportion to the output. - **Samples say what they emit, transforms what they do.** `{upper(2)}` is two letters, `{uppercase(x)}` is `x` upper-cased; one name for both would turn on whether the argument looks like a number. +- **A record is a template seen as columns, not a second schema format.** A + template's `format` composes its fields into one string; `Record` and + `--format` project the same fields as columns. Two views of one dataset, so a + record author writes the same JSON they already know, and a column is the same + field `Fake` renders by dotted path. The `format` is inert to a record — a + record-only template writes `"format": ""` — but it is compiled and fenced, so + a template that loads renders as whichever shape is asked for. +- **A record shares one reference draw per category.** Two columns that reference + one category — `{/currency.code}` beside `{/currency.symbol}` — read one draw of + it, so a record's facts agree the way a template's [correlated + fields](#correlated-fields) do. The draw is one per record, so it spans a + column's `repeat` and nested templates too (one record is one coherent unit); + a bare reference — `{/currency}`, no field — stays an independent draw every + time, the rule a format string already follows. Only references share: a sibling + field is local to its own column, so a `first` column does not silently bind to + a `first` in the column next to it. + + The string view of that same template does not share. `Fake` renders each + sibling field as its own expansion, so a `{/currency.code}` field beside a + `{/currency.symbol}` field is two draws and may render `EUR $`; writing both + references in one `format` holds them together, as + [One draw, one spelling](#one-draw-one-spelling) says. The scope is what makes a + row coherent when the columns *are* the output, and there the caller cannot fall + back on one format string. Widening it to every render would change what `Fake` + has emitted since the start, for a correlation a single format already reaches. +- **A record's column set is fixed before the first draw.** Only a category-level + template is a record: a path descending into a field, or naming a folder or a + choice, errors. A tail may pass through a choice whose variants carry different + fields, so the columns — and with them the CSV header written once ahead of every + row — would vary per draw. A fixed column set is what the CSV and `INSERT` + contracts rest on, so the restriction holds even where a particular choice would + happen to agree. - **The performance gate asserts allocations, not wall-clock time.** `AllocsPerRun` is deterministic across machines, so a ±10% ceiling does not flake under CI load, while time varies with the machine and its neighbours. A rendering slowdown @@ -500,6 +603,7 @@ fejkdata.go Generator, New, options, the embedded data set, List node.go the node model and JSON -> node compilation path.go the dotted-path walk, and proving a path resolves render.go Fake and the recursive renderer (choices, format strings, expansions) +record.go records: Record, the JSON/CSV/SQL serializers, and their entry points inline.go inline templates: Template, NewTemplate, FakeTemplate, and their compile and link template.go the {token} grammar: scanning, tokens, operands, validation, compiling a format hold.go the hold: one draw per expansion for paths and operands, and its fences diff --git a/cmd/fejkdata/main.go b/cmd/fejkdata/main.go index bf69ed0..cfd5909 100644 --- a/cmd/fejkdata/main.go +++ b/cmd/fejkdata/main.go @@ -15,6 +15,7 @@ import ( "io" "os" "runtime/debug" + "sort" "strconv" "strings" @@ -34,13 +35,19 @@ or a quote). Templates reach the data by reference from the root — argument carrying a bracket, a closing brace or a quote but no valid JSON names neither. +With --format json, ndjson, csv or sql the argument must name a record — a +template whose fields are its columns — and the rows are written as one JSON +array, one JSON object per line, one CSV row (after a header), or one INSERT. + -d, --data-path D a data directory to layer over the shipped data (repeatable; last wins on a clash) + --format F output form: text (default), json, ndjson, csv or sql -h, --help print this help, then exit --list list the paths the data offers, then exit --no-shipped-data load only the --data-path directories -n, --repeat N render the value N times, 1..1048576 (default 1) -s, --seed N seed for reproducible output --separator S string between repeated values (default newline) + --table T the INSERT target for --format sql (default: the path's last segment, or records for an inline template) --version print the version, then exit Flags may come before or after ; -- ends the flags. A short flag's @@ -49,6 +56,8 @@ value attaches or follows (-n3, -n 3); short flags bundle (-hn 3). type invocation struct { dirs []string + format string + formatSet bool help bool list bool noShipped bool @@ -59,6 +68,8 @@ type invocation struct { seeded bool separator string separatorSet bool + table string + tableSet bool version bool } @@ -71,6 +82,7 @@ type flagDef struct { var flagDefs = []flagDef{ {"data-path", "d", true, func(in *invocation, v string) error { in.dirs = append(in.dirs, v); return nil }}, + {"format", "", true, func(in *invocation, v string) error { in.format, in.formatSet = v, true; return nil }}, {"help", "h", false, func(in *invocation, _ string) error { in.help = true; return nil }}, {"list", "", false, func(in *invocation, _ string) error { in.list = true; return nil }}, {"no-shipped-data", "", false, func(in *invocation, _ string) error { in.noShipped = true; return nil }}, @@ -91,6 +103,7 @@ var flagDefs = []flagDef{ return nil }}, {"separator", "", true, func(in *invocation, v string) error { in.separator, in.separatorSet = v, true; return nil }}, + {"table", "", true, func(in *invocation, v string) error { in.table, in.tableSet = v, true; return nil }}, {"version", "", false, func(in *invocation, _ string) error { in.version = true; return nil }}, } @@ -158,7 +171,7 @@ func splitFlags(arg string) ([]flagArg, error) { } func parseArgs(argv []string) (invocation, error) { - in := invocation{repeat: 1, separator: "\n"} + in := invocation{repeat: 1, separator: "\n", format: "text"} for i := 0; i < len(argv); i++ { arg := argv[i] if arg == "--" { @@ -196,14 +209,71 @@ func parseArgs(argv []string) (invocation, error) { return in, nil } +// recordFormat is one way to write a record out: the line each record renders, +// the header that precedes the first one, and the open/close frame plus the +// between-record separator a document form needs. +type recordFormat struct { + header func(*fejkdata.Record) string + line func(r *fejkdata.Record, table string) string + open string + close string + sep string +} + +// recordFormats is every --format that writes records. json frames the records +// as one array document; ndjson is the same column, one object per line. +var recordFormats = map[string]recordFormat{ + "csv": {header: (*fejkdata.Record).CSVHeader, line: func(r *fejkdata.Record, _ string) string { return r.CSVLine() }, sep: "\n"}, + "json": {line: jsonLine, open: "[", close: "]", sep: ",\n"}, + "ndjson": {line: jsonLine, sep: "\n"}, + "sql": {line: func(r *fejkdata.Record, table string) string { return r.SQLInsert(table) }, sep: "\n"}, +} + +func jsonLine(r *fejkdata.Record, _ string) string { return r.JSON() } + +// writesRecords reports whether the format writes records rather than plain text. +func (in invocation) writesRecords() bool { + return in.format != "text" +} + +// formatNames lists the --format values, text included, for the misuse error. +func formatNames() string { + names := []string{"text"} + for name := range recordFormats { + names = append(names, name) + } + sort.Strings(names) + return strings.Join(names[:len(names)-1], ", ") + " or " + names[len(names)-1] +} + +// checkFlags rejects a flag value or combination that cannot run. +func (in invocation) checkFlags() error { + if _, ok := recordFormats[in.format]; !ok && in.format != "text" { + return fmt.Errorf("--format takes %s, got %q", formatNames(), in.format) + } + if in.tableSet && in.table == "" { + return errors.New("--table names the INSERT target, so it cannot be empty") + } + if in.tableSet && in.format != "sql" { + return errors.New("--table names the INSERT target, so it needs --format sql") + } + if in.writesRecords() && in.separatorSet { + return errors.New("--separator joins text values, so it has no effect with --format " + in.format) + } + return nil +} + // check rejects a flag combination or an argument that cannot run, and reports // what the argument names, so its shape is settled before any data is read. func (in invocation) check() (argKind, error) { + if err := in.checkFlags(); err != nil { + return argPath, err + } if in.list && len(in.paths) > 0 { return argPath, errors.New("--list takes no path") } - if in.list && (in.repeatSet || in.separatorSet) { - return argPath, errors.New("--list takes no --repeat or --separator") + if in.list && (in.repeatSet || in.separatorSet || in.formatSet || in.tableSet) { + return argPath, errors.New("--list takes no --repeat, --separator, --format or --table") } if in.list { return argPath, nil @@ -233,14 +303,12 @@ func (in invocation) options() []fejkdata.Option { // so a render failure comes before anything is written; a write failure surfaces // from Flush, bufio keeping the first one. func (in invocation) write(f *fejkdata.Generator, kind argKind, w io.Writer) error { - arg := in.paths[0] - draw := func() (string, error) { return f.Fake(arg) } - if kind == argTemplate { - t, err := f.NewTemplate(arg) - if err != nil { - return templateError{err} - } - draw = func() (string, error) { return t.Fake(), nil } + if in.writesRecords() { + return in.writeRecords(f, kind, w) + } + draw, err := in.textDraw(f, kind, in.paths[0]) + if err != nil { + return err } out := bufio.NewWriter(w) for i := 0; i < in.repeat; i++ { @@ -257,6 +325,82 @@ func (in invocation) write(f *fejkdata.Generator, kind argKind, w io.Writer) err return out.Flush() } +// textDraw builds what one text render yields: the value, from a path or an +// inline template. +func (in invocation) textDraw(f *fejkdata.Generator, kind argKind, arg string) (func() (string, error), error) { + if kind != argTemplate { + return func() (string, error) { return f.Fake(arg) }, nil + } + t, err := f.NewTemplate(arg) + if err != nil { + return nil, templateError{err} + } + return func() (string, error) { return t.Fake(), nil }, nil +} + +// recordStream builds the record drawer for the argument, plus the INSERT table +// a sql format names. +func (in invocation) recordStream(f *fejkdata.Generator, kind argKind, arg string) (func() (*fejkdata.Record, error), string, error) { + record := func() (*fejkdata.Record, error) { return f.Record(arg) } + if kind == argTemplate { + t, err := f.NewRecordTemplate(arg) + if err != nil { + return nil, "", templateError{err} + } + record = func() (*fejkdata.Record, error) { return t.Fake(), nil } + } + table := in.table + if table == "" { + table = defaultTable(arg, kind) + } + return record, table, nil +} + +// writeRecords streams a record per line in the chosen format, framing a document +// form with its open/close brackets and a header preceding the first record. +func (in invocation) writeRecords(f *fejkdata.Generator, kind argKind, w io.Writer) error { + record, table, err := in.recordStream(f, kind, in.paths[0]) + if err != nil { + return err + } + format := recordFormats[in.format] + out := bufio.NewWriter(w) + if format.open != "" { + out.WriteString(format.open) + out.WriteByte('\n') + } + for i := 0; i < in.repeat; i++ { + r, err := record() + if err != nil { + return err + } + if i == 0 && format.header != nil { + out.WriteString(format.header(r)) + out.WriteByte('\n') + } + if i > 0 { + out.WriteString(format.sep) + } + out.WriteString(format.line(r, table)) + } + if format.close != "" { + out.WriteByte('\n') + out.WriteString(format.close) + } + out.WriteByte('\n') + return out.Flush() +} + +// defaultTable names the INSERT target when --table is absent: the path's last +// segment, or "records" for an inline template that sits in no folder. +func defaultTable(arg string, kind argKind) string { + if kind == argTemplate { + return "records" + } + segments := strings.Split(arg, ".") + return segments[len(segments)-1] +} + // templateError marks a render failure that is the argument's own fault — an // inline template that does not compile. run reports it as misuse (exit 2, with a // pointer to --help), unlike an unknown path, which is a runtime error (exit 1). diff --git a/cmd/fejkdata/main_test.go b/cmd/fejkdata/main_test.go index 258f740..fc2c485 100644 --- a/cmd/fejkdata/main_test.go +++ b/cmd/fejkdata/main_test.go @@ -2,6 +2,8 @@ package main import ( "bytes" + "encoding/csv" + "encoding/json" "os" "path/filepath" "regexp" @@ -450,6 +452,145 @@ func TestRunEmptyDataPathIsNamed(t *testing.T) { } } +func recordDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + content := `{"format":"{first} {last}","first":["Ada","Bo"],"last":["Lovelace","Ek"]}` + if err := os.WriteFile(filepath.Join(dir, "users.json"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return dir +} + +func TestRunRecordJSON(t *testing.T) { + code, out, errb := runOut("--seed", "1", "--format", "json", "--repeat", "2", "--data-path", recordDir(t), "users") + if code != 0 { + t.Fatalf("run = %d, stderr=%q", code, errb) + } + var arr []map[string]string + if err := json.Unmarshal([]byte(out), &arr); err != nil { + t.Fatalf("json output is not one JSON array: %v\n%q", err, out) + } + if len(arr) != 2 || len(arr[0]) != 2 || arr[0]["first"] == "" || arr[0]["last"] == "" { + t.Fatalf("json output = %q, want an array of two records with first and last columns", out) + } +} + +func TestRunRecordNDJSON(t *testing.T) { + code, out, errb := runOut("--seed", "1", "--format", "ndjson", "--repeat", "2", "--data-path", recordDir(t), "users") + if code != 0 { + t.Fatalf("run = %d, stderr=%q", code, errb) + } + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + if len(lines) != 2 { + t.Fatalf("ndjson output = %d lines %q, want one object per line", len(lines), out) + } + for _, line := range lines { + var m map[string]string + if err := json.Unmarshal([]byte(line), &m); err != nil || len(m) != 2 { + t.Fatalf("ndjson line %q is not one object: %v", line, err) + } + } +} + +func TestRunRecordCSVRoundTrips(t *testing.T) { + code, out, errb := runOut("--seed", "1", "--format", "csv", "--repeat", "3", "--data-path", recordDir(t), "users") + if code != 0 { + t.Fatalf("run = %d, stderr=%q", code, errb) + } + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + if len(lines) != 4 { + t.Fatalf("csv output = %d lines %q, want a header and 3 rows", len(lines), out) + } + if lines[0] != "first,last" { + t.Fatalf("csv header = %q, want first,last", lines[0]) + } + r, err := csv.NewReader(strings.NewReader(out)).ReadAll() + if err != nil { + t.Fatalf("csv output is not valid CSV: %v", err) + } + if len(r) != 4 || len(r[0]) != 2 { + t.Fatalf("csv parsed to %v, want 4 records of 2 fields", r) + } +} + +func TestRunRecordSQL(t *testing.T) { + code, out, errb := runOut("--seed", "1", "--format", "sql", "--repeat", "2", "--table", "people", "--data-path", recordDir(t), "users") + if code != 0 { + t.Fatalf("run = %d, stderr=%q", code, errb) + } + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + if len(lines) != 2 || !strings.HasPrefix(lines[0], `INSERT INTO "people" ("first", "last") VALUES (`) { + t.Fatalf("sql output = %q, want two INSERT INTO people statements", out) + } +} + +func TestRunRecordDefaultTable(t *testing.T) { + code, out, errb := runOut("--seed", "1", "--format", "sql", "--data-path", recordDir(t), "users") + if code != 0 || !strings.HasPrefix(out, `INSERT INTO "users" (`) { + t.Fatalf("default table = %d, %q, want the path's last segment; stderr %q", code, out, errb) + } +} + +func TestRunRecordInlineTemplate(t *testing.T) { + code, out, errb := runOut("--seed", "1", "--format", "json", `{"format":"{x}","x":["a","b"]}`) + if code != 0 { + t.Fatalf("inline record = %d, stderr=%q", code, errb) + } + var arr []map[string]string + if err := json.Unmarshal([]byte(out), &arr); err != nil || len(arr) != 1 || (arr[0]["x"] != "a" && arr[0]["x"] != "b") { + t.Fatalf("inline record json = %q, want one record with a column x (err %v)", out, err) + } +} + +func TestRunRecordMisuse(t *testing.T) { + for _, c := range []struct { + args []string + want string + }{ + {[]string{"--format", "yaml", "users"}, "--format takes csv, json, ndjson, sql or text"}, + {[]string{"--format", "json", "--separator", ",", "users"}, "--separator joins text values"}, + {[]string{"--table", "t", "users"}, "--table names the INSERT target"}, + {[]string{"--format", "json", "--table", "t", "users"}, "--table names the INSERT target"}, + {[]string{"--format", "sql", "--table", "", "users"}, "--table names the INSERT target"}, + } { + code, out, errb := runOut(c.args...) + if code != 2 || out != "" || !strings.Contains(errb, c.want) { + t.Errorf("run(%v) = %d, %q, %q; want misuse naming %q", c.args, code, out, errb, c.want) + } + } +} + +func TestRunRecordOnABareValueIsRuntimeError(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "word.json"), []byte(`["a","b"]`), 0o644); err != nil { + t.Fatal(err) + } + code, _, errb := runOut("--format", "json", "--data-path", dir, "word") + if code != 1 { + t.Fatalf("record on a choice = %d, want exit 1 (runtime error), stderr %q", code, errb) + } +} + +func TestRunRecordRejectsATopLevelRepeat(t *testing.T) { + dir := t.TempDir() + content := `{"format":"{a}-","repeat":3,"separator":"|","a":["x","y"]}` + if err := os.WriteFile(filepath.Join(dir, "rep.json"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + code, _, errb := runOut("--format", "json", "--data-path", dir, "rep") + if code != 1 || !strings.Contains(errb, "carries repeat 3") { + t.Errorf("record on a repeating path = %d, %q; want exit 1 naming the repeat", code, errb) + } + code, _, errb = runOut("--format", "json", content) + if code != 2 || !strings.Contains(errb, "carries repeat 3") { + t.Errorf("record on a repeating inline template = %d, %q; want exit 2 naming the repeat", code, errb) + } + if code, out, _ := runOut("--seed", "1", "--data-path", dir, "rep"); code != 0 || strings.TrimSpace(out) != "x-|x-|x-" { + t.Errorf("text view = %d, %q; want the repeat still composed", code, out) + } +} + func TestRunNumbersFollowTheShell(t *testing.T) { _, want, _ := runOut("--seed", "7", "--repeat", "3", "sv_SE.word") code, got, errb := runOut("--seed", "007", "--repeat", "+3", "sv_SE.word") diff --git a/fejkdata.go b/fejkdata.go index 4ede2f1..c558d9b 100644 --- a/fejkdata.go +++ b/fejkdata.go @@ -45,6 +45,7 @@ type Generator struct { mu sync.Mutex rand *session categories map[string]node + records map[node]recordShape } // session is one generator's mutable render state: the seeded rng plus the {seq()} diff --git a/hold.go b/hold.go index 908939f..05099df 100644 --- a/hold.go +++ b/hold.go @@ -267,14 +267,20 @@ type draws struct { // gives one value, and a shown operand is the operand computed. Every other name is // drawn afresh, so {word} {word} still draws twice. checkTokens, checkPath and // linkRefs prove every step, so the walk cannot fail. -func readField(s *session, t *template, held *draws, a arm) string { +func readField(s *session, t *template, held, refScope *draws, a arm) string { if !t.held[a.key] { if len(a.tail) > 0 { panic(fmt.Sprintf("fejkdata: %q reads a path into %q, which the expansion does not hold", a.name, a.key)) } - return render(s, t.fields[a.key]) + return render(s, t.fields[a.key], refScope) } - if v, read := held.value[a.path]; read { + // A reference that reads a path reads the caller's scope, so its draw outlives + // this expansion; a sibling, and a reference read whole, stay local to it. + d := held + if isRef(a.key) && refScope != nil && len(a.tail) > 0 { + d = refScope + } + if v, read := d.value[a.path]; read { return v } var v string @@ -286,16 +292,16 @@ func readField(s *session, t *template, held *draws, a arm) string { if consumed := len(a.tail) - len(rest); consumed > 0 { key = a.steps[consumed-1] } - n, drew := held.variant[key] + n, drew := d.variant[key] if !drew { n = drawn(s, c) - held.variant[key] = n + d.variant[key] = n } return []node{n}, nil }, - leaf: func(n node) error { v = render(s, n); return nil }, + leaf: func(n node) error { v = render(s, n, refScope); return nil }, }) - held.value[a.path] = v + d.value[a.path] = v return v } diff --git a/hold_test.go b/hold_test.go index 3827d55..ac1ac56 100644 --- a/hold_test.go +++ b/hold_test.go @@ -718,6 +718,6 @@ func TestReadFieldPanicsOnAnUnheldPath(t *testing.T) { t.Fatal("not a template") } mustPanic(t, "unheld arm with a path", func() { - readField(engine(1).rand, tm, nil, arm{name: "w.x", key: "w", tail: []string{"x"}, path: "w.x"}) + readField(engine(1).rand, tm, nil, nil, arm{name: "w.x", key: "w", tail: []string{"x"}, path: "w.x"}) }) } diff --git a/inline.go b/inline.go index f68ca29..91f6686 100644 --- a/inline.go +++ b/inline.go @@ -20,7 +20,7 @@ type Template struct { func (t *Template) Fake() string { t.g.mu.Lock() defer t.g.mu.Unlock() - return render(t.g.rand, t.n) + return render(t.g.rand, t.n, nil) } // NewTemplate compiles an inline template — a format string or a JSON value — and diff --git a/perf_test.go b/perf_test.go index 765525a..fc5821c 100644 --- a/perf_test.go +++ b/perf_test.go @@ -51,6 +51,26 @@ func TestNoRenderAllocRegression(t *testing.T) { } } +// A record's fences read the compiled tree, so they belong to New, not to a draw. +func TestNoRecordAllocRegression(t *testing.T) { + for _, s := range []struct{ name, json string }{ + {"record 3 columns", `{"format":"","a":"x","b":"y","c":"z"}`}, + {"record 50 columns", wideTokenJSON(50)}, + } { + f, err := New(WithoutShippedData(), WithDataFS(fstest.MapFS{"x.json": {Data: []byte(s.json)}})) + if err != nil { + t.Fatalf("New(%s): %v", s.name, err) + } + if _, err := f.Record("x"); err != nil { + t.Fatalf("Record(%s): %v", s.name, err) // else the gate would measure the error path + } + const base = 4.0 + if allocs := testing.AllocsPerRun(10000, func() { f.Record("x") }); allocs > base*1.10 { + t.Errorf("%s: %.1f allocs/op regressed past %.1f (baseline %.1f + 10%%); a record fence running per draw is the usual cause", s.name, allocs, base*1.10, base) + } + } +} + func BenchmarkNestedDepth25(b *testing.B) { benchPath(b, tmpData(b, "deep", nestedJSON(25)), "deep") } func BenchmarkNestedDepth100(b *testing.B) { benchPath(b, tmpData(b, "deep", nestedJSON(100)), "deep") } func BenchmarkWideTokens100(b *testing.B) { diff --git a/readme_test.go b/readme_test.go index 594fabc..4cc569e 100644 --- a/readme_test.go +++ b/readme_test.go @@ -43,6 +43,34 @@ func TestReadmeExamplesLoadAndRender(t *testing.T) { } } +func TestReadmeRecordExample(t *testing.T) { + src := readme(t) + section := src[strings.Index(src, "### Records"):] + section = section[:strings.Index(section, "## Library")] + block := jsonBlock.FindStringSubmatch(section) + if block == nil { + t.Fatal("README lost the Records example") + } + f, err := New(WithDataPath(writeData(t, map[string]string{"users": block[1]})), WithSeed(1)) + if err != nil { + t.Fatal(err) + } + r, err := f.Record("users") + if err != nil { + t.Fatal(err) + } + got := r.Columns() + if len(got) != 2 || got[0].Name != "first" || got[0].Value != "Bo" || got[1].Name != "last" || got[1].Value != "Lovelace" { + t.Fatalf("record columns = %v, want first=Bo, last=Lovelace with seed 1", got) + } + if r.CSVHeader() != "first,last" || r.CSVLine() != "Bo,Lovelace" { + t.Fatalf("csv = %q, %q, want first,last / Bo,Lovelace", r.CSVHeader(), r.CSVLine()) + } + if r.SQLInsert("users") != `INSERT INTO "users" ("first", "last") VALUES ('Bo', 'Lovelace');` { + t.Fatalf("sql = %q, want the seeded INSERT", r.SQLInsert("users")) + } +} + func TestReadmeSQLExampleOutput(t *testing.T) { src := readme(t) src = src[strings.Index(src, "### Your own data"):] diff --git a/record.go b/record.go new file mode 100644 index 0000000..4a70eaf --- /dev/null +++ b/record.go @@ -0,0 +1,285 @@ +package fejkdata + +import ( + "encoding/csv" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" +) + +// Column is one rendered column of a record. +type Column struct { + Name string + Value string +} + +// Record is one record rendered from a template: every direct field is a column, +// listed in name order. Each column is its own expansion, so a sibling field is +// local to it, while a reference that reads a path is drawn once for the whole +// record. +type Record struct { + columns []Column +} + +// Columns returns the record's columns in name order. +func (r *Record) Columns() []Column { + return append([]Column(nil), r.columns...) +} + +// JSON renders the record as one JSON object, every column a string. +func (r *Record) JSON() string { + m := make(map[string]string, len(r.columns)) + for _, c := range r.columns { + m[c.Name] = c.Value + } + b, _ := json.Marshal(m) + return string(b) +} + +// CSVHeader renders the column names as one CSV header line. +func (r *Record) CSVHeader() string { + return csvLine(r.names()) +} + +// CSVLine renders the column values as one CSV row. +func (r *Record) CSVLine() string { + return csvLine(r.values()) +} + +func (r *Record) names() []string { + out := make([]string, len(r.columns)) + for i, c := range r.columns { + out[i] = c.Name + } + return out +} + +func (r *Record) values() []string { + out := make([]string, len(r.columns)) + for i, c := range r.columns { + out[i] = c.Value + } + return out +} + +func csvLine(cols []string) string { + var b strings.Builder + w := csv.NewWriter(&b) + _ = w.Write(cols) + w.Flush() + line := strings.TrimSuffix(b.String(), "\n") + if line == "" { + return `""` // a blank line is a row every CSV reader drops + } + return line +} + +// SQLInsert renders the record as one INSERT statement into table: identifiers in +// ANSI double quotes, every value a single-quoted string literal. +func (r *Record) SQLInsert(table string) string { + cols := make([]string, len(r.columns)) + vals := make([]string, len(r.columns)) + for i, c := range r.columns { + cols[i] = quoteIdent(c.Name) + vals[i] = "'" + strings.ReplaceAll(c.Value, "'", "''") + "'" + } + return fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s);", quoteIdent(table), strings.Join(cols, ", "), strings.Join(vals, ", ")) +} + +func quoteIdent(s string) string { + return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` +} + +// Record renders a path as one record: the template it names, with each direct +// field drawn as a column. Only a category-level template is a record — a path +// that descends into a field, or that names a folder or a choice, is an error. +func (f *Generator) Record(path string) (*Record, error) { + f.mu.Lock() + defer f.mu.Unlock() + _, n, tail, err := resolveCategory(f.categories, strings.Split(path, ".")) + if err != nil { + return nil, fmt.Errorf("fejkdata: %s: %w", path, err) + } + if len(tail) > 0 { + return nil, fmt.Errorf("fejkdata: %s descends into %q, a field; only a category-level template is a record", path, tail[0]) + } + shape := f.recordShapeOf(n) + if shape.err != nil { + return nil, fmt.Errorf("fejkdata: %s %w", path, shape.err) + } + return renderRecord(f.rand, shape.t, shape.columns), nil +} + +// recordShape is what recordOf settled about a node: the template to project, its +// columns, or why it is not a record. +type recordShape struct { + t *template + columns []string + err error +} + +// recordShapeOf fences a node once and remembers the answer. Callers hold the +// generator's lock. +func (f *Generator) recordShapeOf(n node) recordShape { + if shape, done := f.records[n]; done { + return shape + } + t, columns, err := recordOf(n) + shape := recordShape{t: t, columns: columns, err: err} + if f.records == nil { + f.records = map[node]recordShape{} + } + f.records[n] = shape + return shape +} + +// RecordTemplate is an inline record compiled, referenced and validated once, +// ready to render many times with [RecordTemplate.Fake]. +type RecordTemplate struct { + g *Generator + t *template + columns []string +} + +// Fake renders the record with one draw. +func (t *RecordTemplate) Fake() *Record { + t.g.mu.Lock() + defer t.g.mu.Unlock() + return renderRecord(t.g.rand, t.t, t.columns) +} + +// NewRecordTemplate compiles an inline record — a JSON object with a format and +// fields — and binds its references against the loaded tree. +func (f *Generator) NewRecordTemplate(input string) (*RecordTemplate, error) { + t, err := f.NewTemplate(input) + if err != nil { + return nil, err + } + tm, columns, err := recordOf(t.n) + if err != nil { + return nil, fmt.Errorf("fejkdata: an inline record %w", err) + } + return &RecordTemplate{g: f, t: tm, columns: columns}, nil +} + +// FakeRecord compiles and renders an inline record in one call. +func (f *Generator) FakeRecord(input string) (*Record, error) { + t, err := f.NewRecordTemplate(input) + if err != nil { + return nil, err + } + return t.Fake(), nil +} + +// recordOf is the fence both record entry points pass. The columns come back with +// the template, fixed for every draw the caller goes on to make. +func recordOf(n node) (*template, []string, error) { + t, ok := n.(*template) + if !ok { + return nil, nil, errors.New("names a choice, not a template; a record is a template whose fields are its columns") + } + if t.repeat != 1 { + return nil, nil, fmt.Errorf("carries repeat %d, which composes its format into one string; a record projects columns instead — drop the repeat and render the record again for more rows", t.repeat) + } + columns := recordColumns(t) + if len(columns) == 0 { + return nil, nil, errors.New("has no fields, so no columns") + } + if err := checkColumnRefs(t, columns); err != nil { + return nil, nil, err + } + return t, columns, nil +} + +// checkColumnRefs rejects the reference reads a record's shared draw cannot answer +// for: one column rendering a level another reads a path into, and a column +// reading the record back through its own path. +func checkColumnRefs(t *template, columns []string) error { + reads, err := columnRefs(t, columns) + if err != nil { + return err + } + sort.Slice(reads, func(i, j int) bool { + if reads[i].a.path != reads[j].a.path { + return reads[i].a.path < reads[j].a.path + } + return reads[i].column < reads[j].column + }) + for i, level := range reads { + for _, into := range reads[i+1:] { + if strings.HasPrefix(into.a.path, level.a.path+".") { + return fmt.Errorf("column %q renders {%s}, a level column %q reads a path into with {%s}; name the fields you want instead", + level.column, level.a.name, into.column, into.a.name) + } + } + } + return nil +} + +// columnRef is one held reference read, and the column whose render reaches it. +type columnRef struct { + column string + a arm +} + +// columnRefs lists every reference that reads a path, anywhere a column renders, +// following the same edges expand does. A reference landing back on the record +// itself is reported rather than collected, whether it reads a path or the record +// whole: either way the column describes a draw other than its neighbours'. +func columnRefs(t *template, columns []string) ([]columnRef, error) { + var out []columnRef + var err error + for _, name := range columns { + seen := map[node]bool{} + var walk func(n node) + walk = func(n node) { + if n == nil || seen[n] || err != nil { + return + } + seen[n] = true + if tm, ok := n.(*template); ok { + for _, ref := range refTokens(tm.format) { + a := splitArm(ref, tm.refs) + if tm.fields[a.key] == node(t) { + err = fmt.Errorf("column %q reads {%s}, which points back at this record; a column cannot read another column — move the shared value into its own category and reference that", name, a.name) + return + } + if len(a.tail) > 0 { + out = append(out, columnRef{name, a}) + } + } + } + for _, e := range renderEdges(n) { + walk(e.to) + } + } + walk(t.fields[name]) + } + return out, err +} + +// renderRecord draws each column once, in the name order recordOf fixed, over one +// reference scope shared across them. +func renderRecord(s *session, t *template, columns []string) *Record { + scope := &draws{variant: map[string]node{}, value: map[string]string{}} + r := &Record{columns: make([]Column, len(columns))} + for i, name := range columns { + r.columns[i] = Column{Name: name, Value: render(s, t.fields[name], scope)} + } + return r +} + +// recordColumns is the sorted non-reference field names — the columns a record +// projects. A {/path} binding is carried in fields under its root path, so only a +// name that is not a reference is a column. +func recordColumns(t *template) []string { + var names []string + for _, name := range sortedNames(t.fields) { + if !isRef(name) { + names = append(names, name) + } + } + return names +} diff --git a/record_test.go b/record_test.go new file mode 100644 index 0000000..01c5d20 --- /dev/null +++ b/record_test.go @@ -0,0 +1,408 @@ +package fejkdata + +import ( + "encoding/csv" + "encoding/json" + "strings" + "testing" +) + +func recordCat(t *testing.T) *Generator { + t.Helper() + dir := writeData(t, map[string]string{ + "users": `{ + "format": "{first} {last}", + "first": ["Ada", "Bo"], + "last": ["Lovelace", "Ek"] + }`, + }) + return newGenerator(t, dir, WithSeed(1)) +} + +func TestRecordProjectsFieldsAsColumns(t *testing.T) { + f := recordCat(t) + r, err := f.Record("users") + if err != nil { + t.Fatal(err) + } + got := r.Columns() + if len(got) != 2 || got[0].Name != "first" || got[1].Name != "last" { + t.Fatalf("Record.Columns() = %v, want columns first, last in name order", got) + } + if (got[0].Value != "Ada" && got[0].Value != "Bo") || (got[1].Value != "Lovelace" && got[1].Value != "Ek") { + t.Fatalf("columns = %v, want the field values", got) + } +} + +func TestRecordIsDeterministic(t *testing.T) { + a, b := recordCat(t), recordCat(t) + for i := 0; i < 20; i++ { + x, _ := a.Record("users") + y, _ := b.Record("users") + if x.JSON() != y.JSON() { + t.Fatalf("same seed diverged: %s != %s", x.JSON(), y.JSON()) + } + } +} + +func TestRecordSkipsReferenceBindings(t *testing.T) { + dir := writeData(t, map[string]string{ + "name": `{"format": "{first} {last}", "first": "Ada", "last": "Lovelace"}`, + "user": `{"format": "they are {/name}", "id": "1"}`, + }) + f := newGenerator(t, dir, WithSeed(1)) + r, err := f.Record("user") + if err != nil { + t.Fatal(err) + } + got := r.Columns() + if len(got) != 1 || got[0].Name != "id" { + t.Fatalf("Record.Columns() = %v, want only the id column (a {/path} is not a column)", got) + } +} + +func TestRecordErrors(t *testing.T) { + dir := writeData(t, map[string]string{ + "bare": `"hello"`, + "pick": `["a", "b"]`, + "group/cat": `"x"`, + }) + f := newGenerator(t, dir, WithSeed(1)) + for _, c := range []struct { + path string + want string + }{ + {"bare", "has no fields, so no columns"}, + {"pick", "names a choice"}, + {"group", "names a folder"}, + {"nope", "no entry"}, + } { + if _, err := f.Record(c.path); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("Record(%q) = %v, want an error containing %q", c.path, err, c.want) + } + } +} + +func TestRecordJSON(t *testing.T) { + f := recordCat(t) + r, err := f.Record("users") + if err != nil { + t.Fatal(err) + } + var m map[string]string + if err := json.Unmarshal([]byte(r.JSON()), &m); err != nil { + t.Fatalf("Record.JSON() is not valid JSON: %v\n%s", err, r.JSON()) + } + if len(m) != 2 || (m["first"] != "Ada" && m["first"] != "Bo") { + t.Fatalf("Record.JSON() = %s, want two addressable columns", r.JSON()) + } + for _, f := range r.Columns() { + if m[f.Name] != f.Value { + t.Fatalf("JSON column %q = %q, want %q", f.Name, m[f.Name], f.Value) + } + } +} + +func TestRecordCSV(t *testing.T) { + dir := writeData(t, map[string]string{ + "note": `{"format": "{word}, {word}", "word": ["a", "b,c", "d\"e", "f\n"]}`, + }) + f := newGenerator(t, dir, WithSeed(1)) + seen := map[string]bool{} + for i := 0; i < 200 && len(seen) < 4; i++ { + r, err := f.Record("note") + if err != nil { + t.Fatal(err) + } + if got := r.CSVHeader(); got != "word" { + t.Fatalf("CSVHeader() = %q, want word", got) + } + rec, err := csv.NewReader(strings.NewReader(r.CSVLine() + "\n")).Read() + if err != nil { + t.Fatalf("CSVLine() is not valid CSV: %v\n%q", err, r.CSVLine()) + } + want := r.Columns()[0].Value + if len(rec) != 1 || rec[0] != want { + t.Fatalf("CSVLine() = %v, want the field value %q round-tripped", rec, want) + } + seen[want] = true + } + if len(seen) != 4 { + t.Fatalf("round-tripped %d of the 4 values; the comma, quote and newline shapes must each survive", len(seen)) + } +} + +func TestRecordCSVEmptyValueStaysARow(t *testing.T) { + dir := writeData(t, map[string]string{"blank": `{"format": "", "note": ""}`}) + f := newGenerator(t, dir, WithSeed(1)) + r, err := f.Record("blank") + if err != nil { + t.Fatal(err) + } + rows, err := csv.NewReader(strings.NewReader(r.CSVHeader() + "\n" + r.CSVLine() + "\n")).ReadAll() + if err != nil { + t.Fatalf("csv: %v", err) + } + if len(rows) != 2 || len(rows[1]) != 1 || rows[1][0] != "" { + t.Fatalf("one empty column parsed to %v, want a header and one row of one empty field", rows) + } +} + +func TestRecordRejectsOverlappingReferenceColumns(t *testing.T) { + cat := `{"format":"","a":[{"format":"A={b}","b":"1"},{"format":"A={b}","b":"2"}]}` + for _, c := range []struct{ name, row string }{ + {"sibling columns", `{"format":"","whole":"{/cat.a}","inner":"{/cat.a.b}"}`}, + {"through a nested template", `{"format":"","whole":"{/cat.a}","inner":{"format":"{/cat.a.b} {x}","x":"1"}}`}, + {"through a column repeat", `{"format":"","whole":"{/cat.a}","inner":{"format":"{/cat.a.b}","repeat":2,"separator":"-"}}`}, + {"through a choice variant", `{"format":"","whole":"{/cat.a}","inner":[{"format":"{/cat.a.b} {x}","x":"1"},{"format":"{/cat.a.b}! {x}","x":"2"}]}`}, + {"as a builtin operand", `{"format":"","whole":"{uppercase(/cat.a)}","inner":"{/cat.a.b}"}`}, + } { + f := newGenerator(t, writeData(t, map[string]string{"cat": cat, "row": c.row}), WithSeed(1)) + _, err := f.Record("row") + if err == nil || !strings.Contains(err.Error(), "reads a path into") { + t.Errorf("%s: Record = %v, want the overlap rejected the way one format is", c.name, err) + continue + } + if !strings.Contains(err.Error(), `"whole"`) || !strings.Contains(err.Error(), `"inner"`) { + t.Errorf("%s: error %q names neither column; it must name both", c.name, err) + } + if _, err := f.Fake("row"); err != nil { + t.Errorf("%s: Fake(row) = %v, want the string view untouched", c.name, err) + } + } +} + +func TestInlineRecordRejectsOverlappingColumns(t *testing.T) { + dir := writeData(t, map[string]string{ + "cat": `{"format":"","a":[{"format":"A={b}","b":"1"},{"format":"A={b}","b":"2"}]}`, + }) + f := newGenerator(t, dir, WithSeed(1)) + _, err := f.FakeRecord(`{"format":"","whole":"{/cat.a}","inner":"{/cat.a.b}"}`) + if err == nil || !strings.Contains(err.Error(), "reads a path into") { + t.Fatalf("inline record over an overlapping pair = %v, want the inline entry point to refuse it too", err) + } +} + +func TestRecordRejectsAColumnReadingItsOwnRecord(t *testing.T) { + for _, c := range []struct{ name, column string }{ + {"a path into itself", `"full":"{/person.first} {/person.last}"`}, + {"the record read whole", `"whole":"{/person}"`}, + {"the record as an operand", `"up":"{uppercase(/person)}"`}, + } { + person := `{"format":"{first} {last}","first":["Ada","Bo"],"last":["Lovelace","Ek"],` + c.column + `}` + f := newGenerator(t, writeData(t, map[string]string{"person": person}), WithSeed(1)) + if _, err := f.Record("person"); err == nil || !strings.Contains(err.Error(), "points back at this record") { + t.Errorf("%s: Record = %v, want it refused; the column would contradict the columns beside it", c.name, err) + } + } +} + +func TestRecordBareReferenceStaysIndependentAsAnOperand(t *testing.T) { + dir := writeData(t, map[string]string{ + "cur": `[{"format":"{code}","code":"aud"},{"format":"{code}","code":"eur"}]`, + "row": `{"format":"","up":"{uppercase(/cur)}","low":"{lowercase(/cur)}"}`, + }) + f := newGenerator(t, dir, WithSeed(1)) + sawMismatch := false + for i := 0; i < 200 && !sawMismatch; i++ { + r, err := f.Record("row") + if err != nil { + t.Fatal(err) + } + m := map[string]string{} + for _, c := range r.Columns() { + m[c.Name] = c.Value + } + sawMismatch = !strings.EqualFold(m["up"], m["low"]) + } + if !sawMismatch { + t.Error("two bare-reference operand columns never disagreed; a bare reference draws on its own, as the plain spelling does") + } + for i := 0; i < 50; i++ { + v, err := f.FakeTemplate("{/cur}|{uppercase(/cur)}") + if err != nil { + t.Fatal(err) + } + if parts := strings.Split(v, "|"); !strings.EqualFold(parts[0], parts[1]) { + t.Fatalf("one format rendered %q; within an expansion a bare reference is still one draw", v) + } + } +} + +func TestRecordSQLInsert(t *testing.T) { + dir := writeData(t, map[string]string{ + "person": `{"format": "{last}", "last": "O'Brien"}`, + }) + f := newGenerator(t, dir, WithSeed(1)) + r, err := f.Record("person") + if err != nil { + t.Fatal(err) + } + got := r.SQLInsert("people") + if got != `INSERT INTO "people" ("last") VALUES ('O''Brien');` { + t.Fatalf("SQLInsert() = %q, want quoted identifiers and the single quote doubled", got) + } +} + +func TestRecordRejectsFieldDescent(t *testing.T) { + dir := writeData(t, map[string]string{ + "cat": `{"format":"{sub}","sub":{"format":"{x}","x":"1"}}`, + "row": `[{"format":"{x}","x":"1"},{"format":"{x}","x":"2"}]`, + }) + f := newGenerator(t, dir, WithSeed(1)) + for _, path := range []string{"cat.sub", "row.x"} { + if _, err := f.Record(path); err == nil || !strings.Contains(err.Error(), "field") { + t.Errorf("Record(%q) = %v, want a 'descends into a field' error", path, err) + } + } +} + +func TestRecordSharesAReferenceAcrossColumns(t *testing.T) { + dir := writeData(t, map[string]string{ + "currency": `[{"format":"{code}","code":"AUD","symbol":"$"},{"format":"{code}","code":"EUR","symbol":"€"}]`, + "price": `{"format":"","code":"{/currency.code}","symbol":"{/currency.symbol}"}`, + }) + f := newGenerator(t, dir, WithSeed(1)) + for i := 0; i < 100; i++ { + r, err := f.Record("price") + if err != nil { + t.Fatal(err) + } + m := map[string]string{} + for _, c := range r.Columns() { + m[c.Name] = c.Value + } + switch m["code"] { + case "AUD": + if m["symbol"] != "$" { + t.Fatalf("record %q: code AUD but symbol %q, want one currency draw across columns", r.JSON(), m["symbol"]) + } + case "EUR": + if m["symbol"] != "€" { + t.Fatalf("record %q: code EUR but symbol %q, want one currency draw across columns", r.JSON(), m["symbol"]) + } + default: + t.Fatalf("record %q has unexpected code %q", r.JSON(), m["code"]) + } + } +} + +func TestRecordSharesAReferenceIntoAColumnRepeat(t *testing.T) { + dir := writeData(t, map[string]string{ + "currency": `[{"format":"{code}","code":"AUD"},{"format":"{code}","code":"EUR"}]`, + "order": `{"format":"","codes":{"format":"{/currency.code}","repeat":3,"separator":"-"}}`, + }) + f := newGenerator(t, dir, WithSeed(1)) + for i := 0; i < 50; i++ { + r, err := f.Record("order") + if err != nil { + t.Fatal(err) + } + parts := strings.Split(r.Columns()[0].Value, "-") + if len(parts) != 3 || parts[0] != parts[1] || parts[1] != parts[2] { + t.Fatalf("codes column = %q, want one shared draw across its repeat", r.Columns()[0].Value) + } + } +} + +func TestRecordBareReferenceStaysIndependent(t *testing.T) { + dir := writeData(t, map[string]string{ + "currency": `[{"format":"{code}","code":"AUD"},{"format":"{code}","code":"EUR"}]`, + "order": `{"format":"","whole":"{/currency}","code":"{/currency.code}"}`, + }) + f := newGenerator(t, dir, WithSeed(1)) + sawMismatch := false + for i := 0; i < 100; i++ { + r, err := f.Record("order") + if err != nil { + t.Fatal(err) + } + m := map[string]string{} + for _, c := range r.Columns() { + m[c.Name] = c.Value + } + if m["whole"] != m["code"] { + sawMismatch = true + break + } + } + if !sawMismatch { + t.Fatal("a bare {/currency} column never disagreed with a tailed {/currency.code} column; a bare reference should draw independently") + } +} + +func TestRecordSQLQuotesIdentifiers(t *testing.T) { + dir := writeData(t, map[string]string{ + "row": `{"format": "", "postal-code": "1", "street-number": "2"}`, + }) + f := newGenerator(t, dir, WithSeed(1)) + r, err := f.Record("row") + if err != nil { + t.Fatal(err) + } + got := r.SQLInsert("my-table") + if got != `INSERT INTO "my-table" ("postal-code", "street-number") VALUES ('1', '2');` { + t.Fatalf("SQLInsert() = %q, want hyphenated identifiers and table quoted", got) + } +} + +func TestFakeRecordAndTemplate(t *testing.T) { + f := recordCat(t) + in := `{"format":"{x} {y}","x":["1","2"],"y":["3","4"]}` + want, err := f.FakeRecord(in) + if err != nil { + t.Fatalf("FakeRecord: %v", err) + } + if len(want.Columns()) != 2 { + t.Fatalf("FakeRecord columns = %v, want two columns", want.Columns()) + } + reusable, err := f.NewRecordTemplate(in) + if err != nil { + t.Fatalf("NewRecordTemplate: %v", err) + } + for i := 0; i < 20; i++ { + if got := reusable.Fake(); len(got.Columns()) != 2 { + t.Fatalf("RecordTemplate.Fake() = %v, want two columns", got.Columns()) + } + } +} + +func TestInlineRecordErrors(t *testing.T) { + f := recordCat(t) + for _, c := range []struct { + input string + want string + }{ + {`"hello"`, "has no fields, so no columns"}, + {`["a","b"]`, "a record is a template whose fields are its columns"}, + {`{"format":"{a}-","repeat":3,"separator":"|","a":["x","y"]}`, "carries repeat 3"}, + } { + if _, err := f.FakeRecord(c.input); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("FakeRecord(%q) = %v, want an error naming %q", c.input, err, c.want) + } + } +} + +func TestRecordRejectsATopLevelRepeat(t *testing.T) { + dir := writeData(t, map[string]string{ + "rep": `{"format":"{a}-","repeat":3,"separator":"|","a":["x","y"]}`, + }) + f := newGenerator(t, dir, WithSeed(1)) + if _, err := f.Record("rep"); err == nil || !strings.Contains(err.Error(), "carries repeat 3") { + t.Errorf("Record on a repeating template = %v, want an error naming the repeat", err) + } + if v, err := f.Fake("rep"); err != nil || v != "x-|x-|x-" { + t.Errorf("Fake(rep) = %q, %v, want the repeat still composed for the string view", v, err) + } +} + +func TestRecordTemplateRejectsATopLevelRepeat(t *testing.T) { + f := recordCat(t) + in := `{"format":"{a}-","repeat":3,"separator":"|","a":["x","y"]}` + if _, err := f.NewRecordTemplate(in); err == nil || !strings.Contains(err.Error(), "carries repeat 3") { + t.Errorf("NewRecordTemplate on a repeating template = %v, want an error naming the repeat", err) + } + if _, err := f.NewTemplate(in); err != nil { + t.Errorf("NewTemplate on the same input = %v, want the string view to still compile", err) + } +} diff --git a/reference.go b/reference.go index 7744474..ff26770 100644 --- a/reference.go +++ b/reference.go @@ -93,7 +93,7 @@ func linkTemplateRefs(folder []string, path string, t *template, root map[string if err != nil { return fmt.Errorf("%s: reference {%s}: %w", path, name, err) } - head, target, tail, err := resolveRef(root, segments) + head, target, tail, err := resolveCategory(root, segments) if err != nil { return fmt.Errorf("%s: reference {%s}: %w", path, name, err) } @@ -146,11 +146,11 @@ func eachTemplate(root map[string]node, fn func(folder []string, path string, t return inFolder(nil, root) } -// resolveRef walks a reference path through the folders to the category it names, -// returning that head, the node, and the tail left to read into it. A descent of -// its own rather than a walkPath: it walks groups only and returns where they end, -// not a leaf. -func resolveRef(root map[string]node, segments []string) (head []string, target node, tail []string, err error) { +// resolveCategory walks a dotted path through the folders to the category it +// names, returning that head, the node, and the tail left to read into it. A +// descent of its own rather than a walkPath: it walks groups only and returns +// where they end, not a leaf. +func resolveCategory(root map[string]node, segments []string) (head []string, target node, tail []string, err error) { var n node = &group{children: root} i := 0 for ; i < len(segments); i++ { diff --git a/render.go b/render.go index 2d998fb..7a7af3a 100644 --- a/render.go +++ b/render.go @@ -27,7 +27,7 @@ func (f *Generator) Fake(path string) (string, error) { if _, ok := n.(*group); ok { return "", fmt.Errorf("fejkdata: %s names a folder, not a value", path) } - return render(f.rand, n), nil + return render(f.rand, n, nil), nil } // descend walks named fields to the node a path names. It is the one render-side @@ -50,17 +50,18 @@ func descend(s *session, root node, segments []string) (node, error) { } // render evaluates a compiled node to a string. compile validates every node up -// front, so rendering a compiled tree cannot fail. -func render(s *session, n node) string { +// front, so rendering a compiled tree cannot fail. refScope carries the draws a +// reference shares beyond its own expansion; nil keeps every reference local. +func render(s *session, n node, refScope *draws) string { switch n := n.(type) { case *choice: - return render(s, pick(s, n)) + return render(s, pick(s, n), refScope) case *template: if n.repeat == 1 { if n.fixed { return n.lit } - return expand(s, n) + return expand(s, n, refScope) } var b strings.Builder b.Grow(n.repeat * (n.grow + len(n.separator))) @@ -68,7 +69,7 @@ func render(s *session, n node) string { if i > 0 { b.WriteString(n.separator) } - b.WriteString(expand(s, n)) + b.WriteString(expand(s, n, refScope)) } return b.String() default: @@ -90,11 +91,12 @@ func pick(r rng, c *choice) node { // expand renders a template's compiled ops. compile validated every token, so this // cannot fail. -func expand(s *session, t *template) string { +func expand(s *session, t *template, refScope *draws) string { var b strings.Builder b.Grow(t.grow) // One draw per held name, for this expansion only: a nested template and each - // repeat iteration get their own, since each is its own expansion. + // repeat iteration get their own, since each is its own expansion. A reference + // reads the caller's scope instead, whenever one was supplied. var held *draws if len(t.held) > 0 { held = &draws{ @@ -108,7 +110,7 @@ func expand(s *session, t *template) string { case 'l': b.WriteString(o.lit) case 'f': - b.WriteString(readField(s, t, held, o.arms[s.IntN(len(o.arms))])) + b.WriteString(readField(s, t, held, refScope, o.arms[s.IntN(len(o.arms))])) case 'b': // Read before the call, so the value a calc computes is the value the // format showed. calcVars fixed the order op.operands holds. @@ -116,7 +118,7 @@ func expand(s *session, t *template) string { if len(o.operands) > 0 { operands = make([]string, len(o.operands)) for j, a := range o.operands { - operands[j] = readField(s, t, held, a) + operands[j] = readField(s, t, held, refScope, a) } } b.WriteString(o.call(s, b.String(), operands)) // b.String() is the output so far diff --git a/shipped_data_test.go b/shipped_data_test.go index fe1409d..374b7a8 100644 --- a/shipped_data_test.go +++ b/shipped_data_test.go @@ -108,6 +108,10 @@ func TestFakeIsSafeForConcurrentUse(t *testing.T) { return } f.List() + if _, err := f.Record("sv_SE.person"); err != nil { + t.Error(err) + return + } tmpl, err := f.NewTemplate("{/sv_SE.person.last}") if err != nil { t.Error(err) diff --git a/template_test.go b/template_test.go index 74dbc30..080972e 100644 --- a/template_test.go +++ b/template_test.go @@ -38,7 +38,7 @@ func compiled(t *testing.T, s string) node { func mustRender(t *testing.T, f *Generator, s string) string { t.Helper() - return render(f.rand, compiled(t, s)) + return render(f.rand, compiled(t, s), nil) } func TestStringIsAFormat(t *testing.T) { @@ -315,7 +315,7 @@ func TestGrowIsALowerBound(t *testing.T) { t.Fatalf("format %q did not compile to a template", format) } for i := 0; i < 50; i++ { - if got := len(expand(f.rand, tmpl)); got < tmpl.grow { + if got := len(expand(f.rand, tmpl, nil)); got < tmpl.grow { t.Errorf("format %q: expand emitted %d bytes, below grow %d", format, got, tmpl.grow) } }