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
+63 -42
View File
@@ -15,6 +15,7 @@ import (
"io"
"os"
"runtime/debug"
"sort"
"strconv"
"strings"
@@ -208,20 +209,41 @@ func parseArgs(argv []string) (invocation, error) {
return in, nil
}
// recordFormat reports whether the format names a record output (json, csv or
// sql) rather than the default text.
// recordFormat is one way to write a record out: the line it renders, and the
// 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 {
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.
func (in invocation) checkFlags() error {
if in.formatSet {
switch in.format {
case "text", "json", "csv", "sql":
default:
return fmt.Errorf("--format takes text, json, csv or sql, got %q", in.format)
}
if _, ok := recordFormats[in.format]; !ok && in.format != "text" {
return fmt.Errorf("--format takes %s, got %q", formatNames(), in.format)
}
if in.tableSet && in.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
// from Flush, bufio keeping the first one.
func (in invocation) write(f *fejkdata.Generator, kind argKind, w io.Writer) error {
arg := in.paths[0]
if in.recordFormat() {
return in.writeRecords(f, kind, arg, w)
}
draw := func() (string, error) { return f.Fake(arg) }
if kind == argTemplate {
t, err := f.NewTemplate(arg)
draw, err := in.draw(f, kind, in.paths[0])
if err != nil {
return templateError{err}
return err
}
draw = func() (string, error) { return t.Fake(), nil }
separator := in.separator
if in.recordFormat() {
separator = "\n"
}
out := bufio.NewWriter(w)
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
}
if i > 0 {
out.WriteString(in.separator)
out.WriteString(separator)
}
out.WriteString(v)
}
@@ -299,40 +317,43 @@ func (in invocation) write(f *fejkdata.Generator, kind argKind, w io.Writer) err
return out.Flush()
}
// writeRecords streams a record per line in the chosen format: one JSON object
// per line, a CSV header then a row per line, or one INSERT per line.
func (in invocation) writeRecords(f *fejkdata.Generator, kind argKind, arg string, w io.Writer) error {
draw := func() (*fejkdata.Record, error) { return f.Record(arg) }
// draw builds what one render yields: the value's text, or the record's line in
// the chosen format, the header carried ahead of the first one.
func (in invocation) draw(f *fejkdata.Generator, kind argKind, arg string) (func() (string, error), error) {
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 {
t, err := f.NewRecordTemplate(arg)
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)
table := in.table
format, table, first := recordFormats[in.format], in.table, true
if table == "" {
table = defaultTable(arg, kind)
}
for i := 0; i < in.repeat; i++ {
r, err := draw()
return func() (string, error) {
r, err := record()
if err != nil {
return err
return "", err
}
switch in.format {
case "json":
fmt.Fprintln(out, r.JSON())
case "csv":
if i == 0 {
fmt.Fprintln(out, r.CSVHeader())
line := format.line(r, table)
if first && format.header != nil {
line = format.header(r) + "\n" + line
}
fmt.Fprintln(out, r.CSVLine())
case "sql":
fmt.Fprintln(out, r.SQLInsert(table))
}
}
return out.Flush()
first = false
return line, nil
}, nil
}
// 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
// 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, shared *draws, a arm) string {
func readField(s *session, t *template, held, refScope *draws, a arm) string {
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 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
if isRef(a.key) && shared != nil {
d = shared
if isRef(a.key) && refScope != nil {
d = refScope
}
if v, read := d.value[a.path]; read {
return v
@@ -298,7 +299,7 @@ func readField(s *session, t *template, held, shared *draws, a arm) string {
}
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
return v
+1 -1
View File
@@ -20,7 +20,7 @@ type Template struct {
func (t *Template) Fake() string {
t.g.mu.Lock()
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
+56 -54
View File
@@ -3,13 +3,13 @@ package fejkdata
import (
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
)
// Field is one rendered column of a record.
type Field struct {
// Column is one rendered column of a record.
type Column struct {
Name string
Value string
}
@@ -19,19 +19,19 @@ type Field struct {
// string a [Generator.Fake] call renders; a record is its inverse — each field
// projected as a column instead of composed.
type Record struct {
fields []Field
columns []Column
}
// Fields returns the record's columns in name order.
func (r *Record) Fields() []Field {
return append([]Field(nil), r.fields...)
// Columns returns the record's columns in name order.
func (r *Record) Columns() []Column {
return append([]Column(nil), r.columns...)
}
// JSON renders the record as one JSON object, every column a string.
func (r *Record) JSON() string {
m := make(map[string]string, len(r.fields))
for _, f := range r.fields {
m[f.Name] = f.Value
m := make(map[string]string, len(r.columns))
for _, c := range r.columns {
m[c.Name] = c.Value
}
b, _ := json.Marshal(m)
return string(b)
@@ -48,17 +48,17 @@ func (r *Record) CSVLine() string {
}
func (r *Record) names() []string {
out := make([]string, len(r.fields))
for i, f := range r.fields {
out[i] = f.Name
out := make([]string, len(r.columns))
for i, c := range r.columns {
out[i] = c.Name
}
return out
}
func (r *Record) values() []string {
out := make([]string, len(r.fields))
for i, f := range r.fields {
out[i] = f.Value
out := make([]string, len(r.columns))
for i, c := range r.columns {
out[i] = c.Value
}
return out
}
@@ -74,11 +74,11 @@ func csvLine(cols []string) string {
// SQLInsert renders the record as one INSERT statement into table: identifiers in
// ANSI double quotes, every value a single-quoted string literal.
func (r *Record) SQLInsert(table string) string {
cols := make([]string, len(r.fields))
vals := make([]string, len(r.fields))
for i, f := range r.fields {
cols[i] = quoteIdent(f.Name)
vals[i] = "'" + strings.ReplaceAll(f.Value, "'", "''") + "'"
cols := make([]string, len(r.columns))
vals := make([]string, len(r.columns))
for i, c := range r.columns {
cols[i] = quoteIdent(c.Name)
vals[i] = "'" + strings.ReplaceAll(c.Value, "'", "''") + "'"
}
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) {
f.mu.Lock()
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 {
return nil, fmt.Errorf("fejkdata: %s: %w", path, err)
}
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])
}
t, ok := n.(*template)
if !ok {
return nil, fmt.Errorf("fejkdata: %s names a choice, not a template; only a category-level template is a record", path)
t, err := recordOf(n)
if err != nil {
return nil, fmt.Errorf("fejkdata: %s %w", path, err)
}
r := renderRecord(f.rand, t)
if len(r.fields) == 0 {
return nil, fmt.Errorf("fejkdata: %s has no fields, so no columns", path)
}
return r, nil
return renderRecord(f.rand, t), nil
}
// RecordTemplate is an inline record compiled, referenced and validated once,
// ready to render many times with [RecordTemplate.Fake].
type RecordTemplate struct {
g *Generator
n node
t *template
}
// Fake renders the record with one draw.
func (t *RecordTemplate) Fake() *Record {
t.g.mu.Lock()
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
// fields — and binds its references against the loaded tree.
func (f *Generator) NewRecordTemplate(input string) (*RecordTemplate, error) {
n, err := compileInput(input)
t, err := f.NewTemplate(input)
if err != nil {
return nil, fmt.Errorf("fejkdata: %w", err)
return nil, err
}
t, ok := n.(*template)
if !ok {
return nil, fmt.Errorf("fejkdata: an inline record is a JSON object with a format and fields, not a choice")
rt, err := recordOf(t.n)
if err != nil {
return nil, fmt.Errorf("fejkdata: an inline record %w", err)
}
if len(recordColumns(t)) == 0 {
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
return &RecordTemplate{g: f, t: rt}, nil
}
// 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
}
// 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,
// 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,
// so two columns that reference one category read one draw of it.
// skipped the same way List and the graph do. The columns share one reference
// scope, so two columns that reference one category read one draw of it.
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{}
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
}
@@ -176,11 +179,10 @@ func renderRecord(s *session, t *template) *Record {
// name that is not a reference is a column.
func recordColumns(t *template) []string {
var names []string
for name := range t.fields {
for _, name := range sortedNames(t.fields) {
if !isRef(name) {
names = append(names, name)
}
}
sort.Strings(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 {
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 {
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)
}
// resolveRef walks a reference path through the folders to the category it names,
// returning that head, the node, and the tail left to read into it. A descent of
// its own rather than a walkPath: it walks groups only and returns where they end,
// not a leaf.
func resolveRef(root map[string]node, segments []string) (head []string, target node, tail []string, err error) {
// resolveCategory walks a dotted path through the folders to the category it
// names, returning that head, the node, and the tail left to read into it. A
// descent of its own rather than a walkPath: it walks groups only and returns
// where they end, not a leaf.
func resolveCategory(root map[string]node, segments []string) (head []string, target node, tail []string, err error) {
var n node = &group{children: root}
i := 0
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 {
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
@@ -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
// front, so rendering a compiled tree cannot fail.
func render(s *session, n node) string {
return renderShared(s, n, nil)
}
// 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 {
// front, so rendering a compiled tree cannot fail. refScope carries the draws a
// reference shares beyond its own expansion; nil keeps every reference local.
func render(s *session, n node, refScope *draws) string {
switch n := n.(type) {
case *choice:
return renderShared(s, pick(s, n), shared)
return render(s, pick(s, n), refScope)
case *template:
if n.repeat == 1 {
if n.fixed {
return n.lit
}
return expand(s, n, shared)
return expand(s, n, refScope)
}
var b strings.Builder
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 {
b.WriteString(n.separator)
}
b.WriteString(expand(s, n, shared))
b.WriteString(expand(s, n, refScope))
}
return b.String()
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
// cannot fail.
func expand(s *session, t *template, shared *draws) string {
func expand(s *session, t *template, refScope *draws) string {
var b strings.Builder
b.Grow(t.grow)
// 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
// draw context, when a record supplies one, overrides that for references.
// repeat iteration get their own, since each is its own expansion. A reference
// reads the caller's scope instead, whenever one was supplied.
var held *draws
if len(t.held) > 0 {
held = &draws{
@@ -115,7 +110,7 @@ func expand(s *session, t *template, shared *draws) string {
case 'l':
b.WriteString(o.lit)
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':
// Read before the call, so the value a calc computes is the value the
// 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 {
operands = make([]string, len(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