Refuse overlapping reference reads across a record's columns, keep an empty column a CSV row, and reject an empty --table
Tests / vet + fmt + tests (pull_request) Successful in 1m4s

This commit is contained in:
2026-09-04 09:10:03 +02:00
parent be5dfdf977
commit c612c9e734
3 changed files with 83 additions and 14 deletions
+7 -3
View File
@@ -125,9 +125,13 @@ required key — so `"format": ""` carries the fields with an inert format: it
renders nothing by `Fake`, and is compiled only so the tree's fences still run.
The columns are the point, and their facts stay together: two columns that
reference one category — `{/currency.code}` and `{/currency.symbol}` — share one
draw of it, so the record is internally consistent. A field hold, transform or
operand ties fields together within one column as always (see
[Correlated fields](#correlated-fields) and [Decisions](#decisions)).
draw of it, so the record is internally consistent. That one draw is also why two
columns may not read overlapping reference paths — `{/cat.a}` beside `{/cat.a.b}`
is refused, naming the fields to write instead, exactly as
[One draw, one spelling](#one-draw-one-spelling) refuses the pair inside a single
format. A field hold, transform or operand ties fields together within one column
as always (see [Correlated fields](#correlated-fields) and
[Decisions](#decisions)).
## Library
+4 -3
View File
@@ -216,9 +216,7 @@ type recordFormat struct {
line func(r *fejkdata.Record, table string) string
}
// recordFormats is every --format that writes records. Adding one is this entry
// alone: the flag check reads the same table the writer dispatches through, so a
// format cannot be accepted and then not written.
// recordFormats is every --format that writes records.
var recordFormats = map[string]recordFormat{
"csv": {header: (*fejkdata.Record).CSVHeader, line: func(r *fejkdata.Record, _ string) string { return r.CSVLine() }},
"json": {line: func(r *fejkdata.Record, _ string) string { return r.JSON() }},
@@ -245,6 +243,9 @@ func (in invocation) checkFlags() error {
if _, ok := recordFormats[in.format]; !ok && in.format != "text" {
return fmt.Errorf("--format takes %s, got %q", formatNames(), in.format)
}
if in.tableSet && in.table == "" {
return errors.New("--table names the INSERT target, so it cannot be empty")
}
if in.tableSet && in.format != "sql" {
return errors.New("--table names the INSERT target, so it needs --format sql")
}
+72 -8
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
)
@@ -15,9 +16,8 @@ type Column struct {
}
// Record is one record rendered from a template: every direct field is a column,
// drawn independently and listed in name order. The template's format is the
// string a [Generator.Fake] call renders; a record is its inverse — each field
// projected as a column instead of composed.
// listed in name order. Each column is its own expansion, so a sibling field is
// local to it, while a reference is drawn once for the whole record.
type Record struct {
columns []Column
}
@@ -68,7 +68,11 @@ func csvLine(cols []string) string {
w := csv.NewWriter(&b)
_ = w.Write(cols)
w.Flush()
return strings.TrimSuffix(b.String(), "\n")
line := strings.TrimSuffix(b.String(), "\n")
if line == "" {
return `""` // a blank line is a row every CSV reader drops
}
return line
}
// SQLInsert renders the record as one INSERT statement into table: identifiers in
@@ -145,10 +149,8 @@ func (f *Generator) FakeRecord(input string) (*Record, error) {
return t.Fake(), nil
}
// recordOf is the fence both record entry points pass: the node is a template, it
// carries no repeat — which composes the format rather than projecting columns —
// and it offers at least one column. The columns come back with it, fixed for
// every draw the caller goes on to make.
// recordOf is the fence both record entry points pass. The columns come back with
// the template, fixed for every draw the caller goes on to make.
func recordOf(n node) (*template, []string, error) {
t, ok := n.(*template)
if !ok {
@@ -161,9 +163,71 @@ func recordOf(n node) (*template, []string, error) {
if len(columns) == 0 {
return nil, nil, errors.New("has no fields, so no columns")
}
if err := checkColumnRefs(t, columns); err != nil {
return nil, nil, err
}
return t, columns, nil
}
// checkColumnRefs rejects two reference reads that overlap across a record's
// columns: one renders a level the other reads a path into, and the record's one
// draw of that level cannot answer for both. checkNoOverlap settles the pair
// within a single format; the record's shared scope is what carries it across
// columns, so the same pair is settled here. A bare reference holds nothing, so it
// is not collected and keeps drawing on its own.
func checkColumnRefs(t *template, columns []string) error {
reads := columnRefs(t, columns)
sort.Slice(reads, func(i, j int) bool {
if reads[i].a.path != reads[j].a.path {
return reads[i].a.path < reads[j].a.path
}
return reads[i].column < reads[j].column
})
for i, level := range reads {
for _, into := range reads[i+1:] {
if strings.HasPrefix(into.a.path, level.a.path+".") {
return fmt.Errorf("column %q renders {%s}, a level column %q reads a path into with {%s}; name the fields you want instead",
level.column, level.a.name, into.column, into.a.name)
}
}
}
return nil
}
// columnRef is one held reference read, and the column whose render reaches it.
type columnRef struct {
column string
a arm
}
// columnRefs lists every held reference read anywhere a column renders, following
// the same edges expand does.
func columnRefs(t *template, columns []string) []columnRef {
var out []columnRef
for _, name := range columns {
seen := map[node]bool{}
var walk func(n node)
walk = func(n node) {
if n == nil || seen[n] {
return
}
seen[n] = true
if tm, ok := n.(*template); ok {
for _, r := range boundReaders(tm.format, tm.bound, tm.refs) {
if a := splitArm(r.name, tm.refs); isRef(a.key) && len(a.tail) > 0 {
out = append(out, columnRef{name, a})
}
}
}
for _, e := range renderEdges(n) {
walk(e.to)
}
}
walk(t.fields[name])
}
return out
}
// renderRecord draws each column once, in the name order recordOf fixed. The
// columns share one reference scope, so two columns that reference one category
// read one draw of it.