Load a default written out in every position, refuse null outside a column again, read a field once per alternation, refuse an empty segment in the CLI's lone reference, and hold a declared datatype as one value

This commit is contained in:
2026-10-07 23:20:59 +02:00
committed by Lillem4n
parent 49736bd2b6
commit d8acc9f3f3
8 changed files with 104 additions and 71 deletions
+17 -13
View File
@@ -120,8 +120,8 @@ echo '{/users}' | fejkdata --seed 1 -d ./mydata --format sql --table people # IN
`--repeat` streams that many records — `json` frames them as one array document, `--repeat` streams that many records — `json` frames them as one array document,
`ndjson` writes one object per line, `csv` a row after a header, `sql` one INSERT `ndjson` writes one object per line, `csv` a row after a header, `sql` one INSERT
per line. Only a category-level template is a record; a field, choice or folder per line. Only a category-level template is a record, or a one-item choice of one; a
errors, and so does a `repeat` on field, a choice of more items or a folder errors, and so does a `repeat` on
the template itself, which composes the format into one string rather than the template itself, which composes the format into one string rather than
projecting columns — ask for more records with `--repeat`. A `repeat` on a column projecting columns — ask for more records with `--repeat`. A `repeat` on a column
is fine. is fine.
@@ -342,7 +342,8 @@ A `*Record` carries its columns via `Columns()` — each a `Column` of `Name`,
`DataType`, rendered `Value` and `Null` — and serializes them with `JSON()` `DataType`, rendered `Value` and `Null` — and serializes them with `JSON()`
(one object), `CSVHeader()`/`CSVLine()`, or `SQLInsert(table)` — the shapes the (one object), `CSVHeader()`/`CSVLine()`, or `SQLInsert(table)` — the shapes the
CLI's `--format` writes. `FakeRecord` and `FakeRecordTemplate` take a record; a CLI's `--format` writes. `FakeRecord` and `FakeRecordTemplate` 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 of more than one item, or a
folder — errors.
```go ```go
type User struct { type User struct {
@@ -369,7 +370,8 @@ its own. `fake:"-"` leaves a field unfilled.
Untagged fields keep their values, and so does a pointer back to a struct already being filled; a type Untagged fields keep their values, and so does a pointer back to a struct already being filled; a type
whose fields reach more than 1024 structs is refused, naming `fake:"-"` to cut it. The whose fields reach more than 1024 structs is refused, naming `fake:"-"` to cut it. The
first call for a type compiles its tags and reports what they get wrong, with the same first call for a type compiles its tags and reports what they get wrong, with the same
error on every later call; a `datatype` in a tag names the Go type that already sets it. error on every later call; a `datatype` in a tag other than the one its Go type sets is
refused, naming that type.
A `*Generator` is safe for concurrent use; a seeded sequence is reproducible only A `*Generator` is safe for concurrent use; a seeded sequence is reproducible only
when drawn from one goroutine. Changing how a value is composed shifts the seeded when drawn from one goroutine. Changing how a value is composed shifts the seeded
@@ -427,8 +429,8 @@ An item of a choice may carry a `weight` (default `1`) to skew its odds:
Renders `070-412 38 91` ten times as often as `08-…`. A string item is weighted by Renders `070-412 38 91` ten times as often as `08-…`. A string item is weighted by
writing it as `{ "format": "AB", "weight": 3 }`. A repeated item counts as written, writing it as `{ "format": "AB", "weight": 3 }`. A repeated item counts as written,
so `["a", "a", "b"]` draws `a` two times in three. Rejected at load: a weight that is so `["a", "a", "b"]` draws `a` two times in three. Rejected at load: a weight that is
negative, non-numeric or `0` (never drawn — remove the item), and one where it does negative, non-numeric or `0` (never drawn — remove the item), and one other than `1`
nothing: outside a choice, or on the item of a one-item choice. where nothing is drawn against it: outside a choice, or on the item of a one-item choice.
### Repeat ### Repeat
@@ -439,8 +441,8 @@ that many times — each an independent draw — joined by `separator` (default
{ "format": "{word}", "repeat": 3, "separator": " ", "word": ["foo", "bar", "baz"] } { "format": "{word}", "repeat": 3, "separator": " ", "word": ["foo", "bar", "baz"] }
``` ```
Renders e.g. `bar foo baz`. Rejected at load: a `separator` without a `repeat` above Renders e.g. `bar foo baz`. Rejected at load: a `separator` other than `""` without a
`1`, and a `repeat` that multiplies to more than 1 048 576 renders along any path of `repeat` above `1`, and a `repeat` that multiplies to more than 1 048 576 renders along any path of
nested repeats. nested repeats.
### Datatype ### Datatype
@@ -495,9 +497,9 @@ renders a null as `""`. The other items' weights skew its odds:
{ "format": "", "deleted_at": null, "middle": [null, { "format": "{n}", "n": ["Ann", "Eva"], "weight": 3 }] } { "format": "", "deleted_at": null, "middle": [null, { "format": "{n}", "n": ["Ann", "Eva"], "weight": 3 }] }
``` ```
`deleted_at` is null every draw, `middle` a name three draws in four. Anywhere but a `deleted_at` is null every draw, `middle` a name three draws in four. Rejected at
column, `null` is `""`. Rejected at load: a column whose items hold different load: `null` anywhere but a column, where it only renders `""`, naming `""`, and a
datatypes. column whose items hold different datatypes.
### Table ### Table
@@ -629,8 +631,10 @@ namespace too.
A `{name(args)}` token calls a builtin. Arguments are checked at `New`: a bad A `{name(args)}` token calls a builtin. Arguments are checked at `New`: a bad
count, range, country or expression fails fast; an integer may carry a sign or leading count, range, country or expression fails fast; an integer may carry a sign or leading
zeros (`+5` and `05` are `5`); bounds are finite; a sample that could only ever emit one zeros (`+5` and `05` are `5`); bounds are finite; a constant sample — `int` or `float`
value (`int(5,5)`, `float(1,1,2)`) is rejected naming the text to write instead; with min equal to max, `date` with from equal to to and a layout with no clock field,
and a `date` or `time` layout naming no field — is rejected naming the text to write
instead;
and a length, count or decimal place beyond a sane maximum is rejected, so a and a length, count or decimal place beyond a sane maximum is rejected, so a
fat-fingered `hex(2000000000)` never tries to allocate gigabytes. Every builtin draws only from the seed — a fat-fingered `hex(2000000000)` never tries to allocate gigabytes. Every builtin draws only from the seed — a
time-based id takes its timestamp from the rng, not the clock — so seeded output time-based id takes its timestamp from the rng, not the clock — so seeded output
+30 -11
View File
@@ -291,12 +291,15 @@ func operandError(arg string) error {
if fi, err := os.Stat(arg); err == nil && fi.Mode().IsRegular() { if fi, err := os.Stat(arg); err == nil && fi.Mode().IsRegular() {
return fmt.Errorf("%s; read a file from stdin: fejkdata < %s", unexpected, shellQuoted(arg)) return fmt.Errorf("%s; read a file from stdin: fejkdata < %s", unexpected, shellQuoted(arg))
} }
example, ref := pipeHint, "{/"+strings.TrimPrefix(arg, "/")+"}" ref := "{/" + strings.TrimPrefix(arg, "/") + "}"
if _, lone := loneReference(arg); lone { _, argLone := loneReference(arg)
inline, err := fejkdata.IsTemplate(arg)
_, refLone := loneReference(ref)
example := pipeHint
switch {
case argLone || inline:
example = piped(arg) example = piped(arg)
} else if inline, err := fejkdata.IsTemplate(arg); inline { case err == nil && refLone:
example = piped(arg)
} else if _, lone := loneReference(ref); lone && err == nil {
example = piped(ref) example = piped(ref)
} }
return fmt.Errorf("%s; the template is read from stdin: %s", unexpected, example) return fmt.Errorf("%s; the template is read from stdin: %s", unexpected, example)
@@ -453,11 +456,8 @@ type input struct {
// allowed. // allowed.
func parseInput(raw string) (input, error) { func parseInput(raw string) (input, error) {
trimmed := strings.Trim(raw, jsonSpace) trimmed := strings.Trim(raw, jsonSpace)
switch trimmed { if trimmed == "" {
case "":
return input{}, fmt.Errorf("stdin holds no template: %s", pipeHint) return input{}, fmt.Errorf("stdin holds no template: %s", pipeHint)
case "null":
return input{}, fmt.Errorf("stdin holds only null, as jq prints for a missing key: %s", pipeHint)
} }
text := raw text := raw
if json.Valid([]byte(trimmed)) { if json.Valid([]byte(trimmed)) {
@@ -487,20 +487,39 @@ func loneReference(text string) (string, bool) {
} }
// isPath reports whether a reference's body after its / is a path alone: no second /, no // isPath reports whether a reference's body after its / is a path alone: no second /, no
// folder sigil, and none of the |, ( or " as " a token reads outside a selector. // folder sigil, no empty segment, and none of the |, ( or " as " a token reads outside a selector.
// It copies the grammar, since the CLI reads only the library's public API. // It copies the grammar, since the CLI reads only the library's public API.
func isPath(body string) bool { func isPath(body string) bool {
if body == "" || strings.ContainsAny(body[:1], "/.") { if body == "" || strings.ContainsAny(body[:1], "/.") {
return false return false
} }
names := withoutSelectors(body) names := withoutSelectors(body)
if strings.ContainsAny(names, "|(") || strings.Contains(names, " as ") { if strings.ContainsAny(names, "|(") || strings.Contains(names, " as ") || hasEmptySegment(names) {
return false return false
} }
inline, err := fejkdata.IsTemplate(body) inline, err := fejkdata.IsTemplate(body)
return !inline && err == nil return !inline && err == nil
} }
// hasEmptySegment reports whether a dotted path holds a segment with no name: two dots are a
// step up, and only a step up may end the path.
func hasEmptySegment(path string) bool {
segments := strings.Split(path, ".")
stepUp := false
for i, seg := range segments[1:] {
last := i == len(segments)-2
switch {
case seg != "":
stepUp = false
case stepUp && !last, !stepUp && last:
return true
default:
stepUp = !stepUp
}
}
return false
}
// spacedReference adds to a record refusal that whitespace around a lone reference made // spacedReference adds to a record refusal that whitespace around a lone reference made
// the template text. // the template text.
func spacedReference(template string, err error) error { func spacedReference(template string, err error) error {
+15 -15
View File
@@ -20,29 +20,26 @@ const (
DataTypeBoolean DataType = datatype.Boolean DataTypeBoolean DataType = datatype.Boolean
) )
// datatypeOf reads a template's "datatype" (default DataTypeString). // datatypeOf reads a template's "datatype", nil where it carries none.
func datatypeOf(m map[string]any, pos position) (DataType, error) { func datatypeOf(m map[string]any, pos position) (*DataType, error) {
v, ok := m["datatype"] v, ok := m["datatype"]
if !ok { if !ok {
return DataTypeString, nil return nil, nil
} }
name, ok := v.(string) name, ok := v.(string)
if !ok { if !ok {
return 0, fmt.Errorf("datatype must be a string, got %T", v) return nil, fmt.Errorf("datatype must be a string, got %T", v)
} }
if name == DataTypeString.String() { for d := DataTypeString; d < datatype.Count; d++ {
return DataTypeString, nil
}
for d := DataTypeInteger; d < datatype.Count; d++ {
if name != d.String() { if name != d.String() {
continue continue
} }
if pos != inColumn { if pos != inColumn && d != DataTypeString {
return 0, errors.New("datatype only types a record column — a field of the top-level template — so it has no effect here") return nil, errors.New(`datatype only types a record column — a field of the top-level template — so it has no effect here; drop "datatype"`)
} }
return d, nil return &d, nil
} }
return 0, fmt.Errorf(`datatype takes "string", "integer", "number" or "boolean", got %q`, name) return nil, fmt.Errorf(`datatype takes "string", "integer", "number" or "boolean", got %q`, name)
} }
// checkColumns rejects a record column whose items hold different datatypes, checking a column // checkColumns rejects a record column whose items hold different datatypes, checking a column
@@ -100,10 +97,13 @@ func columnDatatype(n node) DataType {
// itemDatatype is the datatype a column item declares, else that of the column it reads. // itemDatatype is the datatype a column item declares, else that of the column it reads.
func itemDatatype(t *template) DataType { func itemDatatype(t *template) DataType {
if !t.declared && t.readsColumn != nil { switch {
case t.datatype != nil:
return *t.datatype
case t.readsColumn != nil:
return columnDatatype(t.readsColumn.column) return columnDatatype(t.readsColumn.column)
} }
return t.datatype return DataTypeString
} }
// columnItems is a column's template items, its choices unwrapped, and whether one is null. // columnItems is a column's template items, its choices unwrapped, and whether one is null.
@@ -139,7 +139,7 @@ const (
func kindOf(t *template) itemKind { func kindOf(t *template) itemKind {
switch { switch {
case t.declared: case t.datatype != nil:
return kindDeclares return kindDeclares
case itemDatatype(t) != DataTypeString: case itemDatatype(t) != DataTypeString:
return kindReads return kindReads
+5 -1
View File
@@ -77,6 +77,7 @@ func refuseTwiceDrawnIn(addressed map[pickKey]string, read string, t *template,
reads := map[string]int{} reads := map[string]int{}
var into []arm var into []arm
for _, o := range t.compiled.ops { for _, o := range t.compiled.ops {
heads := map[string]bool{} // one op draws once, whichever arm it takes
for _, a := range slices.Concat(o.arms, o.operands) { for _, a := range slices.Concat(o.arms, o.operands) {
if grammar.IsRef(a.head) || a.kind == namedRead { if grammar.IsRef(a.head) || a.kind == namedRead {
continue continue
@@ -84,7 +85,10 @@ func refuseTwiceDrawnIn(addressed map[pickKey]string, read string, t *template,
if _, kept := addressed[key.under(a.key())]; kept { if _, kept := addressed[key.under(a.key())]; kept {
into = append(into, a) into = append(into, a)
} }
reads[a.head]++ heads[a.head] = true
}
for head := range heads {
reads[head]++
} }
} }
for _, head := range sortedNames(reads) { for _, head := range sortedNames(reads) {
+21 -16
View File
@@ -51,8 +51,7 @@ type template struct {
fields map[string]node fields map[string]node
repeat int repeat int
separator string separator string
datatype DataType datatype *DataType // the "datatype" it carries, which wins over that of a column it reads; nil where none
declared bool // carries a "datatype", which wins over the datatype of a column it reads
fromString bool // written as a JSON string rather than an object fromString bool // written as a JSON string rather than an object
isRecord bool // compiled at the top without a repeat, so its fields are record columns isRecord bool // compiled at the top without a repeat, so its fields are record columns
unbound []unboundRead // the heads its tokens read that no field holds unbound []unboundRead // the heads its tokens read that no field holds
@@ -116,13 +115,15 @@ const (
inColumn // a column, or a choice item standing in for one inColumn // a column, or a choice item standing in for one
) )
// compileAt compiles a node that is no choice's item. Only a choice's items carry a // compileAt compiles a node drawn against no other: no choice's item, or a one-item choice's.
// weight, so one here would be inert whatever its type. // A weight there does nothing, bar the default 1 written out.
func compileAt(v any, pos position) (node, error) { func compileAt(v any, pos position) (node, error) {
if m, ok := v.(map[string]any); ok { w, err := weightOf(v)
if _, weighted := m["weight"]; weighted { if err != nil {
return nil, fmt.Errorf("weight only skews a choice's items, so it has no effect here; it is an option and can never be a field") return nil, err
} }
if w != 1 {
return nil, fmt.Errorf(`weight %v skews a draw between items, and nothing here is drawn against this one; drop "weight"`, w)
} }
return compileItem(v, pos) return compileItem(v, pos)
} }
@@ -142,7 +143,7 @@ func compileItem(v any, pos position) (node, error) {
return compileTemplate(v, pos) return compileTemplate(v, pos)
case nil: case nil:
if pos != inColumn { if pos != inColumn {
return compileString("") return nil, fmt.Errorf(`null is a record column's value, and here it only renders "", so write ""`)
} }
return &nullItem{}, nil return &nullItem{}, nil
default: default:
@@ -158,6 +159,12 @@ func jsonKind(v any) string {
return "a number" return "a number"
case bool: case bool:
return "a boolean" return "a boolean"
case string:
return "a string"
case []any:
return "a list"
case map[string]any:
return "an object"
} }
return fmt.Sprintf("%T", v) return fmt.Sprintf("%T", v)
} }
@@ -242,13 +249,12 @@ func compileTemplate(m map[string]any, pos position) (node, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &template{format: o.format, tokens: toks, fields: fields, repeat: o.repeat, separator: o.separator, datatype: o.datatype, declared: o.declared, isRecord: fieldPos == inColumn, unbound: unbound}, nil return &template{format: o.format, tokens: toks, fields: fields, repeat: o.repeat, separator: o.separator, datatype: o.datatype, isRecord: fieldPos == inColumn, unbound: unbound}, nil
} }
// templateOptions is what a template object's option keys say. // templateOptions is what a template object's option keys say.
type templateOptions struct { type templateOptions struct {
datatype DataType datatype *DataType
declared bool
format string format string
repeat int repeat int
separator string separator string
@@ -266,7 +272,6 @@ func readOptions(m map[string]any, pos position) (templateOptions, error) {
return o, err return o, err
} }
o.repeat = repeat o.repeat = repeat
_, o.declared = m["datatype"]
if o.datatype, err = datatypeOf(m, pos); err != nil { if o.datatype, err = datatypeOf(m, pos); err != nil {
return o, err return o, err
} }
@@ -274,8 +279,8 @@ func readOptions(m map[string]any, pos position) (templateOptions, error) {
if o.separator, ok = sv.(string); !ok { if o.separator, ok = sv.(string); !ok {
return o, fmt.Errorf("separator must be a string, got %T", sv) return o, fmt.Errorf("separator must be a string, got %T", sv)
} }
if repeat == 1 { if repeat == 1 && o.separator != "" {
return o, fmt.Errorf("separator joins repeated renders, so it has no effect without a repeat above 1") return o, fmt.Errorf(`separator %q joins repeated renders, and this template renders once; drop "separator", or add a repeat above 1`, o.separator)
} }
} }
return o, nil return o, nil
@@ -332,7 +337,7 @@ func weightOf(raw any) (float64, error) {
} }
w, ok := wv.(float64) w, ok := wv.(float64)
if !ok { if !ok {
return 0, fmt.Errorf("weight must be a number, got %T", wv) return 0, fmt.Errorf(`weight is an option and takes a number, not %s; a field cannot be named "weight"`, jsonKind(wv))
} }
if w < 0 || math.IsNaN(w) || math.IsInf(w, 0) { if w < 0 || math.IsNaN(w) || math.IsInf(w, 0) {
return 0, fmt.Errorf("weight must be finite and positive, got %v", w) return 0, fmt.Errorf("weight must be finite and positive, got %v", w)
+1 -1
View File
@@ -104,7 +104,7 @@ type columnRead struct {
// or name, and nil where it does not or t declares a string, which reads the column as text. // or name, and nil where it does not or t declares a string, which reads the column as text.
func columnReadOf(t *template, targets map[*nameBinding]nameTarget) *columnRead { func columnReadOf(t *template, targets map[*nameBinding]nameTarget) *columnRead {
ops := t.compiled.ops ops := t.compiled.ops
if t.declared && t.datatype == DataTypeString || t.repeat != 1 || len(ops) != 1 || ops[0].Kind != grammar.PathRead || len(ops[0].arms) != 1 { if t.datatype != nil && *t.datatype == DataTypeString || t.repeat != 1 || len(ops) != 1 || ops[0].Kind != grammar.PathRead || len(ops[0].arms) != 1 {
return nil return nil
} }
a := ops[0].arms[0] a := ops[0].arms[0]
+1 -1
View File
@@ -86,7 +86,7 @@ func TestADefaultWrittenOutIsTheDefault(t *testing.T) {
} { } {
sameRenders(t, long, short) sameRenders(t, long, short)
} }
if r := compiled(t, `{"format":"","n":{"format":"1","datatype":"string"}}`).(*template); !r.isRecord || r.fields["n"].(*template).datatype != DataTypeString { if r := compiled(t, `{"format":"","n":{"format":"1","datatype":"string"}}`).(*template); !r.isRecord || *r.fields["n"].(*template).datatype != DataTypeString {
t.Error("a column of datatype string is no string column of a record") t.Error("a column of datatype string is no string column of a record")
} }
} }
+14 -13
View File
@@ -19,31 +19,32 @@ type valueProof struct {
// checkDatatype rejects a typed column item some render of which is not text of its datatype. // checkDatatype rejects a typed column item some render of which is not text of its datatype.
func (p *valueProof) checkDatatype(label string, n node) error { func (p *valueProof) checkDatatype(label string, n node) error {
t, ok := n.(*template) t, ok := n.(*template)
if !ok || t.datatype == DataTypeString { if !ok || t.datatype == nil || *t.datatype == DataTypeString {
return nil return nil
} }
if reason := p.proveColumnItem(t).Not[t.datatype]; reason != "" { if reason := p.proveColumnItem(t).Not[*t.datatype]; reason != "" {
return fmt.Errorf("%s: datatype %s: %s", label, t.datatype, reason) return fmt.Errorf("%s: datatype %s: %s", label, *t.datatype, reason)
} }
return nil return nil
} }
// checkField rejects a column a field of Go type ft cannot fill: a datatype, which the Go type // checkField rejects a column a field of Go type ft cannot fill: a datatype other than the one the
// sets, a null outside a pointer, or a value its kind's datatype or range refuses. // Go type sets, a null outside a pointer, or a value its kind's datatype or range refuses.
func (p *valueProof) checkField(label string, ft reflect.Type, column node) error { func (p *valueProof) checkField(label string, ft reflect.Type, column node) error {
items, _ := columnItems(column)
for _, it := range items {
if it.declared {
return fmt.Errorf("%s: its Go type %s sets the datatype; drop \"datatype\"", label, ft)
}
}
elem := ft elem := ft
if ft.Kind() == reflect.Pointer { if ft.Kind() == reflect.Pointer {
elem = ft.Elem() elem = ft.Elem()
} else if p.proveColumn(column).Nullable {
return fmt.Errorf("%s: its tag can draw null, which %s cannot hold; make it *%s", label, ft, ft)
} }
kind := columnKinds[elem.Kind()] kind := columnKinds[elem.Kind()]
items, _ := columnItems(column)
for _, it := range items {
if it.datatype != nil && *it.datatype != kind.datatype {
return fmt.Errorf("%s: its Go type %s sets the datatype %s; drop \"datatype\"", label, ft, kind.datatype)
}
}
if ft.Kind() != reflect.Pointer && p.proveColumn(column).Nullable {
return fmt.Errorf("%s: its tag can draw null, which %s cannot hold; make it *%s", label, ft, ft)
}
if kind.datatype == DataTypeString { if kind.datatype == DataTypeString {
return nil return nil
} }