Add fakes CLI (cmd/fakes); document it README-first with an SQL template example
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
# fakes
|
# fakes
|
||||||
|
|
||||||
A Go library for generating fake data, built for **internationalization**. It
|
A Go library and CLI for generating fake data, built for
|
||||||
exists because the existing Go fake libraries lacked the locale coverage and
|
**internationalization**. It exists because the existing Go fake libraries
|
||||||
format control we needed.
|
lacked the locale coverage and format control we needed.
|
||||||
|
|
||||||
- **Standards first** — formats, names and structures follow international and
|
- **Standards first** — formats, names and structures follow international and
|
||||||
local standards first and foremost.
|
local standards first and foremost.
|
||||||
@@ -17,15 +17,62 @@ format control we needed.
|
|||||||
- **Reproducible** — seed a faker and it emits the same sequence every time.
|
- **Reproducible** — seed a faker and it emits the same sequence every time.
|
||||||
- **Zero dependencies** — standard library only.
|
- **Zero dependencies** — standard library only.
|
||||||
|
|
||||||
## Install
|
## CLI
|
||||||
|
|
||||||
|
Install the `fakes` command, then point it at a locale directory and a category
|
||||||
|
path — it prints one value to stdout. The first path segment names a category (a
|
||||||
|
JSON file); deeper dotted segments descend into it.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go get github.com/Timewave-AB/fakes
|
go install github.com/Timewave-AB/fakes/cmd/fakes@latest
|
||||||
|
|
||||||
|
fakes ./locales/sv_SE person # Sara Eriksson
|
||||||
|
fakes ./locales/sv_SE person.last # Eriksson (dotted path into a category)
|
||||||
|
fakes -seed 42 ./locales/sv_SE address
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires Go 1.22+ (for `math/rand/v2`).
|
Without installing, run it from a checkout with `go run ./cmd/fakes …`. Exit
|
||||||
|
codes: `0` success, `1` runtime error (bad locale, unknown path), `2` misuse.
|
||||||
|
|
||||||
## Usage
|
### Generating a file from a custom template
|
||||||
|
|
||||||
|
A category is just a JSON file in the locale directory, so you can drop in your
|
||||||
|
own and render it — no code change. Save this as `locales/sv_SE/sql.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"format": "INSERT INTO users V#ALUES({sql-username});",
|
||||||
|
"sql-username": {
|
||||||
|
"format": "'{username}'",
|
||||||
|
"repeat": 3,
|
||||||
|
"separator": "),(",
|
||||||
|
"username": ["pixelfox", "snork", "turbohund", "blip", "zoom", "wahoo"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`sql-username` renders `'{username}'` `repeat` times and joins the results with
|
||||||
|
the `),(` separator; the outer `V#ALUES(…)` wraps that into one valid row list.
|
||||||
|
(`#A` escapes the literal `A`, which a format string would otherwise read as a
|
||||||
|
letter token — see [Locale data format](#locale-data-format).)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
fakes -seed 1 ./locales/sv_SE sql
|
||||||
|
# INSERT INTO users VALUES('zoom'),('wahoo'),('blip');
|
||||||
|
```
|
||||||
|
|
||||||
|
Raise `repeat` for more rows per statement, or loop in the shell to build a
|
||||||
|
whole seed file:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
for _ in $(seq 100); do fakes ./locales/sv_SE sql; done > seed.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## Library
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get github.com/Timewave-AB/fakes # requires Go 1.22+ (for math/rand/v2)
|
||||||
|
```
|
||||||
|
|
||||||
Point `New` at a locale directory, then generate values by category path with
|
Point `New` at a locale directory, then generate values by category path with
|
||||||
`Fake`. The first path segment names a category (a JSON file); deeper dotted
|
`Fake`. The first path segment names a category (a JSON file); deeper dotted
|
||||||
@@ -78,10 +125,10 @@ av == bv // true
|
|||||||
|
|
||||||
A `*Fakes` is **not** safe for concurrent use — create one per goroutine.
|
A `*Fakes` is **not** safe for concurrent use — create one per goroutine.
|
||||||
|
|
||||||
### Locales
|
## Locales
|
||||||
|
|
||||||
The library ships a ready-to-use set under [`locales/`](locales) (`en_US`,
|
The library ships a ready-to-use set under [`locales/`](locales) (`en_US`,
|
||||||
`sv_SE`). Pass `New` the path to one of them, a copy, or your own directory —
|
`sv_SE`). Point either tool at one of them, a copy, or your own directory —
|
||||||
anywhere on disk.
|
anywhere on disk.
|
||||||
|
|
||||||
Each ships these categories, formatted per locale (e.g. `date` is `MM/DD/YYYY`
|
Each ships these categories, formatted per locale (e.g. `date` is `MM/DD/YYYY`
|
||||||
@@ -220,10 +267,11 @@ docker compose run --rm test # latest
|
|||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
fakes.go Fakes, New, options, seeding
|
fakes.go Fakes, New, options, seeding
|
||||||
template.go Fake, the recursive renderer (choices, format strings, paths)
|
template.go Fake, the recursive renderer (choices, format strings, paths)
|
||||||
locale.go locale loading + tag parsing
|
locale.go locale loading + tag parsing
|
||||||
locales/ shipped locale data (JSON)
|
cmd/fakes/ the `fakes` CLI (New + Fake over stdout)
|
||||||
|
locales/ shipped locale data (JSON)
|
||||||
```
|
```
|
||||||
|
|
||||||
To add a category, drop a JSON file into a locale directory; to add a locale,
|
To add a category, drop a JSON file into a locale directory; to add a locale,
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// Command fakes prints one fake value from a locale directory.
|
||||||
|
//
|
||||||
|
// fakes ./locales/sv_SE person # a full person
|
||||||
|
// fakes ./locales/sv_SE person.last # just the surname (dotted path)
|
||||||
|
// fakes -seed 42 ./locales/sv_SE address
|
||||||
|
//
|
||||||
|
// It is a thin CLI over the fakes library: New(dir) then Fake(path).
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/Timewave-AB/fakes"
|
||||||
|
)
|
||||||
|
|
||||||
|
const usage = `Usage: fakes [-seed N] <locale-dir> <path>
|
||||||
|
|
||||||
|
<locale-dir> a locale directory, e.g. ./locales/sv_SE
|
||||||
|
<path> a category, or a dotted path into one (person, person.last)
|
||||||
|
-seed N seed for reproducible output`
|
||||||
|
|
||||||
|
func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) }
|
||||||
|
|
||||||
|
// run is main's testable core: it returns the process exit code (0 ok, 1
|
||||||
|
// runtime error, 2 misuse) and writes only to the given streams.
|
||||||
|
func run(args []string, stdout, stderr io.Writer) int {
|
||||||
|
fs := flag.NewFlagSet("fakes", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(stderr)
|
||||||
|
fs.Usage = func() { fmt.Fprintln(stderr, usage) }
|
||||||
|
seed := fs.Uint64("seed", 0, "seed for reproducible output")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if fs.NArg() != 2 {
|
||||||
|
fs.Usage()
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
var opts []fakes.Option
|
||||||
|
fs.Visit(func(fl *flag.Flag) {
|
||||||
|
if fl.Name == "seed" {
|
||||||
|
opts = append(opts, fakes.WithSeed(*seed))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
f, err := fakes.New(fs.Arg(0), opts...)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(stderr, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
v, err := f.Fake(fs.Arg(1))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(stderr, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fmt.Fprintln(stdout, v)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const svSE = "../../locales/sv_SE"
|
||||||
|
|
||||||
|
// runOut runs the CLI and returns exit code, stdout, stderr.
|
||||||
|
func runOut(args ...string) (int, string, string) {
|
||||||
|
var out, errb bytes.Buffer
|
||||||
|
code := run(args, &out, &errb)
|
||||||
|
return code, out.String(), errb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunOutputsValue(t *testing.T) {
|
||||||
|
code, out, errb := runOut(svSE, "person")
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("run = %d, stderr=%q", code, errb)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(out) == "" {
|
||||||
|
t.Fatalf("empty output, stderr=%q", errb)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(out, "\n") {
|
||||||
|
t.Errorf("output should end with newline, got %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunDotPath(t *testing.T) {
|
||||||
|
// person.last descends to just a surname — never the full "First Last".
|
||||||
|
code, full, _ := runOut("-seed", "7", svSE, "person")
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("person run = %d", code)
|
||||||
|
}
|
||||||
|
code, last, errb := runOut("-seed", "7", svSE, "person.last")
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("person.last run = %d, stderr=%q", code, errb)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(last) == "" {
|
||||||
|
t.Fatal("empty person.last output")
|
||||||
|
}
|
||||||
|
if last == full {
|
||||||
|
t.Errorf("person.last %q should differ from person %q", last, full)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunSeedDeterministic(t *testing.T) {
|
||||||
|
_, a, _ := runOut("-seed", "42", svSE, "address")
|
||||||
|
_, b, _ := runOut("-seed", "42", svSE, "address")
|
||||||
|
if a != b {
|
||||||
|
t.Errorf("same seed diverged: %q != %q", a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunUsageOnWrongArgCount(t *testing.T) {
|
||||||
|
for _, args := range [][]string{{}, {svSE}, {svSE, "person", "extra"}} {
|
||||||
|
code, _, errb := runOut(args...)
|
||||||
|
if code != 2 {
|
||||||
|
t.Errorf("run(%v) = %d, want 2", args, code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(errb, "Usage") {
|
||||||
|
t.Errorf("run(%v) stderr = %q, want usage", args, errb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunUnknownCategoryFails(t *testing.T) {
|
||||||
|
code, _, errb := runOut(svSE, "nope")
|
||||||
|
if code != 1 {
|
||||||
|
t.Fatalf("run = %d, want 1", code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(errb, "nope") {
|
||||||
|
t.Errorf("stderr %q should name the unknown category", errb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBadLocaleFails(t *testing.T) {
|
||||||
|
code, _, errb := runOut("../../locales/sv", "person")
|
||||||
|
if code != 1 {
|
||||||
|
t.Fatalf("run = %d, want 1", code)
|
||||||
|
}
|
||||||
|
if errb == "" {
|
||||||
|
t.Error("want an error message on stderr")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user