Take the datatype and null of the column a lone reference reads, and check a column's datatypes once its references are linked
Tests / vet + fmt + tests (pull_request) Successful in 1m3s

This commit is contained in:
2026-09-15 20:20:25 +02:00
parent 802f62a4de
commit 2b2010bdd6
10 changed files with 159 additions and 57 deletions
+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
```
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
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`.
@@ -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
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)}`.
- **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
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.
+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)
}
// 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.
func columnDatatype(n node) (DataType, error) {
items, _ := columnItems(n)
if len(items) == 0 {
return DataTypeString, nil
}
first := itemDatatype(items[0])
for _, t := range items[1:] {
if t.datatype != items[0].datatype {
return items[0].datatype, disagreement(items[0], t)
if d := itemDatatype(t); d != first {
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) {
var collect func(node)
collect = func(n node) {
@@ -90,6 +123,10 @@ func columnItems(n node) (items []*template, nullable bool) {
}
case *template:
items = append(items, n)
if n.inherits != nil {
_, inherited := columnItems(n.inherits)
nullable = nullable || inherited
}
case *null:
nullable = true
}
@@ -98,17 +135,17 @@ func columnItems(n node) (items []*template, nullable bool) {
return items, nullable
}
// disagreement names the fix for two items of one column declaring different datatypes.
func disagreement(a, b *template) error {
typed, bare := a, b
if typed.datatype == DataTypeString {
typed, bare = b, a
// disagreement names the fix for two items of one column holding different datatypes.
func disagreement(a *template, da DataType, b *template, db DataType) error {
bare, want := b, da
if da == DataTypeString {
bare, want = a, db
}
switch {
case bare.datatype != DataTypeString:
return fmt.Errorf("its items declare %s and %s; a column holds one datatype", a.datatype, b.datatype)
case da != DataTypeString && db != DataTypeString:
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
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
// terminate.
func checkScope(s nodeScope) error {
if err := s(checkColumns); err != nil {
return err
}
mem := reachMemo{}
if err := s(func(path string, n node) error { return repeatCheck(path, n, mem) }); err != nil {
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
// was drawn as, so every path under it reads one row, and the value each read, by
// its one spelling, so the same read written twice reads one value.
// was drawn as, so every path under it reads one row, the value each read, by its one
// 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 {
variant map[string]node
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
@@ -299,7 +308,7 @@ func readField(s *session, t *template, held, refScope *draws, a arm) string {
}
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
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
// siblings a {calc()} reads. nil when the format holds nothing (see expand).
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() {}
@@ -244,7 +247,7 @@ func compileTemplate(m map[string]any, pos position) (node, error) {
if err := checkTokens(o.format, fields); err != nil {
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 {
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)
}
n, err := compileAt(m[k], pos)
if err == nil && pos == inColumn {
_, err = columnDatatype(n)
}
if err != nil {
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))
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}
}
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{}}
r := &Record{columns: append([]Column(nil), columns...)}
for i := range r.columns {
n := drawn(s, t.fields[r.columns[i].Name])
if _, isNull := n.(*null); isNull {
r.columns[i].Null = true
} else {
r.columns[i].Value = render(s, n, scope)
}
r.columns[i].Value, r.columns[i].Null = renderColumn(s, t.fields[r.columns[i].Name], scope)
}
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
// projects. A {/path} binding is carried in fields under its root path, so only a
// 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 {
return fmt.Errorf("%s: %w", path, err)
}
t.inherits = columnRead(t)
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
// 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 {
+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))
for i, c := range columns {
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 {
return err
}
@@ -318,10 +315,15 @@ func (k columnKind) holds(v proven) bool {
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
// outside a pointer, or a value its kind's datatype or range refuses.
// 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.
func (p *valueProof) checkField(label string, ft reflect.Type, column node) error {
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
if ft.Kind() == reflect.Pointer {
elem = ft.Elem()
@@ -333,7 +335,7 @@ func (p *valueProof) checkField(label string, ft reflect.Type, column node) erro
return nil
}
for _, it := range items {
v := p.of(it)
v := p.columnItem(it)
if reason := v.not[kind.datatype]; 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
- `{a} & {b}`, each reading `person` → one person, or two when `a` and `b` name different groups
- 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
+29 -10
View File
@@ -23,24 +23,38 @@ type valueProof struct {
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 {
t, ok := n.(*template)
if !ok || t.datatype == DataTypeString {
return nil
}
if err := p.prove(t, t.datatype); err != nil {
return fmt.Errorf("%s: %w", path, err)
if d := readDatatype(t); d != DataTypeString {
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
}
// prove reports why some render of n is not text of datatype d.
func (p *valueProof) prove(n node, d DataType) error {
if reason := p.of(n).not[d]; reason != "" {
return fmt.Errorf("datatype %s: %s", d, reason)
// columnItem proves a column item: what it renders, or, reading a column whole, that column's
// items, whose nulls it draws as null rather than rendering them.
func (p *valueProof) columnItem(t *template) proven {
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 {
@@ -66,7 +80,13 @@ func (p *valueProof) of(n node) proven {
func (p *valueProof) unite(nodes []node) proven {
v := p.of(nodes[0])
for _, n := range nodes[1:] {
w := p.of(n)
v = v.or(p.of(n))
}
return v
}
// or is what a proof knows of a render that is either v or w.
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.integral = v.integral && w.integral
if v.notOperand == "" {
@@ -77,7 +97,6 @@ func (p *valueProof) unite(nodes []node) proven {
v.not[d] = w.not[d]
}
}
}
return v
}