Answer architecture round 1: refuse a name read inside the field it binds, check every scope's cycles, build a column read in one pass, drop the implicit struct pick for names across tags, and state the fresh-draw rule once

This commit is contained in:
2026-10-05 09:39:18 +02:00
committed by Lillem4n
parent a45edf8fb5
commit 1a15a139c8
23 changed files with 257 additions and 230 deletions
-1
View File
@@ -37,7 +37,6 @@ In [docs/decisions.md](docs/decisions.md):
- `{a|b}` stays beside nested choices - `{a|b}` stays beside nested choices
- Flags follow getopt_long - Flags follow getopt_long
- An argument is a template by its shape, not by a flag - An argument is a template by its shape, not by a flag
- An inline template skips the cycle fence
- An inline template that does not compile is misuse (exit 2), including a reference that resolves to nothing - An inline template that does not compile is misuse (exit 2), including a reference that resolves to nothing
- A padded JSON argument is rejected, not trimmed - A padded JSON argument is rejected, not trimmed
- `FakeTemplate` and `NewTemplate` both stay - `FakeTemplate` and `NewTemplate` both stay
+2 -2
View File
@@ -20,8 +20,8 @@ replacement, and each removed path, column or flag.
- Every `{…}` draws afresh, and a name is the one way to keep a pick: `{p.a} {p.b}`, - Every `{…}` draws afresh, and a name is the one way to keep a pick: `{p.a} {p.b}`,
`{w} {uppercase(w)}` and `{/person.first} {/person.last}` are two draws each, which `{w} {uppercase(w)}` and `{/person.first} {/person.last}` are two draws each, which
`{p as q}{q.a} {q.b}` and `{/person as n}{n.first} {n.last}` make one. `{x as n}` `{p as q}{q.a} {q.b}` and `{/person as n}{n.first} {n.last}` make one. `{x as n}`
binds a sibling field as well as a reference, and a struct's path tags into one binds a sibling field as well as a reference, and a struct's tags share its names,
category read one pick of it. `drawGroup` is no longer an option, and `New` no longer `{/sv_SE.person as p}{p.first}` beside `{p.sex}`. `drawGroup` is no longer an option, and `New` no longer
refuses a read beside a path into what it reads, or reads of one table family that refuses a read beside a path into what it reads, or reads of one table family that
could disagree. could disagree.
- First release: the CLI, the library and the shipped data set. - First release: the CLI, the library and the shipped data set.
+28 -26
View File
@@ -136,8 +136,7 @@ it as nothing.
A record is one render, so its columns read one pick of each [name](#names) its category A record is one render, so its columns read one pick of each [name](#names) its category
binds: `"code": "{/currency as c}{c.code}"` beside `"symbol": "{c.symbol}"` is one binds: `"code": "{/currency as c}{c.code}"` beside `"symbol": "{c.symbol}"` is one
currency. Every other `{…}` draws afresh, so `{/currency.code}` and `{/currency.symbol}` currency, while `{/currency.code}` and `{/currency.symbol}` in two columns may name two.
in two columns may name two currencies.
A category never references itself: `{/users.first}` or a bare `{/users}` inside `users` A category never references itself: `{/users.first}` or a bare `{/users}` inside `users`
describes a draw other than the fields beside it. Read a sibling as a field, and put a describes a draw other than the fields beside it. Read a sibling as a field, and put a
@@ -337,12 +336,12 @@ a column of one record, its tag a path or an inline template — told apart by
[datatype](#datatype): a string, bool, integer or float kind, or a pointer to one, [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 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 kind, and a value the kind cannot hold, such as `{int(0,300)}` in a `uint8`, is refused
naming a kind that holds it. Path tags reading into one category, two or more of them naming a kind that holds it. The tags are a record's columns, so they share its
and none selecting a row, read one pick of it, as a record's columns read one name: [names](#names): `First` tagged `{/sv_SE.person as p}{p.first}` and `Sex` tagged `{p.sex}`
`sv_SE.person.first` and `sv_SE.person.sex` describe one person, while an inline template describe one person, while two path tags into `sv_SE.person` are two draws. The fields an
in a tag draws afresh. The fields an embedded struct promotes are columns of the embedded struct promotes are columns of the same record; a named struct field, or a
same record; a named struct field, or a pointer to one, fills from its own tags as a pointer to one, fills from its own tags as a record of its own, so it picks apart from its
record of its own, so it picks apart from its parent. `fake:"-"` leaves a parent. `fake:"-"` leaves a
struct field, embedded or named, or a pointer to one, unfilled. Untagged fields keep 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 their values, and so does a pointer back to a struct already being filled; a type
whose fields reach more than 1024 structs is refused, naming `fake:"-"` to cut it. The whose fields reach more than 1024 structs is refused, naming `fake:"-"` to cut it. The
@@ -371,13 +370,16 @@ Every character is literal except a `{…}` token:
| Token | Renders | | Token | Renders |
|-------|---------| |-------|---------|
| `{name}` | the sibling field `name`, or the pick bound to the name ([Names](#names)) | | `{name}` | the sibling field `name`, or the pick bound to the name ([Names](#names)) |
| `{x as name}` | nothing: binds one pick of `x`, a reference or a sibling field, to `name` ([Names](#names)) | | `{x as name}` | nothing: binds one pick of `x`, a reference or a path into a sibling field, to `name` ([Names](#names)) |
| `{name.field}` | `field` of a fresh draw of the sibling `name`, or of the pick bound to it ([Correlated fields](#correlated-fields)) | | `{name.field}` | `field` of a fresh draw of the sibling `name`, or of the pick bound to it ([Correlated fields](#correlated-fields)) |
| `{a\|b}` | one of the named fields, even odds | | `{a\|b}` | one of the named fields, even odds |
| `{fn(args)}` | a builtin ([Functions](#functions)) | | `{fn(args)}` | a builtin ([Functions](#functions)) |
| `{/path}`, `{.path}`, `{..path}` | a node reached from the data root, this file's folder, or the folder above ([References](#references)) | | `{/path}`, `{.path}`, `{..path}` | a node reached from the data root, this file's folder, or the folder above ([References](#references)) |
| `{{`, `}}` | a literal `{` or `}` | | `{{`, `}}` | a literal `{` or `}` |
Each `{…}` makes a new pick, so `{w} {w}`, `{p.a} {p.b}` and `{/person.first}
{/person.last}` are two draws each. A [name](#names) keeps one pick for every read of it.
```json ```json
"100 Main St, Apt {int(1,9)}{upper(1)} — tel {digits(3)}-{digits(4)}" "100 Main St, Apt {int(1,9)}{upper(1)} — tel {digits(3)}-{digits(4)}"
``` ```
@@ -444,8 +446,8 @@ order.id: datatype integer: {digits(3)} prints text, not an integer
order.id: datatype integer: "1{digits(2)}" is not one value; write one literal or one {float()}, {int()}, {seq()} or {calc()}, or read one order.id: datatype integer: "1{digits(2)}" is not one value; write one literal or one {float()}, {int()}, {seq()} or {calc()}, or read one
``` ```
A column of one reference alone to another record's column — `"score": "{/src.score}"` A column of one reference or name read alone to another record's column —
— is that column: it takes the column's datatype and is null where the column is, and `"score": "{/src.score}"`, or `"score": "{s.score}"` with `s` bound to `/src` — is that column: it takes the column's datatype and is null where the column is, and
a struct field tagged `src.score` is nil there. A `datatype` of its own types the a struct field tagged `src.score` is nil there. A `datatype` of its own types the
column's values where they prove it, and one restating the datatype it takes is refused. column's values where they prove it, and one restating the datatype it takes is refused.
Any other read renders the column's text, a null as `""`. Any other read renders the column's text, a null as `""`.
@@ -668,8 +670,7 @@ literals, sibling fields and [names](#names), each rendered then read as a numbe
a second argument rounds to that many decimals. A hyphen is always subtraction, so a a second argument rounds to that many decimals. A hyphen is always subtraction, so a
hyphenated field or name can't be an operand. hyphenated field or name can't be an operand.
Each `{…}` draws afresh, so a calc computes from the values its format shows only where A calc computes from the values its format shows where both read one [name](#names):
both read one [name](#names):
```json ```json
{ "format": "{net as n}{qty as q}{n} x {q} = {calc(n * q, 2)}", "net": ["19.99", "5.00"], "qty": ["3", "7"] } { "format": "{net as n}{qty as q}{n} x {q} = {calc(n * q, 2)}", "net": ["19.99", "5.00"], "qty": ["3", "7"] }
@@ -687,8 +688,8 @@ fail, except in a [typed column](#datatype), which must prove neither happens.
`{lowercase(x)}`, `{uppercase(x)}` and `{ascii(x)}` rewrite the value of `x` — a `{lowercase(x)}`, `{uppercase(x)}` and `{ascii(x)}` rewrite the value of `x` — a
field, a path, a `..path` or a [name](#names) — and nest. `ascii` folds Latin letters (`Åsa Öberg` field, a path, a `..path` or a [name](#names) — and nest. `ascii` folds Latin letters (`Åsa Öberg`
→ `Asa Oberg`) and drops any other non-ASCII rune. `x` draws afresh like any `{…}`, so an → `Asa Oberg`) and drops any other non-ASCII rune. An email
email matches the name beside it where both read one name: matches the name beside it where both read one name:
```json ```json
{ "format": "{person as p}{p.first} {p.last} <{lowercase(ascii(p.first))}.{lowercase(ascii(p.last))}@example.com>", { "format": "{person as p}{p.first} {p.last} <{lowercase(ascii(p.first))}.{lowercase(ascii(p.last))}@example.com>",
@@ -714,9 +715,8 @@ without naming `sv_SE`:
"Hej, {/en_US.person}!" "Hej, {/en_US.person}!"
``` ```
Renders e.g. `Hej, Pat Smith!`. Each reference draws afresh, a path into a category Renders e.g. `Hej, Pat Smith!`. `{.person.first} {.person.last}` may name two people, as
too: `{.person.first} {.person.last}` may name two people, and `{/misc.uuid} {/misc.uuid}` `{/misc.uuid} {/misc.uuid}` is two draws ([Format string](#format-string)). Bind the person to a [name](#names) to read one: `{.person as p}{p.first}
is two draws. Bind the person to a [name](#names) to read one: `{.person as p}{p.first}
{p.last}`. Rejected at `New`: a path that is {p.last}`. Rejected at `New`: a path that is
unknown, names a folder, has no folder above, reads a field not every variant unknown, names a folder, has no folder above, reads a field not every variant
of a choice carries, or names the category the reference sits in, and a reference of a choice carries, or names the category the reference sits in, and a reference
@@ -724,8 +724,8 @@ that leads back to its own value, directly, mutually or through a chain.
### Names ### Names
`{x as n}` binds one pick of `x`, a reference or a sibling field, to the name `n` and prints `{x as n}` binds one pick of `x`, a reference or a path into a sibling field, to the name `n`
nothing. `{n}` then and prints nothing. `{n}` then
prints that pick, and `{n.path}` reads a path through it, so every read of `n` describes one prints that pick, and `{n.path}` reads a path through it, so every read of `n` describes one
row: row:
@@ -754,6 +754,8 @@ Refused at `New`, each error naming what to write instead:
and outside it; and outside it;
- a name that is a field or an option; - a name that is a field or an option;
- a binding of anything but a reference or a path into a field of the template binding it; - a binding of anything but a reference or a path into a field of the template binding it;
- a read of a name inside the field it binds, which would render itself: read it outside the
field;
- a binding in a choice's item, which every other item would leave unbound; - a binding in a choice's item, which every other item would leave unbound;
- a binding nothing reads, or one read once outside a `repeat` nested where it is bound, - a binding nothing reads, or one read once outside a `repeat` nested where it is bound,
which the spelling it binds draws the same way: `{/word as w}{w}` is `{/word}`, and which the spelling it binds draws the same way: `{/word as w}{w}` is `{/word}`, and
@@ -766,8 +768,8 @@ Refused at `New`, each error naming what to write instead:
### Correlated fields ### Correlated fields
`{name.field}` reads a path into a sibling, drawing it afresh as any `{…}` does. Several `{name.field}` reads a path into a sibling. Several tokens read one draw of it through a
tokens read one draw of it through a [name](#names): bind the sibling, and every read of [name](#names): bind the sibling, and every read of
the name reads that one row — a locality and the postal code that really covers it: the name reads that one row — a locality and the postal code that really covers it:
```json ```json
@@ -780,8 +782,8 @@ the name reads that one row — a locality and the postal code that really cover
``` ```
Renders e.g. `Kungsgatan 35` / `176 99 Stockholm`, never a Stockholm code beside Renders e.g. `Kungsgatan 35` / `176 99 Stockholm`, never a Stockholm code beside
Tranås; each row's `weight` says how often it appears. A name keeps every level its Tranås; each row's `weight` says how often it appears. `{p.geo.town.name}
reads pass: `{p.geo.town.name} {p.geo.town.zip}` share the town. Every variant of a choice {p.geo.town.zip}` share the town too, as a name keeps every level its reads pass. Every variant of a choice
on the path must carry the rest of it, so a row missing a field is named at load: on the path must carry the rest of it, so a row missing a field is named at load:
```text ```text
@@ -952,14 +954,14 @@ node.go the node model and JSON -> node compilation
table.go tables: the rows TSV, its options and links, row selection and draws table.go tables: the rows TSV, its options and links, row selection and draws
name.go names: {x as n} bindings, the scopes they live in, and the reads they answer name.go names: {x as n} bindings, the scopes they live in, and the reads they answer
namefence.go the load fences over names namefence.go the load fences over names
pick.go a render's named picks: the frames of the name scopes rendering, and the reads kept under a name pick.go a render's named picks: the frames of the name scopes rendering, the memo a pick keeps, and the reads kept under it
scope.go the scope a render reads names and rows through
path.go the dotted-path walks with their selectors, and proving a path resolves path.go the dotted-path walks with their selectors, and proving a path resolves
render.go Fake, the recursive renderer (choices, format strings, expansions) and the reads its tokens make render.go Fake, the recursive renderer (choices, format strings, expansions) and the reads its tokens make
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
struct.go structs: FakeStruct, fake tags, the one pick a struct's path tags into one category read, and a field's Go type as its column's datatype struct.go structs: FakeStruct, fake tags, the one pick a struct's path tags into one category read, and a field's Go type as its column's datatype
inline.go inline templates: Template, NewTemplate, FakeTemplate, IsTemplate, and their compile and link inline.go inline templates: Template, NewTemplate, FakeTemplate, IsTemplate, and their compile and link
format.go the {token} grammar: scanning, tokens, operands, validation, compiling a format format.go the {token} grammar: scanning, tokens, operands, validation, compiling a format
draws.go the memo a named pick keeps its draws in, and the scope a render reads names and rows through
pins.go the pin set: the table rows one path or one named pick fixes pins.go the pin set: the table rows one path or one named pick fixes
reference.go reference sigils, and resolving references across the tree reference.go reference sigils, and resolving references across the tree
graph.go the node graph: tree walks, containment and render edges, and the cycle and repeat fences graph.go the node graph: tree walks, containment and render edges, and the cycle and repeat fences
+13 -14
View File
@@ -57,30 +57,29 @@ func BenchmarkRepeat(b *testing.B) {
benchPath(b, dir, "many") benchPath(b, dir, "many")
} }
// BenchmarkHeld measures a format that holds a head and reads two paths from it — // BenchmarkNamed measures a format that binds a field to a name and reads two paths through
// what a correlated pair costs against BenchmarkUnheld, the same output drawn from // it — what a correlated pair costs against BenchmarkUnnamed, the same output drawn from two
// two independent fields. // independent fields.
func BenchmarkHeld(b *testing.B) { func BenchmarkNamed(b *testing.B) {
dir := tmpData(b, "addr", `{"format":"{place.postal-code} {place.locality}","place":[{"format":"{locality}","locality":"Stockholm","postal-code":"1{digits(2)} {digits(2)}"},{"format":"{locality}","locality":"Tranås","postal-code":"573 {digits(2)}"}]}`) dir := tmpData(b, "addr", `{"format":"{place as p}{p.postal-code} {p.locality}","place":[{"format":"{locality}","locality":"Stockholm","postal-code":"1{digits(2)} {digits(2)}"},{"format":"{locality}","locality":"Tranås","postal-code":"573 {digits(2)}"}]}`)
benchPath(b, dir, "addr") benchPath(b, dir, "addr")
} }
func BenchmarkUnheld(b *testing.B) { func BenchmarkUnnamed(b *testing.B) {
dir := tmpData(b, "addr", `{"format":"{postal-code} {locality}","postal-code":["1{digits(2)} {digits(2)}","573 {digits(2)}"],"locality":["Stockholm","Tranås"]}`) dir := tmpData(b, "addr", `{"format":"{postal-code} {locality}","postal-code":["1{digits(2)} {digits(2)}","573 {digits(2)}"],"locality":["Stockholm","Tranås"]}`)
benchPath(b, dir, "addr") benchPath(b, dir, "addr")
} }
// BenchmarkHeldDeep reads two paths through an intermediate level, the shape the // BenchmarkNamedDeep reads two paths through a name and an intermediate level.
// depth question is about. func BenchmarkNamedDeep(b *testing.B) {
func BenchmarkHeldDeep(b *testing.B) { dir := tmpData(b, "addr", `{"format":"{p as q}{q.addr.city} {q.addr.zip}","p":{"format":"{addr}","addr":{"format":"{city}","city":["Stockholm","Tranås"],"zip":"1{digits(2)} {digits(2)}"}}}`)
dir := tmpData(b, "addr", `{"format":"{p.addr.city} {p.addr.zip}","p":{"format":"{addr}","addr":{"format":"{city}","city":["Stockholm","Tranås"],"zip":"1{digits(2)} {digits(2)}"}}}`)
benchPath(b, dir, "addr") benchPath(b, dir, "addr")
} }
// BenchmarkHeldWide reads ten paths from one row: the point where the maps an // BenchmarkNamedWide reads ten paths through one name: the point where the maps a pick keeps
// expansion holds outgrow a single bucket. // outgrow a single bucket.
func BenchmarkHeldWide(b *testing.B) { func BenchmarkNamedWide(b *testing.B) {
dir := tmpData(b, "row", `{"format":"{r.a}{r.b}{r.c}{r.d}{r.e}{r.f}{r.g}{r.h}{r.i}{r.j}","r":[ dir := tmpData(b, "row", `{"format":"{r as s}{s.a}{s.b}{s.c}{s.d}{s.e}{s.f}{s.g}{s.h}{s.i}{s.j}","r":[
{"format":"x","a":"1","b":"2","c":"3","d":"4","e":"5","f":"6","g":"7","h":"8","i":"9","j":"0"}, {"format":"x","a":"1","b":"2","c":"3","d":"4","e":"5","f":"6","g":"7","h":"8","i":"9","j":"0"},
{"format":"y","a":"A","b":"B","c":"C","d":"D","e":"E","f":"F","g":"G","h":"H","i":"I","j":"J"}]}`) {"format":"y","a":"A","b":"B","c":"C","d":"D","e":"E","f":"F","g":"G","h":"H","i":"I","j":"J"}]}`)
benchPath(b, dir, "row") benchPath(b, dir, "row")
+6 -11
View File
@@ -232,9 +232,8 @@ func inlineScope(n node, label string) nodeScope {
// binding is one scope's way through bind, the one load pipeline; its fields are where // binding is one scope's way through bind, the one load pipeline; its fields are where
// scopes differ. // scopes differ.
type binding struct { type binding struct {
scope nodeScope scope nodeScope
link func() error link func() error
scopeFence func() error
// Set by a caller that proves the columns against their Go types itself. // Set by a caller that proves the columns against their Go types itself.
typedByGo bool typedByGo bool
} }
@@ -251,7 +250,6 @@ func categoryBinding(sites []categorySite, root map[string]node) binding {
} }
return linkRefs(sites, root) return linkRefs(sites, root)
}, },
scopeFence: func() error { return checkNoCycles(sites) },
} }
} }
@@ -259,10 +257,8 @@ func (b binding) bind() error {
if err := b.link(); err != nil { if err := b.link(); err != nil {
return err return err
} }
if b.scopeFence != nil { if err := checkNoCycles(b.scope); err != nil {
if err := b.scopeFence(); err != nil { return err
return err
}
} }
if !b.typedByGo { if !b.typedByGo {
if err := checkColumns(b.scope); err != nil { if err := checkColumns(b.scope); err != nil {
@@ -274,9 +270,8 @@ func (b binding) bind() error {
// checkNodeFences runs the per-node fences every binding needs over a scope, each // checkNodeFences runs the per-node fences every binding needs over a scope, each
// over the whole scope before the next, so which of several broken nodes is reported // over the whole scope before the next, so which of several broken nodes is reported
// does not depend on the walk. Its walks recurse unguarded, so the scope's cycles are // does not depend on the walk. Its walks recurse unguarded, so bind refuses the scope's cycles
// refused first: categories by checkNoCycles, and an inline node closes none, since nothing // first.
// references it.
func checkNodeFences(s nodeScope) error { func checkNodeFences(s nodeScope) error {
mem := renderCounts{} mem := renderCounts{}
if err := s(func(path string, n node) error { return repeatCheck(path, n, mem) }); err != nil { if err := s(func(path string, n node) error { return repeatCheck(path, n, mem) }); err != nil {
+6 -6
View File
@@ -330,8 +330,8 @@ func TestShippedSwedishAddress(t *testing.T) {
} }
// TestShippedAddressIsOneNamedPick pins that an address's street, postal code, // TestShippedAddressIsOneNamedPick pins that an address's street, postal code,
// locality and region are one place in the geo tables, read through a name or a struct's // locality and region are one place in the geo tables, read through a name in a template or
// tags, and that a reference beside the name is another address. // across a struct's tags, and that a reference beside the name is another address.
func TestShippedAddressIsOneNamedPick(t *testing.T) { func TestShippedAddressIsOneNamedPick(t *testing.T) {
f := newGenerator(t, "data", WithSeed(3)) f := newGenerator(t, "data", WithSeed(3))
for _, c := range []struct{ locale, country, template, largest string }{ for _, c := range []struct{ locale, country, template, largest string }{
@@ -354,16 +354,16 @@ func TestShippedAddressIsOneNamedPick(t *testing.T) {
} }
if c.country == "SE" { if c.country == "SE" {
var row struct { var row struct {
Street string `fake:"sv_SE.address.street"` Street string `fake:"{/sv_SE.address as a}{a.street}"`
PostalCode string `fake:"sv_SE.address.postal-code"` PostalCode string `fake:"{a.postal-code}"`
Locality string `fake:"sv_SE.address.locality"` Locality string `fake:"{a.locality}"`
} }
for i := 0; i < 200; i++ { for i := 0; i < 200; i++ {
if err := f.FakeStruct(&row); err != nil { if err := f.FakeStruct(&row); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got := row.Street + "|" + row.PostalCode + "|" + row.Locality + "|"; !places[got] { if got := row.Street + "|" + row.PostalCode + "|" + row.Locality + "|"; !places[got] {
t.Fatalf("sv_SE.address: struct tags %q are no street, postal code and locality of one place", got) t.Fatalf("sv_SE.address: struct tags reading one name, %q, are no street, postal code and locality of one place", got)
} }
} }
} }
+1 -1
View File
@@ -77,7 +77,7 @@ func checkColumns(s nodeScope) error {
items, _ := columnItems(column) items, _ := columnItems(column)
for _, it := range items { for _, it := range items {
if r := it.link.readsColumn; r != nil { if r := it.link.readsColumn; r != nil {
if err := check(r.a.head[1:], r.a.tail[0], r.column); err != nil { if err := check(r.category, r.field, r.column); err != nil {
return err return err
} }
} }
+4 -10
View File
@@ -73,13 +73,6 @@ record. A folder-relative `{.name}` or `{..name}` is refused naming `{/name}`, s
inline template sits in no folder. `IsTemplate` exports the rule, so the CLI, struct inline template sits in no folder. `IsTemplate` exports the rule, so the CLI, struct
tags and any other caller read one. tags and any other caller read one.
## An inline template skips the cycle fence
2026-09-03, Lilleman auf Larv.
Each load proves what it loads 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.
## An inline template that does not compile is misuse (exit 2), including a reference that resolves to nothing ## An inline template that does not compile is misuse (exit 2), including a reference that resolves to nothing
2026-09-24, Lilleman auf Larv. 2026-09-24, Lilleman auf Larv.
@@ -305,9 +298,8 @@ not agree on one among themselves; each must only hold that type.
The fields an embedded struct promotes are the struct's own — `e.First`, as 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 `encoding/json` and SQL mappers read them — so they are columns of its record and share
its picks: path tags into one category, two or more of them and none selecting a row, read its names: one tag may bind `{/person as p}{p.first}` and another read `{p.sex}`, while a
one pick of it, as a category's record columns read one pick of each name (goal 4.1), while path tag draws afresh as `{/path}` does (goal 4.1); a tagged field that another field hides is refused, not dropped. A named
an inline template in a tag draws afresh as any `{…}` does; 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 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 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 already being filled is left alone, since filling it would never end. `New` cannot see a
@@ -474,6 +466,8 @@ table, `street`, `car` or `useragent`, carries no key and is only drawn.
id by the postort's name, unique across Sweden as the key it replaces, give a new id by the postort's name, unique across Sweden as the key it replaces, give a new
postort the next unused integer, and retire a removed or renamed one's id for good. postort the next unused integer, and retire a removed or renamed one's id for good.
The code still takes a `name` option and selects by it; `todo.md` item 41 brings it to this decision.
## A parent row with no child row is a load error ## A parent row with no child row is a load error
2026-09-17, Lilleman auf Larv. 2026-09-17, Lilleman auf Larv.
+10 -2
View File
@@ -166,14 +166,22 @@ func repeatCheck(path string, n node, mem renderCounts) error {
// graph (renderEdges). Every node is a root: a field its parent's format never // graph (renderEdges). Every node is a root: a field its parent's format never
// renders is still reachable by dot path, so a cycle in one would otherwise reach // renders is still reachable by dot path, so a cycle in one would otherwise reach
// render and be fatal there. // render and be fatal there.
func checkNoCycles(sites []categorySite) error { func checkNoCycles(s nodeScope) error {
const ( const (
grey = 1 grey = 1
black = 2 black = 2
) )
inScope := map[node]bool{}
_ = s(func(_ string, n node) error {
inScope[n] = true
return nil
})
color := map[node]int{} color := map[node]int{}
var visit func(n node, path string) error var visit func(n node, path string) error
visit = func(n node, path string) error { visit = func(n node, path string) error {
if !inScope[n] {
return nil // loaded before this scope and proven then, so it never reaches back into it
}
switch color[n] { switch color[n] {
case grey: case grey:
return fmt.Errorf("reference cycle: %s", path) return fmt.Errorf("reference cycle: %s", path)
@@ -189,5 +197,5 @@ func checkNoCycles(sites []categorySite) error {
color[n] = black color[n] = black
return nil return nil
} }
return sitesScope(sites)(func(path string, n node) error { return visit(n, path) }) return s(func(path string, n node) error { return visit(n, path) })
} }
+1 -1
View File
@@ -194,7 +194,7 @@ func linkNodeRefs(scope nodeScope, root map[string]node) error {
}); err != nil { }); err != nil {
return err return err
} }
for _, pass := range namePasses { for _, pass := range linkPasses {
if err := scope(func(path string, m node) error { if err := scope(func(path string, m node) error {
if t, ok := m.(*template); ok { if t, ok := m.(*template); ok {
return pass(path, t) return pass(path, t)
+9 -17
View File
@@ -20,7 +20,6 @@ type nameScope struct {
type nameBinding struct { type nameBinding struct {
name string name string
ref string // what it binds, as written: a reference, or a path into a field of binder ref string // what it binds, as written: a reference, or a path into a field of binder
field bool // ref is a path into a field, so a read of the name stays in the category
body string body string
where string // the fields reaching the binding template, as a compile error spells them where string // the fields reaching the binding template, as a compile error spells them
scope *nameScope scope *nameScope
@@ -43,6 +42,9 @@ type nameUse struct {
operand, noRef, nested bool operand, noRef, nested bool
} }
// bindsField reports whether b binds a path into a field, so a read of it stays in the category.
func (b *nameBinding) bindsField() bool { return !isRef(b.ref) }
func (sc *nameScope) lookup(name string) *nameBinding { func (sc *nameScope) lookup(name string) *nameBinding {
for ; sc != nil; sc = sc.up { for ; sc != nil; sc = sc.up {
if b, ok := sc.bindings[name]; ok { if b, ok := sc.bindings[name]; ok {
@@ -111,7 +113,7 @@ func (sc *nameScope) bindAll(t *template, inChoice bool, where string) error {
if b, twice := sc.bindings[tok.bound]; twice { if b, twice := sc.bindings[tok.bound]; twice {
return fmt.Errorf("%stoken {%s}: name %q is bound twice %s, by {%s} too; rename one", where, tok.body, tok.bound, sc.spelled(), b.body) return fmt.Errorf("%stoken {%s}: name %q is bound twice %s, by {%s} too; rename one", where, tok.body, tok.bound, sc.spelled(), b.body)
} }
b := &nameBinding{name: tok.bound, ref: tok.boundRef, field: !isRef(tok.boundRef), body: tok.body, where: where, scope: sc, index: len(sc.order), binder: t} b := &nameBinding{name: tok.bound, ref: tok.boundRef, body: tok.body, where: where, scope: sc, index: len(sc.order), binder: t}
if sc.bindings == nil { if sc.bindings == nil {
sc.bindings = map[string]*nameBinding{} sc.bindings = map[string]*nameBinding{}
} }
@@ -249,29 +251,19 @@ func namedReads(t *template, fn func(o *op, a *arm) error) error {
return nil return nil
} }
// namePasses run in order, each over every linked template before the next starts: a pass reads // linkPasses run in order once every template is linked, each over every template before the next
// what the one before filled in. // starts: a pass reads what the one before filled in.
var namePasses = []func(path string, t *template) error{linkNames, checkNameReads, checkCalcNames} var linkPasses = []func(path string, t *template) error{linkNames, linkColumnRead, checkNameReads, checkCalcNames}
// linkNames compiles t's reads of a name as paths from the head its binding's reference names, once // linkNames compiles t's reads of a name as paths from the head its binding's reference names, once
// every template is linked, so each binder has resolved that reference. // every template is linked, so each binder has resolved that reference.
func linkNames(path string, t *template) error { func linkNames(path string, t *template) error {
if err := namedReads(t, func(o *op, a *arm) error { return namedReads(t, func(o *op, a *arm) error {
if err := linkName(t, a); err != nil { if err := linkName(t, a); err != nil {
return fmt.Errorf("%s: token {%s}: %w", t.site.label(path), o.body, err) return fmt.Errorf("%s: token {%s}: %w", t.site.label(path), o.body, err)
} }
return nil return nil
}); err != nil { })
return err
}
if ops := t.compiled.ops; t.repeat == 1 && len(ops) == 1 && ops[0].kind == nameRead && len(ops[0].arms) == 1 && ops[0].arms[0].kind == namedRead {
a := ops[0].arms[0]
full := append(a.named.tail[:len(a.named.tail):len(a.named.tail)], a.tail...)
if column := recordColumn(a.named.head, full); column != nil {
t.link.readsColumn = &columnRead{a: a, column: column}
}
}
return nil
} }
func linkName(t *template, a *arm) error { func linkName(t *template, a *arm) error {
+44 -3
View File
@@ -363,16 +363,57 @@ func TestANameIsATransformOperand(t *testing.T) {
} }
} }
func TestOneRenderReadsOneNameAcrossDrawGroups(t *testing.T) { func TestOneRenderReadsOneNameAcrossNestedTemplates(t *testing.T) {
dir := writeData(t, map[string]string{ dir := writeData(t, map[string]string{
"word": `["a","b","c","d","e","f","g","h"]`, "word": `["a","b","c","d","e","f","g","h"]`,
"other": `{"format":"{x}","x":["1","2"]}`, "other": `{"format":"{x}","x":["1","2"]}`,
"cat": `{"format":"{/word as w}{a}","a":{"format":"{w}|{b}","b":{"format":"{w}{/other.x}","drawGroup":"g"}}}`, "cat": `{"format":"{/word as w}{a}","a":{"format":"{w}|{b}","b":"{w}{/other.x}"}}`,
}) })
f := newGenerator(t, dir, WithSeed(59)) f := newGenerator(t, dir, WithSeed(59))
for i := 0; i < 50; i++ { for i := 0; i < 50; i++ {
if got := fake(t, f, "cat.a"); got[0] != got[2] { if got := fake(t, f, "cat.a"); got[0] != got[2] {
t.Fatalf("cat.a = %q, want one pick of w in both draw groups", got) t.Fatalf("cat.a = %q, want one pick of w in both templates", got)
} }
} }
} }
func TestAFieldBindingReadInsideItsFieldIsRefused(t *testing.T) {
f := engine(1)
for src, want := range map[string]string{
`{"format":"{x as n}{n}{n}","x":"{n}"}`: `name "n" is read inside "x", the field it binds`,
`{"format":"{x as n}{n.a}{n.a}","x":{"format":"{a}","a":"{n}"}}`: `name "n" is read inside "x", the field it binds`,
`{"format":"{x as n}{y as m}{n}{n}{m}{m}","x":"{m}","y":"{n}"}`: "cycle",
} {
if _, err := f.NewTemplate(src); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("NewTemplate(%s) = %v, want it refused naming %s", src, err, want)
}
}
}
func TestANameReadAloneIsTheRecordColumnItReads(t *testing.T) {
dir := writeData(t, map[string]string{
"src": `{"format":"","score":[null,{"format":"{int(1,9)}","datatype":"integer"}],"code":["1","2"]}`,
"row": `{"format":"","a":"{/src.score as s}{s}","b":"{s}","c":"{/src as r}{r.score}","d":"{r.code}"}`,
})
f := newGenerator(t, dir, WithSeed(3))
nulls := 0
for i := 0; i < 100; i++ {
r, err := f.FakeRecord("row")
if err != nil {
t.Fatal(err)
}
c := r.Columns()
if c[0].DataType != DataTypeInteger || c[2].DataType != DataTypeInteger || c[0].Value != c[1].Value || c[0].Null != c[1].Null {
t.Fatalf("%s: want a, b and c integer columns of src.score, a and b one pick", r.JSON())
}
if c[0].Null {
nulls++
}
}
if nulls == 0 || nulls == 100 {
t.Errorf("a was null %d times in 100 records, want both outcomes", nulls)
}
if _, err := f.NewRecordTemplate(`{"format":"","a":"{/src.score as s}{s}","b":"{s}"}`); err != nil {
t.Errorf("NewRecordTemplate = %v, want a name-read column accepted inline", err)
}
}
+17 -1
View File
@@ -1,6 +1,7 @@
package fejkdata package fejkdata
import ( import (
"errors"
"fmt" "fmt"
"slices" "slices"
"strings" "strings"
@@ -18,6 +19,9 @@ func checkNameReads(path string, t *template) error {
} }
} }
return namedReads(t, func(o *op, a *arm) error { return namedReads(t, func(o *op, a *arm) error {
if a.named.bindsField() && rendersInside(a.named.head, t) {
return fmt.Errorf("%s: token {%s}: name %q is read inside %q, the field it binds, which would render itself; read the name outside that field", t.site.label(path), o.body, a.named.name, a.named.ref)
}
for _, leaf := range a.leaves { for _, leaf := range a.leaves {
if err := a.named.checkOnce(a.spelling, leaf, a.path); err != nil { if err := a.named.checkOnce(a.spelling, leaf, a.path); err != nil {
return fmt.Errorf("%s: token {%s}: %w", t.site.label(path), o.body, err) return fmt.Errorf("%s: token {%s}: %w", t.site.label(path), o.body, err)
@@ -27,11 +31,23 @@ func checkNameReads(path string, t *template) error {
}) })
} }
// rendersInside reports whether t sits in what n contains.
func rendersInside(n node, t *template) bool {
return eachNode(n, "", func(_ string, m node) error {
if m == t {
return errFound
}
return nil
}) != nil
}
var errFound = errors.New("found")
// checkUses refuses a binding read once at a spot the bound spelling can stand: that spelling // checkUses refuses a binding read once at a spot the bound spelling can stand: that spelling
// draws the same way without the name. A field stands only beside its binding. // draws the same way without the name. A field stands only beside its binding.
func (b *nameBinding) checkUses() error { func (b *nameBinding) checkUses() error {
r := b.uses[0] r := b.uses[0]
if len(b.uses) > 1 || r.nested || b.field && r.in != b.binder { if len(b.uses) > 1 || r.nested || b.bindsField() && r.in != b.binder {
return nil return nil
} }
spelling := b.ref spelling := b.ref
+1 -1
View File
@@ -92,7 +92,7 @@ func (s tableSite) label(path string) string {
type templateLink struct { type templateLink struct {
refs map[string]refBinding // each reference the format reads -> what it resolves to refs map[string]refBinding // each reference the format reads -> what it resolves to
refHeads map[string]node // each refBinding.head -> the category it names refHeads map[string]node // each refBinding.head -> the category it names
readsColumn *columnRead // set when the format is one reference alone reading a record's column readsColumn *columnRead // set when the format is one reference or name read alone reading a record's column
} }
func (*template) isNode() {} func (*template) isNode() {}
+1 -2
View File
@@ -159,8 +159,7 @@ func TestNoStructAllocRegression(t *testing.T) {
if err := f.FakeStruct(&v); err != nil { if err := f.FakeStruct(&v); err != nil {
t.Fatal(err) t.Fatal(err)
} }
// The three tags read one pick of x: its frame and memo cost two allocations. const base = 5.0
const base = 7.0
if allocs := testing.AllocsPerRun(10000, func() { f.FakeStruct(&v) }); allocs > base*1.10 { 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) 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)
} }
+29 -1
View File
@@ -9,6 +9,34 @@ type namedPick struct {
pins pinSet pins pinSet
} }
// drawMemo is what a named pick has drawn: the variant each level was drawn as, so every path
// under it reads one variant; the value each read produced, by its path, so the same read written
// twice reads one value; the frames a read opens for the name scopes around where it lands
// (enter); and the rows drawn by each step down after a "..", by level.
type drawMemo struct {
variant map[string]node
value map[string]readValue
enteredFrames map[*nameScope]*pickFrame
steppedDown map[string]*pinSet
}
// stepDownPins is the pins a path steps down into after stepping up to t: kept in m where there
// is one, so every path stepping down there reads one draw.
func (m *drawMemo) stepDownPins(pins *pinSet, t *table, levels []string, at int) *pinSet {
if m == nil {
return pins.above(t)
}
p, ok := m.steppedDown[levels[at]]
if !ok {
p = pins.above(t)
if m.steppedDown == nil {
m.steppedDown = map[string]*pinSet{}
}
m.steppedDown[levels[at]] = p
}
return p
}
// pickFrame is one render of a name scope: a pick per binding, each drawn on its first read. // pickFrame is one render of a name scope: a pick per binding, each drawn on its first read.
type pickFrame struct { type pickFrame struct {
scope *nameScope scope *nameScope
@@ -117,7 +145,7 @@ func readName(s *generatorState, sc renderScope, a arm) readValue {
return r return r
} }
leaf, pins := p.draw(s, a.named.head, a.steps, a.levels, a.path) leaf, pins := p.draw(s, a.named.head, a.steps, a.levels, a.path)
if a.named.field { if a.named.bindsField() {
return p.renderAt(s, leaf, pins, a.path, sc) return p.renderAt(s, leaf, pins, a.path, sc)
} }
sc, mark := sc.enter(leaf, &p.memo) sc, mark := sc.enter(leaf, &p.memo)
View File
+31 -31
View File
@@ -76,7 +76,7 @@ func linkRefs(sites []categorySite, root map[string]node) error {
}); err != nil { }); err != nil {
return err return err
} }
for _, pass := range namePasses { for _, pass := range linkPasses {
if err := eachTemplate(sites, func(_ categorySite, path string, t *template) error { return pass(path, t) }); err != nil { if err := eachTemplate(sites, func(_ categorySite, path string, t *template) error { return pass(path, t) }); err != nil {
return err return err
} }
@@ -98,9 +98,6 @@ func linkTemplate(folder []string, path, category string, t *template, root map[
} }
func compileArms(t *template) { func compileArms(t *template) {
if r := t.link.readsColumn; r != nil {
compileArm(t, &r.a)
}
for i := range t.compiled.ops { for i := range t.compiled.ops {
o := &t.compiled.ops[i] o := &t.compiled.ops[i]
for j := range o.operands { for j := range o.operands {
@@ -154,46 +151,41 @@ func (t *template) resolveLink(folder []string, path, category string, root map[
link.refHeads[head] = target link.refHeads[head] = target
link.refs[name] = refBinding{head, tail} link.refs[name] = refBinding{head, tail}
} }
link.readsColumn = columnReadOf(t, link)
return link, nil return link, nil
} }
// columnRead is a record's column read by a format of that one reference or name read alone, // columnRead is a record's column read by a format of that one reference or name read alone,
// which is the column: it takes the column's datatype and null. // which is the column: it takes the column's datatype and null. category and field name it.
type columnRead struct { type columnRead struct {
a arm a arm
column node category, field string
column node
} }
func columnReadOf(t *template, link templateLink) *columnRead { // linkColumnRead sets the record's column t's format reads, where it is one reference or name
name, lone := loneRef(t.tokens) // read alone, once the names are linked.
if !lone || t.repeat != 1 { func linkColumnRead(_ string, t *template) error {
ops := t.compiled.ops
if t.repeat != 1 || len(ops) != 1 || ops[0].kind != nameRead || len(ops[0].arms) != 1 {
return nil return nil
} }
a := splitArm(name, link.refs) a := ops[0].arms[0]
if column := recordColumn(link.refHeads[a.head], a.tail); column != nil { head, category, tail := t.head(a.head), a.head, a.tail
return &columnRead{a: a, column: column} switch {
case a.kind == namedRead && !a.named.bindsField():
ref := a.named.binder.link.refs[a.named.ref]
head, category = a.named.head, ref.head
tail = append(ref.tail[:len(ref.tail):len(ref.tail)], a.tail...)
case a.kind == namedRead || !isRef(a.head):
return nil
}
target, isTemplate := head.(*template)
if isTemplate && target.isRecord && len(tail) == 1 {
t.link.readsColumn = &columnRead{a: a, category: category[1:], field: tail[0], column: target.fields[tail[0]]}
} }
return nil return nil
} }
// recordColumn is the column tail names in head, nil where head is no record or tail no column of it.
func recordColumn(head node, tail []string) node {
target, isTemplate := head.(*template)
if !isTemplate || !target.isRecord || len(tail) != 1 {
return nil
}
return target.fields[tail[0]]
}
// loneRef is the reference a format of one reference token and nothing else reads.
func loneRef(toks []formatToken) (string, bool) {
if len(toks) != 1 || toks[0].kind != nameRead || len(toks[0].names) != 1 || !isRef(toks[0].names[0]) {
return "", false
}
return toks[0].names[0], true
}
// eachTemplate calls fn once per template of the categories, with the category it sits // eachTemplate calls fn once per template of the categories, with the category it sits
// in and the dot path reaching it. // in and the dot path reaching it.
func eachTemplate(sites []categorySite, fn func(s categorySite, path string, t *template) error) error { func eachTemplate(sites []categorySite, fn func(s categorySite, path string, t *template) error) error {
@@ -259,3 +251,11 @@ func refTokens(toks []formatToken) []string {
} }
return refs return refs
} }
// loneRef is the reference a format of one reference token and nothing else reads.
func loneRef(toks []formatToken) (string, bool) {
if len(toks) != 1 || toks[0].kind != nameRead || len(toks[0].names) != 1 || !isRef(toks[0].names[0]) {
return "", false
}
return toks[0].names[0], true
}
-28
View File
@@ -1,33 +1,5 @@
package fejkdata package fejkdata
// drawMemo is what a named pick has drawn: the variant each level was drawn as, so every path
// under it reads one variant; the value each read produced, by its path, so the same read written
// twice reads one value; the frames a read opens for the name scopes around where it lands
// (enter); and the rows drawn by each step down after a "..", by level.
type drawMemo struct {
variant map[string]node
value map[string]readValue
enteredFrames map[*nameScope]*pickFrame
steppedDown map[string]*pinSet
}
// stepDownPins is the pins a path steps down into after stepping up to t: kept in m where there
// is one, so every path stepping down there reads one draw.
func (m *drawMemo) stepDownPins(pins *pinSet, t *table, levels []string, at int) *pinSet {
if m == nil {
return pins.above(t)
}
p, ok := m.steppedDown[levels[at]]
if !ok {
p = pins.above(t)
if m.steppedDown == nil {
m.steppedDown = map[string]*pinSet{}
}
m.steppedDown[levels[at]] = p
}
return p
}
// readValue is what one read drew: its text, and whether it landed on a null. // readValue is what one read drew: its text, and whether it landed on a null.
type readValue struct { type readValue struct {
text string text string
+10 -44
View File
@@ -95,7 +95,6 @@ type structFields struct {
typ reflect.Type typ reflect.Type
label string label string
tags map[string]any tags map[string]any
paths map[string]string // each field tagged with a path, and the path
shape *structShape shape *structShape
} }
@@ -105,12 +104,11 @@ func (sc *structCompile) compileShape(t reflect.Type, label string) (*structShap
} }
sc.visiting[t] = true sc.visiting[t] = true
defer delete(sc.visiting, t) defer delete(sc.visiting, t)
c := &structFields{structCompile: sc, typ: t, label: label, tags: map[string]any{}, paths: map[string]string{}, shape: &structShape{}} c := &structFields{structCompile: sc, typ: t, label: label, tags: map[string]any{}, shape: &structShape{}}
if err := c.gatherFields(t, nil); err != nil { if err := c.gatherFields(t, nil); err != nil {
return nil, err return nil, err
} }
if len(c.tags) > 0 { if len(c.tags) > 0 {
bindShared(sc.root, c.tags, c.paths)
if err := c.shape.compileRecord(sc.root, t, label, c.tags); err != nil { if err := c.shape.compileRecord(sc.root, t, label, c.tags); err != nil {
return nil, err return nil, err
} }
@@ -174,14 +172,11 @@ func (c *structFields) addTag(sf reflect.StructField, tag string) error {
if visible, ok := c.typ.FieldByName(sf.Name); !ok || !slices.Equal(visible.Index, sf.Index) { if visible, ok := c.typ.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.typ, sf.Index), sf.Name) return fmt.Errorf("%s.%s: hidden by another field named %s, so its fake tag cannot fill it; rename one", c.label, fieldPath(c.typ, sf.Index), sf.Name)
} }
v, path, err := tagValue(sf, tag) v, err := tagValue(sf, tag)
if err != nil { if err != nil {
return fmt.Errorf("%s.%s: %w", c.label, sf.Name, err) return fmt.Errorf("%s.%s: %w", c.label, sf.Name, err)
} }
c.tags[sf.Name] = v c.tags[sf.Name] = v
if path != "" {
c.paths[sf.Name] = path
}
return nil return nil
} }
@@ -220,52 +215,22 @@ func (c *structFields) nest(sf reflect.StructField, elem reflect.Type) error {
} }
// tagValue reads a field's fake tag as the value its column compiles from: an inline template // 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}, returning the path too. // as written, or a path as the reference {/path}.
func tagValue(sf reflect.StructField, tag string) (any, string, error) { func tagValue(sf reflect.StructField, tag string) (any, error) {
if err := checkTaggedType(sf); err != nil { if err := checkTaggedType(sf); err != nil {
return nil, "", err return nil, err
} }
inline, err := isTemplate(tag) inline, err := isTemplate(tag)
switch { switch {
case err != nil: case err != nil:
return nil, "", err return nil, err
case inline: case inline:
v, err := inputValue(tag) return inputValue(tag)
return v, "", err
} }
if err := checkPathNames(tag); err != nil { if err := checkPathNames(tag); err != nil {
return nil, "", err return nil, err
} }
return "{/" + tag + "}", tag, nil return "{/" + tag + "}", nil
}
// bindShared has the fields whose paths read into one category, two or more of them and none
// selecting a row, read one pick of it through a name, as a record's columns read one pick.
func bindShared(root *folder, tags map[string]any, paths map[string]string) {
byCategory := map[string][]string{}
for _, field := range sortedNames(paths) {
segs, err := splitPath(paths[field])
if err != nil || hasSelector(segs) {
continue
}
if _, i := folderAt(root, segs); i < len(segs) {
category := strings.Join(segs[:i+1], ".")
byCategory[category] = append(byCategory[category], field)
}
}
var format strings.Builder
for _, category := range sortedNames(byCategory) {
fields := byCategory[category]
if len(fields) < 2 {
continue
}
name := strings.ReplaceAll(category, ".", "-")
fmt.Fprintf(&format, "{/%s as %s}", category, name)
for _, field := range fields {
tags[field] = "{" + name + strings.TrimPrefix(paths[field], category) + "}"
}
}
tags["format"] = format.String()
} }
// checkTaggedType rejects a tagged field no column can fill. // checkTaggedType rejects a tagged field no column can fill.
@@ -288,6 +253,7 @@ func checkTaggedType(sf reflect.StructField) error {
// compileRecord compiles the tagged fields of t as one record, and proves each column holds // compileRecord compiles the tagged fields of t as one record, and proves each column holds
// only what its field's Go type can. // only what its field's Go type can.
func (s *structShape) compileRecord(root *folder, t reflect.Type, label string, tags map[string]any) error { func (s *structShape) compileRecord(root *folder, t reflect.Type, label string, tags map[string]any) error {
tags["format"] = ""
n, err := compile(tags) n, err := compile(tags)
if err != nil { if err != nil {
return fmt.Errorf("%s: %w", label, err) return fmt.Errorf("%s: %w", label, err)
+28 -22
View File
@@ -8,18 +8,18 @@ import (
) )
type structPlace struct { type structPlace struct {
City string `fake:"place.city"` City string `fake:"{/place as p}{p.city}"`
Zip string `fake:"place.zip"` Zip string `fake:"{p.zip}"`
} }
type structUser struct { type structUser struct {
Active bool `fake:"[\"true\",\"false\"]"` Active bool `fake:"[\"true\",\"false\"]"`
Age uint8 `fake:"{int(18,99)}"` Age uint8 `fake:"{int(18,99)}"`
Email string `fake:"{lowercase(/person.first)}@example.com"` Email string `fake:"{lowercase(p.first)}@example.com"`
First string `fake:"person.first"` First string `fake:"{/person as p}{p.first}"`
Home structPlace Home structPlace
ID int64 `fake:"{seq()}"` ID int64 `fake:"{seq()}"`
Last string `fake:"person.last"` Last string `fake:"{p.last}"`
Level uint8 `fake:"{float(0,255,0)}"` Level uint8 `fake:"{float(0,255,0)}"`
Nick *string `fake:"[null,\"bo\"]"` Nick *string `fake:"[null,\"bo\"]"`
Note string Note string
@@ -31,7 +31,7 @@ type structUser struct {
} }
type structGiven struct { type structGiven struct {
First string `fake:"person.first"` First string `fake:"{/person as p}{p.first}"`
} }
type StructFamily struct { type StructFamily struct {
@@ -41,7 +41,7 @@ type StructFamily struct {
type structEmployee struct { type structEmployee struct {
structGiven structGiven
*StructFamily *StructFamily
Email string `fake:"{lowercase(/person.first)}@example.com"` Email string `fake:"{lowercase(p.first)}@example.com"`
} }
type structLink struct { type structLink struct {
@@ -118,25 +118,28 @@ func TestFakeStructFieldTaggedWithAColumnIsThatColumn(t *testing.T) {
} }
} }
func digitOrNil(s string) bool { func TestFakeStructPathTagsDrawAfreshAndANameKeepsOnePick(t *testing.T) {
return s == "nil" || len(s) == 1 && s >= "1" && s <= "9"
}
func TestFakeStructPathTagsReadOnePickOfTheirCategory(t *testing.T) {
f := structData(t) f := structData(t)
var v struct { var v struct {
Leg string `fake:"trip.leg"` A string `fake:"trip.leg"`
To string `fake:"trip.leg.to"` B string `fake:"trip.leg"`
Name string `fake:"person"` Leg string `fake:"{/trip as r}{r.leg}"`
Last string `fake:"person.last"` To string `fake:"{r.leg.to}"`
Name string `fake:"{/person as p}{p}"`
Last string `fake:"{p.last}"`
} }
apart := false
for i := 0; i < 100; i++ { for i := 0; i < 100; i++ {
if err := f.FakeStruct(&v); err != nil { if err := f.FakeStruct(&v); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if v.Leg != v.To || v.Name != "Ada "+v.Last && v.Name != "Bo "+v.Last { if v.Leg != v.To || v.Name != "Ada "+v.Last && v.Name != "Bo "+v.Last {
t.Fatalf("%+v, want the tags into trip one leg, and into person one person", v) t.Fatalf("%+v, want the reads of r one leg, and of p one person", v)
} }
apart = apart || v.A != v.B
}
if !apart {
t.Error("two tags of trip.leg drew one leg in 100 fills, want each path tag a draw of its own")
} }
} }
@@ -157,8 +160,8 @@ func TestFakeStructFillsTaggedFields(t *testing.T) {
switch { switch {
case !reflect.DeepEqual(u, twin): case !reflect.DeepEqual(u, twin):
t.Fatalf("same seed diverged: %+v != %+v", u, twin) t.Fatalf("same seed diverged: %+v != %+v", u, twin)
case people[u.First] != u.Last || u.Email != "ada@example.com" && u.Email != "bo@example.com": case people[u.First] != u.Last || u.Email != strings.ToLower(u.First)+"@example.com":
t.Fatalf("person fields %q %q %q, want one person across the path tags, and the template its own draw", u.First, u.Last, u.Email) t.Fatalf("person fields %q %q %q, want one person read through p 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: 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) 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): 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):
@@ -180,14 +183,13 @@ func TestFakeStructFillsTaggedFields(t *testing.T) {
func TestFakeStructFillsEmbeddedFieldsIntoItsRecord(t *testing.T) { func TestFakeStructFillsEmbeddedFieldsIntoItsRecord(t *testing.T) {
f := structData(t) f := structData(t)
people := map[string]string{"Ada": "Lovelace", "Bo": "Ek"}
for i := 0; i < 100; i++ { for i := 0; i < 100; i++ {
var e structEmployee var e structEmployee
if err := f.FakeStruct(&e); err != nil { if err := f.FakeStruct(&e); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if e.StructFamily == nil || people[e.First] != e.Last { if e.StructFamily == nil || e.Last != "Lovelace" && e.Last != "Ek" || e.Email != strings.ToLower(e.First)+"@example.com" {
t.Fatalf("%+v, %+v: want the promoted fields one person, the embedded pointer allocated", e, e.StructFamily) t.Fatalf("%+v, %+v: want a promoted field's name read by the struct's own, the embedded pointer allocated", e, e.StructFamily)
} }
} }
var skipped struct { var skipped struct {
@@ -351,3 +353,7 @@ func TestFakeStructBoundsTheStructsATypeReaches(t *testing.T) {
t.Errorf("a type reaching 1025 structs, the last embedded: FakeStruct = %v, want it refused naming the cap and fake:\"-\"", err) t.Errorf("a type reaching 1025 structs, the last embedded: FakeStruct = %v, want it refused naming the cap and fake:\"-\"", err)
} }
} }
func digitOrNil(s string) bool {
return s == "nil" || len(s) == 1 && s >= "1" && s <= "9"
}
+4 -4
View File
@@ -591,7 +591,7 @@ func TestTableFences(t *testing.T) {
"a whole table's format selecting another row": map[string]string{"x.json": `{"format":"{v} {/y[1].w}","rows":"x.tsv","key":"code"}`, "x.tsv": "code\tv\n1\ta\n2\tb\n", "y.json": `{"format":"{w}","rows":"y.tsv","key":"k"}`, "y.tsv": "k\tw\n1\tm\n2\tn\n", "r.json": `"{/x} {/y[2].w}"`}, "a whole table's format selecting another row": map[string]string{"x.json": `{"format":"{v} {/y[1].w}","rows":"x.tsv","key":"code"}`, "x.tsv": "code\tv\n1\ta\n2\tb\n", "y.json": `{"format":"{w}","rows":"y.tsv","key":"k"}`, "y.tsv": "k\tw\n1\tm\n2\tn\n", "r.json": `"{/x} {/y[2].w}"`},
"a table reaching its family through a template": with(geo(), map[string]string{"addr.json": `"{/locality.name}"`, "region.tsv": "code\tname\tpopulation\ttimezone\tnote\n01\tStockholms län\t2400000\tEurope/Stockholm\t{/addr}\n12\tSkåne län\t1400000\tEurope/Stockholm\t-\n14\tVästra Götalands län\t1750000\tEurope/Stockholm\t-\n"}), "a table reaching its family through a template": with(geo(), map[string]string{"addr.json": `"{/locality.name}"`, "region.tsv": "code\tname\tpopulation\ttimezone\tnote\n01\tStockholms län\t2400000\tEurope/Stockholm\t{/addr}\n12\tSkåne län\t1400000\tEurope/Stockholm\t-\n14\tVästra Götalands län\t1750000\tEurope/Stockholm\t-\n"}),
"a table reaching its family through a repeat": with(geo(), map[string]string{"addr.json": `{"format":"{/locality.name} ","repeat":3}`, "region.tsv": "code\tname\tpopulation\ttimezone\tnote\n01\tStockholms län\t2400000\tEurope/Stockholm\t{/addr}\n12\tSkåne län\t1400000\tEurope/Stockholm\t-\n14\tVästra Götalands län\t1750000\tEurope/Stockholm\t-\n"}), "a table reaching its family through a repeat": with(geo(), map[string]string{"addr.json": `{"format":"{/locality.name} ","repeat":3}`, "region.tsv": "code\tname\tpopulation\ttimezone\tnote\n01\tStockholms län\t2400000\tEurope/Stockholm\t{/addr}\n12\tSkåne län\t1400000\tEurope/Stockholm\t-\n14\tVästra Götalands län\t1750000\tEurope/Stockholm\t-\n"}),
"a selected row reaching its family's other row": with(geo(), map[string]string{"a.json": `"{/region[01].note}"`, "addr.json": `{"format":"{/region[12].name}","drawGroup":"g"}`, "region.tsv": "code\tname\tpopulation\ttimezone\tnote\n01\tStockholms län\t2400000\tEurope/Stockholm\t{/addr}\n12\tSkåne län\t1400000\tEurope/Stockholm\t-\n14\tVästra Götalands län\t1750000\tEurope/Stockholm\t-\n"}), "a selected row reaching its family's other row": with(geo(), map[string]string{"a.json": `"{/region[01].note}"`, "addr.json": `"{/region[12].name}"`, "region.tsv": "code\tname\tpopulation\ttimezone\tnote\n01\tStockholms län\t2400000\tEurope/Stockholm\t{/addr}\n12\tSkåne län\t1400000\tEurope/Stockholm\t-\n14\tVästra Götalands län\t1750000\tEurope/Stockholm\t-\n"}),
"a cell repeating a reference": with(base, map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": "a\nx\n{/region} {/region} {uppercase(/region)}\n"}), "a cell repeating a reference": with(base, map[string]string{"t.json": `{"format":"{a}","rows":"t.tsv"}`, "t.tsv": "a\nx\n{/region} {/region} {uppercase(/region)}\n"}),
} }
for name, files := range accepted { for name, files := range accepted {
@@ -899,9 +899,9 @@ func TestShippedTables(t *testing.T) {
} }
} }
var row struct { var row struct {
Idempotent bool `fake:"misc.httpmethod.idempotent"` Idempotent bool `fake:"{/misc.httpmethod as m}{m.idempotent}"`
Method string `fake:"misc.httpmethod.method"` Method string `fake:"{m.method}"`
Safe bool `fake:"misc.httpmethod.safe"` Safe bool `fake:"{m.safe}"`
} }
if err := newGenerator(t, "data", WithSeed(1)).FakeStruct(&row); err != nil { if err := newGenerator(t, "data", WithSeed(1)).FakeStruct(&row); err != nil {
t.Fatalf("FakeStruct into a bool = %v, want the register's yes and no shipped as true and false", err) t.Fatalf("FakeStruct into a bool = %v, want the register's yes and no shipped as true and false", err)
+12 -2
View File
@@ -6,7 +6,7 @@
`Bar = 9` `Bar = 9`
`Next ID = 94` `Next ID = 96`
| Goal | W | | Goal | W |
|---|---| |---|---|
@@ -82,7 +82,7 @@
| 81 | 0.2.0 | decision | **Refuse at load every `calc` operand not proven numeric and every divisor not proven nonzero, in a string column too.** | 4 | 4 | 6 | 8 | 6.1, 4 | 20.6 | | 81 | 0.2.0 | decision | **Refuse at load every `calc` operand not proven numeric and every divisor not proven nonzero, in a string column too.** | 4 | 4 | 6 | 8 | 6.1, 4 | 20.6 |
| 36 | 0.2.0 | | **Report every mistake a load finds in one error, as the decision "A load reports every mistake at once" states.** | 5 | 6 | 7 | 8 | 6 | 19.5 | | 36 | 0.2.0 | | **Report every mistake a load finds in one error, as the decision "A load reports every mistake at once" states.** | 5 | 6 | 7 | 8 | 6 | 19.5 |
| 88 | 0.2.0 | | **Add a 12-digit `YYYYMMDDNNNC` form of `sv_SE.personnummer` and `sv_SE.samordningsnummer`, the form Skatteverket's testpersonnummer series and many systems store.** | 2 | 3 | 6 | 6 | 4 | 19.4 | | 88 | 0.2.0 | | **Add a 12-digit `YYYYMMDDNNNC` form of `sv_SE.personnummer` and `sv_SE.samordningsnummer`, the form Skatteverket's testpersonnummer series and many systems store.** | 2 | 3 | 6 | 6 | 4 | 19.4 |
| 41 | 0.2.0 | | **Key every table by one key column, as the decision "A table selects by one key column, a code, never a free-form name" states.** | 6 | 7 | 7 | 8 | 5.5 | 19.1 | | 41 | 0.2.0 | decision | **Key every table by one key column, as the decision "A table selects by one key column, a code, never a free-form name" states.** | 6 | 7 | 7 | 8 | 5.5 | 19.1 |
| 52 | 0.2.0 | | **Name the file a link error comes from beside its category path, `sub.x (/d/a/sub/x.json): …`.** | 1 | 2 | 5 | 7 | 6 | 19.0 | | 52 | 0.2.0 | | **Name the file a link error comes from beside its category path, `sub.x (/d/a/sub/x.json): …`.** | 1 | 2 | 5 | 7 | 6 | 19.0 |
| 28 | 0.2.0 | decision | **Accept a middle name, and draw a `personnummer` whose sex matches a sex selected through a named pick.** | 4 | 4 | 6 | 7 | 4.1 | 18.9 | | 28 | 0.2.0 | decision | **Accept a middle name, and draw a `personnummer` whose sex matches a sex selected through a named pick.** | 4 | 4 | 6 | 7 | 4.1 | 18.9 |
| 24 | 0.2.0 | defect | **Stop `misc.territory[EH].tld` rendering `.eh`, the one shipped TLD `misc.tld` does not hold.** | 1 | 1 | 4 | 7 | 4 | 18.6 | | 24 | 0.2.0 | defect | **Stop `misc.territory[EH].tld` rendering `.eh`, the one shipped TLD `misc.tld` does not hold.** | 1 | 1 | 4 | 7 | 4 | 18.6 |
@@ -104,6 +104,7 @@
| 27 | 0.2.0 | | **Merge `en_US.ip` and `sv_SE.ip`, today byte-identical, into one `misc.ip`.** | 3 | 2 | 4 | 6 | 5.7 | 14.1 | | 27 | 0.2.0 | | **Merge `en_US.ip` and `sv_SE.ip`, today byte-identical, into one `misc.ip`.** | 3 | 2 | 4 | 6 | 5.7 | 14.1 |
| 22 | 0.2.0 | | **Give `url` and `email` a path that draws only domains nobody can register, keeping the wide set as the default.** | 3 | 4 | 7 | 8 | 16 | 14.0 | | 22 | 0.2.0 | | **Give `url` and `email` a path that draws only domains nobody can register, keeping the wide set as the default.** | 3 | 4 | 7 | 8 | 16 | 14.0 |
| 46 | 0.2.0 | | **Report the same error every load for a table with two bad options, and for a folder with two unnamed rows files.** | 2 | 2 | 3 | 7 | 6, 10 | 14.0 | | 46 | 0.2.0 | | **Report the same error every load for a table with two bad options, and for a folder with two unnamed rows files.** | 2 | 2 | 3 | 7 | 6, 10 | 14.0 |
| 94 | 0.2.0 | | **Refuse a name whose every read passes one deeper level, naming the binding of that level: `{place as p}{p.geo.town.name} {p.geo.town.zip}` is `{place.geo.town as t}{t.name} {t.zip}`.** | 3 | 3 | 3 | 5 | 5.7 | 10.2 |
| 14 | 0.3.0 | | **Spell `misc.creditcard`'s digit runs `{digits(n)}`.** | 2 | 2 | 4 | 6 | 5.7 | 15.1 | | 14 | 0.3.0 | | **Spell `misc.creditcard`'s digit runs `{digits(n)}`.** | 2 | 2 | 4 | 6 | 5.7 | 15.1 |
| 38 | 0.3.0 | defect | **Compare `calcParser.binary`'s operator as a rune.** | 1 | 1 | 3 | 6 | 6 | 13.8 | | 38 | 0.3.0 | defect | **Compare `calcParser.binary`'s operator as a rune.** | 1 | 1 | 3 | 6 | 6 | 13.8 |
| 45 | 0.3.0 | | **Name the node a selector follows in `stepInto`'s refusal.** | 1 | 1 | 3 | 6 | 6 | 13.8 | | 45 | 0.3.0 | | **Name the node a selector follows in `stepInto`'s refusal.** | 1 | 1 | 3 | 6 | 6 | 13.8 |
@@ -127,6 +128,7 @@
| 37 | 0.3.0 | | **Rename `newRand` to `newGeneratorState` and `Generator.rand` to `state`.** | 1 | 1 | 1 | 5 | 3.3 | 9.8 | | 37 | 0.3.0 | | **Rename `newRand` to `newGeneratorState` and `Generator.rand` to `state`.** | 1 | 1 | 1 | 5 | 3.3 | 9.8 |
| 43 | 0.3.0 | | **Call `templateError` a compile failure in its doc.** | 1 | 1 | 1 | 5 | 3.3 | 9.8 | | 43 | 0.3.0 | | **Call `templateError` a compile failure in its doc.** | 1 | 1 | 1 | 5 | 3.3 | 9.8 |
| 82 | 0.3.0 | principle | **Pass `newRand` its entropy source, so no test swaps the package's `randomBytes`.** | 1 | 1 | 1 | 5 | 3.2 | 9.8 | | 82 | 0.3.0 | principle | **Pass `newRand` its entropy source, so no test swaps the package's `randomBytes`.** | 1 | 1 | 1 | 5 | 3.2 | 9.8 |
| 95 | 0.3.0 | principle | **Hold a struct's columns and the field each fills in one slice.** | 1 | 2 | 1 | 5 | 3.2 | 9.5 |
| 19 | 0.3.0 | | **Fill the 398 Swedish localities weighted 200 from SCB småorter.** | 2 | 4 | 4 | 6 | 14.1 | 9.2 | | 19 | 0.3.0 | | **Fill the 398 Swedish localities weighted 200 from SCB småorter.** | 2 | 4 | 4 | 6 | 14.1 | 9.2 |
| 56 | 0.3.0 | defect | **Build on a manual run of `test.yml`.** | 1 | 1 | 1 | 4 | 1 | 8.8 | | 56 | 0.3.0 | defect | **Build on a manual run of `test.yml`.** | 1 | 1 | 1 | 4 | 1 | 8.8 |
| 83 | 0.3.0 | principle | **Read a name binding's head and tail from its binder's link, and delete `linkBindings`.** | 4 | 3 | 1 | 6 | 3.2 | 8.1 | | 83 | 0.3.0 | principle | **Read a name binding's head and tail from its binder's link, and delete `linkBindings`.** | 4 | 3 | 1 | 6 | 3.2 | 8.1 |
@@ -356,6 +358,10 @@ Require the path step to reach a sibling category.
`readTableOptions` and `loadDir` return on the first in Go's map order. `readTableOptions` and `loadDir` return on the first in Go's map order.
### 94. Refuse a name whose every read passes one deeper level, naming the binding of that level: `{place as p}{p.geo.town.name} {p.geo.town.zip}` is `{place.geo.town as t}{t.name} {t.zip}`.
Both spellings load and draw alike, against goal 5.7. Widen the read-once refusal in `namefence.go` to the longest prefix every read of a name shares.
### 14. Spell `misc.creditcard`'s digit runs `{digits(n)}`. ### 14. Spell `misc.creditcard`'s digit runs `{digits(n)}`.
A `"repeat": 14` over `{digits(1)}` draws every 14-digit run with the odds `{digits(14)}` does. Both load, since neither has a part that changes nothing; the shipped data writes the obvious one. Re-pin seeded output in its own commit. A `"repeat": 14` over `{digits(1)}` draws every 14-digit run with the odds `{digits(14)}` does. Both load, since neither has a part that changes nothing; the shipped data writes the obvious one. Re-pin seeded output in its own commit.
@@ -432,6 +438,10 @@ Its doc says render failure, and it wraps what `NewTemplate` and `NewRecordTempl
`fejkdata_test.go` swaps the global `randomBytes` to fail seeding, so every `New` in the package reads the failing source while that test runs. Technical principle: functional core, imperative shell. `fejkdata_test.go` swaps the global `randomBytes` to fail seeding, so every `New` in the package reads the failing source while that test runs. Technical principle: functional core, imperative shell.
### 95. Hold a struct's columns and the field each fills in one slice.
`structShape.columns` and `structShape.fieldIndexes` pair up by index alone, against the technical principle "one owner per value".
### 56. Build on a manual run of `test.yml`. ### 56. Build on a manual run of `test.yml`.
`workflow_dispatch` leaves `github.event.before` empty, so the diff compares `HEAD` with itself and skips `docker build`. `workflow_dispatch` leaves `github.event.before` empty, so the diff compares `HEAD` with itself and skips `docker build`.