From 802f62a4de532b569f243667a048828e2c9a1f7a Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 20:17:07 +0200 Subject: [PATCH 01/12] Tests: a column of one reference alone takes the datatype and null of the column it reads, in records and structs, and a datatype restating it is refused --- datatype_test.go | 21 ++++++++++++------ record_test.go | 56 ++++++++++++++++++++++++++++++++++++++++++++++++ struct_test.go | 43 +++++++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 7 deletions(-) diff --git a/datatype_test.go b/datatype_test.go index a537991..754d8ea 100644 --- a/datatype_test.go +++ b/datatype_test.go @@ -30,10 +30,7 @@ func TestDatatypeAndNullSitOnlyInAColumn(t *testing.T) { `{"format":"{p}","p":{"format":"{n}","n":{"format":"1","datatype":"integer"}}}`: "datatype only types a record column", `{"format":"{n}","repeat":2,"n":{"format":"1","datatype":"integer"}}`: "datatype only types a record column", `null`: `so write ""`, - `{"format":"{p}","p":{"format":"{x}","x":[null,"a"]}}`: `so write ""`, - `{"format":"","c":[{"format":"1","datatype":"integer"},"x"]}`: `write it as {"format":"x","datatype":"integer"}`, - `{"format":"","c":[{"format":"1","datatype":"integer"},{"format":"2","weight":3}]}`: `give it "datatype": "integer"`, - `{"format":"","c":[{"format":"1","datatype":"integer"},{"format":"true","datatype":"boolean"}]}`: "a column holds one datatype", + `{"format":"{p}","p":{"format":"{x}","x":[null,"a"]}}`: `so write ""`, } { if _, err := compile(parse(t, src)); err == nil || !strings.Contains(err.Error(), want) { t.Errorf("compile(%s) = %v, want an error containing %q", src, err, want) @@ -44,9 +41,18 @@ func TestDatatypeAndNullSitOnlyInAColumn(t *testing.T) { func TestDatatypeRejectsAValueItsTypeRejects(t *testing.T) { tree := map[string]string{ "cat": `[{"format":"{code}","code":"200"},{"format":"{code}","code":"2x"}]`, - "src": `{"format":"","score":[null,{"format":"{int(1,9)}","datatype":"integer"}]}`, + "src": `{"format":"","code":[null,"200","2x"],"score":[null,{"format":"{int(1,9)}","datatype":"integer"}]}`, } for _, c := range []struct{ name, column, want string }{ + {"an item beside a typed one", `[{"format":"1","datatype":"integer"},"x"]`, `write it as {"format":"x","datatype":"integer"}`}, + {"a weighted item beside a typed one", `[{"format":"1","datatype":"integer"},{"format":"2","weight":3}]`, `give it "datatype": "integer"`}, + {"items of two datatypes", `[{"format":"1","datatype":"integer"},{"format":"true","datatype":"boolean"}]`, "a column holds one datatype"}, + {"an item beside a typed column it reads", `["{/src.score}","x"]`, `write it as {"format":"x","datatype":"integer"}`}, + {"a typed item beside a string column it reads", `["{/src.code}",{"format":"1","datatype":"integer"}]`, `item "{/src.code}" declares no datatype beside one declaring integer`}, + {"a datatype over a typed column", `{"format":"{/src.score}","datatype":"integer"}`, `{/src.score} takes datatype integer from the column it reads; drop "datatype"`}, + {"another datatype over a typed column", `{"format":"{/src.score}","datatype":"number"}`, `{/src.score} takes datatype integer from the column it reads; drop "datatype"`}, + {"a value of the column it reads", `{"format":"{/src.code}","datatype":"integer"}`, `"2x" is not an integer`}, + {"a null read into text", `{"format":"{x}","x":"{/src.score}","datatype":"integer"}`, "reads a null"}, {"a sample with leading zeros", `{"format":"{digits(3)}","datatype":"integer"}`, "{digits(3)} prints text, not an integer"}, {"a fraction", `{"format":"{v}","v":["1","1.5"],"datatype":"integer"}`, `"1.5" is not an integer`}, {"past int64", `{"format":"9223372036854775808","datatype":"integer"}`, "past the int64 range"}, @@ -55,7 +61,6 @@ func TestDatatypeRejectsAValueItsTypeRejects(t *testing.T) { {"a sign before a sample", `{"format":"-{int(1,9)}","datatype":"integer"}`, "is not one value"}, {"a repeat", `{"format":"{int(1,9)}","repeat":2,"separator":",","datatype":"integer"}`, "carries a repeat"}, {"through a reference", `{"format":"{/cat.code}","datatype":"integer"}`, `"2x" is not an integer`}, - {"a null through a reference", `{"format":"{/src.score}","datatype":"integer"}`, "reads a null"}, {"a bare dot", `{"format":".5","datatype":"number"}`, `".5" is not a number`}, {"a plus sign", `{"format":"+1","datatype":"number"}`, `"+1" is not a number`}, {"a text sample", `{"format":"{hex(4)}","datatype":"number"}`, "{hex(4)} prints text, not a number"}, @@ -120,9 +125,11 @@ func TestDatatypeAcceptsAColumnThatAlwaysParses(t *testing.T) { `{"format":"{calc(a / (b + 1), 2)}","a":"{int(1,9)}","b":"{digits(2)}","datatype":"number"}`, `{"format":"{calc(a / b, 0)}","a":"{int(1,9)}","b":"{int(1,9)}","datatype":"integer"}`, `{"format":"{calc(sub * 1.25, 2)}","sub":{"format":"{calc(a * b)}","a":"{int(1,9)}","b":"{float(0,5,2)}"},"datatype":"number"}`, + `{"format":"{/src.code}","datatype":"integer"}`, } { row := `{"format":"","col":` + column + `}` - f, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": cat, "row": row})), WithSeed(1)) + src := `{"format":"","code":["200","404"]}` + f, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": cat, "row": row, "src": src})), WithSeed(1)) if err != nil { t.Errorf("%s: New = %v, want it loaded", column, err) continue diff --git a/record_test.go b/record_test.go index 0301804..8d76f14 100644 --- a/record_test.go +++ b/record_test.go @@ -382,6 +382,62 @@ func TestRecordBareReferenceStaysIndependent(t *testing.T) { } } +func TestRecordColumnOfOneReferenceIsTheColumnItReads(t *testing.T) { + dir := writeData(t, map[string]string{ + "mid": `{"format":"","score":"{/src.score}"}`, + "row": `{"format":"","chain":"{/mid.score}","code":{"format":"{/src.code}","datatype":"integer"},"label":"n={/src.score}","same":"{/src.score}","score":"{/src.score}","text":"{/src.code}"}`, + "src": `{"format":"","code":[null,"200","404"],"score":[null,{"format":"{int(1,9)}","datatype":"integer"}]}`, + }) + f := newGenerator(t, dir, WithSeed(1)) + inline, err := f.NewRecordTemplate(`{"format":"","score":"{/src.score}"}`) + if err != nil { + t.Fatal(err) + } + nulls := map[string]int{} + for i := 0; i < 200; i++ { + r, err := f.FakeRecord("row") + if err != nil { + t.Fatal(err) + } + cols := map[string]Column{} + for _, c := range r.Columns() { + cols[c.Name] = c + } + score, code := cols["score"], cols["code"] + agree := func(name string, want Column) bool { + return cols[name].Null == want.Null && cols[name].Value == want.Value + } + switch { + case score.DataType != DataTypeInteger || cols["chain"].DataType != DataTypeInteger || code.DataType != DataTypeInteger || cols["label"].DataType != DataTypeString || cols["text"].DataType != DataTypeString: + t.Fatalf("%s: want score, chain and code integer columns, label and text string ones", r.JSON()) + case score.Null == (len(score.Value) == 1 && score.Value >= "1" && score.Value <= "9"): + t.Fatalf("score = %+v, want null or a digit from src.score", score) + case code.Null == (code.Value == "200" || code.Value == "404"): + t.Fatalf("code = %+v, want null or a code from src.code", code) + case !agree("same", score) || !agree("chain", score) || !agree("text", code): + t.Fatalf("%s: want every read of one src column one draw, through mid too", r.JSON()) + case cols["label"].Null || cols["label"].Value != "n="+score.Value: + t.Fatalf("label = %+v beside score %+v, want the text of the read, a null as \"\"", cols["label"], score) + case strings.Contains(r.JSON(), `"score":null`) != score.Null: + t.Fatalf("JSON() = %s, want a null score written null", r.JSON()) + } + ic := inline.Fake().Columns()[0] + if ic.DataType != DataTypeInteger || ic.Null == (len(ic.Value) == 1) { + t.Fatalf("inline score = %+v, want an integer column, null or a digit", ic) + } + for name, c := range map[string]Column{"code": code, "inline": ic, "score": score} { + if c.Null { + nulls[name]++ + } + } + } + for _, name := range []string{"code", "inline", "score"} { + if n := nulls[name]; n == 0 || n == 200 { + t.Errorf("%s drew null %d times in 200 records, want both outcomes", name, n) + } + } +} + func TestRecordSQLQuotesIdentifiers(t *testing.T) { dir := writeData(t, map[string]string{ "row": `{"format": "", "postal-code": "1", "street-number": "2"}`, diff --git a/struct_test.go b/struct_test.go index 7332a32..54d8333 100644 --- a/struct_test.go +++ b/struct_test.go @@ -2,6 +2,7 @@ package fejkdata import ( "reflect" + "strconv" "strings" "testing" ) @@ -53,10 +54,43 @@ func structData(t *testing.T) *Generator { return newGenerator(t, writeData(t, map[string]string{ "person": `[{"format":"{first} {last}","first":"Ada","last":"Lovelace"},{"format":"{first} {last}","first":"Bo","last":"Ek"}]`, "place": `[{"format":"{city}","city":"Stockholm","zip":"111 22"},{"format":"{city}","city":"Tranås","zip":"573 31"}]`, + "src": `{"format":"","code":[null,"200","404"],"score":[null,{"format":"{int(1,9)}","datatype":"integer"}]}`, "trip": `{"format":"","leg":[{"format":"{to}","to":"Oslo"},{"format":"{to}","to":"Rome"}]}`, }), WithSeed(1)) } +func TestFakeStructReadsAColumnWhole(t *testing.T) { + f := structData(t) + nils := map[string]int{} + for i := 0; i < 200; i++ { + var v struct { + Code *string `fake:"src.code"` + Label string `fake:"n={/src.score}"` + Score *int64 `fake:"src.score"` + } + if err := f.FakeStruct(&v); err != nil { + t.Fatal(err) + } + switch { + case v.Code == nil: + nils["code"]++ + case *v.Code != "200" && *v.Code != "404": + t.Fatalf("Code = %q, want nil or a code, never a null's \"\"", *v.Code) + } + switch { + case v.Score == nil && v.Label == "n=": + nils["score"]++ + case v.Score == nil || *v.Score < 1 || *v.Score > 9 || v.Label != "n="+strconv.FormatInt(*v.Score, 10): + t.Fatalf("%+v, want Score nil beside Label n=, or one digit in both", v) + } + } + for _, name := range []string{"code", "score"} { + if n := nils[name]; n == 0 || n == 200 { + t.Errorf("%s was nil %d times in 200 fills, want both outcomes", name, n) + } + } +} + func TestFakeStructFillsTaggedFields(t *testing.T) { a, b := structData(t), structData(t) people := map[string]string{"Ada": "Lovelace", "Bo": "Ek"} @@ -166,6 +200,15 @@ func TestFakeStructErrors(t *testing.T) { {&struct { A int `fake:"[null,\"{int(1,9)}\"]"` }{}, "can draw null, which int cannot hold; make it *int"}, + {&struct { + A int64 `fake:"src.score"` + }{}, "can draw null, which int64 cannot hold; make it *int64"}, + {&struct { + A string `fake:"src.code"` + }{}, "can draw null, which string cannot hold; make it *string"}, + {&struct { + A *bool `fake:"src.score"` + }{}, ".A (*bool): {int(1,9)} prints an integer, not a boolean"}, {&struct { A int `fake:"{digits(3)}"` }{}, ".A (int): {digits(3)} prints text, not an integer"}, -- 2.52.0 From 2b2010bdd65b3d0070531377a8366d76c0e0a93d Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 20:20:25 +0200 Subject: [PATCH 02/12] Take the datatype and null of the column a lone reference reads, and check a column's datatypes once its references are linked --- README.md | 11 +++++++++ datatype.go | 65 +++++++++++++++++++++++++++++++++++++++++----------- graph.go | 3 +++ hold.go | 15 +++++++++--- node.go | 8 +++---- record.go | 25 ++++++++++++++------ reference.go | 15 ++++++++++++ struct.go | 14 ++++++----- todo.md | 5 ---- value.go | 55 +++++++++++++++++++++++++++++--------------- 10 files changed, 159 insertions(+), 57 deletions(-) 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 -- 2.52.0 From 04a75f7ded04f125087666a11bcfca9091b46a07 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 20:38:35 +0200 Subject: [PATCH 03/12] Tests: a JSON-string item reading a string column keeps the advice naming its object form --- datatype_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/datatype_test.go b/datatype_test.go index 754d8ea..8cbf156 100644 --- a/datatype_test.go +++ b/datatype_test.go @@ -48,7 +48,7 @@ func TestDatatypeRejectsAValueItsTypeRejects(t *testing.T) { {"a weighted item beside a typed one", `[{"format":"1","datatype":"integer"},{"format":"2","weight":3}]`, `give it "datatype": "integer"`}, {"items of two datatypes", `[{"format":"1","datatype":"integer"},{"format":"true","datatype":"boolean"}]`, "a column holds one datatype"}, {"an item beside a typed column it reads", `["{/src.score}","x"]`, `write it as {"format":"x","datatype":"integer"}`}, - {"a typed item beside a string column it reads", `["{/src.code}",{"format":"1","datatype":"integer"}]`, `item "{/src.code}" declares no datatype beside one declaring integer`}, + {"a typed item beside a string column it reads", `["{/src.code}",{"format":"1","datatype":"integer"}]`, `write it as {"format":"{/src.code}","datatype":"integer"}`}, {"a datatype over a typed column", `{"format":"{/src.score}","datatype":"integer"}`, `{/src.score} takes datatype integer from the column it reads; drop "datatype"`}, {"another datatype over a typed column", `{"format":"{/src.score}","datatype":"number"}`, `{/src.score} takes datatype integer from the column it reads; drop "datatype"`}, {"a value of the column it reads", `{"format":"{/src.code}","datatype":"integer"}`, `"2x" is not an integer`}, -- 2.52.0 From b4ebab5abf1f48bc59197591f22f85856f29db95 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 20:42:13 +0200 Subject: [PATCH 04/12] Keep a read's null with its draw, share the lone-reference check, name a JSON-string item's object form again, prove each column once per pass, and read a record off template.record --- README.md | 8 +++---- datatype.go | 25 ++++++++++---------- hold.go | 65 +++++++++++++++++++++++++++++++++------------------- inline.go | 8 ++----- node.go | 12 +++++----- record.go | 31 +++++-------------------- reference.go | 36 ++++++++++++++++++++++------- render.go | 6 ++--- struct.go | 4 ++-- todo.md | 4 +++- value.go | 29 +++++++++++++++++------ 11 files changed, 129 insertions(+), 99 deletions(-) diff --git a/README.md b/README.md index f87be72..9811035 100644 --- a/README.md +++ b/README.md @@ -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 ``` -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 +A column of one reference alone to another record's column — `"score": "{/src.score}"` +— is that column: it takes the column's datatype and is null where the column is, and +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 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 -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. ### Options and fields diff --git a/datatype.go b/datatype.go index 7056580..3b9d3e2 100644 --- a/datatype.go +++ b/datatype.go @@ -94,7 +94,7 @@ func columnDatatype(n node) (DataType, error) { 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 { if t.datatype != DataTypeString { return t.datatype @@ -102,17 +102,20 @@ func itemDatatype(t *template) DataType { 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 { - if t.inherits == nil { + if t.readsColumn == nil { return DataTypeString } - d, _ := columnDatatype(t.inherits) // checkColumns refuses that column where it sits - return d + items, _ := columnItems(t.readsColumn.column) + 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 -// or reads whole a column that can be. +// columnItems is a column's template items, its choices unwrapped, and whether one is null. func columnItems(n node) (items []*template, nullable bool) { var collect func(node) collect = func(n node) { @@ -123,10 +126,6 @@ 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 } @@ -143,8 +142,8 @@ func disagreement(a *template, da DataType, b *template, db DataType) error { } switch { 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("its items hold %s and %s; a column holds one datatype", da, db) + 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 beside one declaring %s; a column holds one, so give it "datatype": %q`, bare.format, want, want) diff --git a/hold.go b/hold.go index 9c013d9..16b00a8 100644 --- a/hold.go +++ b/hold.go @@ -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 -// 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. +// was drawn as, so every path under it reads one row, and the draw each read made, by +// its one spelling, so the same read written twice reads one draw. type draws struct { variant map[string]node - value map[string]string - nulls map[node]bool + value map[string]draw } -func (d *draws) drewNull(column node) { - if d.nulls == nil { - d.nulls = map[node]bool{} - } - d.nulls[column] = true +// draw is what one read drew: its text, and whether it landed on a null. +type draw struct { + text string + null bool } // 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 // drawn afresh, so {word} {word} still draws twice. checkTokens, checkPath and // 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 len(a.tail) > 0 { 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 - // this expansion; a sibling, and a reference read whole, stay local to it. - d := held - if isRef(a.key) && refScope != nil && len(a.tail) > 0 { - d = refScope + d := readScope(held, refScope, a) + if r, done := d.value[a.path]; done { + return r } - if v, read := d.value[a.path]; read { - return v - } - var v string + var r draw _ = walkPath(t.fields[a.key], a.tail, pathWalk{ // Hold the draw at every level passed through, so two paths sharing a // prefix share it. @@ -308,10 +300,35 @@ func readField(s *session, t *template, held, refScope *draws, a arm) string { } 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 - return v + d.value[a.path] = r + 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 diff --git a/inline.go b/inline.go index ca693dc..763f985 100644 --- a/inline.go +++ b/inline.go @@ -101,12 +101,8 @@ func loneReference(arg string) (string, bool) { raw = m["format"] } format, isString := raw.(string) - var units []ftoken - if !isString || eachToken(format, func(t ftoken) error { units = append(units, t); return nil }) != nil || len(units) != 1 { - return "", false - } - body := units[0].body - if units[0].kind != 'b' || !isRef(body) || strings.ContainsAny(body, "|(") { + body, lone := loneRef(format) + if !isString || !lone { return "", false } if strings.HasPrefix(body, "/") { diff --git a/node.go b/node.go index c244d18..604ea0c 100644 --- a/node.go +++ b/node.go @@ -58,10 +58,10 @@ type template struct { bound map[string]string // 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 + held map[string]bool + fromString bool // written as a JSON string rather than an object + 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 } func (*template) isNode() {} @@ -127,7 +127,7 @@ func compileString(s string) (node, error) { if err := checkTokens(s, nil); err != nil { return nil, err } - t := &template{format: s, repeat: 1} + t := &template{format: s, repeat: 1, fromString: true} if err := t.compileFormat(); err != nil { return nil, err } @@ -234,7 +234,7 @@ func compileTemplate(m map[string]any, pos position) (node, error) { return nil, err } fieldPos := inFormat - if pos == atTop && projectsColumns(o.repeat) { + if pos == atTop && o.repeat == 1 { fieldPos = inColumn } fields, err := compileFields(m, fieldPos) diff --git a/record.go b/record.go index ca6070e..d1f8fab 100644 --- a/record.go +++ b/record.go @@ -205,13 +205,13 @@ func recordOf(n node) (*template, []Column, error) { if !ok { 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) if len(names) == 0 { 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 { return nil, nil, err } @@ -223,10 +223,6 @@ func recordOf(n node) (*template, []Column, error) { 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 // for: one column rendering a level another reads a path into, and a column // 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 // reference scope shared across them. 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...)} 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 } -// 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 846154f..08f209e 100644 --- a/reference.go +++ b/reference.go @@ -107,22 +107,42 @@ 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) + t.readsColumn = columnReadOf(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 { +// columnRead is a record's column read by a format of that one reference alone, which is the +// column: it takes the column's datatype and null. +type columnRead struct { + a arm + column node +} + +func columnReadOf(t *template) *columnRead { + name, lone := loneRef(t.format) + if !lone || t.repeat != 1 { return nil } - a := t.ops[0].arms[0] + a := splitArm(name, t.refs) 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 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 diff --git a/render.go b/render.go index 330ed0f..ec12628 100644 --- a/render.go +++ b/render.go @@ -103,7 +103,7 @@ func expand(s *session, t *template, refScope *draws) string { if len(t.held) > 0 { held = &draws{ 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 { @@ -112,7 +112,7 @@ func expand(s *session, t *template, refScope *draws) string { case 'l': b.WriteString(o.lit) 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': // Read before the call, so the value a calc computes is the value the // 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 { operands = make([]string, len(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 diff --git a/struct.go b/struct.go index f5498a8..3b60e03 100644 --- a/struct.go +++ b/struct.go @@ -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 // 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) + items, _ := 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) @@ -327,7 +327,7 @@ func (p *valueProof) checkField(label string, ft reflect.Type, column node) erro elem := ft if ft.Kind() == reflect.Pointer { 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) } kind := columnKinds[elem.Kind()] diff --git a/todo.md b/todo.md index 810d1ec..1b0f4a6 100644 --- a/todo.md +++ b/todo.md @@ -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` → `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 - 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 variants differ on purpose. Share the lists only if that is a clean win. diff --git a/value.go b/value.go index 1a5c9d4..faa715f 100644 --- a/value.go +++ b/value.go @@ -16,15 +16,17 @@ type proven struct { integral bool notOperand string // why some render reads as no finite number, the way calc reads it 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. 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, -// 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 { t, ok := n.(*template) if !ok || t.datatype == DataTypeString { @@ -39,14 +41,25 @@ func (p *valueProof) checkDatatype(path string, n node) error { return nil } -// 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. +// columnItem proves a column item: what it renders, or, when it is a column it reads, that column. func (p *valueProof) columnItem(t *template) proven { - if t.inherits == nil { + if t.readsColumn == nil { 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 - items, _ := columnItems(t.inherits) for i, it := range items { if w := p.columnItem(it); i == 0 { v = w @@ -54,6 +67,8 @@ func (p *valueProof) columnItem(t *template) proven { v = v.or(w) } } + v.null = v.null || nullable + p.columns[n] = 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. 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 + v.integral, v.null = v.integral && w.integral, v.null || w.null if v.notOperand == "" { v.notOperand = w.notOperand } -- 2.52.0 From 2a750008bfc9cd7a3a39f2b8b1b1778bfb5c5acf Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 20:48:12 +0200 Subject: [PATCH 05/12] Tests: name the struct test for a field tagged with a column as that column --- struct_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/struct_test.go b/struct_test.go index 54d8333..673500f 100644 --- a/struct_test.go +++ b/struct_test.go @@ -59,7 +59,7 @@ func structData(t *testing.T) *Generator { }), WithSeed(1)) } -func TestFakeStructReadsAColumnWhole(t *testing.T) { +func TestFakeStructFieldTaggedWithAColumnIsThatColumn(t *testing.T) { f := structData(t) nils := map[string]int{} for i := 0; i < 200; i++ { -- 2.52.0 From bc4280561ef74b97a13fbbce7496f39120f41545 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 21:10:36 +0200 Subject: [PATCH 06/12] Tests: a struct column need not agree on a datatype, text beside a typed column read names its text spelling, a column read is reported before its reader, and column reads through a dot, a choice and an always-null column --- datatype_test.go | 8 ++++++++ record_test.go | 20 +++++++++++++------- struct_test.go | 30 +++++++++++++++++++++++++++++- 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/datatype_test.go b/datatype_test.go index 8cbf156..5116721 100644 --- a/datatype_test.go +++ b/datatype_test.go @@ -49,6 +49,7 @@ func TestDatatypeRejectsAValueItsTypeRejects(t *testing.T) { {"items of two datatypes", `[{"format":"1","datatype":"integer"},{"format":"true","datatype":"boolean"}]`, "a column holds one datatype"}, {"an item beside a typed column it reads", `["{/src.score}","x"]`, `write it as {"format":"x","datatype":"integer"}`}, {"a typed item beside a string column it reads", `["{/src.code}",{"format":"1","datatype":"integer"}]`, `write it as {"format":"{/src.code}","datatype":"integer"}`}, + {"text beside a typed column it reads", `["{/src.score}","n/a"]`, `to read that column as text, write {"format":"{text}","text":"{/src.score}"}`}, {"a datatype over a typed column", `{"format":"{/src.score}","datatype":"integer"}`, `{/src.score} takes datatype integer from the column it reads; drop "datatype"`}, {"another datatype over a typed column", `{"format":"{/src.score}","datatype":"number"}`, `{/src.score} takes datatype integer from the column it reads; drop "datatype"`}, {"a value of the column it reads", `{"format":"{/src.code}","datatype":"integer"}`, `"2x" is not an integer`}, @@ -98,6 +99,13 @@ func TestDatatypeRejectsAValueItsTypeRejects(t *testing.T) { t.Errorf("%s: NewTemplate = %v, want the inline template refused the same way", c.name, err) } } + _, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{ + "a": `{"format":"","c":["{/b.x}",{"format":"1","datatype":"integer"}]}`, + "b": `{"format":"","x":["2",{"format":"1","datatype":"integer"}]}`, + }))) + if want := `b: field "x": item "2"`; err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("a column reading one whose items disagree: New = %v, want the column read reported first, containing %q", err, want) + } } var jsonInteger = regexp.MustCompile(`^(0|-?[1-9][0-9]*)$`) diff --git a/record_test.go b/record_test.go index 8d76f14..9094172 100644 --- a/record_test.go +++ b/record_test.go @@ -385,7 +385,7 @@ func TestRecordBareReferenceStaysIndependent(t *testing.T) { func TestRecordColumnOfOneReferenceIsTheColumnItReads(t *testing.T) { dir := writeData(t, map[string]string{ "mid": `{"format":"","score":"{/src.score}"}`, - "row": `{"format":"","chain":"{/mid.score}","code":{"format":"{/src.code}","datatype":"integer"},"label":"n={/src.score}","same":"{/src.score}","score":"{/src.score}","text":"{/src.code}"}`, + "row": `{"format":"","chain":"{/mid.score}","code":{"format":"{/src.code}","datatype":"integer"},"dot":"{.src.score}","label":"n={/src.score}","mixed":[{"format":"{text}","text":"{/src.score}"},"n/a"],"pick":["{/src.score}",{"format":"7","datatype":"integer"}],"same":"{/src.score}","score":"{/src.score}","text":"{/src.code}"}`, "src": `{"format":"","code":[null,"200","404"],"score":[null,{"format":"{int(1,9)}","datatype":"integer"}]}`, }) f := newGenerator(t, dir, WithSeed(1)) @@ -408,22 +408,28 @@ func TestRecordColumnOfOneReferenceIsTheColumnItReads(t *testing.T) { return cols[name].Null == want.Null && cols[name].Value == want.Value } switch { - case score.DataType != DataTypeInteger || cols["chain"].DataType != DataTypeInteger || code.DataType != DataTypeInteger || cols["label"].DataType != DataTypeString || cols["text"].DataType != DataTypeString: - t.Fatalf("%s: want score, chain and code integer columns, label and text string ones", r.JSON()) + case score.DataType != DataTypeInteger || cols["chain"].DataType != DataTypeInteger || code.DataType != DataTypeInteger || cols["pick"].DataType != DataTypeInteger || cols["label"].DataType != DataTypeString || cols["mixed"].DataType != DataTypeString || cols["text"].DataType != DataTypeString: + t.Fatalf("%s: want score, chain, code and pick integer columns, label, mixed and text string ones", r.JSON()) case score.Null == (len(score.Value) == 1 && score.Value >= "1" && score.Value <= "9"): t.Fatalf("score = %+v, want null or a digit from src.score", score) case code.Null == (code.Value == "200" || code.Value == "404"): t.Fatalf("code = %+v, want null or a code from src.code", code) - case !agree("same", score) || !agree("chain", score) || !agree("text", code): + case !agree("same", score) || !agree("chain", score) || !agree("dot", score) || !agree("text", code): t.Fatalf("%s: want every read of one src column one draw, through mid too", r.JSON()) + case cols["pick"].Value != "7" && !agree("pick", score), cols["mixed"].Null || cols["mixed"].Value != "n/a" && cols["mixed"].Value != score.Value: + t.Fatalf("pick = %+v, mixed = %+v beside score %+v: want pick 7 or the score's draw, mixed n/a or the score's text", cols["pick"], cols["mixed"], score) case cols["label"].Null || cols["label"].Value != "n="+score.Value: t.Fatalf("label = %+v beside score %+v, want the text of the read, a null as \"\"", cols["label"], score) case strings.Contains(r.JSON(), `"score":null`) != score.Null: t.Fatalf("JSON() = %s, want a null score written null", r.JSON()) } - ic := inline.Fake().Columns()[0] - if ic.DataType != DataTypeInteger || ic.Null == (len(ic.Value) == 1) { - t.Fatalf("inline score = %+v, want an integer column, null or a digit", ic) + ir := inline.Fake() + ic, sqlValue := ir.Columns()[0], ir.Columns()[0].Value + if ic.Null { + sqlValue = "NULL" + } + if ic.DataType != DataTypeInteger || ic.Null == (len(ic.Value) == 1) || ir.SQLInsert("t") != `INSERT INTO "t" ("score") VALUES (`+sqlValue+`);` || ir.CSVLine() != ic.Value { + t.Fatalf("inline score = %+v written %s and %q, want an integer column, NULL and an empty field or a bare digit", ic, ir.SQLInsert("t"), ir.CSVLine()) } for name, c := range map[string]Column{"code": code, "inline": ic, "score": score} { if c.Null { diff --git a/struct_test.go b/struct_test.go index 673500f..61f2fcc 100644 --- a/struct_test.go +++ b/struct_test.go @@ -54,7 +54,8 @@ func structData(t *testing.T) *Generator { return newGenerator(t, writeData(t, map[string]string{ "person": `[{"format":"{first} {last}","first":"Ada","last":"Lovelace"},{"format":"{first} {last}","first":"Bo","last":"Ek"}]`, "place": `[{"format":"{city}","city":"Stockholm","zip":"111 22"},{"format":"{city}","city":"Tranås","zip":"573 31"}]`, - "src": `{"format":"","code":[null,"200","404"],"score":[null,{"format":"{int(1,9)}","datatype":"integer"}]}`, + "mid": `{"format":"","score":["{/src.score}",{"format":"5","datatype":"integer"}]}`, + "src": `{"format":"","code":[null,"200","404"],"del":null,"score":[null,{"format":"{int(1,9)}","datatype":"integer"}]}`, "trip": `{"format":"","leg":[{"format":"{to}","to":"Oslo"},{"format":"{to}","to":"Rome"}]}`, }), WithSeed(1)) } @@ -62,15 +63,39 @@ func structData(t *testing.T) *Generator { func TestFakeStructFieldTaggedWithAColumnIsThatColumn(t *testing.T) { f := structData(t) nils := map[string]int{} + show := func(p any) string { + switch p := p.(type) { + case *string: + if p != nil { + return *p + } + case *int64: + if p != nil { + return strconv.FormatInt(*p, 10) + } + } + return "nil" + } for i := 0; i < 200; i++ { var v struct { Code *string `fake:"src.code"` + Codes *string `fake:"[\"{/src.score}\",\"{/src.code}\"]"` + Del *int64 `fake:"src.del"` Label string `fake:"n={/src.score}"` + Mixed *string `fake:"[\"{/src.score}\",\"x\"]"` + Pair *int64 `fake:"[\"{/src.score}\",\"5\"]"` Score *int64 `fake:"src.score"` } if err := f.FakeStruct(&v); err != nil { t.Fatal(err) } + score, code := show(v.Score), show(v.Code) + switch { + case show(v.Del) != "nil": + t.Fatalf("Del = %s, want nil from a column only ever null", show(v.Del)) + case show(v.Mixed) != "x" && show(v.Mixed) != score, show(v.Pair) != "5" && show(v.Pair) != score, show(v.Codes) != score && show(v.Codes) != code: + t.Fatalf("Mixed %s, Pair %s, Codes %s beside score %s and code %s: want each the literal or the draw of the column it reads", show(v.Mixed), show(v.Pair), show(v.Codes), score, code) + } switch { case v.Code == nil: nils["code"]++ @@ -209,6 +234,9 @@ func TestFakeStructErrors(t *testing.T) { {&struct { A *bool `fake:"src.score"` }{}, ".A (*bool): {int(1,9)} prints an integer, not a boolean"}, + {&struct { + A int64 `fake:"mid.score"` + }{}, "can draw null, which int64 cannot hold; make it *int64"}, {&struct { A int `fake:"{digits(3)}"` }{}, ".A (int): {digits(3)} prints text, not an integer"}, -- 2.52.0 From f781fa47d19e3a2db73d13dae15118fc2381f3d8 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 21:12:21 +0200 Subject: [PATCH 07/12] Tests: an item beside a typed column read that can hold its datatype keeps the object-form advice --- datatype_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/datatype_test.go b/datatype_test.go index 5116721..88a00d1 100644 --- a/datatype_test.go +++ b/datatype_test.go @@ -47,7 +47,7 @@ func TestDatatypeRejectsAValueItsTypeRejects(t *testing.T) { {"an item beside a typed one", `[{"format":"1","datatype":"integer"},"x"]`, `write it as {"format":"x","datatype":"integer"}`}, {"a weighted item beside a typed one", `[{"format":"1","datatype":"integer"},{"format":"2","weight":3}]`, `give it "datatype": "integer"`}, {"items of two datatypes", `[{"format":"1","datatype":"integer"},{"format":"true","datatype":"boolean"}]`, "a column holds one datatype"}, - {"an item beside a typed column it reads", `["{/src.score}","x"]`, `write it as {"format":"x","datatype":"integer"}`}, + {"an item beside a typed column it reads", `["{/src.score}","5"]`, `write it as {"format":"5","datatype":"integer"}`}, {"a typed item beside a string column it reads", `["{/src.code}",{"format":"1","datatype":"integer"}]`, `write it as {"format":"{/src.code}","datatype":"integer"}`}, {"text beside a typed column it reads", `["{/src.score}","n/a"]`, `to read that column as text, write {"format":"{text}","text":"{/src.score}"}`}, {"a datatype over a typed column", `{"format":"{/src.score}","datatype":"integer"}`, `{/src.score} takes datatype integer from the column it reads; drop "datatype"`}, -- 2.52.0 From acad25708a1f818df7bfd39b36750917d6490819 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 21:13:34 +0200 Subject: [PATCH 08/12] Leave column agreement to the records that read DataType, name the text spelling beside a typed column read, and check a column after the columns it reads --- README.md | 3 ++- datatype.go | 47 ++++++++++++++++++++++++++++++++++++----------- graph.go | 15 ++++++++++----- inline.go | 10 +++++----- record.go | 2 +- struct.go | 2 +- 6 files changed, 55 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 9811035..df5dd38 100644 --- a/README.md +++ b/README.md @@ -627,7 +627,8 @@ tokens add cost in proportion to the output. its columns from a struct instead, because a Go caller has already written that schema: the fields name the columns and their types are the datatypes, so a tag says only what to draw, and a `datatype` in it would be a second spelling of the - type. + type. The Go type is a struct column's one datatype, so its items need not agree on + one among themselves; each must only hold that type. - **A struct's records follow Go's field access, and compile on first use.** The fields an embedded struct promotes are the struct's own — `e.First`, as `encoding/json` and SQL mappers read them — so they are columns of its record and diff --git a/datatype.go b/datatype.go index 3b9d3e2..ec05352 100644 --- a/datatype.go +++ b/datatype.go @@ -64,18 +64,41 @@ func datatypeOf(m map[string]any, pos position) (DataType, error) { return 0, fmt.Errorf(`datatype takes "integer", "number" or "boolean", got %q`, name) } -// 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 { +// checkColumns rejects a record column whose items hold different datatypes, checking a column +// after the columns its items read, so a column read is named before its readers. +func checkColumns(s nodeScope) error { + checked := map[node]bool{} + var check func(path, name string, column node) error + check = func(path, name string, column node) error { + if checked[column] { + return nil + } + checked[column] = true + items, _ := columnItems(column) + for _, it := range items { + if r := it.readsColumn; r != nil { + if err := check(r.a.key[1:], r.a.tail[0], r.column); err != nil { + return err + } + } + } + if _, err := columnDatatype(column); err != nil { return fmt.Errorf("%s: field %q: %w", path, name, err) } + return nil } - return nil + return s(func(path string, n node) error { + t, ok := n.(*template) + if !ok || !t.record { + return nil + } + for _, name := range recordColumns(t) { + if err := check(path, name, t.fields[name]); err != nil { + return err + } + } + return nil + }) } // columnDatatype is the datatype a column's items hold. They must agree, since a @@ -136,13 +159,15 @@ func columnItems(n node) (items []*template, nullable bool) { // 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 + bare, typed, want := b, a, da if da == DataTypeString { - bare, want = a, db + bare, typed, want = a, b, db } switch { case da != DataTypeString && db != DataTypeString: return fmt.Errorf("its items hold %s and %s; a column holds one datatype", da, db) + case typed.datatype == DataTypeString && (&valueProof{}).columnItem(bare).not[want] != "": + return fmt.Errorf(`item %q is not %s, the datatype item %q takes from the column it reads; to read that column as text, write {"format":"{text}","text":%q}`, bare.format, dataTypeNouns[want], typed.format, typed.format) 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) } diff --git a/graph.go b/graph.go index 86b51c7..be5a593 100644 --- a/graph.go +++ b/graph.go @@ -177,14 +177,19 @@ func inlineScope(n node, label string) nodeScope { return func(fn func(path string, m node) error) error { return eachNode(n, label, fn) } } -// checkScope runs the per-node fences over a scope, each over the whole scope +// checkScope runs every fence over a scope: checkColumns, then checkRenders. +func checkScope(s nodeScope) error { + if err := checkColumns(s); err != nil { + return err + } + return checkRenders(s) +} + +// checkRenders runs the per-node fences over a scope, each over the whole scope // before the next, so which of several broken nodes is reported does not depend on // 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 - } +func checkRenders(s nodeScope) error { mem := reachMemo{} if err := s(func(path string, n node) error { return repeatCheck(path, n, mem) }); err != nil { return err diff --git a/inline.go b/inline.go index 763f985..23b592b 100644 --- a/inline.go +++ b/inline.go @@ -32,7 +32,7 @@ func (f *Generator) NewTemplate(input string) (*Template, error) { if err != nil { return nil, fmt.Errorf("fejkdata: %w", err) } - if err := bindInline(n, "template", f.categories); err != nil { + if err := bindInline(n, "template", f.categories, checkScope); err != nil { return nil, fmt.Errorf("fejkdata: %w", err) } return &Template{g: f, n: n}, nil @@ -133,14 +133,14 @@ func inputValue(input string) (any, error) { return raw, nil } -// bindInline links an inline node's references against root and runs the fences over it, -// naming its nodes from label. -func bindInline(n node, label string, root map[string]node) error { +// bindInline links an inline node's references against root and runs check over it, naming its +// nodes from label. +func bindInline(n node, label string, root map[string]node, check func(nodeScope) error) error { scope := inlineScope(n, label) if err := linkNodeRefs(scope, root); err != nil { return err } - return checkScope(scope) + return check(scope) } // linkNodeRefs binds the references in an inline node's templates against the diff --git a/record.go b/record.go index d1f8fab..b630526 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]) // checkColumns refused a column whose items disagree + datatype, _ := columnDatatype(t.fields[name]) // checkColumns refused items that disagree wherever DataType is read columns[i] = Column{Name: name, DataType: datatype} } return t, columns, nil diff --git a/struct.go b/struct.go index 3b60e03..11d6dad 100644 --- a/struct.go +++ b/struct.go @@ -258,7 +258,7 @@ func (s *structShape) compileRecord(root map[string]node, t reflect.Type, label if err != nil { return fmt.Errorf("%s: %w", label, err) } - if err := bindInline(n, label, root); err != nil { + if err := bindInline(n, label, root, checkRenders); err != nil { return err } record, columns, err := recordOf(n) -- 2.52.0 From c991f35f4f9cbad3f0957238b92c873c8c0a6daa Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 21:27:19 +0200 Subject: [PATCH 09/12] Tests: another datatype over a column read types it where the values prove it, and datatypes taken from column reads name the text spelling, keeping an item's weight --- datatype_test.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/datatype_test.go b/datatype_test.go index 88a00d1..352a4a0 100644 --- a/datatype_test.go +++ b/datatype_test.go @@ -41,7 +41,7 @@ func TestDatatypeAndNullSitOnlyInAColumn(t *testing.T) { func TestDatatypeRejectsAValueItsTypeRejects(t *testing.T) { tree := map[string]string{ "cat": `[{"format":"{code}","code":"200"},{"format":"{code}","code":"2x"}]`, - "src": `{"format":"","code":[null,"200","2x"],"score":[null,{"format":"{int(1,9)}","datatype":"integer"}]}`, + "src": `{"format":"","code":[null,"200","2x"],"flag":{"format":"{b}","b":["true","false"],"datatype":"boolean"},"score":[null,{"format":"{int(1,9)}","datatype":"integer"}]}`, } for _, c := range []struct{ name, column, want string }{ {"an item beside a typed one", `[{"format":"1","datatype":"integer"},"x"]`, `write it as {"format":"x","datatype":"integer"}`}, @@ -51,7 +51,9 @@ func TestDatatypeRejectsAValueItsTypeRejects(t *testing.T) { {"a typed item beside a string column it reads", `["{/src.code}",{"format":"1","datatype":"integer"}]`, `write it as {"format":"{/src.code}","datatype":"integer"}`}, {"text beside a typed column it reads", `["{/src.score}","n/a"]`, `to read that column as text, write {"format":"{text}","text":"{/src.score}"}`}, {"a datatype over a typed column", `{"format":"{/src.score}","datatype":"integer"}`, `{/src.score} takes datatype integer from the column it reads; drop "datatype"`}, - {"another datatype over a typed column", `{"format":"{/src.score}","datatype":"number"}`, `{/src.score} takes datatype integer from the column it reads; drop "datatype"`}, + {"a datatype a typed column's values reject", `{"format":"{/src.score}","datatype":"boolean"}`, "{int(1,9)} prints an integer, not a boolean"}, + {"two typed columns it reads", `["{/src.score}","{/src.flag}"]`, `so to read "{/src.score}" as text, write {"format":"{text}","text":"{/src.score}"}`}, + {"text beside a weighted typed column read", `[{"format":"{/src.score}","weight":3},"n/a"]`, `to read that column as text, set its "format" to "{text}" and add "text": "{/src.score}"`}, {"a value of the column it reads", `{"format":"{/src.code}","datatype":"integer"}`, `"2x" is not an integer`}, {"a null read into text", `{"format":"{x}","x":"{/src.score}","datatype":"integer"}`, "reads a null"}, {"a sample with leading zeros", `{"format":"{digits(3)}","datatype":"integer"}`, "{digits(3)} prints text, not an integer"}, @@ -134,9 +136,10 @@ func TestDatatypeAcceptsAColumnThatAlwaysParses(t *testing.T) { `{"format":"{calc(a / b, 0)}","a":"{int(1,9)}","b":"{int(1,9)}","datatype":"integer"}`, `{"format":"{calc(sub * 1.25, 2)}","sub":{"format":"{calc(a * b)}","a":"{int(1,9)}","b":"{float(0,5,2)}"},"datatype":"number"}`, `{"format":"{/src.code}","datatype":"integer"}`, + `{"format":"{/src.n}","datatype":"number"}`, } { row := `{"format":"","col":` + column + `}` - src := `{"format":"","code":["200","404"]}` + src := `{"format":"","code":["200","404"],"n":{"format":"{int(1,9)}","datatype":"integer"}}` f, err := New(WithoutShippedData(), WithDataPath(writeData(t, map[string]string{"cat": cat, "row": row, "src": src})), WithSeed(1)) if err != nil { t.Errorf("%s: New = %v, want it loaded", column, err) -- 2.52.0 From 72fe01e89b6bb19b4f4c4de6eea51114856c8c8c Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 21:28:52 +0200 Subject: [PATCH 10/12] Refuse only a datatype restating the column read, name the text spelling for datatypes taken from column reads keeping an item's other keys, and keep the fence Decision true --- README.md | 21 +++++++++++---------- datatype.go | 26 ++++++++++++++++++++++++-- graph.go | 1 - value.go | 4 ++-- 4 files changed, 37 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index df5dd38..de34fa8 100644 --- a/README.md +++ b/README.md @@ -312,9 +312,9 @@ order.id: datatype integer: "1{digits(2)}" is not one value; write one literal o A column of one reference alone to another record's column — `"score": "{/src.score}"` — is that column: it takes the column's datatype and is null where the column is, and -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 -other read renders the column's text, a null as `""`. +a struct field tagged `src.score` is nil there. A `datatype` of its own types the +column's values where they prove it, and one restating the datatype it takes is refused. +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 @@ -549,10 +549,11 @@ tokens add cost in proportion to the output. tag of one reference alone, `{/users}`, is refused naming the path `users`: both render the same text, and only the path names a record. `IsTemplate` exports the rule, so the CLI, struct tags and any other caller read one. -- **An inline template skips the cycle fence, and only that one.** `New` proves the - loaded tree acyclic, an inline node is a finite tree of its own, and nothing in - the tree can reference it, so no render of it reaches itself. Every other fence - runs over both, from one `checkScope`. +- **An inline template skips the cycle fence.** `New` proves the loaded tree + acyclic, an inline node is a finite tree of its own, and nothing in the tree can + reference it, so no render of it reaches itself. Every other fence runs over both, + from one `checkScope`, except that struct tags leave column agreement to their Go + types. - **An inline template that does not compile is misuse (exit 2), including a reference that resolves to nothing** — the whole argument is the spelling under test, and `NewTemplate` compiles, links and validates as one step. An unknown @@ -672,9 +673,9 @@ tokens add cost in proportion to the output. 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. + than restating them, and a `datatype` restating the one it takes is a second + spelling. Any other `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 ec05352..6221b29 100644 --- a/datatype.go +++ b/datatype.go @@ -165,11 +165,33 @@ func disagreement(a *template, da DataType, b *template, db DataType) error { } switch { case da != DataTypeString && db != DataTypeString: - return fmt.Errorf("its items hold %s and %s; a column holds one datatype", da, db) + return bothTyped(a, da, b, db) case typed.datatype == DataTypeString && (&valueProof{}).columnItem(bare).not[want] != "": - return fmt.Errorf(`item %q is not %s, the datatype item %q takes from the column it reads; to read that column as text, write {"format":"{text}","text":%q}`, bare.format, dataTypeNouns[want], typed.format, typed.format) + return fmt.Errorf(`item %q is not %s, the datatype item %q takes from the column it reads; to read that column as text, %s`, bare.format, dataTypeNouns[want], typed.format, asText(typed)) 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 beside one declaring %s; a column holds one, so give it "datatype": %q`, bare.format, want, want) } + +// bothTyped names the fix for two items holding different datatypes: reading one that takes its +// datatype from the column it reads as text, since only a declared datatype can be edited away. +func bothTyped(a *template, da DataType, b *template, db DataType) error { + read := a + if a.datatype != DataTypeString { + read = b + } + if read.datatype != DataTypeString { + return fmt.Errorf("its items hold %s and %s; a column holds one datatype", da, db) + } + return fmt.Errorf("its items hold %s and %s; a column holds one datatype, so to read %q as text, %s", da, db, read.format, asText(read)) +} + +// asText names the spelling that reads the column a column-read item reads as text, keeping the +// other keys an object item carries. +func asText(t *template) string { + if t.fromString { + return fmt.Sprintf(`write {"format":"{text}","text":%q}`, t.format) + } + return fmt.Sprintf(`set its "format" to "{text}" and add "text": %q`, t.format) +} diff --git a/graph.go b/graph.go index be5a593..962a51c 100644 --- a/graph.go +++ b/graph.go @@ -177,7 +177,6 @@ func inlineScope(n node, label string) nodeScope { return func(fn func(path string, m node) error) error { return eachNode(n, label, fn) } } -// checkScope runs every fence over a scope: checkColumns, then checkRenders. func checkScope(s nodeScope) error { if err := checkColumns(s); err != nil { return err diff --git a/value.go b/value.go index faa715f..cf59cdf 100644 --- a/value.go +++ b/value.go @@ -26,13 +26,13 @@ type valueProof struct { } // 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 is. +// and one restating the datatype of the column it is. func (p *valueProof) checkDatatype(path string, n node) error { t, ok := n.(*template) if !ok || t.datatype == DataTypeString { return nil } - if d := readDatatype(t); d != DataTypeString { + if d := readDatatype(t); d == t.datatype { 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 != "" { -- 2.52.0 From 1e60d52e48be54b56f19e78c928de91521d08f93 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 21:43:09 +0200 Subject: [PATCH 11/12] Tests: a typed column read whose values hold the datatype declared beside it names the typed spelling --- datatype_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/datatype_test.go b/datatype_test.go index 352a4a0..9e37149 100644 --- a/datatype_test.go +++ b/datatype_test.go @@ -53,6 +53,7 @@ func TestDatatypeRejectsAValueItsTypeRejects(t *testing.T) { {"a datatype over a typed column", `{"format":"{/src.score}","datatype":"integer"}`, `{/src.score} takes datatype integer from the column it reads; drop "datatype"`}, {"a datatype a typed column's values reject", `{"format":"{/src.score}","datatype":"boolean"}`, "{int(1,9)} prints an integer, not a boolean"}, {"two typed columns it reads", `["{/src.score}","{/src.flag}"]`, `so to read "{/src.score}" as text, write {"format":"{text}","text":"{/src.score}"}`}, + {"a typed column read that holds the datatype declared beside it", `[{"format":"1.5","datatype":"number"},"{/src.score}"]`, `so write "{/src.score}" as {"format":"{/src.score}","datatype":"number"}`}, {"text beside a weighted typed column read", `[{"format":"{/src.score}","weight":3},"n/a"]`, `to read that column as text, set its "format" to "{text}" and add "text": "{/src.score}"`}, {"a value of the column it reads", `{"format":"{/src.code}","datatype":"integer"}`, `"2x" is not an integer`}, {"a null read into text", `{"format":"{x}","x":"{/src.score}","datatype":"integer"}`, "reads a null"}, -- 2.52.0 From 72a6ef90713c0650bcd4c7e6b7b87bb9ee2cf0dd Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 15 Sep 2026 21:44:36 +0200 Subject: [PATCH 12/12] Name the typed spelling for a column read whose values hold the datatype beside it, and say holding for a datatype taken from a column read --- datatype.go | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/datatype.go b/datatype.go index 6221b29..36ba1ad 100644 --- a/datatype.go +++ b/datatype.go @@ -171,22 +171,34 @@ func disagreement(a *template, da DataType, b *template, db DataType) error { 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 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 holding %s; a column holds one, so give it "datatype": %q`, bare.format, want, want) } -// bothTyped names the fix for two items holding different datatypes: reading one that takes its -// datatype from the column it reads as text, since only a declared datatype can be edited away. +// bothTyped names the fix for two items holding different datatypes: the one taking its datatype +// from the column it reads is typed as the other where its values prove it, else read as text. func bothTyped(a *template, da DataType, b *template, db DataType) error { - read := a + read, other := a, db if a.datatype != DataTypeString { - read = b + read, other = b, da } - if read.datatype != DataTypeString { + switch { + case read.datatype != DataTypeString: return fmt.Errorf("its items hold %s and %s; a column holds one datatype", da, db) + case (&valueProof{}).columnItem(read).not[other] == "": + return fmt.Errorf("its items hold %s and %s; a column holds one datatype, so %s", da, db, typedAs(read, other)) } return fmt.Errorf("its items hold %s and %s; a column holds one datatype, so to read %q as text, %s", da, db, read.format, asText(read)) } +// typedAs names the spelling giving a column-read item datatype d, keeping the other keys an object +// item carries. +func typedAs(t *template, d DataType) string { + if t.fromString { + return fmt.Sprintf(`write %q as {"format":%q,"datatype":%q}`, t.format, t.format, d) + } + return fmt.Sprintf(`give %q "datatype": %q`, t.format, d) +} + // asText names the spelling that reads the column a column-read item reads as text, keeping the // other keys an object item carries. func asText(t *template) string { -- 2.52.0