Compare commits

...

6 Commits

16 changed files with 261 additions and 39 deletions
+17 -8
View File
@@ -34,15 +34,24 @@ replacement, and each removed path, column or flag.
`en_US.address` read those records, so `en_US.address.street` no longer carries `en_US.address` read those records, so `en_US.address.street` no longer carries
`name` and `suffix`, and a locale folder loads only beside `geo`. `name` and `suffix`, and a locale folder loads only beside `geo`.
- `{date(from,to,'layout')}` and `{time('layout')}`: a second between two days, or - `{date(from,to,'layout')}` and `{time('layout')}`: a second between two days, or
within one, in a quoted Go layout. `sv_SE.date`, `en_US.date`, `sv_SE.time` and within one, in a single-quoted Go layout, drawn in UTC; `from` may equal `to`.
`en_US.time` render through them, so `date.year`, `date.month`, `date.day` and `sv_SE.date`, `en_US.date`, `sv_SE.time` and `en_US.time` render through them, so
`time.hour` are no longer paths, and `misc.datetime` is an RFC 3339 instant. `date.year`, `date.month`, `date.day`, `time.hour`, `time.minute`, `time.minute.t`,
`time.sec` and `time.ampm` are no longer paths — a part of a date is now its own
`{date(…,'2006')}`. `misc.datetime` is an RFC 3339 instant.
- `sex`, `first-name` and `last-name` tables in `sv_SE` and `en_US`, weighted by - `sex`, `first-name` and `last-name` tables in `sv_SE` and `en_US`, weighted by
bearers from SCB, the SSA and the Census Bureau; `first-name` links to `sex`, and a bearers from SCB, the SSA and the Census Bureau; `first-name` links to `sex`, and a
name both sexes carry is a row under each. `person` reads them, so its columns are name both sexes carry is a row under each. `person` reads them, so its columns are
`first`, `last`, `prefix` and `sex`, and `person.femalefirst` and `first`, `last`, `prefix` and `sex`; `person.femalefirst` and `person.malefirst` are
`person.malefirst` are no longer paths. no longer paths — draw `sex[f].first-name` and `sex[m].first-name` instead.
`en_US.title` links to `sex` as well, so `en_US.person.prefix` draws `Mr` or `Ms`
without contradicting the record's `sex`.
- `sv_SE.personnummer` and `sv_SE.samordningsnummer`, Skatteverket's test series - `sv_SE.personnummer` and `sv_SE.samordningsnummer`, Skatteverket's test series
from a `sv_SE.birth-number` table under `sex`, in place of `sv_SE.ssn`; `en_US.ssn` from a `sv_SE.birth-number` table under `sex`, in place of `sv_SE.ssn`, whose
in the ranges the SSA assigns, and `en_US.itin`. `en_US.person.prefix` no longer `ssn.mmdd`, `ssn.mmdd.m` and `ssn.mmdd.d` go with it. `en_US.ssn` now draws the
draws `Mr`, `Mrs`, `Ms` or `Miss`, which could contradict the record's `sex`. ranges the SSA assigns and carries the columns `area`, `group` and `serial`, so
`--format csv en_US.ssn` writes a header where it used to fail; `en_US.itin` is new.
- An error names a spelling that runs: a layout is named single-quoted and free of
its own quotes, a row of a table with no key is named as the path that selects it,
`sv_SE.sex[f].first-name[Kim]`, and a category with no columns names the record
that gives it one.
+35 -5
View File
@@ -22,6 +22,7 @@ fejkdata 'geo.SE.locality[Lund].street' # Fjelievägen — a linked tabl
fejkdata --data-path ./mydata sv_SE.word # layer a directory over the shipped data fejkdata --data-path ./mydata sv_SE.word # layer a directory over the shipped data
fejkdata --no-shipped-data -d ./mydata --list # only your data fejkdata --no-shipped-data -d ./mydata --list # only your data
fejkdata 'name: {/sv_SE.person.last}' # name: <a surname> — an inline template fejkdata 'name: {/sv_SE.person.last}' # name: <a surname> — an inline template
fejkdata "{date(1990-01-01,2010-12-31,'2006-01-02')}" # 2003-11-27 — the argument in "…", the layout in '…'
fejkdata '{"format":"name: {x}","x":["bosse","lina"]}' # name: bosse or name: lina fejkdata '{"format":"name: {x}","x":["bosse","lina"]}' # name: bosse or name: lina
``` ```
@@ -237,7 +238,16 @@ draws a woman's name, and a name both sexes carry is a row under each, so
`person` reads one draw of the three, so its `first` and `sex` columns agree, and so `person` reads one draw of the three, so its `first` and `sex` columns agree, and so
does a `personnummer` in the same render: its birth number, `sv_SE.birth-number` does a `personnummer` in the same render: its birth number, `sv_SE.birth-number`
under `sex`, is Skatteverket's test series, 238 for a woman and 239 for a man, which no under `sex`, is Skatteverket's test series, 238 for a woman and 239 for a man, which no
real person is ever given. real person is ever given. `en_US.title` links to `sex` too, so a person's prefix
never contradicts it.
A person of a chosen sex is assembled from the tables — `sex[f].first-name` beside
`last-name` — while a shipped `personnummer` agrees with the sex its own render
*drew*, not with one a path selects. That test series is also small: a personnummer
is one of about 70,000 values, a day in 1930–2025 against the two birth numbers, so a
fixture past a few hundred rows repeats one and a `UNIQUE` column needs a category of
your own. `sv_SE.date` and `en_US.date` are uniform over 1970-01-01 to 2029-12-31,
`misc.datetime` over 2000-01-01 to 2029-12-31.
A `geo` folder holds one tree per country under its alpha-2 code: five A `geo` folder holds one tree per country under its alpha-2 code: five
[linked tables](#linked-tables) named alike, and an `address` record over one [linked tables](#linked-tables) named alike, and an `address` record over one
@@ -536,12 +546,14 @@ them a birthdate:
Renders e.g. `811218-2389`. A layout is Go's: the reference time `Mon Jan 2 Renders e.g. `811218-2389`. A layout is Go's: the reference time `Mon Jan 2
15:04:05 MST 2006` spelled as the output should look, quoted, since a layout may 15:04:05 MST 2006` spelled as the output should look, quoted, since a layout may
carry the comma that separates arguments, with English names. Every second carry the comma that separates arguments, with English names. Every second
between the two days is reachable, so a layout with a clock draws the time too. between the two days is reachable, so a layout with a clock draws the time too, and
`from` may equal `to`, which is that one day. The instant is UTC, so a zone in the
layout prints `UTC` or `Z`.
The quotes delimit a layout outside a selector only, so `[O'Fallon]` in an The quotes delimit a layout outside a selector only, so `[O'Fallon]` in an
argument stays a name. Rejected at `New`: a bound that is no calendar date, or not argument stays a name. Rejected at `New`: a bound that is no calendar date, or not
before the other; an unquoted layout, naming the quoted one; a layout naming no field, which is text; before the other; an unquoted layout, naming the single-quoted one; a layout naming no field, which is
for `date` a layout naming no date field, naming `time`; and for `time` a layout text, as is one day in a layout with no clock; for `date` a layout naming no date
naming a date field, naming `date`. `{seq()}` spans `Fake` field, naming `time`; and for `time` a layout naming a date field, naming `date`. `{seq()}` spans `Fake`
calls and `repeat`, resets with a new generator, and is the natural primary key for calls and `repeat`, resets with a new generator, and is the natural primary key for
the SQL example above. the SQL example above.
@@ -721,6 +733,15 @@ datatype and nullability, and each table's key, name, weight and parent columns;
changes it or `data/` adds its `CHANGELOG.md` entry, which CI checks. A removed, changes it or `data/` adds its `CHANGELOG.md` entry, which CI checks. A removed,
renamed or retyped line is a major. renamed or retyped line is a major.
## Audience
App developers writing tests and fixtures, in Go and at a shell:
- a **bulk fixture author**, thousands of rows into CSV or SQL
- a **Go test author**, filling a struct with `FakeStruct`
- a **hand fixture author**, one value at a shell
- a **validator-facing author**, who needs a value a real checker accepts
## Goals ## Goals
1. **Valid by construction** — every value passes the check its real consumer 1. **Valid by construction** — every value passes the check its real consumer
@@ -1063,12 +1084,21 @@ renamed or retyped line is a major.
bigger neighbour ship no address; counting the land outside every place too would bigger neighbour ship no address; counting the land outside every place too would
drop a quarter of the places, whose codes straddle unincorporated land, for a drop a quarter of the places, whose codes straddle unincorporated land, for a
postal city the USPS mostly names the same way. postal city the USPS mostly names the same way.
- **`--list` stays a plain list of paths.** It is what a script reads, so every line
has to be a path that `Fake` takes; a marker for the tables a `[selector]` follows,
or a legend above them, would make the output something to parse before use.
`--help` names the selector spelling instead, and the Table section teaches it.
- **A layout is always quoted.** A layout may carry the comma that separates - **A layout is always quoted.** A layout may carry the comma that separates
arguments, `'January 2, 2006'`, and one spelling for every layout beats a rule arguments, `'January 2, 2006'`, and one spelling for every layout beats a rule
about which ones need the quotes, so the bare spelling is refused naming the about which ones need the quotes, so the bare spelling is refused naming the
quoted one. The layout is Go's reference time because the library renders with quoted one. The layout is Go's reference time because the library renders with
it and a Go caller already knows it; its names are English, and a locale's own it and a Go caller already knows it; its names are English, and a locale's own
month and weekday names are data. month and weekday names are data.
- **A title is a table under `sex`.** A prefix drawn apart would put `Mr` on a record
whose `sex` column says `female`, which is the disagreement the record exists to
prevent; the tables this set already has are what a title needs, so `en_US.title`
links to `sex` as `first-name` does. Swedish has no everyday sexed honorific, so
`sv_SE` keeps an unsexed `dr` and `prof`.
- **No builtin reads the clock, so a date is bounded by days, never by an age.** - **No builtin reads the clock, so a date is bounded by days, never by an age.**
An `age(min,max)` would make a seeded fixture change with the day it runs on, An `age(min,max)` would make a seeded fixture change with the day it runs on,
which is what a seed exists to prevent; a birthdate for someone 20 to 60 is which is what a seed exists to prevent; a birthdate for someone 20 to 60 is
+35 -8
View File
@@ -472,17 +472,28 @@ func iban(r rng, cc string) string {
const dayLayout = "2006-01-02" const dayLayout = "2006-01-02"
// Alike in no field; layoutDay differs from layoutProbe in its date fields only. // Alike in no field; layoutDay differs from layoutProbe in its date fields only,
// layoutClock in its clock fields only.
var ( var (
layoutProbe = time.Date(2001, 2, 3, 4, 5, 6, 0, time.UTC) layoutProbe = time.Date(2001, 2, 3, 4, 5, 6, 0, time.UTC)
layoutProbe2 = time.Date(2010, 11, 12, 13, 14, 15, 0, time.UTC) layoutProbe2 = time.Date(2010, 11, 12, 13, 14, 15, 0, time.UTC)
layoutDay = time.Date(2010, 11, 12, 4, 5, 6, 0, time.UTC) layoutDay = time.Date(2010, 11, 12, 4, 5, 6, 0, time.UTC)
layoutClock = time.Date(2001, 2, 3, 13, 14, 15, 0, time.UTC)
) )
// quotedLayout reports whether an arg carries the single quotes a layout is written in.
func quotedLayout(a string) bool {
return len(a) >= 2 && a[0] == '\'' && a[len(a)-1] == '\''
}
// layoutArg is the Go layout a quoted arg holds, refused when unquoted or constant. // layoutArg is the Go layout a quoted arg holds, refused when unquoted or constant.
func layoutArg(a string) (string, error) { func layoutArg(a string) (string, error) {
if len(a) < 2 || a[0] != '\'' || a[len(a)-1] != '\'' { if !quotedLayout(a) {
return "", fmt.Errorf("layout %s is not quoted; write '%s'", a, strings.Trim(a, "'")) bare := strings.Trim(a, `'"`)
if strings.HasPrefix(a, `"`) || strings.HasSuffix(a, `"`) {
return "", fmt.Errorf("layout %s is double-quoted; write '%s'", a, bare)
}
return "", fmt.Errorf("layout %s is not quoted; write '%s', which a shell keeps only inside a double-quoted argument", a, bare)
} }
layout := a[1 : len(a)-1] layout := a[1 : len(a)-1]
if layoutProbe.Format(layout) == layoutProbe2.Format(layout) { if layoutProbe.Format(layout) == layoutProbe2.Format(layout) {
@@ -501,18 +512,31 @@ func layoutOf(a string) string {
} }
// layoutArity checks a call ending in a layout takes n args, naming the quoted // layoutArity checks a call ending in a layout takes n args, naming the quoted
// layout when an unquoted one split into more. // layout where an unquoted one split into more.
func layoutArity(name string, n int, a []string) error { func layoutArity(name string, n int, a []string) error {
if len(a) == n { if len(a) == n {
return nil return nil
} }
hint := "" hint := ""
if len(a) > n { if len(a) > n && !split(a[n-1:]) {
hint = fmt.Sprintf("; a layout holding a comma is quoted: '%s'", strings.Join(a[n-1:], ", ")) hint = fmt.Sprintf("; a layout holding a comma is quoted: '%s'", strings.Join(a[n-1:], ", "))
} }
return fmt.Errorf("%s takes %d args, got %d%s", name, n, len(a), hint) return fmt.Errorf("%s takes %d argument%s, got %d%s", name, n, plural(n), len(a), hint)
} }
// split reports whether the surplus args already hold a quoted layout, which no
// comma split apart.
func split(a []string) bool {
for _, arg := range a {
if quotedLayout(arg) {
return true
}
}
return false
}
func plural(n int) string { return map[bool]string{true: "s"}[n != 1] }
func dateArgs(_ map[string]node, a []string) error { func dateArgs(_ map[string]node, a []string) error {
if err := layoutArity("date", 3, a); err != nil { if err := layoutArity("date", 3, a); err != nil {
return err return err
@@ -525,8 +549,8 @@ func dateArgs(_ map[string]node, a []string) error {
if err != nil { if err != nil {
return fmt.Errorf("date(from,to,layout): to %q is not a YYYY-MM-DD date", a[1]) return fmt.Errorf("date(from,to,layout): to %q is not a YYYY-MM-DD date", a[1])
} }
if !from.Before(to) { if to.Before(from) {
return fmt.Errorf("date(from,to,layout): from %s is not before to %s", a[0], a[1]) return fmt.Errorf("date(from,to,layout): from %s is after to %s", a[0], a[1])
} }
layout, err := layoutArg(a[2]) layout, err := layoutArg(a[2])
if err != nil { if err != nil {
@@ -535,6 +559,9 @@ func dateArgs(_ map[string]node, a []string) error {
if layoutProbe.Format(layout) == layoutDay.Format(layout) { if layoutProbe.Format(layout) == layoutDay.Format(layout) {
return fmt.Errorf("date(from,to,layout): '%s' names no date field; write time('%s')", layout, layout) return fmt.Errorf("date(from,to,layout): '%s' names no date field; write time('%s')", layout, layout)
} }
if from.Equal(to) && layoutProbe.Format(layout) == layoutClock.Format(layout) {
return fmt.Errorf("date(%s,%s,'%s') is the constant %q; write it as text", a[0], a[1], layout, from.Format(layout))
}
return nil return nil
} }
+49 -3
View File
@@ -298,13 +298,13 @@ func TestBuiltinDateAndTime(t *testing.T) {
func TestBuiltinDateArgs(t *testing.T) { func TestBuiltinDateArgs(t *testing.T) {
for tmpl, want := range map[string]string{ for tmpl, want := range map[string]string{
`"{date(1990-13-01,1990-12-31,'2006-01-02')}"`: "1990-13-01", `"{date(1990-13-01,1990-12-31,'2006-01-02')}"`: "1990-13-01",
`"{date(1990-12-31,1990-01-01,'2006-01-02')}"`: "before", `"{date(1990-12-31,1990-01-01,'2006-01-02')}"`: "is after",
`"{date(1990-01-01,1990-01-01,'2006-01-02')}"`: "before", `"{date(1990-01-01,1990-01-01,'2006-01-02')}"`: "write it as text",
`"{date(1990-01-01,1990-12-31,2006-01-02)}"`: "'2006-01-02'", `"{date(1990-01-01,1990-12-31,2006-01-02)}"`: "'2006-01-02'",
`"{date(1990-01-01,1990-12-31,'January 2, 2006)}"`: "'", `"{date(1990-01-01,1990-12-31,'January 2, 2006)}"`: "'",
`"{date(1990-01-01,1990-12-31,'x')}"`: "text", `"{date(1990-01-01,1990-12-31,'x')}"`: "text",
`"{date(1990-01-01,1990-12-31,'')}"`: "text", `"{date(1990-01-01,1990-12-31,'')}"`: "text",
`"{date(1990-01-01,1990-12-31)}"`: "3 args", `"{date(1990-01-01,1990-12-31)}"`: "3 arguments",
`"{date(1990-01-01,1990-12-31,January 2, 2006)}"`: "'January 2, 2006'", `"{date(1990-01-01,1990-12-31,January 2, 2006)}"`: "'January 2, 2006'",
`"{time(3:04 PM, Mon)}"`: "'3:04 PM, Mon'", `"{time(3:04 PM, Mon)}"`: "'3:04 PM, Mon'",
`"{time(15:04)}"`: "'15:04'", `"{time(15:04)}"`: "'15:04'",
@@ -318,3 +318,49 @@ func TestBuiltinDateArgs(t *testing.T) {
} }
} }
} }
// TestBuiltinLayoutErrorsNameARunnableSpelling pins the two layout errors a user
// can follow: a double-quoted layout is named single-quoted, without its own
// quotes carried into the suggestion, and a bare one says a shell ate them.
func TestBuiltinLayoutErrorsNameARunnableSpelling(t *testing.T) {
_, err := compile(parse(t, `"{date(1990-01-01,1990-12-31,\"2006-01-02\")}"`))
if err == nil || !strings.Contains(err.Error(), "write '2006-01-02'") {
t.Fatalf("a double-quoted layout = %v, want it named single-quoted", err)
}
if strings.Contains(err.Error(), `'"`) {
t.Errorf("%v names a layout that renders its own quotes", err)
}
_, err = compile(parse(t, `"{time(15:04)}"`))
if err == nil || !strings.Contains(err.Error(), "write '15:04'") || !strings.Contains(err.Error(), "shell") {
t.Fatalf("a bare layout = %v, want it named quoted and the shell explained", err)
}
// The comma hint belongs to a layout that split, not to a call given extra args.
_, err = compile(parse(t, `"{time(0,12,'15:04')}"`))
if err == nil || !strings.Contains(err.Error(), "takes 1 argument, got 3") {
t.Fatalf("time with three args = %v, want the count named in the singular", err)
}
if strings.Contains(err.Error(), "holding a comma") {
t.Errorf("%v offers the comma hint though the layout is already quoted", err)
}
}
// TestBuiltinDateSpansOneDay pins that from == to is a day: with a clock layout it
// draws every second of it, and without one it could only emit one value.
func TestBuiltinDateSpansOneDay(t *testing.T) {
f := engine(1)
seen := map[string]bool{}
for i := 0; i < 500; i++ {
got := mustRender(t, f, `"{date(2026-01-01,2026-01-01,'2006-01-02 15:04:05')}"`)
if !strings.HasPrefix(got, "2026-01-01 ") {
t.Fatalf("date over one day = %q, out of range", got)
}
seen[got] = true
}
if len(seen) < 400 {
t.Fatalf("date over one day drew %d distinct seconds in 500, want the whole day", len(seen))
}
_, err := compile(parse(t, `"{date(2026-01-01,2026-01-01,'2006-01-02')}"`))
if err == nil || !strings.Contains(err.Error(), "write it as text") {
t.Fatalf("one day in a date-only layout = %v, want it named a constant", err)
}
}
+1 -1
View File
@@ -2,6 +2,6 @@
"format": "{prefix}{first} {last}", "format": "{prefix}{first} {last}",
"first": "{.first-name.name}", "first": "{.first-name.name}",
"last": "{.last-name.name}", "last": "{.last-name.name}",
"prefix": ["", { "format": "{title} ", "title": ["Dr", "Mx", "Prof"], "weight": 0.1 }], "prefix": ["", { "format": "{.title.name} ", "weight": 0.1 }],
"sex": "{.sex.name}" "sex": "{.sex.name}"
} }
+1
View File
@@ -0,0 +1 @@
{ "format": "{name}", "rows": "title.tsv", "name": "name", "parent": "sex", "weight": "share" }
+11
View File
@@ -0,0 +1,11 @@
name sex share
Dr f 6
Dr m 6
Miss f 4
Mr m 60
Mrs f 22
Ms f 30
Mx f 1
Mx m 1
Prof f 3
Prof m 3
1 name sex share
2 Dr f 6
3 Dr m 6
4 Miss f 4
5 Mr m 60
6 Mrs f 22
7 Ms f 30
8 Mx f 1
9 Mx m 1
10 Prof f 3
11 Prof m 3
+1 -1
View File
@@ -2,6 +2,6 @@
"format": "{prefix}{first} {last}", "format": "{prefix}{first} {last}",
"first": "{.first-name.name}", "first": "{.first-name.name}",
"last": "{.last-name.name}", "last": "{.last-name.name}",
"prefix": ["", { "format": "{string} ", "string": ["dr", "prof"], "weight": 0.05 }], "prefix": ["", { "format": "{title} ", "title": ["dr", "prof"], "weight": 0.05 }],
"sex": "{.sex.name}" "sex": "{.sex.name}"
} }
+22
View File
@@ -392,3 +392,25 @@ func TestShippedStreetNumberFormats(t *testing.T) {
} }
} }
} }
// TestShippedUSTitleAgreesWithSex pins that one person's title and sex agree, and
// that the everyday titles are reachable.
func TestShippedUSTitleAgreesWithSex(t *testing.T) {
f := newGenerator(t, "data", WithSeed(4))
female := map[string]bool{"Miss": true, "Mrs": true, "Ms": true}
seen := map[string]bool{}
for i := 0; i < 3000; i++ {
got := fakeTemplate(t, f, `{/en_US.person.prefix}|{/en_US.person.sex}`)
title, sex, _ := strings.Cut(got, "|")
title = strings.TrimSpace(title)
seen[title] = true
if title == "Mr" && sex != "male" || female[title] && sex != "female" {
t.Fatalf("%q: the title contradicts the sex", got)
}
}
for _, want := range []string{"", "Mr", "Ms"} {
if !seen[want] {
t.Errorf("en_US.person.prefix never drew %q in 3000 draws", want)
}
}
}
+9 -1
View File
@@ -140,6 +140,10 @@ func (f *Generator) FakeRecord(path string) (*Record, error) {
return nil, fmt.Errorf("fejkdata: %s descends into %q, a field; only a category-level template is a record", path, 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) shape := f.recordShapeOf(n)
if errors.Is(shape.err, errNoColumns) {
column := path[strings.LastIndex(path, ".")+1:]
return nil, fmt.Errorf("fejkdata: %s %w; render it as a column of one: '{\"format\":\"\",\"%s\":\"{/%s}\"}'", path, shape.err, column, path)
}
if shape.err != nil { if shape.err != nil {
return nil, fmt.Errorf("fejkdata: %s %w", path, shape.err) return nil, fmt.Errorf("fejkdata: %s %w", path, shape.err)
} }
@@ -227,6 +231,10 @@ func (f *Generator) FakeRecordTemplate(input string) (*Record, error) {
return t.Fake(), nil return t.Fake(), nil
} }
// errNoColumns is the one record fence a path can answer, so its entry point names
// the record to write instead.
var errNoColumns = errors.New("has no fields, so no columns")
// recordOf is the fence both record entry points pass. The columns come back with // 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. // the template, fixed for every draw the caller goes on to make.
func recordOf(n node) (*template, []Column, error) { func recordOf(n node) (*template, []Column, error) {
@@ -239,7 +247,7 @@ func recordOf(n node) (*template, []Column, error) {
} }
names := recordColumns(t) names := recordColumns(t)
if len(names) == 0 { if len(names) == 0 {
return nil, nil, errors.New("has no fields, so no columns") return nil, nil, errNoColumns
} }
if !t.record { 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) 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)
+15
View File
@@ -547,3 +547,18 @@ func TestRecordTemplateRejectsATopLevelRepeat(t *testing.T) {
t.Errorf("NewTemplate on the same input = %v, want the string view to still compile", err) t.Errorf("NewTemplate on the same input = %v, want the string view to still compile", err)
} }
} }
// TestRecordOfAFieldlessCategoryNamesTheWrapper pins the way out of the first
// command a fixture author types: a category of one value has no columns, and the
// error names the record that gives it one.
func TestRecordOfAFieldlessCategoryNamesTheWrapper(t *testing.T) {
f := newGenerator(t, "data", WithSeed(1))
want := `'{"format":"","personnummer":"{/sv_SE.personnummer}"}'`
_, err := f.FakeRecord("sv_SE.personnummer")
if err == nil || !strings.Contains(err.Error(), want) {
t.Fatalf("FakeRecord(sv_SE.personnummer) = %v, want it to name %s", err, want)
}
if _, err := f.FakeRecordTemplate(`{"format":"","personnummer":"{/sv_SE.personnummer}"}`); err != nil {
t.Fatalf("the named wrapper does not render: %v", err)
}
}
+17 -5
View File
@@ -14,6 +14,7 @@ import (
// cell of the row a render pinned; a cell carrying tokens compiles to a string node. // cell of the row a render pinned; a cell carrying tokens compiles to a string node.
type table struct { type table struct {
category string category string
path string // the category's path from the data root, set when the tables are linked
file string file string
format *template // fields are the column nodes format *template // fields are the column nodes
columns []string columns []string
@@ -350,6 +351,7 @@ func linkTables(root map[string]node) error {
return err return err
} }
case *table: case *table:
n.path = path
if n.parent < 0 { if n.parent < 0 {
continue continue
} }
@@ -508,7 +510,7 @@ func (t *table) under(r int, a *table, pr int) bool {
// naming several rows resolves inside the ancestors pinned in d. // naming several rows resolves inside the ancestors pinned in d.
func (t *table) find(sel string, d *draws) (int, error) { func (t *table) find(sel string, d *draws) (int, error) {
if t.key < 0 && t.name < 0 { if t.key < 0 && t.name < 0 {
return 0, fmt.Errorf("%s has no key or name column to select a row by", t.category) return 0, fmt.Errorf("%s has no key or name column to select a row by", t.spelledPath())
} }
if r, ok := t.byKey[sel]; ok { if r, ok := t.byKey[sel]; ok {
return r, nil return r, nil
@@ -521,7 +523,7 @@ func (t *table) find(sel string, d *draws) (int, error) {
case 1: case 1:
return rows[0], nil return rows[0], nil
case 0: case 0:
return 0, fmt.Errorf("no row of %s has key or name %q", t.category, sel) return 0, fmt.Errorf("no row of %s has key or name %q", t.spelledPath(), sel)
} }
keys := make([]string, len(rows)) keys := make([]string, len(rows))
for i, r := range rows { for i, r := range rows {
@@ -531,14 +533,24 @@ func (t *table) find(sel string, d *draws) (int, error) {
keys[i] = t.cell(r, t.key) keys[i] = t.cell(r, t.key)
} }
} }
listed := strings.Join(keys, ", ")
if t.key < 0 { if t.key < 0 {
return 0, fmt.Errorf("%q names %d rows of %s; select it inside its %s, one of %v", sel, len(rows), t.category, t.parentT.category, keys) return 0, fmt.Errorf("%q names %d rows of %s; select it inside its %s, one of %s", sel, len(rows), t.spelledPath(), t.parentT.spelledPath(), listed)
} }
inside := "" inside := ""
if t.parentT != nil { if t.parentT != nil {
inside = fmt.Sprintf(", or select it inside its %s", t.parentT.category) inside = fmt.Sprintf(", or select it inside its %s", t.parentT.category)
} }
return 0, fmt.Errorf("%q names %d rows of %s; select one by key, one of %v%s", sel, len(rows), t.category, keys, inside) return 0, fmt.Errorf("%q names %d rows of %s; select one by key, one of %s%s", sel, len(rows), t.spelledPath(), listed, inside)
}
// spelledPath is the path a selector on t is written at, the category's own name
// until the tables are linked.
func (t *table) spelledPath() string {
if t.path == "" {
return t.category
}
return t.path
} }
// selectorSpelling is how a path writes a selected row, for messages: by key, or // selectorSpelling is how a path writes a selected row, for messages: by key, or
@@ -546,7 +558,7 @@ func (t *table) find(sel string, d *draws) (int, error) {
func (t *table) selectorSpelling(r int) string { func (t *table) selectorSpelling(r int) string {
switch { switch {
case t.key >= 0: case t.key >= 0:
return t.category + "[" + t.cell(r, t.key) + "]" return t.spelledPath() + "[" + t.cell(r, t.key) + "]"
case t.name >= 0: case t.name >= 0:
return t.parentT.selectorSpelling(t.parentRow(r)) + "." + t.category + "[" + t.cell(r, t.name) + "]" return t.parentT.selectorSpelling(t.parentRow(r)) + "." + t.category + "[" + t.cell(r, t.name) + "]"
} }
+31 -2
View File
@@ -172,7 +172,7 @@ func TestTableSelectsARowByKeyOrName(t *testing.T) {
} }
for path, want := range map[string]string{ for path, want := range map[string]string{
"region[99]": `"99"`, "region[99]": `"99"`,
"locality[Sandby]": "L7 L8", "locality[Sandby]": "L7, L8",
"region[12].code[1]": "not a table", "region[12].code[1]": "not a table",
"region[]": "empty", "region[]": "empty",
"region[12": "]", "region[12": "]",
@@ -334,7 +334,7 @@ func TestTableSelectorInAReference(t *testing.T) {
} }
for name, c := range map[string]struct{ json, want string }{ for name, c := range map[string]struct{ json, want string }{
"unknown row": {`"{/region[99].name}"`, `"99"`}, "unknown row": {`"{/region[99].name}"`, `"99"`},
"ambiguous name": {`"{/locality[Sandby].name}"`, "L7 L8"}, "ambiguous name": {`"{/locality[Sandby].name}"`, "L7, L8"},
"not inside": {`"{/region[12].municipality[0180].name}"`, "not inside"}, "not inside": {`"{/region[12].municipality[0180].name}"`, "not inside"},
"selector on template": {`"{/x[1].a}"`, "not a table"}, "selector on template": {`"{/x[1].a}"`, "not a table"},
} { } {
@@ -744,3 +744,32 @@ func TestNamedTableWithoutAKeyResolvesInsideItsParent(t *testing.T) {
t.Fatalf("a name selected inside a pinned parent = %q", got) t.Fatalf("a name selected inside a pinned parent = %q", got)
} }
} }
// TestAmbiguousNameNamesARunnablePath pins what a shell user reads: each row is
// named as the path they can type, from the root, and the rows are listed plainly.
func TestAmbiguousNameNamesARunnablePath(t *testing.T) {
files := map[string]string{}
for name, body := range siblings() {
files["se/"+name] = body
}
files["se/street.json"] = `{"format":"{name}","rows":"street.tsv","name":"name","parent":"locality","weight":"segments"}`
f := newGenerator(t, writeFiles(t, files), WithSeed(1))
_, err := f.Fake("se.street[Sandbyvägen]")
if err == nil {
t.Fatal("se.street[Sandbyvägen] resolved, though two rows carry that name")
}
for _, want := range []string{"se.locality[L7].street[Sandbyvägen]", "se.locality[L8].street[Sandbyvägen]"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("%v does not name %s", err, want)
}
if got := fake(t, f, want); got != "Sandbyvägen" {
t.Errorf("Fake(%q) = %q, want the named path to run", want, got)
}
}
if strings.Contains(err.Error(), "[se.locality") {
t.Errorf("%v lists the rows as a Go slice; separate them with commas", err)
}
if _, err := f.Fake("se.locality[Sandby]"); err == nil || !strings.Contains(err.Error(), "one of L7, L8") {
t.Fatalf("se.locality[Sandby] = %v, want its keys listed plainly", err)
}
}
+1 -1
View File
@@ -125,7 +125,7 @@ func checkFunc(body string, fields map[string]node) error {
return fmt.Errorf("token {%s}: unknown function %q", body, name) return fmt.Errorf("token {%s}: unknown function %q", body, name)
} }
if b.arity >= 0 && len(args) != b.arity { if b.arity >= 0 && len(args) != b.arity {
return fmt.Errorf("token {%s}: %s takes %d args, got %d", body, name, b.arity, len(args)) return fmt.Errorf("token {%s}: %s takes %d argument%s, got %d", body, name, b.arity, plural(b.arity), len(args))
} }
if b.check != nil { if b.check != nil {
if err := b.check(fields, args); err != nil { if err := b.check(fields, args); err != nil {
+9 -1
View File
@@ -24,7 +24,7 @@ en_US.itin.serial string
en_US.last-name format "{name}" key name weight count en_US.last-name format "{name}" key name weight count
en_US.last-name.count string en_US.last-name.count string
en_US.last-name.name string en_US.last-name.name string
en_US.person format "{prefix}{first} {last}" reads en_US.first-name en_US.last-name en_US.sex en_US.person format "{prefix}{first} {last}" reads en_US.first-name en_US.last-name en_US.sex en_US.title
en_US.person.first string en_US.person.first string
en_US.person.last string en_US.person.last string
en_US.person.prefix string en_US.person.prefix string
@@ -48,11 +48,19 @@ en_US.sex.first-name.count
en_US.sex.first-name.name en_US.sex.first-name.name
en_US.sex.first-name.sex en_US.sex.first-name.sex
en_US.sex.name string en_US.sex.name string
en_US.sex.title
en_US.sex.title.name
en_US.sex.title.sex
en_US.sex.title.share
en_US.ssn format "{area}-{group}-{serial}" en_US.ssn format "{area}-{group}-{serial}"
en_US.ssn.area string en_US.ssn.area string
en_US.ssn.group string en_US.ssn.group string
en_US.ssn.serial string en_US.ssn.serial string
en_US.time format "{time('3:04 PM')}" en_US.time format "{time('3:04 PM')}"
en_US.title format "{name}" name name weight share parent sex
en_US.title.name string
en_US.title.sex string
en_US.title.share string
en_US.url format "https://{host}{path}" en_US.url format "https://{host}{path}"
en_US.url.host string en_US.url.host string
en_US.url.path string en_US.url.path string
+7 -3
View File
@@ -164,9 +164,13 @@ address, phone, national id, company and date names each.
1. Table node, key and name selection, parent links, consistent draws, the 1. Table node, key and name selection, parent links, consistent draws, the
choice-of-rows fence, `DATA-LICENSES.md`, `data-import/` — done. choice-of-rows fence, `DATA-LICENSES.md`, `data-import/` — done.
2. `geo/SE` and `geo/US`, and `address` in both locales on top of them — done. 2. `geo/SE` and `geo/US`, and `address` in both locales on top of them — done.
3. Weighted person names and valid ids in both locales; `date()` — done. Middle 3. Weighted person names and valid ids in both locales; `date()` — done. Left for
names wait for a draw group that shares its family's pins, so a second name later: middle names, and drawing a shipped `personnummer` inside a *selected*
is drawn under the same sex. sex, both of which want a draw group that shares its family's pins — until then
the conflict error can name a rewrite that returns a different value when the
read it conflicts with sits inside another category. Give the national ids one
record shape in step 6, and report a struct column's draw conflict with the path
spelling a tag takes rather than the reference spelling.
4. `misc` conversions and the new `misc` tables. 4. `misc` conversions and the new `misc` tables.
5. The remaining locale categories: company, phone, finance, vehicle, words. 5. The remaining locale categories: company, phone, finance, vehicle, words.
6. Records with building-block columns across the shipped set; shape re-pin. 6. Records with building-block columns across the shipped set; shape re-pin.