From a93380e05def18d1ff6d9704fb1da2074356d63e Mon Sep 17 00:00:00 2001 From: Andrii Iudin Date: Thu, 5 Feb 2026 16:50:36 +0000 Subject: [PATCH] Optimize JSONPath context tracking and descendant traversal, with reproducible benchmarks --- README.md | 1 + go.sum | 2 - pkg/jsonpath/config/config.go | 17 + pkg/jsonpath/config/config_test.go | 15 + pkg/jsonpath/context_usage.go | 391 ++++++++++++++++++ pkg/jsonpath/context_usage_test.go | 315 ++++++++++++++ pkg/jsonpath/filter_context.go | 146 ++++++- pkg/jsonpath/filter_context_test.go | 137 ++++++ pkg/jsonpath/jsonpath_plus_test.go | 66 +++ .../lazy_context_tracking_stress_test.go | 106 +++++ pkg/jsonpath/lazy_context_tracking_test.go | 83 ++++ pkg/jsonpath/parser.go | 14 +- pkg/jsonpath/parser_lazy_context_test.go | 138 +++++++ pkg/jsonpath/segment.go | 19 +- pkg/jsonpath/yaml_query.go | 49 ++- pkg/jsonpath/yaml_query_test.go | 36 ++ 16 files changed, 1506 insertions(+), 29 deletions(-) create mode 100644 pkg/jsonpath/config/config_test.go create mode 100644 pkg/jsonpath/context_usage.go create mode 100644 pkg/jsonpath/context_usage_test.go create mode 100644 pkg/jsonpath/filter_context_test.go create mode 100644 pkg/jsonpath/lazy_context_tracking_stress_test.go create mode 100644 pkg/jsonpath/lazy_context_tracking_test.go create mode 100644 pkg/jsonpath/parser_lazy_context_test.go diff --git a/README.md b/README.md index b7302a3..ebf91b5 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ store: ### Context Variables Context variables provide information about the current evaluation context within filter expressions. They are prefixed with `@` and can be used in comparisons. +By default, context tracking is eager to preserve historical behavior. If you want to reduce overhead for queries that do not use context variables, enable `config.WithLazyContextTracking()` to turn on tracking only when a query uses `@property`, `@path`, `@parentProperty`, or `@index`. #### `@property` diff --git a/go.sum b/go.sum index 76fc3f4..6cf1eda 100644 --- a/go.sum +++ b/go.sum @@ -12,8 +12,6 @@ github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZV github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= -go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U= go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pkg/jsonpath/config/config.go b/pkg/jsonpath/config/config.go index 8e6b8a2..a064736 100644 --- a/pkg/jsonpath/config/config.go +++ b/pkg/jsonpath/config/config.go @@ -10,6 +10,15 @@ func WithPropertyNameExtension() Option { } } +// WithLazyContextTracking enables on-demand tracking for JSONPath Plus context variables. +// When enabled, tracking is only turned on if the query uses @property, @path, @parentProperty, or @index. +// Defaults to false to preserve historical eager tracking behavior. +func WithLazyContextTracking() Option { + return func(cfg *config) { + cfg.lazyContextTracking = true + } +} + // WithStrictRFC9535 disables JSONPath Plus extensions and enforces strict RFC 9535 compliance. // By default, JSONPath Plus extensions are enabled as they are a true superset of RFC 9535. // Use this option if you need to ensure pure RFC 9535 compliance. @@ -22,11 +31,13 @@ func WithStrictRFC9535() Option { type Config interface { PropertyNameEnabled() bool JSONPathPlusEnabled() bool + LazyContextTrackingEnabled() bool } type config struct { propertyNameExtension bool strictRFC9535 bool + lazyContextTracking bool } func (c *config) PropertyNameEnabled() bool { @@ -40,6 +51,12 @@ func (c *config) JSONPathPlusEnabled() bool { return !c.strictRFC9535 } +// LazyContextTrackingEnabled returns true if on-demand tracking is enabled. +// Defaults to false for backward compatibility. +func (c *config) LazyContextTrackingEnabled() bool { + return c.lazyContextTracking +} + func New(opts ...Option) Config { cfg := &config{} for _, opt := range opts { diff --git a/pkg/jsonpath/config/config_test.go b/pkg/jsonpath/config/config_test.go new file mode 100644 index 0000000..42fc5bc --- /dev/null +++ b/pkg/jsonpath/config/config_test.go @@ -0,0 +1,15 @@ +package config + +import "testing" + +func TestLazyContextTrackingOption(t *testing.T) { + cfg := New() + if cfg.LazyContextTrackingEnabled() { + t.Fatalf("expected lazy context tracking disabled by default") + } + + cfg = New(WithLazyContextTracking()) + if !cfg.LazyContextTrackingEnabled() { + t.Fatalf("expected lazy context tracking enabled with option") + } +} diff --git a/pkg/jsonpath/context_usage.go b/pkg/jsonpath/context_usage.go new file mode 100644 index 0000000..4ee137f --- /dev/null +++ b/pkg/jsonpath/context_usage.go @@ -0,0 +1,391 @@ +package jsonpath + +type contextVarUsage struct { + property bool + parent bool + parentProperty bool + path bool + index bool +} + +// mark records that a context variable kind is used +func (u *contextVarUsage) mark(kind contextVarKind) { + switch kind { + case contextVarProperty: + u.property = true + case contextVarParent: + u.parent = true + case contextVarParentProperty: + u.parentProperty = true + case contextVarPath: + u.path = true + case contextVarIndex: + u.index = true + } +} + +// contextVarUsage collects context variable usage for the full JSONPath AST +func (q jsonPathAST) contextVarUsage() contextVarUsage { + var usage contextVarUsage + for _, seg := range q.segments { + seg.collectContextVarUsage(&usage) + } + return usage +} + +// collectContextVarUsage records context variable usage for a query, handling nil safely +func (q *jsonPathAST) collectContextVarUsage(usage *contextVarUsage) { + if q == nil { + return + } + for _, seg := range q.segments { + seg.collectContextVarUsage(usage) + } +} + +// hasPropertyNameReferences reports whether the query references the property name selector +func (q jsonPathAST) hasPropertyNameReferences() bool { + for _, seg := range q.segments { + if seg.hasPropertyNameReferences() { + return true + } + } + return false +} + +// hasPropertyNameReferencesPtr is a nil-safe wrapper around hasPropertyNameReferences +func (q *jsonPathAST) hasPropertyNameReferencesPtr() bool { + if q == nil { + return false + } + return q.hasPropertyNameReferences() +} + +// hasPropertyNameReferences reports whether the segment references the property name selector +func (s *segment) hasPropertyNameReferences() bool { + if s.kind == segmentKindProperyName { + return true + } + if s.child != nil && s.child.hasPropertyNameReferences() { + return true + } + if s.descendant != nil && s.descendant.hasPropertyNameReferences() { + return true + } + return false +} + +// hasPropertyNameReferences reports whether the inner segment references the property name selector +func (s *innerSegment) hasPropertyNameReferences() bool { + for _, sel := range s.selectors { + if sel.hasPropertyNameReferences() { + return true + } + } + return false +} + +// hasPropertyNameReferences reports whether the selector references the property name selector +func (s *selector) hasPropertyNameReferences() bool { + if s.filter != nil && s.filter.hasPropertyNameReferences() { + return true + } + return false +} + +// hasPropertyNameReferences reports whether the filter selector references the property name selector +func (f *filterSelector) hasPropertyNameReferences() bool { + if f.expression == nil { + return false + } + return f.expression.hasPropertyNameReferences() +} + +// hasPropertyNameReferences reports whether any logical OR expression references the property name selector +func (e *logicalOrExpr) hasPropertyNameReferences() bool { + for _, expr := range e.expressions { + if expr.hasPropertyNameReferences() { + return true + } + } + return false +} + +// hasPropertyNameReferences reports whether any logical AND expression references the property name selector +func (e *logicalAndExpr) hasPropertyNameReferences() bool { + for _, expr := range e.expressions { + if expr.hasPropertyNameReferences() { + return true + } + } + return false +} + +// hasPropertyNameReferences reports whether the basic expression references the property name selector +func (e *basicExpr) hasPropertyNameReferences() bool { + if e.parenExpr != nil && e.parenExpr.expr != nil { + return e.parenExpr.expr.hasPropertyNameReferences() + } + if e.comparisonExpr != nil { + return e.comparisonExpr.hasPropertyNameReferences() + } + if e.testExpr != nil { + return e.testExpr.hasPropertyNameReferences() + } + return false +} + +// hasPropertyNameReferences reports whether the comparison expression references the property name selector +func (e *comparisonExpr) hasPropertyNameReferences() bool { + if e.left != nil && e.left.hasPropertyNameReferences() { + return true + } + if e.right != nil && e.right.hasPropertyNameReferences() { + return true + } + return false +} + +// hasPropertyNameReferences reports whether the comparable references the property name selector +func (c *comparable) hasPropertyNameReferences() bool { + if c.singularQuery != nil && c.singularQuery.hasPropertyNameReferences() { + return true + } + if c.functionExpr != nil && c.functionExpr.hasPropertyNameReferences() { + return true + } + return false +} + +// hasPropertyNameReferences reports whether the test expression references the property name selector +func (e *testExpr) hasPropertyNameReferences() bool { + if e.filterQuery != nil && e.filterQuery.hasPropertyNameReferences() { + return true + } + if e.functionExpr != nil && e.functionExpr.hasPropertyNameReferences() { + return true + } + return false +} + +// hasPropertyNameReferences reports whether the function expression references the property name selector +func (e *functionExpr) hasPropertyNameReferences() bool { + for _, arg := range e.args { + if arg.hasPropertyNameReferences() { + return true + } + } + return false +} + +// hasPropertyNameReferences reports whether the function argument references the property name selector +func (a *functionArgument) hasPropertyNameReferences() bool { + if a.filterQuery != nil && a.filterQuery.hasPropertyNameReferences() { + return true + } + if a.logicalExpr != nil && a.logicalExpr.hasPropertyNameReferences() { + return true + } + if a.functionExpr != nil && a.functionExpr.hasPropertyNameReferences() { + return true + } + return false +} + +// hasPropertyNameReferences reports whether the filter query references the property name selector +func (q *filterQuery) hasPropertyNameReferences() bool { + if q == nil { + return false + } + if q.relQuery != nil { + return q.relQuery.hasPropertyNameReferences() + } + if q.jsonPathQuery != nil { + return q.jsonPathQuery.hasPropertyNameReferencesPtr() + } + return false +} + +// hasPropertyNameReferences reports whether the relative query references the property name selector +func (q *relQuery) hasPropertyNameReferences() bool { + for _, seg := range q.segments { + if seg.hasPropertyNameReferences() { + return true + } + } + return false +} + +// hasPropertyNameReferences reports whether the absolute query references the property name selector +func (q *absQuery) hasPropertyNameReferences() bool { + for _, seg := range q.segments { + if seg.hasPropertyNameReferences() { + return true + } + } + return false +} + +// hasPropertyNameReferences reports whether the singular query references the property name selector +func (q *singularQuery) hasPropertyNameReferences() bool { + if q.relQuery != nil { + return q.relQuery.hasPropertyNameReferences() + } + if q.absQuery != nil { + return q.absQuery.hasPropertyNameReferences() + } + return false +} + +// collectContextVarUsage records usage from a segment +func (s *segment) collectContextVarUsage(usage *contextVarUsage) { + if s.child != nil { + s.child.collectContextVarUsage(usage) + } + if s.descendant != nil { + s.descendant.collectContextVarUsage(usage) + } +} + +// collectContextVarUsage records usage from an inner segment +func (s *innerSegment) collectContextVarUsage(usage *contextVarUsage) { + for _, sel := range s.selectors { + sel.collectContextVarUsage(usage) + } +} + +// collectContextVarUsage records usage from a selector +func (s *selector) collectContextVarUsage(usage *contextVarUsage) { + if s.filter != nil { + s.filter.collectContextVarUsage(usage) + } +} + +// collectContextVarUsage records usage from a filter selector +func (f *filterSelector) collectContextVarUsage(usage *contextVarUsage) { + if f.expression != nil { + f.expression.collectContextVarUsage(usage) + } +} + +// collectContextVarUsage records usage from a logical OR expression +func (e *logicalOrExpr) collectContextVarUsage(usage *contextVarUsage) { + for _, expr := range e.expressions { + expr.collectContextVarUsage(usage) + } +} + +// collectContextVarUsage records usage from a logical AND expression +func (e *logicalAndExpr) collectContextVarUsage(usage *contextVarUsage) { + for _, expr := range e.expressions { + expr.collectContextVarUsage(usage) + } +} + +// collectContextVarUsage records usage from a basic expression +func (e *basicExpr) collectContextVarUsage(usage *contextVarUsage) { + if e.parenExpr != nil && e.parenExpr.expr != nil { + e.parenExpr.expr.collectContextVarUsage(usage) + return + } + if e.comparisonExpr != nil { + e.comparisonExpr.collectContextVarUsage(usage) + return + } + if e.testExpr != nil { + e.testExpr.collectContextVarUsage(usage) + } +} + +// collectContextVarUsage records usage from a comparison expression +func (e *comparisonExpr) collectContextVarUsage(usage *contextVarUsage) { + if e.left != nil { + e.left.collectContextVarUsage(usage) + } + if e.right != nil { + e.right.collectContextVarUsage(usage) + } +} + +// collectContextVarUsage records usage from a comparable +func (c *comparable) collectContextVarUsage(usage *contextVarUsage) { + if c.contextVar != nil { + usage.mark(c.contextVar.kind) + } + if c.singularQuery != nil { + c.singularQuery.collectContextVarUsage(usage) + } + if c.functionExpr != nil { + c.functionExpr.collectContextVarUsage(usage) + } +} + +// collectContextVarUsage records usage from a test expression +func (e *testExpr) collectContextVarUsage(usage *contextVarUsage) { + if e.filterQuery != nil { + e.filterQuery.collectContextVarUsage(usage) + } + if e.functionExpr != nil { + e.functionExpr.collectContextVarUsage(usage) + } +} + +// collectContextVarUsage records usage from a function expression +func (e *functionExpr) collectContextVarUsage(usage *contextVarUsage) { + for _, arg := range e.args { + arg.collectContextVarUsage(usage) + } +} + +// collectContextVarUsage records usage from a function argument +func (a *functionArgument) collectContextVarUsage(usage *contextVarUsage) { + if a.contextVar != nil { + usage.mark(a.contextVar.kind) + } + if a.filterQuery != nil { + a.filterQuery.collectContextVarUsage(usage) + } + if a.logicalExpr != nil { + a.logicalExpr.collectContextVarUsage(usage) + } + if a.functionExpr != nil { + a.functionExpr.collectContextVarUsage(usage) + } +} + +// collectContextVarUsage records usage from a filter query +func (q *filterQuery) collectContextVarUsage(usage *contextVarUsage) { + if q == nil { + return + } + if q.relQuery != nil { + q.relQuery.collectContextVarUsage(usage) + } + if q.jsonPathQuery != nil { + q.jsonPathQuery.collectContextVarUsage(usage) + } +} + +// collectContextVarUsage records usage from a relative query +func (q *relQuery) collectContextVarUsage(usage *contextVarUsage) { + for _, seg := range q.segments { + seg.collectContextVarUsage(usage) + } +} + +// collectContextVarUsage records usage from an absolute query +func (q *absQuery) collectContextVarUsage(usage *contextVarUsage) { + for _, seg := range q.segments { + seg.collectContextVarUsage(usage) + } +} + +// collectContextVarUsage records usage from a singular query +func (q *singularQuery) collectContextVarUsage(usage *contextVarUsage) { + if q.relQuery != nil { + q.relQuery.collectContextVarUsage(usage) + } + if q.absQuery != nil { + q.absQuery.collectContextVarUsage(usage) + } +} diff --git a/pkg/jsonpath/context_usage_test.go b/pkg/jsonpath/context_usage_test.go new file mode 100644 index 0000000..daf5691 --- /dev/null +++ b/pkg/jsonpath/context_usage_test.go @@ -0,0 +1,315 @@ +package jsonpath + +import ( + "testing" + + "go.yaml.in/yaml/v4" +) + +func TestContextUsageAndPropertyReferenceDetection(t *testing.T) { + propSeg := &segment{kind: segmentKindProperyName} + + // nil-safe pointer wrapper + var nilAST *jsonPathAST + if nilAST.hasPropertyNameReferencesPtr() { + t.Fatalf("expected nil AST wrapper to report false") + } + nilAST.collectContextVarUsage(&contextVarUsage{}) + + // empty and positive AST checks + if (jsonPathAST{}).hasPropertyNameReferences() { + t.Fatalf("expected empty AST to report no property-name references") + } + if !(jsonPathAST{segments: []*segment{propSeg}}).hasPropertyNameReferences() { + t.Fatalf("expected AST with property-name segment to report true") + } + + // negative branches across all helper types + if (&segment{}).hasPropertyNameReferences() { + t.Fatalf("expected empty segment to report false") + } + if (&innerSegment{}).hasPropertyNameReferences() { + t.Fatalf("expected empty inner segment to report false") + } + if (&selector{}).hasPropertyNameReferences() { + t.Fatalf("expected selector without filter to report false") + } + if (&filterSelector{}).hasPropertyNameReferences() { + t.Fatalf("expected filter selector without expression to report false") + } + if (&logicalOrExpr{}).hasPropertyNameReferences() { + t.Fatalf("expected empty logicalOrExpr to report false") + } + if (&logicalAndExpr{}).hasPropertyNameReferences() { + t.Fatalf("expected empty logicalAndExpr to report false") + } + if (&basicExpr{}).hasPropertyNameReferences() { + t.Fatalf("expected empty basicExpr to report false") + } + if (&comparisonExpr{}).hasPropertyNameReferences() { + t.Fatalf("expected empty comparisonExpr to report false") + } + if (&comparable{}).hasPropertyNameReferences() { + t.Fatalf("expected empty comparable to report false") + } + if (&testExpr{}).hasPropertyNameReferences() { + t.Fatalf("expected empty testExpr to report false") + } + if (&functionExpr{}).hasPropertyNameReferences() { + t.Fatalf("expected empty functionExpr to report false") + } + if (&functionArgument{}).hasPropertyNameReferences() { + t.Fatalf("expected empty functionArgument to report false") + } + if (&filterQuery{}).hasPropertyNameReferences() { + t.Fatalf("expected empty filterQuery to report false") + } + if (&relQuery{}).hasPropertyNameReferences() { + t.Fatalf("expected empty relQuery to report false") + } + if (&absQuery{}).hasPropertyNameReferences() { + t.Fatalf("expected empty absQuery to report false") + } + if (&singularQuery{}).hasPropertyNameReferences() { + t.Fatalf("expected empty singularQuery to report false") + } + var nilFilterQuery *filterQuery + if nilFilterQuery.hasPropertyNameReferences() { + t.Fatalf("expected nil filterQuery to report false") + } + + // positive branches across each helper type + truthySelector := &selector{filter: &filterSelector{expression: &logicalOrExpr{ + expressions: []*logicalAndExpr{ + { + expressions: []*basicExpr{ + {testExpr: &testExpr{filterQuery: &filterQuery{relQuery: &relQuery{segments: []*segment{propSeg}}}}}, + }, + }, + }, + }}} + if !(&innerSegment{selectors: []*selector{truthySelector}}).hasPropertyNameReferences() { + t.Fatalf("expected inner segment to report true") + } + if !(&segment{child: &innerSegment{selectors: []*selector{truthySelector}}}).hasPropertyNameReferences() { + t.Fatalf("expected segment(child) to report true") + } + if !(&segment{descendant: &innerSegment{selectors: []*selector{truthySelector}}}).hasPropertyNameReferences() { + t.Fatalf("expected segment(descendant) to report true") + } + + comp := &comparable{singularQuery: &singularQuery{relQuery: &relQuery{segments: []*segment{propSeg}}}} + if !(&comparisonExpr{left: comp}).hasPropertyNameReferences() { + t.Fatalf("expected comparisonExpr(left) to report true") + } + if !(&comparisonExpr{right: comp}).hasPropertyNameReferences() { + t.Fatalf("expected comparisonExpr(right) to report true") + } + if !(&comparable{functionExpr: &functionExpr{args: []*functionArgument{{filterQuery: &filterQuery{relQuery: &relQuery{segments: []*segment{propSeg}}}}}}}).hasPropertyNameReferences() { + t.Fatalf("expected comparable(functionExpr) to report true") + } + if !(&testExpr{functionExpr: &functionExpr{args: []*functionArgument{{filterQuery: &filterQuery{relQuery: &relQuery{segments: []*segment{propSeg}}}}}}}).hasPropertyNameReferences() { + t.Fatalf("expected testExpr(functionExpr) to report true") + } + logicalArg := &functionArgument{ + logicalExpr: &logicalOrExpr{ + expressions: []*logicalAndExpr{ + { + expressions: []*basicExpr{ + { + testExpr: &testExpr{ + filterQuery: &filterQuery{ + relQuery: &relQuery{segments: []*segment{propSeg}}, + }, + }, + }, + }, + }, + }, + }, + } + if !logicalArg.hasPropertyNameReferences() { + t.Fatalf("expected functionArgument(logicalExpr) to report true") + } + if !(&functionArgument{functionExpr: &functionExpr{args: []*functionArgument{{filterQuery: &filterQuery{relQuery: &relQuery{segments: []*segment{propSeg}}}}}}}).hasPropertyNameReferences() { + t.Fatalf("expected functionArgument(functionExpr) to report true") + } + if !(&filterQuery{jsonPathQuery: &jsonPathAST{segments: []*segment{propSeg}}}).hasPropertyNameReferences() { + t.Fatalf("expected filterQuery(jsonPathQuery) to report true") + } + if !(&absQuery{segments: []*segment{propSeg}}).hasPropertyNameReferences() { + t.Fatalf("expected absQuery to report true") + } + if !(&singularQuery{absQuery: &absQuery{segments: []*segment{propSeg}}}).hasPropertyNameReferences() { + t.Fatalf("expected singularQuery(absQuery) to report true") + } +} + +func TestCollectContextVarUsageCoversBranches(t *testing.T) { + var usage contextVarUsage + + // mark all context variable kinds + for _, kind := range []contextVarKind{ + contextVarProperty, + contextVarParent, + contextVarParentProperty, + contextVarPath, + contextVarIndex, + } { + usage.mark(kind) + } + if !usage.property || !usage.parent || !usage.parentProperty || !usage.path || !usage.index { + t.Fatalf("expected all context variable flags to be marked") + } + + // exercise traversal methods including nil guards and optional children + var nilAST *jsonPathAST + nilAST.collectContextVarUsage(&usage) + var nilFilterQuery *filterQuery + nilFilterQuery.collectContextVarUsage(&usage) + + leftComp := &comparable{contextVar: &contextVariable{kind: contextVarProperty}} + rightComp := &comparable{functionExpr: &functionExpr{ + args: []*functionArgument{{contextVar: &contextVariable{kind: contextVarIndex}}}, + }} + firstBasic := &basicExpr{ + parenExpr: &parenExpr{ + expr: &logicalOrExpr{ + expressions: []*logicalAndExpr{ + {expressions: []*basicExpr{{comparisonExpr: &comparisonExpr{left: leftComp, right: rightComp}}}}, + }, + }, + }, + } + childSelector := &selector{ + filter: &filterSelector{expression: &logicalOrExpr{ + expressions: []*logicalAndExpr{{expressions: []*basicExpr{firstBasic}}}, + }}, + } + + pathArg := &functionArgument{ + logicalExpr: &logicalOrExpr{ + expressions: []*logicalAndExpr{{expressions: []*basicExpr{ + {testExpr: &testExpr{functionExpr: &functionExpr{args: []*functionArgument{ + {contextVar: &contextVariable{kind: contextVarPath}}, + }}}}, + }}}, + }, + } + filterArg := &functionArgument{ + functionExpr: &functionExpr{ + args: []*functionArgument{{ + filterQuery: &filterQuery{relQuery: &relQuery{segments: []*segment{{kind: segmentKindChild}}}}, + }}, + }, + } + jsonPathArg := &functionArgument{ + filterQuery: &filterQuery{jsonPathQuery: &jsonPathAST{segments: []*segment{{kind: segmentKindChild}}}}, + } + secondBasic := &basicExpr{ + testExpr: &testExpr{ + filterQuery: &filterQuery{ + relQuery: &relQuery{segments: []*segment{{kind: segmentKindChild}}}, + jsonPathQuery: &jsonPathAST{segments: []*segment{{kind: segmentKindChild}}}, + }, + functionExpr: &functionExpr{args: []*functionArgument{ + {contextVar: &contextVariable{kind: contextVarParentProperty}}, + pathArg, + filterArg, + jsonPathArg, + }}, + }, + } + descSelector := &selector{ + filter: &filterSelector{expression: &logicalOrExpr{ + expressions: []*logicalAndExpr{{expressions: []*basicExpr{secondBasic}}}, + }}, + } + + tree := &jsonPathAST{segments: []*segment{ + {child: &innerSegment{selectors: []*selector{childSelector}}}, + {descendant: &innerSegment{selectors: []*selector{descSelector}}}, + }} + tree.collectContextVarUsage(&usage) + + // direct calls to cover rel/abs/singular and empty paths + (&relQuery{}).collectContextVarUsage(&usage) + (&absQuery{}).collectContextVarUsage(&usage) + (&singularQuery{}).collectContextVarUsage(&usage) + (&singularQuery{relQuery: &relQuery{segments: []*segment{{kind: segmentKindChild}}}}).collectContextVarUsage(&usage) + (&singularQuery{absQuery: &absQuery{segments: []*segment{{kind: segmentKindChild}}}}).collectContextVarUsage(&usage) + + if !usage.property || !usage.parentProperty || !usage.path || !usage.index { + t.Fatalf("expected collect traversal to retain marked usage flags") + } +} + +func TestDescendApplyAndDescendantSegmentQuery(t *testing.T) { + calls := 0 + descendApply(nil, func(*yaml.Node) { + calls++ + }) + if calls != 0 { + t.Fatalf("expected no callback calls for nil root") + } + + root := &yaml.Node{Kind: yaml.MappingNode, Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "a"}, + {Kind: yaml.SequenceNode, Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "x"}, + }}, + }} + descendApply(root, func(*yaml.Node) { + calls++ + }) + if calls < 3 { + t.Fatalf("expected descendApply to visit full tree, got %d", calls) + } + + idx := &_index{propertyKeys: map[*yaml.Node]*yaml.Node{}, parentNodes: map[*yaml.Node]*yaml.Node{}} + q := segment{ + kind: segmentKindDescendant, + descendant: &innerSegment{ + kind: segmentDotWildcard, + }, + } + result := q.Query(idx, root, root) + if len(result) == 0 { + t.Fatalf("expected descendant query to return at least one node") + } +} + +type bareFilterContext struct { + _index +} + +func (b *bareFilterContext) PropertyName() string { return "" } +func (b *bareFilterContext) SetPropertyName(string) {} +func (b *bareFilterContext) Parent() *yaml.Node { return nil } +func (b *bareFilterContext) SetParent(*yaml.Node) {} +func (b *bareFilterContext) ParentPropertyName() string { return "" } +func (b *bareFilterContext) SetParentPropertyName(string) {} +func (b *bareFilterContext) Path() string { return "$" } +func (b *bareFilterContext) PushPathSegment(string) {} +func (b *bareFilterContext) PopPathSegment() {} +func (b *bareFilterContext) SetPendingPathSegment(*yaml.Node, string) {} +func (b *bareFilterContext) GetAndClearPendingPathSegment(*yaml.Node) string { return "" } +func (b *bareFilterContext) SetPendingPropertyName(*yaml.Node, string) {} +func (b *bareFilterContext) GetAndClearPendingPropertyName(*yaml.Node) string { return "" } +func (b *bareFilterContext) Root() *yaml.Node { return nil } +func (b *bareFilterContext) SetRoot(*yaml.Node) {} +func (b *bareFilterContext) Index() int { return -1 } +func (b *bareFilterContext) SetIndex(int) {} +func (b *bareFilterContext) EnableParentTracking() {} +func (b *bareFilterContext) ParentTrackingEnabled() bool { return false } +func (b *bareFilterContext) Clone() FilterContext { return b } + +func TestEnableTrackingHelpersNoOpForMissingOptionalMethods(t *testing.T) { + ctx := &bareFilterContext{_index: _index{ + propertyKeys: make(map[*yaml.Node]*yaml.Node), + parentNodes: make(map[*yaml.Node]*yaml.Node), + }} + enablePropertyTracking(ctx) + enablePathTracking(ctx) + enableIndexTracking(ctx) +} diff --git a/pkg/jsonpath/filter_context.go b/pkg/jsonpath/filter_context.go index f698f5c..2349ce5 100644 --- a/pkg/jsonpath/filter_context.go +++ b/pkg/jsonpath/filter_context.go @@ -60,7 +60,10 @@ type filterContext struct { pendingPropertyNames map[*yaml.Node]string // tracks property names for nodes from wildcards (for @parentProperty) root *yaml.Node arrayIndex int - parentTrackingActive bool + parentTrackingActive bool + propertyTrackingActive bool + pathTrackingActive bool + indexTrackingActive bool } // NewFilterContext creates a new FilterContext with the given root node @@ -78,6 +81,15 @@ func NewFilterContext(root *yaml.Node) FilterContext { } } +// newFilterContextLazy creates a new FilterContext with lazy allocations +func newFilterContextLazy(root *yaml.Node) FilterContext { + return &filterContext{ + pathSegments: make([]string, 0), + root: root, + arrayIndex: -1, + } +} + // PropertyName returns the current property name or array index as string func (fc *filterContext) PropertyName() string { return fc.propertyName @@ -85,6 +97,9 @@ func (fc *filterContext) PropertyName() string { // SetPropertyName sets the current property name func (fc *filterContext) SetPropertyName(name string) { + if !fc.propertyTrackingActive { + return + } fc.propertyName = name } @@ -95,6 +110,9 @@ func (fc *filterContext) Parent() *yaml.Node { // SetParent sets the parent node func (fc *filterContext) SetParent(parent *yaml.Node) { + if !fc.parentTrackingActive { + return + } fc.parent = parent } @@ -105,11 +123,17 @@ func (fc *filterContext) ParentPropertyName() string { // SetParentPropertyName sets the parent's property name func (fc *filterContext) SetParentPropertyName(name string) { + if !fc.pathTrackingActive { + return + } fc.parentPropertyName = name } // Path returns the normalized JSONPath to the current node func (fc *filterContext) Path() string { + if !fc.pathTrackingActive { + return "$" + } if len(fc.pathSegments) == 0 { return "$" } @@ -118,11 +142,17 @@ func (fc *filterContext) Path() string { // PushPathSegment adds a path segment (should be in normalized form like "['key']" or "[0]") func (fc *filterContext) PushPathSegment(segment string) { + if !fc.pathTrackingActive { + return + } fc.pathSegments = append(fc.pathSegments, segment) } // PopPathSegment removes the last path segment func (fc *filterContext) PopPathSegment() { + if !fc.pathTrackingActive { + return + } if len(fc.pathSegments) > 0 { fc.pathSegments = fc.pathSegments[:len(fc.pathSegments)-1] } @@ -130,13 +160,21 @@ func (fc *filterContext) PopPathSegment() { // SetPendingPathSegment stores a path segment for a node (used by wildcards/slices) func (fc *filterContext) SetPendingPathSegment(node *yaml.Node, segment string) { - if fc.pendingPathSegments != nil { - fc.pendingPathSegments[node] = segment + if !fc.pathTrackingActive { + return } + // Lazily allocate when path tracking is enabled + if fc.pendingPathSegments == nil { + fc.pendingPathSegments = make(map[*yaml.Node]string) + } + fc.pendingPathSegments[node] = segment } // GetAndClearPendingPathSegment retrieves and removes a pending path segment for a node func (fc *filterContext) GetAndClearPendingPathSegment(node *yaml.Node) string { + if !fc.pathTrackingActive { + return "" + } if fc.pendingPathSegments == nil { return "" } @@ -150,13 +188,21 @@ func (fc *filterContext) GetAndClearPendingPathSegment(node *yaml.Node) string { // SetPendingPropertyName stores a property name for a node (used by wildcards for @parentProperty) func (fc *filterContext) SetPendingPropertyName(node *yaml.Node, name string) { - if fc.pendingPropertyNames != nil { - fc.pendingPropertyNames[node] = name + if !fc.pathTrackingActive { + return } + // Lazily allocate when path tracking is enabled + if fc.pendingPropertyNames == nil { + fc.pendingPropertyNames = make(map[*yaml.Node]string) + } + fc.pendingPropertyNames[node] = name } // GetAndClearPendingPropertyName retrieves and removes a pending property name for a node func (fc *filterContext) GetAndClearPendingPropertyName(node *yaml.Node) string { + if !fc.pathTrackingActive { + return "" + } if fc.pendingPropertyNames == nil { return "" } @@ -185,6 +231,9 @@ func (fc *filterContext) Index() int { // SetIndex sets the current array index func (fc *filterContext) SetIndex(idx int) { + if !fc.indexTrackingActive { + return + } fc.arrayIndex = idx } @@ -198,6 +247,70 @@ func (fc *filterContext) ParentTrackingEnabled() bool { return fc.parentTrackingActive } +// EnablePropertyTracking enables property name tracking for @property and '~' selectors +func (fc *filterContext) EnablePropertyTracking() { + fc.propertyTrackingActive = true +} + +// PropertyTrackingEnabled returns true if property tracking is active +func (fc *filterContext) PropertyTrackingEnabled() bool { + return fc.propertyTrackingActive +} + +// EnablePathTracking enables path and parent-property tracking for @path and @parentProperty +func (fc *filterContext) EnablePathTracking() { + fc.pathTrackingActive = true +} + +// PathTrackingEnabled returns true if path tracking is active +func (fc *filterContext) PathTrackingEnabled() bool { + return fc.pathTrackingActive +} + +// EnableIndexTracking enables array index tracking for @index +func (fc *filterContext) EnableIndexTracking() { + fc.indexTrackingActive = true +} + +// IndexTrackingEnabled returns true if index tracking is active +func (fc *filterContext) IndexTrackingEnabled() bool { + return fc.indexTrackingActive +} + +func (fc *filterContext) setPropertyKey(key *yaml.Node, value *yaml.Node) { + if !fc.propertyTrackingActive { + return + } + if fc.propertyKeys == nil { + fc.propertyKeys = make(map[*yaml.Node]*yaml.Node) + } + fc.propertyKeys[key] = value +} + +func (fc *filterContext) getPropertyKey(key *yaml.Node) *yaml.Node { + if !fc.propertyTrackingActive || fc.propertyKeys == nil { + return nil + } + return fc.propertyKeys[key] +} + +func (fc *filterContext) setParentNode(child *yaml.Node, parent *yaml.Node) { + if !fc.parentTrackingActive { + return + } + if fc.parentNodes == nil { + fc.parentNodes = make(map[*yaml.Node]*yaml.Node) + } + fc.parentNodes[child] = parent +} + +func (fc *filterContext) getParentNode(child *yaml.Node) *yaml.Node { + if !fc.parentTrackingActive || fc.parentNodes == nil { + return nil + } + return fc.parentNodes[child] +} + // Clone creates a shallow copy of the context for nested evaluation func (fc *filterContext) Clone() FilterContext { pathCopy := make([]string, len(fc.pathSegments)) @@ -205,16 +318,19 @@ func (fc *filterContext) Clone() FilterContext { // Share the pending maps - they're cleared on use anyway return &filterContext{ - _index: fc._index, - propertyName: fc.propertyName, - parent: fc.parent, - parentPropertyName: fc.parentPropertyName, - pathSegments: pathCopy, - pendingPathSegments: fc.pendingPathSegments, - pendingPropertyNames: fc.pendingPropertyNames, - root: fc.root, - arrayIndex: fc.arrayIndex, - parentTrackingActive: fc.parentTrackingActive, + _index: fc._index, + propertyName: fc.propertyName, + parent: fc.parent, + parentPropertyName: fc.parentPropertyName, + pathSegments: pathCopy, + pendingPathSegments: fc.pendingPathSegments, + pendingPropertyNames: fc.pendingPropertyNames, + root: fc.root, + arrayIndex: fc.arrayIndex, + parentTrackingActive: fc.parentTrackingActive, + propertyTrackingActive: fc.propertyTrackingActive, + pathTrackingActive: fc.pathTrackingActive, + indexTrackingActive: fc.indexTrackingActive, } } diff --git a/pkg/jsonpath/filter_context_test.go b/pkg/jsonpath/filter_context_test.go new file mode 100644 index 0000000..978c837 --- /dev/null +++ b/pkg/jsonpath/filter_context_test.go @@ -0,0 +1,137 @@ +package jsonpath + +import ( + "testing" + + "go.yaml.in/yaml/v4" +) + +func TestFilterContextLazyGuardsAndTrackingFlags(t *testing.T) { + root := &yaml.Node{Kind: yaml.MappingNode} + ctx := newFilterContextLazy(root).(*filterContext) + node := &yaml.Node{Kind: yaml.ScalarNode, Value: "v"} + + // Guards should no-op before tracking is enabled. + ctx.SetPropertyName("name") + if ctx.PropertyName() != "" { + t.Fatalf("expected empty property name before enabling tracking") + } + ctx.SetParent(root) + if ctx.Parent() != nil { + t.Fatalf("expected nil parent before enabling tracking") + } + ctx.SetParentPropertyName("pp") + if ctx.ParentPropertyName() != "" { + t.Fatalf("expected empty parent property before enabling path tracking") + } + ctx.SetIndex(3) + if ctx.Index() != -1 { + t.Fatalf("expected default index before enabling index tracking") + } + if got := ctx.Path(); got != "$" { + t.Fatalf("expected root path when path tracking disabled, got %q", got) + } + ctx.PushPathSegment("['a']") + ctx.PopPathSegment() + ctx.SetPendingPathSegment(node, "['a']") + if got := ctx.GetAndClearPendingPathSegment(node); got != "" { + t.Fatalf("expected empty pending path when path tracking disabled, got %q", got) + } + ctx.SetPendingPropertyName(node, "a") + if got := ctx.GetAndClearPendingPropertyName(node); got != "" { + t.Fatalf("expected empty pending property when path tracking disabled, got %q", got) + } + if got := ctx.getPropertyKey(node); got != nil { + t.Fatalf("expected nil property key when property tracking disabled") + } + if got := ctx.getParentNode(node); got != nil { + t.Fatalf("expected nil parent node when parent tracking disabled") + } + ctx.parentNodes = nil + ctx.setParentNode(node, root) + if ctx.parentNodes != nil { + t.Fatalf("expected setParentNode to no-op when parent tracking is disabled") + } + + // Enable all tracking modes and verify values are persisted. + ctx.EnablePropertyTracking() + ctx.EnablePathTracking() + ctx.EnableIndexTracking() + ctx.EnableParentTracking() + if !ctx.PropertyTrackingEnabled() || !ctx.PathTrackingEnabled() || !ctx.IndexTrackingEnabled() || !ctx.ParentTrackingEnabled() { + t.Fatalf("expected all tracking flags enabled") + } + + ctx.SetPropertyName("name") + if ctx.PropertyName() != "name" { + t.Fatalf("expected property name to be set") + } + ctx.SetParent(root) + if ctx.Parent() != root { + t.Fatalf("expected parent node to be set") + } + ctx.SetParentPropertyName("pp") + if ctx.ParentPropertyName() != "pp" { + t.Fatalf("expected parent property name to be set") + } + ctx.SetIndex(7) + if ctx.Index() != 7 { + t.Fatalf("expected index to be set") + } + + ctx.PushPathSegment("['a']") + if got := ctx.Path(); got != "$['a']" { + t.Fatalf("unexpected path %q", got) + } + ctx.PopPathSegment() + if got := ctx.Path(); got != "$" { + t.Fatalf("expected path reset to root, got %q", got) + } + + ctx.pendingPathSegments = nil + ctx.SetPendingPathSegment(node, "['x']") + if got := ctx.GetAndClearPendingPathSegment(node); got != "['x']" { + t.Fatalf("expected pending path segment to be returned, got %q", got) + } + ctx.pendingPropertyNames = nil + ctx.SetPendingPropertyName(node, "x") + if got := ctx.GetAndClearPendingPropertyName(node); got != "x" { + t.Fatalf("expected pending property name to be returned, got %q", got) + } + + ctx.propertyKeys = nil + ctx.setPropertyKey(node, root) + if got := ctx.getPropertyKey(node); got != root { + t.Fatalf("expected stored property key value") + } + + ctx.parentNodes = nil + ctx.setParentNode(node, root) + if got := ctx.getParentNode(node); got != root { + t.Fatalf("expected stored parent node value") + } +} + +func TestFilterContextClonePreservesTrackingFlags(t *testing.T) { + root := &yaml.Node{Kind: yaml.MappingNode} + ctx := newFilterContextLazy(root).(*filterContext) + ctx.EnableParentTracking() + ctx.EnablePropertyTracking() + ctx.EnablePathTracking() + ctx.EnableIndexTracking() + ctx.SetPropertyName("name") + ctx.SetParentPropertyName("pp") + ctx.SetIndex(4) + ctx.PushPathSegment("['a']") + + cloned := ctx.Clone().(*filterContext) + if !cloned.ParentTrackingEnabled() || !cloned.PropertyTrackingEnabled() || !cloned.PathTrackingEnabled() || !cloned.IndexTrackingEnabled() { + t.Fatalf("expected clone to preserve tracking flags") + } + if cloned.PropertyName() != "name" || cloned.ParentPropertyName() != "pp" || cloned.Index() != 4 { + t.Fatalf("expected clone to preserve tracked values") + } + if got := cloned.Path(); got != "$['a']" { + t.Fatalf("expected clone to preserve path, got %q", got) + } +} diff --git a/pkg/jsonpath/jsonpath_plus_test.go b/pkg/jsonpath/jsonpath_plus_test.go index 3080f4d..f4bcb64 100644 --- a/pkg/jsonpath/jsonpath_plus_test.go +++ b/pkg/jsonpath/jsonpath_plus_test.go @@ -659,6 +659,60 @@ users: assert.Len(t, results, 1, "should find 1 user with count < 100") } +func TestLazyContextTrackingContextVariables(t *testing.T) { + yamlData := ` +store: + book: + - title: "Book 1" + - title: "Book 2" + bicycle: + details: { price: 20 } +paths: + /users: + get: { summary: "Get users" } + post: { summary: "Create user" } + /orders: + get: { summary: "Get orders" } +items: + - name: "First" + - name: "Second" + - name: "Third" +` + var node yaml.Node + err := yaml.Unmarshal([]byte(yamlData), &node) + assert.NoError(t, err) + + path, err := NewPath(`$.paths.*[?(@property == 'get')]`, config.WithLazyContextTracking()) + assert.NoError(t, err) + results := path.Query(&node) + assert.Len(t, results, 2) + + path, err = NewPath(`$.store.book[?(@path == "$['store']['book'][0]")]`, config.WithLazyContextTracking()) + assert.NoError(t, err) + results = path.Query(&node) + assert.Len(t, results, 1) + + path, err = NewPath(`$.items[?(@index == 1)]`, config.WithLazyContextTracking()) + assert.NoError(t, err) + results = path.Query(&node) + if assert.Len(t, results, 1) { + assert.Equal(t, "Second", mappingValue(results[0], "name")) + } + + path, err = NewPath(`$.items[?length(@parent) == 3]`, config.WithLazyContextTracking()) + assert.NoError(t, err) + results = path.Query(&node) + assert.Len(t, results, 3) + + path, err = NewPath(`$.store.*[?(@parentProperty == 'book')]`, config.WithLazyContextTracking()) + assert.NoError(t, err) + results = path.Query(&node) + if assert.Len(t, results, 2) { + assert.Equal(t, "Book 1", mappingValue(results[0], "title")) + assert.Equal(t, "Book 2", mappingValue(results[1], "title")) + } +} + // TestParentSelector tests the ^ parent selector func TestParentSelector(t *testing.T) { yamlData := ` @@ -1263,6 +1317,18 @@ store: } } +func mappingValue(node *yaml.Node, key string) string { + if node == nil || node.Kind != yaml.MappingNode { + return "" + } + for i := 0; i < len(node.Content)-1; i += 2 { + if node.Content[i].Value == key { + return node.Content[i+1].Value + } + } + return "" +} + // Helper function to check if a YAML string contains expected content func containsYAML(haystack, needle string) bool { // Simple substring check - good enough for test assertions diff --git a/pkg/jsonpath/lazy_context_tracking_stress_test.go b/pkg/jsonpath/lazy_context_tracking_stress_test.go new file mode 100644 index 0000000..41f7a07 --- /dev/null +++ b/pkg/jsonpath/lazy_context_tracking_stress_test.go @@ -0,0 +1,106 @@ +//go:build lazytracking && stress + +package jsonpath + +import ( + "fmt" + "os" + "testing" + + "github.com/pb33f/jsonpath/pkg/jsonpath/config" + "github.com/pb33f/jsonpath/pkg/jsonpath/token" + "go.yaml.in/yaml/v4" +) + +func BenchmarkLazyContextTrackingStress(b *testing.B) { + const ( + stressItems = 200 + stressChildren = 40 + stressGrandchildren = 40 + ) + root := buildStressRoot(stressItems, stressChildren, stressGrandchildren) + + bench := func(b *testing.B, opts ...config.Option) { + tokenizer := token.NewTokenizer("$.items[*].children[*].children[*].value", opts...) + parser := newParserPrivate(tokenizer, tokenizer.Tokenize(), opts...) + if err := parser.parse(); err != nil { + b.Fatalf("Error parsing JSON Path: %v", err) + } + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = parser.ast.Query(root, root) + } + } + + switch os.Getenv("LAZY_TRACKING_MODE") { + case "eager": + bench(b) + return + case "lazy": + bench(b, config.WithLazyContextTracking()) + return + } + + b.Run("EagerTracking", func(b *testing.B) { + bench(b) + }) + b.Run("LazyTracking", func(b *testing.B) { + bench(b, config.WithLazyContextTracking()) + }) +} + +func buildStressRoot(items, children, grandchildren int) *yaml.Node { + doc := &yaml.Node{Kind: yaml.DocumentNode} + root := &yaml.Node{Kind: yaml.MappingNode} + doc.Content = append(doc.Content, root) + + root.Content = append(root.Content, + &yaml.Node{Kind: yaml.ScalarNode, Value: "items"}, + buildStressItems(items, children, grandchildren), + ) + + return doc +} + +func buildStressItems(items, children, grandchildren int) *yaml.Node { + seq := &yaml.Node{Kind: yaml.SequenceNode} + for i := 0; i < items; i++ { + item := &yaml.Node{Kind: yaml.MappingNode} + item.Content = append(item.Content, + &yaml.Node{Kind: yaml.ScalarNode, Value: fmt.Sprintf("item-%d", i)}, + &yaml.Node{Kind: yaml.ScalarNode, Value: "value"}, + &yaml.Node{Kind: yaml.ScalarNode, Value: "children"}, + buildStressChildren(children, grandchildren), + ) + seq.Content = append(seq.Content, item) + } + return seq +} + +func buildStressChildren(children, grandchildren int) *yaml.Node { + seq := &yaml.Node{Kind: yaml.SequenceNode} + for i := 0; i < children; i++ { + child := &yaml.Node{Kind: yaml.MappingNode} + child.Content = append(child.Content, + &yaml.Node{Kind: yaml.ScalarNode, Value: "children"}, + buildStressGrandchildren(grandchildren), + ) + seq.Content = append(seq.Content, child) + } + return seq +} + +func buildStressGrandchildren(grandchildren int) *yaml.Node { + seq := &yaml.Node{Kind: yaml.SequenceNode} + for i := 0; i < grandchildren; i++ { + grandchild := &yaml.Node{Kind: yaml.MappingNode} + grandchild.Content = append(grandchild.Content, + &yaml.Node{Kind: yaml.ScalarNode, Value: "value"}, + &yaml.Node{Kind: yaml.ScalarNode, Value: fmt.Sprintf("leaf-%d", i)}, + ) + seq.Content = append(seq.Content, grandchild) + } + return seq +} diff --git a/pkg/jsonpath/lazy_context_tracking_test.go b/pkg/jsonpath/lazy_context_tracking_test.go new file mode 100644 index 0000000..d36dfcc --- /dev/null +++ b/pkg/jsonpath/lazy_context_tracking_test.go @@ -0,0 +1,83 @@ +//go:build lazytracking + +package jsonpath + +import ( + "github.com/pb33f/jsonpath/pkg/jsonpath/config" + "github.com/pb33f/jsonpath/pkg/jsonpath/token" + "go.yaml.in/yaml/v4" + "testing" +) + +func TestPropertyNameQueryLazyTracking(t *testing.T) { + yamlData := ` +store: book-store +` + var root yaml.Node + err := yaml.Unmarshal([]byte(yamlData), &root) + if err != nil { + t.Fatalf("Error parsing YAML: %v", err) + } + + tokenizer := token.NewTokenizer("$.store~", config.WithPropertyNameExtension(), config.WithLazyContextTracking()) + parser := newParserPrivate(tokenizer, tokenizer.Tokenize(), config.WithPropertyNameExtension(), config.WithLazyContextTracking()) + err = parser.parse() + if err != nil { + t.Fatalf("Error parsing JSON Path: %v", err) + } + + result := parser.ast.Query(&root, &root) + if len(result) != 1 { + t.Fatalf("Expected 1 result, got %d", len(result)) + } + actual := nodeToString(result[0]) + if actual != "store" { + t.Fatalf("Expected %q, got %q", "store", actual) + } +} + +func BenchmarkLazyContextTracking(b *testing.B) { + yamlData := ` +store: + book: + - title: "Book 1" + - title: "Book 2" + bicycle: + details: { price: 20 } +paths: + /users: + get: { summary: "Get users" } + post: { summary: "Create user" } + /orders: + get: { summary: "Get orders" } +items: + - name: "First" + - name: "Second" + - name: "Third" +` + var root yaml.Node + if err := yaml.Unmarshal([]byte(yamlData), &root); err != nil { + b.Fatalf("Error parsing YAML: %v", err) + } + + bench := func(b *testing.B, opts ...config.Option) { + tokenizer := token.NewTokenizer("$.store.book[*].title", opts...) + parser := newParserPrivate(tokenizer, tokenizer.Tokenize(), opts...) + if err := parser.parse(); err != nil { + b.Fatalf("Error parsing JSON Path: %v", err) + } + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = parser.ast.Query(&root, &root) + } + } + + b.Run("EagerTracking", func(b *testing.B) { + bench(b) + }) + b.Run("LazyTracking", func(b *testing.B) { + bench(b, config.WithLazyContextTracking()) + }) +} diff --git a/pkg/jsonpath/parser.go b/pkg/jsonpath/parser.go index d9b341b..488c6f7 100644 --- a/pkg/jsonpath/parser.go +++ b/pkg/jsonpath/parser.go @@ -40,7 +40,8 @@ type JSONPath struct { // newParserPrivate creates a new JSONPath with the given tokens. func newParserPrivate(tokenizer *token.Tokenizer, tokens []token.TokenInfo, opts ...config.Option) *JSONPath { - return &JSONPath{tokenizer, tokens, jsonPathAST{}, 0, []mode{modeNormal}, config.New(opts...)} + cfg := config.New(opts...) + return &JSONPath{tokenizer, tokens, jsonPathAST{lazyContextTracking: cfg.LazyContextTrackingEnabled()}, 0, []mode{modeNormal}, cfg} } // parse parses the JSONPath tokens and returns the root node of the AST. @@ -563,7 +564,10 @@ func (p *JSONPath) parseTestExpr() (*testExpr, error) { if err != nil { return nil, err } - return &testExpr{filterQuery: &filterQuery{jsonPathQuery: &jsonPathAST{segments: query.segments}}, not: not}, nil + return &testExpr{filterQuery: &filterQuery{jsonPathQuery: &jsonPathAST{ + segments: query.segments, + lazyContextTracking: p.config.LazyContextTrackingEnabled(), + }}, not: not}, nil default: funcExpr, err := p.parseFunctionExpr() if err != nil { @@ -717,7 +721,10 @@ func (p *JSONPath) parseFunctionArgument(single bool) (*functionArgument, error) if err != nil { return nil, err } - return &functionArgument{filterQuery: &filterQuery{jsonPathQuery: &jsonPathAST{segments: query.segments}}}, nil + return &functionArgument{filterQuery: &filterQuery{jsonPathQuery: &jsonPathAST{ + segments: query.segments, + lazyContextTracking: p.config.LazyContextTrackingEnabled(), + }}}, nil } // Check for JSONPath Plus context variables as function arguments @@ -777,6 +784,7 @@ func (p *JSONPath) parseLiteral() (*literal, error) { type jsonPathAST struct { // "$" segments []*segment + lazyContextTracking bool } func (q jsonPathAST) ToString() string { diff --git a/pkg/jsonpath/parser_lazy_context_test.go b/pkg/jsonpath/parser_lazy_context_test.go new file mode 100644 index 0000000..87d2d74 --- /dev/null +++ b/pkg/jsonpath/parser_lazy_context_test.go @@ -0,0 +1,138 @@ +package jsonpath + +import ( + "testing" + + "github.com/pb33f/jsonpath/pkg/jsonpath/config" + "github.com/pb33f/jsonpath/pkg/jsonpath/token" +) + +func TestParseTestExprPropagatesLazyContextTrackingToNestedJSONPathQuery(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts []config.Option + want bool + }{ + { + name: "eager by default", + want: false, + }, + { + name: "lazy with option", + opts: []config.Option{config.WithLazyContextTracking()}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tokenizer := token.NewTokenizer("$[?$.x]", tt.opts...) + parser := newParserPrivate(tokenizer, tokenizer.Tokenize(), tt.opts...) + if err := parser.parse(); err != nil { + t.Fatalf("parse failed: %v", err) + } + + nested := nestedJSONPathFromTestExpr(parser.ast) + if nested == nil { + t.Fatalf("expected nested jsonpath query in test expression") + } + if nested.lazyContextTracking != tt.want { + t.Fatalf("expected nested lazyContextTracking=%v, got %v", tt.want, nested.lazyContextTracking) + } + }) + } +} + +func TestParseFunctionArgumentPropagatesLazyContextTrackingToNestedJSONPathQuery(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts []config.Option + want bool + }{ + { + name: "eager by default", + want: false, + }, + { + name: "lazy with option", + opts: []config.Option{config.WithLazyContextTracking()}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tokenizer := token.NewTokenizer("$[?length($.x) > 0]", tt.opts...) + parser := newParserPrivate(tokenizer, tokenizer.Tokenize(), tt.opts...) + if err := parser.parse(); err != nil { + t.Fatalf("parse failed: %v", err) + } + + nested := nestedJSONPathFromFunctionArg(parser.ast) + if nested == nil { + t.Fatalf("expected nested jsonpath query in function argument") + } + if nested.lazyContextTracking != tt.want { + t.Fatalf("expected nested lazyContextTracking=%v, got %v", tt.want, nested.lazyContextTracking) + } + }) + } +} + +func nestedJSONPathFromTestExpr(ast jsonPathAST) *jsonPathAST { + if len(ast.segments) == 0 { + return nil + } + seg := ast.segments[0] + if seg == nil || seg.child == nil || len(seg.child.selectors) == 0 { + return nil + } + sel := seg.child.selectors[0] + if sel == nil || sel.filter == nil || sel.filter.expression == nil || len(sel.filter.expression.expressions) == 0 { + return nil + } + andExpr := sel.filter.expression.expressions[0] + if andExpr == nil || len(andExpr.expressions) == 0 { + return nil + } + basic := andExpr.expressions[0] + if basic == nil || basic.testExpr == nil || basic.testExpr.filterQuery == nil { + return nil + } + return basic.testExpr.filterQuery.jsonPathQuery +} + +func nestedJSONPathFromFunctionArg(ast jsonPathAST) *jsonPathAST { + if len(ast.segments) == 0 { + return nil + } + seg := ast.segments[0] + if seg == nil || seg.child == nil || len(seg.child.selectors) == 0 { + return nil + } + sel := seg.child.selectors[0] + if sel == nil || sel.filter == nil || sel.filter.expression == nil || len(sel.filter.expression.expressions) == 0 { + return nil + } + andExpr := sel.filter.expression.expressions[0] + if andExpr == nil || len(andExpr.expressions) == 0 { + return nil + } + basic := andExpr.expressions[0] + if basic == nil || basic.comparisonExpr == nil || basic.comparisonExpr.left == nil || basic.comparisonExpr.left.functionExpr == nil { + return nil + } + args := basic.comparisonExpr.left.functionExpr.args + if len(args) == 0 || args[0] == nil || args[0].filterQuery == nil { + return nil + } + return args[0].filterQuery.jsonPathQuery +} diff --git a/pkg/jsonpath/segment.go b/pkg/jsonpath/segment.go index fc6d31e..d6192b9 100644 --- a/pkg/jsonpath/segment.go +++ b/pkg/jsonpath/segment.go @@ -77,10 +77,19 @@ func (s innerSegment) ToString() string { return builder.String() } -func descend(value *yaml.Node, root *yaml.Node) []*yaml.Node { - result := []*yaml.Node{value} - for _, child := range value.Content { - result = append(result, descend(child, root)...) +func descendApply(value *yaml.Node, apply func(*yaml.Node)) { + if value == nil { + return + } + stack := []*yaml.Node{value} + for len(stack) > 0 { + n := stack[len(stack)-1] + stack = stack[:len(stack)-1] + apply(n) + if len(n.Content) > 0 { + for i := len(n.Content) - 1; i >= 0; i-- { + stack = append(stack, n.Content[i]) + } + } } - return result } diff --git a/pkg/jsonpath/yaml_query.go b/pkg/jsonpath/yaml_query.go index 0f71989..d597a60 100644 --- a/pkg/jsonpath/yaml_query.go +++ b/pkg/jsonpath/yaml_query.go @@ -58,12 +58,36 @@ func (q jsonPathAST) Query(current *yaml.Node, root *yaml.Node) []*yaml.Node { root = root.Content[0] } - ctx := NewFilterContext(root) + var ctx FilterContext + if q.lazyContextTracking { + ctx = newFilterContextLazy(root) + } else { + ctx = NewFilterContext(root) + } // Only enable parent tracking if the query uses ^ or @parent if q.hasParentReferences() { ctx.EnableParentTracking() } + usage := q.contextVarUsage() + if usage.parent { + ctx.EnableParentTracking() + } + if q.lazyContextTracking { + if usage.property || usage.parentProperty || q.hasPropertyNameReferences() { + enablePropertyTracking(ctx) + } + if usage.path || usage.parentProperty { + enablePathTracking(ctx) + } + if usage.index { + enableIndexTracking(ctx) + } + } else { + enablePropertyTracking(ctx) + enablePathTracking(ctx) + enableIndexTracking(ctx) + } result := make([]*yaml.Node, 0) result = append(result, root) @@ -202,6 +226,24 @@ func parentTrackingEnabled(idx index) bool { return false } +func enablePropertyTracking(ctx FilterContext) { + if enabler, ok := ctx.(interface{ EnablePropertyTracking() }); ok { + enabler.EnablePropertyTracking() + } +} + +func enablePathTracking(ctx FilterContext) { + if enabler, ok := ctx.(interface{ EnablePathTracking() }); ok { + enabler.EnablePathTracking() + } +} + +func enableIndexTracking(ctx FilterContext) { + if enabler, ok := ctx.(interface{ EnableIndexTracking() }); ok { + enabler.EnableIndexTracking() + } +} + func (s segment) Query(idx index, value *yaml.Node, root *yaml.Node) []*yaml.Node { switch s.kind { case segmentKindChild: @@ -209,10 +251,9 @@ func (s segment) Query(idx index, value *yaml.Node, root *yaml.Node) []*yaml.Nod case segmentKindDescendant: // run the inner segment against this node var result = []*yaml.Node{} - children := descend(value, root) - for _, child := range children { + descendApply(value, func(child *yaml.Node) { result = append(result, s.descendant.Query(idx, child, root)...) - } + }) // make children unique by pointer value result = unique(result) return result diff --git a/pkg/jsonpath/yaml_query_test.go b/pkg/jsonpath/yaml_query_test.go index 38183a8..f0ff1ae 100644 --- a/pkg/jsonpath/yaml_query_test.go +++ b/pkg/jsonpath/yaml_query_test.go @@ -1,6 +1,7 @@ package jsonpath import ( + "fmt" "github.com/pb33f/jsonpath/pkg/jsonpath/config" "github.com/pb33f/jsonpath/pkg/jsonpath/token" "go.yaml.in/yaml/v4" @@ -286,3 +287,38 @@ paths: }) } } + +func BenchmarkDescendantQuery(b *testing.B) { + root := buildBenchmarkRoot(5, 6) + tokenizer := token.NewTokenizer("$..*") + parser := newParserPrivate(tokenizer, tokenizer.Tokenize()) + if err := parser.parse(); err != nil { + b.Fatalf("Error parsing JSON Path: %v", err) + } + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = parser.ast.Query(root, root) + } +} + +func buildBenchmarkRoot(depth, breadth int) *yaml.Node { + doc := &yaml.Node{Kind: yaml.DocumentNode} + doc.Content = append(doc.Content, buildBenchmarkMapping(depth, breadth)) + return doc +} + +func buildBenchmarkMapping(depth, breadth int) *yaml.Node { + if depth == 0 { + return &yaml.Node{Kind: yaml.ScalarNode, Value: "leaf"} + } + + node := &yaml.Node{Kind: yaml.MappingNode} + for i := 0; i < breadth; i++ { + key := &yaml.Node{Kind: yaml.ScalarNode, Value: fmt.Sprintf("k%d", i)} + value := buildBenchmarkMapping(depth-1, breadth) + node.Content = append(node.Content, key, value) + } + return node +}