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
10 changed files with 159 additions and 57 deletions
Showing only changes of commit 2b2010bdd6 - Show all commits
+11
View File
@@ -310,6 +310,12 @@ 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}"`,
or a struct field tagged `src.score` — is that column: it takes the column's datatype
and is null, or nil, where the column is. A `datatype` of its own types a string
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 `""`.
A typed column's `{calc()}` must be proven to print a number: each operand a number A typed column's `{calc()}` must be proven to print a number: each operand a number
literal, an `{int()}`, `{float()}`, `{seq()}` or `{digits()}` call, a calc, or a read of literal, an `{int()}`, `{float()}`, `{seq()}` or `{digits()}` call, a calc, or a read of
such values, whose bounds keep every divisor from zero and the result within `1e300`. such values, whose bounds keep every divisor from zero and the result within `1e300`.
@@ -663,6 +669,11 @@ tokens add cost in proportion to the output.
- **A typed column holds one value, not composed text.** Its bounds come from a - **A typed column holds one value, not composed text.** Its bounds come from a
literal or a call's arguments, so a load error names a real value, a range check is literal or a call's arguments, so a load error names a real value, a range check is
one comparison, and `1{digits(2)}` is a second spelling of `{int(100,199)}`. one comparison, and `1{digits(2)}` is a second spelling of `{int(100,199)}`.
- **A column of one reference alone is the column it reads.** `{/src.score}` renders
exactly what `src.score` draws, so it takes that column's datatype and null rather
than restating them, and a `datatype` restating a typed column is a second spelling.
Over a string column a `datatype` still types the values — the one way to type a
column someone else wrote.
- **A typed column's calc is refused unless proven.** Operand bounds must keep each - **A typed column's calc is refused unless proven.** Operand bounds must keep each
divisor from zero and the result finite; what they cannot show is refused rather divisor from zero and the result finite; what they cannot show is refused rather
than trusted, since a bare `NaN` breaks the JSON and SQL it lands in. than trusted, since a bare `NaN` breaks the JSON and SQL it lands in.
+51 -14
View File
@@ -64,22 +64,55 @@ func datatypeOf(m map[string]any, pos position) (DataType, error) {
return 0, fmt.Errorf(`datatype takes "integer", "number" or "boolean", got %q`, name) return 0, fmt.Errorf(`datatype takes "integer", "number" or "boolean", got %q`, name)
} }
// columnDatatype is the datatype a column's items declare. They must agree, since a // checkColumns rejects a record column whose items hold different datatypes.
func checkColumns(path string, n node) error {
t, ok := n.(*template)
if !ok || !t.record {
return nil
}
for _, name := range recordColumns(t) {
if _, err := columnDatatype(t.fields[name]); err != nil {
return fmt.Errorf("%s: field %q: %w", path, name, err)
}
}
return nil
}
// columnDatatype is the datatype a column's items hold. They must agree, since a
// column holds one; a column only ever null is a string. // column holds one; a column only ever null is a string.
func columnDatatype(n node) (DataType, error) { func columnDatatype(n node) (DataType, error) {
items, _ := columnItems(n) items, _ := columnItems(n)
if len(items) == 0 { if len(items) == 0 {
return DataTypeString, nil return DataTypeString, nil
} }
first := itemDatatype(items[0])
for _, t := range items[1:] { for _, t := range items[1:] {
if t.datatype != items[0].datatype { if d := itemDatatype(t); d != first {
return items[0].datatype, disagreement(items[0], t) return first, disagreement(items[0], first, t, d)
} }
} }
return items[0].datatype, nil return first, nil
} }
// columnItems is a column's template items, its choices unwrapped, and whether one is null. // itemDatatype is the datatype a column item declares, else that of the column it reads whole.
func itemDatatype(t *template) DataType {
if t.datatype != DataTypeString {
return t.datatype
}
return readDatatype(t)
}
// readDatatype is the datatype of the column t reads whole; a string when it reads none.
func readDatatype(t *template) DataType {
if t.inherits == nil {
return DataTypeString
}
d, _ := columnDatatype(t.inherits) // checkColumns refuses that column where it sits
return d
}
// 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) {
@@ -90,6 +123,10 @@ 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
} }
@@ -98,17 +135,17 @@ func columnItems(n node) (items []*template, nullable bool) {
return items, nullable return items, nullable
} }
// disagreement names the fix for two items of one column declaring different datatypes. // disagreement names the fix for two items of one column holding different datatypes.
func disagreement(a, b *template) error { func disagreement(a *template, da DataType, b *template, db DataType) error {
typed, bare := a, b bare, want := b, da
if typed.datatype == DataTypeString { if da == DataTypeString {
typed, bare = b, a bare, want = a, db
} }
switch { switch {
case bare.datatype != DataTypeString: case da != DataTypeString && db != DataTypeString:
return fmt.Errorf("its items declare %s and %s; a column holds one datatype", a.datatype, b.datatype) return fmt.Errorf("its items declare %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.fields == nil: // a JSON string; an object, which may carry a weight, has a fields map
return fmt.Errorf(`item %q declares no datatype, and a column holds one; write it as {"format":%q,"datatype":%q}`, bare.format, bare.format, typed.datatype) 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(`an item declares no datatype beside one declaring %s; a column holds one, so give it "datatype": %q`, typed.datatype, typed.datatype) 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)
} }
+3
View File
@@ -182,6 +182,9 @@ func inlineScope(n node, label string) nodeScope {
// the walk. It runs after checkNoCycles, whose guarantee is what lets the walks // the walk. It runs after checkNoCycles, whose guarantee is what lets the walks
// terminate. // terminate.
func checkScope(s nodeScope) error { func checkScope(s nodeScope) error {
if err := s(checkColumns); err != nil {
return err
}
mem := reachMemo{} mem := reachMemo{}
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 {
return err return err
+12 -3
View File
@@ -253,11 +253,20 @@ 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, and the value each read, by // was drawn as, so every path under it reads one row, the value each read, by its one
// its one spelling, so the same read written twice reads one value. // spelling, so the same read written twice reads one value, and the columns drawn null,
// 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]string
nulls map[node]bool
}
func (d *draws) drewNull(column node) {
if d.nulls == nil {
d.nulls = map[node]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
@@ -299,7 +308,7 @@ 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 = render(s, n, refScope); return nil }, leaf: func(n node) error { v, _ = renderColumn(s, n, refScope); return nil },
}) })
d.value[a.path] = v d.value[a.path] = v
return v return v
+4 -4
View File
@@ -59,6 +59,9 @@ 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.
inherits node
record bool // compiled at the top without a repeat, so its fields are record columns
} }
func (*template) isNode() {} func (*template) isNode() {}
@@ -244,7 +247,7 @@ func compileTemplate(m map[string]any, pos position) (node, error) {
if err := checkTokens(o.format, fields); err != nil { if err := checkTokens(o.format, fields); err != nil {
return nil, err return nil, err
} }
t := &template{format: o.format, fields: fields, repeat: o.repeat, separator: o.separator, datatype: o.datatype} t := &template{format: o.format, fields: fields, repeat: o.repeat, separator: o.separator, datatype: o.datatype, record: fieldPos == inColumn}
if err := t.compileFormat(); err != nil { if err := t.compileFormat(); err != nil {
return nil, err return nil, err
} }
@@ -307,9 +310,6 @@ func compileFields(m map[string]any, pos position) (map[string]node, error) {
return nil, fmt.Errorf("field %w", err) return nil, fmt.Errorf("field %w", err)
} }
n, err := compileAt(m[k], pos) n, err := compileAt(m[k], pos)
if err == nil && pos == inColumn {
_, err = columnDatatype(n)
}
if err != nil { if err != nil {
return nil, fmt.Errorf("field %q: %w", k, err) return nil, fmt.Errorf("field %q: %w", k, err)
} }
+18 -7
View File
@@ -217,7 +217,7 @@ func recordOf(n node) (*template, []Column, error) {
} }
columns := make([]Column, len(names)) columns := make([]Column, len(names))
for i, name := range names { for i, name := range names {
datatype, _ := columnDatatype(t.fields[name]) // compile refused a column whose items disagree datatype, _ := columnDatatype(t.fields[name]) // checkColumns refused a column whose items disagree
columns[i] = Column{Name: name, DataType: datatype} columns[i] = Column{Name: name, DataType: datatype}
} }
return t, columns, nil return t, columns, nil
@@ -300,16 +300,27 @@ 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]string{}}
r := &Record{columns: append([]Column(nil), columns...)} r := &Record{columns: append([]Column(nil), columns...)}
for i := range r.columns { for i := range r.columns {
n := drawn(s, t.fields[r.columns[i].Name]) r.columns[i].Value, r.columns[i].Null = renderColumn(s, t.fields[r.columns[i].Name], scope)
if _, isNull := n.(*null); isNull {
r.columns[i].Null = true
} else {
r.columns[i].Value = render(s, n, scope)
}
} }
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.
+15
View File
@@ -107,9 +107,24 @@ 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)
return nil return nil
} }
// columnRead is the column t reads whole: its format is one reference alone, reading a field of
// a record, so what that column draws is what t draws.
func columnRead(t *template) node {
if t.repeat != 1 || len(t.ops) != 1 || t.ops[0].kind != 'f' || len(t.ops[0].arms) != 1 {
return nil
}
a := t.ops[0].arms[0]
target, isTemplate := t.fields[a.key].(*template)
if !isRef(a.name) || !isTemplate || !target.record || len(a.tail) != 1 {
return nil
}
return target.fields[a.tail[0]]
}
// 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
// and the dot path reaching it, folders and names in sorted order. // and the dot path reaching it, folders and names in sorted order.
func eachTemplate(root map[string]node, fn func(folder []string, path string, t *template) error) error { func eachTemplate(root map[string]node, fn func(folder []string, path string, t *template) error) error {
+8 -6
View File
@@ -269,9 +269,6 @@ func (s *structShape) compileRecord(root map[string]node, t reflect.Type, label
s.fields = make([][]int, len(columns)) s.fields = make([][]int, len(columns))
for i, c := range columns { for i, c := range columns {
sf, _ := t.FieldByName(c.Name) sf, _ := t.FieldByName(c.Name)
if c.DataType != DataTypeString {
return fmt.Errorf("%s.%s: its Go type %s sets the datatype; drop \"datatype\"", label, c.Name, sf.Type)
}
if err := proof.checkField(label+"."+c.Name, sf.Type, record.fields[c.Name]); err != nil { if err := proof.checkField(label+"."+c.Name, sf.Type, record.fields[c.Name]); err != nil {
return err return err
} }
@@ -318,10 +315,15 @@ func (k columnKind) holds(v proven) bool {
return v.lo >= k.lo && v.hi <= k.hi return v.lo >= k.lo && v.hi <= k.hi
} }
// checkField rejects a column some render of which a field of Go type ft cannot hold: a null // checkField rejects a column a field of Go type ft cannot fill: a datatype, which the Go type
// 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, nullable := columnItems(column)
for _, it := range items {
if it.datatype != DataTypeString {
return fmt.Errorf("%s: its Go type %s sets the datatype; drop \"datatype\"", label, ft)
}
}
elem := ft elem := ft
if ft.Kind() == reflect.Pointer { if ft.Kind() == reflect.Pointer {
elem = ft.Elem() elem = ft.Elem()
@@ -333,7 +335,7 @@ func (p *valueProof) checkField(label string, ft reflect.Type, column node) erro
return nil return nil
} }
for _, it := range items { for _, it := range items {
v := p.of(it) v := p.columnItem(it)
if reason := v.not[kind.datatype]; reason != "" { if reason := v.not[kind.datatype]; reason != "" {
return fmt.Errorf("%s (%s): %s", label, ft, reason) return fmt.Errorf("%s (%s): %s", label, ft, reason)
} }
-5
View File
@@ -17,11 +17,6 @@ The record API lands first, so the data update can use it.
- `code` and `symbol` sibling fields reading `currency`, as `{code} {symbol}` → a matching pair - `code` and `symbol` sibling fields reading `currency`, as `{code} {symbol}` → a matching pair
- `{a} & {b}`, each reading `person` → one person, or two when `a` and `b` name different groups - `{a} & {b}`, each reading `person` → one person, or two when `a` and `b` name different groups
- two bare `{/sv_SE.word}` → two words - two bare `{/sv_SE.word}` → two words
- Reference inheritance — settle whether a column that is exactly one reference to
another record's column, like `{/src.score}`, takes that column's datatype and
null. Today a null there writes `""`, a `*T` struct field reading it gets `""`
rather than nil, and a typed column reading it is refused. Settle before draw
groups and the data update.
### Data ### Data
+37 -18
View File
@@ -23,24 +23,38 @@ type valueProof struct {
memo map[node]proven memo map[node]proven
} }
// checkDatatype rejects a typed column 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.
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 {
return nil return nil
} }
if err := p.prove(t, t.datatype); err != nil { if d := readDatatype(t); d != DataTypeString {
return fmt.Errorf("%s: %w", path, err) return fmt.Errorf(`%s: %s takes datatype %s from the column it reads; drop "datatype"`, path, t.format, d)
}
if reason := p.columnItem(t).not[t.datatype]; reason != "" {
return fmt.Errorf("%s: datatype %s: %s", path, t.datatype, reason)
} }
return nil return nil
} }
// prove reports why some render of n is not text of datatype d. // columnItem proves a column item: what it renders, or, reading a column whole, that column's
func (p *valueProof) prove(n node, d DataType) error { // items, whose nulls it draws as null rather than rendering them.
if reason := p.of(n).not[d]; reason != "" { func (p *valueProof) columnItem(t *template) proven {
return fmt.Errorf("datatype %s: %s", d, reason) if t.inherits == nil {
return p.of(t)
} }
return nil var v proven
items, _ := columnItems(t.inherits)
for i, it := range items {
if w := p.columnItem(it); i == 0 {
v = w
} else {
v = v.or(w)
}
}
return v
} }
func (p *valueProof) of(n node) proven { func (p *valueProof) of(n node) proven {
@@ -66,16 +80,21 @@ func (p *valueProof) of(n node) proven {
func (p *valueProof) unite(nodes []node) proven { func (p *valueProof) unite(nodes []node) proven {
v := p.of(nodes[0]) v := p.of(nodes[0])
for _, n := range nodes[1:] { for _, n := range nodes[1:] {
w := p.of(n) v = v.or(p.of(n))
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 return v
if v.notOperand == "" { }
v.notOperand = w.notOperand
} // or is what a proof knows of a render that is either v or w.
for d := range v.not { func (v proven) or(w proven) proven {
if v.not[d] == "" { v.lo, v.hi, v.nonZero = min(v.lo, w.lo), max(v.hi, w.hi), min(v.nonZero, w.nonZero)
v.not[d] = w.not[d] v.integral = v.integral && w.integral
} if v.notOperand == "" {
v.notOperand = w.notOperand
}
for d := range v.not {
if v.not[d] == "" {
v.not[d] = w.not[d]
} }
} }
return v return v