317 lines
9.6 KiB
Go
317 lines
9.6 KiB
Go
package fejkdata
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// Column is one rendered column of a record. Value is the rendered text, which a
|
|
// serializer quotes for DataTypeString and writes bare for any other datatype; a Null
|
|
// column has no Value.
|
|
type Column struct {
|
|
Name string
|
|
DataType DataType
|
|
Value string
|
|
Null bool
|
|
}
|
|
|
|
// 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.
|
|
func (r *Record) JSON() string {
|
|
var b strings.Builder
|
|
b.WriteByte('{')
|
|
for i, c := range r.columns {
|
|
if i > 0 {
|
|
b.WriteByte(',')
|
|
}
|
|
b.WriteString(jsonString(c.Name))
|
|
b.WriteByte(':')
|
|
b.WriteString(literal(c, jsonString, "null"))
|
|
}
|
|
b.WriteByte('}')
|
|
return b.String()
|
|
}
|
|
|
|
func jsonString(s string) string {
|
|
b, _ := json.Marshal(s)
|
|
return string(b)
|
|
}
|
|
|
|
// CSVHeader renders the column names as one CSV header line.
|
|
func (r *Record) CSVHeader() string {
|
|
fields := make([]string, len(r.columns))
|
|
for i, c := range r.columns {
|
|
fields[i] = csvField(c.Name)
|
|
}
|
|
return strings.Join(fields, ",")
|
|
}
|
|
|
|
// CSVLine renders the column values as one CSV row: a null column an empty field and an
|
|
// empty string "", the convention PostgreSQL's COPY reads a null by. A record of one null
|
|
// column is a blank line, which COPY reads as null and most CSV readers skip.
|
|
func (r *Record) CSVLine() string {
|
|
fields := make([]string, len(r.columns))
|
|
for i, c := range r.columns {
|
|
fields[i] = literal(c, csvField, "")
|
|
}
|
|
return strings.Join(fields, ",")
|
|
}
|
|
|
|
// csvField quotes a field where encoding/csv would, and an empty one too.
|
|
func csvField(s string) string {
|
|
first, _ := utf8.DecodeRuneInString(s)
|
|
switch {
|
|
case s == "":
|
|
return `""`
|
|
case s == `\.` || strings.ContainsAny(s, "\",\r\n") || unicode.IsSpace(first):
|
|
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
|
|
}
|
|
return s
|
|
}
|
|
|
|
// SQLInsert renders the record as one INSERT statement into table, identifiers in ANSI
|
|
// double quotes.
|
|
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] = literal(c, sqlString, "NULL")
|
|
}
|
|
return fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s);", quoteIdent(table), strings.Join(cols, ", "), strings.Join(vals, ", "))
|
|
}
|
|
|
|
func sqlString(s string) string {
|
|
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
|
|
}
|
|
|
|
func quoteIdent(s string) string {
|
|
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
|
|
}
|
|
|
|
// literal writes a string column quoted, a proven typed one bare, and a null as nullText.
|
|
func literal(c Column, quote func(string) string, nullText string) string {
|
|
switch {
|
|
case c.Null:
|
|
return nullText
|
|
case c.DataType == DataTypeString:
|
|
return quote(c.Value)
|
|
}
|
|
return c.Value
|
|
}
|
|
|
|
// FakeRecord 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) FakeRecord(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 []Column
|
|
err error
|
|
}
|
|
|
|
// recordShapeOf fences a node once and remembers the answer. 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 []Column
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// FakeRecordTemplate compiles and renders an inline record in one call.
|
|
func (f *Generator) FakeRecordTemplate(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, []Column, 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")
|
|
}
|
|
names := recordColumns(t)
|
|
if len(names) == 0 {
|
|
return nil, nil, errors.New("has no fields, so no columns")
|
|
}
|
|
if !t.record {
|
|
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)
|
|
}
|
|
if err := checkColumnRefs(t, names); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
columns := make([]Column, len(names))
|
|
for i, name := range names {
|
|
datatype, _ := columnDatatype(t.fields[name]) // checkColumns refused items that disagree wherever DataType is read
|
|
columns[i] = Column{Name: name, DataType: datatype}
|
|
}
|
|
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 that reads a path, anywhere a column renders,
|
|
// following the same edges expand does. A reference landing back on the record
|
|
// itself is reported rather than collected, whether it reads a path or the record
|
|
// whole: either way the column describes a draw other than its neighbours'.
|
|
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 _, ref := range refTokens(tm.format) {
|
|
a := splitArm(ref, tm.refs)
|
|
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 []Column) *Record {
|
|
scope := &draws{variant: map[string]node{}, value: map[string]draw{}}
|
|
r := &Record{columns: append([]Column(nil), columns...)}
|
|
for i := range r.columns {
|
|
column := renderLeaf(s, t.fields[r.columns[i].Name], scope)
|
|
r.columns[i].Value, r.columns[i].Null = column.text, column.null
|
|
}
|
|
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
|
|
}
|