Add record output to JSON, CSV and SQL #8

Open
lilleman wants to merge 21 commits from record-output into main
6 changed files with 100 additions and 33 deletions
Showing only changes of commit 79827a66d6 - Show all commits
+8 -6
View File
@@ -126,12 +126,14 @@ renders nothing by `Fake`, and is compiled only so the tree's fences still run.
The columns are the point, and their facts stay together: two columns that The columns are the point, and their facts stay together: two columns that
reference one category — `{/currency.code}` and `{/currency.symbol}` — share one reference one category — `{/currency.code}` and `{/currency.symbol}` — share one
draw of it, so the record is internally consistent. That one draw is also why two draw of it, so the record is internally consistent. That one draw is also why two
columns may not read overlapping reference paths`{/cat.a}` beside `{/cat.a.b}` columns may not read overlapping reference *paths*`{/cat.a}` beside
is refused, naming the fields to write instead, exactly as `{/cat.a.b}` is refused, naming the fields to write instead, as
[One draw, one spelling](#one-draw-one-spelling) refuses the pair inside a single [One draw, one spelling](#one-draw-one-spelling) refuses that pair inside a single
format. A field hold, transform or operand ties fields together within one column format. A column may not reference the record it belongs to either: `{/users.first}`
as always (see [Correlated fields](#correlated-fields) and inside `users` would contradict the `first` column beside it, so put a value two
[Decisions](#decisions)). columns share in its own category and reference that. A field hold, transform or
operand ties fields together within one column as always (see
[Correlated fields](#correlated-fields) and [Decisions](#decisions)).
## Library ## Library
+1
View File
@@ -45,6 +45,7 @@ type Generator struct {
mu sync.Mutex mu sync.Mutex
rand *session rand *session
categories map[string]node categories map[string]node
records map[node]recordShape
} }
// session is one generator's mutable render state: the seeded rng plus the {seq()} // session is one generator's mutable render state: the seeded rng plus the {seq()}
+3 -3
View File
@@ -274,10 +274,10 @@ func readField(s *session, t *template, held, refScope *draws, a arm) string {
} }
return render(s, t.fields[a.key], refScope) return render(s, t.fields[a.key], refScope)
} }
// A reference reads the caller's scope, so its draw outlives this expansion; a // A reference that reads a path reads the caller's scope, so its draw outlives
// sibling stays local to it. // this expansion; a sibling, and a reference read whole, stay local to it.
d := held d := held
if isRef(a.key) && refScope != nil { if isRef(a.key) && refScope != nil && len(a.tail) > 0 {
d = refScope d = refScope
} }
if v, read := d.value[a.path]; read { if v, read := d.value[a.path]; read {
+19
View File
@@ -51,6 +51,25 @@ func TestNoRenderAllocRegression(t *testing.T) {
} }
} }
// A record's fences read the compiled tree, so they belong to New, not to a draw.
// A per-draw walk costs allocations in proportion to the tree; this pins that the
// count does not move with the column count.
func TestNoRecordAllocRegression(t *testing.T) {
for _, s := range []struct{ name, json string }{
{"record 3 columns", `{"format":"","a":"x","b":"y","c":"z"}`},
{"record 50 columns", wideTokenJSON(50)},
} {
f, err := New(WithoutShippedData(), WithDataFS(fstest.MapFS{"x.json": {Data: []byte(s.json)}}))
if err != nil {
t.Fatalf("New(%s): %v", s.name, err)
}
const base = 4.0
if allocs := testing.AllocsPerRun(10000, func() { f.Record("x") }); allocs > base*1.10 {
t.Errorf("%s: %.1f allocs/op regressed past %.1f (baseline %.1f + 10%%); a record fence running per draw is the usual cause", s.name, allocs, base*1.10, base)
}
}
}
func BenchmarkNestedDepth25(b *testing.B) { benchPath(b, tmpData(b, "deep", nestedJSON(25)), "deep") } func BenchmarkNestedDepth25(b *testing.B) { benchPath(b, tmpData(b, "deep", nestedJSON(25)), "deep") }
func BenchmarkNestedDepth100(b *testing.B) { benchPath(b, tmpData(b, "deep", nestedJSON(100)), "deep") } func BenchmarkNestedDepth100(b *testing.B) { benchPath(b, tmpData(b, "deep", nestedJSON(100)), "deep") }
func BenchmarkWideTokens100(b *testing.B) { func BenchmarkWideTokens100(b *testing.B) {
+56 -21
View File
@@ -17,7 +17,8 @@ type Column struct {
// Record is one record rendered from a template: every direct field is a column, // Record is one record rendered from a template: every direct field is a column,
// listed in name order. Each column is its own expansion, so a sibling field is // listed in name order. Each column is its own expansion, so a sibling field is
// local to it, while a reference is drawn once for the whole record. // local to it, while a reference that reads a path is drawn once for the whole
// record.
type Record struct { type Record struct {
columns []Column columns []Column
} }
@@ -104,11 +105,35 @@ func (f *Generator) Record(path string) (*Record, error) {
if len(tail) > 0 { if len(tail) > 0 {
return nil, fmt.Errorf("fejkdata: %s descends into %q, a field; only a category-level template is a record", path, tail[0]) return nil, fmt.Errorf("fejkdata: %s descends into %q, a field; only a category-level template is a record", path, tail[0])
} }
t, columns, err := recordOf(n) shape := f.recordShapeOf(n)
if err != nil { if shape.err != nil {
return nil, fmt.Errorf("fejkdata: %s %w", path, err) return nil, fmt.Errorf("fejkdata: %s %w", path, shape.err)
} }
return renderRecord(f.rand, t, columns), nil return renderRecord(f.rand, shape.t, shape.columns), nil
}
// recordShape is what recordOf settled about a node: the template to project, its
// columns, or why it is not a record.
type recordShape struct {
t *template
columns []string
err error
}
// recordShapeOf fences a node once and remembers the answer. The fences read the
// compiled tree, which New fixed, so a repeated Record call on one path pays them
// once rather than per draw. Callers hold the generator's lock.
func (f *Generator) recordShapeOf(n node) recordShape {
if shape, done := f.records[n]; done {
return shape
}
t, columns, err := recordOf(n)
shape := recordShape{t: t, columns: columns, err: err}
if f.records == nil {
f.records = map[node]recordShape{}
}
f.records[n] = shape
return shape
} }
// RecordTemplate is an inline record compiled, referenced and validated once, // RecordTemplate is an inline record compiled, referenced and validated once,
@@ -169,14 +194,14 @@ func recordOf(n node) (*template, []string, error) {
return t, columns, nil return t, columns, nil
} }
// checkColumnRefs rejects two reference reads that overlap across a record's // checkColumnRefs rejects the reference reads a record's shared draw cannot answer
// columns: one renders a level the other reads a path into, and the record's one // for: one column rendering a level another reads a path into, and a column
// draw of that level cannot answer for both. checkNoOverlap settles the pair // reading the record back through its own path.
// within a single format; the record's shared scope is what carries it across
// columns, so the same pair is settled here. A bare reference holds nothing, so it
// is not collected and keeps drawing on its own.
func checkColumnRefs(t *template, columns []string) error { func checkColumnRefs(t *template, columns []string) error {
reads := columnRefs(t, columns) reads, err := columnRefs(t, columns)
if err != nil {
return err
}
sort.Slice(reads, func(i, j int) bool { sort.Slice(reads, func(i, j int) bool {
if reads[i].a.path != reads[j].a.path { if reads[i].a.path != reads[j].a.path {
return reads[i].a.path < reads[j].a.path return reads[i].a.path < reads[j].a.path
@@ -200,21 +225,32 @@ type columnRef struct {
a arm a arm
} }
// columnRefs lists every held reference read anywhere a column renders, following // columnRefs lists every reference read that reads a path, anywhere a column
// the same edges expand does. // renders, following the same edges expand does. A read that lands back on the
func columnRefs(t *template, columns []string) []columnRef { // record itself names a sibling column, which no draw of the record can answer
// for, so it is reported here rather than collected.
func columnRefs(t *template, columns []string) ([]columnRef, error) {
var out []columnRef var out []columnRef
var err error
for _, name := range columns { for _, name := range columns {
seen := map[node]bool{} seen := map[node]bool{}
var walk func(n node) var walk func(n node)
walk = func(n node) { walk = func(n node) {
if n == nil || seen[n] { if n == nil || seen[n] || err != nil {
return return
} }
seen[n] = true seen[n] = true
if tm, ok := n.(*template); ok { if tm, ok := n.(*template); ok {
for _, r := range boundReaders(tm.format, tm.bound, tm.refs) { for _, r := range boundReaders(tm.format, tm.bound, tm.refs) {
if a := splitArm(r.name, tm.refs); isRef(a.key) && len(a.tail) > 0 { a := splitArm(r.name, tm.refs)
if !isRef(a.key) {
continue
}
if tm.fields[a.key] == node(t) {
err = fmt.Errorf("column %q reads {%s}, which points back at this record; a column cannot read another column — move the shared value into its own category and reference that", name, a.name)
return
}
if len(a.tail) > 0 {
out = append(out, columnRef{name, a}) out = append(out, columnRef{name, a})
} }
} }
@@ -225,12 +261,11 @@ func columnRefs(t *template, columns []string) []columnRef {
} }
walk(t.fields[name]) walk(t.fields[name])
} }
return out return out, err
} }
// renderRecord draws each column once, in the name order recordOf fixed. The // renderRecord draws each column once, in the name order recordOf fixed, over one
// columns share one reference scope, so two columns that reference one category // reference scope shared across them.
// read one draw of it.
func renderRecord(s *session, t *template, columns []string) *Record { func renderRecord(s *session, t *template, columns []string) *Record {
scope := &draws{variant: map[string]node{}, value: map[string]string{}} scope := &draws{variant: map[string]node{}, value: map[string]string{}}
r := &Record{columns: make([]Column, len(columns))} r := &Record{columns: make([]Column, len(columns))}
+13 -3
View File
@@ -152,9 +152,9 @@ func TestRecordRejectsOverlappingReferenceColumns(t *testing.T) {
cat := `{"format":"","a":[{"format":"A={b}","b":"1"},{"format":"A={b}","b":"2"}]}` cat := `{"format":"","a":[{"format":"A={b}","b":"1"},{"format":"A={b}","b":"2"}]}`
for _, c := range []struct{ name, row string }{ for _, c := range []struct{ name, row string }{
{"sibling columns", `{"format":"","whole":"{/cat.a}","inner":"{/cat.a.b}"}`}, {"sibling columns", `{"format":"","whole":"{/cat.a}","inner":"{/cat.a.b}"}`},
{"through a nested template", `{"format":"","whole":"{/cat.a}","inner":{"format":"{/cat.a.b}"}}`}, {"through a nested template", `{"format":"","whole":"{/cat.a}","inner":{"format":"{/cat.a.b} {x}","x":"1"}}`},
{"through a column repeat", `{"format":"","whole":"{/cat.a}","inner":{"format":"{/cat.a.b}","repeat":2,"separator":"-"}}`}, {"through a column repeat", `{"format":"","whole":"{/cat.a}","inner":{"format":"{/cat.a.b}","repeat":2,"separator":"-"}}`},
{"through a choice variant", `{"format":"","whole":"{/cat.a}","inner":[{"format":"{/cat.a.b}"},{"format":"{/cat.a.b}!"}]}`}, {"through a choice variant", `{"format":"","whole":"{/cat.a}","inner":[{"format":"{/cat.a.b} {x}","x":"1"},{"format":"{/cat.a.b}! {x}","x":"2"}]}`},
{"as a builtin operand", `{"format":"","whole":"{uppercase(/cat.a)}","inner":"{/cat.a.b}"}`}, {"as a builtin operand", `{"format":"","whole":"{uppercase(/cat.a)}","inner":"{/cat.a.b}"}`},
} { } {
f := newGenerator(t, writeData(t, map[string]string{"cat": cat, "row": c.row}), WithSeed(1)) f := newGenerator(t, writeData(t, map[string]string{"cat": cat, "row": c.row}), WithSeed(1))
@@ -172,6 +172,17 @@ func TestRecordRejectsOverlappingReferenceColumns(t *testing.T) {
} }
} }
func TestInlineRecordRejectsOverlappingColumns(t *testing.T) {
dir := writeData(t, map[string]string{
"cat": `{"format":"","a":[{"format":"A={b}","b":"1"},{"format":"A={b}","b":"2"}]}`,
})
f := newGenerator(t, dir, WithSeed(1))
_, err := f.FakeRecord(`{"format":"","whole":"{/cat.a}","inner":"{/cat.a.b}"}`)
if err == nil || !strings.Contains(err.Error(), "reads a path into") {
t.Fatalf("inline record over an overlapping pair = %v, want the inline entry point to refuse it too", err)
}
}
func TestRecordRejectsAColumnReadingItsOwnRecord(t *testing.T) { func TestRecordRejectsAColumnReadingItsOwnRecord(t *testing.T) {
dir := writeData(t, map[string]string{ dir := writeData(t, map[string]string{
"person": `{"format":"{first} {last}","first":["Ada","Bo"],"last":["Lovelace","Ek"],"full":"{/person.first} {/person.last}"}`, "person": `{"format":"{first} {last}","first":["Ada","Bo"],"last":["Lovelace","Ek"],"full":"{/person.first} {/person.last}"}`,
@@ -361,7 +372,6 @@ func TestInlineRecordErrors(t *testing.T) {
{`"hello"`, "has no fields, so no columns"}, {`"hello"`, "has no fields, so no columns"},
{`["a","b"]`, "a record is a template whose fields are its columns"}, {`["a","b"]`, "a record is a template whose fields are its columns"},
{`{"format":"{a}-","repeat":3,"separator":"|","a":["x","y"]}`, "carries repeat 3"}, {`{"format":"{a}-","repeat":3,"separator":"|","a":["x","y"]}`, "carries repeat 3"},
{`{"format":"","whole":"{/cur.code}","inner":"{/cur.code.x}"}`, "reads a path into"},
} { } {
if _, err := f.FakeRecord(c.input); err == nil || !strings.Contains(err.Error(), c.want) { if _, err := f.FakeRecord(c.input); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("FakeRecord(%q) = %v, want an error naming %q", c.input, err, c.want) t.Errorf("FakeRecord(%q) = %v, want an error naming %q", c.input, err, c.want)