diff --git a/README.md b/README.md index ed89bcb..f87be72 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/datatype.go b/datatype.go index 6ddc484..7056580 100644 --- a/datatype.go +++ b/datatype.go @@ -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) } diff --git a/graph.go b/graph.go index 07fd20f..86b51c7 100644 --- a/graph.go +++ b/graph.go @@ -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 diff --git a/hold.go b/hold.go index 05099df..9c013d9 100644 --- a/hold.go +++ b/hold.go @@ -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 diff --git a/node.go b/node.go index 56f84a5..c244d18 100644 --- a/node.go +++ b/node.go @@ -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) } diff --git a/record.go b/record.go index 84bf8dd..ca6070e 100644 --- a/record.go +++ b/record.go @@ -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. diff --git a/reference.go b/reference.go index ff26770..846154f 100644 --- a/reference.go +++ b/reference.go @@ -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 { diff --git a/struct.go b/struct.go index 727ae75..f5498a8 100644 --- a/struct.go +++ b/struct.go @@ -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) } diff --git a/todo.md b/todo.md index 1e4124b..810d1ec 100644 --- a/todo.md +++ b/todo.md @@ -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 diff --git a/value.go b/value.go index d8aa48c..1a5c9d4 100644 --- a/value.go +++ b/value.go @@ -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,16 +80,21 @@ 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.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 == "" { - v.notOperand = w.notOperand - } - for d := range v.not { - if v.not[d] == "" { - v.not[d] = w.not[d] - } + 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 == "" { + v.notOperand = w.notOperand + } + for d := range v.not { + if v.not[d] == "" { + v.not[d] = w.not[d] } } return v