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
+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) {