400 lines
17 KiB
Markdown
400 lines
17 KiB
Markdown
# fejkdata
|
||
|
||
A Go library and CLI for generating locale-aware fake data from JSON templates.
|
||
Forked from [github.com/Timewave-AB/fakes](https://github.com/Timewave-AB/fakes).
|
||
|
||
## Goals
|
||
|
||
1. **Valid by construction** — every value passes the check its real consumer
|
||
applies; facts that belong together come from one draw, within a value and
|
||
across categories.
|
||
2. **Text means what it says** — a format renders as written; only `{…}` varies,
|
||
random characters included (`{digits(3)}`). One spelling per result; the wrong
|
||
one is a load error naming the right one.
|
||
3. **Every mistake is a load error** — `New` rejects; `Fake` on a loaded generator
|
||
fails only for an unknown path.
|
||
4. **Zero to a value in one command** — `go install`, then `fejkdata sv_SE.person`:
|
||
no checkout, no flag. Flags are GNU-form (`--seed 42`, `-n 3`) in any position;
|
||
the first custom template needs no escape and no option.
|
||
5. **Data lives in JSON** — a builtin only for what data can't express.
|
||
6. **Reproducible** — seed in, same stream out; no builtin reads a clock.
|
||
7. **Zero dependencies** — standard library only.
|
||
8. **Docs index the grammar** — every syntax feature is a heading; every example
|
||
runs under test and shows its output; a rule is stated once.
|
||
|
||
## CLI
|
||
|
||
```sh
|
||
go install gitea.larvit.se/larvit/fejkdata/cmd/fejkdata@latest
|
||
|
||
fejkdata sv_SE.person # Sara Eriksson
|
||
fejkdata sv_SE.person.last # Eriksson
|
||
fejkdata --seed 42 sv_SE.address # the same address every run
|
||
fejkdata -n 3 --separator ', ' sv_SE.word # nät, barn, sol
|
||
fejkdata --list # every path the data offers
|
||
fejkdata --data-path ./mydata sv_SE.word # layer a directory over the shipped data
|
||
fejkdata --no-shipped-data -d ./mydata --list # only your data
|
||
```
|
||
|
||
A path names a category, or a field inside one: each dot segment descends one
|
||
level — folders, then the category (a JSON file), then fields.
|
||
|
||
| Flag | |
|
||
|------|--|
|
||
| `-d`, `--data-path D` | a directory to layer over the shipped data; repeatable, the last wins a name clash |
|
||
| `--no-shipped-data` | load only the `--data-path` directories |
|
||
| `-s`, `--seed N` | reproducible output |
|
||
| `-n`, `--repeat N` | render the path N times, each an independent draw |
|
||
| `--separator S` | between repeated values (default a newline) |
|
||
| `--list` | print every path, then exit |
|
||
| `--version`, `-h`, `--help` | print, then exit |
|
||
|
||
`--name value` and `--name=value` both work (see [Decisions](#decisions)); flags
|
||
go anywhere, `--` ends them. Exit codes: `0` success, `1` runtime error (missing
|
||
dir, unknown path), `2` misuse. From a checkout: `go run ./cmd/fejkdata …`.
|
||
|
||
### Your own data
|
||
|
||
A category is a JSON file in a directory. Save this as `mydata/sql.json`:
|
||
|
||
```json
|
||
{
|
||
"format": "INSERT INTO users VALUES({sql-username});",
|
||
"sql-username": {
|
||
"format": "'{username}'",
|
||
"repeat": 3,
|
||
"separator": "),(",
|
||
"username": ["pixelfox", "snork", "turbohund", "blip", "zoom", "wahoo"]
|
||
}
|
||
}
|
||
```
|
||
|
||
```sh
|
||
fejkdata --seed 1 --data-path ./mydata sql
|
||
# INSERT INTO users VALUES('zoom'),('wahoo'),('blip');
|
||
fejkdata --repeat 100 --data-path ./mydata sql > seed.sql
|
||
```
|
||
|
||
## Library
|
||
|
||
```sh
|
||
go get gitea.larvit.se/larvit/fejkdata # Go 1.22+
|
||
```
|
||
|
||
```go
|
||
f, err := fejkdata.New(fejkdata.WithSeed(42))
|
||
if err != nil {
|
||
log.Fatal(err)
|
||
}
|
||
v, err := f.Fake("sv_SE.address") // "Kungsvägen 68\n379 17 Stockholm"
|
||
paths := f.List() // every path Fake accepts, sorted
|
||
```
|
||
|
||
| Option | |
|
||
|--------|--|
|
||
| `WithSeed(n)` | same seed, same data: identical sequence |
|
||
| `WithDataPath(dir)` | layer a directory; repeat to layer several, the last wins a clash |
|
||
| `WithDataFS(fsys)` | layer an `fs.FS`, such as your own `embed.FS` |
|
||
| `WithoutShippedData()` | load only what you give |
|
||
|
||
A `*Generator` is safe for concurrent use; a seeded sequence is reproducible only
|
||
when drawn from one goroutine. Changing how a value is composed shifts the seeded
|
||
stream for that value and everything drawn after it.
|
||
|
||
## Data
|
||
|
||
The shipped set under [`data/`](data) — one folder per locale (`en_US`, `sv_SE`)
|
||
plus a locale-neutral `misc` folder — is embedded, so the CLI and the library
|
||
work with no data on disk. A directory is a namespace: each JSON file is a
|
||
category named after the file, each subdirectory a dot-path segment, so
|
||
`mydata/sv_SE/person.json` is `sv_SE.person` and replaces the shipped one.
|
||
Sources merge in order; matching folders combine, any other clash is won by the
|
||
last loaded. Names may not use `.`, `|`, `(`, `{` or `}`; dot-prefixed entries
|
||
are skipped, so a data directory can also be a checkout.
|
||
|
||
Each locale carries `address`, `color`, `company`, `date`, `email`, `ip`,
|
||
`person`, `phone`, `price`, `sentence`, `ssn`, `time`, `url`, `username`,
|
||
`version` and `word`, formatted per locale. `misc` carries `car`, `coordinate`,
|
||
`country` (ISO 3166), `creditcard` (Luhn-valid), `currency` (ISO 4217), `emoji`,
|
||
`httpstatus`, `language` (ISO 639), `mac`, `mimetype`, `objectid`, `timezone`
|
||
(IANA), `useragent` and `uuid` (v4). Many carry sub-fields — `misc.currency.symbol`,
|
||
`misc.country.alpha2`, `misc.httpstatus.code` — which `--list` shows.
|
||
|
||
## Data format
|
||
|
||
Every value is a **node**, nestable without limit:
|
||
|
||
| Node | JSON | Renders |
|
||
|------|------|---------|
|
||
| string | `"Malmö"` | its text, with any `{…}` tokens expanded |
|
||
| choice | `["a", "b", …]` | one item, picked at random |
|
||
| template | `{"format": "…", …}` | its format, with `{name}` tokens rendering the named fields |
|
||
|
||
### Format string
|
||
|
||
Every character is literal except a `{…}` token:
|
||
|
||
| Token | Renders |
|
||
|-------|---------|
|
||
| `{name}` | the sibling field `name` |
|
||
| `{name.field}` | `field` of one draw of `name` ([Correlated fields](#correlated-fields)) |
|
||
| `{a\|b}` | one of the named fields, even odds |
|
||
| `{fn(args)}` | a builtin ([Functions](#functions)) |
|
||
| `{..path}` | a node reached from the data root ([References](#references)) |
|
||
| `{{`, `}}` | a literal `{` or `}` |
|
||
|
||
```json
|
||
"100 Main St, Apt {int(1,9)}{upper(1)} — tel {digits(3)}-{digits(4)}"
|
||
```
|
||
|
||
Renders e.g. `100 Main St, Apt 4B — tel 555-0199`. Random characters come from
|
||
the sample functions, so a phone pattern is `070-{digits(3)} {digits(2)} {digits(2)}`.
|
||
A lone `}` is a load error naming `}}`; the arms of `{a|b}` must differ, a
|
||
repeated arm being a second spelling of [weight](#weight).
|
||
|
||
### Weight
|
||
|
||
An item of a choice may carry a `weight` (default `1`) to skew its odds:
|
||
|
||
```json
|
||
[
|
||
{ "format": "070-{digits(3)} {digits(2)} {digits(2)}", "weight": 10 },
|
||
"08-{digits(3)} {digits(2)} {digits(2)}",
|
||
{ "format": "010-{digits(3)} {digits(2)} {digits(2)}", "weight": 2 }
|
||
]
|
||
```
|
||
|
||
Renders `070-412 38 91` ten times as often as `08-…`. A string item is weighted by
|
||
writing it as `{ "format": "AB", "weight": 3 }`. Rejected at load: a weight that
|
||
is negative, non-numeric, `1` (the default) or outside a choice, a set summing to
|
||
zero, and a repeated item — `["a", "a", "b"]` is `[{ "format": "a", "weight": 2 }, "b"]`,
|
||
and the error says so.
|
||
|
||
### Repeat
|
||
|
||
A template may carry `repeat` (an integer above `1`) to render its format that
|
||
many times — each an independent draw — joined by `separator` (default `""`):
|
||
|
||
```json
|
||
{ "format": "{word}", "repeat": 3, "separator": " ", "word": ["foo", "bar", "baz"] }
|
||
```
|
||
|
||
Renders e.g. `bar foo baz`. A `separator` without a `repeat` is rejected.
|
||
|
||
### Options and fields
|
||
|
||
`format`, `weight`, `repeat` and `separator` are the only options; **any other
|
||
key is a field** (see [Decisions](#decisions)). An object that does nothing a
|
||
string can't — only a `format` — is rejected naming the string, as is a one-item
|
||
choice naming its item.
|
||
|
||
### Functions
|
||
|
||
A `{name(args)}` token calls a builtin. Arguments are checked at `New`: a bad
|
||
count, range, country or expression fails fast, and a length, count or decimal
|
||
place beyond a sane maximum is rejected, so a fat-fingered `hex(2000000000)`
|
||
never tries to allocate gigabytes. Every builtin draws only from the seed — a
|
||
time-based id takes its timestamp from the rng, not the clock — so seeded output
|
||
stays reproducible.
|
||
|
||
| Function | Kind | Renders |
|
||
|----------|------|---------|
|
||
| `{luhn()}` | derivation | Luhn check digit over the digits emitted so far in this expansion |
|
||
| `{mod11()}` | derivation | weighted mod-11 check char (weights 2–7 from the right); `X` for 10 |
|
||
| `{ean()}` | derivation | EAN-13 / UPC-A / ISBN-13 / GTIN check digit |
|
||
| `{digits(n)}` | sample | `n` digits 0–9 |
|
||
| `{upper(n)}`, `{lower(n)}` | sample | `n` letters A–Z, a–z |
|
||
| `{int(min,max)}` | sample | uniform integer in `[min, max]` |
|
||
| `{float(min,max,dp)}` | sample | number in `[min, max]` with `dp` decimals |
|
||
| `{hex(n)}` | sample | `n` lowercase hex digits |
|
||
| `{base64(n)}` | sample | `n` random bytes, base64 |
|
||
| `{uuid()}` | sample | UUID v7 (`misc.uuid` ships v4 as data) |
|
||
| `{ulid()}` | sample | ULID, 26 Crockford base32 chars |
|
||
| `{nanoid(n)}` | sample | URL-safe Nano ID, `n` chars |
|
||
| `{iban(CC)}` | sample | length- and mod-97-valid IBAN for BE, DE, DK, ES, FI, NO or SE |
|
||
| `{seq()}`, `{seq(name)}` | counter | next integer from 1 in this generator; `name` selects an independent counter |
|
||
| `{calc(expr)}`, `{calc(expr,dp)}` | computation | an arithmetic expression over sibling fields ([Computation](#computation)) |
|
||
| `{lowercase(x)}`, `{uppercase(x)}`, `{ascii(x)}` | transform | a field's value rewritten ([Transforms](#transforms)) |
|
||
|
||
A derivation reads what is to its left, so place it after its payload; the
|
||
buffer is per expansion, so a nested template keeps fixed parts out of the sum. A
|
||
Swedish personnummer is a Luhn checksum over the nine digits before it:
|
||
|
||
```json
|
||
{ "format": "{century}{core}", "century": ["19", "20"],
|
||
"core": { "format": "{digits(2)}{mmdd}-{digits(3)}{luhn()}", "mmdd": ["0115", "0704", "1218"] } }
|
||
```
|
||
|
||
Renders e.g. `19811218-9876`. `{seq()}` spans `Fake` calls and `repeat`, resets
|
||
with a new generator, and is the natural primary key for the SQL example above.
|
||
|
||
### Computation
|
||
|
||
`{calc(expr)}` evaluates `+ - * /`, parentheses and unary minus over number
|
||
literals and sibling field names, each rendered then read as a number; a second
|
||
argument rounds to that many decimals. A hyphen is always subtraction, so a
|
||
hyphenated field can't be an operand.
|
||
|
||
```json
|
||
{ "format": "{net} x {qty} = {calc(net * qty, 2)}", "net": ["19.99", "5.00"], "qty": ["3", "7"] }
|
||
```
|
||
|
||
Renders e.g. `19.99 x 3 = 59.97`: an operand is drawn once per expansion, so the
|
||
operand shown is the operand computed. A non-numeric operand yields `NaN` and a
|
||
division by zero `Inf`; both print rather than fail.
|
||
|
||
### Transforms
|
||
|
||
`{lowercase(x)}`, `{uppercase(x)}` and `{ascii(x)}` rewrite the value of `x` — a
|
||
field, a path or a `..path` — and nest. `ascii` folds Latin letters (`Åsa Öberg`
|
||
→ `Asa Oberg`) and drops any other non-ASCII rune. Like a calc operand, `x` is
|
||
drawn once per expansion, so an email built from a name matches the name beside it:
|
||
|
||
```json
|
||
{ "format": "{p.first} {p.last} <{lowercase(ascii(p.first))}.{lowercase(ascii(p.last))}@example.com>",
|
||
"p": [
|
||
{ "format": "{first} {last}", "first": "Åsa", "last": "Öberg" },
|
||
{ "format": "{first} {last}", "first": "Bo", "last": "Ek" }
|
||
] }
|
||
```
|
||
|
||
Renders `Åsa Öberg <asa.oberg@example.com>` or `Bo Ek <bo.ek@example.com>`, never
|
||
a mix.
|
||
|
||
### References
|
||
|
||
`{..path}` renders a node from the **data root** — the path `Fake` takes, across
|
||
every loaded source — so one category borrows another, even across folders or
|
||
layered directories:
|
||
|
||
```json
|
||
"Hej, {..en_US.person}!"
|
||
```
|
||
|
||
Renders e.g. `Hej, Pat Smith!`. A reference into a category is held like a
|
||
[correlated](#correlated-fields) path — `{..sv_SE.person.first} {..sv_SE.person.last}`
|
||
name one person, `{lowercase(..sv_SE.person.first)}` reads that same draw — while
|
||
a bare `{..misc.uuid} {..misc.uuid}` is two draws. Rejected at `New`: a path that
|
||
is unknown, names a folder, or reads a field not every variant of a choice
|
||
carries, and a reference that leads back to its own value, directly, mutually or
|
||
through a chain.
|
||
|
||
### Correlated fields
|
||
|
||
`{name.field}` reads a path into a sibling, and a sibling read that way is drawn
|
||
**once per expansion**, so several tokens read one row — a locality and the
|
||
postal code that really covers it:
|
||
|
||
```json
|
||
{ "format": "{street} {int(1,99)}\n{place.postal-code} {place.locality}",
|
||
"street": ["Kungsgatan", "Storgatan"],
|
||
"place": [
|
||
{ "format": "{locality}", "locality": "Stockholm", "postal-code": "1{digits(2)} {digits(2)}", "weight": 975 },
|
||
{ "format": "{locality}", "locality": "Tranås", "postal-code": "573 {digits(2)}", "weight": 18 }
|
||
] }
|
||
```
|
||
|
||
Renders e.g. `Kungsgatan 35` / `176 99 Stockholm`, never a Stockholm code beside
|
||
Tranås; each row's `weight` says how often it appears. A path is held at every
|
||
level it passes through (`{p.geo.town.name} {p.geo.town.zip}` share the town),
|
||
one path read twice reads one value, and a field no path addresses is drawn each
|
||
time (`{word} {word}` differs). The hold lasts one expansion: each `repeat`
|
||
iteration and each nested template draws again. Every variant of a choice on the
|
||
path must carry the rest of it, so a row missing a field is named at load:
|
||
|
||
```text
|
||
token {place.postal-code}: field "place": not every variant of this 2-way choice carries "postal-code"; all carry [locality]
|
||
```
|
||
|
||
The sub-fields stay addressable — `Fake("address.place.locality")` renders, and
|
||
`List` advertises it. A path may not read into a level carrying a `repeat`.
|
||
|
||
### One draw, one spelling
|
||
|
||
A format may not both **render** a level and **read a path into** it — `{p}`
|
||
beside `{p.first}`, `{..cat.net}` beside `{calc(net * 2)}`, or `{q}` beside
|
||
`{p.first}` where `q` renders `{..cat.p.last}` — because the render draws afresh
|
||
while the path reads the held draw, and the two would disagree. Wherever the
|
||
second route sits, it is a load error naming the spelling to use:
|
||
|
||
```text
|
||
token {p} renders a level that {p.first} reads a path into; name the fields you want instead
|
||
```
|
||
|
||
### Performance
|
||
|
||
Each file is parsed, validated and weight-indexed once, in `New`. A `Fake` call
|
||
then costs about what its output costs: an unweighted pick is O(1) whatever the
|
||
list's length, a weighted one O(log n), and long formats, deep nesting and many
|
||
tokens add cost in proportion to the output.
|
||
|
||
## Decisions
|
||
|
||
- **Options and fields share one namespace.** `format`, `weight`, `repeat` and
|
||
`separator` are reserved; every other key is a field. Nesting fields under a
|
||
key, or prefixing options, would tax every template to guard against a
|
||
misspelt option.
|
||
- **`{a|b}` stays beside nested choices.** `[[…], […]]` picks the same way, but
|
||
its arms are anonymous; `{femalefirst|malefirst}` keeps `person.femalefirst`
|
||
addressable.
|
||
- **`--name value` and `--name=value` both work.** GNU getopt_long convention,
|
||
which every shell user expects. A single-dash long flag is rejected naming the
|
||
double-dash spelling.
|
||
- **The shipped data is embedded, not discovered.** A directory a machine happens
|
||
to have would make `--seed 42` machine-dependent. Data still lives in `data/`
|
||
as JSON; `--data-path` layers over it.
|
||
- **Samples say what they emit, transforms what they do.** `{upper(2)}` is two
|
||
letters, `{uppercase(x)}` is `x` upper-cased; one name for both would turn on
|
||
whether the argument looks like a number.
|
||
|
||
## Development
|
||
|
||
Everything runs in Docker — **no local tooling beyond Docker is needed**.
|
||
Source is bind-mounted; build caches persist in the `gocache` volume.
|
||
|
||
```sh
|
||
docker compose run --rm test # go test -race
|
||
docker compose run --rm cover # tests with coverage
|
||
docker compose run --rm bench # benchmarks
|
||
docker compose run --rm build # compile the library
|
||
docker compose run --rm vet # go vet
|
||
docker compose run --rm dev # interactive shell
|
||
```
|
||
|
||
Commands that rewrite source keep your file ownership when run with `--user`:
|
||
|
||
```sh
|
||
docker compose run --rm --user "$(id -u):$(id -g)" fmt # gofmt -w .
|
||
docker compose run --rm --user "$(id -u):$(id -g)" tidy # go mod tidy
|
||
```
|
||
|
||
Every pull request runs `docker build .` against both the latest and the lowest
|
||
supported Go, and must pass before it can be merged. That build is the whole
|
||
gate — vet, format check and tests — so run it locally before pushing:
|
||
|
||
```sh
|
||
docker build . # latest
|
||
docker build --build-arg GO_VERSION=1.22.12 . # lowest supported
|
||
GO_VERSION=1.22.12 docker compose run --rm test # the same tests, without the image build
|
||
```
|
||
|
||
## Layout
|
||
|
||
```
|
||
fejkdata.go Generator, New, options, the embedded data set, List
|
||
node.go the node model and JSON -> node compilation
|
||
render.go Fake and the recursive renderer (choices, format strings, paths, held draws)
|
||
template.go the {token} grammar: scanning, arms, operands, validation
|
||
reference.go {..path} binding across the tree, the render graph, and the walks over it
|
||
builtins.go the {name()} function registry and its implementations
|
||
calc.go the {calc()} arithmetic evaluator: parser, eval, validation
|
||
data.go data loading: fs.FS folders/files -> namespace tree, multi-source merge
|
||
cmd/fejkdata/ the fejkdata CLI
|
||
data/ shipped data (JSON), embedded at build: locale folders + a misc folder
|
||
format-migration/ converters from the pre-release grammar; delete before the first tag
|
||
```
|
||
|
||
## License
|
||
|
||
MIT — see [LICENSE](LICENSE).
|