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. 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 The columns are the point, and their facts stay together: two columns that
reference one category — `{/currency.code}` and `{/currency.symbol}` — share one reference one category — `{/currency.code}` and `{/currency.symbol}` — share one
draw of it, so the record is internally consistent. A field hold, transform or draw of it, so the record is internally consistent. That one draw is also why two
operand ties fields together within one column as always (see columns may not read overlapping reference paths — `{/cat.a}` beside `{/cat.a.b}`
[Correlated fields](#correlated-fields) and [Decisions](#decisions)). 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 ## Library
+4 -3
View File
@@ -216,9 +216,7 @@ type recordFormat struct {
line func(r *fejkdata.Record, table string) string line func(r *fejkdata.Record, table string) string
} }
// recordFormats is every --format that writes records. Adding one is this entry // recordFormats is every --format that writes records.
// alone: the flag check reads the same table the writer dispatches through, so a
// format cannot be accepted and then not written.
var recordFormats = map[string]recordFormat{ var recordFormats = map[string]recordFormat{
"csv": {header: (*fejkdata.Record).CSVHeader, line: func(r *fejkdata.Record, _ string) string { return r.CSVLine() }}, "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() }}, "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" { if _, ok := recordFormats[in.format]; !ok && in.format != "text" {
return fmt.Errorf("--format takes %s, got %q", formatNames(), in.format) 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" { if in.tableSet && in.format != "sql" {
return errors.New("--table names the INSERT target, so it needs --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" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"sort"
"strings" "strings"
) )
@@ -15,9 +16,8 @@ type Column struct {
} }
// Record is one record rendered from a template: every direct field is a column, // 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 // listed in name order. Each column is its own expansion, so a sibling field is
// string a [Generator.Fake] call renders; a record is its inverse — each field // local to it, while a reference is drawn once for the whole record.
// projected as a column instead of composed.
type Record struct { type Record struct {
columns []Column columns []Column
} }
@@ -68,7 +68,11 @@ func csvLine(cols []string) string {
w := csv.NewWriter(&b) w := csv.NewWriter(&b)
_ = w.Write(cols) _ = w.Write(cols)
w.Flush() 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 // 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 return t.Fake(), nil
} }
// recordOf is the fence both record entry points pass: the node is a template, it // recordOf is the fence both record entry points pass. The columns come back with
// carries no repeat — which composes the format rather than projecting columns — // the template, fixed for every draw the caller goes on to make.
// and it offers at least one column. The columns come back with it, fixed for
// every draw the caller goes on to make.
func recordOf(n node) (*template, []string, error) { func recordOf(n node) (*template, []string, error) {
t, ok := n.(*template) t, ok := n.(*template)
if !ok { if !ok {
@@ -161,9 +163,71 @@ func recordOf(n node) (*template, []string, error) {
if len(columns) == 0 { if len(columns) == 0 {
return nil, nil, errors.New("has no fields, so no columns") 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 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 // renderRecord draws each column once, in the name order recordOf fixed. The
// columns share one reference scope, so two columns that reference one category // columns share one reference scope, so two columns that reference one category
// read one draw of it. // read one draw of it.