From cde56b7924b2d67525b0d7b5c421d21b753925ba Mon Sep 17 00:00:00 2001 From: lilleman Date: Tue, 9 Jun 2026 01:52:39 +0200 Subject: [PATCH] Add session-scoped seq() builtin; move uuid v4 from locales to misc only --- README.md | 22 +++++++++++------- builtins.go | 50 ++++++++++++++++++++++++++++------------- builtins_test.go | 26 ++++++++++++++++++++++ data/en_US/uuid.json | 6 ----- data/sv_SE/uuid.json | 6 ----- data_test.go | 2 -- fakes.go | 24 ++++++++++++++++---- template.go | 53 +++++++++++++++++++++++--------------------- template_test.go | 1 + 9 files changed, 124 insertions(+), 66 deletions(-) delete mode 100644 data/en_US/uuid.json delete mode 100644 data/sv_SE/uuid.json diff --git a/README.md b/README.md index 2e9fbff..eb9e75f 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ Each shipped locale carries these categories, formatted per locale (e.g. `date` is `MM/DD/YYYY` in `en_US`, `YYYY-MM-DD` in `sv_SE`; `ssn` is a US SSN vs a Swedish personnummer): `address`, `color`, `company`, `date`, `email`, `ip`, `person`, `phone`, `price`, `sentence`, `ssn`, `time`, `url`, `username`, -`uuid`, `version`, `word`. +`version`, `word`. `data/misc` carries locale-neutral categories: `uuid` (a proper random v4), `mac`, and `creditcard` (per-network numbers ending in a valid `{luhn()}` digit). @@ -230,14 +230,15 @@ form prefixes the century outside the checksummed core: "core": { "format": "00{mmdd}-000{luhn()}", "mmdd": [ … ] } } ``` -A function must be deterministic in the seeded rng (no wall-clock), so a seeded -faker stays reproducible. A time-based id (UUID v7, ULID) therefore draws its -timestamp from the rng, not the clock — the result is a valid, reproducible value, -not a real point in time. +A function must be deterministic (no wall-clock), so a seeded faker stays +reproducible. A time-based id (UUID v7, ULID) therefore draws its timestamp from +the rng, not the clock — the result is a valid, reproducible value, not a real +point in time. -There are two kinds. **Derivations** read the digits emitted so far, so put them -after their payload; **generators** read only the rng, so they stand alone. -Arguments are validated at `New` (a bad count, range, or country fails fast). +There are three kinds. **Derivations** read the digits emitted so far, so put them +after their payload; **generators** read only the rng, so they stand alone; one +**session counter** (`seq`) advances state held on the faker. Arguments are +validated at `New` (a bad count, range, or country fails fast). | Function | Kind | Emits | |----------|------|-------| @@ -253,6 +254,7 @@ Arguments are validated at `New` (a bad count, range, or country fails fast). | `{int(min,max)}` | generator | uniform integer in `[min, max]` | | `{float(min,max,dp)}` | generator | number in `[min, max]` with `dp` decimals | | `{iban(CC)}` | generator | a length- and mod-97-valid IBAN for country `CC` (BE, DE, DK, ES, FI, NO, SE) | +| `{seq()}`, `{seq(name)}` | session counter | next integer (from 1) in this faker's sequence; `name` selects an independent counter | `{ean()}` is also the ISBN-13 check (an ISBN-13 *is* an EAN-13 — build the 978/979 prefix in data and call `{ean()}`). `{iban()}` is a generator, not a derivation: @@ -260,6 +262,10 @@ an IBAN's check digits sit *before* the account number, which a left-to-right reader can't reach, so it emits the whole value (a generic numeric BBAN — valid length and checksum, not real bank routing). +`{seq()}`'s counter lives on the faker, so it spans `Fake` calls (and `repeat`) +and resets when you build a new faker — `seq` is reproducible by being ordered, +not random. It's the natural fit for a primary-key column in the SQL example above. + **References.** A `{..path}` token renders a node from the **data root** instead of a sibling field — the dot path is the one `Fake` takes, resolved across every loaded directory. One category can borrow another, even across folders or layered diff --git a/builtins.go b/builtins.go index 006dcbc..c62d2a1 100644 --- a/builtins.go +++ b/builtins.go @@ -15,22 +15,32 @@ import ( // the wall clock. Add a builtin only for what data can't express: a random v4 // UUID already ships as data (data/misc/uuid.json), so the builtin is v7. var builtins = map[string]builtin{ - "luhn": {arity: 0, call: func(_ rng, e string, _ []string) string { return string(rune('0' + luhnCheck(e))) }}, - "mod11": {arity: 0, call: func(_ rng, e string, _ []string) string { return mod11Check(e) }}, - "ean": {arity: 0, call: func(_ rng, e string, _ []string) string { return eanCheck(e) }}, - "uuid": {arity: 0, call: func(r rng, _ string, _ []string) string { return uuidV7(r) }}, - "ulid": {arity: 0, call: func(r rng, _ string, _ []string) string { return ulid(r) }}, - "objectid": {arity: 0, call: func(r rng, _ string, _ []string) string { return randHex(r, 24) }}, - "nanoid": {arity: 1, check: posIntArg, call: func(r rng, _ string, a []string) string { return nanoid(r, atoi(a[0])) }}, - "hex": {arity: 1, check: posIntArg, call: func(r rng, _ string, a []string) string { return randHex(r, atoi(a[0])) }}, - "base64": {arity: 1, check: posIntArg, call: func(r rng, _ string, a []string) string { - return base64.StdEncoding.EncodeToString(randBytes(r, atoi(a[0]))) + "luhn": {arity: 0, call: func(_ *session, e string, _ []string) string { return string(rune('0' + luhnCheck(e))) }}, + "mod11": {arity: 0, call: func(_ *session, e string, _ []string) string { return mod11Check(e) }}, + "ean": {arity: 0, call: func(_ *session, e string, _ []string) string { return eanCheck(e) }}, + "uuid": {arity: 0, call: func(s *session, _ string, _ []string) string { return uuidV7(s) }}, + "ulid": {arity: 0, call: func(s *session, _ string, _ []string) string { return ulid(s) }}, + "objectid": {arity: 0, call: func(s *session, _ string, _ []string) string { return randHex(s, 24) }}, + "nanoid": {arity: 1, check: posIntArg, call: func(s *session, _ string, a []string) string { return nanoid(s, atoi(a[0])) }}, + "hex": {arity: 1, check: posIntArg, call: func(s *session, _ string, a []string) string { return randHex(s, atoi(a[0])) }}, + "base64": {arity: 1, check: posIntArg, call: func(s *session, _ string, a []string) string { + return base64.StdEncoding.EncodeToString(randBytes(s, atoi(a[0]))) }}, - "int": {arity: 2, check: intRangeArgs, call: func(r rng, _ string, a []string) string { - return strconv.Itoa(atoi(a[0]) + r.IntN(atoi(a[1])-atoi(a[0])+1)) + "int": {arity: 2, check: intRangeArgs, call: func(s *session, _ string, a []string) string { + return strconv.Itoa(atoi(a[0]) + s.IntN(atoi(a[1])-atoi(a[0])+1)) }}, "float": {arity: 3, check: floatArgs, call: floatCall}, - "iban": {arity: 1, check: ibanArg, call: func(r rng, _ string, a []string) string { return iban(r, a[0]) }}, + "iban": {arity: 1, check: ibanArg, call: func(s *session, _ string, a []string) string { return iban(s, a[0]) }}, + // seq is the one stateful builtin: a per-session counter from 1, advancing on + // each call. An optional name selects an independent counter; no name uses the + // default one. Deterministic by construction, so a seeded faker stays stable. + "seq": {arity: -1, check: seqArg, call: func(s *session, _ string, a []string) string { + key := "" + if len(a) == 1 { + key = a[0] + } + return strconv.FormatUint(s.next(key), 10) + }}, } const hexDigits = "0123456789abcdef" @@ -89,10 +99,20 @@ func floatArgs(a []string) error { return nil } -func floatCall(r rng, _ string, a []string) string { +func floatCall(s *session, _ string, a []string) string { lo, _ := strconv.ParseFloat(a[0], 64) hi, _ := strconv.ParseFloat(a[1], 64) - return strconv.FormatFloat(lo+r.Float64()*(hi-lo), 'f', atoi(a[2]), 64) + return strconv.FormatFloat(lo+s.Float64()*(hi-lo), 'f', atoi(a[2]), 64) +} + +func seqArg(a []string) error { + if len(a) > 1 { + return fmt.Errorf("seq takes at most one name, got %d args", len(a)) + } + if len(a) == 1 && a[0] == "" { + return fmt.Errorf("seq name must not be empty") + } + return nil } // nanoidAlphabet is the 64-char URL-safe set Nano IDs use (order is irrelevant diff --git a/builtins_test.go b/builtins_test.go index 969dbd8..cd42624 100644 --- a/builtins_test.go +++ b/builtins_test.go @@ -96,6 +96,32 @@ func TestBuiltinChecksums(t *testing.T) { } } +// TestBuiltinSeqPerSession pins seq's contract: a counter from 1, advancing on +// each call, named counters independent, and the whole thing scoped to one faker +// (session) so a fresh faker restarts at 1. +func TestBuiltinSeqPerSession(t *testing.T) { + f := engine(1) + for i := 1; i <= 5; i++ { + if got := mustRender(t, f, `{"format":"{seq()}"}`); got != strconv.Itoa(i) { + t.Fatalf("seq call %d = %q, want %d", i, got, i) + } + } + if got := mustRender(t, f, `{"format":"{seq(orders)}"}`); got != "1" { + t.Fatalf("named seq(orders) = %q, want its own count from 1", got) + } + if got := mustRender(t, f, `{"format":"{seq()}"}`); got != "6" { + t.Fatalf("default seq after the named one = %q, want 6 (counters are independent)", got) + } + // repeat drives one counter forward across its renders... + if got := mustRender(t, engine(2), `{"format":"{seq()}","repeat":3,"separator":","}`); got != "1,2,3" { + t.Fatalf("repeat seq = %q, want 1,2,3", got) + } + // ...and a fresh session restarts from 1. + if got := mustRender(t, engine(2), `{"format":"{seq()}"}`); got != "1" { + t.Fatalf("new session seq = %q, want 1", got) + } +} + func TestBuiltinIBAN(t *testing.T) { wantLen := map[string]int{"SE": 24, "DE": 22, "NO": 15} f := engine(1) diff --git a/data/en_US/uuid.json b/data/en_US/uuid.json deleted file mode 100644 index 5c78bd6..0000000 --- a/data/en_US/uuid.json +++ /dev/null @@ -1,6 +0,0 @@ -[ - { - "format": "{x}{x}{x}{x}{x}{x}{x}{x}-{x}{x}{x}{x}-{x}{x}{x}{x}-{x}{x}{x}{x}-{x}{x}{x}{x}{x}{x}{x}{x}{x}{x}{x}{x}", - "x": ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"] - } -] diff --git a/data/sv_SE/uuid.json b/data/sv_SE/uuid.json deleted file mode 100644 index 5c78bd6..0000000 --- a/data/sv_SE/uuid.json +++ /dev/null @@ -1,6 +0,0 @@ -[ - { - "format": "{x}{x}{x}{x}{x}{x}{x}{x}-{x}{x}{x}{x}-{x}{x}{x}{x}-{x}{x}{x}{x}-{x}{x}{x}{x}{x}{x}{x}{x}{x}{x}{x}{x}", - "x": ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"] - } -] diff --git a/data_test.go b/data_test.go index ec5fbd8..53a5ba9 100644 --- a/data_test.go +++ b/data_test.go @@ -14,7 +14,6 @@ func TestShippedDataCategories(t *testing.T) { letters := regexp.MustCompile(`^[\pL'-]+$`) semver := regexp.MustCompile(`^v?\d+\.\d+\.\d+(-(alpha|beta|rc)\.\d+)?$`) ip := regexp.MustCompile(`^((\d{1,3}\.){3}\d{1,3}|([0-9a-f]{4}:){7}[0-9a-f]{4})$`) - uuid := regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) color := regexp.MustCompile(`^(#[0-9a-f]{6}|[\pL ]+)$`) url := regexp.MustCompile(`^https?://([a-z0-9-]+\.)+[a-z]{2,}(/[a-z0-9./-]*)?$`) email := regexp.MustCompile(`^[a-z0-9._-]+@([a-z0-9-]+\.)+[a-z]{2,}$`) @@ -45,7 +44,6 @@ func TestShippedDataCategories(t *testing.T) { regexp.MustCompile(`^.+ (AB|HB|KB)$`)}, {"color", color, color}, {"url", url, url}, - {"uuid", uuid, uuid}, {"username", uname, uname}, {"price", regexp.MustCompile(`^\$\d{1,3}(,\d{3})?\.\d{2}$`), diff --git a/fakes.go b/fakes.go index a7d878f..e6ff76d 100644 --- a/fakes.go +++ b/fakes.go @@ -25,10 +25,25 @@ import ( // Fakes generates fake data from a loaded namespace tree. Create one with [New]. type Fakes struct { - rand *rand.Rand + rand *session categories map[string]node // root namespace: name -> compiled node tree } +// session is one faker's mutable render state: the seeded rng plus the {seq()} +// counters. Scoping it to the Fakes means sequences (and randomness) belong to +// that faker 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 { + *rand.Rand + counters map[string]uint64 +} + +// next returns the next value (counting from 1) of the named {seq()} counter. +func (s *session) next(key string) uint64 { + s.counters[key]++ + return s.counters[key] +} + type config struct { seed uint64 seeded bool @@ -60,11 +75,12 @@ func New(paths []string, opts ...Option) (*Fakes, error) { return &Fakes{rand: newRand(c.seed, c.seeded), categories: cats}, nil } -func newRand(seed uint64, seeded bool) *rand.Rand { +func newRand(seed uint64, seeded bool) *session { + r := rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15)) if !seeded { var b [16]byte _, _ = crand.Read(b[:]) - return rand.New(rand.NewPCG(binary.LittleEndian.Uint64(b[:8]), binary.LittleEndian.Uint64(b[8:]))) + r = rand.New(rand.NewPCG(binary.LittleEndian.Uint64(b[:8]), binary.LittleEndian.Uint64(b[8:]))) } - return rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15)) + return &session{Rand: r, counters: map[string]uint64{}} } diff --git a/template.go b/template.go index 7449085..ed608b1 100644 --- a/template.go +++ b/template.go @@ -70,7 +70,7 @@ func (f *Fakes) Fake(path string) (string, error) { // descend walks named fields, resolving choices it meets along the way. It is // the one render-side step that can fail, because the path comes from the // caller and may name a field that does not exist. -func descend(r rng, n node, segments []string) (node, error) { +func descend(s *session, n node, segments []string) (node, error) { if len(segments) == 0 { return n, nil } @@ -80,15 +80,15 @@ func descend(r rng, n node, segments []string) (node, error) { if !ok { return nil, fmt.Errorf("no entry %q", segments[0]) } - return descend(r, child, segments[1:]) + return descend(s, child, segments[1:]) case *template: child, ok := n.fields[segments[0]] if !ok { return nil, fmt.Errorf("no field %q", segments[0]) } - return descend(r, child, segments[1:]) + return descend(s, child, segments[1:]) case *choice: - return descend(r, pick(r, n), segments) // a choice consumes no path segment + return descend(s, pick(s, n), segments) // a choice consumes no path segment default: return nil, fmt.Errorf("cannot descend into %T at %q", n, segments[0]) } @@ -96,22 +96,22 @@ func descend(r rng, n node, segments []string) (node, error) { // render evaluates a compiled node to a string. compile validates every node up // front, so rendering a compiled tree cannot fail. -func render(r rng, n node) string { +func render(s *session, n node) string { switch n := n.(type) { case literal: return string(n) case *choice: - return render(r, pick(r, n)) + return render(s, pick(s, n)) case *template: if n.repeat == 1 { - return expand(r, n.format, n.fields) + return expand(s, n.format, n.fields) } var b strings.Builder for i := 0; i < n.repeat; i++ { if i > 0 { b.WriteString(n.separator) } - b.WriteString(expand(r, n.format, n.fields)) + b.WriteString(expand(s, n.format, n.fields)) } return b.String() default: @@ -137,7 +137,7 @@ func pick(r rng, c *choice) node { // any other "{token}" is substituted via resolve; every other rune is literal. // checkTokens validated the braces and tokens at compile time, so this scan // cannot fail. -func expand(r rng, format string, fields map[string]node) string { +func expand(s *session, format string, fields map[string]node) string { var b strings.Builder rs := []rune(format) for i := 0; i < len(rs); i++ { @@ -149,13 +149,13 @@ func expand(r rng, format string, fields map[string]node) string { b.WriteRune('#') } case '0': - b.WriteByte(byte('0' + r.IntN(10))) + b.WriteByte(byte('0' + s.IntN(10))) case '1': - b.WriteByte(byte('1' + r.IntN(9))) + b.WriteByte(byte('1' + s.IntN(9))) case 'A': - b.WriteByte(byte('A' + r.IntN(26))) + b.WriteByte(byte('A' + s.IntN(26))) case 'a': - b.WriteByte(byte('a' + r.IntN(26))) + b.WriteByte(byte('a' + s.IntN(26))) case '{': end := i + 1 for rs[end] != '}' { // checkTokens guarantees a closing '}' @@ -163,9 +163,9 @@ func expand(r rng, format string, fields map[string]node) string { } body := string(rs[i+1 : end]) if name, args, ok := funcCall(body); ok { - b.WriteString(builtins[name].call(r, b.String(), args)) // b.String() is the output so far + b.WriteString(builtins[name].call(s, b.String(), args)) // b.String() is the output so far } else { - b.WriteString(resolve(r, body, fields)) + b.WriteString(resolve(s, body, fields)) } i = end default: @@ -178,22 +178,25 @@ func expand(r rng, format string, fields map[string]node) string { // resolve renders a "{token}" body: one or more names separated by '|', one // picked at random. A name is a sibling field or a {..path} reference, which // linkRefs bound into fields too; checkTokens and linkRefs guarantee both exist. -func resolve(r rng, token string, fields map[string]node) string { +func resolve(s *session, token string, fields map[string]node) string { names := strings.Split(token, "|") - return render(r, fields[names[r.IntN(len(names))]]) + return render(s, fields[names[s.IntN(len(names))]]) } // builtin is a format-string function invoked as {name(args)}. It receives the -// rng, the output emitted so far in the current expansion (for derivations such -// as a checksum over preceding digits), and its args. A builtin must be pure -// over those inputs — no wall-clock, no crypto/rand — so seeding stays -// reproducible; a time-based id derives its time from the rng. The optional -// check validates args at compile time (their values, beyond the arity count). -// The registry lives in builtins.go. +// session (its rng, and the {seq()} counters), the output emitted so far in the +// current expansion (for derivations such as a checksum over preceding digits), +// and its args. Almost all are pure over (rng, emitted, args) — no wall-clock, no +// crypto/rand — so seeding stays reproducible; a time-based id derives its time +// from the rng. seq is the one exception: it advances per-session counter state, +// which is itself deterministic (1, 2, 3 …). arity is the exact arg count, or -1 +// for variadic (then check does all the validation). The optional check validates +// args at compile time (their values, beyond the count). The registry lives in +// builtins.go. type builtin struct { arity int check func(args []string) error - call func(r rng, emitted string, args []string) string + call func(s *session, emitted string, args []string) string } // funcCall splits a "{token}" body shaped name(args) into its parts; ok is false @@ -230,7 +233,7 @@ func checkFunc(body string) error { if !known { return fmt.Errorf("token {%s}: unknown function %q", body, name) } - if 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)) } if b.check != nil { diff --git a/template_test.go b/template_test.go index fd384ff..159cae3 100644 --- a/template_test.go +++ b/template_test.go @@ -192,6 +192,7 @@ func TestCompileErrors(t *testing.T) { `{"format":"{float(1,2)}"}`, // wrong arity `{"format":"{float(1,2,-1)}"}`, // negative decimals `{"format":"{iban(US)}"}`, // unsupported country + `{"format":"{seq(a,b)}"}`, // seq takes at most one name } { if _, err := compile(parse(t, bad)); err == nil { t.Errorf("compile(%s) = nil error, want error", bad)