diff --git a/README.md b/README.md index 2cda366..ed89bcb 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,10 @@ a JSON object, array or string, or that carries a `{` token, is instead an spot. Its tokens reach the data by reference from the root — `{/sv_SE.person.last}`, so shipped and `--data-path` categories are alike available. An inline template sits in no folder, so the folder-relative `{.name}` -and `{..name}` are rejected naming the root spelling. A path never contains a -brace, a bracket or a quote, so the two cannot collide (see -[Decisions](#decisions)). +and `{..name}` are rejected naming the root spelling, and one reference alone — +`{/sv_SE.person}` — is the path written as a template, rejected naming the path, as is +a path written `/sv_SE.person`. A path never contains a brace, a bracket or a quote, +so the two cannot collide (see [Decisions](#decisions)). | Flag | | |------|--| @@ -155,6 +156,8 @@ v = t.Fake() // render many times, r, err := f.FakeRecord("users") // one record: each field a column s := r.JSON() // {"first":"Ada","last":"Lovelace"} r, err = f.FakeRecordTemplate(`{"format":"{x}","x":["a","b"]}`) // compile + render inline +err = f.FakeStruct(&user) // fill a struct's fake:"…" tagged fields +ok, err := fejkdata.IsTemplate(arg) // an inline template by its shape, else a path ``` | Option | | @@ -170,6 +173,31 @@ A `*Record` carries its columns via `Columns()` — each a `Column` of `Name`, 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. +```go +type User struct { + ID int64 `fake:"{seq()}"` + Last string `fake:"sv_SE.person.last"` + Age uint8 `fake:"{int(18,99)}"` + Nick *string `fake:"[null, \"{/sv_SE.username}\"]"` + Home Address // filled from Address's own tags +} +``` + +`FakeStruct` fills a struct through a pointer: each exported field tagged `fake:"…"` is +a column of one record, its tag a path or an inline template — told apart by +`IsTemplate`, as the CLI tells an argument — and its Go type the column's +[datatype](#datatype): a string, bool, integer or float kind, or a pointer to one, +which a [`null`](#null) item leaves nil. An integer stays within int64 whatever its +kind, and a value the kind cannot hold, such as `{int(0,300)}` in a `uint8`, is refused +naming a kind that holds it. The fields an embedded struct promotes are columns of the +same record; a named struct field, or a pointer to one, fills from its own tags as a +record of its own, so its references draw apart from its parent's. `fake:"-"` leaves a +struct field, embedded or named, or a pointer to one, unfilled. 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 +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. + 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 stream for that value and everything drawn after it. @@ -182,8 +210,8 @@ work with no data on disk. A directory is a namespace: each JSON file is a category named after the file, each subdirectory a dot-path segment, so `mydata/sv_SE/person.json` is `sv_SE.person` and replaces the shipped one. Sources merge in order; matching folders combine, any other clash is won by the -last loaded. Names may not use `.`, `|`, `(`, `{`, `}`, `[`, `]`, `"` or `/`; -dot-prefixed entries are skipped, so a data directory can also be a checkout. +last loaded. Names may not use `.`, `|`, `(`, `{`, `}`, `[`, `]`, `"` or `/`, nor be +`-`, which a struct tag reserves; dot-prefixed entries are skipped, so a data directory can also be a checkout. Each locale carries `address`, `color`, `company`, `date`, `email`, `ip`, `person`, `phone`, `price`, `sentence`, `ssn`, `time`, `url`, `username`, @@ -473,7 +501,8 @@ tokens add cost in proportion to the output. one is a load error naming the right one. 3. **Every mistake is a load error** — `New` rejects the data and `NewTemplate` the inline template; on a loaded generator `Fake` fails only for an unknown - path, and `Template.Fake` cannot fail at all. + path, `FakeStruct` only for a non-struct argument or a type its tags do not + describe, with the same error every call, and `Template.Fake` cannot fail at all. 4. **Zero to a value in one command** — `go install`, then `fejkdata sv_SE.person`: no checkout, no flag. Flags are GNU-form (`--seed 42`, `-n 3`) in any position; the first custom template needs no escape and no option. @@ -510,7 +539,10 @@ tokens add cost in proportion to the output. whole — though only a leading one could collide — keeps one simple name rule instead of a leading-position special case. The JSON string is what makes the library's own advice reachable: the error for an object holding only a format - names `"…"`, and that spelling has to work where it is printed. + names `"…"`, and that spelling has to work where it is printed. An argument or struct + tag of one reference alone, `{/users}`, is refused naming the path `users`: both + render the same text, and only the path names a record. `IsTemplate` exports the + rule, so the CLI, struct tags and any other caller read one. - **An inline template skips the cycle fence, and only that one.** `New` proves the loaded tree acyclic, an inline node is a finite tree of its own, and nothing in the tree can reference it, so no render of it reaches itself. Every other fence @@ -579,13 +611,27 @@ tokens add cost in proportion to the output. - **Samples say what they emit, transforms what they do.** `{upper(2)}` is two letters, `{uppercase(x)}` is `x` upper-cased; one name for both would turn on whether the argument looks like a number. -- **A record is a template seen as columns, not a second schema format.** A +- **A record is a template seen as columns; a Go struct is the one second schema.** A template's `format` composes its fields into one string; `FakeRecord` and `--format` project the same fields as columns. Two views of one dataset, so a record author writes the same JSON they already know, and a column is the same field `Fake` renders by dotted path. The `format` is inert to a record — a record-only template writes `"format": ""` — but it is compiled and fenced, so - a template that loads renders as whichever shape is asked for. + a template that loads renders as whichever shape is asked for. `FakeStruct` takes + its columns from a struct instead, because a Go caller has already written that + schema: the fields name the columns and their types are the datatypes, so a tag + says only what to draw, and a `datatype` in it would be a second spelling of the + type. +- **A struct's records follow Go's field access, and compile on first use.** The + fields an embedded struct promotes are the struct's own — `e.First`, as + `encoding/json` and SQL mappers read them — so they are columns of its record and + share its draws; a tagged field that another field hides is refused, not dropped. A + named struct field is another entity and a record of its own. `fake:"-"` leaves a + struct field, embedded or named, unfilled, so no name may be `-`; a pointer back to + a struct already being filled is left alone, since filling it would never end. `New` cannot + see a caller's types, so the first `FakeStruct` for a type compiles its tags and the + answer, error included, is kept per type: a test's first call is its load, and no + `NewStruct` handle is needed, as the cache already compiles once. - **A record shares one reference draw per category.** Two columns that reference one category — `{/currency.code}` beside `{/currency.symbol}` — read one draw of it, so a record's facts agree the way a template's [correlated @@ -674,7 +720,8 @@ node.go the node model and JSON -> node compilation path.go the dotted-path walk, and proving a path resolves render.go Fake and the recursive renderer (choices, format strings, expansions) record.go records: Record, the JSON/CSV/SQL serializers, and their entry points -inline.go inline templates: Template, NewTemplate, FakeTemplate, and their compile and link +struct.go structs: FakeStruct, fake tags, and a field's Go type as its column's datatype +inline.go inline templates: Template, NewTemplate, FakeTemplate, IsTemplate, and their compile and link template.go the {token} grammar: scanning, tokens, operands, validation, compiling a format hold.go the hold: one draw per expansion for paths and operands, and its fences reference.go reference sigils, and binding references across the tree diff --git a/cmd/fejkdata/main.go b/cmd/fejkdata/main.go index 36b605f..bb95e71 100644 --- a/cmd/fejkdata/main.go +++ b/cmd/fejkdata/main.go @@ -9,7 +9,6 @@ package main import ( "bufio" - "encoding/json" "errors" "fmt" "io" @@ -415,20 +414,13 @@ const ( argTemplate ) -// classify reads what a positional argument names by its shape: a { token, or a -// JSON object, array or string, is an inline template; anything else is a path. +// classify reads what a positional argument names by its shape (see fejkdata.IsTemplate). func classify(arg string) (argKind, error) { - if strings.ContainsRune(arg, '{') || (isJSONStart(strings.TrimSpace(arg)) && json.Valid([]byte(arg))) { + inline, err := fejkdata.IsTemplate(arg) + if inline { return argTemplate, nil } - if i := strings.IndexAny(arg, `[]}"`); i >= 0 { - return argPath, fmt.Errorf("%q holds a %q, which no path may, and it is not valid JSON, so it names no template either", arg, arg[i:i+1]) - } - return argPath, nil -} - -func isJSONStart(arg string) bool { - return strings.HasPrefix(arg, "[") || strings.HasPrefix(arg, `"`) + return argPath, err } func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) } diff --git a/cmd/fejkdata/main_test.go b/cmd/fejkdata/main_test.go index fc2c485..2e17b5c 100644 --- a/cmd/fejkdata/main_test.go +++ b/cmd/fejkdata/main_test.go @@ -305,42 +305,22 @@ func TestRunShippedDataByDefault(t *testing.T) { } func TestClassify(t *testing.T) { - for arg, want := range map[string]argKind{ - "sv_SE.person": argPath, - "person.last": argPath, - "name: {x}": argTemplate, // a { token: a path can never carry a brace - `{"format":"x"}`: argTemplate, - `["a","b"]`: argTemplate, // a JSON array carries no brace - `[1, 2]`: argTemplate, - ` ["a","b"]`: argTemplate, // padding is the template's own error, not a shape verdict - `"hello"`: argTemplate, // a JSON string, the spelling a format-only object names - } { - got, err := classify(arg) - if err != nil || got != want { + for arg, want := range map[string]argKind{"sv_SE.person": argPath, "name: {x}": argTemplate} { + if got, err := classify(arg); err != nil || got != want { t.Errorf("classify(%q) = %v, %v; want %v", arg, got, err, want) } } - for arg, want := range map[string]string{ - "[abc]": `holds a "["`, - "[abc].field": `holds a "["`, - "x[1]": `holds a "["`, - "a]b": `holds a "]"`, - "a}b": `holds a "}"`, - `"abc`: `holds a "\""`, - `"a]b`: `holds a "\""`, // the opener the reader typed, not the bracket behind it - } { - _, err := classify(arg) - if err == nil || !strings.Contains(err.Error(), want) { - t.Errorf("classify(%q) = %v; want it rejected naming %s", arg, err, want) - } + if _, err := classify("[abc]"); err == nil || !strings.Contains(err.Error(), `holds a "["`) { + t.Errorf("classify([abc]) = %v; want it rejected naming the bracket", err) } } func TestUsageReferencesResolve(t *testing.T) { for _, token := range regexp.MustCompile(`\{/[^}]+\}`).FindAllString(usage, -1) { - code, out, errb := runOut("--seed", "1", token) + path := token[2 : len(token)-1] + code, out, errb := runOut("--seed", "1", path) if code != 0 || strings.TrimSpace(out) == "" { - t.Errorf("usage advertises %s: run = %d, %q, stderr %q", token, code, out, errb) + t.Errorf("usage advertises %s: run %s = %d, %q, stderr %q", token, path, code, out, errb) } } } @@ -373,12 +353,13 @@ func TestRunInlineTemplate(t *testing.T) { func TestRunTemplateMisuse(t *testing.T) { for arg, want := range map[string]string{ - "{bad": "unterminated", - "[red,green]": "names no template either", - `{"format":"x"}`: "is a string", - "{/no.such.path}": "no entry", - "x[1]": "names no template either", - ` ["a","b"] `: "may not be padded", + "{bad": "unterminated", + "[red,green]": "names no template either", + `{"format":"x"}`: "is a string", + "name: {/no.such.path}": "no entry", + "{/sv_SE.person}": "write sv_SE.person", + "x[1]": "names no template either", + ` ["a","b"] `: "may not be padded", } { code, out, errb := runOut("--seed", "1", arg) if code != 2 || out != "" || !strings.Contains(errb, "try 'fejkdata --help'") || !strings.Contains(errb, want) { diff --git a/datatype.go b/datatype.go index 03bdb7f..6ddc484 100644 --- a/datatype.go +++ b/datatype.go @@ -67,19 +67,7 @@ func datatypeOf(m map[string]any, pos position) (DataType, error) { // columnDatatype is the datatype a column's items declare. They must agree, since a // column holds one; a column only ever null is a string. func columnDatatype(n node) (DataType, error) { - var items []*template - var collect func(node) - collect = func(n node) { - switch n := n.(type) { - case *choice: - for _, it := range n.items { - collect(it) - } - case *template: - items = append(items, n) - } - } - collect(n) + items, _ := columnItems(n) if len(items) == 0 { return DataTypeString, nil } @@ -91,6 +79,25 @@ func columnDatatype(n node) (DataType, error) { return items[0].datatype, nil } +// columnItems is a column's template items, its choices unwrapped, and whether one is null. +func columnItems(n node) (items []*template, nullable bool) { + var collect func(node) + collect = func(n node) { + switch n := n.(type) { + case *choice: + for _, it := range n.items { + collect(it) + } + case *template: + items = append(items, n) + case *null: + nullable = true + } + } + collect(n) + return items, nullable +} + // disagreement names the fix for two items of one column declaring different datatypes. func disagreement(a, b *template) error { typed, bare := a, b diff --git a/fejkdata.go b/fejkdata.go index 35cd73e..24bcf7e 100644 --- a/fejkdata.go +++ b/fejkdata.go @@ -21,6 +21,7 @@ import ( "io/fs" "math/rand/v2" "os" + "reflect" "sort" "sync" ) @@ -46,6 +47,7 @@ type Generator struct { rand *session categories map[string]node records map[node]recordShape + structs map[reflect.Type]structResult } // session is one generator's mutable render state: the seeded rng plus the {seq()} diff --git a/graph.go b/graph.go index f8361e1..07fd20f 100644 --- a/graph.go +++ b/graph.go @@ -173,8 +173,8 @@ func treeScope(root map[string]node) nodeScope { return func(fn func(path string, n node) error) error { return walkNodes(root, fn) } } -func inlineScope(n node) nodeScope { - return func(fn func(path string, m node) error) error { return eachNode(n, "template", fn) } +func inlineScope(n node, label string) nodeScope { + return func(fn func(path string, m node) error) error { return eachNode(n, label, fn) } } // checkScope runs the per-node fences over a scope, each over the whole scope diff --git a/inline.go b/inline.go index 91f6686..ca693dc 100644 --- a/inline.go +++ b/inline.go @@ -32,11 +32,7 @@ func (f *Generator) NewTemplate(input string) (*Template, error) { if err != nil { return nil, fmt.Errorf("fejkdata: %w", err) } - scope := inlineScope(n) - if err := linkNodeRefs(scope, f.categories); err != nil { - return nil, fmt.Errorf("fejkdata: %w", err) - } - if err := checkScope(scope); err != nil { + if err := bindInline(n, "template", f.categories); err != nil { return nil, fmt.Errorf("fejkdata: %w", err) } return &Template{g: f, n: n}, nil @@ -53,17 +49,102 @@ func (f *Generator) FakeTemplate(input string) (string, error) { return t.Fake(), nil } -// compileInput compiles an inline template: a JSON value, or a bare format string -// when the input is not JSON. +// IsTemplate reports whether arg is an inline template rather than a path, by its shape: a { +// token, or a JSON object, array or string, is a template, and anything else is a path. A +// name never holds a bracket, a brace or a quote, so an arg holding one that is not valid +// JSON names neither, and errors; so do a template of one reference alone, which is a path +// written as a template, and a path written with a leading /. +func IsTemplate(arg string) (bool, error) { + inline, err := isTemplate(arg) + if err != nil { + return false, fmt.Errorf("fejkdata: %w", err) + } + return inline, nil +} + +func isTemplate(arg string) (bool, error) { + if strings.ContainsRune(arg, '{') || (isJSONStart(strings.TrimSpace(arg)) && json.Valid([]byte(arg))) { + if path, lone := loneReference(arg); lone { + return false, pathAdvice(path, fmt.Sprintf("%s is the path %s written as a template", arg, path)) + } + return true, nil + } + if i := strings.IndexAny(arg, `[]}"`); i >= 0 { + return false, fmt.Errorf("%q holds a %q, which no path may, and it is not valid JSON, so it names no template either", arg, arg[i:i+1]) + } + if path := strings.TrimLeft(arg, "/"); path != arg && path != "" { + return false, pathAdvice(path, fmt.Sprintf("path %s starts with /, and every path starts at the root already", arg)) + } + return false, nil +} + +func isJSONStart(arg string) bool { + return strings.HasPrefix(arg, "[") || strings.HasPrefix(arg, `"`) +} + +// pathAdvice refuses a spelling of path, naming path to write, or why no name can spell it. +func pathAdvice(path, refusal string) error { + if err := checkPathNames(path); err != nil { + return err + } + return fmt.Errorf("%s; write %s", refusal, path) +} + +// loneReference is the path a template spells when the value it holds — a format string, a +// JSON string, or an object holding only a format — is one reference token and nothing else. +func loneReference(arg string) (string, bool) { + var raw any + if json.Unmarshal([]byte(arg), &raw) != nil { + raw = arg + } + if m, isObject := raw.(map[string]any); isObject && len(m) == 1 { + raw = m["format"] + } + format, isString := raw.(string) + var units []ftoken + if !isString || eachToken(format, func(t ftoken) error { units = append(units, t); return nil }) != nil || len(units) != 1 { + return "", false + } + body := units[0].body + if units[0].kind != 'b' || !isRef(body) || strings.ContainsAny(body, "|(") { + return "", false + } + if strings.HasPrefix(body, "/") { + body = "/" + strings.TrimLeft(body, "/") + } + _, path, err := refShape(body) + return path, err == nil +} + func compileInput(input string) (node, error) { + v, err := inputValue(input) + if err != nil { + return nil, err + } + return compile(v) +} + +// inputValue reads an inline template as the value compile takes: the JSON value it holds, or +// the input itself as a format string when it is not JSON. +func inputValue(input string) (any, error) { var raw any if err := json.Unmarshal([]byte(input), &raw); err != nil { - return compile(input) + return input, nil } if trimmed := strings.TrimSpace(input); trimmed != input { return nil, fmt.Errorf("a JSON template may not be padded with spaces, which a format string would render; write %s", trimmed) } - return compile(raw) + return raw, nil +} + +// bindInline links an inline node's references against root and runs the fences over it, +// naming its nodes from label. +func bindInline(n node, label string, root map[string]node) error { + scope := inlineScope(n, label) + if err := linkNodeRefs(scope, root); err != nil { + return err + } + return checkScope(scope) } // linkNodeRefs binds the references in an inline node's templates against the diff --git a/inline_test.go b/inline_test.go index f44e164..b3a7f9d 100644 --- a/inline_test.go +++ b/inline_test.go @@ -150,6 +150,51 @@ func TestNewTemplateReusable(t *testing.T) { } } +func TestIsTemplate(t *testing.T) { + for arg, want := range map[string]bool{ + "sv_SE.person": false, + "person.last": false, + "name: {x}": true, + `{"format":"x"}`: true, + `["a","b"]`: true, + `[1, 2]`: true, + ` ["a","b"]`: true, // padding is the template's own error, not a shape verdict + `"hello"`: true, + "{/a}{/b}": true, + "{/a|/b}": true, + "{uppercase(/a)}": true, + "{{/a}}": true, + `"{/a} x"`: true, + } { + if got, err := IsTemplate(arg); err != nil || got != want { + t.Errorf("IsTemplate(%q) = %v, %v; want %v", arg, got, err, want) + } + } + for arg, want := range map[string]string{ + "[abc]": `holds a "["`, + "[abc].field": `holds a "["`, + "x[1]": `holds a "["`, + "a]b": `holds a "]"`, + "a}b": `holds a "}"`, + `"abc`: `holds a "\""`, + `"a]b`: `holds a "\""`, // the opener the reader typed, not the bracket behind it + "{/sv_SE.person.last}": "{/sv_SE.person.last} is the path sv_SE.person.last written as a template; write sv_SE.person.last", + `"{/sv_SE.person}"`: "write sv_SE.person", + "{.person.last}": "write person.last", + "/sv_SE.person": "write sv_SE.person", + ` "{/sv_SE.person}"`: "write sv_SE.person", + `{"format":"{/sv_SE.person}"}`: "write sv_SE.person", + "//sv_SE.person": "write sv_SE.person", + "{//sv_SE.person}": "{//sv_SE.person} is the path sv_SE.person written as a template; write sv_SE.person", + "/sv_SE/person": `path "sv_SE/person" contains "/"`, + "{/-}": `path "-" is reserved`, + } { + if _, err := IsTemplate(arg); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("IsTemplate(%q) = %v; want it rejected naming %s", arg, err, want) + } + } +} + func TestFakeTemplateRepeatBound(t *testing.T) { f := shipped(t) _, err := f.FakeTemplate(`{"format":"{x}","repeat":200,"x":{"format":"{y}","repeat":200,"y":{"format":"z","repeat":200}}}`) diff --git a/loading_test.go b/loading_test.go index 681fe67..bd947af 100644 --- a/loading_test.go +++ b/loading_test.go @@ -298,6 +298,10 @@ func TestNewErrors(t *testing.T) { map[string]string{"a(b/cat": `"1"`}, `folder "a(b" contains "("`, }, + "field name a struct tag reserves": { + map[string]string{"a": `{"format":"{x}","x":"1","-":"2"}`}, + `field "-" is reserved`, + }, // A repeated arm skews an alternation, which weight is the spelling for. "repeated alternation arm": { map[string]string{"a": `{"format":"{x|x}","x":"1"}`}, diff --git a/node.go b/node.go index cef1bc7..56f84a5 100644 --- a/node.go +++ b/node.go @@ -378,13 +378,17 @@ const reservedInName = ".|({}/[]\"" // reservedList spells reservedInName for an error message, so the two cannot drift. var reservedList = strings.Join(strings.Split(reservedInName, ""), " ") -// checkName rejects a name the dot path, {token} and JSON grammars cannot spell. +// checkName rejects a name the dot path, {token} and JSON grammars cannot spell, or a struct +// tag cannot read. // Both a category or folder and a field go through it, so there is one answer to // what a name may contain. func checkName(name string) error { if name == "" { return fmt.Errorf("%q is empty, which is not a path segment, so List never offers it", name) } + if name == "-" { + return fmt.Errorf(`%q is reserved: the struct tag fake:"-" leaves a field unfilled, so no tag could read it; rename it`, name) + } if i := strings.IndexAny(name, reservedInName); i >= 0 { return fmt.Errorf("%q contains %q; a name may not use %s, which the dot path, {token} and JSON grammars reserve", name, name[i:i+1], reservedList) @@ -392,6 +396,16 @@ func checkName(name string) error { return nil } +// checkPathNames rejects a dotted path with a segment no name may be. +func checkPathNames(path string) error { + for _, seg := range strings.Split(path, ".") { + if err := checkName(seg); err != nil { + return fmt.Errorf("path %w", err) + } + } + return nil +} + // isOption reports whether a template key configures the node instead of naming a // field. These names can never be fields. func isOption(name string) bool { diff --git a/perf_test.go b/perf_test.go index 0ee6dcb..ab92408 100644 --- a/perf_test.go +++ b/perf_test.go @@ -75,6 +75,25 @@ func TestNoRecordAllocRegression(t *testing.T) { } } +func TestNoStructAllocRegression(t *testing.T) { + f, err := New(WithoutShippedData(), WithDataFS(fstest.MapFS{"x.json": {Data: []byte(`{"format":"","a":"x","b":"y","c":"z"}`)}})) + if err != nil { + t.Fatal(err) + } + var v struct { + A string `fake:"x.a"` + B string `fake:"x.b"` + C string `fake:"x.c"` + } + if err := f.FakeStruct(&v); err != nil { + t.Fatal(err) + } + const base = 5.0 + if allocs := testing.AllocsPerRun(10000, func() { f.FakeStruct(&v) }); allocs > base*1.10 { + t.Errorf("FakeStruct: %.1f allocs/op regressed past %.1f (baseline %.1f + 10%%); compiling the type per call is the usual cause", allocs, base*1.10, base) + } +} + func BenchmarkNestedDepth25(b *testing.B) { benchPath(b, tmpData(b, "deep", nestedJSON(25)), "deep") } func BenchmarkNestedDepth100(b *testing.B) { benchPath(b, tmpData(b, "deep", nestedJSON(100)), "deep") } func BenchmarkWideTokens100(b *testing.B) { diff --git a/shipped_data_test.go b/shipped_data_test.go index 193a96b..7b37f3e 100644 --- a/shipped_data_test.go +++ b/shipped_data_test.go @@ -118,6 +118,13 @@ func TestFakeIsSafeForConcurrentUse(t *testing.T) { return } tmpl.Fake() + var u struct { + Last string `fake:"sv_SE.person.last"` + } + if err := f.FakeStruct(&u); err != nil { + t.Error(err) + return + } } }() } diff --git a/struct.go b/struct.go new file mode 100644 index 0000000..727ae75 --- /dev/null +++ b/struct.go @@ -0,0 +1,422 @@ +package fejkdata + +import ( + "errors" + "fmt" + "math" + "reflect" + "slices" + "strconv" + "strings" +) + +// FakeStruct fills the struct v points to. Each exported field tagged `fake:"…"` is a +// column of one record: its tag a path or an inline template, told apart as [IsTemplate] +// tells them, and its Go type the column's datatype. The fields an embedded struct promotes +// are columns of that record too, while a named struct field, or a pointer to one, fills +// from its own tags as a record of its own. The first call for a type compiles its tags, +// so a later call for that type fails only as the first did. +func (f *Generator) FakeStruct(v any) error { + p := reflect.ValueOf(v) + if p.Kind() != reflect.Pointer || p.IsNil() || p.Elem().Kind() != reflect.Struct { + return fmt.Errorf("fejkdata: FakeStruct fills a struct through a non-nil pointer, got %T", v) + } + f.mu.Lock() + defer f.mu.Unlock() + shape, err := f.structShapeOf(p.Elem().Type()) + if err != nil { + return fmt.Errorf("fejkdata: %w", err) + } + shape.fill(f.rand, p.Elem()) + return nil +} + +type structResult struct { + shape *structShape + err error +} + +// maxStructs caps the structs compiling one type walks through its fields. +const maxStructs = 1 << 10 + +// structShapeOf compiles a struct type once and remembers the answer. Callers hold the +// generator's lock. +func (f *Generator) structShapeOf(t reflect.Type) (*structShape, error) { + if r, done := f.structs[t]; done { + return r.shape, r.err + } + label := t.Name() + if label == "" { + label = "struct" + } + sc := &structCompile{root: f.categories, visiting: map[reflect.Type]bool{}, structs: maxStructs} + shape, err := sc.record(t, label) + if err == nil && shape.empty() { + err = fmt.Errorf("%s has no fake tags, so nothing to fill", t) + } + if f.structs == nil { + f.structs = map[reflect.Type]structResult{} + } + f.structs[t] = structResult{shape, err} + return shape, err +} + +// structShape is a struct type compiled to fill: its tagged fields as one record, the field +// index path each column fills, and the struct fields carrying tags of their own. +type structShape struct { + record *template + columns []Column + fields [][]int + nested []nestedStruct +} + +// nestedStruct is a named struct field, or a pointer to one, filled as a record of its own. +type nestedStruct struct { + index []int + shape *structShape +} + +func (s *structShape) empty() bool { return s.record == nil && len(s.nested) == 0 } + +// structCompile is what compiling one struct type shares across the structs it reaches: the +// loaded tree, the types compiling or embedded above, so a pointer back to one is left alone +// rather than filled without end, and how many more structs it may walk. +type structCompile struct { + root map[string]node + visiting map[reflect.Type]bool + structs int +} + +// structFields gathers what one struct type fills: its tagged fields, those its embedded +// structs promote included, as the tags of one record, and its named struct fields as nested +// records. +type structFields struct { + *structCompile + t reflect.Type + label string + tags map[string]any + shape *structShape +} + +func (sc *structCompile) record(t reflect.Type, label string) (*structShape, error) { + if err := sc.spend(label); err != nil { + return nil, err + } + sc.visiting[t] = true + defer delete(sc.visiting, t) + c := &structFields{structCompile: sc, t: t, label: label, tags: map[string]any{}, shape: &structShape{}} + if err := c.walk(t, nil); err != nil { + return nil, err + } + if len(c.tags) > 0 { + if err := c.shape.compileRecord(sc.root, t, label, c.tags); err != nil { + return nil, err + } + } + return c.shape, nil +} + +func (sc *structCompile) spend(label string) error { + if sc.structs--; sc.structs >= 0 { + return nil + } + return fmt.Errorf(`%s: the struct fields reach more than %d structs; leave a struct field unfilled with fake:"-"`, label, maxStructs) +} + +// walk gathers the fields of struct type t, which sits at index within c.t. +func (c *structFields) walk(t reflect.Type, index []int) error { + for i := 0; i < t.NumField(); i++ { + sf := t.Field(i) + sf.Index = append(index[:len(index):len(index)], i) + if err := c.field(sf); err != nil { + return err + } + } + return nil +} + +func (c *structFields) field(sf reflect.StructField) error { + tag, tagged := sf.Tag.Lookup("fake") + elem := structOf(sf.Type) + switch { + case tagged && tag == "-": + if elem == nil { + return fmt.Errorf(`%s.%s: fake:"-" leaves a struct field unfilled, and any other untagged field keeps its value already; drop the tag`, c.label, sf.Name) + } + return nil + case tagged: + return c.column(sf, tag) + case elem == nil || c.visiting[elem]: + return nil + case sf.Anonymous: + return c.embed(sf, elem) + case sf.IsExported(): + return c.nest(sf, elem) + } + return nil +} + +// structOf is the struct type a field holds, by value or through a pointer; nil when none. +func structOf(t reflect.Type) reflect.Type { + if t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return nil + } + return t +} + +// column adds a tagged field's tag to the record, refusing one another field hides. +func (c *structFields) column(sf reflect.StructField, tag string) error { + if visible, ok := c.t.FieldByName(sf.Name); !ok || !slices.Equal(visible.Index, sf.Index) { + return fmt.Errorf("%s.%s: hidden by another field named %s, so its fake tag cannot fill it; rename one", c.label, fieldPath(c.t, sf.Index), sf.Name) + } + v, err := tagValue(sf, tag) + if err != nil { + return fmt.Errorf("%s.%s: %w", c.label, sf.Name, err) + } + c.tags[sf.Name] = v + return nil +} + +// fieldPath names the field at index within t through each struct it is embedded in. +func fieldPath(t reflect.Type, index []int) string { + names := make([]string, len(index)) + for i := range index { + names[i] = t.FieldByIndex(index[:i+1]).Name + } + return strings.Join(names, ".") +} + +func (c *structFields) embed(sf reflect.StructField, elem reflect.Type) error { + if err := c.spend(c.label + "." + fieldPath(c.t, sf.Index)); err != nil { + return err + } + c.visiting[elem] = true + defer delete(c.visiting, elem) + tags, nested := len(c.tags), len(c.shape.nested) + if err := c.walk(elem, sf.Index); err != nil { + return err + } + if sf.Type.Kind() == reflect.Pointer && !sf.IsExported() && (len(c.tags) > tags || len(c.shape.nested) > nested) { + return fmt.Errorf("%s.%s: an unexported embedded pointer field cannot be set, so the tags beneath it cannot fill; embed %s by value", c.label, fieldPath(c.t, sf.Index), elem) + } + return nil +} + +func (c *structFields) nest(sf reflect.StructField, elem reflect.Type) error { + nested, err := c.record(elem, c.label+"."+sf.Name) + if err != nil || nested.empty() { + return err + } + c.shape.nested = append(c.shape.nested, nestedStruct{sf.Index, nested}) + return nil +} + +// tagValue reads a field's fake tag as the value its column compiles from: an inline template +// as written, or a path as the reference {/path}. +func tagValue(sf reflect.StructField, tag string) (any, error) { + if err := checkTaggedType(sf); err != nil { + return nil, err + } + inline, err := isTemplate(tag) + switch { + case err != nil: + return nil, err + case inline: + return inputValue(tag) + } + if err := checkPathNames(tag); err != nil { + return nil, err + } + return "{/" + tag + "}", nil +} + +// checkTaggedType rejects a tagged field no column can fill. +func checkTaggedType(sf reflect.StructField) error { + _, holds := columnKinds[sf.Type.Kind()] + if sf.Type.Kind() == reflect.Pointer { + _, holds = columnKinds[sf.Type.Elem().Kind()] + } + switch { + case !sf.IsExported(): + return errors.New("unexported, so its fake tag cannot fill it") + case holds: + return nil + case structOf(sf.Type) != nil: + return errors.New(`a struct field fills from the tags on its own fields; drop this one, or write fake:"-" to leave it unfilled`) + } + return fmt.Errorf("a fake tag fills a string, bool, integer or float field, or a pointer to one, not %s", sf.Type) +} + +// compileRecord compiles the tagged fields of t as one record, and proves each column holds +// only what its field's Go type can. +func (s *structShape) compileRecord(root map[string]node, t reflect.Type, label string, tags map[string]any) error { + tags["format"] = "" + n, err := compile(tags) + if err != nil { + return fmt.Errorf("%s: %w", label, err) + } + if err := bindInline(n, label, root); err != nil { + return err + } + record, columns, err := recordOf(n) + if err != nil { + return fmt.Errorf("%s: %w", label, err) + } + proof := &valueProof{} + s.fields = make([][]int, len(columns)) + for i, c := range columns { + sf, _ := t.FieldByName(c.Name) + if c.DataType != DataTypeString { + return fmt.Errorf("%s.%s: its Go type %s sets the datatype; drop \"datatype\"", label, c.Name, sf.Type) + } + if err := proof.checkField(label+"."+c.Name, sf.Type, record.fields[c.Name]); err != nil { + return err + } + s.fields[i] = sf.Index + } + s.record, s.columns = record, columns + return nil +} + +// columnKind is what a field of one Go kind holds: the datatype its text proves as, the range a +// number of it stays in, and the kind to name when a value is not proven within that range. +type columnKind struct { + datatype DataType + lo, hi float64 + wider reflect.Kind +} + +var columnKinds = map[reflect.Kind]columnKind{ + reflect.Bool: {datatype: DataTypeBoolean}, + reflect.Float32: {DataTypeNumber, -math.MaxFloat32, math.MaxFloat32, reflect.Float64}, + reflect.Float64: {DataTypeNumber, -math.MaxFloat64, math.MaxFloat64, reflect.Float64}, + reflect.Int: {DataTypeInteger, math.MinInt, math.MaxInt, reflect.Int64}, + reflect.Int16: {DataTypeInteger, math.MinInt16, math.MaxInt16, reflect.Int64}, + reflect.Int32: {DataTypeInteger, math.MinInt32, math.MaxInt32, reflect.Int64}, + reflect.Int64: {DataTypeInteger, math.MinInt64, math.MaxInt64, reflect.Int64}, + reflect.Int8: {DataTypeInteger, math.MinInt8, math.MaxInt8, reflect.Int64}, + reflect.String: {datatype: DataTypeString}, + reflect.Uint: {DataTypeInteger, 0, math.MaxUint, reflect.Int64}, + reflect.Uint16: {DataTypeInteger, 0, math.MaxUint16, reflect.Int64}, + reflect.Uint32: {DataTypeInteger, 0, math.MaxUint32, reflect.Int64}, + reflect.Uint64: {DataTypeInteger, 0, math.MaxUint64, reflect.Int64}, + reflect.Uint8: {DataTypeInteger, 0, math.MaxUint8, reflect.Int64}, +} + +// holds reports whether a field of this kind holds every value v proves. An integer prints +// whole, so its bounds round inward first. +func (k columnKind) holds(v proven) bool { + switch k.datatype { + case DataTypeString, DataTypeBoolean: + return true + case DataTypeInteger: + return math.Ceil(v.lo) >= k.lo && math.Floor(v.hi) <= k.hi + } + return v.lo >= k.lo && v.hi <= k.hi +} + +// checkField rejects a column some render of which a field of Go type ft cannot hold: 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 { + items, nullable := columnItems(column) + elem := ft + if ft.Kind() == reflect.Pointer { + elem = ft.Elem() + } else if nullable { + return fmt.Errorf("%s: its tag can draw null, which %s cannot hold; make it *%s", label, ft, ft) + } + kind := columnKinds[elem.Kind()] + if kind.datatype == DataTypeString { + return nil + } + for _, it := range items { + v := p.of(it) + if reason := v.not[kind.datatype]; reason != "" { + return fmt.Errorf("%s (%s): %s", label, ft, reason) + } + if !kind.holds(v) { + return fmt.Errorf("%s (%s): %q is not proven within %s; make it %s", label, ft, it.format, elem.Kind(), kind.wider) + } + } + return nil +} + +// fill draws the record into v's tagged fields, then each nested struct as a record of its own. +func (s *structShape) fill(sess *session, v reflect.Value) { + if s.record != nil { + for i, c := range renderRecord(sess, s.record, s.columns).columns { + setColumn(fieldAt(v, s.fields[i]), c) + } + } + for _, n := range s.nested { + field := fieldAt(v, n.index) + if field.Kind() == reflect.Pointer { + if field.IsNil() { + field.Set(reflect.New(field.Type().Elem())) + } + field = field.Elem() + } + n.shape.fill(sess, field) + } +} + +// fieldAt is v's field at index, allocating each nil embedded pointer on the way. +func fieldAt(v reflect.Value, index []int) reflect.Value { + for _, i := range index { + if v.Kind() == reflect.Pointer { + if v.IsNil() { + v.Set(reflect.New(v.Type().Elem())) + } + v = v.Elem() + } + v = v.Field(i) + } + return v +} + +// setColumn writes a drawn column into its field: a null as a nil pointer, a value through a +// fresh pointer or straight into the field. +func setColumn(field reflect.Value, c Column) { + if field.Kind() != reflect.Pointer { + setText(field, c.Value) + return + } + if c.Null { + field.SetZero() + return + } + value := reflect.New(field.Type().Elem()) + setText(value.Elem(), c.Value) + field.Set(value) +} + +// setText parses text into a field of one of columnKinds, which checkField proved it parses as. +func setText(field reflect.Value, text string) { + var err error + switch kind := columnKinds[field.Kind()]; { + case kind.datatype == DataTypeString: + field.SetString(text) + case kind.datatype == DataTypeBoolean: + var b bool + b, err = strconv.ParseBool(text) + field.SetBool(b) + case kind.datatype == DataTypeNumber: + var x float64 + x, err = strconv.ParseFloat(text, field.Type().Bits()) + field.SetFloat(x) + case field.CanInt(): + var n int64 + n, err = strconv.ParseInt(text, 10, field.Type().Bits()) + field.SetInt(n) + default: + var n uint64 + n, err = strconv.ParseUint(text, 10, field.Type().Bits()) + field.SetUint(n) + } + if err != nil { + panic(fmt.Sprintf("fejkdata: %q reached a %s field unproven: %v", text, field.Type(), err)) + } +} diff --git a/struct_test.go b/struct_test.go new file mode 100644 index 0000000..7332a32 --- /dev/null +++ b/struct_test.go @@ -0,0 +1,262 @@ +package fejkdata + +import ( + "reflect" + "strings" + "testing" +) + +type structPlace struct { + City string `fake:"place.city"` + Zip string `fake:"place.zip"` +} + +type structUser struct { + Active bool `fake:"[\"true\",\"false\"]"` + Age uint8 `fake:"{int(18,99)}"` + Email string `fake:"{lowercase(/person.first)}@example.com"` + First string `fake:"person.first"` + Home structPlace + ID int64 `fake:"{seq()}"` + Last string `fake:"person.last"` + Level uint8 `fake:"{float(0,255,0)}"` + Nick *string `fake:"[null,\"bo\"]"` + Note string + Rank *int `fake:"{\"format\":\"{r}\",\"r\":[\"1\",\"2\"]}"` + Score float32 `fake:"{float(0,1,2)}"` + Skip *structPlace `fake:"-"` + Work *structPlace + hidden structPlace +} + +type structGiven struct { + First string `fake:"person.first"` +} + +type StructFamily struct { + Last string `fake:"person.last"` +} + +type structEmployee struct { + structGiven + *StructFamily + Email string `fake:"{lowercase(/person.first)}@example.com"` +} + +type structLink struct { + Name string `fake:"person.first"` + Next *structLink +} + +func structData(t *testing.T) *Generator { + t.Helper() + return newGenerator(t, writeData(t, map[string]string{ + "person": `[{"format":"{first} {last}","first":"Ada","last":"Lovelace"},{"format":"{first} {last}","first":"Bo","last":"Ek"}]`, + "place": `[{"format":"{city}","city":"Stockholm","zip":"111 22"},{"format":"{city}","city":"Tranås","zip":"573 31"}]`, + "trip": `{"format":"","leg":[{"format":"{to}","to":"Oslo"},{"format":"{to}","to":"Rome"}]}`, + }), WithSeed(1)) +} + +func TestFakeStructFillsTaggedFields(t *testing.T) { + a, b := structData(t), structData(t) + people := map[string]string{"Ada": "Lovelace", "Bo": "Ek"} + zips := map[string]string{"Stockholm": "111 22", "Tranås": "573 31"} + actives, nils := 0, 0 + for i := 0; i < 100; i++ { + oldNick, oldTwinNick := "old", "old" + u, twin := structUser{Nick: &oldNick, Note: "keep"}, structUser{Nick: &oldTwinNick, Note: "keep"} + if err := a.FakeStruct(&u); err != nil { + t.Fatal(err) + } + if err := b.FakeStruct(&twin); err != nil { + t.Fatal(err) + } + switch { + case !reflect.DeepEqual(u, twin): + t.Fatalf("same seed diverged: %+v != %+v", u, twin) + case people[u.First] != u.Last || u.Email != strings.ToLower(u.First)+"@example.com": + t.Fatalf("person fields %q %q %q, want one person drawn across the struct", u.First, u.Last, u.Email) + case zips[u.Home.City] != u.Home.Zip || u.Work == nil || zips[u.Work.City] != u.Work.Zip: + t.Fatalf("places %+v, %+v, want each nested struct one place, the pointer allocated", u.Home, u.Work) + case u.ID != int64(i+1) || u.Age < 18 || u.Age > 99 || u.Score < 0 || u.Score > 1 || u.Rank == nil || (*u.Rank != 1 && *u.Rank != 2): + t.Fatalf("typed fields %+v, want each the value its tag draws", u) + case u.Nick != nil && *u.Nick != "bo", u.Note != "keep", u.hidden != (structPlace{}), u.Skip != nil, oldNick != "old": + t.Fatalf("%+v: want Nick nil or bo, and the untagged fields left as they were", u) + } + if u.Active { + actives++ + } + if u.Nick == nil { + nils++ + } + } + if actives == 0 || actives == 100 || nils == 0 || nils == 100 { + t.Errorf("100 draws gave %d active and %d nil nicks, want both outcomes of each", actives, nils) + } +} + +func TestFakeStructFillsEmbeddedFieldsIntoItsRecord(t *testing.T) { + f := structData(t) + people := map[string]string{"Ada": "Lovelace", "Bo": "Ek"} + for i := 0; i < 100; i++ { + var e structEmployee + if err := f.FakeStruct(&e); err != nil { + t.Fatal(err) + } + if e.StructFamily == nil || people[e.First] != e.Last || e.Email != strings.ToLower(e.First)+"@example.com" { + t.Fatalf("%+v, %+v: want the promoted fields one person with the struct's own, the embedded pointer allocated", e, e.StructFamily) + } + } + var skipped struct { + structGiven `fake:"-"` + Email string `fake:"{lowercase(/person.first)}@example.com"` + } + if err := f.FakeStruct(&skipped); err != nil || skipped.First != "" || skipped.Email == "" { + t.Errorf("FakeStruct = %v, %+v; want the embedded struct under fake:\"-\" left unfilled beside the filled field", err, skipped) + } +} + +func TestFakeStructDrawsANestedStructApart(t *testing.T) { + f := structData(t) + for i := 0; i < 100; i++ { + var trip struct{ From, To structPlace } + if err := f.FakeStruct(&trip); err != nil { + t.Fatal(err) + } + if trip.From.City != trip.To.City { + return + } + } + t.Error("From and To drew one place in 100 trips; a nested struct is a record of its own, so each draws apart") +} + +func TestFakeStructLeavesAPointerBackAlone(t *testing.T) { + var l structLink + if err := structData(t).FakeStruct(&l); err != nil || l.Name == "" || l.Next != nil { + t.Errorf("FakeStruct = %v, %+v; want Name filled and Next, a pointer back to the struct being filled, left nil", err, l) + } +} + +func TestFakeStructErrors(t *testing.T) { + f := structData(t) + for _, c := range []struct { + v any + want string + }{ + {structUser{}, "fills a struct through a non-nil pointer, got fejkdata.structUser"}, + {(*structUser)(nil), "through a non-nil pointer"}, + {new(int), "through a non-nil pointer"}, + {nil, "through a non-nil pointer"}, + {&struct{ A string }{}, "has no fake tags"}, + {&struct { + a string `fake:"person.first"` + }{}, ".a: unexported"}, + {&struct { + A []string `fake:"person.first"` + }{}, ".A: a fake tag fills a string, bool, integer or float field, or a pointer to one, not []string"}, + {&struct { + A **int `fake:"{int(1,9)}"` + }{}, "not **int"}, + {&struct { + A structPlace `fake:"place"` + }{}, ".A: a struct field fills from the tags on its own fields"}, + {&struct { + A int `fake:"{\"format\":\"{int(1,9)}\",\"datatype\":\"integer\"}"` + }{}, `its Go type int sets the datatype; drop "datatype"`}, + {&struct { + A int `fake:"[null,\"{int(1,9)}\"]"` + }{}, "can draw null, which int cannot hold; make it *int"}, + {&struct { + A int `fake:"{digits(3)}"` + }{}, ".A (int): {digits(3)} prints text, not an integer"}, + {&struct { + A int `fake:"person.first"` + }{}, `"Ada" is not an integer`}, + {&struct { + A int8 `fake:"{int(0,300)}"` + }{}, `"{int(0,300)}" is not proven within int8; make it int64`}, + {&struct { + A uint `fake:"{int(-1,5)}"` + }{}, `"{int(-1,5)}" is not proven within uint; make it int64`}, + {&struct { + A float32 `fake:"[\"1\",\"1e39\"]"` + }{}, `"1e39" is not proven within float32; make it float64`}, + {&struct { + A int32 `fake:"{seq()}"` + }{}, `"{seq()}" is not proven within int32; make it int64`}, + {&struct { + A bool `fake:"{int(0,1)}"` + }{}, "prints an integer, not a boolean"}, + {&struct { + A string `fake:"nope.x"` + }{}, `no entry "nope"`}, + {&struct { + A string `fake:"{/person.first}"` + }{}, "is the path person.first written as a template; write person.first"}, + {&struct { + A string `fake:"\"{/person.first}\""` + }{}, "is the path person.first written as a template; write person.first"}, + {&struct { + A string `fake:"a|b"` + }{}, `contains "|"`}, + {&struct { + A string `fake:""` + }{}, "is empty"}, + {&struct { + A string `fake:"[abc]"` + }{}, `holds a "["`}, + {&struct { + A string `fake:"{.person.first} x"` + }{}, "write {/person.first}"}, + {&struct { + A string `fake:"/person.first"` + }{}, "write person.first"}, + {&struct { + A string `fake:"-"` + }{}, `fake:"-" leaves a struct field unfilled`}, + {&struct{ *structGiven }{}, "an unexported embedded pointer field cannot be set"}, + {&struct { + structGiven + First string `fake:"person.last"` + }{}, "struct.structGiven.First: hidden by another field named First"}, + {&struct { + A string `fake:"{x}"` + }{}, `no field "x"`}, + {&struct { + A string `fake:"trip.leg"` + B string `fake:"trip.leg.to"` + }{}, "reads a path into"}, + {&struct { + Trip struct { + A int `fake:"{digits(3)}"` + } + }{}, ": struct.Trip.A (int): {digits(3)} prints text"}, + } { + err := f.FakeStruct(c.v) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("FakeStruct(%T) = %v, want an error containing %q", c.v, err, c.want) + continue + } + if again := f.FakeStruct(c.v); again == nil || again.Error() != err.Error() { + t.Errorf("FakeStruct(%T) again = %v, want the first call's error, %v", c.v, again, err) + } + } +} + +func TestFakeStructBoundsTheStructsATypeReaches(t *testing.T) { + f := structData(t) + place := reflect.TypeOf(structPlace{}) + tree := place + for depth := 1; depth <= 8; depth++ { + tree = reflect.StructOf([]reflect.StructField{{Name: "L", Type: reflect.PointerTo(tree)}, {Name: "R", Type: reflect.PointerTo(tree)}}) + } + fields := []reflect.StructField{{Name: "L", Type: reflect.PointerTo(tree)}, {Name: "R", Type: reflect.PointerTo(tree)}, {Name: "P", Type: place}} + if err := f.FakeStruct(reflect.New(reflect.StructOf(fields)).Interface()); err != nil { + t.Fatalf("a type reaching 1024 structs: FakeStruct = %v, want it filled", err) + } + embedded := reflect.StructField{Name: "StructFamily", Type: reflect.TypeOf(StructFamily{}), Anonymous: true} + err := f.FakeStruct(reflect.New(reflect.StructOf(append(fields, embedded))).Interface()) + if err == nil || !strings.Contains(err.Error(), "more than 1024 structs") || !strings.Contains(err.Error(), `leave a struct field unfilled with fake:"-"`) { + t.Errorf("a type reaching 1025 structs, the last embedded: FakeStruct = %v, want it refused naming the cap and fake:\"-\"", err) + } +} diff --git a/todo.md b/todo.md index f252672..1e4124b 100644 --- a/todo.md +++ b/todo.md @@ -6,11 +6,6 @@ The record API lands first, so the data update can use it. ### Record API -- Struct-filling — fill a Go struct from `fake:"…"` tags holding a path or an - inline template, for parity with gofakeit and go-faker. The field's Go type is - the column type, through the same conversion and load checks as typed columns, - and a nested struct is its own draw group. Revise the Decision "A record is a - template seen as columns, not a second schema format" with that reason. - Draw groups — references into one category share one draw per render (one record, or one `Fake`) in both views; each `repeat` iteration draws anew, and a bare reference draws each time. An option naming a draw group splits a render @@ -24,8 +19,9 @@ The record API lands first, so the data update can use it. - two bare `{/sv_SE.word}` → two words - Reference inheritance — settle whether a column that is exactly one reference to another record's column, like `{/src.score}`, takes that column's datatype and - null. Today a null there writes `""`, and a typed column reading it is refused. - Settle before draw groups and the data update. + null. Today a null there writes `""`, a `*T` struct field reading it gets `""` + rather than nil, and a typed column reading it is refused. Settle before draw + groups and the data update. ### Data