Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d981a7304 | |||
| b17106afab | |||
| 314ba49b9d | |||
| 19aae8bbfe | |||
| 2d28220869 | |||
| cc15bdd1d9 |
@@ -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
@@ -1,4 +1,3 @@
|
|||||||
.claude
|
.claude
|
||||||
*.out
|
*.out
|
||||||
__pycache__/
|
__pycache__/
|
||||||
todo.md
|
|
||||||
@@ -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
@@ -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).
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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
@@ -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"})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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"):]
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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++ {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user