Add record output to JSON, CSV and SQL #8
+64
-43
@@ -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, err := in.draw(f, kind, in.paths[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
draw := func() (string, error) { return f.Fake(arg) }
|
||||
if kind == argTemplate {
|
||||
t, err := f.NewTemplate(arg)
|
||||
if err != nil {
|
||||
return templateError{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())
|
||||
}
|
||||
fmt.Fprintln(out, r.CSVLine())
|
||||
case "sql":
|
||||
fmt.Fprintln(out, r.SQLInsert(table))
|
||||
line := format.line(r, table)
|
||||
if first && format.header != nil {
|
||||
line = format.header(r) + "\n" + line
|
||||
}
|
||||
}
|
||||
return out.Flush()
|
||||
first = false
|
||||
return line, nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
// defaultTable names the INSERT target when --table is absent: the path's last
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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++ {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user