diff --git a/README.md b/README.md index 2cda366..f6d64b0 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,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 +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 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 when drawn from one goroutine. Changing how a value is composed shifts the seeded 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. 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 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`: 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. @@ -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 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 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 +703,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..e005fc5 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))) { - return argTemplate, nil + inline, err := fejkdata.IsTemplate(arg) + if inline { + return argTemplate, err } - 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/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..92dcdd7 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,61 @@ 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. +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) { + 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/struct.go b/struct.go new file mode 100644 index 0000000..19a05c2 --- /dev/null +++ b/struct.go @@ -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)) + } +} diff --git a/todo.md b/todo.md index f252672..282f2dd 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