Compare commits

..

3 Commits

12 changed files with 252 additions and 155 deletions
+7
View File
@@ -11,6 +11,13 @@
# Deferred # Deferred
- **A record cannot ask for an independent reference draw (2026-09-04).** Within
one record every tailed reference to a category is one draw, and a bare
`{/cat}` renders the whole category, so a column wanting its own draw of
`{/cat.field}` has no spelling for it. Left until a use case names which columns
should disagree; premise: a record is one coherent row, which is what columns
are for. Not raised in review before that.
- **Shipped-data de-duplication (2026-09-02).** `email.json`'s `local` is a - **Shipped-data de-duplication (2026-09-02).** `email.json`'s `local` is a
drifted copy of `username.json`, and no shipped file yet uses a held path, an drifted copy of `username.json`, and no shipped file yet uses a held path, an
operand or a reference. Fixed in the data fill before the first tag, when the operand or a reference. Fixed in the data fill before the first tag, when the
+36 -9
View File
@@ -41,7 +41,7 @@ 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`, `csv` or `sql` — a record's columns, one per line | | `--format F` | `text` (default), `json`, `csv` or `sql` — a record's columns, one record per row |
| `--table T` | the INSERT target for `--format sql` (default: the path's last segment, or `records` for an inline template) | | `--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 |
@@ -81,7 +81,7 @@ For structured output a record writes the row for you.
### Records ### Records
A record is a template seen as columns: its fields are the columns, its `format` A record is a template seen as columns: its fields are the columns, its `format`
the whole. `--format json|csv|sql` streams a record per line; the library's the whole. `--format json|csv|sql` streams one record per row; the library's
`Record` (below) hands back the columns. Every column is a string — this is the `Record` (below) hands back the columns. Every column is a string — this is the
out-of-scope of typed scalars, see [Decisions](#decisions). Save out-of-scope of typed scalars, see [Decisions](#decisions). Save
`mydata/users.json`: `mydata/users.json`:
@@ -103,11 +103,22 @@ fejkdata --seed 1 --data-path ./mydata --format sql --table people users # INSER
``` ```
`--repeat` streams that many records — newline-delimited JSON (one object per `--repeat` streams that many records — newline-delimited JSON (one object per
line, NDJSON), a CSV row per line after a header, or an INSERT per line in SQL. line, NDJSON), a CSV row after a header, or an INSERT in SQL. To fold NDJSON into
To fold NDJSON into a single array, `fejkdata … --format json | jq -s .`. Only a a single array, `fejkdata … --format json | jq -s .`. Only a category-level
category-level template is a record; a field, choice or folder errors. Column template is a record; a field, choice or folder errors, and so does a `repeat` on
identifiers are double-quoted in SQL, so a hyphenated field like `postal-code` the template itself, which composes the format into one string rather than
stays valid. 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 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 required key — so `"format": ""` carries the fields with an inert format: it
@@ -146,7 +157,7 @@ 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 `Fields()`, and serializes them with `JSON()` A `*Record` carries its columns via `Columns()`, and serializes them with `JSON()`
(one object), `CSVHeader()`/`CSVLine()`, or `SQLInsert(table)` — the same three (one object), `CSVHeader()`/`CSVLine()`, or `SQLInsert(table)` — the same three
shapes the CLI's `--format` streams. `Record` and `FakeRecord` take a record; a shapes the CLI's `--format` streams. `Record` and `FakeRecord` take a record; a
path or template that is not one — a bare string, a choice, or a folder — errors. path or template that is not one — a bare string, a choice, or a folder — errors.
@@ -529,7 +540,23 @@ tokens add cost in proportion to the output.
time, the rule a format string already follows. Only references share: a sibling 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 field is local to its own column, so a `first` column does not silently bind to
a `first` in the column next to it. a `first` in the column next to it.
- **Filling a Go struct is out of scope.** `Record.Fields()` returns the columns a
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.
- **Filling a Go struct is out of scope.** `Record.Columns()` returns the columns a
caller maps onto a struct themselves, casting each string to the field's type. caller maps onto a struct themselves, casting each string to the field's type.
gofakeit's `fake:"{firstname}"` tags reflect over an arbitrary struct type and gofakeit's `fake:"{firstname}"` tags reflect over an arbitrary struct type and
cast into its fields — a different concern from "data lives in JSON", and one cast into its fields — a different concern from "data lives in JSON", and one
+63 -42
View File
@@ -15,6 +15,7 @@ import (
"io" "io"
"os" "os"
"runtime/debug" "runtime/debug"
"sort"
"strconv" "strconv"
"strings" "strings"
@@ -208,20 +209,41 @@ func parseArgs(argv []string) (invocation, error) {
return in, nil return in, nil
} }
// recordFormat reports whether the format names a record output (json, csv or // recordFormat is one way to write a record out: the line it renders, and the
// sql) rather than the default text. // header that precedes the first one, if the format has one.
type recordFormat struct {
header func(*fejkdata.Record) string
line func(r *fejkdata.Record, table string) string
}
// recordFormats is every --format that writes records. Adding one is this entry
// alone: the flag check reads the same table the writer dispatches through, so a
// format cannot be accepted and then not written.
var recordFormats = map[string]recordFormat{
"csv": {header: (*fejkdata.Record).CSVHeader, line: func(r *fejkdata.Record, _ string) string { return r.CSVLine() }},
"json": {line: func(r *fejkdata.Record, _ string) string { return r.JSON() }},
"sql": {line: func(r *fejkdata.Record, table string) string { return r.SQLInsert(table) }},
}
// recordFormat reports whether the format writes records rather than plain text.
func (in invocation) recordFormat() bool { func (in invocation) recordFormat() bool {
return in.formatSet && in.format != "text" 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. // checkFlags rejects a flag value or combination that cannot run.
func (in invocation) checkFlags() error { func (in invocation) checkFlags() error {
if in.formatSet { if _, ok := recordFormats[in.format]; !ok && in.format != "text" {
switch in.format { return fmt.Errorf("--format takes %s, got %q", formatNames(), in.format)
case "text", "json", "csv", "sql":
default:
return fmt.Errorf("--format takes text, json, csv or sql, got %q", in.format)
}
} }
if in.tableSet && in.format != "sql" { if in.tableSet && in.format != "sql" {
return errors.New("--table names the INSERT target, so it needs --format sql") return errors.New("--table names the INSERT target, so it needs --format sql")
@@ -272,17 +294,13 @@ 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 {
arg := in.paths[0] draw, err := in.draw(f, kind, in.paths[0])
if in.recordFormat() {
return in.writeRecords(f, kind, arg, w)
}
draw := func() (string, error) { return f.Fake(arg) }
if kind == argTemplate {
t, err := f.NewTemplate(arg)
if err != nil { if err != nil {
return templateError{err} return err
} }
draw = func() (string, error) { return t.Fake(), nil } separator := in.separator
if in.recordFormat() {
separator = "\n"
} }
out := bufio.NewWriter(w) out := bufio.NewWriter(w)
for i := 0; i < in.repeat; i++ { for i := 0; i < in.repeat; i++ {
@@ -291,7 +309,7 @@ func (in invocation) write(f *fejkdata.Generator, kind argKind, w io.Writer) err
return err return err
} }
if i > 0 { if i > 0 {
out.WriteString(in.separator) out.WriteString(separator)
} }
out.WriteString(v) out.WriteString(v)
} }
@@ -299,40 +317,43 @@ func (in invocation) write(f *fejkdata.Generator, kind argKind, w io.Writer) err
return out.Flush() return out.Flush()
} }
// writeRecords streams a record per line in the chosen format: one JSON object // draw builds what one render yields: the value's text, or the record's line in
// per line, a CSV header then a row per line, or one INSERT per line. // the chosen format, the header carried ahead of the first one.
func (in invocation) writeRecords(f *fejkdata.Generator, kind argKind, arg string, w io.Writer) error { func (in invocation) draw(f *fejkdata.Generator, kind argKind, arg string) (func() (string, error), error) {
draw := func() (*fejkdata.Record, error) { return f.Record(arg) } if !in.recordFormat() {
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
}
record := func() (*fejkdata.Record, error) { return f.Record(arg) }
if kind == argTemplate { if kind == argTemplate {
t, err := f.NewRecordTemplate(arg) t, err := f.NewRecordTemplate(arg)
if err != nil { if err != nil {
return templateError{err} return nil, templateError{err}
} }
draw = func() (*fejkdata.Record, error) { return t.Fake(), nil } record = func() (*fejkdata.Record, error) { return t.Fake(), nil }
} }
out := bufio.NewWriter(w) format, table, first := recordFormats[in.format], in.table, true
table := in.table
if table == "" { if table == "" {
table = defaultTable(arg, kind) table = defaultTable(arg, kind)
} }
for i := 0; i < in.repeat; i++ { return func() (string, error) {
r, err := draw() r, err := record()
if err != nil { if err != nil {
return err return "", err
} }
switch in.format { line := format.line(r, table)
case "json": if first && format.header != nil {
fmt.Fprintln(out, r.JSON()) line = format.header(r) + "\n" + line
case "csv":
if i == 0 {
fmt.Fprintln(out, r.CSVHeader())
} }
fmt.Fprintln(out, r.CSVLine()) first = false
case "sql": return line, nil
fmt.Fprintln(out, r.SQLInsert(table)) }, nil
}
}
return out.Flush()
} }
// defaultTable names the INSERT target when --table is absent: the path's last // defaultTable names the INSERT target when --table is absent: the path's last
+20 -1
View File
@@ -531,7 +531,7 @@ func TestRunRecordMisuse(t *testing.T) {
args []string args []string
want string want string
}{ }{
{[]string{"--format", "yaml", "users"}, "--format takes text, json, csv or sql"}, {[]string{"--format", "yaml", "users"}, "--format takes csv, json, sql or text"},
{[]string{"--format", "json", "--separator", ",", "users"}, "--separator joins text values"}, {[]string{"--format", "json", "--separator", ",", "users"}, "--separator joins text values"},
{[]string{"--table", "t", "users"}, "--table names the INSERT target"}, {[]string{"--table", "t", "users"}, "--table names the INSERT target"},
{[]string{"--format", "json", "--table", "t", "users"}, "--table names the INSERT target"}, {[]string{"--format", "json", "--table", "t", "users"}, "--table names the INSERT target"},
@@ -554,6 +554,25 @@ func TestRunRecordOnABareValueIsRuntimeError(t *testing.T) {
} }
} }
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")
+7 -6
View File
@@ -267,17 +267,18 @@ 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, shared *draws, a arm) string { func readField(s *session, t *template, held, refScope *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 renderShared(s, t.fields[a.key], shared) return render(s, t.fields[a.key], refScope)
} }
// A reference shares its draw across a record's columns; a sibling is per expansion. // A reference reads the caller's scope, so its draw outlives this expansion; a
// sibling stays local to it.
d := held d := held
if isRef(a.key) && shared != nil { if isRef(a.key) && refScope != nil {
d = shared d = refScope
} }
if v, read := d.value[a.path]; read { if v, read := d.value[a.path]; read {
return v return v
@@ -298,7 +299,7 @@ func readField(s *session, t *template, held, shared *draws, a arm) string {
} }
return []node{n}, nil return []node{n}, nil
}, },
leaf: func(n node) error { v = renderShared(s, n, shared); return nil }, leaf: func(n node) error { v = render(s, n, refScope); return nil },
}) })
d.value[a.path] = v d.value[a.path] = v
return v return v
+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) return render(t.g.rand, t.n, nil)
} }
// 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
+1 -1
View File
@@ -59,7 +59,7 @@ func TestReadmeRecordExample(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
got := r.Fields() got := r.Columns()
if len(got) != 2 || got[0].Name != "first" || got[0].Value != "Bo" || got[1].Name != "last" || got[1].Value != "Lovelace" { 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) t.Fatalf("record columns = %v, want first=Bo, last=Lovelace with seed 1", got)
} }
+56 -54
View File
@@ -3,13 +3,13 @@ package fejkdata
import ( import (
"encoding/csv" "encoding/csv"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"sort"
"strings" "strings"
) )
// Field is one rendered column of a record. // Column is one rendered column of a record.
type Field struct { type Column struct {
Name string Name string
Value string Value string
} }
@@ -19,19 +19,19 @@ type Field struct {
// string a [Generator.Fake] call renders; a record is its inverse — each field // string a [Generator.Fake] call renders; a record is its inverse — each field
// projected as a column instead of composed. // projected as a column instead of composed.
type Record struct { type Record struct {
fields []Field columns []Column
} }
// Fields returns the record's columns in name order. // Columns returns the record's columns in name order.
func (r *Record) Fields() []Field { func (r *Record) Columns() []Column {
return append([]Field(nil), r.fields...) return append([]Column(nil), r.columns...)
} }
// JSON renders the record as one JSON object, every column a string. // JSON renders the record as one JSON object, every column a string.
func (r *Record) JSON() string { func (r *Record) JSON() string {
m := make(map[string]string, len(r.fields)) m := make(map[string]string, len(r.columns))
for _, f := range r.fields { for _, c := range r.columns {
m[f.Name] = f.Value m[c.Name] = c.Value
} }
b, _ := json.Marshal(m) b, _ := json.Marshal(m)
return string(b) return string(b)
@@ -48,17 +48,17 @@ func (r *Record) CSVLine() string {
} }
func (r *Record) names() []string { func (r *Record) names() []string {
out := make([]string, len(r.fields)) out := make([]string, len(r.columns))
for i, f := range r.fields { for i, c := range r.columns {
out[i] = f.Name out[i] = c.Name
} }
return out return out
} }
func (r *Record) values() []string { func (r *Record) values() []string {
out := make([]string, len(r.fields)) out := make([]string, len(r.columns))
for i, f := range r.fields { for i, c := range r.columns {
out[i] = f.Value out[i] = c.Value
} }
return out return out
} }
@@ -74,11 +74,11 @@ func csvLine(cols []string) string {
// SQLInsert renders the record as one INSERT statement into table: identifiers in // SQLInsert renders the record as one INSERT statement into table: identifiers in
// ANSI double quotes, every value a single-quoted string literal. // ANSI double quotes, every value a single-quoted string literal.
func (r *Record) SQLInsert(table string) string { func (r *Record) SQLInsert(table string) string {
cols := make([]string, len(r.fields)) cols := make([]string, len(r.columns))
vals := make([]string, len(r.fields)) vals := make([]string, len(r.columns))
for i, f := range r.fields { for i, c := range r.columns {
cols[i] = quoteIdent(f.Name) cols[i] = quoteIdent(c.Name)
vals[i] = "'" + strings.ReplaceAll(f.Value, "'", "''") + "'" vals[i] = "'" + strings.ReplaceAll(c.Value, "'", "''") + "'"
} }
return fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s);", quoteIdent(table), strings.Join(cols, ", "), strings.Join(vals, ", ")) return fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s);", quoteIdent(table), strings.Join(cols, ", "), strings.Join(vals, ", "))
} }
@@ -93,60 +93,46 @@ func quoteIdent(s string) string {
func (f *Generator) Record(path string) (*Record, error) { func (f *Generator) Record(path string) (*Record, error) {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
_, n, tail, err := resolveRef(f.categories, strings.Split(path, ".")) _, n, tail, err := resolveCategory(f.categories, strings.Split(path, "."))
if err != nil { if err != nil {
return nil, fmt.Errorf("fejkdata: %s: %w", path, err) return nil, fmt.Errorf("fejkdata: %s: %w", path, err)
} }
if len(tail) > 0 { 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]) return nil, fmt.Errorf("fejkdata: %s descends into %q, a field; only a category-level template is a record", path, tail[0])
} }
t, ok := n.(*template) t, err := recordOf(n)
if !ok { if err != nil {
return nil, fmt.Errorf("fejkdata: %s names a choice, not a template; only a category-level template is a record", path) return nil, fmt.Errorf("fejkdata: %s %w", path, err)
} }
r := renderRecord(f.rand, t) return renderRecord(f.rand, t), nil
if len(r.fields) == 0 {
return nil, fmt.Errorf("fejkdata: %s has no fields, so no columns", path)
}
return r, nil
} }
// RecordTemplate is an inline record compiled, referenced and validated once, // RecordTemplate is an inline record compiled, referenced and validated once,
// ready to render many times with [RecordTemplate.Fake]. // ready to render many times with [RecordTemplate.Fake].
type RecordTemplate struct { type RecordTemplate struct {
g *Generator g *Generator
n node t *template
} }
// Fake renders the record with one draw. // Fake renders the record with one draw.
func (t *RecordTemplate) Fake() *Record { func (t *RecordTemplate) Fake() *Record {
t.g.mu.Lock() t.g.mu.Lock()
defer t.g.mu.Unlock() defer t.g.mu.Unlock()
return renderRecord(t.g.rand, t.n.(*template)) return renderRecord(t.g.rand, t.t)
} }
// NewRecordTemplate compiles an inline record — a JSON object with a format and // NewRecordTemplate compiles an inline record — a JSON object with a format and
// fields — and binds its references against the loaded tree. // fields — and binds its references against the loaded tree.
func (f *Generator) NewRecordTemplate(input string) (*RecordTemplate, error) { func (f *Generator) NewRecordTemplate(input string) (*RecordTemplate, error) {
n, err := compileInput(input) t, err := f.NewTemplate(input)
if err != nil { if err != nil {
return nil, fmt.Errorf("fejkdata: %w", err) return nil, err
} }
t, ok := n.(*template) rt, err := recordOf(t.n)
if !ok { if err != nil {
return nil, fmt.Errorf("fejkdata: an inline record is a JSON object with a format and fields, not a choice") return nil, fmt.Errorf("fejkdata: an inline record %w", err)
} }
if len(recordColumns(t)) == 0 { return &RecordTemplate{g: f, t: rt}, nil
return nil, fmt.Errorf("fejkdata: an inline record needs at least one field to project as a column")
}
scope := inlineScope(t)
if err := linkNodeRefs(scope, f.categories); err != nil {
return nil, fmt.Errorf("fejkdata: %w", err)
}
if err := checkScope(scope); err != nil {
return nil, fmt.Errorf("fejkdata: %w", err)
}
return &RecordTemplate{g: f, n: t}, nil
} }
// FakeRecord compiles and renders an inline record in one call. // FakeRecord compiles and renders an inline record in one call.
@@ -158,15 +144,32 @@ func (f *Generator) FakeRecord(input string) (*Record, error) {
return t.Fake(), nil return t.Fake(), nil
} }
// recordOf is the fence both record entry points pass: the node is a
// category-level template, it carries no repeat — which composes the format
// rather than projecting columns — and it offers at least one column.
func recordOf(n node) (*template, error) {
t, ok := n.(*template)
if !ok {
return nil, errors.New("names a choice, not a template; only a category-level template is a record")
}
if t.repeat != 1 {
return 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)
}
if len(recordColumns(t)) == 0 {
return nil, errors.New("has no fields, so no columns")
}
return t, nil
}
// renderRecord projects a template's direct fields as columns, drawn once each, // renderRecord projects a template's direct fields as columns, drawn once each,
// in name order. A {/path} binding is a render edge, not a column, so it is // in name order. A {/path} binding is a render edge, not a column, so it is
// skipped the same way List and the graph do. The columns share one draw context, // skipped the same way List and the graph do. The columns share one reference
// so two columns that reference one category read one draw of it. // scope, so two columns that reference one category read one draw of it.
func renderRecord(s *session, t *template) *Record { func renderRecord(s *session, t *template) *Record {
shared := &draws{variant: map[string]node{}, value: map[string]string{}} scope := &draws{variant: map[string]node{}, value: map[string]string{}}
r := &Record{} r := &Record{}
for _, name := range recordColumns(t) { for _, name := range recordColumns(t) {
r.fields = append(r.fields, Field{Name: name, Value: renderShared(s, t.fields[name], shared)}) r.columns = append(r.columns, Column{Name: name, Value: render(s, t.fields[name], scope)})
} }
return r return r
} }
@@ -176,11 +179,10 @@ func renderRecord(s *session, t *template) *Record {
// name that is not a reference is a column. // name that is not a reference is a column.
func recordColumns(t *template) []string { func recordColumns(t *template) []string {
var names []string var names []string
for name := range t.fields { for _, name := range sortedNames(t.fields) {
if !isRef(name) { if !isRef(name) {
names = append(names, name) names = append(names, name)
} }
} }
sort.Strings(names)
return names return names
} }
+41 -16
View File
@@ -25,9 +25,9 @@ func TestRecordProjectsFieldsAsColumns(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
got := r.Fields() got := r.Columns()
if len(got) != 2 || got[0].Name != "first" || got[1].Name != "last" { if len(got) != 2 || got[0].Name != "first" || got[1].Name != "last" {
t.Fatalf("Record.Fields() = %v, want columns first, last in name order", got) 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") { 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) t.Fatalf("columns = %v, want the field values", got)
@@ -55,9 +55,9 @@ func TestRecordSkipsReferenceBindings(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
got := r.Fields() got := r.Columns()
if len(got) != 1 || got[0].Name != "id" { if len(got) != 1 || got[0].Name != "id" {
t.Fatalf("Record.Fields() = %v, want only the id column (a {/path} is not a column)", got) t.Fatalf("Record.Columns() = %v, want only the id column (a {/path} is not a column)", got)
} }
} }
@@ -96,7 +96,7 @@ func TestRecordJSON(t *testing.T) {
if len(m) != 2 || (m["first"] != "Ada" && m["first"] != "Bo") { if len(m) != 2 || (m["first"] != "Ada" && m["first"] != "Bo") {
t.Fatalf("Record.JSON() = %s, want two addressable columns", r.JSON()) t.Fatalf("Record.JSON() = %s, want two addressable columns", r.JSON())
} }
for _, f := range r.Fields() { for _, f := range r.Columns() {
if m[f.Name] != f.Value { if m[f.Name] != f.Value {
t.Fatalf("JSON column %q = %q, want %q", f.Name, m[f.Name], f.Value) t.Fatalf("JSON column %q = %q, want %q", f.Name, m[f.Name], f.Value)
} }
@@ -119,7 +119,7 @@ func TestRecordCSV(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("CSVLine() is not valid CSV: %v\n%q", err, r.CSVLine()) t.Fatalf("CSVLine() is not valid CSV: %v\n%q", err, r.CSVLine())
} }
if len(rec) != 1 || rec[0] != r.Fields()[0].Value { if len(rec) != 1 || rec[0] != r.Columns()[0].Value {
t.Fatalf("CSVLine() = %v, want the field value round-tripped", rec) t.Fatalf("CSVLine() = %v, want the field value round-tripped", rec)
} }
} }
@@ -164,7 +164,7 @@ func TestRecordSharesAReferenceAcrossColumns(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
m := map[string]string{} m := map[string]string{}
for _, c := range r.Fields() { for _, c := range r.Columns() {
m[c.Name] = c.Value m[c.Name] = c.Value
} }
switch m["code"] { switch m["code"] {
@@ -193,9 +193,9 @@ func TestRecordSharesAReferenceIntoAColumnRepeat(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
parts := strings.Split(r.Fields()[0].Value, "-") parts := strings.Split(r.Columns()[0].Value, "-")
if len(parts) != 3 || parts[0] != parts[1] || parts[1] != parts[2] { 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.Fields()[0].Value) t.Fatalf("codes column = %q, want one shared draw across its repeat", r.Columns()[0].Value)
} }
} }
} }
@@ -213,7 +213,7 @@ func TestRecordBareReferenceStaysIndependent(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
m := map[string]string{} m := map[string]string{}
for _, c := range r.Fields() { for _, c := range r.Columns() {
m[c.Name] = c.Value m[c.Name] = c.Value
} }
if m["whole"] != m["code"] { if m["whole"] != m["code"] {
@@ -248,16 +248,16 @@ func TestFakeRecordAndTemplate(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("FakeRecord: %v", err) t.Fatalf("FakeRecord: %v", err)
} }
if len(want.Fields()) != 2 { if len(want.Columns()) != 2 {
t.Fatalf("FakeRecord fields = %v, want two columns", want.Fields()) t.Fatalf("FakeRecord columns = %v, want two columns", want.Columns())
} }
reusable, err := f.NewRecordTemplate(in) reusable, err := f.NewRecordTemplate(in)
if err != nil { if err != nil {
t.Fatalf("NewRecordTemplate: %v", err) t.Fatalf("NewRecordTemplate: %v", err)
} }
for i := 0; i < 20; i++ { for i := 0; i < 20; i++ {
if got := reusable.Fake(); len(got.Fields()) != 2 { if got := reusable.Fake(); len(got.Columns()) != 2 {
t.Fatalf("RecordTemplate.Fake() = %v, want two columns", got.Fields()) t.Fatalf("RecordTemplate.Fake() = %v, want two columns", got.Columns())
} }
} }
} }
@@ -268,11 +268,36 @@ func TestInlineRecordErrors(t *testing.T) {
input string input string
want string want string
}{ }{
{`"hello"`, "needs at least one field"}, {`"hello"`, "has no fields, so no columns"},
{`["a","b"]`, "a choice"}, {`["a","b"]`, "names a choice, not a template"},
{`{"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) { 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) 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 := resolveRef(root, segments) head, target, tail, err := resolveCategory(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)
} }
// resolveRef walks a reference path through the folders to the category it names, // resolveCategory walks a dotted path through the folders to the category it
// returning that head, the node, and the tail left to read into it. A descent of // names, returning that head, the node, and the tail left to read into it. A
// its own rather than a walkPath: it walks groups only and returns where they end, // descent of its own rather than a walkPath: it walks groups only and returns
// not a leaf. // where they end, not a leaf.
func resolveRef(root map[string]node, segments []string) (head []string, target node, tail []string, err error) { func resolveCategory(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++ {
+12 -17
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 return render(f.rand, n, nil), 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,23 +50,18 @@ func descend(s *session, root node, segments []string) (node, error) {
} }
// render evaluates a compiled node to a string. compile validates every node up // render evaluates a compiled node to a string. compile validates every node up
// front, so rendering a compiled tree cannot fail. // front, so rendering a compiled tree cannot fail. refScope carries the draws a
func render(s *session, n node) string { // reference shares beyond its own expansion; nil keeps every reference local.
return renderShared(s, n, nil) func render(s *session, n node, refScope *draws) string {
}
// renderShared is render with a shared draw context: the draws a record shares
// across its columns; a nil shared is a standalone render.
func renderShared(s *session, n node, shared *draws) string {
switch n := n.(type) { switch n := n.(type) {
case *choice: case *choice:
return renderShared(s, pick(s, n), shared) return render(s, pick(s, n), refScope)
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, shared) return expand(s, n, refScope)
} }
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)))
@@ -74,7 +69,7 @@ func renderShared(s *session, n node, shared *draws) string {
if i > 0 { if i > 0 {
b.WriteString(n.separator) b.WriteString(n.separator)
} }
b.WriteString(expand(s, n, shared)) b.WriteString(expand(s, n, refScope))
} }
return b.String() return b.String()
default: default:
@@ -96,12 +91,12 @@ 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, shared *draws) string { func expand(s *session, t *template, refScope *draws) 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 shared // repeat iteration get their own, since each is its own expansion. A reference
// draw context, when a record supplies one, overrides that for references. // 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{
@@ -115,7 +110,7 @@ func expand(s *session, t *template, shared *draws) string {
case 'l': case 'l':
b.WriteString(o.lit) b.WriteString(o.lit)
case 'f': case 'f':
b.WriteString(readField(s, t, held, shared, o.arms[s.IntN(len(o.arms))])) b.WriteString(readField(s, t, held, refScope, 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.
@@ -123,7 +118,7 @@ func expand(s *session, t *template, shared *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, shared, a) operands[j] = readField(s, t, held, refScope, 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
+1 -1
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)) return render(f.rand, compiled(t, s), nil)
} }
func TestStringIsAFormat(t *testing.T) { func TestStringIsAFormat(t *testing.T) {