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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
33 changes: 33 additions & 0 deletions gnata.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
82 changes: 82 additions & 0 deletions gnata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions internal/evaluator/value.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
21 changes: 20 additions & 1 deletion internal/parser/process.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
}
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions npm/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion npm/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
38 changes: 32 additions & 6 deletions stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
48 changes: 48 additions & 0 deletions stream_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}