Struct-filling #14

Merged
lilleman merged 10 commits from struct-filling into main 2026-09-15 17:52:29 +02:00
15 changed files with 966 additions and 87 deletions
+57 -10
View File
@@ -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 — spot. Its tokens reach the data by reference from the root —
`{/sv_SE.person.last}`, so shipped and `--data-path` categories are alike `{/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}` 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 and `{..name}` are rejected naming the root spelling, and one reference alone —
brace, a bracket or a quote, so the two cannot collide (see `{/sv_SE.person}` — is the path written as a template, rejected naming the path, as is
[Decisions](#decisions)). 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 | | | Flag | |
|------|--| |------|--|
@@ -155,6 +156,8 @@ v = t.Fake() // render many times,
r, err := f.FakeRecord("users") // one record: each field a column r, err := f.FakeRecord("users") // one record: each field a column
s := r.JSON() // {"first":"Ada","last":"Lovelace"} s := r.JSON() // {"first":"Ada","last":"Lovelace"}
r, err = f.FakeRecordTemplate(`{"format":"{x}","x":["a","b"]}`) // compile + render inline 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 | | | 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 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, 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 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
stream for that value and everything drawn after it. 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 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. `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 Sources merge in order; matching folders combine, any other clash is won by the
last loaded. Names may not use `.`, `|`, `(`, `{`, `}`, `[`, `]`, `"` or `/`; last loaded. Names may not use `.`, `|`, `(`, `{`, `}`, `[`, `]`, `"` or `/`, nor be
dot-prefixed entries are skipped, so a data directory can also be a checkout. `-`, 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`, Each locale carries `address`, `color`, `company`, `date`, `email`, `ip`,
`person`, `phone`, `price`, `sentence`, `ssn`, `time`, `url`, `username`, `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. one is a load error naming the right one.
3. **Every mistake is a load error** — `New` rejects the data and `NewTemplate` 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 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`: 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; 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. 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 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 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 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 - **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 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 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 - **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 letters, `{uppercase(x)}` is `x` upper-cased; one name for both would turn on
whether the argument looks like a number. 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 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 `--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 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 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 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 - **A record shares one reference draw per category.** Two columns that reference
one category — `{/currency.code}` beside `{/currency.symbol}` — read one draw of one category — `{/currency.code}` beside `{/currency.symbol}` — read one draw of
it, so a record's facts agree the way a template's [correlated 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 path.go the dotted-path walk, and proving a path resolves
render.go Fake and the recursive renderer (choices, format strings, expansions) render.go Fake and the recursive renderer (choices, format strings, expansions)
record.go records: Record, the JSON/CSV/SQL serializers, and their entry points 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 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 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 reference.go reference sigils, and binding references across the tree
+4 -12
View File
@@ -9,7 +9,6 @@ package main
import ( import (
"bufio" "bufio"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -415,20 +414,13 @@ const (
argTemplate argTemplate
) )
// classify reads what a positional argument names by its shape: a { token, or a // classify reads what a positional argument names by its shape (see fejkdata.IsTemplate).
// JSON object, array or string, is an inline template; anything else is a path.
func classify(arg string) (argKind, error) { 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 return argTemplate, nil
} }
if i := strings.IndexAny(arg, `[]}"`); i >= 0 { return argPath, err
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, `"`)
} }
func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) } func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) }
+14 -33
View File
@@ -305,42 +305,22 @@ func TestRunShippedDataByDefault(t *testing.T) {
} }
func TestClassify(t *testing.T) { func TestClassify(t *testing.T) {
for arg, want := range map[string]argKind{ for arg, want := range map[string]argKind{"sv_SE.person": argPath, "name: {x}": argTemplate} {
"sv_SE.person": argPath, if got, err := classify(arg); err != nil || got != want {
"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 {
t.Errorf("classify(%q) = %v, %v; want %v", arg, got, err, want) t.Errorf("classify(%q) = %v, %v; want %v", arg, got, err, want)
} }
} }
for arg, want := range map[string]string{ if _, err := classify("[abc]"); err == nil || !strings.Contains(err.Error(), `holds a "["`) {
"[abc]": `holds a "["`, t.Errorf("classify([abc]) = %v; want it rejected naming the bracket", err)
"[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)
}
} }
} }
func TestUsageReferencesResolve(t *testing.T) { func TestUsageReferencesResolve(t *testing.T) {
for _, token := range regexp.MustCompile(`\{/[^}]+\}`).FindAllString(usage, -1) { 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) == "" { 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) { func TestRunTemplateMisuse(t *testing.T) {
for arg, want := range map[string]string{ for arg, want := range map[string]string{
"{bad": "unterminated", "{bad": "unterminated",
"[red,green]": "names no template either", "[red,green]": "names no template either",
`{"format":"x"}`: "is a string", `{"format":"x"}`: "is a string",
"{/no.such.path}": "no entry", "name: {/no.such.path}": "no entry",
"x[1]": "names no template either", "{/sv_SE.person}": "write sv_SE.person",
` ["a","b"] `: "may not be padded", "x[1]": "names no template either",
` ["a","b"] `: "may not be padded",
} { } {
code, out, errb := runOut("--seed", "1", arg) code, out, errb := runOut("--seed", "1", arg)
if code != 2 || out != "" || !strings.Contains(errb, "try 'fejkdata --help'") || !strings.Contains(errb, want) { if code != 2 || out != "" || !strings.Contains(errb, "try 'fejkdata --help'") || !strings.Contains(errb, want) {
+20 -13
View File
@@ -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 // 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. // column holds one; a column only ever null is a string.
func columnDatatype(n node) (DataType, error) { func columnDatatype(n node) (DataType, error) {
var items []*template items, _ := columnItems(n)
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)
if len(items) == 0 { if len(items) == 0 {
return DataTypeString, nil return DataTypeString, nil
} }
@@ -91,6 +79,25 @@ func columnDatatype(n node) (DataType, error) {
return items[0].datatype, nil 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. // disagreement names the fix for two items of one column declaring different datatypes.
func disagreement(a, b *template) error { func disagreement(a, b *template) error {
typed, bare := a, b typed, bare := a, b
+2
View File
@@ -21,6 +21,7 @@ import (
"io/fs" "io/fs"
"math/rand/v2" "math/rand/v2"
"os" "os"
"reflect"
"sort" "sort"
"sync" "sync"
) )
@@ -46,6 +47,7 @@ type Generator struct {
rand *session rand *session
categories map[string]node categories map[string]node
records map[node]recordShape records map[node]recordShape
structs map[reflect.Type]structResult
} }
// session is one generator's mutable render state: the seeded rng plus the {seq()} // session is one generator's mutable render state: the seeded rng plus the {seq()}
+2 -2
View File
@@ -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) } return func(fn func(path string, n node) error) error { return walkNodes(root, fn) }
} }
func inlineScope(n node) nodeScope { func inlineScope(n node, label string) nodeScope {
return func(fn func(path string, m node) error) error { return eachNode(n, "template", fn) } 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 // checkScope runs the per-node fences over a scope, each over the whole scope
+90 -9
View File
@@ -32,11 +32,7 @@ func (f *Generator) NewTemplate(input string) (*Template, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("fejkdata: %w", err) return nil, fmt.Errorf("fejkdata: %w", err)
} }
scope := inlineScope(n) if err := bindInline(n, "template", f.categories); err != nil {
if err := linkNodeRefs(scope, f.categories); err != nil {
return nil, fmt.Errorf("fejkdata: %w", err)
}
if err := checkScope(scope); err != nil {
return nil, fmt.Errorf("fejkdata: %w", err) return nil, fmt.Errorf("fejkdata: %w", err)
} }
return &Template{g: f, n: n}, nil return &Template{g: f, n: n}, nil
@@ -53,17 +49,102 @@ func (f *Generator) FakeTemplate(input string) (string, error) {
return t.Fake(), nil return t.Fake(), nil
} }
// compileInput compiles an inline template: a JSON value, or a bare format string // IsTemplate reports whether arg is an inline template rather than a path, by its shape: a {
// when the input is not JSON. // 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) { 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 var raw any
if err := json.Unmarshal([]byte(input), &raw); err != nil { if err := json.Unmarshal([]byte(input), &raw); err != nil {
return compile(input) return input, nil
} }
if trimmed := strings.TrimSpace(input); trimmed != input { 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 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 // linkNodeRefs binds the references in an inline node's templates against the
+45
View File
@@ -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) { func TestFakeTemplateRepeatBound(t *testing.T) {
f := shipped(t) f := shipped(t)
_, err := f.FakeTemplate(`{"format":"{x}","repeat":200,"x":{"format":"{y}","repeat":200,"y":{"format":"z","repeat":200}}}`) _, err := f.FakeTemplate(`{"format":"{x}","repeat":200,"x":{"format":"{y}","repeat":200,"y":{"format":"z","repeat":200}}}`)
+4
View File
@@ -298,6 +298,10 @@ func TestNewErrors(t *testing.T) {
map[string]string{"a(b/cat": `"1"`}, map[string]string{"a(b/cat": `"1"`},
`folder "a(b" contains "("`, `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. // A repeated arm skews an alternation, which weight is the spelling for.
"repeated alternation arm": { "repeated alternation arm": {
map[string]string{"a": `{"format":"{x|x}","x":"1"}`}, map[string]string{"a": `{"format":"{x|x}","x":"1"}`},
+15 -1
View File
@@ -378,13 +378,17 @@ const reservedInName = ".|({}/[]\""
// reservedList spells reservedInName for an error message, so the two cannot drift. // reservedList spells reservedInName for an error message, so the two cannot drift.
var reservedList = strings.Join(strings.Split(reservedInName, ""), " ") 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 // Both a category or folder and a field go through it, so there is one answer to
// what a name may contain. // what a name may contain.
func checkName(name string) error { func checkName(name string) error {
if name == "" { if name == "" {
return fmt.Errorf("%q is empty, which is not a path segment, so List never offers it", 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 { 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", 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) name, name[i:i+1], reservedList)
@@ -392,6 +396,16 @@ func checkName(name string) error {
return nil 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 // isOption reports whether a template key configures the node instead of naming a
// field. These names can never be fields. // field. These names can never be fields.
func isOption(name string) bool { func isOption(name string) bool {
+19
View File
@@ -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 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 BenchmarkNestedDepth100(b *testing.B) { benchPath(b, tmpData(b, "deep", nestedJSON(100)), "deep") }
func BenchmarkWideTokens100(b *testing.B) { func BenchmarkWideTokens100(b *testing.B) {
+7
View File
@@ -118,6 +118,13 @@ func TestFakeIsSafeForConcurrentUse(t *testing.T) {
return return
} }
tmpl.Fake() tmpl.Fake()
var u struct {
Last string `fake:"sv_SE.person.last"`
}
if err := f.FakeStruct(&u); err != nil {
t.Error(err)
return
}
} }
}() }()
} }
+422
View File
@@ -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))
}
}
+262
View File
@@ -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)
}
}
+3 -7
View File
@@ -6,11 +6,6 @@ The record API lands first, so the data update can use it.
### Record API ### 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 - 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 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 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 - two bare `{/sv_SE.word}` → two words
- Reference inheritance — settle whether a column that is exactly one reference to - 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 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. null. Today a null there writes `""`, a `*T` struct field reading it gets `""`
Settle before draw groups and the data update. rather than nil, and a typed column reading it is refused. Settle before draw
groups and the data update.
### Data ### Data