Embed the shipped data, load any fs.FS, New takes options, Fake is safe for concurrent use
This commit is contained in:
@@ -28,29 +28,29 @@ lacked the locale coverage and format control we needed.
|
|||||||
|
|
||||||
## CLI
|
## CLI
|
||||||
|
|
||||||
Install the `fejkdata` command, then give it one or more `--data-path` directories
|
Install the `fejkdata` command and give it a path — it prints one value to stdout.
|
||||||
and a path — it prints one value to stdout. Each dot segment descends one level:
|
The shipped data set is built in; each dot segment descends one level: folders,
|
||||||
folders, then the category (a JSON file), then fields inside it.
|
then the category (a JSON file), then fields inside it.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go install gitea.larvit.se/larvit/fejkdata/cmd/fejkdata@latest
|
go install gitea.larvit.se/larvit/fejkdata/cmd/fejkdata@latest
|
||||||
|
|
||||||
fejkdata --data-path ./data/sv_SE person # Sara Eriksson
|
fejkdata sv_SE.person # Sara Eriksson
|
||||||
fejkdata --data-path ./data/sv_SE person.last # Eriksson (dotted path into a category)
|
fejkdata sv_SE.person.last # Eriksson (dotted path into a category)
|
||||||
fejkdata --data-path ./data sv_SE.person # point at the tree; the folder is a segment
|
fejkdata --seed 42 sv_SE.address
|
||||||
fejkdata -d ./data/sv_SE -d ./mydata word # layer dirs; the last wins a name clash
|
fejkdata --repeat 3 sv_SE.person # three values, one per line
|
||||||
fejkdata --seed 42 --data-path ./data/sv_SE address
|
fejkdata -n 3 --separator ', ' sv_SE.word # nät, barn, sol
|
||||||
fejkdata --repeat 3 --data-path ./data/sv_SE person # three values, one per line
|
fejkdata --list # every path the data offers
|
||||||
fejkdata -n 3 --separator ', ' --data-path ./data/sv_SE word # nät, barn, sol
|
fejkdata --data-path ./mydata sv_SE.word # layer a dir over the shipped data; last wins a clash
|
||||||
fejkdata --data-path ./data/sv_SE --list # every path this data offers
|
fejkdata --no-shipped-data -d ./mydata --list # only your data
|
||||||
```
|
```
|
||||||
|
|
||||||
Flags are GNU-style: `--name value` or `--name=value`, short aliases `-d`, `-n`,
|
Flags are GNU-style: `--name value` or `--name=value`, short aliases `-d`, `-n`,
|
||||||
`-s`, `-h`, in any position; `--` ends the flags. `--data-path` is repeatable
|
`-s`, `-h`, in any position; `--` ends the flags. `--data-path` is repeatable
|
||||||
(last wins a name clash). `--repeat N` renders the path N times — each an
|
(last wins a name clash) and `--no-shipped-data` leaves the built-in set out.
|
||||||
independent draw — joined by `--separator` (default a newline, so values land one
|
`--repeat N` renders the path N times — each an independent draw — joined by
|
||||||
per line). Not sure what a data set offers? `--list` prints every path you can ask
|
`--separator` (default a newline, so values land one per line). `--list` prints
|
||||||
for; `--version` prints the build version.
|
every path you can ask for; `--version` prints the build version.
|
||||||
|
|
||||||
Without installing, run it from a checkout with `go run ./cmd/fejkdata …`. Exit
|
Without installing, run it from a checkout with `go run ./cmd/fejkdata …`. Exit
|
||||||
codes: `0` success (including `--list`, `--version`, `--help`), `1` runtime error
|
codes: `0` success (including `--list`, `--version`, `--help`), `1` runtime error
|
||||||
@@ -59,7 +59,7 @@ codes: `0` success (including `--list`, `--version`, `--help`), `1` runtime erro
|
|||||||
### Generating a file from a custom template
|
### Generating a file from a custom template
|
||||||
|
|
||||||
A category is just a JSON file in a data directory, so you can drop in your
|
A category is just a JSON file in a data directory, so you can drop in your
|
||||||
own and render it — no code change. Save this as `data/sv_SE/sql.json`:
|
own and render it — no code change. Save this as `mydata/sql.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -79,7 +79,7 @@ the `),(` separator; the outer `V#ALUES(…)` wraps that into one valid row list
|
|||||||
letter token — see [Data format](#data-format).)
|
letter token — see [Data format](#data-format).)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
fejkdata --seed 1 --data-path ./data/sv_SE sql
|
fejkdata --seed 1 --data-path ./mydata sql
|
||||||
# INSERT INTO users VALUES('zoom'),('wahoo'),('blip');
|
# INSERT INTO users VALUES('zoom'),('wahoo'),('blip');
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ Raise the template's `repeat` for more rows per statement; use the CLI's
|
|||||||
`--repeat` for more statements — together they build a whole seed file:
|
`--repeat` for more statements — together they build a whole seed file:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
fejkdata --repeat 100 --data-path ./data/sv_SE sql > seed.sql
|
fejkdata --repeat 100 --data-path ./mydata sql > seed.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
## Library
|
## Library
|
||||||
@@ -96,9 +96,9 @@ fejkdata --repeat 100 --data-path ./data/sv_SE sql > seed.sql
|
|||||||
go get gitea.larvit.se/larvit/fejkdata # requires Go 1.22+ (for math/rand/v2)
|
go get gitea.larvit.se/larvit/fejkdata # requires Go 1.22+ (for math/rand/v2)
|
||||||
```
|
```
|
||||||
|
|
||||||
Point `New` at one or more data directories, then generate values by path with
|
`New` loads the shipped data set, plus any `WithDataPath` directories layered over
|
||||||
`Fake`. Each dot segment descends one level: folders, then the category (a JSON
|
it; generate values by path with `Fake`. Each dot segment descends one level:
|
||||||
file), then fields inside it.
|
folders, then the category (a JSON file), then fields inside it.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
package main
|
package main
|
||||||
@@ -111,37 +111,40 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
f, err := fejkdata.New([]string{"./data/sv_SE"})
|
f, err := fejkdata.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, path := range []string{"person", "address", "phone", "address.locality"} {
|
for _, path := range []string{"sv_SE.person", "sv_SE.address", "sv_SE.phone", "sv_SE.address.locality"} {
|
||||||
v, err := f.Fake(path)
|
v, err := f.Fake(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
fmt.Printf("%-18s %s\n", path, v)
|
fmt.Printf("%-24s %s\n", path, v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
```
|
```
|
||||||
person Sara Eriksson
|
sv_SE.person Sara Eriksson
|
||||||
address Kungsvägen 68
|
sv_SE.address Kungsvägen 68
|
||||||
379 17 Stockholm
|
379 17 Stockholm
|
||||||
phone 072-402 91 67
|
sv_SE.phone 072-402 91 67
|
||||||
address.locality Linköping
|
sv_SE.address.locality Linköping
|
||||||
```
|
```
|
||||||
|
|
||||||
Seed a generator for reproducible output — same seed + locale yields an identical
|
Options: `WithSeed(n)` for a reproducible sequence — same seed + data yields an
|
||||||
sequence, handy for stable tests:
|
identical sequence, handy for stable tests; `WithDataPath(dir)` layers a directory
|
||||||
|
(repeat it to layer several, last wins a clash); `WithDataFS(fsys)` layers an
|
||||||
|
`fs.FS`, such as your own `embed.FS`; `WithoutShippedData()` loads only what you
|
||||||
|
give.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
a, _ := fejkdata.New([]string{"./data/sv_SE"}, fejkdata.WithSeed(42))
|
a, _ := fejkdata.New(fejkdata.WithSeed(42))
|
||||||
b, _ := fejkdata.New([]string{"./data/sv_SE"}, fejkdata.WithSeed(42))
|
b, _ := fejkdata.New(fejkdata.WithSeed(42))
|
||||||
av, _ := a.Fake("person")
|
av, _ := a.Fake("sv_SE.person")
|
||||||
bv, _ := b.Fake("person")
|
bv, _ := b.Fake("sv_SE.person")
|
||||||
av == bv // true
|
av == bv // true
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -149,28 +152,27 @@ av == bv // true
|
|||||||
dotted fields and folder segments (what the CLI's `--list` prints). Every path it
|
dotted fields and folder segments (what the CLI's `--list` prints). Every path it
|
||||||
lists renders.
|
lists renders.
|
||||||
|
|
||||||
A `*Generator` is **not** safe for concurrent use — create one per goroutine.
|
A `*Generator` is safe for concurrent use; a seeded sequence is reproducible only
|
||||||
|
when drawn from one goroutine.
|
||||||
|
|
||||||
## Data
|
## Data
|
||||||
|
|
||||||
The library ships a ready-to-use set under [`data/`](data): one folder per locale
|
The shipped set under [`data/`](data) — one folder per locale (`en_US`, `sv_SE`)
|
||||||
(`en_US`, `sv_SE`) plus a locale-neutral `misc` folder. Point either tool at the
|
plus a locale-neutral `misc` folder — is embedded in the library and the CLI, so
|
||||||
whole tree, a single folder, a copy, or your own directory — anywhere on disk. A
|
both work with no data on disk. Layer your own directories over it; a category or
|
||||||
category or folder name must not use `.`, `|`, `(` or `}` (see [Data
|
folder name must not use `.`, `|`, `(` or `}` (see [Data format](#data-format)),
|
||||||
format](#data-format)), and dot-prefixed entries are skipped, so a data directory
|
and dot-prefixed entries are skipped, so a data directory can also be a checkout.
|
||||||
can also be a checkout.
|
|
||||||
|
|
||||||
A directory is just a namespace. Each JSON file is a category named after the
|
A directory is just a namespace. Each JSON file is a category named after the
|
||||||
file; each subdirectory is a dot-path segment — folders nest exactly like JSON
|
file; each subdirectory is a dot-path segment — folders nest exactly like JSON
|
||||||
objects do. So `data/sv_SE/person.json` is `Fake("person")` when you point at
|
objects do. So `mydata/sv_SE/person.json` is `Fake("sv_SE.person")`.
|
||||||
`data/sv_SE`, or `Fake("sv_SE.person")` when you point at `data`.
|
|
||||||
|
|
||||||
Pass several directories and they merge, left to right: matching folders combine
|
Sources merge in order: matching folders combine by their children, and any other
|
||||||
by their children, and any other clash is won by the last directory loaded. That
|
clash is won by the last one loaded. That lets you override a shipped category
|
||||||
lets you layer your own data over the built-ins without copying them:
|
without copying the rest:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
fejkdata.New([]string{"./data/sv_SE", "./mydata"}) // mydata overrides on a clash
|
fejkdata.New(fejkdata.WithDataPath("./mydata")) // mydata/sv_SE/person.json replaces sv_SE.person
|
||||||
```
|
```
|
||||||
|
|
||||||
Each shipped locale carries these categories, formatted per locale (e.g. `date`
|
Each shipped locale carries these categories, formatted per locale (e.g. `date`
|
||||||
@@ -517,16 +519,16 @@ docker compose run --rm test # latest
|
|||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
fejkdata.go Generator, New, List, options, seeding
|
fejkdata.go Generator, New, options, the embedded data set, List
|
||||||
node.go the node model and JSON -> node compilation
|
node.go the node model and JSON -> node compilation
|
||||||
render.go Fake and the recursive renderer (choices, format strings, paths, bound draws)
|
render.go Fake and the recursive renderer (choices, format strings, paths, bound draws)
|
||||||
template.go the {token} grammar: scanning, function and path tokens, validation
|
template.go the {token} grammar: scanning, function and path tokens, validation
|
||||||
reference.go {..path} binding across the tree, the render graph, and the walks over it
|
reference.go {..path} binding across the tree, the render graph, and the walks over it
|
||||||
builtins.go the {name()} function registry and its implementations
|
builtins.go the {name()} function registry and its implementations
|
||||||
calc.go the {calc()} arithmetic evaluator: parser, eval, validation
|
calc.go the {calc()} arithmetic evaluator: parser, eval, validation
|
||||||
data.go data loading: folders/files -> namespace tree, multi-path merge
|
data.go data loading: fs.FS folders/files -> namespace tree, multi-source merge
|
||||||
cmd/fejkdata/ the `fejkdata` CLI (New + Fake/List over stdout)
|
cmd/fejkdata/ the `fejkdata` CLI (New + Fake/List over stdout)
|
||||||
data/ shipped data (JSON): locale folders + a misc folder
|
data/ shipped data (JSON), embedded at build: locale folders + a misc folder
|
||||||
```
|
```
|
||||||
|
|
||||||
To add a category, drop a JSON file into a data directory; to add a locale, add
|
To add a category, drop a JSON file into a data directory; to add a locale, add
|
||||||
|
|||||||
+25
-16
@@ -1,10 +1,10 @@
|
|||||||
// Command fejkdata prints fake values from one or more data directories.
|
// Command fejkdata prints fake values from the shipped data and any directories
|
||||||
|
// layered over it.
|
||||||
//
|
//
|
||||||
// fejkdata --data-path ./data/sv_SE person # a full person
|
// fejkdata sv_SE.person # a full person
|
||||||
// fejkdata --data-path ./data/sv_SE person.last # just the surname
|
// fejkdata sv_SE.person.last # just the surname
|
||||||
// fejkdata --data-path ./data sv_SE.person # point at the tree, address by folder
|
// fejkdata --data-path ./mydata sv_SE.person # layer custom data; the last dir wins
|
||||||
// fejkdata --data-path ./data/sv_SE --data-path ./mydata person # layer custom data; last dir wins
|
// fejkdata --seed 42 sv_SE.address
|
||||||
// fejkdata --seed 42 --data-path ./data/sv_SE address
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -23,13 +23,14 @@ const usage = `Usage: fejkdata [flags] <path>
|
|||||||
|
|
||||||
<path> a category, or a dotted path into one (person, person.last)
|
<path> a category, or a dotted path into one (person, person.last)
|
||||||
|
|
||||||
-d, --data-path D a data directory, e.g. ./data/sv_SE (repeatable; last wins on a clash)
|
-d, --data-path D a data directory to layer over the shipped data (repeatable; last wins on a clash)
|
||||||
-h, --help print this help, then exit
|
-h, --help print this help, then exit
|
||||||
--list list the paths the data offers, then exit
|
--list list the paths the data offers, then exit
|
||||||
-n, --repeat N render the path N times (default 1)
|
--no-shipped-data load only the --data-path directories
|
||||||
-s, --seed N seed for reproducible output
|
-n, --repeat N render the path N times (default 1)
|
||||||
--separator S string between repeated values (default newline)
|
-s, --seed N seed for reproducible output
|
||||||
--version print the version, then exit
|
--separator S string between repeated values (default newline)
|
||||||
|
--version print the version, then exit
|
||||||
|
|
||||||
Flags may come before or after <path>; -- ends the flags.
|
Flags may come before or after <path>; -- ends the flags.
|
||||||
`
|
`
|
||||||
@@ -38,6 +39,7 @@ type invocation struct {
|
|||||||
dirs []string
|
dirs []string
|
||||||
help bool
|
help bool
|
||||||
list bool
|
list bool
|
||||||
|
noShipped bool
|
||||||
paths []string
|
paths []string
|
||||||
repeat int
|
repeat int
|
||||||
seed uint64
|
seed uint64
|
||||||
@@ -57,6 +59,7 @@ var flagDefs = []flagDef{
|
|||||||
{"data-path", "d", true, func(in *invocation, v string) error { in.dirs = append(in.dirs, v); return nil }},
|
{"data-path", "d", true, func(in *invocation, v string) error { in.dirs = append(in.dirs, v); return nil }},
|
||||||
{"help", "h", false, func(in *invocation, _ string) error { in.help = true; return nil }},
|
{"help", "h", false, func(in *invocation, _ string) error { in.help = true; return nil }},
|
||||||
{"list", "", false, func(in *invocation, _ string) error { in.list = true; return nil }},
|
{"list", "", false, func(in *invocation, _ string) error { in.list = true; return nil }},
|
||||||
|
{"no-shipped-data", "", false, func(in *invocation, _ string) error { in.noShipped = true; return nil }},
|
||||||
{"repeat", "n", true, func(in *invocation, v string) error {
|
{"repeat", "n", true, func(in *invocation, v string) error {
|
||||||
n, err := strconv.Atoi(v)
|
n, err := strconv.Atoi(v)
|
||||||
if err != nil || n < 1 {
|
if err != nil || n < 1 {
|
||||||
@@ -167,8 +170,8 @@ func run(args []string, stdout, stderr io.Writer) int {
|
|||||||
fmt.Fprintln(stdout, "fejkdata "+buildVersion())
|
fmt.Fprintln(stdout, "fejkdata "+buildVersion())
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
if len(in.dirs) == 0 {
|
if in.noShipped && len(in.dirs) == 0 {
|
||||||
return misuse(stderr, errors.New("--data-path is required"))
|
return misuse(stderr, errors.New("--no-shipped-data needs at least one --data-path"))
|
||||||
}
|
}
|
||||||
if in.list && len(in.paths) > 0 {
|
if in.list && len(in.paths) > 0 {
|
||||||
return misuse(stderr, errors.New("--list takes no path"))
|
return misuse(stderr, errors.New("--list takes no path"))
|
||||||
@@ -178,10 +181,16 @@ func run(args []string, stdout, stderr io.Writer) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var opts []fejkdata.Option
|
var opts []fejkdata.Option
|
||||||
|
if in.noShipped {
|
||||||
|
opts = append(opts, fejkdata.WithoutShippedData())
|
||||||
|
}
|
||||||
|
for _, dir := range in.dirs {
|
||||||
|
opts = append(opts, fejkdata.WithDataPath(dir))
|
||||||
|
}
|
||||||
if in.seeded {
|
if in.seeded {
|
||||||
opts = append(opts, fejkdata.WithSeed(in.seed))
|
opts = append(opts, fejkdata.WithSeed(in.seed))
|
||||||
}
|
}
|
||||||
f, err := fejkdata.New(in.dirs, opts...)
|
f, err := fejkdata.New(opts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(stderr, err)
|
fmt.Fprintln(stderr, err)
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -3,29 +3,60 @@ package fejkdata
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// loadData loads every given directory into one namespace tree and returns its
|
// dataSource is one tree to load: an fs.FS and the directory in it to start from.
|
||||||
// root children. A directory becomes a group; each *.json file in it compiles to
|
// label prefixes file names in errors; path, when set, is a directory on disk that
|
||||||
// a node keyed by its base name (address.json -> "address"); each subdirectory
|
// must exist.
|
||||||
// becomes a nested group, so folders turn into dot-path segments. Paths are
|
type dataSource struct {
|
||||||
// merged left to right: matching groups merge by their children, and any other
|
fsys fs.FS
|
||||||
// clash (a leaf, or a leaf-vs-group) is won by the last directory loaded. Once
|
label string
|
||||||
// merged, linkRefs binds every {..path} reference against the final tree.
|
path string
|
||||||
func loadData(paths []string) (map[string]node, error) {
|
root string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s dataSource) name(p string) string {
|
||||||
|
if s.label == "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return path.Join(s.label, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadData loads every source into one namespace tree and returns its root
|
||||||
|
// children. A directory becomes a group; each *.json file in it compiles to a node
|
||||||
|
// keyed by its base name (address.json -> "address"); each subdirectory becomes a
|
||||||
|
// nested group, so folders turn into dot-path segments. Sources merge left to right:
|
||||||
|
// matching groups merge by their children, and any other clash is won by the last
|
||||||
|
// source loaded. Once merged, linkRefs binds every {..path} reference against the
|
||||||
|
// final tree.
|
||||||
|
func loadData(sources []dataSource) (map[string]node, error) {
|
||||||
root := map[string]node{}
|
root := map[string]node{}
|
||||||
for _, p := range paths {
|
for _, src := range sources {
|
||||||
g, err := loadDir(p)
|
if src.path != "" {
|
||||||
|
info, err := os.Stat(src.path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: no such directory", src.path)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return nil, fmt.Errorf("%s is not a directory", src.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dir := src.root
|
||||||
|
if dir == "" {
|
||||||
|
dir = "."
|
||||||
|
}
|
||||||
|
g, err := loadDir(src, dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
mergeChildren(root, g.children)
|
mergeChildren(root, g.children)
|
||||||
}
|
}
|
||||||
if len(root) == 0 {
|
if len(root) == 0 {
|
||||||
return nil, fmt.Errorf("no .json data found in %v", paths)
|
return nil, fmt.Errorf("no .json data found")
|
||||||
}
|
}
|
||||||
if err := linkRefs(root); err != nil {
|
if err := linkRefs(root); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -39,29 +70,22 @@ func loadData(paths []string) (map[string]node, error) {
|
|||||||
return root, nil
|
return root, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadDir compiles one directory into a group. os.ReadDir yields entries sorted
|
// loadDir compiles one directory into a group. fs.ReadDir yields entries sorted
|
||||||
// by name, so the tree is built deterministically. Empty subdirectories (no JSON
|
// by name, so the tree is built deterministically. Empty subdirectories (no JSON
|
||||||
// anywhere under them) are skipped rather than added as empty namespaces.
|
// anywhere under them) are skipped rather than added as empty namespaces.
|
||||||
func loadDir(dir string) (*group, error) {
|
func loadDir(src dataSource, dir string) (*group, error) {
|
||||||
info, err := os.Stat(dir)
|
entries, err := fs.ReadDir(src.fsys, dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("%s: %w", src.name(dir), err)
|
||||||
}
|
|
||||||
if !info.IsDir() {
|
|
||||||
return nil, fmt.Errorf("%s is not a directory", dir)
|
|
||||||
}
|
|
||||||
entries, err := os.ReadDir(dir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
g := &group{children: map[string]node{}}
|
g := &group{children: map[string]node{}}
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if strings.HasPrefix(e.Name(), ".") { // hidden: a checkout or an editor's file, never data
|
if strings.HasPrefix(e.Name(), ".") { // hidden: a checkout or an editor's file, never data
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
full := filepath.Join(dir, e.Name())
|
full := path.Join(dir, e.Name())
|
||||||
if e.IsDir() {
|
if e.IsDir() {
|
||||||
child, err := loadDir(full)
|
child, err := loadDir(src, full)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -69,7 +93,7 @@ func loadDir(dir string) (*group, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := checkName(e.Name()); err != nil {
|
if err := checkName(e.Name()); err != nil {
|
||||||
return nil, fmt.Errorf("%s: folder %w", full, err)
|
return nil, fmt.Errorf("%s: folder %w", src.name(full), err)
|
||||||
}
|
}
|
||||||
g.children[e.Name()] = child
|
g.children[e.Name()] = child
|
||||||
continue
|
continue
|
||||||
@@ -79,19 +103,19 @@ func loadDir(dir string) (*group, error) {
|
|||||||
}
|
}
|
||||||
name := strings.TrimSuffix(e.Name(), ".json")
|
name := strings.TrimSuffix(e.Name(), ".json")
|
||||||
if err := checkName(name); err != nil {
|
if err := checkName(name); err != nil {
|
||||||
return nil, fmt.Errorf("%s: category %w", full, err)
|
return nil, fmt.Errorf("%s: category %w", src.name(full), err)
|
||||||
}
|
}
|
||||||
b, err := os.ReadFile(full)
|
b, err := fs.ReadFile(src.fsys, full)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("%s: %w", src.name(full), err)
|
||||||
}
|
}
|
||||||
var raw any
|
var raw any
|
||||||
if err := json.Unmarshal(b, &raw); err != nil {
|
if err := json.Unmarshal(b, &raw); err != nil {
|
||||||
return nil, fmt.Errorf("%s: %w", full, err)
|
return nil, fmt.Errorf("%s: %w", src.name(full), err)
|
||||||
}
|
}
|
||||||
n, err := compile(raw)
|
n, err := compile(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%s: %w", full, err)
|
return nil, fmt.Errorf("%s: %w", src.name(full), err)
|
||||||
}
|
}
|
||||||
g.children[name] = n
|
g.children[name] = n
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-30
@@ -1,78 +1,110 @@
|
|||||||
// Package fejkdata generates fake data from recursive JSON templates.
|
// Package fejkdata generates fake data from recursive JSON templates.
|
||||||
//
|
//
|
||||||
// Data lives in JSON on disk, not in Go. Point [New] at one or more data
|
// Data lives in JSON, not in Go. A generator starts from the shipped data set and
|
||||||
// directories; folders and files become a dot-path namespace, then generate
|
// layers any directories you add; folders and files become a dot-path namespace,
|
||||||
// values by path:
|
// then generate values by path:
|
||||||
//
|
//
|
||||||
// f, _ := fejkdata.New([]string{"./data/sv_SE"}, fejkdata.WithSeed(42))
|
// f, _ := fejkdata.New(fejkdata.WithSeed(42))
|
||||||
// f.Fake("address") // "Storgatan 12\n234 56 Göteborg"
|
// f.Fake("sv_SE.address") // "Storgatan 12\n234 56 Göteborg"
|
||||||
// f.Fake("address.locality") // "Göteborg"
|
// f.Fake("sv_SE.address.locality") // "Göteborg"
|
||||||
//
|
//
|
||||||
// A subdirectory is a namespace segment, so pointing at "./data" instead reaches
|
// Several sources merge in order, the last winning a name clash, so custom data
|
||||||
// a category as "sv_SE.address". Several directories are merged left to right;
|
// layers over the built-ins. The JSON template format is documented in the README.
|
||||||
// the last one wins on a name clash, so you can layer custom data over the
|
|
||||||
// built-ins. The JSON template format is documented in the README.
|
|
||||||
//
|
|
||||||
// A [Generator] is not safe for concurrent use; create one per goroutine.
|
|
||||||
package fejkdata
|
package fejkdata
|
||||||
|
|
||||||
import (
|
import (
|
||||||
crand "crypto/rand"
|
crand "crypto/rand"
|
||||||
|
"embed"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
//go:embed data
|
||||||
|
var shippedFS embed.FS
|
||||||
|
|
||||||
// Generator generates fake data from a loaded namespace tree. Create one with [New].
|
// Generator generates fake data from a loaded namespace tree. Create one with [New].
|
||||||
|
// It is safe for concurrent use; a seeded sequence is reproducible only when drawn
|
||||||
|
// from one goroutine.
|
||||||
type Generator struct {
|
type Generator struct {
|
||||||
|
mu sync.Mutex
|
||||||
rand *session
|
rand *session
|
||||||
categories map[string]node // root namespace: name -> compiled node tree
|
categories map[string]node
|
||||||
}
|
}
|
||||||
|
|
||||||
// session is one generator's mutable render state: the seeded rng plus the {seq()}
|
// session is one generator's mutable render state: the seeded rng plus the {seq()}
|
||||||
// counters. Scoping it to the Generator means sequences (and randomness) belong to
|
// counters.
|
||||||
// that generator and reset when you create a new one. Embedding *rand.Rand makes a
|
|
||||||
// *session satisfy the rng interface the renderer draws from.
|
|
||||||
type session struct {
|
type session struct {
|
||||||
*rand.Rand
|
*rand.Rand
|
||||||
counters map[string]uint64
|
counters map[string]uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
// next returns the next value (counting from 1) of the named {seq()} counter.
|
|
||||||
func (s *session) next(key string) uint64 {
|
func (s *session) next(key string) uint64 {
|
||||||
s.counters[key]++
|
s.counters[key]++
|
||||||
return s.counters[key]
|
return s.counters[key]
|
||||||
}
|
}
|
||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
seed uint64
|
seed uint64
|
||||||
seeded bool
|
seeded bool
|
||||||
|
shipped bool
|
||||||
|
sources []dataSource
|
||||||
}
|
}
|
||||||
|
|
||||||
// Option configures a [Generator].
|
// Option configures a [Generator].
|
||||||
type Option func(*config)
|
type Option func(*config)
|
||||||
|
|
||||||
// WithSeed makes output reproducible: two generators with the same seed and locale
|
// WithSeed makes output reproducible: two generators with the same seed and data
|
||||||
// emit identical sequences.
|
// emit identical sequences.
|
||||||
func WithSeed(seed uint64) Option {
|
func WithSeed(seed uint64) Option {
|
||||||
return func(c *config) { c.seed, c.seeded = seed, true }
|
return func(c *config) { c.seed, c.seeded = seed, true }
|
||||||
}
|
}
|
||||||
|
|
||||||
// New builds a generator from one or more data directories (e.g. "./data/sv_SE").
|
// WithDataPath layers a data directory over what is loaded before it. Repeat it to
|
||||||
// Each JSON file becomes a category named after the file (address.json ->
|
// layer several; the last wins a name clash.
|
||||||
// "address") and each subdirectory a namespace segment; directories are merged
|
func WithDataPath(dir string) Option {
|
||||||
// in order, the last winning a name clash. It errors on a missing directory,
|
return func(c *config) {
|
||||||
// invalid JSON, or no data found.
|
c.sources = append(c.sources, dataSource{fsys: os.DirFS(dir), label: dir, path: dir})
|
||||||
func New(paths []string, opts ...Option) (*Generator, error) {
|
|
||||||
cats, err := loadData(paths)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("fejkdata: %w", err)
|
|
||||||
}
|
}
|
||||||
var c config
|
}
|
||||||
|
|
||||||
|
// WithDataFS layers a data tree held in an [fs.FS], such as an embed.FS of your own.
|
||||||
|
func WithDataFS(fsys fs.FS) Option {
|
||||||
|
return func(c *config) { c.sources = append(c.sources, dataSource{fsys: fsys}) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithoutShippedData leaves the shipped data set out, so only the sources given
|
||||||
|
// with [WithDataPath] and [WithDataFS] load.
|
||||||
|
func WithoutShippedData() Option {
|
||||||
|
return func(c *config) { c.shipped = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a generator from the shipped data set and the options' sources, merged
|
||||||
|
// in order with the last winning a name clash. Each JSON file becomes a category
|
||||||
|
// named after the file (address.json -> "address") and each subdirectory a
|
||||||
|
// namespace segment. It errors on a missing directory, invalid JSON, invalid data,
|
||||||
|
// or no data at all.
|
||||||
|
func New(opts ...Option) (*Generator, error) {
|
||||||
|
c := config{shipped: true}
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
opt(&c)
|
opt(&c)
|
||||||
}
|
}
|
||||||
|
var sources []dataSource
|
||||||
|
if c.shipped {
|
||||||
|
sources = append(sources, dataSource{fsys: shippedFS, root: "data"})
|
||||||
|
}
|
||||||
|
sources = append(sources, c.sources...)
|
||||||
|
if len(sources) == 0 {
|
||||||
|
return nil, fmt.Errorf("fejkdata: no data: WithoutShippedData needs at least one WithDataPath or WithDataFS")
|
||||||
|
}
|
||||||
|
cats, err := loadData(sources)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fejkdata: %w", err)
|
||||||
|
}
|
||||||
return &Generator{rand: newRand(c.seed, c.seeded), categories: cats}, nil
|
return &Generator{rand: newRand(c.seed, c.seeded), categories: cats}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ type rng interface {
|
|||||||
// e.g. "sv_SE.address" or "sv_SE.address.street". Choices along the way are
|
// e.g. "sv_SE.address" or "sv_SE.address.street". Choices along the way are
|
||||||
// resolved at random. A path naming a folder (no value of its own) is an error.
|
// resolved at random. A path naming a folder (no value of its own) is an error.
|
||||||
func (f *Generator) Fake(path string) (string, error) {
|
func (f *Generator) Fake(path string) (string, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
n, err := descend(f.rand, &group{children: f.categories}, strings.Split(path, "."))
|
n, err := descend(f.rand, &group{children: f.categories}, strings.Split(path, "."))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("fejkdata: %s: %w", path, err)
|
return "", fmt.Errorf("fejkdata: %s: %w", path, err)
|
||||||
|
|||||||
Reference in New Issue
Block a user