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.
- 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`
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
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
@@ -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
`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
`--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,
where it used to be an empty string, so `--format sql` writes `NULL` and a `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
```
fejkdata renders the template on its stdin: a format string, or a JSON object, array or
string. Its `{…}` tokens reach the data by reference from the root,
fejkdata renders the template on its stdin: a format string, or any JSON value. Its `{…}` tokens reach the data by reference from the root,
`{/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
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
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
any JSON value, and the whitespace around it is dropped; `42` or `true` alone renders its
text, and `null` alone renders nothing. An empty or blank stdin is a template too, and
sends none; `-n` joins renders with `--separator`, empty by default. A JSON object, array
or string drops the whitespace around it, and `null` alone renders nothing; `42` or `true`
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
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
@@ -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 |
| `--no-shipped-data` | load only the `--data-path` directories |
| `-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` |
| `--separator S` | between repeated renders (default empty); a record `--format` ignores it |
| `-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); 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) |
| `--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 |
@@ -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
layout prints `UTC` or `Z`.
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
field as 1970-01-01, and `date` over one day with no clock field prints that day.
argument stays a name. A layout naming no field prints itself, `date` with a clock-only
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
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
+54 -31
View File
@@ -35,7 +35,7 @@ fejkdata <<'EOF'
born {date(1990-01-01,2010-12-31,'2006-01-02')}
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
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
@@ -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
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
template is any JSON value, and the whitespace around it is dropped; 42 or true alone
renders its text, and null nothing. An empty or blank stdin renders as it is. A
object, array or string drops the whitespace around it, and null alone renders nothing;
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.
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;
--repeat, --separator, --format and --table do nothing here
--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
--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
(default: a lone reference's last segment, else records)
--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 {
args []string
dirs []string
format string
help bool
list bool
noShipped bool
repeat int
seed uint64
seeded bool
separator string
table string
tableSet bool
version bool
args []string
dirs []string
format string
help bool
list bool
noShipped bool
repeat int
seed uint64
seeded bool
separator string
separatorSet bool
table string
tableSet bool
version bool
}
type flagDef struct {
@@ -117,7 +121,7 @@ var flagDefs = []flagDef{
in.seed, in.seeded = n, true
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 }},
{"version", "", false, func(in *invocation, _ string) error { in.version = true; return nil }},
}
@@ -233,15 +237,16 @@ type recordFormat struct {
open string
close 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
// as one array document; ndjson writes 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"},
"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", writes: "writes the records as one JSON array"},
"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", writes: "writes one INSERT per line"},
}
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" {
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
}
@@ -400,16 +408,19 @@ func (in invocation) writeRecords(f *fejkdata.Generator, src input, w io.Writer)
return out.Flush()
}
// writeNoRecord writes zero records: an empty document where the format frames one, else nothing. It
// draws one record and drops it, so a lone reference to nothing fails as it would with records.
// writeNoRecord writes zero records: the header where the format writes one, an empty document
// 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 {
if _, err := record(); err != nil {
r, err := record()
switch {
case err != nil:
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
}
@@ -595,7 +606,7 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return 0
}
if err := in.write(f, src, stdout); err != nil {
return fail(stderr, err)
return fail(stderr, in.noDataCause(err))
}
return 0
}
@@ -611,6 +622,18 @@ func readStdin(stdin io.Reader) (input, error) {
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 {
var te inputError
if errors.As(err, &te) {
+9 -3
View File
@@ -1,6 +1,7 @@
package fejkdata
import (
"encoding/json"
"fmt"
"github.com/larvit/fejkdata/internal/grammar"
@@ -36,7 +37,7 @@ func (f *Generator) NewTemplate(input string) (*Template, error) {
f.mu.Lock()
defer f.mu.Unlock()
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
}
@@ -68,13 +69,18 @@ func compileInput(input string) (node, error) {
return compile(inputValue(input))
}
// inputValue reads an inline template as the value compile takes: the JSON value it holds, the
// whitespace around it dropped, or the input itself as a format string when it is not JSON.
// inputValue reads an inline template as the value compile takes: the JSON object, array, string
// 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 {
raw, err := jsonvalue.Decode([]byte(input))
if err != nil {
return jsonvalue.String(input)
}
switch raw.Any().(type) {
case json.Number, bool:
return jsonvalue.String(input)
}
return raw
}
+10 -1
View File
@@ -83,10 +83,19 @@ func timeArg(a []string) error {
if err := layoutArity("time", 1, a); err != nil {
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
}
// 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
// in seconds, since a Duration overflows past 292 years.
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 {
return child, nil
}
return nil, fmt.Errorf("no entry %q", seg)
return nil, noEntry{seg}
case *template:
if child, ok := n.fields[seg]; ok {
return child, nil
+1 -1
View File
@@ -136,7 +136,7 @@ func (f *Generator) FakeRecord(path string) (*Record, error) {
f.loadShippedAt(segments)
_, n, tail, err := resolveCategory(f.root.children, segments)
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
env := renderEnv{frames: &frames}
+17 -1
View File
@@ -1,6 +1,7 @@
package fejkdata
import (
"errors"
"fmt"
"strings"
@@ -77,7 +78,7 @@ func resolveCategory(root map[string]node, segments []string) (categorySegs []st
}
n, ok := g.children[segments[i]]
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
}
@@ -114,3 +115,18 @@ func refTokens(toks []grammar.Token) []string {
}
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)
n, pins, err := drawCallerPath(f.drawState, &f.root, segments)
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 {
return "", fmt.Errorf("fejkdata: %s names a folder, not a value", path)