Add {calc()}: arithmetic over number literals and sibling fields
This commit is contained in:
@@ -244,10 +244,11 @@ 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 three kinds. **Derivations** read the digits emitted so far, so put them
|
||||
There are four 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).
|
||||
**session counter** (`seq`) advances state held on the faker; and one
|
||||
**computation** (`calc`) evaluates arithmetic over sibling fields. Arguments are
|
||||
validated at `New` (a bad count, range, country, or expression fails fast).
|
||||
|
||||
| Function | Kind | Emits |
|
||||
|----------|------|-------|
|
||||
@@ -264,6 +265,7 @@ validated at `New` (a bad count, range, or country fails fast).
|
||||
| `{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 |
|
||||
| `{calc(expr)}`, `{calc(expr,dp)}` | computation | value of an arithmetic expression over number literals and sibling fields; `dp` rounds |
|
||||
|
||||
`{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:
|
||||
@@ -275,6 +277,23 @@ length and checksum, not real bank routing).
|
||||
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.
|
||||
|
||||
**Computation.** `{calc(expr)}` evaluates an arithmetic expression — `+ - * /`,
|
||||
parentheses and unary minus, the usual precedence — and emits the result. Operands
|
||||
are number literals and **sibling field names**, each rendered then read as a
|
||||
number; an optional second arg rounds to that many decimals (`{calc(net * qty, 2)}`),
|
||||
otherwise the value prints in minimal form. A hyphen is always subtraction, so a
|
||||
hyphenated field name can't be an operand. The expression is checked at `New`
|
||||
(parse, and that every name is a real field):
|
||||
|
||||
```json
|
||||
{ "format": "{net} x {qty} = {calc(net * qty, 2)}", "net": ["19.99"], "qty": ["3"] }
|
||||
```
|
||||
|
||||
renders `19.99 x 3 = 59.97`. Each name is rendered where it appears, so a field
|
||||
shown *and* used in a `calc` is drawn twice — keep a shared operand in a
|
||||
single-value field if the two must agree. A field that doesn't render to a number
|
||||
yields `NaN`, which prints rather than failing the render.
|
||||
|
||||
**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
|
||||
@@ -380,6 +399,7 @@ docker compose run --rm test # latest
|
||||
fakes.go Fakes, New, options, seeding
|
||||
template.go Fake, the recursive renderer (choices, format strings, paths)
|
||||
builtins.go the {name()} function registry and its implementations
|
||||
calc.go the {calc()} arithmetic evaluator: parser, eval, validation
|
||||
data.go data loading: folders/files -> namespace tree, multi-path merge
|
||||
cmd/fakes/ the `fakes` CLI (New + Fake over stdout)
|
||||
data/ shipped data (JSON): locale folders + a misc folder
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
package fakes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// calc is the {calc(expr[, dp])} token: an arithmetic expression over number
|
||||
// literals and sibling-field names (rendered, then parsed as numbers), with
|
||||
// + - * /, unary minus and parentheses. Unlike a builtin it reads the field
|
||||
// environment, so expand and checkTokens route it here rather than the builtins
|
||||
// registry. The value prints in minimal decimal form, or rounded to dp decimals
|
||||
// when given. A field that doesn't render to a number becomes NaN, which
|
||||
// propagates and prints as "NaN" — visible, never a render error. The expression
|
||||
// is re-parsed each render, mirroring how expand re-scans the format; checkCalc
|
||||
// proves it parses (and names real fields) at New, so render never fails.
|
||||
|
||||
// calcNode is a parsed expression node.
|
||||
type calcNode interface {
|
||||
eval(s *session, fields map[string]node) float64
|
||||
}
|
||||
|
||||
type calcNum float64 // a number literal
|
||||
type calcVar string // a sibling-field name
|
||||
type calcNeg struct{ x calcNode }
|
||||
type calcBin struct { // a + - * / b
|
||||
op byte
|
||||
l, r calcNode
|
||||
}
|
||||
|
||||
func (n calcNum) eval(*session, map[string]node) float64 { return float64(n) }
|
||||
|
||||
func (n calcVar) eval(s *session, fields map[string]node) float64 {
|
||||
v, err := strconv.ParseFloat(strings.TrimSpace(render(s, fields[string(n)])), 64)
|
||||
if err != nil {
|
||||
return math.NaN() // a non-numeric operand stays visible, never an error
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (n calcNeg) eval(s *session, fields map[string]node) float64 { return -n.x.eval(s, fields) }
|
||||
|
||||
func (n calcBin) eval(s *session, fields map[string]node) float64 {
|
||||
l, r := n.l.eval(s, fields), n.r.eval(s, fields)
|
||||
switch n.op {
|
||||
case '+':
|
||||
return l + r
|
||||
case '-':
|
||||
return l - r
|
||||
case '*':
|
||||
return l * r
|
||||
default: // '/'
|
||||
return l / r
|
||||
}
|
||||
}
|
||||
|
||||
// checkCalc validates a calc token at compile time: a parseable expression whose
|
||||
// operands all name existing fields, and an optional non-negative integer dp.
|
||||
func checkCalc(args []string, fields map[string]node) error {
|
||||
if len(args) < 1 || len(args) > 2 {
|
||||
return fmt.Errorf("calc takes an expression and an optional decimals count, got %d args", len(args))
|
||||
}
|
||||
expr, err := parseCalc(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("calc(%q): %w", args[0], err)
|
||||
}
|
||||
for _, name := range calcVars(expr) {
|
||||
if _, ok := fields[name]; !ok {
|
||||
return fmt.Errorf("calc(%q): no field %q", args[0], name)
|
||||
}
|
||||
}
|
||||
if len(args) == 2 {
|
||||
if dp, err := strconv.Atoi(args[1]); err != nil || dp < 0 {
|
||||
return fmt.Errorf("calc decimals %q must be a non-negative integer", args[1])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// calcEval renders a calc token. checkCalc proved the expression parses and the
|
||||
// decimals arg is valid, so neither step here can fail. dp -1 prints the minimal
|
||||
// form; a given dp rounds to that many places.
|
||||
func calcEval(s *session, args []string, fields map[string]node) string {
|
||||
expr, _ := parseCalc(args[0])
|
||||
dp := -1
|
||||
if len(args) == 2 {
|
||||
dp = atoi(args[1])
|
||||
}
|
||||
return strconv.FormatFloat(expr.eval(s, fields), 'f', dp, 64)
|
||||
}
|
||||
|
||||
// calcVars lists the field names an expression references, for the existence
|
||||
// check in checkCalc.
|
||||
func calcVars(n calcNode) []string {
|
||||
switch n := n.(type) {
|
||||
case calcVar:
|
||||
return []string{string(n)}
|
||||
case calcNeg:
|
||||
return calcVars(n.x)
|
||||
case calcBin:
|
||||
return append(calcVars(n.l), calcVars(n.r)...)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// calcParser is a recursive-descent parser over the expression runes, threading
|
||||
// expr -> term -> factor for the standard * / before + - precedence.
|
||||
type calcParser struct {
|
||||
rs []rune
|
||||
pos int
|
||||
}
|
||||
|
||||
// parseCalc parses a whole expression, requiring it to consume all input.
|
||||
func parseCalc(expr string) (calcNode, error) {
|
||||
p := &calcParser{rs: []rune(expr)}
|
||||
if p.space(); p.pos >= len(p.rs) {
|
||||
return nil, fmt.Errorf("empty expression")
|
||||
}
|
||||
n, err := p.expr()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.space(); p.pos != len(p.rs) {
|
||||
return nil, fmt.Errorf("unexpected %q", string(p.rs[p.pos:]))
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (p *calcParser) space() {
|
||||
for p.pos < len(p.rs) && unicode.IsSpace(p.rs[p.pos]) {
|
||||
p.pos++
|
||||
}
|
||||
}
|
||||
|
||||
func (p *calcParser) expr() (calcNode, error) { return p.binary(p.term, '+', '-') }
|
||||
func (p *calcParser) term() (calcNode, error) { return p.binary(p.factor, '*', '/') }
|
||||
|
||||
// binary parses a left-associative run of next() operands joined by the given
|
||||
// operators, the one shape expr and term share.
|
||||
func (p *calcParser) binary(next func() (calcNode, error), ops ...byte) (calcNode, error) {
|
||||
n, err := next()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for {
|
||||
p.space()
|
||||
if p.pos >= len(p.rs) || !contains(ops, byte(p.rs[p.pos])) {
|
||||
return n, nil
|
||||
}
|
||||
op := byte(p.rs[p.pos])
|
||||
p.pos++
|
||||
r, err := next()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n = calcBin{op, n, r}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *calcParser) factor() (calcNode, error) {
|
||||
p.space()
|
||||
if p.pos >= len(p.rs) {
|
||||
return nil, fmt.Errorf("unexpected end of expression")
|
||||
}
|
||||
switch c := p.rs[p.pos]; {
|
||||
case c == '-':
|
||||
p.pos++
|
||||
x, err := p.factor()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return calcNeg{x}, nil
|
||||
case c == '(':
|
||||
p.pos++
|
||||
n, err := p.expr()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.space(); p.pos >= len(p.rs) || p.rs[p.pos] != ')' {
|
||||
return nil, fmt.Errorf("missing ')'")
|
||||
}
|
||||
p.pos++
|
||||
return n, nil
|
||||
case c == '.' || c >= '0' && c <= '9':
|
||||
return p.number()
|
||||
case c == '_' || unicode.IsLetter(c):
|
||||
return p.ident()
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected %q", string(c))
|
||||
}
|
||||
}
|
||||
|
||||
func (p *calcParser) number() (calcNode, error) {
|
||||
start, dot := p.pos, false
|
||||
for p.pos < len(p.rs) {
|
||||
if c := p.rs[p.pos]; c >= '0' && c <= '9' {
|
||||
p.pos++
|
||||
} else if c == '.' && !dot {
|
||||
dot, p.pos = true, p.pos+1
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
v, err := strconv.ParseFloat(string(p.rs[start:p.pos]), 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bad number %q", string(p.rs[start:p.pos]))
|
||||
}
|
||||
return calcNum(v), nil
|
||||
}
|
||||
|
||||
// ident reads a field name: a letter or '_', then letters, digits or '_'. A '-'
|
||||
// is always the minus operator, so a hyphenated field name can't be an operand.
|
||||
func (p *calcParser) ident() (calcNode, error) {
|
||||
start := p.pos
|
||||
for p.pos < len(p.rs) {
|
||||
if c := p.rs[p.pos]; c == '_' || unicode.IsLetter(c) || unicode.IsDigit(c) {
|
||||
p.pos++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return calcVar(string(p.rs[start:p.pos])), nil
|
||||
}
|
||||
|
||||
func contains(bs []byte, b byte) bool {
|
||||
for _, x := range bs {
|
||||
if x == b {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package fakes
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestCalcArithmetic pins the operators, precedence, parentheses and unary minus
|
||||
// over number literals.
|
||||
func TestCalcArithmetic(t *testing.T) {
|
||||
f := engine(1)
|
||||
cases := map[string]string{
|
||||
`{"format":"{calc(2 + 3)}"}`: "5",
|
||||
`{"format":"{calc(2 * 3 + 4)}"}`: "10", // * binds tighter than +
|
||||
`{"format":"{calc(2 + 3 * 4)}"}`: "14",
|
||||
`{"format":"{calc((2 + 3) * 4)}"}`: "20", // parentheses override
|
||||
`{"format":"{calc(10 / 4)}"}`: "2.5",
|
||||
`{"format":"{calc(-2 + 5)}"}`: "3", // unary minus
|
||||
`{"format":"{calc(2 - -3)}"}`: "5",
|
||||
`{"format":"{calc(1.5 * 2)}"}`: "3", // whole result drops the decimals
|
||||
}
|
||||
for tmpl, want := range cases {
|
||||
if got := mustRender(t, f, tmpl); got != want {
|
||||
t.Errorf("%s = %q, want %q", tmpl, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalcAuto pins the default (no-dp) rendering: minimal decimal form, no
|
||||
// scientific notation, whole numbers without a fraction.
|
||||
func TestCalcAuto(t *testing.T) {
|
||||
f := engine(1)
|
||||
cases := map[string]string{
|
||||
`{"format":"{calc(10 / 3)}"}`: "3.3333333333333335",
|
||||
`{"format":"{calc(6 / 2)}"}`: "3",
|
||||
`{"format":"{calc(1 / 4)}"}`: "0.25",
|
||||
}
|
||||
for tmpl, want := range cases {
|
||||
if got := mustRender(t, f, tmpl); got != want {
|
||||
t.Errorf("%s = %q, want %q", tmpl, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalcDecimals pins the optional decimals arg: rounds to dp places, dp 0
|
||||
// drops the fraction.
|
||||
func TestCalcDecimals(t *testing.T) {
|
||||
f := engine(1)
|
||||
cases := map[string]string{
|
||||
`{"format":"{calc(10 / 3, 2)}"}`: "3.33",
|
||||
`{"format":"{calc(10 / 3, 0)}"}`: "3",
|
||||
`{"format":"{calc(2 * 3, 2)}"}`: "6.00",
|
||||
}
|
||||
for tmpl, want := range cases {
|
||||
if got := mustRender(t, f, tmpl); got != want {
|
||||
t.Errorf("%s = %q, want %q", tmpl, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalcFields pins that bare names resolve to sibling fields, rendered then
|
||||
// parsed as numbers.
|
||||
func TestCalcFields(t *testing.T) {
|
||||
if got := mustRender(t, engine(1), `{"format":"{calc(price * qty, 2)}","price":["19.99"],"qty":["3"]}`); got != "59.97" {
|
||||
t.Fatalf("calc over fields = %q, want 59.97", got)
|
||||
}
|
||||
// A field that is itself a template renders before parsing.
|
||||
if got := mustRender(t, engine(1), `{"format":"{calc(a - b)}","a":[{"format":"{n}","n":["10"]}],"b":["3"]}`); got != "7" {
|
||||
t.Fatalf("calc(a - b) = %q, want 7", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalcNonNumericIsNaN pins the never-fail rule: a field that doesn't render
|
||||
// to a number becomes NaN, which propagates and prints visibly.
|
||||
func TestCalcNonNumericIsNaN(t *testing.T) {
|
||||
if got := mustRender(t, engine(1), `{"format":"{calc(x * 2)}","x":["abc"]}`); got != "NaN" {
|
||||
t.Fatalf("calc over non-numeric field = %q, want NaN", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalcReproducible pins that a calc over a random operand stays seed-stable.
|
||||
func TestCalcReproducible(t *testing.T) {
|
||||
tmpl := `{"format":"{calc(q * 2 + 1)}","q":[{"format":"{int(1,1000000)}"}]}`
|
||||
if a, b := mustRender(t, engine(7), tmpl), mustRender(t, engine(7), tmpl); a != b {
|
||||
t.Fatalf("calc not reproducible: %q != %q", a, b)
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -163,7 +163,11 @@ func expand(s *session, 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(s, b.String(), args)) // b.String() is the output so far
|
||||
if name == "calc" { // calc reads sibling fields, not just (rng, emitted)
|
||||
b.WriteString(calcEval(s, args, fields))
|
||||
} else {
|
||||
b.WriteString(builtins[name].call(s, b.String(), args)) // b.String() is the output so far
|
||||
}
|
||||
} else {
|
||||
b.WriteString(resolve(s, body, fields))
|
||||
}
|
||||
@@ -342,7 +346,11 @@ func checkTokens(format string, fields map[string]node) error {
|
||||
}
|
||||
body := string(rs[i+1 : end])
|
||||
if strings.IndexByte(body, '(') >= 0 { // a function token, not a field
|
||||
if err := checkFunc(body); err != nil {
|
||||
if name, args, ok := funcCall(body); ok && name == "calc" {
|
||||
if err := checkCalc(args, fields); err != nil {
|
||||
return fmt.Errorf("token {%s}: %w", body, err)
|
||||
}
|
||||
} else if err := checkFunc(body); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -193,6 +193,14 @@ func TestCompileErrors(t *testing.T) {
|
||||
`{"format":"{float(1,2,-1)}"}`, // negative decimals
|
||||
`{"format":"{iban(US)}"}`, // unsupported country
|
||||
`{"format":"{seq(a,b)}"}`, // seq takes at most one name
|
||||
`{"format":"{calc()}"}`, // calc needs an expression
|
||||
`{"format":"{calc(1 +)}"}`, // dangling operator
|
||||
`{"format":"{calc((1 + 2)}"}`, // unbalanced parenthesis
|
||||
`{"format":"{calc(1 2)}"}`, // two operands, no operator
|
||||
`{"format":"{calc(price)}"}`, // operand names no field
|
||||
`{"format":"{calc(1, 2, 3)}"}`, // too many args
|
||||
`{"format":"{calc(1, x)}"}`, // decimals arg not an integer
|
||||
`{"format":"{calc(1, -1)}"}`, // decimals negative
|
||||
} {
|
||||
if _, err := compile(parse(t, bad)); err == nil {
|
||||
t.Errorf("compile(%s) = nil error, want error", bad)
|
||||
|
||||
Reference in New Issue
Block a user