diff --git a/CHANGELOG.md b/CHANGELOG.md index cd33d25..032d4f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/README.md b/README.md index 8d6f6a7..67c891c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/fejkdata/main.go b/cmd/fejkdata/main.go index 0e161fe..3726e3f 100644 --- a/cmd/fejkdata/main.go +++ b/cmd/fejkdata/main.go @@ -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) { diff --git a/inline.go b/inline.go index c9f4857..7db50e2 100644 --- a/inline.go +++ b/inline.go @@ -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 } diff --git a/internal/builtinfunc/layout.go b/internal/builtinfunc/layout.go index 2765354..328deb0 100644 --- a/internal/builtinfunc/layout.go +++ b/internal/builtinfunc/layout.go @@ -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 { diff --git a/path.go b/path.go index 9e70a19..37666af 100644 --- a/path.go +++ b/path.go @@ -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 diff --git a/record.go b/record.go index 5f720ba..78ded03 100644 --- a/record.go +++ b/record.go @@ -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} diff --git a/reference.go b/reference.go index 7b9452b..b9a351c 100644 --- a/reference.go +++ b/reference.go @@ -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 +} diff --git a/render.go b/render.go index 28d28fa..6d37f85 100644 --- a/render.go +++ b/render.go @@ -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)