290 lines
8.6 KiB
Go
290 lines
8.6 KiB
Go
package fejkdata
|
|
|
|
import (
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// Column is one rendered column of a record.
|
|
type Column struct {
|
|
Name string
|
|
Value string
|
|
}
|
|
|
|
// 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
|
|
// local to it, while a reference that reads a path is drawn once for the whole
|
|
// record.
|
|
type Record struct {
|
|
columns []Column
|
|
}
|
|
|
|
// 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.columns))
|
|
for _, c := range r.columns {
|
|
m[c.Name] = c.Value
|
|
}
|
|
b, _ := json.Marshal(m)
|
|
return string(b)
|
|
}
|
|
|
|
// CSVHeader renders the column names as one CSV header line.
|
|
func (r *Record) CSVHeader() string {
|
|
return csvLine(r.names())
|
|
}
|
|
|
|
// CSVLine renders the column values as one CSV row.
|
|
func (r *Record) CSVLine() string {
|
|
return csvLine(r.values())
|
|
}
|
|
|
|
func (r *Record) names() []string {
|
|
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.columns))
|
|
for i, c := range r.columns {
|
|
out[i] = c.Value
|
|
}
|
|
return out
|
|
}
|
|
|
|
func csvLine(cols []string) string {
|
|
var b strings.Builder
|
|
w := csv.NewWriter(&b)
|
|
_ = w.Write(cols)
|
|
w.Flush()
|
|
line := strings.TrimSuffix(b.String(), "\n")
|
|
if line == "" {
|
|
return `""` // a blank line is a row every CSV reader drops
|
|
}
|
|
return line
|
|
}
|
|
|
|
// 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.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, ", "))
|
|
}
|
|
|
|
func quoteIdent(s string) string {
|
|
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
|
|
}
|
|
|
|
// Record renders a path as one record: the template it names, with each direct
|
|
// field drawn as a column. Only a category-level template is a record — a path
|
|
// that descends into a field, or that names a folder or a choice, is an error.
|
|
func (f *Generator) Record(path string) (*Record, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
_, 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])
|
|
}
|
|
shape := f.recordShapeOf(n)
|
|
if shape.err != nil {
|
|
return nil, fmt.Errorf("fejkdata: %s %w", path, shape.err)
|
|
}
|
|
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,
|
|
// ready to render many times with [RecordTemplate.Fake].
|
|
type RecordTemplate struct {
|
|
g *Generator
|
|
t *template
|
|
columns []string
|
|
}
|
|
|
|
// 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.t, t.columns)
|
|
}
|
|
|
|
// 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) {
|
|
t, err := f.NewTemplate(input)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tm, columns, err := recordOf(t.n)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fejkdata: an inline record %w", err)
|
|
}
|
|
return &RecordTemplate{g: f, t: tm, columns: columns}, nil
|
|
}
|
|
|
|
// FakeRecord compiles and renders an inline record in one call.
|
|
func (f *Generator) FakeRecord(input string) (*Record, error) {
|
|
t, err := f.NewRecordTemplate(input)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return t.Fake(), nil
|
|
}
|
|
|
|
// recordOf is the fence both record entry points pass. The columns come back with
|
|
// the template, fixed for every draw the caller goes on to make.
|
|
func recordOf(n node) (*template, []string, error) {
|
|
t, ok := n.(*template)
|
|
if !ok {
|
|
return nil, nil, errors.New("names a choice, not a template; a record is a template whose fields are its columns")
|
|
}
|
|
if t.repeat != 1 {
|
|
return nil, 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)
|
|
}
|
|
columns := recordColumns(t)
|
|
if len(columns) == 0 {
|
|
return nil, nil, errors.New("has no fields, so no columns")
|
|
}
|
|
if err := checkColumnRefs(t, columns); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return t, columns, nil
|
|
}
|
|
|
|
// checkColumnRefs rejects the reference reads a record's shared draw cannot answer
|
|
// for: one column rendering a level another reads a path into, and a column
|
|
// reading the record back through its own path.
|
|
func checkColumnRefs(t *template, columns []string) error {
|
|
reads, err := columnRefs(t, columns)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sort.Slice(reads, func(i, j int) bool {
|
|
if reads[i].a.path != reads[j].a.path {
|
|
return reads[i].a.path < reads[j].a.path
|
|
}
|
|
return reads[i].column < reads[j].column
|
|
})
|
|
for i, level := range reads {
|
|
for _, into := range reads[i+1:] {
|
|
if strings.HasPrefix(into.a.path, level.a.path+".") {
|
|
return fmt.Errorf("column %q renders {%s}, a level column %q reads a path into with {%s}; name the fields you want instead",
|
|
level.column, level.a.name, into.column, into.a.name)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// columnRef is one held reference read, and the column whose render reaches it.
|
|
type columnRef struct {
|
|
column string
|
|
a arm
|
|
}
|
|
|
|
// columnRefs lists every reference read that reads a path, anywhere a column
|
|
// renders, following the same edges expand does. A read that lands back on the
|
|
// 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 err error
|
|
for _, name := range columns {
|
|
seen := map[node]bool{}
|
|
var walk func(n node)
|
|
walk = func(n node) {
|
|
if n == nil || seen[n] || err != nil {
|
|
return
|
|
}
|
|
seen[n] = true
|
|
if tm, ok := n.(*template); ok {
|
|
for _, r := range boundReaders(tm.format, tm.bound, tm.refs) {
|
|
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})
|
|
}
|
|
}
|
|
}
|
|
for _, e := range renderEdges(n) {
|
|
walk(e.to)
|
|
}
|
|
}
|
|
walk(t.fields[name])
|
|
}
|
|
return out, err
|
|
}
|
|
|
|
// renderRecord draws each column once, in the name order recordOf fixed, over one
|
|
// reference scope shared across them.
|
|
func renderRecord(s *session, t *template, columns []string) *Record {
|
|
scope := &draws{variant: map[string]node{}, value: map[string]string{}}
|
|
r := &Record{columns: make([]Column, len(columns))}
|
|
for i, name := range columns {
|
|
r.columns[i] = Column{Name: name, Value: render(s, t.fields[name], scope)}
|
|
}
|
|
return r
|
|
}
|
|
|
|
// recordColumns is the sorted non-reference field names — the columns a record
|
|
// projects. A {/path} binding is carried in fields under its root path, so only a
|
|
// name that is not a reference is a column.
|
|
func recordColumns(t *template) []string {
|
|
var names []string
|
|
for _, name := range sortedNames(t.fields) {
|
|
if !isRef(name) {
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
return names
|
|
}
|