Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)

Expand Down Expand Up @@ -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/`.
19 changes: 15 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,15 +148,15 @@ 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[]
```

### 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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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" .
Expand Down
5 changes: 4 additions & 1 deletion functions/array_funcs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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), false→0 (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
}
Expand Down
88 changes: 55 additions & 33 deletions functions/hof_funcs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
32 changes: 17 additions & 15 deletions functions/object_funcs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
14 changes: 11 additions & 3 deletions functions/register.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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())
Expand Down
2 changes: 1 addition & 1 deletion functions/string_funcs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
56 changes: 56 additions & 0 deletions gnata.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 10 additions & 8 deletions internal/evaluator/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading