diff --git a/README.md b/README.md index 7bb8915..63a837a 100644 --- a/README.md +++ b/README.md @@ -422,8 +422,8 @@ All standard regex features (character classes, quantifiers, alternation, groupi ``` gnata/ -├── gnata.go # Public API: Compile, Eval, EvalBytes, EvalBytesWithVars, EvalMap, EvalWithVars, CustomFunc -├── stream.go # StreamEvaluator, GroupPlan, EvalMany, EvalMap, MetricsHook +├── gnata.go # Public API: Compile, Eval, EvalBytes, EvalBytesWithVars, EvalMap, EvalWithVars, CustomEnvironment, OrderedMap, JSONNull +├── stream.go # StreamEvaluator, GroupPlan, EvalMany, EvalManyWithVars, EvalMap, MetricsHook ├── bounded_cache.go # Lock-free FIFO ring-buffer plan cache ├── deep_equal.go # JSONata-compatible deep equality ├── internal/ diff --git a/gnata.go b/gnata.go index 164c833..dfcb5af 100644 --- a/gnata.go +++ b/gnata.go @@ -64,6 +64,18 @@ func Compile(expr string) (*Expression, error) { // It receives evaluated arguments and the current context value (focus). type CustomFunc func(args []any, focus any) (any, error) +// CustomEnvironment is a reusable root environment containing standard library +// functions and caller-provided custom functions. +type CustomEnvironment struct { + env *evaluator.Environment +} + +// NewCustomEnvironment pre-builds a reusable environment for a stable set of +// custom functions. Each evaluation creates a child environment for variables. +func NewCustomEnvironment(customFuncs map[string]CustomFunc) *CustomEnvironment { + return &CustomEnvironment{env: newEnv(customFuncs)} +} + // builtinEnv is a shared root environment with all standard library functions. // Created once at init; each eval creates a thin child env for per-call bindings. var builtinEnv *evaluator.Environment @@ -401,6 +413,23 @@ func (e *Expression) EvalWithCustomFuncs(ctx context.Context, data any, env *eva return e.evalCore(ctx, data, env, nil) } +// EvalWithCustomEnvironmentAndVars evaluates with a pre-built custom +// environment and per-call variable bindings. Construct the environment once +// via NewCustomEnvironment and reuse it across calls — rebuilding the +// environment per evaluation re-registers the entire standard library and is +// significantly more expensive than the per-call variable bind below. +func (e *Expression) EvalWithCustomEnvironmentAndVars( + ctx context.Context, + data any, + customEnv *CustomEnvironment, + vars map[string]any, +) (result any, err error) { + if customEnv == nil { + return e.evalCore(ctx, data, builtinEnv, vars) + } + return e.evalCore(ctx, data, customEnv.env, vars) +} + // NewCustomEnv creates a root environment with all standard library functions // plus the provided custom functions. The returned environment is goroutine-safe // for concurrent reads and should be reused across evaluations. @@ -450,6 +479,10 @@ func IsNull(v any) bool { return evaluator.IsNull(v) } +type JSONNull = evaluator.JSONNull + +var Null = evaluator.Null + type OrderedMap = evaluator.OrderedMap func NewOrderedMap() *OrderedMap { diff --git a/gnata_test.go b/gnata_test.go index 38705b5..a4fc90c 100644 --- a/gnata_test.go +++ b/gnata_test.go @@ -49,6 +49,88 @@ func TestOrderedMap_TypeAssertFromEval(t *testing.T) { } } +func TestQuotedPathArrayIndexSelect(t *testing.T) { + data := map[string]any{ + "testData": []any{ + map[string]any{"value": "output"}, + }, + } + tests := []struct { + name string + expr string + want any + }{ + {name: "unquoted", expr: `testData[0].value`, want: "output"}, + {name: "quoted", expr: `"testData"[0]."value"`, want: "output"}, + {name: "mixed", expr: `testData[0]."value"`, want: "output"}, + {name: "rooted quoted", expr: `$."testData"[0]."value"`, want: "output"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + compiled, err := gnata.Compile(tc.expr) + if err != nil { + t.Fatalf("Compile(%q): %v", tc.expr, err) + } + got, err := compiled.Eval(context.Background(), data) + if err != nil { + t.Fatalf("Eval(%q): %v", tc.expr, err) + } + if !gnata.DeepEqual(got, tc.want) { + t.Errorf("Eval(%q) = %#v (%T), want %#v (%T)", tc.expr, got, got, tc.want, tc.want) + } + }) + } +} + +func TestJSONNull_TypeAssertFromEval(t *testing.T) { + compiled, err := gnata.Compile(`{"a": null}`) + if err != nil { + t.Fatalf("Compile: %v", err) + } + result, err := compiled.Eval(context.Background(), nil) + if err != nil { + t.Fatalf("Eval: %v", err) + } + om, ok := result.(*gnata.OrderedMap) + if !ok { + t.Fatalf("Eval result type %T, want *gnata.OrderedMap", result) + } + val, exists := om.Get("a") + if !exists { + t.Fatal("missing key a") + } + if !gnata.IsNull(val) { + t.Fatalf("IsNull(%v) = false, want true", val) + } + if _, ok := val.(gnata.JSONNull); !ok { + t.Fatalf("value type %T, want gnata.JSONNull", val) + } + if val != gnata.Null { + t.Fatalf("value %v != gnata.Null", val) + } +} + +func TestEvalWithCustomEnvironmentAndVars(t *testing.T) { + env := gnata.NewCustomEnvironment(map[string]gnata.CustomFunc{ + "greet": func(args []any, _ any) (any, error) { + return "hello " + args[0].(string), nil + }, + }) + compiled, err := gnata.Compile(`$greet($who)`) + if err != nil { + t.Fatalf("Compile: %v", err) + } + got, err := compiled.EvalWithCustomEnvironmentAndVars( + context.Background(), nil, env, map[string]any{"who": "world"}, + ) + if err != nil { + t.Fatalf("EvalWithCustomEnvironmentAndVars: %v", err) + } + if got != "hello world" { + t.Fatalf("got %v, want %q", got, "hello world") + } +} + func TestDeepEqual(t *testing.T) { tests := []struct { a, b any diff --git a/internal/evaluator/value.go b/internal/evaluator/value.go index c09bdbe..c71afae 100644 --- a/internal/evaluator/value.go +++ b/internal/evaluator/value.go @@ -9,17 +9,17 @@ import ( ) // Null is the singleton JSONata null value. -var Null any = jsonNullType{} +var Null any = JSONNull{} // JSONNull is a sentinel type that represents JSON null explicitly, // distinguishing it from Go nil (which represents JSONata undefined). -type jsonNullType struct{} +type JSONNull struct{} -func (jsonNullType) MarshalJSON() ([]byte, error) { return []byte(parser.NullJSON), nil } +func (JSONNull) MarshalJSON() ([]byte, error) { return []byte(parser.NullJSON), nil } // IsNull reports whether v is the JSON null sentinel. func IsNull(v any) bool { - _, ok := v.(jsonNullType) + _, ok := v.(JSONNull) return ok } diff --git a/internal/parser/process.go b/internal/parser/process.go index 550c8dc..ecc5bee 100644 --- a/internal/parser/process.go +++ b/internal/parser/process.go @@ -126,7 +126,7 @@ func collectPathSteps(node *Node) ([]*Node, error) { if processed.Type == NodePath { return processed.Steps, nil } - // In a path context, a string literal (e.g. ."Product Name") is a field name lookup. + promoteQuotedPathNames(processed) if processed.Type == NodeString { processed = &Node{Type: NodeName, Value: processed.Value, Pos: processed.Pos} } @@ -158,6 +158,25 @@ func collectPathSteps(node *Node) ([]*Node, error) { return append(leftSteps, rightSteps...), nil } +func promoteQuotedPathNames(n *Node) { + for n != nil && n.Type == NodeBinary && n.Value == "[" { + if n.Left != nil && n.Left.Type == NodeString { + left := n.Left + n.Left = &Node{ + Type: NodeName, + Value: left.Value, + Pos: left.Pos, + KeepArray: left.KeepArray, + Group: left.Group, + Index: left.Index, + Focus: left.Focus, + } + return + } + n = n.Left + } +} + // processBinaryChildren recursively processes a non-dot binary node. func processBinaryChildren(node *Node) (*Node, error) { var err error diff --git a/npm/package-lock.json b/npm/package-lock.json index 4fc0d4a..da72a61 100644 --- a/npm/package-lock.json +++ b/npm/package-lock.json @@ -1,12 +1,12 @@ { "name": "gnata-js", - "version": "0.2.3", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gnata-js", - "version": "0.2.3", + "version": "0.3.0", "license": "MIT", "devDependencies": { "typescript": "^5.8.0" diff --git a/npm/package.json b/npm/package.json index 4c1bf3e..8b740a0 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "gnata-js", - "version": "0.2.3", + "version": "0.3.0", "description": "Browser JSONata via gnata WASM for backend parity, not a performance optimization", "license": "MIT", "repository": { diff --git a/stream.go b/stream.go index 92d3e5b..e531e24 100644 --- a/stream.go +++ b/stream.go @@ -216,7 +216,19 @@ func (se *StreamEvaluator) Reset() { func (se *StreamEvaluator) EvalMany( ctx context.Context, data json.RawMessage, schemaKey string, exprIndices []int, ) ([]any, error) { - return se.evalInternal(ctx, data, nil, nil, schemaKey, exprIndices) + return se.evalInternal(ctx, data, nil, nil, nil, schemaKey, exprIndices) +} + +// EvalManyWithVars is like EvalMany but injects extra $-variable bindings into +// the full-eval environment. Fast-path expressions are unaffected (they never +// reference $-variables, so the fast-path result is independent of vars). +// +// Pass nil or empty vars to get exactly EvalMany behaviour at zero extra cost. +func (se *StreamEvaluator) EvalManyWithVars( + ctx context.Context, data json.RawMessage, vars map[string]any, + schemaKey string, exprIndices []int, +) ([]any, error) { + return se.evalInternal(ctx, data, nil, nil, vars, schemaKey, exprIndices) } // EvalMap evaluates the specified expressions against a map of raw JSON values. @@ -229,11 +241,17 @@ func (se *StreamEvaluator) EvalMany( func (se *StreamEvaluator) EvalMap( ctx context.Context, data map[string]json.RawMessage, schemaKey string, exprIndices []int, ) ([]any, error) { - return se.evalInternal(ctx, nil, nil, data, schemaKey, exprIndices) + return se.evalInternal(ctx, nil, nil, data, nil, schemaKey, exprIndices) } func (se *StreamEvaluator) evalInternal( - ctx context.Context, data json.RawMessage, preparsed any, mapData map[string]json.RawMessage, schemaKey string, exprIndices []int, + ctx context.Context, + data json.RawMessage, + preparsed any, + mapData map[string]json.RawMessage, + vars map[string]any, + schemaKey string, + exprIndices []int, ) (results []any, err error) { defer recoverEvalPanic(&err) if len(exprIndices) == 0 { @@ -265,7 +283,7 @@ func (se *StreamEvaluator) evalInternal( } results = make([]any, len(exprIndices)) - batch := evalBatch{se: se, plan: plan, data: data, mapData: mapData, parsed: preparsed, parseAttempted: preparsed != nil} + batch := evalBatch{se: se, plan: plan, data: data, mapData: mapData, parsed: preparsed, parseAttempted: preparsed != nil, vars: vars} for i, idx := range exprIndices { if err := ctx.Err(); err != nil { return nil, err @@ -297,6 +315,7 @@ type evalBatch struct { parsed any parsedErr error parseAttempted bool + vars map[string]any } // evalSingleExpr evaluates one expression, trying fast paths first (pure path, @@ -374,9 +393,16 @@ func (b *evalBatch) fullEval(ctx context.Context, idx int, expr *Expression, sta } var result any var err error - if b.se.customEnv != nil { + switch { + case len(b.vars) > 0: + parent := b.se.customEnv + if parent == nil { + parent = builtinEnv + } + result, err = expr.evalCore(ctx, b.parsed, parent, b.vars) + case b.se.customEnv != nil: result, err = expr.EvalWithCustomFuncs(ctx, b.parsed, b.se.customEnv) - } else { + default: result, err = expr.Eval(ctx, b.parsed) } if b.se.metrics != nil { diff --git a/stream_test.go b/stream_test.go index 4567b66..d3a2c5c 100644 --- a/stream_test.go +++ b/stream_test.go @@ -600,3 +600,51 @@ func TestStreamEvaluator_ConcurrentSafety(t *testing.T) { }) } } + +func TestStreamEvaluator_EvalManyWithVars(t *testing.T) { + se := gnata.NewStreamEvaluator(nil) + + varIdx, err := se.Compile(`$myVar.value`) + if err != nil { + t.Fatalf("Compile: %v", err) + } + fastIdx, err := se.Compile(`data.user_type = 2`) + if err != nil { + t.Fatalf("Compile fast: %v", err) + } + + vars := map[string]any{ + "myVar": map[string]any{"value": "hello-from-var"}, + } + + results, err := se.EvalManyWithVars( + context.Background(), + json.RawMessage(streamTestData), + vars, + "schema-vars", + []int{varIdx, fastIdx}, + ) + if err != nil { + t.Fatalf("EvalManyWithVars: %v", err) + } + if got := results[0]; got != "hello-from-var" { + t.Errorf("var expression: want %q, got %v", "hello-from-var", got) + } + if got := results[1]; got != true { + t.Errorf("fast-path expression: want true, got %v", got) + } + + resultsNoVars, err := se.EvalManyWithVars( + context.Background(), + json.RawMessage(streamTestData), + nil, + "schema-vars", + []int{fastIdx}, + ) + if err != nil { + t.Fatalf("EvalManyWithVars(nil vars): %v", err) + } + if got := resultsNoVars[0]; got != true { + t.Errorf("nil-vars fast-path: want true, got %v", got) + } +}