From 80ec0d4027dfae19ff4bdcbb9d2bc7443b68e344 Mon Sep 17 00:00:00 2001 From: NirBarak-RecoLabs Date: Fri, 24 Apr 2026 15:21:29 +0300 Subject: [PATCH 1/6] Fix signature validation for variadic, optional-skip, context, and u-type Addresses four conformance gaps in function signature validation against the JSONata spec: - Variadic overflow: cap consumption to leave room for mandatory params that follow, preventing spurious T0410 "too few arguments" errors when a trailing fixed param is present after a variadic. - Optional-skip: when an optional param's type doesn't match, skip the spec and retry the same arg against the next spec instead of raising T0410. - '-' (context) modifier: inject the current focus value when the argument is absent. Adds Context field to ParamSpec and threads focus through processCallArgs / validateCallArgs. - 'u' type specifier (union of primitives: bool, number, string, null). Recognised by both the parser and evaluator. Adds test cases 035-040 covering each scenario end-to-end. --- internal/evaluator/eval_function.go | 4 +- internal/evaluator/signature.go | 63 +++++++++++++------ internal/parser/signature.go | 7 ++- .../groups/function-signatures/case035.json | 9 +++ .../groups/function-signatures/case036.json | 6 ++ .../groups/function-signatures/case037.json | 6 ++ .../groups/function-signatures/case038.json | 6 ++ .../groups/function-signatures/case039.json | 9 +++ .../groups/function-signatures/case040.json | 6 ++ 9 files changed, 93 insertions(+), 23 deletions(-) create mode 100644 testdata/groups/function-signatures/case035.json create mode 100644 testdata/groups/function-signatures/case036.json create mode 100644 testdata/groups/function-signatures/case037.json create mode 100644 testdata/groups/function-signatures/case038.json create mode 100644 testdata/groups/function-signatures/case039.json create mode 100644 testdata/groups/function-signatures/case040.json diff --git a/internal/evaluator/eval_function.go b/internal/evaluator/eval_function.go index 903994aa..0a8b34cd 100644 --- a/internal/evaluator/eval_function.go +++ b/internal/evaluator/eval_function.go @@ -53,7 +53,7 @@ func evalFunction(node *parser.Node, input any, env *Environment) (any, error) { // HOF callbacks bypass this (they go through ApplyFunction instead). if sb, ok := fn.(*SignedBuiltin); ok { specs, _ := parser.ParseSig(sb.Sig) - coerced, returnUndefined, sigErr := processCallArgs(specs, args) + coerced, returnUndefined, sigErr := processCallArgs(specs, args, input) if sigErr != nil { return nil, sigErr } @@ -176,7 +176,7 @@ func callFunction(fn any, args []any, focus any, env *Environment) (any, error) case *Lambda: if f.Sig != "" { specs, _ := parser.ParseSig(f.Sig) - coerced, returnUndefined, err := processCallArgs(specs, args) + coerced, returnUndefined, err := processCallArgs(specs, args, focus) if err != nil { return nil, err } diff --git a/internal/evaluator/signature.go b/internal/evaluator/signature.go index 235ae01c..6e2dfe9c 100644 --- a/internal/evaluator/signature.go +++ b/internal/evaluator/signature.go @@ -19,11 +19,12 @@ import ( // // 3. Argument type validation: delegates to validateCallArgs, which returns // T0410 on base-type mismatch or arity errors, and T0412 on array -// content-type violations. +// content-type violations. Context specs ('-') that are missing receive +// the focus value instead of triggering T0410. // // It returns (coercedArgs, returnUndefined, err). // When returnUndefined is true the caller must return (nil, nil) immediately. -func processCallArgs(specs []parser.ParamSpec, args []any) (coercedArgs []any, returnUndefined bool, err error) { +func processCallArgs(specs []parser.ParamSpec, args []any, focus any) (coercedArgs []any, returnUndefined bool, err error) { coerced := slices.Clone(args) for i, spec := range specs { @@ -50,16 +51,20 @@ func processCallArgs(specs []parser.ParamSpec, args []any) (coercedArgs []any, r } } - if err := validateCallArgs(specs, coerced); err != nil { + expanded, err := validateCallArgs(specs, coerced, focus) + if err != nil { return nil, false, err } - return coerced, false, nil + return expanded, false, nil } // validateCallArgs checks that args satisfy the compiled parameter specs. // It returns T0410 on a base-type mismatch or arity error, and T0412 when // an array content-type constraint is violated. -func validateCallArgs(specs []parser.ParamSpec, args []any) error { +// For context specs ('-') that have no corresponding argument, the focus +// value is injected and appended to the returned slice. +func validateCallArgs(specs []parser.ParamSpec, args []any, focus any) ([]any, error) { + result := slices.Clone(args) si := 0 // spec index ai := 0 // arg index @@ -67,10 +72,18 @@ func validateCallArgs(specs []parser.ParamSpec, args []any) error { spec := specs[si] if spec.Variadic { - // Variadic spec: validate every remaining arg against it. - for ai < len(args) { - if err := validateOneCallArg(spec, args[ai], ai+1); err != nil { - return err + // Variadic spec: consume args up to maxConsume, stopping on type + // mismatch so subsequent mandatory specs can claim the remaining args. + mandatoryAfter := 0 + for k := si + 1; k < len(specs); k++ { + if !specs[k].Optional && !specs[k].Context { + mandatoryAfter++ + } + } + maxConsume := len(result) - mandatoryAfter + for ai < maxConsume { + if err := validateOneCallArg(spec, result[ai], ai+1); err != nil { + break } ai++ } @@ -78,10 +91,12 @@ func validateCallArgs(specs []parser.ParamSpec, args []any) error { continue } - if ai >= len(args) { - // No arg for this spec position. - if !spec.Optional { - return &JSONataError{ + if ai >= len(result) { + if spec.Context { + result = append(result, focus) + ai++ + } else if !spec.Optional { + return nil, &JSONataError{ Code: "T0410", Message: fmt.Sprintf("argument %d does not match function signature: too few arguments", ai+1), } @@ -90,22 +105,26 @@ func validateCallArgs(specs []parser.ParamSpec, args []any) error { continue } - if err := validateOneCallArg(spec, args[ai], ai+1); err != nil { - return err + if err := validateOneCallArg(spec, result[ai], ai+1); err != nil { + if spec.Optional { + si++ + continue + } + return nil, err } ai++ si++ } // Extra args beyond all specs → T0410 (too many arguments). - if ai < len(args) { - return &JSONataError{ + if ai < len(result) { + return nil, &JSONataError{ Code: "T0410", Message: fmt.Sprintf("argument %d does not match function signature: too many arguments", ai+1), } } - return nil + return result, nil } // validateOneCallArg checks a single argument against one parameter spec. @@ -179,7 +198,7 @@ func sigTypeMatches(arg any, t byte) bool { _, ok := arg.(bool) return ok case 'l': - return arg == nil + return arg == nil || IsNull(arg) case 'a': _, ok := arg.([]any) return ok @@ -191,6 +210,12 @@ func sigTypeMatches(arg any, t byte) bool { return true } return false + case 'u': // union of primitives: Boolean, Number, String, or Null + switch arg.(type) { + case bool, float64, string, json.Number: + return true + } + return IsNull(arg) } return false } diff --git a/internal/parser/signature.go b/internal/parser/signature.go index 758d20b3..10fd3bae 100644 --- a/internal/parser/signature.go +++ b/internal/parser/signature.go @@ -7,10 +7,11 @@ import ( // ParamSpec describes one parameter's type constraint parsed from a function signature. type ParamSpec struct { - Types []byte // accepted base types: b n s l a o f j x + Types []byte // accepted base types: b n s l a o f j x u ContentType byte // for 'a': element type constraint; 0 = any Optional bool // ? — param may be omitted Variadic bool // + — repeats; must be the last spec + Context bool // - — inject focus (context value) when the argument is missing } // ParseSig parses and validates a raw function-signature string (the content @@ -53,6 +54,8 @@ func ParseSig(raw string) ([]ParamSpec, error) { spec.Optional = true case '+': spec.Variadic = true + case '-': + spec.Context = true } i++ } @@ -170,7 +173,7 @@ func sigStripReturnType(s string) string { func isValidSigType(c byte) bool { switch c { - case 'b', 'n', 's', 'l', 'a', 'o', 'f', 'j', 'x': + case 'b', 'n', 's', 'l', 'a', 'o', 'f', 'j', 'x', 'u': return true } return false diff --git a/testdata/groups/function-signatures/case035.json b/testdata/groups/function-signatures/case035.json new file mode 100644 index 00000000..79616eaa --- /dev/null +++ b/testdata/groups/function-signatures/case035.json @@ -0,0 +1,9 @@ +{ + "expr": "λ($arg1, $arg2){{\"$arg1\": $arg1, \"$arg2\": $arg2}}(1, 2, 3)", + "dataset": null, + "bindings": {}, + "result": { + "$arg1": 1, + "$arg2": 2 + } +} diff --git a/testdata/groups/function-signatures/case036.json b/testdata/groups/function-signatures/case036.json new file mode 100644 index 00000000..821f931c --- /dev/null +++ b/testdata/groups/function-signatures/case036.json @@ -0,0 +1,6 @@ +{ + "expr": "λ($arg1, $arg2, $arg3)>{[$arg1, $arg2, $arg3]}(1, 2, \"a\")", + "dataset": null, + "bindings": {}, + "result": [1, 2, "a"] +} diff --git a/testdata/groups/function-signatures/case037.json b/testdata/groups/function-signatures/case037.json new file mode 100644 index 00000000..67ee7c16 --- /dev/null +++ b/testdata/groups/function-signatures/case037.json @@ -0,0 +1,6 @@ +{ + "expr": "λ($arg1, $arg2, $arg3)>{[$arg1, $arg2, $arg3]}(1, 2, \"a\")", + "data": "b", + "bindings": {}, + "result": [1, 2, "a"] +} diff --git a/testdata/groups/function-signatures/case038.json b/testdata/groups/function-signatures/case038.json new file mode 100644 index 00000000..09b31c42 --- /dev/null +++ b/testdata/groups/function-signatures/case038.json @@ -0,0 +1,6 @@ +{ + "expr": "λ($arg1, $arg2, $arg3)>{[$arg1, $arg2, $arg3]}(1, 2)", + "data": "b", + "bindings": {}, + "result": [1, 2, "b"] +} diff --git a/testdata/groups/function-signatures/case039.json b/testdata/groups/function-signatures/case039.json new file mode 100644 index 00000000..c8ce2003 --- /dev/null +++ b/testdata/groups/function-signatures/case039.json @@ -0,0 +1,9 @@ +{ + "expr": "λ($arg1, $arg2)+:o>{{'$arg1': $arg1, '$arg2': $arg2}}([1, 2], [3, 4], [5, 6])", + "data": "b", + "bindings": {}, + "result": { + "$arg1": [1, 2], + "$arg2": [3, 4] + } +} diff --git a/testdata/groups/function-signatures/case040.json b/testdata/groups/function-signatures/case040.json new file mode 100644 index 00000000..30c05f61 --- /dev/null +++ b/testdata/groups/function-signatures/case040.json @@ -0,0 +1,6 @@ +{ + "expr": "λ($arg1, $arg2)>{[$arg1, $arg2]}(1, 2, 3)", + "dataset": null, + "bindings": {}, + "result": [1, 2] +} From b9a6f607f7b400e578b0abd5db6a86f990f398c4 Mon Sep 17 00:00:00 2001 From: NirBarak-RecoLabs Date: Fri, 24 Apr 2026 15:22:14 +0300 Subject: [PATCH 2/6] Return T0410 from $split on non-string input $split previously returned nil (undefined) for non-string arguments, which matched the jsonata-js reference implementation but diverged from the JSONata spec, which prescribes a T0410 type error. Align with the spec by returning T0410 for non-string args, and update case016/case017 to expect the error code instead of an undefined result. --- functions/string_funcs.go | 2 +- testdata/groups/function-split/case016.json | 2 +- testdata/groups/function-split/case017.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/functions/string_funcs.go b/functions/string_funcs.go index d3beec76..5ae38763 100644 --- a/functions/string_funcs.go +++ b/functions/string_funcs.go @@ -424,7 +424,7 @@ func fnSplit(args []any, _ any) (any, error) { } s, ok := args[0].(string) if !ok { - return nil, nil + return nil, &evaluator.JSONataError{Code: "T0410", Message: fmt.Sprintf("$split: argument 1 must be a string, got %T", args[0])} } if len(args) < 2 { return nil, &evaluator.JSONataError{Code: "D3006", Message: "$split: requires at least 2 arguments"} diff --git a/testdata/groups/function-split/case016.json b/testdata/groups/function-split/case016.json index 8b531902..392478e2 100644 --- a/testdata/groups/function-split/case016.json +++ b/testdata/groups/function-split/case016.json @@ -2,5 +2,5 @@ "expr": "$split(12345, 3)", "dataset": null, "bindings": {}, - "undefinedResult": true + "code": "T0410" } diff --git a/testdata/groups/function-split/case017.json b/testdata/groups/function-split/case017.json index dc297cb3..c6c6961e 100644 --- a/testdata/groups/function-split/case017.json +++ b/testdata/groups/function-split/case017.json @@ -2,5 +2,5 @@ "expr": "$split(12345)", "dataset": null, "bindings": {}, - "undefinedResult": true + "code": "T0410" } From aa4ec140c436d194015aa8541c5595d413f1475a Mon Sep 17 00:00:00 2001 From: NirBarak-RecoLabs Date: Fri, 24 Apr 2026 15:22:26 +0300 Subject: [PATCH 3/6] Sort raw map keys for deterministic iteration When a Go native map[string]any enters the evaluator (via DecodeRawMap or MapKeys/MapRange in paths that walk raw maps), iteration order was Go-randomised, which leaked into ordering-sensitive operations like $keys, $lookup fallbacks, and aggregate results. Sort keys alphabetically using slices.Sorted(maps.Keys(m)) before iteration so identical inputs produce identical outputs across runs. --- internal/evaluator/ordered_map.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/evaluator/ordered_map.go b/internal/evaluator/ordered_map.go index b8418398..91fbe276 100644 --- a/internal/evaluator/ordered_map.go +++ b/internal/evaluator/ordered_map.go @@ -153,8 +153,8 @@ func DecodeJSON(b json.RawMessage) (any, error) { // in the values preserve key insertion order, consistent with DecodeJSON. func DecodeRawMap(m map[string]json.RawMessage) (*OrderedMap, error) { om := NewOrderedMapWithCapacity(len(m)) - for key, raw := range m { - val, err := DecodeJSON(raw) + for _, key := range slices.Sorted(maps.Keys(m)) { + val, err := DecodeJSON(m[key]) if err != nil { return nil, fmt.Errorf("decode key %q: %w", key, err) } @@ -246,7 +246,7 @@ func MapKeys(obj any) []string { case *OrderedMap: return m.Keys() case map[string]any: - return slices.Collect(maps.Keys(m)) + return slices.Sorted(maps.Keys(m)) } return nil } @@ -274,8 +274,8 @@ func MapRange(obj any, fn func(key string, val any) bool) { case *OrderedMap: m.Range(fn) case map[string]any: - for k, v := range m { - if !fn(k, v) { + for _, k := range slices.Sorted(maps.Keys(m)) { + if !fn(k, m[k]) { break } } From fe34475370046c107acbc80c0bfdb1ce16939103 Mon Sep 17 00:00:00 2001 From: NirBarak-RecoLabs Date: Fri, 24 Apr 2026 15:22:47 +0300 Subject: [PATCH 4/6] Pre-parse function signatures at registration Signature strings were re-parsed on every SignedBuiltin / Lambda call via parser.ParseSig, which is pure overhead since the string never changes after the function is defined. Parse once and cache: - Add ParsedSig []parser.ParamSpec to SignedBuiltin and Lambda. - Introduce a newSignedBuiltin helper in functions/register.go that parses at construction time; use it for signature-carrying registrations. - Populate Lambda.ParsedSig once in evalLambda when the signature is compiled from the AST. - Replace the per-call parser.ParseSig() lookups in evalFunction and callFunction with direct reads of the cached ParsedSig slice. Hot-path-only change; no behavioural difference. --- functions/register.go | 14 +++++++++++--- internal/evaluator/env.go | 18 ++++++++++-------- internal/evaluator/eval_function.go | 9 +++++---- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/functions/register.go b/functions/register.go index 743afbb3..719cb4ba 100644 --- a/functions/register.go +++ b/functions/register.go @@ -1,7 +1,10 @@ // Package functions implements the JSONata 2.x standard library. package functions -import "github.com/recolabs/gnata/internal/evaluator" +import ( + "github.com/recolabs/gnata/internal/evaluator" + "github.com/recolabs/gnata/internal/parser" +) // EvalFn is a callback used by higher-order functions to invoke a lambda or // builtin function value without creating an import cycle. The env parameter @@ -77,14 +80,19 @@ var builtinFuncs = []struct { {"toMillis", fnToMillis}, } +func newSignedBuiltin(fn func([]any, any) (any, error), sig string) *evaluator.SignedBuiltin { + parsed, _ := parser.ParseSig(sig) + return &evaluator.SignedBuiltin{Fn: fn, Sig: sig, ParsedSig: parsed} +} + // RegisterAll binds every JSONata built-in function into env. // evalFn must call evaluator.ApplyFunction (supplied by gnata.go). func RegisterAll(env *evaluator.Environment, evalFn EvalFn) { for _, b := range builtinFuncs { env.Bind(b.name, evaluator.BuiltinFunction(b.fn)) } - env.Bind("uppercase", &evaluator.SignedBuiltin{Fn: fnUppercase, Sig: "s-:s"}) - env.Bind("lowercase", &evaluator.SignedBuiltin{Fn: fnLowercase, Sig: "s-:s"}) + env.Bind("uppercase", newSignedBuiltin(fnUppercase, "s-:s")) + env.Bind("lowercase", newSignedBuiltin(fnLowercase, "s-:s")) env.Bind("match", makeFnMatch(evalFn)) env.Bind("replace", makeFnReplace(evalFn)) env.Bind("eval", makeFnEval()) diff --git a/internal/evaluator/env.go b/internal/evaluator/env.go index 7af5ac69..ff98ea07 100644 --- a/internal/evaluator/env.go +++ b/internal/evaluator/env.go @@ -178,16 +178,18 @@ type EnvAwareBuiltin func(args []any, focus any, env *Environment) (any, error) // function via ApplyFunction bypass signature validation, allowing extra // arguments (key, index, array) to be passed silently. type SignedBuiltin struct { - Fn BuiltinFunction - Sig string + Fn BuiltinFunction + Sig string + ParsedSig []parser.ParamSpec // pre-parsed signature; avoids re-parsing on every call } // Lambda represents a user-defined function (lambda expression). type Lambda struct { - Params []string // parameter names - Body *parser.Node // function body AST node - Closure *Environment // lexical scope at definition site - Thunk bool // for tail-call optimization - Sig string // type signature (Wave 5) - CapturedFocus any // focus ($) captured at definition time for zero-param closures + Params []string // parameter names + Body *parser.Node // function body AST node + Closure *Environment // lexical scope at definition site + Thunk bool // for tail-call optimization + Sig string // type signature (Wave 5) + ParsedSig []parser.ParamSpec // pre-parsed signature; avoids re-parsing per call + CapturedFocus any // focus ($) captured at definition time for zero-param closures } diff --git a/internal/evaluator/eval_function.go b/internal/evaluator/eval_function.go index 0a8b34cd..a6bc3594 100644 --- a/internal/evaluator/eval_function.go +++ b/internal/evaluator/eval_function.go @@ -52,8 +52,7 @@ func evalFunction(node *parser.Node, input any, env *Environment) (any, error) { // Validate signature for SignedBuiltins at the direct call site. // HOF callbacks bypass this (they go through ApplyFunction instead). if sb, ok := fn.(*SignedBuiltin); ok { - specs, _ := parser.ParseSig(sb.Sig) - coerced, returnUndefined, sigErr := processCallArgs(specs, args, input) + coerced, returnUndefined, sigErr := processCallArgs(sb.ParsedSig, args, input) if sigErr != nil { return nil, sigErr } @@ -85,8 +84,10 @@ func evalLambda(node *parser.Node, input any, env *Environment) (any, error) { params = append(params, arg.Value) } sig := "" + var parsedSig []parser.ParamSpec if node.Signature != nil { sig = node.Signature.Raw + parsedSig, _ = parser.ParseSig(sig) } return &Lambda{ Params: params, @@ -94,6 +95,7 @@ func evalLambda(node *parser.Node, input any, env *Environment) (any, error) { Closure: env, Thunk: node.Thunk, Sig: sig, + ParsedSig: parsedSig, CapturedFocus: input, }, nil } @@ -175,8 +177,7 @@ func callFunction(fn any, args []any, focus any, env *Environment) (any, error) return f(args, focus, env) case *Lambda: if f.Sig != "" { - specs, _ := parser.ParseSig(f.Sig) - coerced, returnUndefined, err := processCallArgs(specs, args, focus) + coerced, returnUndefined, err := processCallArgs(f.ParsedSig, args, focus) if err != nil { return nil, err } From c7823b9b44d6538c18c166b65622558331e61a43 Mon Sep 17 00:00:00 2001 From: NirBarak-RecoLabs Date: Fri, 24 Apr 2026 15:23:05 +0300 Subject: [PATCH 5/6] Optimize eval hot paths: float arith, DeepEqual, HOF buffers Five targeted hot-path optimisations in the evaluator and function dispatch layer: - Arithmetic fast-path (eval_binary.go): when both operands are already float64, bypass the generic numeric-coercion path and go straight to evalArithFloat64. This is the common case for numeric expressions after AST evaluation. - DeepEqual primitive fast-path (value.go): for same-type float64 / string / bool comparisons, skip normalizeNumber and compare directly. Drops a per-call allocation that showed up in profiles on equality-heavy expressions. - Sequence collapse (value.go): use slices.Clip in CollapseSequence and CollapseToSlice instead of slices.Clone. Since the sequence is being discarded, we can transfer ownership of the backing array rather than allocating a copy. - HOF argument buffers (hof_funcs.go, object_funcs.go): replace the per-iteration hofArgs slice with reusable hofArity / hofArgsBuf / fillHofArgs helpers applied to $map, $filter, $single, $reduce, $sift, and $each. Same arity detection, zero per-iteration allocation. - Sort comparator args (array_funcs.go): pre-allocate the 2-element sortArgs slice once per $sort call instead of rebuilding it on each comparator invocation. No behavioural changes; all existing tests pass. --- functions/array_funcs.go | 5 +- functions/hof_funcs.go | 88 +++++++++++++++++++------------ functions/object_funcs.go | 32 +++++------ internal/evaluator/eval_binary.go | 66 +++++++++++++---------- internal/evaluator/value.go | 24 +++++++-- 5 files changed, 132 insertions(+), 83 deletions(-) diff --git a/functions/array_funcs.go b/functions/array_funcs.go index 736996db..a455d6f0 100644 --- a/functions/array_funcs.go +++ b/functions/array_funcs.go @@ -129,8 +129,11 @@ func makeFnSort(evalFn EvalFn) evaluator.EnvAwareBuiltin { // JSONata $sort comparator: fn(a, b) returns true when a should come // before b. We call fn(b, a) and map true→-1 (a=b). // SortItemsErr only tests < 0, so +1 is unnecessary. + sortArgs := make([]any, 2) cmpFn = func(a, b any) (int, error) { - result, err := evalFn(fn, []any{b, a}, focus, env) + sortArgs[0] = b + sortArgs[1] = a + result, err := evalFn(fn, sortArgs, focus, env) if err != nil { return 0, err } diff --git a/functions/hof_funcs.go b/functions/hof_funcs.go index cb7a4b35..1cdf146c 100644 --- a/functions/hof_funcs.go +++ b/functions/hof_funcs.go @@ -9,25 +9,42 @@ import ( "github.com/recolabs/gnata/internal/parser" ) -// hofArgs trims the HOF callback argument list to the function's expected arity. -// For lambdas, use the declared parameter count. -// For built-in functions, default to 1 (value only) since they have their own -// argument validation and may reject unexpected extra args. -func hofArgs(fn, value, index any, arr []any) []any { +// hofArity returns the callback argument count for the given HOF function. +// For lambdas, uses the declared parameter count (capped at 3). +// For built-in functions, defaults to 1 (value only). +func hofArity(fn any) int { if lambda, ok := fn.(*evaluator.Lambda); ok { - switch len(lambda.Params) { - case 0: - return []any{} - case 1: - return []any{value} - case 2: - return []any{value, index} - default: - return []any{value, index, arr} + n := len(lambda.Params) + if n > 3 { + return 3 } + return n + } + return 1 +} + +// hofArgsBuf allocates a reusable buffer for HOF callback arguments. +func hofArgsBuf(arity int) []any { + if arity == 0 { + return nil + } + return make([]any, arity) +} + +// fillHofArgs populates a pre-allocated argument buffer for a HOF callback. +func fillHofArgs(buf []any, value, index any, arr []any) { + switch len(buf) { + case 0: + case 1: + buf[0] = value + case 2: + buf[0] = value + buf[1] = index + default: + buf[0] = value + buf[1] = index + buf[2] = arr } - // For built-in functions pass (value) only to avoid arity rejections. - return []any{value} } // ── $map ────────────────────────────────────────────────────────────────────── @@ -56,8 +73,9 @@ func makeFnMap(evalFn EvalFn) evaluator.EnvAwareBuiltin { seq := evaluator.CreateSequence() arrAny := slices.Clone(arr) + callArgs := hofArgsBuf(hofArity(fn)) for i, item := range arr { - callArgs := hofArgs(fn, item, float64(i), arrAny) + fillHofArgs(callArgs, item, float64(i), arrAny) val, err := evalFn(fn, callArgs, focus, env) if err != nil { return nil, err @@ -94,8 +112,9 @@ func makeFnFilter(evalFn EvalFn) evaluator.EnvAwareBuiltin { seq := evaluator.CreateSequence() arrAny := slices.Clone(arr) + callArgs := hofArgsBuf(hofArity(fn)) for i, item := range arr { - callArgs := hofArgs(fn, item, float64(i), arrAny) + fillHofArgs(callArgs, item, float64(i), arrAny) val, err := evalFn(fn, callArgs, focus, env) if err != nil { return nil, err @@ -151,8 +170,9 @@ func makeFnSingle(evalFn EvalFn) evaluator.EnvAwareBuiltin { fn := args[1] var matched []any arrAny := slices.Clone(arr) + callArgs := hofArgsBuf(hofArity(fn)) for i, item := range arr { - callArgs := hofArgs(fn, item, float64(i), arrAny) + fillHofArgs(callArgs, item, float64(i), arrAny) val, err := evalFn(fn, callArgs, focus, env) if err != nil { return nil, err @@ -218,21 +238,23 @@ func makeFnReduce(evalFn EvalFn) evaluator.EnvAwareBuiltin { } arrAny := slices.Clone(arr) + var reduceArity int + if lambda, ok := fn.(*evaluator.Lambda); ok { + reduceArity = max(min(len(lambda.Params), 4), 1) + } else { + reduceArity = 2 + } + callArgs := make([]any, reduceArity) for i := startIdx; i < len(arr); i++ { - var callArgs []any - if lambda, ok := fn.(*evaluator.Lambda); ok { - switch len(lambda.Params) { - case 0, 1: - callArgs = []any{acc} - case 2: - callArgs = []any{acc, arr[i]} - case 3: - callArgs = []any{acc, arr[i], float64(i)} - default: - callArgs = []any{acc, arr[i], float64(i), arrAny} - } - } else { - callArgs = []any{acc, arr[i]} + callArgs[0] = acc + if reduceArity > 1 { + callArgs[1] = arr[i] + } + if reduceArity > 2 { + callArgs[2] = float64(i) + } + if reduceArity > 3 { + callArgs[3] = arrAny } val, err := evalFn(fn, callArgs, focus, env) if err != nil { diff --git a/functions/object_funcs.go b/functions/object_funcs.go index 4327474b..062366cc 100644 --- a/functions/object_funcs.go +++ b/functions/object_funcs.go @@ -158,20 +158,19 @@ func fnMerge(args []any, _ any) (any, error) { return result, nil } -func siftArgs(fn, value any, key string, obj any) []any { - if lambda, ok := fn.(*evaluator.Lambda); ok { - switch len(lambda.Params) { - case 0: - return []any{} - case 1: - return []any{value} - case 2: - return []any{value, key} - default: - return []any{value, key, obj} - } +func fillSiftArgs(buf []any, value any, key string, obj any) { + switch len(buf) { + case 0: + case 1: + buf[0] = value + case 2: + buf[0] = value + buf[1] = key + default: + buf[0] = value + buf[1] = key + buf[2] = obj } - return []any{value} } // ── $sift ───────────────────────────────────────────────────────────────────── @@ -199,9 +198,10 @@ func makeFnSift(evalFn EvalFn) evaluator.EnvAwareBuiltin { result := evaluator.NewOrderedMap() keys := evaluator.MapKeys(objVal) + callArgs := hofArgsBuf(hofArity(fn)) for _, ks := range keys { val, _ := evaluator.MapGet(objVal, ks) - callArgs := siftArgs(fn, val, ks, objVal) + fillSiftArgs(callArgs, val, ks, objVal) res, err := evalFn(fn, callArgs, focus, env) if err != nil { return nil, err @@ -242,9 +242,11 @@ func makeFnEach(evalFn EvalFn) evaluator.EnvAwareBuiltin { keys := evaluator.MapKeys(objVal) seq := evaluator.CreateSequence() + callArgs := hofArgsBuf(max(hofArity(fn), 2)) for _, ks := range keys { val, _ := evaluator.MapGet(objVal, ks) - res, err := evalFn(fn, []any{val, ks}, focus, env) + fillSiftArgs(callArgs, val, ks, objVal) + res, err := evalFn(fn, callArgs, focus, env) if err != nil { return nil, err } diff --git a/internal/evaluator/eval_binary.go b/internal/evaluator/eval_binary.go index 03a344e7..233b9867 100644 --- a/internal/evaluator/eval_binary.go +++ b/internal/evaluator/eval_binary.go @@ -88,6 +88,13 @@ func evalBinary(node *parser.Node, input any, env *Environment) (any, error) { / switch node.Value { case "+", "-", "*", "/", "%", "**": op := node.Value + + if lf, ok := left.(float64); ok { + if rf, ok2 := right.(float64); ok2 { + return evalArithFloat64(lf, rf, op) + } + } + if left != nil { if _, ok := ToFloat64(left); !ok { code := "T2001" @@ -114,35 +121,7 @@ func evalBinary(node *parser.Node, input any, env *Environment) (any, error) { / } l, _ := ToFloat64(left) r, _ := ToFloat64(right) - var result float64 - switch op { - case "+": - result = l + r - case "-": - result = l - r - case "*": - result = l * r - case "/": - // Division by zero produces +Inf which propagates to the caller. - // $string(1/0) → D3001 (via valueToString); other uses → D1001. - // We do NOT throw here so that the error code is context-dependent. - result = l / r - if !math.IsInf(result, 0) && !math.IsNaN(result) { - return result, nil - } - return result, nil // let Inf propagate without error - case "%": - if r == 0 { - return nil, &JSONataError{Code: "D3001", Message: "modulo by zero"} - } - result = math.Mod(l, r) - case "**": - result = math.Pow(l, r) - } - if math.IsInf(result, 0) || math.IsNaN(result) { - return nil, &JSONataError{Code: "D1001", Message: fmt.Sprintf("Number out of range: %g", result)} - } - return result, nil + return evalArithFloat64(l, r, op) case "&": ls, err := stringifyValue(left) @@ -378,6 +357,35 @@ func selectByIndices(rightVal any, items []any) (any, bool) { return result, true } +// evalArithFloat64 performs arithmetic on two float64 values. +// Extracted so the fast-path (both operands already float64) can bypass +// ToFloat64 conversion overhead entirely. +func evalArithFloat64(l, r float64, op string) (any, error) { + var result float64 + switch op { + case "+": + result = l + r + case "-": + result = l - r + case "*": + result = l * r + case "/": + result = l / r + return result, nil // let Inf propagate without error + case "%": + if r == 0 { + return nil, &JSONataError{Code: "D3001", Message: "modulo by zero"} + } + result = math.Mod(l, r) + case "**": + result = math.Pow(l, r) + } + if math.IsInf(result, 0) || math.IsNaN(result) { + return nil, &JSONataError{Code: "D1001", Message: fmt.Sprintf("Number out of range: %g", result)} + } + return result, nil +} + func evalSubscriptBlockParent(node *parser.Node, input any, env *Environment) (any, error) { innerPath := node.Left.Expressions[0] tupleCtxs, err := expandPathTuple(innerPath.Steps, []pathCtx{{value: input, env: env}}) diff --git a/internal/evaluator/value.go b/internal/evaluator/value.go index 98ddfd94..c09bdbe3 100644 --- a/internal/evaluator/value.go +++ b/internal/evaluator/value.go @@ -49,8 +49,8 @@ func IsSequence(v any) bool { // CollapseSequence applies JSONata singleton-collapsing rules: // - len 0 → nil (undefined) -// - len 1 → elem[0] unless KeepSingleton -// - len > 1 → []any(seq.Values) +// - len 1 → elem[0] unless KeepSingleton is set +// - len > 1 → []any(seq.Values) — ownership transfer; callers must not mutate func CollapseSequence(s *Sequence) any { switch len(s.Values) { case 0: @@ -61,7 +61,7 @@ func CollapseSequence(s *Sequence) any { } return s.Values[0] default: - return slices.Clone(s.Values) + return slices.Clip(s.Values) } } @@ -98,8 +98,9 @@ func IsArray(v any) bool { } // CollapseToSlice returns the sequence values as a plain []any slice. +// Ownership transfer; callers must not mutate the returned slice. func CollapseToSlice(s *Sequence) []any { - return slices.Clone(s.Values) + return slices.Clip(s.Values) } // ToFloat64 converts a numeric value to float64, handling both float64 and json.Number. @@ -188,7 +189,20 @@ func normalizeNumber(v any) any { } // DeepEqual implements JSONata structural equality. -func DeepEqual(a, b any) bool { +func DeepEqual(a, b any) bool { //nolint:gocyclo // type-switch fast path adds branches but not real complexity + switch av := a.(type) { + case float64: + if bv, ok := b.(float64); ok { + return av == bv + } + case string: + bv, ok := b.(string) + return ok && av == bv + case bool: + bv, ok := b.(bool) + return ok && av == bv + } + a, b = normalizeNumber(a), normalizeNumber(b) if a == nil || b == nil || IsNull(a) || IsNull(b) { return a == nil && b == nil || IsNull(a) && IsNull(b) From 8a27a50b9fc7f730bbcc668f260dfef356ae8a18 Mon Sep 17 00:00:00 2001 From: NirBarak-RecoLabs Date: Fri, 24 Apr 2026 15:23:23 +0300 Subject: [PATCH 6/6] Add EvalMap, EvalBytesWithVars; expose via WASM; fix gjson docs Extends the public Expression API and the WASM bridge with two evaluation paths that were previously only reachable from the StreamEvaluator or the byte-level API, and cleans up the docs to match how gjson is actually used. Expression API (gnata.go): - EvalMap(ctx, data map[string]json.RawMessage): O(1) top-level key lookup via DecodeRawMap, with gjson fast paths for nested access inside each RawMessage. Useful when the caller already has the JSON decoded into a map and wants to avoid re-serialising. - EvalBytesWithVars(ctx, data json.RawMessage, vars map[string]any): evaluate raw JSON bytes with external $-variable bindings, while keeping the gjson fast-path eligible. WASM bridge (wasm/main.go, playground.html): - Export two new JS functions, gnataEvalMap and gnataEvalWithVars, mirroring the new Expression methods. gnataEvalMap takes a JS object and feeds its top-level keys as json.RawMessage values; gnataEvalWithVars takes a vars JSON blob for $-bindings. - Route gnataEval and gnataEvalHandle through EvalBytes so they also benefit from the gjson fast path. - Add JS wrappers in playground.html so the new exports are usable from the browser playground. Documentation (README.md, AGENTS.md): - Fix the StreamEvaluator description: the hot path uses gjson.GetBytes per fast-path expression, not a single gjson.GetManyBytes call for the whole event. - Update the public-API list in README to include EvalMap and EvalBytesWithVars, and expand the WASM section with a table of all six exported JS functions. --- AGENTS.md | 4 +- README.md | 19 ++++++-- gnata.go | 56 ++++++++++++++++++++++ playground.html | 2 + wasm/main.go | 125 +++++++++++++++++++++++++++++++++++++++++------- 5 files changed, 183 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ec7fa6d2..8735c568 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ Lexer → Parser → AST Processing → Fast-Path Analysis → Expression ### StreamEvaluator (stream.go) -Batch-evaluates multiple expressions against events. Schema-keyed `GroupPlan` caching deduplicates field extraction across expressions. Lock-free reads via `atomic.Pointer` snapshot; writes serialized by `sync.Mutex`. Single JSON scan per event via `gjson.GetManyBytes`. +Batch-evaluates multiple expressions against events. Schema-keyed `GroupPlan` caching classifies expressions into fast-path vs full-eval at plan-build time. Lock-free reads via `atomic.Pointer` snapshot; writes serialized by `sync.Mutex`. Fast-path expressions use `gjson.GetBytes` for zero-copy field extraction. ### Evaluator Dispatch (internal/evaluator/) @@ -97,4 +97,4 @@ se := gnata.NewStreamEvaluator(nil, gnata.WithCustomFunctions(customFuncs)) ## WASM -`wasm/main.go` exports `gnataEval`, `gnataCompile`, `gnataEvalHandle` for browser use. Build with `GOOS=js GOARCH=wasm go build -ldflags="-s -w" -trimpath -o gnata.wasm ./wasm/`. +`wasm/main.go` exports six JS functions: `gnataEval`, `gnataCompile`, `gnataEvalHandle`, `gnataReleaseHandle`, `gnataEvalMap` (O(1) top-level key lookup via `EvalMap`), and `gnataEvalWithVars` (external `$`-variable bindings). `gnataEval` and `gnataEvalHandle` use `EvalBytes`; `gnataEvalMap` uses `EvalMap`; `gnataEvalWithVars` uses `EvalBytesWithVars`. All paths leverage gjson fast-path access where applicable. Build with `GOOS=js GOARCH=wasm go build -ldflags="-s -w" -trimpath -o gnata.wasm ./wasm/`. diff --git a/README.md b/README.md index 80f8b210..025efe37 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ Hot Path (millions/day, lock-free) ├── BoundedCache lookup (atomic pointer read) │ ├── HIT ──> Immutable GroupPlan │ └── MISS ──> Build plan (merge GJSON paths, atomic CAS store) - ├── gjson.GetManyBytes: SINGLE scan for ALL expressions + ├── gjson.GetBytes per fast-path expression ├── Fast-path expressions: distribute extracted results (0 allocs) ├── Full-path expressions: selective unmarshal + AST eval └── results[] @@ -156,7 +156,7 @@ Hot Path (millions/day, lock-free) ### Key Properties -- **One JSON scan per event** — all field paths needed by all expressions are merged into a single `gjson.GetManyBytes` call. +- **Efficient JSON field extraction** — fast-path expressions use `gjson.GetBytes` for zero-copy path lookups directly on raw JSON bytes. - **Schema-keyed caching** — the `GroupPlan` (merged paths, expression groupings, selective unmarshal targets) is computed once per schema key and reused immutably. - **Lock-free reads** — `BoundedCache` publishes an `atomic.Pointer` snapshot on every write; reads scan the snapshot without acquiring a lock. Writes are serialised by a mutex. - **Selective unmarshal** — full-path expressions unmarshal only the subtrees they need (e.g., just the `items` array from a 10KB event), not the entire document. @@ -421,7 +421,7 @@ All standard regex features (character classes, quantifiers, alternation, groupi ``` gnata/ -├── gnata.go # Public API: Compile, Eval, EvalBytes, EvalWithVars, CustomFunc +├── gnata.go # Public API: Compile, Eval, EvalBytes, EvalBytesWithVars, EvalMap, EvalWithVars, CustomFunc ├── stream.go # StreamEvaluator, GroupPlan, EvalMany, EvalMap, MetricsHook ├── bounded_cache.go # Lock-free FIFO ring-buffer plan cache ├── deep_equal.go # JSONata-compatible deep equality @@ -485,7 +485,18 @@ python3 -m http.server 8899 caddy file-server --root . --listen :8899 ``` -The WASM build exposes `gnataEval`, `gnataCompile`, and `gnataEvalHandle` functions for use from JavaScript, with a compiled-expression cache for repeated evaluations. A ready-made `playground.html` is included — build the WASM binary, copy the Go WASM support file, and serve the directory: +The WASM build exposes six functions for use from JavaScript (the raw exports are underscore-prefixed; `playground.html` wraps them into clean public names): + +| Function | Description | +|---|---| +| `gnataEval(expr, jsonData)` | One-shot compile + evaluate (expressions are cached). | +| `gnataCompile(expr)` | Compile an expression and return a numeric handle. | +| `gnataEvalHandle(handle, jsonData)` | Evaluate a compiled handle against JSON data. Uses `EvalBytes` internally for gjson fast-path access. | +| `gnataReleaseHandle(handle)` | Free a compiled handle. | +| `gnataEvalMap(handle, jsonObject)` | Evaluate a compiled handle using `EvalMap` — O(1) top-level key lookup with gjson fast paths for nested access. Ideal for pre-destructured data. | +| `gnataEvalWithVars(handle, jsonData, varsJson)` | Evaluate with external `$`-variable bindings (e.g. `{"$threshold": 100}`). | + +A ready-made `playground.html` is included — build the WASM binary, copy the Go WASM support file, and serve the directory: ```bash cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" . diff --git a/gnata.go b/gnata.go index 120ad131..14b55f11 100644 --- a/gnata.go +++ b/gnata.go @@ -223,6 +223,62 @@ func (e *Expression) EvalBytes(ctx context.Context, data json.RawMessage) (resul return e.Eval(ctx, v) } +// EvalMap evaluates the expression against a map of field names to raw JSON values. +// This enables O(1) top-level key lookup with gjson fast paths for nested access, +// making it ideal for pre-destructured data (e.g. database columns, form fields). +func (e *Expression) EvalMap(ctx context.Context, data map[string]json.RawMessage) (result any, err error) { + defer recoverEvalPanic(&err) + if e.fastPath && len(e.paths) == 1 { + if res := resolveGjsonPath(nil, data, e.paths[0]); res.Exists() { + return gjsonValueToAny(&res), nil + } + } + if e.cmpFast != nil { + if res, handled, evalErr := evalComparison(e.cmpFast, nil, data); handled || evalErr != nil { + return res, evalErr + } + } + if e.funcFast != nil { + if res, handled, evalErr := evalFunc(e.funcFast, nil, data); handled || evalErr != nil { + return res, evalErr + } + } + v, err := evaluator.DecodeRawMap(data) + if err != nil { + return nil, err + } + return e.Eval(ctx, v) +} + +// EvalBytesWithVars evaluates the expression against raw JSON bytes with extra +// variable bindings. Combines the gjson fast-path cascade from EvalBytes with +// the variable support from EvalWithVars. Fast-path expressions never reference +// $variables (excluded at compile time), so the fast-path result is independent +// of the variable map; only the full-eval fallback uses vars. +func (e *Expression) EvalBytesWithVars(ctx context.Context, data json.RawMessage, vars map[string]any) (result any, err error) { + defer recoverEvalPanic(&err) + if e.fastPath && len(e.paths) == 1 { + if res := gjson.GetBytes(data, e.paths[0]); res.Exists() { + return gjsonValueToAny(&res), nil + } + } + if e.cmpFast != nil { + if res, handled, evalErr := evalComparison(e.cmpFast, data, nil); handled || evalErr != nil { + return res, evalErr + } + } + if e.funcFast != nil { + if res, handled, evalErr := evalFunc(e.funcFast, data, nil); handled || evalErr != nil { + return res, evalErr + } + } + v, err := evaluator.DecodeJSON(data) + if err != nil { + return nil, err + } + return e.evalCore(ctx, v, builtinEnv, vars) +} + // resolveGjsonPath resolves a gjson path from either raw bytes or a pre-decoded map. // When data is available (EvalMany), it delegates to gjson.GetBytes on the full blob. // When mapData is available (EvalMap), it does an O(1) map lookup for the top-level diff --git a/playground.html b/playground.html index 624e6b8f..9e7a5634 100644 --- a/playground.html +++ b/playground.html @@ -610,6 +610,8 @@

gnata

window.gnataCompile = wrapWasm((...a) => window._gnataCompile(...a)); window.gnataEvalHandle = wrapWasm((...a) => window._gnataEvalHandle(...a)); window.gnataReleaseHandle = wrapWasm((...a) => window._gnataReleaseHandle(...a)); + window.gnataEvalMap = wrapWasm((...a) => window._gnataEvalMap(...a)); + window.gnataEvalWithVars = wrapWasm((...a) => window._gnataEvalWithVars(...a)); go.run(result.instance).catch(err => { statusEl.classList.remove('ready'); diff --git a/wasm/main.go b/wasm/main.go index 9ba78a26..aaac28e5 100644 --- a/wasm/main.go +++ b/wasm/main.go @@ -8,10 +8,12 @@ // // Raw WASM exports (registered on the JS global object with underscore prefix): // -// _gnataEval(expr, jsonData) → string | Error -// _gnataCompile(expr) → number | Error -// _gnataEvalHandle(handle, jsonData) → string | Error -// _gnataReleaseHandle(handle) → undefined | Error +// _gnataEval(expr, jsonData) → string | Error +// _gnataCompile(expr) → number | Error +// _gnataEvalHandle(handle, jsonData) → string | Error +// _gnataReleaseHandle(handle) → undefined | Error +// _gnataEvalMap(handle, jsonObject) → string | Error +// _gnataEvalWithVars(handle, jsonData, vars) → string | Error // // playground.html wraps these with a wrapWasm factory that converts returned // Error values into thrown exceptions, exposing the public names without the @@ -49,6 +51,8 @@ func main() { js.Global().Set("_gnataCompile", js.FuncOf(jsCompile)) js.Global().Set("_gnataEvalHandle", js.FuncOf(jsEvalHandle)) js.Global().Set("_gnataReleaseHandle", js.FuncOf(jsReleaseHandle)) + js.Global().Set("_gnataEvalMap", js.FuncOf(jsEvalMap)) + js.Global().Set("_gnataEvalWithVars", js.FuncOf(jsEvalWithVars)) select {} } @@ -155,8 +159,94 @@ func doEvalHandle(handle uint32, jsonData string) (result string, err error) { return evalAndMarshal(e, jsonData) } +// jsEvalMap: _gnataEvalMap(handle, jsonObject) → string | Error +// Evaluates a compiled expression against a JSON object using EvalMap for O(1) +// top-level key lookup with gjson fast paths for nested access. +func jsEvalMap(_ js.Value, args []js.Value) any { + if len(args) < 2 { + return jsError("gnataEvalMap requires 2 arguments: handle, jsonObject") + } + if args[0].Type() != js.TypeNumber { + return jsError("gnataEvalMap: handle must be a number") + } + result, err := doEvalMap(uint32(args[0].Int()), args[1].String()) + if err != nil { + return jsError(err.Error()) + } + return js.ValueOf(result) +} + +// jsEvalWithVars: _gnataEvalWithVars(handle, jsonData, varsJson) → string | Error +// Evaluates a compiled expression with external variable bindings ($-prefixed +// names accessible in the expression). +func jsEvalWithVars(_ js.Value, args []js.Value) any { + if len(args) < 3 { + return jsError("gnataEvalWithVars requires 3 arguments: handle, jsonData, varsJson") + } + if args[0].Type() != js.TypeNumber { + return jsError("gnataEvalWithVars: handle must be a number") + } + result, err := doEvalWithVars(uint32(args[0].Int()), args[1].String(), args[2].String()) + if err != nil { + return jsError(err.Error()) + } + return js.ValueOf(result) +} + +func doEvalMap(handle uint32, jsonObject string) (result string, err error) { + defer catchPanic(&err) + + val, ok := compiledCache.Load(handle) + if !ok { + return "", fmt.Errorf("unknown handle %d", handle) + } + e := val.(*gnata.Expression) + + var data map[string]json.RawMessage + if jsonObject != "" { + if err := json.Unmarshal([]byte(jsonObject), &data); err != nil { + return "", fmt.Errorf("invalid JSON object: %w", err) + } + } + + res, evalErr := e.EvalMap(context.Background(), data) + if evalErr != nil { + return "", evalErr + } + return marshalResult(res) +} + +func doEvalWithVars(handle uint32, jsonData, varsJSON string) (result string, err error) { + defer catchPanic(&err) + + val, ok := compiledCache.Load(handle) + if !ok { + return "", fmt.Errorf("unknown handle %d", handle) + } + e := val.(*gnata.Expression) + + var vars map[string]any + if varsJSON != "" && varsJSON != "{}" { + if unmarshalErr := json.Unmarshal([]byte(varsJSON), &vars); unmarshalErr != nil { + return "", fmt.Errorf("invalid vars JSON: %w", unmarshalErr) + } + } + + var res any + var evalErr error + if jsonData == "" { + res, evalErr = e.EvalWithVars(context.Background(), nil, vars) + } else { + res, evalErr = e.EvalBytesWithVars(context.Background(), json.RawMessage(jsonData), vars) + } + if evalErr != nil { + return "", evalErr + } + return marshalResult(res) +} + // evalAndMarshal evaluates expr against jsonData and marshals the result to JSON. -// Shared by doEval and doEvalHandle to avoid duplicating unmarshal/eval/marshal logic. +// Uses EvalBytes to enable gjson fast paths (pure path, comparison, function). // // Return values: // - ("", nil) → expression evaluated to undefined (no match). @@ -164,26 +254,27 @@ func doEvalHandle(handle uint32, jsonData string) (result string, err error) { // - (json, nil) → expression evaluated to a concrete value. // - ("", err) → evaluation or marshal error. func evalAndMarshal(e *gnata.Expression, jsonData string) (string, error) { - var data any - if jsonData != "" && jsonData != "null" { - if err := json.Unmarshal([]byte(jsonData), &data); err != nil { - return "", fmt.Errorf("invalid JSON input: %w", err) - } + var res any + var err error + if jsonData == "" { + res, err = e.Eval(context.Background(), nil) + } else { + res, err = e.EvalBytes(context.Background(), json.RawMessage(jsonData)) } - - res, err := e.Eval(context.Background(), data) if err != nil { return "", err } + return marshalResult(res) +} - // Eval returns (nil, nil) for undefined results (non-matching paths). - // Actual JSON null is the evaluator.Null sentinel (jsonNullType), - // which marshals to "null" via MarshalJSON. Returning "" here lets - // the JS wrapper map it to JavaScript undefined. +// marshalResult marshals an evaluation result to JSON. +// Returns ("", nil) for undefined (Go nil), letting the JS wrapper map it to +// JavaScript undefined. Actual JSON null is the evaluator.Null sentinel +// which marshals to "null" via MarshalJSON. +func marshalResult(res any) (string, error) { if res == nil { return "", nil } - out, err := json.Marshal(res) if err != nil { return "", fmt.Errorf("cannot marshal result: %w", err)