From 8cb8cf6334e6161225921ab438c3d01b17fa625e Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 4 Sep 2026 08:40:58 +0200 Subject: [PATCH] Reject a record's top-level repeat, share one record fence between both entry points, and give the renderer one spelling --- cmd/fejkdata/main.go | 107 ++++++++++++++++++++++++----------------- hold.go | 13 ++--- inline.go | 2 +- record.go | 110 ++++++++++++++++++++++--------------------- reference.go | 12 ++--- render.go | 29 +++++------- 6 files changed, 146 insertions(+), 127 deletions(-) diff --git a/cmd/fejkdata/main.go b/cmd/fejkdata/main.go index 0e6fbd9..c3163fb 100644 --- a/cmd/fejkdata/main.go +++ b/cmd/fejkdata/main.go @@ -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 diff --git a/hold.go b/hold.go index ef96774..0c7d6b5 100644 --- a/hold.go +++ b/hold.go @@ -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 diff --git a/inline.go b/inline.go index f68ca29..91f6686 100644 --- a/inline.go +++ b/inline.go @@ -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 diff --git a/record.go b/record.go index be485ba..3eadb16 100644 --- a/record.go +++ b/record.go @@ -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 } diff --git a/reference.go b/reference.go index 7744474..ff26770 100644 --- a/reference.go +++ b/reference.go @@ -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++ { diff --git a/render.go b/render.go index 3dabd45..7a7af3a 100644 --- a/render.go +++ b/render.go @@ -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