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 146 additions and 127 deletions
Showing only changes of commit 8cb8cf6334 - Show all commits
+64 -43
View File
@@ -15,6 +15,7 @@ import (
"io" "io"
"os" "os"
"runtime/debug" "runtime/debug"
"sort"
"strconv" "strconv"
"strings" "strings"
@@ -208,20 +209,41 @@ func parseArgs(argv []string) (invocation, error) {
return in, nil return in, nil
} }
// recordFormat reports whether the format names a record output (json, csv or // recordFormat is one way to write a record out: the line it renders, and the
// sql) rather than the default text. // header that precedes the first one, if the format has one.
type recordFormat struct {
header func(*fejkdata.Record) string
line func(r *fejkdata.Record, table string) string
}
// recordFormats is every --format that writes records. Adding one is this entry
// alone: the flag check reads the same table the writer dispatches through, so a
// format cannot be accepted and then not written.
var recordFormats = map[string]recordFormat{
"csv": {header: (*fejkdata.Record).CSVHeader, line: func(r *fejkdata.Record, _ string) string { return r.CSVLine() }},
"json": {line: func(r *fejkdata.Record, _ string) string { return r.JSON() }},
"sql": {line: func(r *fejkdata.Record, table string) string { return r.SQLInsert(table) }},
}
// recordFormat reports whether the format writes records rather than plain text.
func (in invocation) recordFormat() bool { func (in invocation) recordFormat() bool {
return in.formatSet && in.format != "text" return in.format != "text"
}
// formatNames lists the --format values, text included, for the misuse error.
func formatNames() string {
names := []string{"text"}
for name := range recordFormats {
names = append(names, name)
}
sort.Strings(names)
return strings.Join(names[:len(names)-1], ", ") + " or " + names[len(names)-1]
} }
// checkFlags rejects a flag value or combination that cannot run. // checkFlags rejects a flag value or combination that cannot run.
func (in invocation) checkFlags() error { func (in invocation) checkFlags() error {
if in.formatSet { if _, ok := recordFormats[in.format]; !ok && in.format != "text" {
switch in.format { return fmt.Errorf("--format takes %s, got %q", formatNames(), in.format)
case "text", "json", "csv", "sql":
default:
return fmt.Errorf("--format takes text, json, csv or sql, got %q", in.format)
}
} }
if in.tableSet && in.format != "sql" { if in.tableSet && in.format != "sql" {
return errors.New("--table names the INSERT target, so it needs --format sql") return errors.New("--table names the INSERT target, so it needs --format sql")
@@ -272,17 +294,13 @@ func (in invocation) options() []fejkdata.Option {
// so a render failure comes before anything is written; a write failure surfaces // so a render failure comes before anything is written; a write failure surfaces
// from Flush, bufio keeping the first one. // from Flush, bufio keeping the first one.
func (in invocation) write(f *fejkdata.Generator, kind argKind, w io.Writer) error { func (in invocation) write(f *fejkdata.Generator, kind argKind, w io.Writer) error {
arg := in.paths[0] draw, err := in.draw(f, kind, in.paths[0])
if in.recordFormat() { if err != nil {
return in.writeRecords(f, kind, arg, w) return err
} }
draw := func() (string, error) { return f.Fake(arg) } separator := in.separator
if kind == argTemplate { if in.recordFormat() {
t, err := f.NewTemplate(arg) separator = "\n"
if err != nil {
return templateError{err}
}
draw = func() (string, error) { return t.Fake(), nil }
} }
out := bufio.NewWriter(w) out := bufio.NewWriter(w)
for i := 0; i < in.repeat; i++ { for i := 0; i < in.repeat; i++ {
@@ -291,7 +309,7 @@ func (in invocation) write(f *fejkdata.Generator, kind argKind, w io.Writer) err
return err return err
} }
if i > 0 { if i > 0 {
out.WriteString(in.separator) out.WriteString(separator)
} }
out.WriteString(v) out.WriteString(v)
} }
@@ -299,40 +317,43 @@ func (in invocation) write(f *fejkdata.Generator, kind argKind, w io.Writer) err
return out.Flush() return out.Flush()
} }
// writeRecords streams a record per line in the chosen format: one JSON object // draw builds what one render yields: the value's text, or the record's line in
// per line, a CSV header then a row per line, or one INSERT per line. // the chosen format, the header carried ahead of the first one.
func (in invocation) writeRecords(f *fejkdata.Generator, kind argKind, arg string, w io.Writer) error { func (in invocation) draw(f *fejkdata.Generator, kind argKind, arg string) (func() (string, error), error) {
draw := func() (*fejkdata.Record, error) { return f.Record(arg) } if !in.recordFormat() {
if kind != argTemplate {
return func() (string, error) { return f.Fake(arg) }, nil
}
t, err := f.NewTemplate(arg)
if err != nil {
return nil, templateError{err}
}
return func() (string, error) { return t.Fake(), nil }, nil
}
record := func() (*fejkdata.Record, error) { return f.Record(arg) }
if kind == argTemplate { if kind == argTemplate {
t, err := f.NewRecordTemplate(arg) t, err := f.NewRecordTemplate(arg)
if err != nil { if err != nil {
return templateError{err} return nil, templateError{err}
} }
draw = func() (*fejkdata.Record, error) { return t.Fake(), nil } record = func() (*fejkdata.Record, error) { return t.Fake(), nil }
} }
out := bufio.NewWriter(w) format, table, first := recordFormats[in.format], in.table, true
table := in.table
if table == "" { if table == "" {
table = defaultTable(arg, kind) table = defaultTable(arg, kind)
} }
for i := 0; i < in.repeat; i++ { return func() (string, error) {
r, err := draw() r, err := record()
if err != nil { if err != nil {
return err return "", err
} }
switch in.format { line := format.line(r, table)
case "json": if first && format.header != nil {
fmt.Fprintln(out, r.JSON()) line = format.header(r) + "\n" + line
case "csv":
if i == 0 {
fmt.Fprintln(out, r.CSVHeader())
}
fmt.Fprintln(out, r.CSVLine())
case "sql":
fmt.Fprintln(out, r.SQLInsert(table))
} }
} first = false
return out.Flush() return line, nil
}, nil
} }
// defaultTable names the INSERT target when --table is absent: the path's last // defaultTable names the INSERT target when --table is absent: the path's last
+7 -6
View File
@@ -267,17 +267,18 @@ type draws struct {
// gives one value, and a shown operand is the operand computed. Every other name is // 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 // drawn afresh, so {word} {word} still draws twice. checkTokens, checkPath and
// linkRefs prove every step, so the walk cannot fail. // linkRefs prove every step, so the walk cannot fail.
func readField(s *session, t *template, held, shared *draws, a arm) string { func readField(s *session, t *template, held, refScope *draws, a arm) string {
if !t.held[a.key] { if !t.held[a.key] {
if len(a.tail) > 0 { 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)) panic(fmt.Sprintf("fejkdata: %q reads a path into %q, which the expansion does not hold", a.name, a.key))
} }
return renderShared(s, t.fields[a.key], shared) return render(s, t.fields[a.key], refScope)
} }
// A reference shares its draw across a record's columns; a sibling is per expansion. // A reference reads the caller's scope, so its draw outlives this expansion; a
// sibling stays local to it.
d := held d := held
if isRef(a.key) && shared != nil { if isRef(a.key) && refScope != nil {
d = shared d = refScope
} }
if v, read := d.value[a.path]; read { if v, read := d.value[a.path]; read {
return v return v
@@ -298,7 +299,7 @@ func readField(s *session, t *template, held, shared *draws, a arm) string {
} }
return []node{n}, nil return []node{n}, nil
}, },
leaf: func(n node) error { v = renderShared(s, n, shared); return nil }, leaf: func(n node) error { v = render(s, n, refScope); return nil },
}) })
d.value[a.path] = v d.value[a.path] = v
return v return v
+1 -1
View File
@@ -20,7 +20,7 @@ type Template struct {
func (t *Template) Fake() string { func (t *Template) Fake() string {
t.g.mu.Lock() t.g.mu.Lock()
defer t.g.mu.Unlock() defer t.g.mu.Unlock()
return render(t.g.rand, t.n) return render(t.g.rand, t.n, nil)
} }
// NewTemplate compiles an inline template — a format string or a JSON value — and // NewTemplate compiles an inline template — a format string or a JSON value — and
+56 -54
View File
@@ -3,13 +3,13 @@ package fejkdata
import ( import (
"encoding/csv" "encoding/csv"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"sort"
"strings" "strings"
) )
// Field is one rendered column of a record. // Column is one rendered column of a record.
type Field struct { type Column struct {
Name string Name string
Value string Value string
} }
@@ -19,19 +19,19 @@ type Field struct {
// string a [Generator.Fake] call renders; a record is its inverse — each field // string a [Generator.Fake] call renders; a record is its inverse — each field
// projected as a column instead of composed. // projected as a column instead of composed.
type Record struct { type Record struct {
fields []Field columns []Column
} }
// Fields returns the record's columns in name order. // Columns returns the record's columns in name order.
func (r *Record) Fields() []Field { func (r *Record) Columns() []Column {
return append([]Field(nil), r.fields...) return append([]Column(nil), r.columns...)
} }
// JSON renders the record as one JSON object, every column a string. // JSON renders the record as one JSON object, every column a string.
func (r *Record) JSON() string { func (r *Record) JSON() string {
m := make(map[string]string, len(r.fields)) m := make(map[string]string, len(r.columns))
for _, f := range r.fields { for _, c := range r.columns {
m[f.Name] = f.Value m[c.Name] = c.Value
} }
b, _ := json.Marshal(m) b, _ := json.Marshal(m)
return string(b) return string(b)
@@ -48,17 +48,17 @@ func (r *Record) CSVLine() string {
} }
func (r *Record) names() []string { func (r *Record) names() []string {
out := make([]string, len(r.fields)) out := make([]string, len(r.columns))
for i, f := range r.fields { for i, c := range r.columns {
out[i] = f.Name out[i] = c.Name
} }
return out return out
} }
func (r *Record) values() []string { func (r *Record) values() []string {
out := make([]string, len(r.fields)) out := make([]string, len(r.columns))
for i, f := range r.fields { for i, c := range r.columns {
out[i] = f.Value out[i] = c.Value
} }
return out return out
} }
@@ -74,11 +74,11 @@ func csvLine(cols []string) string {
// SQLInsert renders the record as one INSERT statement into table: identifiers in // SQLInsert renders the record as one INSERT statement into table: identifiers in
// ANSI double quotes, every value a single-quoted string literal. // ANSI double quotes, every value a single-quoted string literal.
func (r *Record) SQLInsert(table string) string { func (r *Record) SQLInsert(table string) string {
cols := make([]string, len(r.fields)) cols := make([]string, len(r.columns))
vals := make([]string, len(r.fields)) vals := make([]string, len(r.columns))
for i, f := range r.fields { for i, c := range r.columns {
cols[i] = quoteIdent(f.Name) cols[i] = quoteIdent(c.Name)
vals[i] = "'" + strings.ReplaceAll(f.Value, "'", "''") + "'" vals[i] = "'" + strings.ReplaceAll(c.Value, "'", "''") + "'"
} }
return fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s);", quoteIdent(table), strings.Join(cols, ", "), strings.Join(vals, ", ")) return fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s);", quoteIdent(table), strings.Join(cols, ", "), strings.Join(vals, ", "))
} }
@@ -93,60 +93,46 @@ func quoteIdent(s string) string {
func (f *Generator) Record(path string) (*Record, error) { func (f *Generator) Record(path string) (*Record, error) {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
_, n, tail, err := resolveRef(f.categories, strings.Split(path, ".")) _, n, tail, err := resolveCategory(f.categories, strings.Split(path, "."))
if err != nil { if err != nil {
return nil, fmt.Errorf("fejkdata: %s: %w", path, err) return nil, fmt.Errorf("fejkdata: %s: %w", path, err)
} }
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, ok := n.(*template) t, err := recordOf(n)
if !ok { if err != nil {
return nil, fmt.Errorf("fejkdata: %s names a choice, not a template; only a category-level template is a record", path) return nil, fmt.Errorf("fejkdata: %s %w", path, err)
} }
r := renderRecord(f.rand, t) return renderRecord(f.rand, t), nil
if len(r.fields) == 0 {
return nil, fmt.Errorf("fejkdata: %s has no fields, so no columns", path)
}
return r, nil
} }
// RecordTemplate is an inline record compiled, referenced and validated once, // RecordTemplate is an inline record compiled, referenced and validated once,
// ready to render many times with [RecordTemplate.Fake]. // ready to render many times with [RecordTemplate.Fake].
type RecordTemplate struct { type RecordTemplate struct {
g *Generator g *Generator
n node t *template
} }
// Fake renders the record with one draw. // Fake renders the record with one draw.
func (t *RecordTemplate) Fake() *Record { func (t *RecordTemplate) Fake() *Record {
t.g.mu.Lock() t.g.mu.Lock()
defer t.g.mu.Unlock() defer t.g.mu.Unlock()
return renderRecord(t.g.rand, t.n.(*template)) return renderRecord(t.g.rand, t.t)
} }
// NewRecordTemplate compiles an inline record — a JSON object with a format and // NewRecordTemplate compiles an inline record — a JSON object with a format and
// fields — and binds its references against the loaded tree. // fields — and binds its references against the loaded tree.
func (f *Generator) NewRecordTemplate(input string) (*RecordTemplate, error) { func (f *Generator) NewRecordTemplate(input string) (*RecordTemplate, error) {
n, err := compileInput(input) t, err := f.NewTemplate(input)
if err != nil { if err != nil {
return nil, fmt.Errorf("fejkdata: %w", err) return nil, err
} }
t, ok := n.(*template) rt, err := recordOf(t.n)
if !ok { if err != nil {
return nil, fmt.Errorf("fejkdata: an inline record is a JSON object with a format and fields, not a choice") return nil, fmt.Errorf("fejkdata: an inline record %w", err)
} }
if len(recordColumns(t)) == 0 { return &RecordTemplate{g: f, t: rt}, nil
return nil, fmt.Errorf("fejkdata: an inline record needs at least one field to project as a column")
}
scope := inlineScope(t)
if err := linkNodeRefs(scope, f.categories); err != nil {
return nil, fmt.Errorf("fejkdata: %w", err)
}
if err := checkScope(scope); err != nil {
return nil, fmt.Errorf("fejkdata: %w", err)
}
return &RecordTemplate{g: f, n: t}, nil
} }
// FakeRecord compiles and renders an inline record in one call. // FakeRecord compiles and renders an inline record in one call.
@@ -158,15 +144,32 @@ func (f *Generator) FakeRecord(input string) (*Record, error) {
return t.Fake(), nil return t.Fake(), nil
} }
// recordOf is the fence both record entry points pass: the node is a
// category-level template, it carries no repeat — which composes the format
// rather than projecting columns — and it offers at least one column.
func recordOf(n node) (*template, error) {
t, ok := n.(*template)
if !ok {
return nil, errors.New("names a choice, not a template; only a category-level template is a record")
}
if t.repeat != 1 {
return 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 len(recordColumns(t)) == 0 {
return nil, errors.New("has no fields, so no columns")
}
return t, nil
}
// renderRecord projects a template's direct fields as columns, drawn once each, // renderRecord projects a template's direct fields as columns, drawn once each,
// in name order. A {/path} binding is a render edge, not a column, so it is // in name order. A {/path} binding is a render edge, not a column, so it is
// skipped the same way List and the graph do. The columns share one draw context, // skipped the same way List and the graph do. The columns share one reference
// so two columns that reference one category read one draw of it. // scope, so two columns that reference one category read one draw of it.
func renderRecord(s *session, t *template) *Record { func renderRecord(s *session, t *template) *Record {
shared := &draws{variant: map[string]node{}, value: map[string]string{}} scope := &draws{variant: map[string]node{}, value: map[string]string{}}
r := &Record{} r := &Record{}
for _, name := range recordColumns(t) { for _, name := range recordColumns(t) {
r.fields = append(r.fields, Field{Name: name, Value: renderShared(s, t.fields[name], shared)}) r.columns = append(r.columns, Column{Name: name, Value: render(s, t.fields[name], scope)})
} }
return r return r
} }
@@ -176,11 +179,10 @@ func renderRecord(s *session, t *template) *Record {
// name that is not a reference is a column. // name that is not a reference is a column.
func recordColumns(t *template) []string { func recordColumns(t *template) []string {
var names []string var names []string
for name := range t.fields { for _, name := range sortedNames(t.fields) {
if !isRef(name) { if !isRef(name) {
names = append(names, name) names = append(names, name)
} }
} }
sort.Strings(names)
return names return names
} }
+6 -6
View File
@@ -93,7 +93,7 @@ func linkTemplateRefs(folder []string, path string, t *template, root map[string
if err != nil { if err != nil {
return fmt.Errorf("%s: reference {%s}: %w", path, name, err) return fmt.Errorf("%s: reference {%s}: %w", path, name, err)
} }
head, target, tail, err := resolveRef(root, segments) head, target, tail, err := resolveCategory(root, segments)
if err != nil { if err != nil {
return fmt.Errorf("%s: reference {%s}: %w", path, name, err) return fmt.Errorf("%s: reference {%s}: %w", path, name, err)
} }
@@ -146,11 +146,11 @@ func eachTemplate(root map[string]node, fn func(folder []string, path string, t
return inFolder(nil, root) return inFolder(nil, root)
} }
// resolveRef walks a reference path through the folders to the category it names, // resolveCategory walks a dotted path through the folders to the category it
// returning that head, the node, and the tail left to read into it. A descent of // names, returning that head, the node, and the tail left to read into it. A
// its own rather than a walkPath: it walks groups only and returns where they end, // descent of its own rather than a walkPath: it walks groups only and returns
// not a leaf. // where they end, not a leaf.
func resolveRef(root map[string]node, segments []string) (head []string, target node, tail []string, err error) { func resolveCategory(root map[string]node, segments []string) (head []string, target node, tail []string, err error) {
var n node = &group{children: root} var n node = &group{children: root}
i := 0 i := 0
for ; i < len(segments); i++ { for ; i < len(segments); i++ {
+12 -17
View File
@@ -27,7 +27,7 @@ func (f *Generator) Fake(path string) (string, error) {
if _, ok := n.(*group); ok { if _, ok := n.(*group); ok {
return "", fmt.Errorf("fejkdata: %s names a folder, not a value", path) return "", fmt.Errorf("fejkdata: %s names a folder, not a value", path)
} }
return render(f.rand, n), nil return render(f.rand, n, nil), nil
} }
// descend walks named fields to the node a path names. It is the one render-side // descend walks named fields to the node a path names. It is the one render-side
@@ -50,23 +50,18 @@ func descend(s *session, root node, segments []string) (node, error) {
} }
// render evaluates a compiled node to a string. compile validates every node up // render evaluates a compiled node to a string. compile validates every node up
// front, so rendering a compiled tree cannot fail. // front, so rendering a compiled tree cannot fail. refScope carries the draws a
func render(s *session, n node) string { // reference shares beyond its own expansion; nil keeps every reference local.
return renderShared(s, n, nil) func render(s *session, n node, refScope *draws) string {
}
// renderShared is render with a shared draw context: the draws a record shares
// across its columns; a nil shared is a standalone render.
func renderShared(s *session, n node, shared *draws) string {
switch n := n.(type) { switch n := n.(type) {
case *choice: case *choice:
return renderShared(s, pick(s, n), shared) return render(s, pick(s, n), refScope)
case *template: case *template:
if n.repeat == 1 { if n.repeat == 1 {
if n.fixed { if n.fixed {
return n.lit return n.lit
} }
return expand(s, n, shared) return expand(s, n, refScope)
} }
var b strings.Builder var b strings.Builder
b.Grow(n.repeat * (n.grow + len(n.separator))) b.Grow(n.repeat * (n.grow + len(n.separator)))
@@ -74,7 +69,7 @@ func renderShared(s *session, n node, shared *draws) string {
if i > 0 { if i > 0 {
b.WriteString(n.separator) b.WriteString(n.separator)
} }
b.WriteString(expand(s, n, shared)) b.WriteString(expand(s, n, refScope))
} }
return b.String() return b.String()
default: default:
@@ -96,12 +91,12 @@ func pick(r rng, c *choice) node {
// expand renders a template's compiled ops. compile validated every token, so this // expand renders a template's compiled ops. compile validated every token, so this
// cannot fail. // cannot fail.
func expand(s *session, t *template, shared *draws) string { func expand(s *session, t *template, refScope *draws) string {
var b strings.Builder var b strings.Builder
b.Grow(t.grow) b.Grow(t.grow)
// One draw per held name, for this expansion only: a nested template and each // One draw per held name, for this expansion only: a nested template and each
// repeat iteration get their own, since each is its own expansion. A shared // repeat iteration get their own, since each is its own expansion. A reference
// draw context, when a record supplies one, overrides that for references. // reads the caller's scope instead, whenever one was supplied.
var held *draws var held *draws
if len(t.held) > 0 { if len(t.held) > 0 {
held = &draws{ held = &draws{
@@ -115,7 +110,7 @@ func expand(s *session, t *template, shared *draws) string {
case 'l': case 'l':
b.WriteString(o.lit) b.WriteString(o.lit)
case 'f': case 'f':
b.WriteString(readField(s, t, held, shared, o.arms[s.IntN(len(o.arms))])) b.WriteString(readField(s, t, held, refScope, o.arms[s.IntN(len(o.arms))]))
case 'b': case 'b':
// Read before the call, so the value a calc computes is the value the // Read before the call, so the value a calc computes is the value the
// format showed. calcVars fixed the order op.operands holds. // format showed. calcVars fixed the order op.operands holds.
@@ -123,7 +118,7 @@ func expand(s *session, t *template, shared *draws) string {
if len(o.operands) > 0 { if len(o.operands) > 0 {
operands = make([]string, len(o.operands)) operands = make([]string, len(o.operands))
for j, a := range o.operands { for j, a := range o.operands {
operands[j] = readField(s, t, held, shared, a) operands[j] = readField(s, t, held, refScope, a)
} }
} }
b.WriteString(o.call(s, b.String(), operands)) // b.String() is the output so far b.WriteString(o.call(s, b.String(), operands)) // b.String() is the output so far