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. 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 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 []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 }