Compare commits
2 Commits
83e11074ac
...
c612c9e734
| Author | SHA1 | Date | |
|---|---|---|---|
| c612c9e734 | |||
| be5dfdf977 |
@@ -125,9 +125,13 @@ required key — so `"format": ""` carries the fields with an inert format: it
|
||||
renders nothing by `Fake`, and is compiled only so the tree's fences still run.
|
||||
The columns are the point, and their facts stay together: two columns that
|
||||
reference one category — `{/currency.code}` and `{/currency.symbol}` — share one
|
||||
draw of it, so the record is internally consistent. A field hold, transform or
|
||||
operand ties fields together within one column as always (see
|
||||
[Correlated fields](#correlated-fields) and [Decisions](#decisions)).
|
||||
draw of it, so the record is internally consistent. That one draw is also why two
|
||||
columns may not read overlapping reference paths — `{/cat.a}` beside `{/cat.a.b}`
|
||||
is refused, naming the fields to write instead, exactly as
|
||||
[One draw, one spelling](#one-draw-one-spelling) refuses the pair inside a single
|
||||
format. A field hold, transform or operand ties fields together within one column
|
||||
as always (see [Correlated fields](#correlated-fields) and
|
||||
[Decisions](#decisions)).
|
||||
|
||||
## Library
|
||||
|
||||
|
||||
@@ -216,9 +216,7 @@ type recordFormat struct {
|
||||
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.
|
||||
// recordFormats is every --format that writes records.
|
||||
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() }},
|
||||
@@ -245,6 +243,9 @@ func (in invocation) checkFlags() error {
|
||||
if _, ok := recordFormats[in.format]; !ok && in.format != "text" {
|
||||
return fmt.Errorf("--format takes %s, got %q", formatNames(), in.format)
|
||||
}
|
||||
if in.tableSet && in.table == "" {
|
||||
return errors.New("--table names the INSERT target, so it cannot be empty")
|
||||
}
|
||||
if in.tableSet && in.format != "sql" {
|
||||
return errors.New("--table names the INSERT target, so it needs --format sql")
|
||||
}
|
||||
|
||||
@@ -535,6 +535,7 @@ func TestRunRecordMisuse(t *testing.T) {
|
||||
{[]string{"--format", "json", "--separator", ",", "users"}, "--separator joins text values"},
|
||||
{[]string{"--table", "t", "users"}, "--table names the INSERT target"},
|
||||
{[]string{"--format", "json", "--table", "t", "users"}, "--table names the INSERT target"},
|
||||
{[]string{"--format", "sql", "--table", "", "users"}, "--table names the INSERT target"},
|
||||
} {
|
||||
code, out, errb := runOut(c.args...)
|
||||
if code != 2 || out != "" || !strings.Contains(errb, c.want) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -15,9 +16,8 @@ type Column struct {
|
||||
}
|
||||
|
||||
// Record is one record rendered from a template: every direct field is a column,
|
||||
// drawn independently and listed in name order. The template's format is the
|
||||
// string a [Generator.Fake] call renders; a record is its inverse — each field
|
||||
// projected as a column instead of composed.
|
||||
// listed in name order. Each column is its own expansion, so a sibling field is
|
||||
// local to it, while a reference is drawn once for the whole record.
|
||||
type Record struct {
|
||||
columns []Column
|
||||
}
|
||||
@@ -68,7 +68,11 @@ func csvLine(cols []string) string {
|
||||
w := csv.NewWriter(&b)
|
||||
_ = w.Write(cols)
|
||||
w.Flush()
|
||||
return strings.TrimSuffix(b.String(), "\n")
|
||||
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
|
||||
@@ -145,10 +149,8 @@ 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 template, it
|
||||
// carries no repeat — which composes the format rather than projecting columns —
|
||||
// and it offers at least one column. The columns come back with it, fixed for
|
||||
// every draw the caller goes on to make.
|
||||
// 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 {
|
||||
@@ -161,9 +163,71 @@ func recordOf(n node) (*template, []string, error) {
|
||||
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 two reference reads that overlap across a record's
|
||||
// columns: one renders a level the other reads a path into, and the record's one
|
||||
// draw of that level cannot answer for both. checkNoOverlap settles the pair
|
||||
// within a single format; the record's shared scope is what carries it across
|
||||
// columns, so the same pair is settled here. A bare reference holds nothing, so it
|
||||
// is not collected and keeps drawing on its own.
|
||||
func checkColumnRefs(t *template, columns []string) error {
|
||||
reads := columnRefs(t, columns)
|
||||
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 held reference read anywhere a column renders, following
|
||||
// the same edges expand does.
|
||||
func columnRefs(t *template, columns []string) []columnRef {
|
||||
var out []columnRef
|
||||
for _, name := range columns {
|
||||
seen := map[node]bool{}
|
||||
var walk func(n node)
|
||||
walk = func(n node) {
|
||||
if n == nil || seen[n] {
|
||||
return
|
||||
}
|
||||
seen[n] = true
|
||||
if tm, ok := n.(*template); ok {
|
||||
for _, r := range boundReaders(tm.format, tm.bound, tm.refs) {
|
||||
if a := splitArm(r.name, tm.refs); isRef(a.key) && len(a.tail) > 0 {
|
||||
out = append(out, columnRef{name, a})
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, e := range renderEdges(n) {
|
||||
walk(e.to)
|
||||
}
|
||||
}
|
||||
walk(t.fields[name])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderRecord draws each column once, in the name order recordOf fixed. The
|
||||
// columns share one reference scope, so two columns that reference one category
|
||||
// read one draw of it.
|
||||
|
||||
+46
-8
@@ -108,19 +108,57 @@ func TestRecordCSV(t *testing.T) {
|
||||
"note": `{"format": "{word}, {word}", "word": ["a", "b,c", "d\"e", "f\n"]}`,
|
||||
})
|
||||
f := newGenerator(t, dir, WithSeed(1))
|
||||
r, err := f.Record("note")
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 200 && len(seen) < 4; i++ {
|
||||
r, err := f.Record("note")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := r.CSVHeader(); got != "word" {
|
||||
t.Fatalf("CSVHeader() = %q, want word", got)
|
||||
}
|
||||
rec, err := csv.NewReader(strings.NewReader(r.CSVLine() + "\n")).Read()
|
||||
if err != nil {
|
||||
t.Fatalf("CSVLine() is not valid CSV: %v\n%q", err, r.CSVLine())
|
||||
}
|
||||
want := r.Columns()[0].Value
|
||||
if len(rec) != 1 || rec[0] != want {
|
||||
t.Fatalf("CSVLine() = %v, want the field value %q round-tripped", rec, want)
|
||||
}
|
||||
seen[want] = true
|
||||
}
|
||||
if len(seen) != 4 {
|
||||
t.Fatalf("round-tripped %d of the 4 values; the comma, quote and newline shapes must each survive", len(seen))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordCSVEmptyValueStaysARow(t *testing.T) {
|
||||
dir := writeData(t, map[string]string{"blank": `{"format": "", "note": ""}`})
|
||||
f := newGenerator(t, dir, WithSeed(1))
|
||||
r, err := f.Record("blank")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := r.CSVHeader(); got != "word" {
|
||||
t.Errorf("CSVHeader() = %q, want word", got)
|
||||
}
|
||||
rec, err := csv.NewReader(strings.NewReader(r.CSVLine() + "\n")).Read()
|
||||
rows, err := csv.NewReader(strings.NewReader(r.CSVHeader() + "\n" + r.CSVLine() + "\n")).ReadAll()
|
||||
if err != nil {
|
||||
t.Fatalf("CSVLine() is not valid CSV: %v\n%q", err, r.CSVLine())
|
||||
t.Fatalf("csv: %v", err)
|
||||
}
|
||||
if len(rec) != 1 || rec[0] != r.Columns()[0].Value {
|
||||
t.Fatalf("CSVLine() = %v, want the field value round-tripped", rec)
|
||||
if len(rows) != 2 || len(rows[1]) != 1 || rows[1][0] != "" {
|
||||
t.Fatalf("one empty column parsed to %v, want a header and one row of one empty field", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordRejectsOverlappingReferenceColumns(t *testing.T) {
|
||||
dir := writeData(t, map[string]string{
|
||||
"cat": `{"format":"","a":[{"format":"A={b}","b":"1"},{"format":"A={b}","b":"2"}]}`,
|
||||
"row": `{"format":"","whole":"{/cat.a}","inner":"{/cat.a.b}"}`,
|
||||
})
|
||||
f := newGenerator(t, dir, WithSeed(1))
|
||||
if _, err := f.Record("row"); err == nil || !strings.Contains(err.Error(), "reads a path into") {
|
||||
t.Fatalf("Record over an overlapping reference pair = %v, want it rejected the way one format is", err)
|
||||
}
|
||||
if _, err := f.Fake("row"); err != nil {
|
||||
t.Errorf("Fake(row) = %v, want the string view untouched", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user