Compare commits

..

3 Commits

9 changed files with 106 additions and 33 deletions
+2 -1
View File
@@ -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 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 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 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 - **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 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 `encoding/json` and SQL mappers read them — so they are columns of its record and
+36 -11
View File
@@ -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) return 0, fmt.Errorf(`datatype takes "integer", "number" or "boolean", got %q`, name)
} }
// checkColumns rejects a record column whose items hold different datatypes. // checkColumns rejects a record column whose items hold different datatypes, checking a column
func checkColumns(path string, n node) error { // after the columns its items read, so a column read is named before its readers.
t, ok := n.(*template) func checkColumns(s nodeScope) error {
if !ok || !t.record { checked := map[node]bool{}
return nil var check func(path, name string, column node) error
} check = func(path, name string, column node) error {
for _, name := range recordColumns(t) { if checked[column] {
if _, err := columnDatatype(t.fields[name]); err != nil { 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 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 // 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. // disagreement names the fix for two items of one column holding different datatypes.
func disagreement(a *template, da DataType, b *template, db DataType) error { func disagreement(a *template, da DataType, b *template, db DataType) error {
bare, want := b, da bare, typed, want := b, a, da
if da == DataTypeString { if da == DataTypeString {
bare, want = a, db bare, typed, want = a, b, db
} }
switch { switch {
case da != DataTypeString && db != DataTypeString: case da != DataTypeString && db != 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", 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 case bare.fromString: // an object may carry a weight, which this spelling would drop
return fmt.Errorf(`item %q declares no datatype, and a column holds one; write it as {"format":%q,"datatype":%q}`, bare.format, bare.format, want) return fmt.Errorf(`item %q declares no datatype, and a column holds one; write it as {"format":%q,"datatype":%q}`, bare.format, bare.format, want)
} }
+9 -1
View File
@@ -47,8 +47,9 @@ func TestDatatypeRejectsAValueItsTypeRejects(t *testing.T) {
{"an item beside a typed one", `[{"format":"1","datatype":"integer"},"x"]`, `write it as {"format":"x","datatype":"integer"}`}, {"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"`}, {"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"}, {"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"}`}, {"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"`}, {"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"`}, {"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 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) 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]*)$`) var jsonInteger = regexp.MustCompile(`^(0|-?[1-9][0-9]*)$`)
+10 -5
View File
@@ -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) } 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 // 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 // the walk. It runs after checkNoCycles, whose guarantee is what lets the walks
// terminate. // terminate.
func checkScope(s nodeScope) error { func checkRenders(s nodeScope) error {
if err := s(checkColumns); err != nil {
return err
}
mem := reachMemo{} mem := reachMemo{}
if err := s(func(path string, n node) error { return repeatCheck(path, n, mem) }); err != nil { if err := s(func(path string, n node) error { return repeatCheck(path, n, mem) }); err != nil {
return err return err
+5 -5
View File
@@ -32,7 +32,7 @@ func (f *Generator) NewTemplate(input string) (*Template, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("fejkdata: %w", err) 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 nil, fmt.Errorf("fejkdata: %w", err)
} }
return &Template{g: f, n: n}, nil return &Template{g: f, n: n}, nil
@@ -133,14 +133,14 @@ func inputValue(input string) (any, error) {
return raw, nil return raw, nil
} }
// bindInline links an inline node's references against root and runs the fences over it, // bindInline links an inline node's references against root and runs check over it, naming its
// naming its nodes from label. // nodes from label.
func bindInline(n node, label string, root map[string]node) error { func bindInline(n node, label string, root map[string]node, check func(nodeScope) error) error {
scope := inlineScope(n, label) scope := inlineScope(n, label)
if err := linkNodeRefs(scope, root); err != nil { if err := linkNodeRefs(scope, root); err != nil {
return err return err
} }
return checkScope(scope) return check(scope)
} }
// linkNodeRefs binds the references in an inline node's templates against the // linkNodeRefs binds the references in an inline node's templates against the
+1 -1
View File
@@ -217,7 +217,7 @@ func recordOf(n node) (*template, []Column, error) {
} }
columns := make([]Column, len(names)) columns := make([]Column, len(names))
for i, name := range names { for i, name := range names {
datatype, _ := columnDatatype(t.fields[name]) // 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} columns[i] = Column{Name: name, DataType: datatype}
} }
return t, columns, nil return t, columns, nil
+13 -7
View File
@@ -385,7 +385,7 @@ func TestRecordBareReferenceStaysIndependent(t *testing.T) {
func TestRecordColumnOfOneReferenceIsTheColumnItReads(t *testing.T) { func TestRecordColumnOfOneReferenceIsTheColumnItReads(t *testing.T) {
dir := writeData(t, map[string]string{ dir := writeData(t, map[string]string{
"mid": `{"format":"","score":"{/src.score}"}`, "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"}]}`, "src": `{"format":"","code":[null,"200","404"],"score":[null,{"format":"{int(1,9)}","datatype":"integer"}]}`,
}) })
f := newGenerator(t, dir, WithSeed(1)) 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 return cols[name].Null == want.Null && cols[name].Value == want.Value
} }
switch { switch {
case score.DataType != DataTypeInteger || cols["chain"].DataType != DataTypeInteger || code.DataType != DataTypeInteger || cols["label"].DataType != DataTypeString || cols["text"].DataType != DataTypeString: 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 and code integer columns, label and text string ones", r.JSON()) 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"): 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) t.Fatalf("score = %+v, want null or a digit from src.score", score)
case code.Null == (code.Value == "200" || code.Value == "404"): case code.Null == (code.Value == "200" || code.Value == "404"):
t.Fatalf("code = %+v, want null or a code from src.code", code) 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()) 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: 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) 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: case strings.Contains(r.JSON(), `"score":null`) != score.Null:
t.Fatalf("JSON() = %s, want a null score written null", r.JSON()) t.Fatalf("JSON() = %s, want a null score written null", r.JSON())
} }
ic := inline.Fake().Columns()[0] ir := inline.Fake()
if ic.DataType != DataTypeInteger || ic.Null == (len(ic.Value) == 1) { ic, sqlValue := ir.Columns()[0], ir.Columns()[0].Value
t.Fatalf("inline score = %+v, want an integer column, null or a digit", ic) 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} { for name, c := range map[string]Column{"code": code, "inline": ic, "score": score} {
if c.Null { if c.Null {
+1 -1
View File
@@ -258,7 +258,7 @@ func (s *structShape) compileRecord(root map[string]node, t reflect.Type, label
if err != nil { if err != nil {
return fmt.Errorf("%s: %w", label, err) 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 return err
} }
record, columns, err := recordOf(n) record, columns, err := recordOf(n)
+29 -1
View File
@@ -54,7 +54,8 @@ func structData(t *testing.T) *Generator {
return newGenerator(t, writeData(t, map[string]string{ return newGenerator(t, writeData(t, map[string]string{
"person": `[{"format":"{first} {last}","first":"Ada","last":"Lovelace"},{"format":"{first} {last}","first":"Bo","last":"Ek"}]`, "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"}]`, "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"}]}`, "trip": `{"format":"","leg":[{"format":"{to}","to":"Oslo"},{"format":"{to}","to":"Rome"}]}`,
}), WithSeed(1)) }), WithSeed(1))
} }
@@ -62,15 +63,39 @@ func structData(t *testing.T) *Generator {
func TestFakeStructFieldTaggedWithAColumnIsThatColumn(t *testing.T) { func TestFakeStructFieldTaggedWithAColumnIsThatColumn(t *testing.T) {
f := structData(t) f := structData(t)
nils := map[string]int{} 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++ { for i := 0; i < 200; i++ {
var v struct { var v struct {
Code *string `fake:"src.code"` Code *string `fake:"src.code"`
Codes *string `fake:"[\"{/src.score}\",\"{/src.code}\"]"`
Del *int64 `fake:"src.del"`
Label string `fake:"n={/src.score}"` Label string `fake:"n={/src.score}"`
Mixed *string `fake:"[\"{/src.score}\",\"x\"]"`
Pair *int64 `fake:"[\"{/src.score}\",\"5\"]"`
Score *int64 `fake:"src.score"` Score *int64 `fake:"src.score"`
} }
if err := f.FakeStruct(&v); err != nil { if err := f.FakeStruct(&v); err != nil {
t.Fatal(err) 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 { switch {
case v.Code == nil: case v.Code == nil:
nils["code"]++ nils["code"]++
@@ -209,6 +234,9 @@ func TestFakeStructErrors(t *testing.T) {
{&struct { {&struct {
A *bool `fake:"src.score"` A *bool `fake:"src.score"`
}{}, ".A (*bool): {int(1,9)} prints an integer, not a boolean"}, }{}, ".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 { {&struct {
A int `fake:"{digits(3)}"` A int `fake:"{digits(3)}"`
}{}, ".A (int): {digits(3)} prints text, not an integer"}, }{}, ".A (int): {digits(3)} prints text, not an integer"},