Print the CSV header at -n 0, refuse --separator with a record format, refuse a date field in time(), read a bare number template as a format string, and say when no data is loaded

This commit is contained in:
2026-10-08 12:24:47 +02:00
committed by Lillem4n
parent 332b99ad88
commit 357a2a276d
9 changed files with 106 additions and 50 deletions
+3 -2
View File
@@ -29,7 +29,8 @@ replacement, and each removed path, column or flag.
- First release: the CLI, the library and the shipped data set. - First release: the CLI, the library and the shipped data set.
- The CLI renders exactly the template on its stdin, `echo '{/sv_SE.person} hihi' | - The CLI renders exactly the template on its stdin, `echo '{/sv_SE.person} hihi' |
fejkdata`, and adds nothing under `--format text`: `echo`'s newline ends the render, and `--separator` fejkdata`, and adds nothing under `--format text`: `echo`'s newline ends the render, and `--separator`
defaults to empty. Whitespace around a JSON template is dropped. A lone reference, defaults to empty. Whitespace around a JSON object, array or string template is
dropped, and a bare number or boolean is a format string, printed as written. A lone reference,
`{/users}`, is the record `users` under `--format`. An argument other than a flag, or `{/users}`, is the record `users` under `--format`. An argument other than a flag, or
nothing piped in, exits 2; an empty or blank stdin is a template, and renders as it is. nothing piped in, exits 2; an empty or blank stdin is a template, and renders as it is.
- Table categories: a category JSON naming a `rows` TSV beside it, with the options - Table categories: a category JSON naming a `rows` TSV beside it, with the options
@@ -100,7 +101,7 @@ replacement, and each removed path, column or flag.
from a `sv_SE.birth-number` table under `sex`, in place of `sv_SE.ssn`, whose from a `sv_SE.birth-number` table under `sex`, in place of `sv_SE.ssn`, whose
`ssn.mmdd`, `ssn.mmdd.m` and `ssn.mmdd.d` go with it. `en_US.ssn` now draws the `ssn.mmdd`, `ssn.mmdd.m` and `ssn.mmdd.d` go with it. `en_US.ssn` now draws the
ranges the SSA assigns and carries the columns `area`, `group` and `serial`, so ranges the SSA assigns and carries the columns `area`, `group` and `serial`, so
`--format csv en_US.ssn` writes a header where it used to fail; `en_US.itin` is new `echo '{/en_US.ssn}' | fejkdata --format csv` writes a header where it used to fail; `en_US.itin` is new
and carries the same three. `person.prefix` is null where a person has no title, and carries the same three. `person.prefix` is null where a person has no title,
where it used to be an empty string, so `--format sql` writes `NULL` and a `string` where it used to be an empty string, so `--format sql` writes `NULL` and a `string`
struct field reading it becomes `*string`. struct field reading it becomes `*string`.
+10 -9
View File
@@ -30,8 +30,7 @@ born {date(1990-01-01,2010-12-31,'2006-01-02')}
EOF EOF
``` ```
fejkdata renders the template on its stdin: a format string, or a JSON object, array or fejkdata renders the template on its stdin: a format string, or any JSON value. Its `{…}` tokens reach the data by reference from the root,
string. Its `{…}` tokens reach the data by reference from the root,
`{/sv_SE.person.last}`, so shipped and `--data-path` categories are alike available, `{/sv_SE.person.last}`, so shipped and `--data-path` categories are alike available,
and everything outside them prints as written. A reference names a category, or a field and everything outside them prints as written. A reference names a category, or a field
inside one: each dot segment descends one level — folders, then the category (a JSON inside one: each dot segment descends one level — folders, then the category (a JSON
@@ -40,9 +39,9 @@ these paths; a template reads one as `{/path}`.
Under `--format text`, the default, what you send is what renders, and fejkdata adds nothing. A format string keeps every Under `--format text`, the default, what you send is what renders, and fejkdata adds nothing. A format string keeps every
byte, so `echo`, a file or a heredoc ends each render with its newline, and `printf '%s'` byte, so `echo`, a file or a heredoc ends each render with its newline, and `printf '%s'`
sends none; `-n` joins renders with `--separator`, empty by default. A JSON template is sends none; `-n` joins renders with `--separator`, empty by default. A JSON object, array
any JSON value, and the whitespace around it is dropped; `42` or `true` alone renders its or string drops the whitespace around it, and `null` alone renders nothing; `42` or `true`
text, and `null` alone renders nothing. An empty or blank stdin is a template too, and alone is a format string, so it prints as written, newline included. An empty or blank stdin is a template too, and
renders as it is. A JSON template that should end renders as it is. A JSON template that should end
each render in a newline writes `\n` in its format, from a quoted heredoc or a file. A each render in a newline writes `\n` in its format, from a quoted heredoc or a file. A
lone reference, one `{/…}` and nothing else, is the record it names under `--format`, one lone reference, one `{/…}` and nothing else, is the record it names under `--format`, one
@@ -55,8 +54,8 @@ template holding one from a quoted heredoc or a file. The rest of stdin's rules
| `-d`, `--data-path D` | a directory to layer over the shipped data; repeatable, the last wins a name clash | | `-d`, `--data-path D` | a directory to layer over the shipped data; repeatable, the last wins a name clash |
| `--no-shipped-data` | load only the `--data-path` directories | | `--no-shipped-data` | load only the `--data-path` directories |
| `-s`, `--seed N` | same seed, version and data: identical output | | `-s`, `--seed N` | same seed, version and data: identical output |
| `-n`, `--repeat N` | render the template N times (0 to 1048576), each an independent draw, streamed; `0` prints nothing, or `[]` under `--format json` | | `-n`, `--repeat N` | render the template N times (0 to 1048576), each an independent draw, streamed; `0` prints nothing, `[]` under `--format json`, or the header under `--format csv` |
| `--separator S` | between repeated renders (default empty); a record `--format` ignores it | | `--separator S` | between repeated renders (default empty); refused with a record `--format`, which has its own separators |
| `--format F` | `text` (default), `json`, `ndjson`, `csv` or `sql` — a record's columns, one record per row (json frames them as an array) | | `--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`, ignored under any other (default: a lone reference's last segment, such as `users` for `{/users}`, else `records`) | | `--table T` | the INSERT target for `--format sql`, ignored under any other (default: a lone reference's last segment, such as `users` for `{/users}`, else `records`) |
| `--list` | print every path, then exit; `--repeat`, `--separator`, `--format` and `--table` do nothing here | | `--list` | print every path, then exit; `--repeat`, `--separator`, `--format` and `--table` do nothing here |
@@ -678,8 +677,10 @@ between the two days is reachable, so a layout with a clock draws the time too,
`from` may equal `to`, which is that one day. The instant is UTC, so a zone in the `from` may equal `to`, which is that one day. The instant is UTC, so a zone in the
layout prints `UTC` or `Z`. layout prints `UTC` or `Z`.
The quotes delimit a layout outside a selector only, so `[O'Fallon]` in an The quotes delimit a layout outside a selector only, so `[O'Fallon]` in an
argument stays a name. A layout naming no field prints itself, `time` prints a date argument stays a name. A layout naming no field prints itself, `date` with a clock-only
field as 1970-01-01, and `date` over one day with no clock field prints that day. layout prints the drawn instant's clock, and `date` over one day with no clock field prints
that day; `time` with a layout naming a date field is refused, naming `date`, since `time`
draws no date.
Rejected at `New`: a bound that is no calendar date, or after the other, and an unquoted Rejected at `New`: a bound that is no calendar date, or after the other, and an unquoted
or double-quoted layout, naming the single-quoted one. `{seq()}` spans `Fake` or double-quoted layout, naming the single-quoted one. `{seq()}` spans `Fake`
calls and `repeat`, resets with a new generator, and is the natural primary key for calls and `repeat`, resets with a new generator, and is the natural primary key for
+54 -31
View File
@@ -35,7 +35,7 @@ fejkdata <<'EOF'
born {date(1990-01-01,2010-12-31,'2006-01-02')} born {date(1990-01-01,2010-12-31,'2006-01-02')}
EOF EOF
A template is a format string, or a JSON object, array or string. Its {…} tokens A template is a format string, or any JSON value. Its {…} tokens
reach the data by reference from the root, {/sv_SE.person.last}, whether the data reach the data by reference from the root, {/sv_SE.person.last}, whether the data
is shipped or layered with --data-path. A reference names a category or a dotted is shipped or layered with --data-path. A reference names a category or a dotted
path into one ({/sv_SE.person.last}), a table's row by key or name path into one ({/sv_SE.person.last}), a table's row by key or name
@@ -45,8 +45,9 @@ path into one ({/sv_SE.person.last}), a table's row by key or name
Under --format text, the default, what you send is what renders, and fejkdata adds Under --format text, the default, what you send is what renders, and fejkdata adds
nothing: a format string keeps every byte, so echo's newline ends each render, and nothing: a format string keeps every byte, so echo's newline ends each render, and
printf '%s' sends none. -n joins renders with --separator, empty by default. A JSON printf '%s' sends none. -n joins renders with --separator, empty by default. A JSON
template is any JSON value, and the whitespace around it is dropped; 42 or true alone object, array or string drops the whitespace around it, and null alone renders nothing;
renders its text, and null nothing. An empty or blank stdin renders as it is. A 42 or true alone is a format string, printed as written. An empty or blank stdin
renders as it is. A
quoted heredoc, <<'EOF', passes $, a backslash or a quote as written. quoted heredoc, <<'EOF', passes $, a backslash or a quote as written.
With --format json, ndjson, csv or sql the template must be a record — a JSON With --format json, ndjson, csv or sql the template must be a record — a JSON
@@ -61,9 +62,11 @@ one INSERT.
--list list the paths the data offers, each written after {/ in a template, then exit; --list list the paths the data offers, each written after {/ in a template, then exit;
--repeat, --separator, --format and --table do nothing here --repeat, --separator, --format and --table do nothing here
--no-shipped-data load only the --data-path directories --no-shipped-data load only the --data-path directories
-n, --repeat N render the template N times, 0..1048576 (default 1) -n, --repeat N render the template N times, 0..1048576 (default 1); 0 prints nothing,
[] under --format json, or the header under --format csv
-s, --seed N same seed, version and data: identical output -s, --seed N same seed, version and data: identical output
--separator S string between repeated renders (default empty); a record --format ignores it --separator S string between repeated renders (default empty); refused with a record
--format, which has its own separators
--table T the INSERT target for --format sql, ignored under any other --table T the INSERT target for --format sql, ignored under any other
(default: a lone reference's last segment, else records) (default: a lone reference's last segment, else records)
--version print the version, then exit --version print the version, then exit
@@ -73,19 +76,20 @@ A short flag's value attaches or follows (-n3, -n 3); short flags bundle (-hn 3)
` `
type invocation struct { type invocation struct {
args []string args []string
dirs []string dirs []string
format string format string
help bool help bool
list bool list bool
noShipped bool noShipped bool
repeat int repeat int
seed uint64 seed uint64
seeded bool seeded bool
separator string separator string
table string separatorSet bool
tableSet bool table string
version bool tableSet bool
version bool
} }
type flagDef struct { type flagDef struct {
@@ -117,7 +121,7 @@ var flagDefs = []flagDef{
in.seed, in.seeded = n, true in.seed, in.seeded = n, true
return nil return nil
}}, }},
{"separator", "", true, func(in *invocation, v string) error { in.separator = v; 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 }}, {"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 }},
} }
@@ -233,15 +237,16 @@ type recordFormat struct {
open string open string
close string close string
sep string sep string
writes string // what it writes between values, which --separator would seem to set
} }
// recordFormats is every --format that writes records. json frames the records // recordFormats is every --format that writes records. json frames the records
// as one array document; ndjson writes one object per line. // as one array document; ndjson writes one object per line.
var recordFormats = map[string]recordFormat{ var recordFormats = map[string]recordFormat{
"csv": {header: (*fejkdata.Record).CSVHeader, line: func(r *fejkdata.Record, _ string) string { return r.CSVLine() }, sep: "\n"}, "csv": {header: (*fejkdata.Record).CSVHeader, line: func(r *fejkdata.Record, _ string) string { return r.CSVLine() }, sep: "\n", writes: `always separates fields with ","`},
"json": {line: jsonLine, open: "[", close: "]", sep: ",\n"}, "json": {line: jsonLine, open: "[", close: "]", sep: ",\n", writes: "writes the records as one JSON array"},
"ndjson": {line: jsonLine, sep: "\n"}, "ndjson": {line: jsonLine, sep: "\n", writes: "writes one record per line"},
"sql": {line: func(r *fejkdata.Record, table string) string { return r.SQLInsert(table) }, sep: "\n"}, "sql": {line: func(r *fejkdata.Record, table string) string { return r.SQLInsert(table) }, sep: "\n", writes: "writes one INSERT per line"},
} }
func jsonLine(r *fejkdata.Record, _ string) string { return r.JSON() } func jsonLine(r *fejkdata.Record, _ string) string { return r.JSON() }
@@ -268,6 +273,9 @@ func (in invocation) checkFlags() error {
if in.tableSet && in.table == "" && in.format == "sql" { if in.tableSet && in.table == "" && in.format == "sql" {
return errors.New("--table names the INSERT target, so it cannot be empty") return errors.New("--table names the INSERT target, so it cannot be empty")
} }
if f, records := recordFormats[in.format]; records && in.separatorSet && !in.list {
return fmt.Errorf("--separator joins text renders; --format %s %s", in.format, f.writes)
}
return nil return nil
} }
@@ -400,16 +408,19 @@ func (in invocation) writeRecords(f *fejkdata.Generator, src input, w io.Writer)
return out.Flush() return out.Flush()
} }
// writeNoRecord writes zero records: an empty document where the format frames one, else nothing. It // writeNoRecord writes zero records: the header where the format writes one, an empty document
// draws one record and drops it, so a lone reference to nothing fails as it would with records. // where it frames one, else nothing. It draws one record, which names the header's columns, so a
// lone reference to nothing fails as it would with records.
func writeNoRecord(record func() (*fejkdata.Record, error), format recordFormat, w io.Writer) error { func writeNoRecord(record func() (*fejkdata.Record, error), format recordFormat, w io.Writer) error {
if _, err := record(); err != nil { r, err := record()
switch {
case err != nil:
return err return err
case format.header != nil:
_, err = io.WriteString(w, format.header(r)+"\n")
case format.open != "":
_, err = io.WriteString(w, format.open+format.close+"\n")
} }
if format.open == "" {
return nil
}
_, err := io.WriteString(w, format.open+format.close+"\n")
return err return err
} }
@@ -595,7 +606,7 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return 0 return 0
} }
if err := in.write(f, src, stdout); err != nil { if err := in.write(f, src, stdout); err != nil {
return fail(stderr, err) return fail(stderr, in.noDataCause(err))
} }
return 0 return 0
} }
@@ -611,6 +622,18 @@ func readStdin(stdin io.Reader) (input, error) {
return parseInput(string(raw)), nil return parseInput(string(raw)), nil
} }
// noDataCause adds the flags that left the generator without data to an error saying none is
// loaded.
func (in invocation) noDataCause(err error) error {
if !in.noShipped || len(in.dirs) > 0 || !strings.HasSuffix(err.Error(), "no data is loaded") {
return err
}
if te, isInput := err.(inputError); isInput {
return inputError{fmt.Errorf("%w: --no-shipped-data and no --data-path", te.error)}
}
return fmt.Errorf("%w: --no-shipped-data and no --data-path", err)
}
func fail(stderr io.Writer, err error) int { func fail(stderr io.Writer, err error) int {
var te inputError var te inputError
if errors.As(err, &te) { if errors.As(err, &te) {
+9 -3
View File
@@ -1,6 +1,7 @@
package fejkdata package fejkdata
import ( import (
"encoding/json"
"fmt" "fmt"
"github.com/larvit/fejkdata/internal/grammar" "github.com/larvit/fejkdata/internal/grammar"
@@ -36,7 +37,7 @@ func (f *Generator) NewTemplate(input string) (*Template, error) {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
if err := loadInline(&f.root, n, "template", false); err != nil { if err := loadInline(&f.root, n, "template", false); err != nil {
return nil, fmt.Errorf("fejkdata: %w", err) return nil, f.noDataNote(fmt.Errorf("fejkdata: %w", err))
} }
return &Template{g: f, n: n}, nil return &Template{g: f, n: n}, nil
} }
@@ -68,13 +69,18 @@ func compileInput(input string) (node, error) {
return compile(inputValue(input)) return compile(inputValue(input))
} }
// inputValue reads an inline template as the value compile takes: the JSON value it holds, the // inputValue reads an inline template as the value compile takes: the JSON object, array, string
// whitespace around it dropped, or the input itself as a format string when it is not JSON. // or null it holds, the whitespace around it dropped, or else the input itself as a format string,
// a bare number or boolean among them.
func inputValue(input string) jsonvalue.Value { func inputValue(input string) jsonvalue.Value {
raw, err := jsonvalue.Decode([]byte(input)) raw, err := jsonvalue.Decode([]byte(input))
if err != nil { if err != nil {
return jsonvalue.String(input) return jsonvalue.String(input)
} }
switch raw.Any().(type) {
case json.Number, bool:
return jsonvalue.String(input)
}
return raw return raw
} }
+10 -1
View File
@@ -83,10 +83,19 @@ func timeArg(a []string) error {
if err := layoutArity("time", 1, a); err != nil { if err := layoutArity("time", 1, a); err != nil {
return err return err
} }
_, err := layoutArg(a[0]) layout, err := layoutArg(a[0])
if err == nil && namesADateField(layout) {
return fmt.Errorf("time(layout): '%s' names a date field, and time draws no date; write date(from,to,layout)", layout)
}
return err return err
} }
// namesADateField reports a layout that prints two instants differing in their date alone apart.
func namesADateField(layout string) bool {
day, nextYear := time.Date(2001, 2, 3, 4, 5, 6, 0, time.UTC), time.Date(2010, 11, 12, 4, 5, 6, 0, time.UTC)
return day.Format(layout) != nextYear.Format(layout)
}
// datePrep draws a second in [from 00:00:00, to 23:59:59] UTC; the span is counted // datePrep draws a second in [from 00:00:00, to 23:59:59] UTC; the span is counted
// in seconds, since a Duration overflows past 292 years. // in seconds, since a Duration overflows past 292 years.
func datePrep(a []string) Call { func datePrep(a []string) Call {
+1 -1
View File
@@ -25,7 +25,7 @@ func childNamed(n node, seg string) (node, error) {
if child, ok := n.children[seg]; ok { if child, ok := n.children[seg]; ok {
return child, nil return child, nil
} }
return nil, fmt.Errorf("no entry %q", seg) return nil, noEntry{seg}
case *template: case *template:
if child, ok := n.fields[seg]; ok { if child, ok := n.fields[seg]; ok {
return child, nil return child, nil
+1 -1
View File
@@ -136,7 +136,7 @@ func (f *Generator) FakeRecord(path string) (*Record, error) {
f.loadShippedAt(segments) f.loadShippedAt(segments)
_, n, tail, err := resolveCategory(f.root.children, segments) _, n, tail, err := resolveCategory(f.root.children, segments)
if err != nil { if err != nil {
return nil, fmt.Errorf("fejkdata: %s: %w", path, err) return nil, f.noDataNote(fmt.Errorf("fejkdata: %s: %w", path, err))
} }
var frames frameStack var frames frameStack
env := renderEnv{frames: &frames} env := renderEnv{frames: &frames}
+17 -1
View File
@@ -1,6 +1,7 @@
package fejkdata package fejkdata
import ( import (
"errors"
"fmt" "fmt"
"strings" "strings"
@@ -77,7 +78,7 @@ func resolveCategory(root map[string]node, segments []string) (categorySegs []st
} }
n, ok := g.children[segments[i]] n, ok := g.children[segments[i]]
if !ok { if !ok {
return nil, nil, nil, fmt.Errorf("no entry %q", segments[i]) return nil, nil, nil, noEntry{segments[i]}
} }
return segments[:i+1], n, segments[i+1:], nil return segments[:i+1], n, segments[i+1:], nil
} }
@@ -114,3 +115,18 @@ func refTokens(toks []grammar.Token) []string {
} }
return refs return refs
} }
// noEntry is a path segment naming nothing in the folder it reads.
type noEntry struct{ seg string }
func (e noEntry) Error() string { return fmt.Sprintf("no entry %q", e.seg) }
// noDataNote adds to err, where a segment names nothing and f holds no data at all, that no
// data is loaded.
func (f *Generator) noDataNote(err error) error {
var ne noEntry
if errors.As(err, &ne) && len(f.root.children) == 0 && len(f.root.unloaded) == 0 {
return fmt.Errorf("%w; no data is loaded", err)
}
return err
}
+1 -1
View File
@@ -27,7 +27,7 @@ func (f *Generator) Fake(path string) (string, error) {
f.loadShippedAt(segments) f.loadShippedAt(segments)
n, pins, err := drawCallerPath(f.drawState, &f.root, segments) n, pins, err := drawCallerPath(f.drawState, &f.root, segments)
if err != nil { if err != nil {
return "", fmt.Errorf("fejkdata: %s: %w", path, err) return "", f.noDataNote(fmt.Errorf("fejkdata: %s: %w", path, err))
} }
if _, ok := n.(*folder); ok { if _, ok := n.(*folder); 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)