Add session-scoped seq() builtin; move uuid v4 from locales to misc only
This commit is contained in:
@@ -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
|
||||
|
||||
+35
-15
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
@@ -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}$`),
|
||||
|
||||
@@ -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{}}
|
||||
}
|
||||
|
||||
+28
-25
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user