Struct-filling: FakeStruct fills fake-tagged fields as a record, each field's Go type its datatype, and IsTemplate owns the path-or-template shape rule
Tests / vet + fmt + tests (pull_request) Successful in 1m0s

This commit is contained in:
2026-09-15 16:23:10 +02:00
parent d4ff3b242e
commit c0b176a839
8 changed files with 424 additions and 46 deletions
+34 -4
View File
@@ -155,6 +155,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 +172,28 @@ 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. A value the kind cannot hold, such as
`{int(0,300)}` in a `uint8`, is refused as a typed column's is. A 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. Untagged fields keep their values, and so does a pointer back
to a struct already being filled. The first call for a type compiles its tags and
reports what they get wrong: a tag holding only `{/path}` names the path to write, and
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.
@@ -473,7 +497,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 type its tags do not describe, 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.
@@ -579,13 +604,17 @@ 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 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 +703,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
+5 -13
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)
return argTemplate, nil if inline {
return argTemplate, err
} }
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)) }
+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
+49 -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,61 @@ 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.
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))) {
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])
}
return false, nil
}
func isJSONStart(arg string) bool {
return strings.HasPrefix(arg, "[") || strings.HasPrefix(arg, `"`)
}
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
+312
View File
@@ -0,0 +1,312 @@
package fejkdata
import (
"errors"
"fmt"
"math"
"reflect"
"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. A 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
}
// structResult is what compiling a struct type settled: its shape, or why it cannot be filled.
type structResult struct {
shape *structShape
err error
}
// 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
}
shape, err := compileStruct(f.categories, t, t.String(), map[reflect.Type]bool{})
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 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 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 }
// compileStruct compiles struct type t, naming its fields from label. visiting holds the types
// compiling above t, so a pointer back to one is left alone rather than filled without end.
func compileStruct(root map[string]node, t reflect.Type, label string, visiting map[reflect.Type]bool) (*structShape, error) {
visiting[t] = true
defer delete(visiting, t)
shape := &structShape{}
tags := map[string]any{}
for i := 0; i < t.NumField(); i++ {
sf := t.Field(i)
tag, tagged := sf.Tag.Lookup("fake")
if !tagged {
if err := shape.addNested(root, sf, label, visiting); err != nil {
return nil, err
}
continue
}
v, err := tagValue(sf, tag)
if err != nil {
return nil, fmt.Errorf("%s.%s: %w", label, sf.Name, err)
}
tags[sf.Name] = v
}
if len(tags) > 0 {
if err := shape.compileRecord(root, t, label, tags); err != nil {
return nil, err
}
}
return shape, nil
}
// addNested adds an untagged exported struct field, or a pointer to one, that carries tags.
func (s *structShape) addNested(root map[string]node, sf reflect.StructField, label string, visiting map[reflect.Type]bool) error {
t := sf.Type
if t.Kind() == reflect.Pointer {
t = t.Elem()
}
if !sf.IsExported() || t.Kind() != reflect.Struct || visiting[t] {
return nil
}
nested, err := compileStruct(root, t, label+"."+sf.Name, visiting)
if err != nil || nested.empty() {
return err
}
s.nested = append(s.nested, nestedStruct{sf.Index[0], 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)
if err != nil {
return nil, err
}
if !inline {
for _, seg := range strings.Split(tag, ".") {
if err := checkName(seg); err != nil {
return nil, fmt.Errorf("path %w", err)
}
}
return "{/" + tag + "}", nil
}
v, err := inputValue(tag)
if s, isString := v.(string); isString && isLoneReference(s) {
path := s[2 : len(s)-1]
return nil, fmt.Errorf("%s is the path %s written as a template; write fake:%q", s, path, path)
}
return v, err
}
// isLoneReference reports whether a format is one {/path} token alone, which a path tag spells.
func isLoneReference(format string) bool {
return len(format) > len("{/}") && strings.HasPrefix(format, "{/") && strings.HasSuffix(format, "}") &&
!strings.ContainsAny(format[1:len(format)-1], "{}|(")
}
// checkTaggedType rejects a tagged field no column can fill.
func checkTaggedType(sf reflect.StructField) error {
elem := sf.Type
if elem.Kind() == reflect.Pointer {
elem = elem.Elem()
}
_, holds := columnKinds[elem.Kind()]
switch {
case !sf.IsExported():
return errors.New("unexported, so its fake tag cannot fill it")
case holds:
return nil
case elem.Kind() == reflect.Struct:
return errors.New("a struct field fills from the tags on its own fields; drop this one")
}
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[0]
}
s.record, s.columns = record, columns
return nil
}
// columnKind is what a field of one Go kind holds: the datatype its text proves as, and the
// range its value stays in.
type columnKind struct {
datatype DataType
lo, hi float64
}
var columnKinds = map[reflect.Kind]columnKind{
reflect.Bool: {DataTypeBoolean, -math.MaxFloat64, math.MaxFloat64},
reflect.Float32: {DataTypeNumber, -math.MaxFloat32, math.MaxFloat32},
reflect.Float64: {DataTypeNumber, -math.MaxFloat64, math.MaxFloat64},
reflect.Int: {DataTypeInteger, math.MinInt, math.MaxInt},
reflect.Int16: {DataTypeInteger, math.MinInt16, math.MaxInt16},
reflect.Int32: {DataTypeInteger, math.MinInt32, math.MaxInt32},
reflect.Int64: {DataTypeInteger, math.MinInt64, math.MaxInt64},
reflect.Int8: {DataTypeInteger, math.MinInt8, math.MaxInt8},
reflect.String: {DataTypeString, -math.MaxFloat64, math.MaxFloat64},
reflect.Uint: {DataTypeInteger, 0, math.MaxUint},
reflect.Uint16: {DataTypeInteger, 0, math.MaxUint16},
reflect.Uint32: {DataTypeInteger, 0, math.MaxUint32},
reflect.Uint64: {DataTypeInteger, 0, math.MaxUint64},
reflect.Uint8: {DataTypeInteger, 0, math.MaxUint8},
}
// 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)
reason := v.not[kind.datatype]
if reason == "" && (v.lo < kind.lo || v.hi > kind.hi) {
reason = fmt.Sprintf("%q is not proven within %s", it.format, elem.Kind())
}
if reason != "" {
return fmt.Errorf("%s (%s): %s", label, ft, reason)
}
}
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(v.Field(s.fields[i]), c)
}
}
for _, n := range s.nested {
field := v.Field(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)
}
}
// 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))
}
}
-5
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