Reject field descent in Record and pin the shared-draw scope
Tests / vet + fmt + tests (pull_request) Successful in 1m2s

This commit is contained in:
2026-09-03 23:22:43 +02:00
parent 6a8aa96b20
commit 93ac610192
3 changed files with 45 additions and 11 deletions
+9 -6
View File
@@ -518,12 +518,15 @@ tokens add cost in proportion to the output.
need a per-column `kind`, a parallel scalar system in a format whose promise is need a per-column `kind`, a parallel scalar system in a format whose promise is
"text means what it says". A column's check digit, number or id is still "text means what it says". A column's check digit, number or id is still
valid-by-construction through a builtin; it is serialized as text. valid-by-construction through a builtin; it is serialized as text.
- **A record's columns share one reference draw.** Two columns that reference one - **A record shares one reference draw per category.** Two columns that reference
category — `{/currency.code}` beside `{/currency.symbol}` — read one draw of it, one category — `{/currency.code}` beside `{/currency.symbol}` — read one draw of
so a record's facts agree the way a template's [correlated it, so a record's facts agree the way a template's [correlated
fields](#correlated-fields) do. Only references share across columns: a sibling fields](#correlated-fields) do. The draw is one per record, so it spans a
field is local to its own column, so `first` does not silently bind to a column's `repeat` and nested templates too (one record is one coherent unit);
`first` in the column next to it. a bare reference — `{/currency}`, no field — stays an independent draw every
time, the rule a format string already follows. Only references share: a sibling
field is local to its own column, so a `first` column does not silently bind to
a `first` in the column next to it.
- **Filling a Go struct is out of scope.** `Record.Fields()` returns the columns a - **Filling a Go struct is out of scope.** `Record.Fields()` returns the columns a
caller maps onto a struct themselves. gofakeit's `fake:"{firstname}"` tags caller maps onto a struct themselves. gofakeit's `fake:"{firstname}"` tags
reflect over an arbitrary struct type and cast into its fields — a different reflect over an arbitrary struct type and cast into its fields — a different
+5 -5
View File
@@ -88,17 +88,17 @@ func quoteIdent(s string) string {
} }
// Record renders a path as one record: the template it names, with each direct // Record renders a path as one record: the template it names, with each direct
// field drawn as a column. A path naming a folder or a value that is not a // field drawn as a column. Only a category-level template is a record — a path
// template — a bare string or a choice, which have no fields — is an error. // that descends into a field, or that names a folder or a choice, is an error.
func (f *Generator) Record(path string) (*Record, error) { func (f *Generator) Record(path string) (*Record, error) {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
n, err := descend(f.rand, &group{children: f.categories}, strings.Split(path, ".")) _, n, tail, err := resolveRef(f.categories, strings.Split(path, "."))
if err != nil { if err != nil {
return nil, fmt.Errorf("fejkdata: %s: %w", path, err) return nil, fmt.Errorf("fejkdata: %s: %w", path, err)
} }
if _, ok := n.(*group); ok { if len(tail) > 0 {
return nil, fmt.Errorf("fejkdata: %s names a folder, not a value", path) return nil, fmt.Errorf("fejkdata: %s descends into %q, a field; only a category-level template is a record", path, tail[0])
} }
t, ok := n.(*template) t, ok := n.(*template)
if !ok { if !ok {
+31
View File
@@ -139,6 +139,19 @@ func TestRecordSQLInsert(t *testing.T) {
} }
} }
func TestRecordRejectsFieldDescent(t *testing.T) {
dir := writeData(t, map[string]string{
"cat": `{"format":"{sub}","sub":{"format":"{x}","x":"1"}}`,
"row": `[{"format":"{x}","x":"1"},{"format":"{x}","x":"2"}]`,
})
f := newGenerator(t, dir, WithSeed(1))
for _, path := range []string{"cat.sub", "row.x"} {
if _, err := f.Record(path); err == nil || !strings.Contains(err.Error(), "field") {
t.Errorf("Record(%q) = %v, want a 'descends into a field' error", path, err)
}
}
}
func TestRecordSharesAReferenceAcrossColumns(t *testing.T) { func TestRecordSharesAReferenceAcrossColumns(t *testing.T) {
dir := writeData(t, map[string]string{ dir := writeData(t, map[string]string{
"currency": `[{"format":"{code}","code":"AUD","symbol":"$"},{"format":"{code}","code":"EUR","symbol":"€"}]`, "currency": `[{"format":"{code}","code":"AUD","symbol":"$"},{"format":"{code}","code":"EUR","symbol":"€"}]`,
@@ -169,6 +182,24 @@ func TestRecordSharesAReferenceAcrossColumns(t *testing.T) {
} }
} }
func TestRecordSharesAReferenceIntoAColumnRepeat(t *testing.T) {
dir := writeData(t, map[string]string{
"currency": `[{"format":"{code}","code":"AUD"},{"format":"{code}","code":"EUR"}]`,
"order": `{"format":"","codes":{"format":"{/currency.code}","repeat":3,"separator":"-"}}`,
})
f := newGenerator(t, dir, WithSeed(1))
for i := 0; i < 50; i++ {
r, err := f.Record("order")
if err != nil {
t.Fatal(err)
}
parts := strings.Split(r.Fields()[0].Value, "-")
if len(parts) != 3 || parts[0] != parts[1] || parts[1] != parts[2] {
t.Fatalf("codes column = %q, want one shared draw across its repeat", r.Fields()[0].Value)
}
}
}
func TestRecordSQLQuotesIdentifiers(t *testing.T) { func TestRecordSQLQuotesIdentifiers(t *testing.T) {
dir := writeData(t, map[string]string{ dir := writeData(t, map[string]string{
"row": `{"format": "", "postal-code": "1", "street-number": "2"}`, "row": `{"format": "", "postal-code": "1", "street-number": "2"}`,