A column of one reference alone takes the datatype and null of the column it reads #15

Merged
lilleman merged 12 commits from reference-inheritance into main 2026-09-15 21:46:18 +02:00
11 changed files with 129 additions and 99 deletions
Showing only changes of commit b4ebab5abf - Show all commits
+4 -4
View File
@@ -310,9 +310,9 @@ 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 {int()}, {float()}, {seq()} or {calc()}, or read one order.id: datatype integer: "1{digits(2)}" is not one value; write one literal or one {int()}, {float()}, {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 alone to another record's column — `"score": "{/src.score}"`
or a struct field tagged `src.score` — is that column: it takes the column's datatype — is that column: it takes the column's datatype and is null where the column is, and
and is null, or nil, where the column is. A `datatype` of its own types a string a struct field tagged `src.score` is nil there. A `datatype` of its own types a string
column's values, and over a typed column is refused naming the datatype it takes. Any column's values, and over a typed column is refused naming the datatype it takes. Any
other read renders the column's text, a null as `""`. other read renders the column's text, a null as `""`.
@@ -336,7 +336,7 @@ renders a null as `""`. The other items' weights skew its odds:
``` ```
`deleted_at` is null every draw, `middle` a name three draws in four. Rejected at `deleted_at` is null every draw, `middle` a name three draws in four. Rejected at
load: `null` anywhere but a column, naming `""`, and a column whose items declare load: `null` anywhere but a column, naming `""`, and a column whose items hold
different datatypes. different datatypes.
### Options and fields ### Options and fields
+12 -13
View File
@@ -94,7 +94,7 @@ func columnDatatype(n node) (DataType, error) {
return first, nil return first, nil
} }
// itemDatatype is the datatype a column item declares, else that of the column it reads whole. // itemDatatype is the datatype a column item declares, else that of the column it is.
func itemDatatype(t *template) DataType { func itemDatatype(t *template) DataType {
if t.datatype != DataTypeString { if t.datatype != DataTypeString {
return t.datatype return t.datatype
@@ -102,17 +102,20 @@ func itemDatatype(t *template) DataType {
return readDatatype(t) return readDatatype(t)
} }
// readDatatype is the datatype of the column t reads whole; a string when it reads none. // readDatatype is the datatype of the column t is, reading it by one reference alone; a string
// when t reads none.
func readDatatype(t *template) DataType { func readDatatype(t *template) DataType {
if t.inherits == nil { if t.readsColumn == nil {
return DataTypeString return DataTypeString
} }
d, _ := columnDatatype(t.inherits) // checkColumns refuses that column where it sits items, _ := columnItems(t.readsColumn.column)
return d if len(items) == 0 {
return DataTypeString
}
return itemDatatype(items[0]) // checkColumns refuses that column where its items disagree
} }
// columnItems is a column's template items, its choices unwrapped, and whether one is null // columnItems is a column's template items, its choices unwrapped, and whether one is null.
// or reads whole a column that can be.
func columnItems(n node) (items []*template, nullable bool) { func columnItems(n node) (items []*template, nullable bool) {
var collect func(node) var collect func(node)
collect = func(n node) { collect = func(n node) {
@@ -123,10 +126,6 @@ func columnItems(n node) (items []*template, nullable bool) {
} }
case *template: case *template:
items = append(items, n) items = append(items, n)
if n.inherits != nil {
_, inherited := columnItems(n.inherits)
nullable = nullable || inherited
}
case *null: case *null:
nullable = true nullable = true
} }
@@ -143,8 +142,8 @@ func disagreement(a *template, da DataType, b *template, db DataType) error {
} }
switch { switch {
case da != DataTypeString && db != DataTypeString: case da != DataTypeString && db != DataTypeString:
return fmt.Errorf("its items declare %s and %s; a column holds one datatype", da, db) return fmt.Errorf("its items hold %s and %s; a column holds one datatype", da, db)
case bare.fields == nil: // a JSON string; an object, which may carry a weight, has a fields map case bare.fromString: // an object may carry a weight, which this spelling would drop
return fmt.Errorf(`item %q declares no datatype, and a column holds one; write it as {"format":%q,"datatype":%q}`, bare.format, bare.format, want) return fmt.Errorf(`item %q declares no datatype, and a column holds one; write it as {"format":%q,"datatype":%q}`, bare.format, bare.format, want)
} }
return fmt.Errorf(`item %q declares no datatype beside one declaring %s; a column holds one, so give it "datatype": %q`, bare.format, want, want) return fmt.Errorf(`item %q declares no datatype beside one declaring %s; a column holds one, so give it "datatype": %q`, bare.format, want, want)
+41 -24
View File
@@ -253,20 +253,17 @@ func checkNoRepeatedRead(format string, c formatOps, refs map[string]refBinding)
} }
// draws is what an expansion has already drawn for its held names: the variant each // draws is what an expansion has already drawn for its held names: the variant each
// was drawn as, so every path under it reads one row, the value each read, by its one // was drawn as, so every path under it reads one row, and the draw each read made, by
// spelling, so the same read written twice reads one value, and the columns drawn null, // its one spelling, so the same read written twice reads one draw.
// so a read of one whole is null too.
type draws struct { type draws struct {
variant map[string]node variant map[string]node
value map[string]string value map[string]draw
nulls map[node]bool
} }
func (d *draws) drewNull(column node) { // draw is what one read drew: its text, and whether it landed on a null.
if d.nulls == nil { type draw struct {
d.nulls = map[node]bool{} text string
} null bool
d.nulls[column] = true
} }
// readField renders one arm of a token. An arm's key is a sibling field or a // readField renders one arm of a token. An arm's key is a sibling field or a
@@ -276,23 +273,18 @@ func (d *draws) drewNull(column node) {
// gives one value, and a shown operand is the operand computed. Every other name is // gives one value, and a shown operand is the operand computed. Every other name is
// drawn afresh, so {word} {word} still draws twice. checkTokens, checkPath and // drawn afresh, so {word} {word} still draws twice. checkTokens, checkPath and
// linkRefs prove every step, so the walk cannot fail. // linkRefs prove every step, so the walk cannot fail.
func readField(s *session, t *template, held, refScope *draws, a arm) string { func readField(s *session, t *template, held, refScope *draws, a arm) draw {
if !t.held[a.key] { if !t.held[a.key] {
if len(a.tail) > 0 { if len(a.tail) > 0 {
panic(fmt.Sprintf("fejkdata: %q reads a path into %q, which the expansion does not hold", a.name, a.key)) panic(fmt.Sprintf("fejkdata: %q reads a path into %q, which the expansion does not hold", a.name, a.key))
} }
return render(s, t.fields[a.key], refScope) return draw{text: render(s, t.fields[a.key], refScope)}
} }
// A reference that reads a path reads the caller's scope, so its draw outlives d := readScope(held, refScope, a)
// this expansion; a sibling, and a reference read whole, stay local to it. if r, done := d.value[a.path]; done {
d := held return r
if isRef(a.key) && refScope != nil && len(a.tail) > 0 {
d = refScope
} }
if v, read := d.value[a.path]; read { var r draw
return v
}
var v string
_ = walkPath(t.fields[a.key], a.tail, pathWalk{ _ = walkPath(t.fields[a.key], a.tail, pathWalk{
// Hold the draw at every level passed through, so two paths sharing a // Hold the draw at every level passed through, so two paths sharing a
// prefix share it. // prefix share it.
@@ -308,10 +300,35 @@ func readField(s *session, t *template, held, refScope *draws, a arm) string {
} }
return []node{n}, nil return []node{n}, nil
}, },
leaf: func(n node) error { v, _ = renderColumn(s, n, refScope); return nil }, leaf: func(n node) error { r = renderLeaf(s, n, refScope); return nil },
}) })
d.value[a.path] = v d.value[a.path] = r
return v return r
}
// readScope is the draws a held read keeps its draw in: for a reference that reads a path,
// refScope, so its draw outlives this expansion; for a sibling, or a reference read whole, held.
func readScope(held, refScope *draws, a arm) *draws {
if isRef(a.key) && refScope != nil && len(a.tail) > 0 {
return refScope
}
return held
}
// renderLeaf draws and renders what a read lands on: null on a null item, or on a column of one
// reference alone whose read drew null.
func renderLeaf(s *session, n node, scope *draws) draw {
n = drawn(s, n)
if _, isNull := n.(*null); isNull {
return draw{null: true}
}
r := draw{text: render(s, n, scope)}
if t, _ := n.(*template); t != nil && t.readsColumn != nil {
if d := readScope(nil, scope, t.readsColumn.a); d != nil {
r.null = d.value[t.readsColumn.a.path].null
}
}
return r
} }
// drawn resolves a choice to one variant, so a bound head is a concrete node the // drawn resolves a choice to one variant, so a bound head is a concrete node the
+2 -6
View File
@@ -101,12 +101,8 @@ func loneReference(arg string) (string, bool) {
raw = m["format"] raw = m["format"]
} }
format, isString := raw.(string) format, isString := raw.(string)
var units []ftoken body, lone := loneRef(format)
if !isString || eachToken(format, func(t ftoken) error { units = append(units, t); return nil }) != nil || len(units) != 1 { if !isString || !lone {
return "", false
}
body := units[0].body
if units[0].kind != 'b' || !isRef(body) || strings.ContainsAny(body, "|(") {
return "", false return "", false
} }
if strings.HasPrefix(body, "/") { if strings.HasPrefix(body, "/") {
+4 -4
View File
@@ -59,8 +59,8 @@ type template struct {
// held is every name drawn once per expansion: the bound levels above, plus the // held is every name drawn once per expansion: the bound levels above, plus the
// siblings a {calc()} reads. nil when the format holds nothing (see expand). // siblings a {calc()} reads. nil when the format holds nothing (see expand).
held map[string]bool held map[string]bool
// inherits is the column a format of one reference alone reads, taking its datatype and null. fromString bool // written as a JSON string rather than an object
inherits node readsColumn *columnRead // set when the format is one reference alone reading a record's column
record bool // compiled at the top without a repeat, so its fields are record columns record bool // compiled at the top without a repeat, so its fields are record columns
} }
@@ -127,7 +127,7 @@ func compileString(s string) (node, error) {
if err := checkTokens(s, nil); err != nil { if err := checkTokens(s, nil); err != nil {
return nil, err return nil, err
} }
t := &template{format: s, repeat: 1} t := &template{format: s, repeat: 1, fromString: true}
if err := t.compileFormat(); err != nil { if err := t.compileFormat(); err != nil {
return nil, err return nil, err
} }
@@ -234,7 +234,7 @@ func compileTemplate(m map[string]any, pos position) (node, error) {
return nil, err return nil, err
} }
fieldPos := inFormat fieldPos := inFormat
if pos == atTop && projectsColumns(o.repeat) { if pos == atTop && o.repeat == 1 {
fieldPos = inColumn fieldPos = inColumn
} }
fields, err := compileFields(m, fieldPos) fields, err := compileFields(m, fieldPos)
+6 -25
View File
@@ -205,13 +205,13 @@ func recordOf(n node) (*template, []Column, error) {
if !ok { if !ok {
return nil, nil, errors.New("names a choice, not a template; a record is a template whose fields are its columns") return nil, nil, errors.New("names a choice, not a template; a record is a template whose fields are its columns")
} }
if !projectsColumns(t.repeat) {
return nil, nil, fmt.Errorf("carries repeat %d, which composes its format into one string; a record projects columns instead — drop the repeat and render the record again for more rows", t.repeat)
}
names := recordColumns(t) names := recordColumns(t)
if len(names) == 0 { if len(names) == 0 {
return nil, nil, errors.New("has no fields, so no columns") return nil, nil, errors.New("has no fields, so no columns")
} }
if !t.record {
return nil, nil, fmt.Errorf("carries repeat %d, which composes its format into one string; a record projects columns instead — drop the repeat and render the record again for more rows", t.repeat)
}
if err := checkColumnRefs(t, names); err != nil { if err := checkColumnRefs(t, names); err != nil {
return nil, nil, err return nil, nil, err
} }
@@ -223,10 +223,6 @@ func recordOf(n node) (*template, []Column, error) {
return t, columns, nil return t, columns, nil
} }
// projectsColumns reports whether a category or inline template with this repeat is a
// record, its fields the columns; a repeat composes the format into one string instead.
func projectsColumns(repeat int) bool { return repeat == 1 }
// checkColumnRefs rejects the reference reads a record's shared draw cannot answer // checkColumnRefs rejects the reference reads a record's shared draw cannot answer
// for: one column rendering a level another reads a path into, and a column // for: one column rendering a level another reads a path into, and a column
// reading the record back through its own path. // reading the record back through its own path.
@@ -297,30 +293,15 @@ func columnRefs(t *template, columns []string) ([]columnRef, error) {
// renderRecord draws each column once, in the name order recordOf fixed, over one // renderRecord draws each column once, in the name order recordOf fixed, over one
// reference scope shared across them. // reference scope shared across them.
func renderRecord(s *session, t *template, columns []Column) *Record { func renderRecord(s *session, t *template, columns []Column) *Record {
scope := &draws{variant: map[string]node{}, value: map[string]string{}} scope := &draws{variant: map[string]node{}, value: map[string]draw{}}
r := &Record{columns: append([]Column(nil), columns...)} r := &Record{columns: append([]Column(nil), columns...)}
for i := range r.columns { for i := range r.columns {
r.columns[i].Value, r.columns[i].Null = renderColumn(s, t.fields[r.columns[i].Name], scope) column := renderLeaf(s, t.fields[r.columns[i].Name], scope)
r.columns[i].Value, r.columns[i].Null = column.text, column.null
} }
return r return r
} }
// renderColumn draws a column and reports whether the draw is null: a null item, or an item
// reading whole a column that scope drew null.
func renderColumn(s *session, column node, scope *draws) (string, bool) {
n := drawn(s, column)
value, isNull := "", true
if _, drewNull := n.(*null); !drewNull {
value = render(s, n, scope)
t, _ := n.(*template)
isNull = t != nil && t.inherits != nil && scope != nil && scope.nulls[t.inherits]
}
if isNull && scope != nil {
scope.drewNull(column)
}
return value, isNull
}
// recordColumns is the sorted non-reference field names — the columns a record // recordColumns is the sorted non-reference field names — the columns a record
// projects. A {/path} binding is carried in fields under its root path, so only a // projects. A {/path} binding is carried in fields under its root path, so only a
// name that is not a reference is a column. // name that is not a reference is a column.
+28 -8
View File
@@ -107,22 +107,42 @@ func linkTemplateRefs(folder []string, path string, t *template, root map[string
if err := t.compileFormat(); err != nil { if err := t.compileFormat(); err != nil {
return fmt.Errorf("%s: %w", path, err) return fmt.Errorf("%s: %w", path, err)
} }
t.inherits = columnRead(t) t.readsColumn = columnReadOf(t)
return nil return nil
} }
// columnRead is the column t reads whole: its format is one reference alone, reading a field of // columnRead is a record's column read by a format of that one reference alone, which is the
// a record, so what that column draws is what t draws. // column: it takes the column's datatype and null.
func columnRead(t *template) node { type columnRead struct {
if t.repeat != 1 || len(t.ops) != 1 || t.ops[0].kind != 'f' || len(t.ops[0].arms) != 1 { a arm
column node
}
func columnReadOf(t *template) *columnRead {
name, lone := loneRef(t.format)
if !lone || t.repeat != 1 {
return nil return nil
} }
a := t.ops[0].arms[0] a := splitArm(name, t.refs)
target, isTemplate := t.fields[a.key].(*template) target, isTemplate := t.fields[a.key].(*template)
if !isRef(a.name) || !isTemplate || !target.record || len(a.tail) != 1 { if !isTemplate || !target.record || len(a.tail) != 1 {
return nil return nil
} }
return target.fields[a.tail[0]] return &columnRead{a: a, column: target.fields[a.tail[0]]}
}
// loneRef is the reference a format of one reference token and nothing else reads.
func loneRef(format string) (string, bool) {
units, body := 0, ""
err := eachToken(format, func(t ftoken) error {
units++
body = t.body
return nil
})
if err != nil || units != 1 || !isRef(body) || strings.ContainsAny(body, "|(") {
return "", false
}
return body, true
} }
// eachTemplate calls fn once per template, with the folder its category sits in // eachTemplate calls fn once per template, with the folder its category sits in
+3 -3
View File
@@ -103,7 +103,7 @@ func expand(s *session, t *template, refScope *draws) string {
if len(t.held) > 0 { if len(t.held) > 0 {
held = &draws{ held = &draws{
variant: make(map[string]node, len(t.held)), variant: make(map[string]node, len(t.held)),
value: make(map[string]string, len(t.held)), value: make(map[string]draw, len(t.held)),
} }
} }
for i := range t.ops { for i := range t.ops {
@@ -112,7 +112,7 @@ func expand(s *session, t *template, refScope *draws) string {
case 'l': case 'l':
b.WriteString(o.lit) b.WriteString(o.lit)
case 'f': case 'f':
b.WriteString(readField(s, t, held, refScope, o.arms[s.IntN(len(o.arms))])) b.WriteString(readField(s, t, held, refScope, o.arms[s.IntN(len(o.arms))]).text)
case 'b': case 'b':
// Read before the call, so the value a calc computes is the value the // Read before the call, so the value a calc computes is the value the
// format showed. calcVars fixed the order op.operands holds. // format showed. calcVars fixed the order op.operands holds.
@@ -120,7 +120,7 @@ func expand(s *session, t *template, refScope *draws) string {
if len(o.operands) > 0 { if len(o.operands) > 0 {
operands = make([]string, len(o.operands)) operands = make([]string, len(o.operands))
for j, a := range o.operands { for j, a := range o.operands {
operands[j] = readField(s, t, held, refScope, a) operands[j] = readField(s, t, held, refScope, a).text
} }
} }
b.WriteString(o.call(s, b.String(), operands)) // b.String() is the output so far b.WriteString(o.call(s, b.String(), operands)) // b.String() is the output so far
+2 -2
View File
@@ -318,7 +318,7 @@ func (k columnKind) holds(v proven) bool {
// checkField rejects a column a field of Go type ft cannot fill: a datatype, which the Go type // checkField rejects a column a field of Go type ft cannot fill: a datatype, which the Go type
// sets, a null outside a pointer, or a value its kind's datatype or range refuses. // sets, 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 { func (p *valueProof) checkField(label string, ft reflect.Type, column node) error {
items, nullable := columnItems(column) items, _ := columnItems(column)
for _, it := range items { for _, it := range items {
if it.datatype != DataTypeString { if it.datatype != DataTypeString {
return fmt.Errorf("%s: its Go type %s sets the datatype; drop \"datatype\"", label, ft) return fmt.Errorf("%s: its Go type %s sets the datatype; drop \"datatype\"", label, ft)
@@ -327,7 +327,7 @@ func (p *valueProof) checkField(label string, ft reflect.Type, column node) erro
elem := ft elem := ft
if ft.Kind() == reflect.Pointer { if ft.Kind() == reflect.Pointer {
elem = ft.Elem() elem = ft.Elem()
} else if nullable { } else if p.column(column).null {
return fmt.Errorf("%s: its tag can draw null, which %s cannot hold; make it *%s", label, ft, ft) return fmt.Errorf("%s: its tag can draw null, which %s cannot hold; make it *%s", label, ft, ft)
} }
kind := columnKinds[elem.Kind()] kind := columnKinds[elem.Kind()]
+3 -1
View File
@@ -24,7 +24,9 @@ The record API lands first, so the data update can use it.
blocks as columns (`sv_SE.person` → `femalefirst`, `malefirst`; `misc.uuid` → blocks as columns (`sv_SE.person` → `femalefirst`, `malefirst`; `misc.uuid` →
`variant`), and `sv_SE.address` draws its postal code apart from its locality. `variant`), and `sv_SE.address` draws its postal code apart from its locality.
It also settles what a version promises about shipped data: its paths, its It also settles what a version promises about shipped data: its paths, its
record columns, and whether a seed renders the same output across versions. record columns with their datatypes and nulls — a column of one reference alone
takes both, so typing a column or adding a null breaks its readers — and whether
a seed renders the same output across versions.
- `email.local` and `username` share their handle lists, while their name - `email.local` and `username` share their handle lists, while their name
variants differ on purpose. Share the lists only if that is a clean win. variants differ on purpose. Share the lists only if that is a clean win.
+21 -6
View File
@@ -16,15 +16,17 @@ type proven struct {
integral bool integral bool
notOperand string // why some render reads as no finite number, the way calc reads it notOperand string // why some render reads as no finite number, the way calc reads it
not [len(dataTypeNames)]string not [len(dataTypeNames)]string
null bool // some draw of a column is null
} }
// valueProof proves what typed columns and their calc operands hold, each node once per scope. // valueProof proves what typed columns and their calc operands hold, each node once per scope.
type valueProof struct { type valueProof struct {
memo map[node]proven memo map[node]proven
columns map[node]proven
} }
// checkDatatype rejects a typed column item some render of which is not text of its datatype, // checkDatatype rejects a typed column item some render of which is not text of its datatype,
// and one declaring a datatype over the typed column it reads whole. // and one declaring a datatype over the typed column it is.
func (p *valueProof) checkDatatype(path string, n node) error { func (p *valueProof) checkDatatype(path string, n node) error {
t, ok := n.(*template) t, ok := n.(*template)
if !ok || t.datatype == DataTypeString { if !ok || t.datatype == DataTypeString {
@@ -39,14 +41,25 @@ func (p *valueProof) checkDatatype(path string, n node) error {
return nil return nil
} }
// columnItem proves a column item: what it renders, or, reading a column whole, that column's // columnItem proves a column item: what it renders, or, when it is a column it reads, that column.
// items, whose nulls it draws as null rather than rendering them.
func (p *valueProof) columnItem(t *template) proven { func (p *valueProof) columnItem(t *template) proven {
if t.inherits == nil { if t.readsColumn == nil {
return p.of(t) return p.of(t)
} }
return p.column(t.readsColumn.column)
}
// column proves a column over what its items draw, a null item marking it null rather than
// rendering "".
func (p *valueProof) column(n node) proven {
if v, done := p.columns[n]; done {
return v
}
if p.columns == nil {
p.columns = map[node]proven{}
}
items, nullable := columnItems(n)
var v proven var v proven
items, _ := columnItems(t.inherits)
for i, it := range items { for i, it := range items {
if w := p.columnItem(it); i == 0 { if w := p.columnItem(it); i == 0 {
v = w v = w
@@ -54,6 +67,8 @@ func (p *valueProof) columnItem(t *template) proven {
v = v.or(w) v = v.or(w)
} }
} }
v.null = v.null || nullable
p.columns[n] = v
return v return v
} }
@@ -88,7 +103,7 @@ func (p *valueProof) unite(nodes []node) proven {
// or is what a proof knows of a render that is either v or w. // or is what a proof knows of a render that is either v or w.
func (v proven) or(w proven) proven { func (v proven) or(w proven) proven {
v.lo, v.hi, v.nonZero = min(v.lo, w.lo), max(v.hi, w.hi), min(v.nonZero, w.nonZero) v.lo, v.hi, v.nonZero = min(v.lo, w.lo), max(v.hi, w.hi), min(v.nonZero, w.nonZero)
v.integral = v.integral && w.integral v.integral, v.null = v.integral && w.integral, v.null || w.null
if v.notOperand == "" { if v.notOperand == "" {
v.notOperand = w.notOperand v.notOperand = w.notOperand
} }