Compare commits

6 Commits

Author SHA1 Message Date
lilleman 5d981a7304 Drop the deferred section, superseded by todo.md
Tests / vet + fmt + tests (pull_request) Successful in 4s
Tests / vet + fmt + tests (push) Successful in 4s
2026-09-05 15:30:02 +02:00
lilleman b17106afab Record the release checklist and the later data de-duplication
Tests / vet + fmt + tests (pull_request) Successful in 4s
Tests / vet + fmt + tests (push) Successful in 4s
2026-09-05 12:12:58 +02:00
lilleman 314ba49b9d README redaction
Tests / vet + fmt + tests (pull_request) Successful in 1m2s
Tests / vet + fmt + tests (push) Successful in 7s
2026-09-03 23:05:27 +02:00
lilleman 19aae8bbfe Name the workflow in the gate-skip note
Tests / vet + fmt + tests (pull_request) Successful in 1m0s
2026-09-03 22:40:31 +02:00
lilleman 2d28220869 Diff the whole push range and de-duplicate the gate-skip note
Tests / vet + fmt + tests (pull_request) Failing after 3s
2026-09-03 22:36:48 +02:00
lilleman cc15bdd1d9 Run the merge gate only for changes its tests read
Tests / vet + fmt + tests (pull_request) Successful in 1m4s
2026-09-03 22:30:02 +02:00
18 changed files with 88 additions and 1186 deletions
+17 -1
View File
@@ -16,12 +16,28 @@ jobs:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with: with:
persist-credentials: false persist-credentials: false
fetch-depth: 0
- name: Relevant changes
id: changes
run: |
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
git fetch --no-tags origin "$GITHUB_BASE_REF"
base="origin/$GITHUB_BASE_REF"
else
base='${{ github.event.before }}'
fi
if git diff --quiet "$base"...HEAD -- '*.go' go.mod go.sum Dockerfile .dockerignore .github data README.md; then
echo "code=false" >> "$GITHUB_OUTPUT"
else
echo "code=true" >> "$GITHUB_OUTPUT"
fi
# `docker build` streams the context to the daemon. A compose bind-mount of # `docker build` streams the context to the daemon. A compose bind-mount of
# `.` mounts an empty host dir instead, because the job is itself a container. # `.` mounts an empty host dir instead, because the job is itself a container.
- name: Latest supported Go - name: Latest supported Go
if: ${{ steps.changes.outputs.code == 'true' }}
run: docker build . run: docker build .
# Runs even when the step above failed, so a red build says whether the # Runs even when the step above failed, so a red build says whether the
# failure is version-specific. # failure is version-specific.
- name: Lowest supported Go - name: Lowest supported Go
if: ${{ !cancelled() }} if: ${{ !cancelled() && steps.changes.outputs.code == 'true' }}
run: docker build --build-arg GO_VERSION=1.22.12 . run: docker build --build-arg GO_VERSION=1.22.12 .
+1 -2
View File
@@ -1,4 +1,3 @@
.claude .claude
*.out *.out
__pycache__/ __pycache__/
todo.md
+4 -106
View File
@@ -41,8 +41,6 @@ brace, a bracket or a quote, so the two cannot collide (see
| `-s`, `--seed N` | reproducible output | | `-s`, `--seed N` | reproducible output |
| `-n`, `--repeat N` | render the value N times (up to 1048576), each an independent draw, streamed | | `-n`, `--repeat N` | render the value N times (up to 1048576), each an independent draw, streamed |
| `--separator S` | between repeated values (default a newline) | | `--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 | | `--list` | print every path, then exit |
| `--version`, `-h`, `--help` | print, then exit | | `--version`, `-h`, `--help` | print, then exit |
@@ -75,67 +73,6 @@ fejkdata --seed 1 --data-path ./mydata sql
fejkdata --repeat 100 --data-path ./mydata sql > seed.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 ## Library
```sh ```sh
@@ -152,9 +89,6 @@ paths := f.List() // every path Fake accepts, sorted
v, err = f.FakeTemplate("name: {/sv_SE.person.last}") // compile + render in one call 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 t, err := f.NewTemplate(`{"format":"name: {x}","x":["bosse","lina"]}`) // compile once
v = t.Fake() // render many times, no re-parse 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 | | | Option | |
@@ -164,11 +98,6 @@ r, err = f.FakeRecord(`{"format":"{x}","x":["a","b"]}`) // compile + render inli
| `WithDataFS(fsys)` | layer an `fs.FS`, such as your own `embed.FS` | | `WithDataFS(fsys)` | layer an `fs.FS`, such as your own `embed.FS` |
| `WithoutShippedData()` | load only what you give | | `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 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 when drawn from one goroutine. Changing how a value is composed shifts the seeded
stream for that value and everything drawn after it. stream for that value and everything drawn after it.
@@ -525,38 +454,6 @@ tokens add cost in proportion to the output.
- **Samples say what they emit, transforms what they do.** `{upper(2)}` is two - **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 letters, `{uppercase(x)}` is `x` upper-cased; one name for both would turn on
whether the argument looks like a number. 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` - **The performance gate asserts allocations, not wall-clock time.** `AllocsPerRun`
is deterministic across machines, so a ±10% ceiling does not flake under CI load, 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 while time varies with the machine and its neighbours. A rendering slowdown
@@ -587,8 +484,10 @@ docker compose run --rm --user "$(id -u):$(id -g)" tidy # go mod tidy
``` ```
Every pull request runs `docker build .` against both the latest and the lowest Every pull request runs `docker build .` against both the latest and the lowest
supported Go, and must pass before it can be merged. That build is the whole supported Go, and must pass before it can be merged — unless it changes none of
gate — vet, complexity, format check and tests — so run it locally before pushing: the files the build and its tests read, nor the workflow itself, in which case
it's skipped (see [Decisions](#decisions)). That build is the whole gate — vet,
complexity, format check and tests — so run it locally before pushing:
```sh ```sh
docker build . # latest docker build . # latest
@@ -603,7 +502,6 @@ fejkdata.go Generator, New, options, the embedded data set, List
node.go the node model and JSON -> node compilation node.go the node model and JSON -> node compilation
path.go the dotted-path walk, and proving a path resolves path.go the dotted-path walk, and proving a path resolves
render.go Fake and the recursive renderer (choices, format strings, expansions) 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 inline.go inline templates: Template, NewTemplate, FakeTemplate, and their compile and link
template.go the {token} grammar: scanning, tokens, operands, validation, compiling a format 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 hold.go the hold: one draw per expansion for paths and operands, and its fences
+11 -155
View File
@@ -15,7 +15,6 @@ import (
"io" "io"
"os" "os"
"runtime/debug" "runtime/debug"
"sort"
"strconv" "strconv"
"strings" "strings"
@@ -35,19 +34,13 @@ 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 argument carrying a bracket, a closing brace or a quote but no valid JSON names
neither. 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) -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 -h, --help print this help, then exit
--list list the paths the data offers, then exit --list list the paths the data offers, then exit
--no-shipped-data load only the --data-path directories --no-shipped-data load only the --data-path directories
-n, --repeat N render the value N times, 1..1048576 (default 1) -n, --repeat N render the value N times, 1..1048576 (default 1)
-s, --seed N seed for reproducible output -s, --seed N seed for reproducible output
--separator S string between repeated values (default newline) --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 --version print the version, then exit
Flags may come before or after <path|template>; -- ends the flags. A short flag's Flags may come before or after <path|template>; -- ends the flags. A short flag's
@@ -56,8 +49,6 @@ value attaches or follows (-n3, -n 3); short flags bundle (-hn 3).
type invocation struct { type invocation struct {
dirs []string dirs []string
format string
formatSet bool
help bool help bool
list bool list bool
noShipped bool noShipped bool
@@ -68,8 +59,6 @@ type invocation struct {
seeded bool seeded bool
separator string separator string
separatorSet bool separatorSet bool
table string
tableSet bool
version bool version bool
} }
@@ -82,7 +71,6 @@ type flagDef struct {
var flagDefs = []flagDef{ var flagDefs = []flagDef{
{"data-path", "d", true, func(in *invocation, v string) error { in.dirs = append(in.dirs, v); return nil }}, {"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 }}, {"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 }}, {"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 }}, {"no-shipped-data", "", false, func(in *invocation, _ string) error { in.noShipped = true; return nil }},
@@ -103,7 +91,6 @@ var flagDefs = []flagDef{
return nil return nil
}}, }},
{"separator", "", true, func(in *invocation, v string) error { in.separator, in.separatorSet = v, true; 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 }}, {"version", "", false, func(in *invocation, _ string) error { in.version = true; return nil }},
} }
@@ -171,7 +158,7 @@ func splitFlags(arg string) ([]flagArg, error) {
} }
func parseArgs(argv []string) (invocation, error) { func parseArgs(argv []string) (invocation, error) {
in := invocation{repeat: 1, separator: "\n", format: "text"} in := invocation{repeat: 1, separator: "\n"}
for i := 0; i < len(argv); i++ { for i := 0; i < len(argv); i++ {
arg := argv[i] arg := argv[i]
if arg == "--" { if arg == "--" {
@@ -209,71 +196,14 @@ func parseArgs(argv []string) (invocation, error) {
return in, nil 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 // 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. // what the argument names, so its shape is settled before any data is read.
func (in invocation) check() (argKind, error) { func (in invocation) check() (argKind, error) {
if err := in.checkFlags(); err != nil {
return argPath, err
}
if in.list && len(in.paths) > 0 { if in.list && len(in.paths) > 0 {
return argPath, errors.New("--list takes no path") return argPath, errors.New("--list takes no path")
} }
if in.list && (in.repeatSet || in.separatorSet || in.formatSet || in.tableSet) { if in.list && (in.repeatSet || in.separatorSet) {
return argPath, errors.New("--list takes no --repeat, --separator, --format or --table") return argPath, errors.New("--list takes no --repeat or --separator")
} }
if in.list { if in.list {
return argPath, nil return argPath, nil
@@ -303,12 +233,14 @@ func (in invocation) options() []fejkdata.Option {
// so a render failure comes before anything is written; a write failure surfaces // so a render failure comes before anything is written; a write failure surfaces
// from Flush, bufio keeping the first one. // from Flush, bufio keeping the first one.
func (in invocation) write(f *fejkdata.Generator, kind argKind, w io.Writer) error { func (in invocation) write(f *fejkdata.Generator, kind argKind, w io.Writer) error {
if in.writesRecords() { arg := in.paths[0]
return in.writeRecords(f, kind, w) draw := func() (string, error) { return f.Fake(arg) }
} if kind == argTemplate {
draw, err := in.textDraw(f, kind, in.paths[0]) t, err := f.NewTemplate(arg)
if err != nil { if err != nil {
return err return templateError{err}
}
draw = func() (string, error) { return t.Fake(), nil }
} }
out := bufio.NewWriter(w) out := bufio.NewWriter(w)
for i := 0; i < in.repeat; i++ { for i := 0; i < in.repeat; i++ {
@@ -325,82 +257,6 @@ func (in invocation) write(f *fejkdata.Generator, kind argKind, w io.Writer) err
return out.Flush() 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 // 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 // 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). // pointer to --help), unlike an unknown path, which is a runtime error (exit 1).
-141
View File
@@ -2,8 +2,6 @@ package main
import ( import (
"bytes" "bytes"
"encoding/csv"
"encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
@@ -452,145 +450,6 @@ 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) { func TestRunNumbersFollowTheShell(t *testing.T) {
_, want, _ := runOut("--seed", "7", "--repeat", "3", "sv_SE.word") _, want, _ := runOut("--seed", "7", "--repeat", "3", "sv_SE.word")
code, got, errb := runOut("--seed", "007", "--repeat", "+3", "sv_SE.word") code, got, errb := runOut("--seed", "007", "--repeat", "+3", "sv_SE.word")
-1
View File
@@ -45,7 +45,6 @@ type Generator struct {
mu sync.Mutex mu sync.Mutex
rand *session rand *session
categories map[string]node categories map[string]node
records map[node]recordShape
} }
// session is one generator's mutable render state: the seeded rng plus the {seq()} // session is one generator's mutable render state: the seeded rng plus the {seq()}
+7 -13
View File
@@ -267,20 +267,14 @@ type draws struct {
// gives one value, and a shown operand is the operand computed. Every other name is // 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 // drawn afresh, so {word} {word} still draws twice. checkTokens, checkPath and
// linkRefs prove every step, so the walk cannot fail. // linkRefs prove every step, so the walk cannot fail.
func readField(s *session, t *template, held, refScope *draws, a arm) string { func readField(s *session, t *template, held *draws, a arm) string {
if !t.held[a.key] { if !t.held[a.key] {
if len(a.tail) > 0 { 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)) 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], refScope) return render(s, t.fields[a.key])
} }
// A reference that reads a path reads the caller's scope, so its draw outlives if v, read := held.value[a.path]; read {
// 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 return v
} }
var v string var v string
@@ -292,16 +286,16 @@ func readField(s *session, t *template, held, refScope *draws, a arm) string {
if consumed := len(a.tail) - len(rest); consumed > 0 { if consumed := len(a.tail) - len(rest); consumed > 0 {
key = a.steps[consumed-1] key = a.steps[consumed-1]
} }
n, drew := d.variant[key] n, drew := held.variant[key]
if !drew { if !drew {
n = drawn(s, c) n = drawn(s, c)
d.variant[key] = n held.variant[key] = n
} }
return []node{n}, nil return []node{n}, nil
}, },
leaf: func(n node) error { v = render(s, n, refScope); return nil }, leaf: func(n node) error { v = render(s, n); return nil },
}) })
d.value[a.path] = v held.value[a.path] = v
return v return v
} }
+1 -1
View File
@@ -718,6 +718,6 @@ func TestReadFieldPanicsOnAnUnheldPath(t *testing.T) {
t.Fatal("not a template") t.Fatal("not a template")
} }
mustPanic(t, "unheld arm with a path", func() { mustPanic(t, "unheld arm with a path", func() {
readField(engine(1).rand, tm, nil, nil, arm{name: "w.x", key: "w", tail: []string{"x"}, path: "w.x"}) readField(engine(1).rand, tm, nil, arm{name: "w.x", key: "w", tail: []string{"x"}, path: "w.x"})
}) })
} }
+1 -1
View File
@@ -20,7 +20,7 @@ type Template struct {
func (t *Template) Fake() string { func (t *Template) Fake() string {
t.g.mu.Lock() t.g.mu.Lock()
defer t.g.mu.Unlock() defer t.g.mu.Unlock()
return render(t.g.rand, t.n, nil) return render(t.g.rand, t.n)
} }
// NewTemplate compiles an inline template — a format string or a JSON value — and // NewTemplate compiles an inline template — a format string or a JSON value — and
-20
View File
@@ -51,26 +51,6 @@ 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 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 BenchmarkNestedDepth100(b *testing.B) { benchPath(b, tmpData(b, "deep", nestedJSON(100)), "deep") }
func BenchmarkWideTokens100(b *testing.B) { func BenchmarkWideTokens100(b *testing.B) {
-28
View File
@@ -43,34 +43,6 @@ 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) { func TestReadmeSQLExampleOutput(t *testing.T) {
src := readme(t) src := readme(t)
src = src[strings.Index(src, "### Your own data"):] src = src[strings.Index(src, "### Your own data"):]
-285
View File
@@ -1,285 +0,0 @@
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
}
-408
View File
@@ -1,408 +0,0 @@
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)
}
}
+6 -6
View File
@@ -93,7 +93,7 @@ func linkTemplateRefs(folder []string, path string, t *template, root map[string
if err != nil { if err != nil {
return fmt.Errorf("%s: reference {%s}: %w", path, name, err) return fmt.Errorf("%s: reference {%s}: %w", path, name, err)
} }
head, target, tail, err := resolveCategory(root, segments) head, target, tail, err := resolveRef(root, segments)
if err != nil { if err != nil {
return fmt.Errorf("%s: reference {%s}: %w", path, name, err) 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) return inFolder(nil, root)
} }
// resolveCategory walks a dotted path through the folders to the category it // resolveRef walks a reference path through the folders to the category it names,
// names, returning that head, the node, and the tail left to read into it. A // returning that head, the node, and the tail left to read into it. A descent of
// descent of its own rather than a walkPath: it walks groups only and returns // its own rather than a walkPath: it walks groups only and returns where they end,
// where they end, not a leaf. // not a leaf.
func resolveCategory(root map[string]node, segments []string) (head []string, target node, tail []string, err error) { func resolveRef(root map[string]node, segments []string) (head []string, target node, tail []string, err error) {
var n node = &group{children: root} var n node = &group{children: root}
i := 0 i := 0
for ; i < len(segments); i++ { for ; i < len(segments); i++ {
+10 -12
View File
@@ -27,7 +27,7 @@ func (f *Generator) Fake(path string) (string, error) {
if _, ok := n.(*group); ok { if _, ok := n.(*group); ok {
return "", fmt.Errorf("fejkdata: %s names a folder, not a value", path) return "", fmt.Errorf("fejkdata: %s names a folder, not a value", path)
} }
return render(f.rand, n, nil), nil return render(f.rand, n), nil
} }
// descend walks named fields to the node a path names. It is the one render-side // descend walks named fields to the node a path names. It is the one render-side
@@ -50,18 +50,17 @@ func descend(s *session, root node, segments []string) (node, error) {
} }
// render evaluates a compiled node to a string. compile validates every node up // render evaluates a compiled node to a string. compile validates every node up
// front, so rendering a compiled tree cannot fail. refScope carries the draws a // front, so rendering a compiled tree cannot fail.
// reference shares beyond its own expansion; nil keeps every reference local. func render(s *session, n node) string {
func render(s *session, n node, refScope *draws) string {
switch n := n.(type) { switch n := n.(type) {
case *choice: case *choice:
return render(s, pick(s, n), refScope) return render(s, pick(s, n))
case *template: case *template:
if n.repeat == 1 { if n.repeat == 1 {
if n.fixed { if n.fixed {
return n.lit return n.lit
} }
return expand(s, n, refScope) return expand(s, n)
} }
var b strings.Builder var b strings.Builder
b.Grow(n.repeat * (n.grow + len(n.separator))) b.Grow(n.repeat * (n.grow + len(n.separator)))
@@ -69,7 +68,7 @@ func render(s *session, n node, refScope *draws) string {
if i > 0 { if i > 0 {
b.WriteString(n.separator) b.WriteString(n.separator)
} }
b.WriteString(expand(s, n, refScope)) b.WriteString(expand(s, n))
} }
return b.String() return b.String()
default: default:
@@ -91,12 +90,11 @@ func pick(r rng, c *choice) node {
// expand renders a template's compiled ops. compile validated every token, so this // expand renders a template's compiled ops. compile validated every token, so this
// cannot fail. // cannot fail.
func expand(s *session, t *template, refScope *draws) string { func expand(s *session, t *template) string {
var b strings.Builder var b strings.Builder
b.Grow(t.grow) b.Grow(t.grow)
// One draw per held name, for this expansion only: a nested template and each // 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. A reference // repeat iteration get their own, since each is its own expansion.
// reads the caller's scope instead, whenever one was supplied.
var held *draws var held *draws
if len(t.held) > 0 { if len(t.held) > 0 {
held = &draws{ held = &draws{
@@ -110,7 +108,7 @@ func expand(s *session, t *template, refScope *draws) string {
case 'l': case 'l':
b.WriteString(o.lit) b.WriteString(o.lit)
case 'f': case 'f':
b.WriteString(readField(s, t, held, refScope, o.arms[s.IntN(len(o.arms))])) b.WriteString(readField(s, t, held, o.arms[s.IntN(len(o.arms))]))
case 'b': case 'b':
// Read before the call, so the value a calc computes is the value the // Read before the call, so the value a calc computes is the value the
// format showed. calcVars fixed the order op.operands holds. // format showed. calcVars fixed the order op.operands holds.
@@ -118,7 +116,7 @@ func expand(s *session, t *template, refScope *draws) string {
if len(o.operands) > 0 { if len(o.operands) > 0 {
operands = make([]string, len(o.operands)) operands = make([]string, len(o.operands))
for j, a := range o.operands { for j, a := range o.operands {
operands[j] = readField(s, t, held, refScope, a) operands[j] = readField(s, t, held, a)
} }
} }
b.WriteString(o.call(s, b.String(), operands)) // b.String() is the output so far b.WriteString(o.call(s, b.String(), operands)) // b.String() is the output so far
-4
View File
@@ -108,10 +108,6 @@ func TestFakeIsSafeForConcurrentUse(t *testing.T) {
return return
} }
f.List() f.List()
if _, err := f.Record("sv_SE.person"); err != nil {
t.Error(err)
return
}
tmpl, err := f.NewTemplate("{/sv_SE.person.last}") tmpl, err := f.NewTemplate("{/sv_SE.person.last}")
if err != nil { if err != nil {
t.Error(err) t.Error(err)
+2 -2
View File
@@ -38,7 +38,7 @@ func compiled(t *testing.T, s string) node {
func mustRender(t *testing.T, f *Generator, s string) string { func mustRender(t *testing.T, f *Generator, s string) string {
t.Helper() t.Helper()
return render(f.rand, compiled(t, s), nil) return render(f.rand, compiled(t, s))
} }
func TestStringIsAFormat(t *testing.T) { 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) t.Fatalf("format %q did not compile to a template", format)
} }
for i := 0; i < 50; i++ { for i := 0; i < 50; i++ {
if got := len(expand(f.rand, tmpl, nil)); got < tmpl.grow { if got := len(expand(f.rand, tmpl)); got < tmpl.grow {
t.Errorf("format %q: expand emitted %d bytes, below grow %d", format, got, tmpl.grow) t.Errorf("format %q: expand emitted %d bytes, below grow %d", format, got, tmpl.grow)
} }
} }
+28
View File
@@ -0,0 +1,28 @@
# Release checklist
What to settle before the first tag, then the work that follows in a later,
data-heavy release.
## Before the first release — settle the record flag and API contract
- `--format` vocabulary — confirm `text`, `json`, `ndjson`, `csv`, `sql`; the
`json`-as-array vs `ndjson`-as-lines split; `--table` (the SQL INSERT target);
the `--separator` rejection on record formats; and the exit codes (misuse 2,
runtime 1).
- Library surface — confirm `Record`, `FakeRecord`, `NewRecordTemplate`,
`RecordTemplate`, `Column`/`Columns()`, and the `JSON()`, `CSVHeader()`,
`CSVLine()`, `SQLInsert()` serializers.
- Typed scalars — columns are strings today (`"42"`, quoted SQL). Confirm that
stays out of scope, or add a per-column `kind` before the tag.
- Struct-filling — `fake:"..."` tags (reflection over an arbitrary struct) stay
out of scope; `Columns()` hands the caller the values to map themselves.
Confirm.
- Independent reference draw — within one record every tailed reference to a
category is one draw, with no spelling for "these columns should disagree".
Confirm the per-record contract, or add the spelling.
## Later, in a data-heavy release
- Shipped-data de-duplication — `email.json`'s `local` is a drifted copy of
`username.json`; fold it in when the shipped set grows and we add lots more
data.