From 29b99ad392625036055716f696149ef4e5aab6bf Mon Sep 17 00:00:00 2001 From: Ulises Rangel Date: Wed, 12 Aug 2026 12:08:30 -0500 Subject: [PATCH 1/3] fix: optimize translation for queries with leading unbound expansion BED-8779 --- cypher/models/cypher/model.go | 7 + cypher/models/pgsql/optimize/direction.go | 166 ++++++++++++++++++ cypher/models/pgsql/optimize/optimizer.go | 1 + .../models/pgsql/optimize/optimizer_test.go | 152 +++++++++++++++- cypher/models/pgsql/translate/model.go | 18 +- .../pgsql/translate/optimizer_safety_test.go | 20 ++- .../models/pgsql/translate/path_functions.go | 10 ++ cypher/models/pgsql/translate/pattern.go | 5 + cypher/models/pgsql/translate/projection.go | 21 +++ cypher/models/pgsql/translate/tracking.go | 18 +- cypher/models/pgsql/translate/traversal.go | 8 + 11 files changed, 404 insertions(+), 22 deletions(-) create mode 100644 cypher/models/pgsql/optimize/direction.go diff --git a/cypher/models/cypher/model.go b/cypher/models/cypher/model.go index 514a4fe4..b1b22a87 100644 --- a/cypher/models/cypher/model.go +++ b/cypher/models/cypher/model.go @@ -1566,6 +1566,12 @@ type PatternPart struct { ShortestPathPattern bool AllShortestPathsPattern bool PatternElements []*PatternElement + + // PathDirectionReversed indicates the optimizer reversed this pattern's element order and + // relationship directions so the traversal can be driven from the more selective terminal + // endpoint. Downstream translation compensates for this when materializing a bound path so + // that the path renders in its original left-to-right logical order. + PathDirectionReversed bool } func NewPatternPart() *PatternPart { @@ -1582,6 +1588,7 @@ func (s *PatternPart) copy() *PatternPart { ShortestPathPattern: s.ShortestPathPattern, AllShortestPathsPattern: s.AllShortestPathsPattern, PatternElements: Copy(s.PatternElements), + PathDirectionReversed: s.PathDirectionReversed, } } diff --git a/cypher/models/pgsql/optimize/direction.go b/cypher/models/pgsql/optimize/direction.go new file mode 100644 index 00000000..561c8d00 --- /dev/null +++ b/cypher/models/pgsql/optimize/direction.go @@ -0,0 +1,166 @@ +package optimize + +import ( + "github.com/specterops/dawgs/cypher/models/cypher" + "github.com/specterops/dawgs/graph" +) + +// InboundTraversalReversalRule reverses a qualifying multi-step traversal pattern so that the +// search is driven from the more selective terminal endpoint inward, rather than expanding +// outward from an unconstrained source through a leading unbounded variable-length expansion. +// +// A pattern such as: +// +// MATCH p = (s:User)-[:MemberOf*0..]->(:Group)-[:AdminTo]->(d:Computer) +// WHERE s.samaccountname =~ '(?i).*[ge]$' AND d.operatingsystem CONTAINS "WINDOWS SERVER" +// RETURN p +// +// is rewritten to drive from d:Computer inbound toward s:User by reversing the pattern element +// order and each relationship direction. The reversed pattern is flagged via +// PatternPart.PathDirectionReversed so path materialization can restore the original +// left-to-right order for RETURN p. +type InboundTraversalReversalRule struct{} + +func (s InboundTraversalReversalRule) Name() string { + return "InboundTraversalReversal" +} + +func (s InboundTraversalReversalRule) Apply(plan *Plan) (bool, error) { + if plan == nil || plan.Query == nil || plan.Query.SingleQuery == nil || plan.Query.SingleQuery.SinglePartQuery == nil { + return false, nil + } + + return reverseInboundTraversalSinglePartQuery(plan.Query.SingleQuery.SinglePartQuery), nil +} + +func reverseInboundTraversalSinglePartQuery(query *cypher.SinglePartQuery) bool { + var ( + applied bool + declaredSymbols = map[string]struct{}{} + ) + + for _, readingClause := range query.ReadingClauses { + if readingClause == nil || readingClause.Match == nil { + continue + } + + match := readingClause.Match + if !match.Optional { + searchSymbols := whereSearchPredicateSymbols(match) + + for _, patternPart := range match.Pattern { + if reverseInboundTraversalPatternPart(patternPart, declaredSymbols, searchSymbols) { + applied = true + } + } + } + + declareMatchSymbols(declaredSymbols, match) + } + + return applied +} + +// reverseInboundTraversalPatternPart reverses a single pattern part if it qualifies. A pattern +// qualifies when it is a non-shortest-path traversal with more than one step, a leading +// unbounded variable-length expansion with a concrete direction whose source endpoint is neither +// externally bound nor more selective than the constrained terminal endpoint. +func reverseInboundTraversalPatternPart(patternPart *cypher.PatternPart, declaredSymbols, searchSymbols map[string]struct{}) bool { + if !inboundTraversalReversalCandidate(patternPart, declaredSymbols, searchSymbols) { + return false + } + + reversePatternElements(patternPart) + patternPart.PathDirectionReversed = !patternPart.PathDirectionReversed + return true +} + +func inboundTraversalReversalCandidate(patternPart *cypher.PatternPart, declaredSymbols, searchSymbols map[string]struct{}) bool { + if patternPart == nil || + patternPart.ShortestPathPattern || + patternPart.AllShortestPathsPattern { + return false + } + + steps := traversalStepsForPattern(patternPart) + if len(steps) < 2 { + return false + } + + leadingStep := steps[0] + if leadingStep.Relationship == nil || + leadingStep.Relationship.Range == nil || + leadingStep.Relationship.Direction == graph.DirectionBoth || + leadingStep.Relationship.Variable != nil { + return false + } + + var ( + sourceNode = steps[0].LeftNode + terminalNode = steps[len(steps)-1].RightNode + sourceSymbol = variableSymbol(sourceNode.Variable) + terminalSym = variableSymbol(terminalNode.Variable) + ) + + // The source endpoint must not be bound by a prior clause; reversing would break the + // established drive order for an externally provided source. + if sourceSymbol != "" { + if _, bound := declaredSymbols[sourceSymbol]; bound { + return false + } + } + + // The terminal endpoint must carry a search constraint to make anchoring there worthwhile. + if !endpointHasSearchConstraint(terminalNode, terminalSym, searchSymbols) { + return false + } + + var ( + sourceSelectivity = endpointSelectivity(sourceNode, sourceSymbol, searchSymbols) + terminalSelectivity = endpointSelectivity(terminalNode, terminalSym, searchSymbols) + ) + + // Only reverse when the terminal endpoint is at least as selective as the source. This keeps + // the drive anchored at the endpoint expected to prune the recursive expansion earliest. + return terminalSelectivity >= sourceSelectivity +} + +func endpointSelectivity(nodePattern *cypher.NodePattern, symbol string, searchSymbols map[string]struct{}) boundSourceSelectivity { + selectivity := nodePatternSelectivity(nodePattern, false) + if _, constrained := searchSymbols[symbol]; constrained && symbol != "" { + mergeSelectivityValue(&selectivity, boundSourceSelectivityPredicate) + } + + return selectivity +} + +// whereSearchPredicateSymbols collects the set of symbols referenced by a search-operator +// predicate (equality, regex, comparison, STARTS/ENDS WITH, CONTAINS, IN) within a match's WHERE +// clause. These indicate a filter that can anchor a traversal at the referenced endpoint. +func whereSearchPredicateSymbols(match *cypher.Match) map[string]struct{} { + symbols := map[string]struct{}{} + + if match == nil || match.Where == nil { + return symbols + } + + for _, expression := range match.Where.Expressions { + addShortestPathSearchPredicateSymbols(symbols, expression) + } + + return symbols +} + +func reversePatternElements(patternPart *cypher.PatternPart) { + elements := patternPart.PatternElements + + for left, right := 0, len(elements)-1; left < right; left, right = left+1, right-1 { + elements[left], elements[right] = elements[right], elements[left] + } + + for _, element := range elements { + if relationshipPattern, ok := element.AsRelationshipPattern(); ok { + relationshipPattern.Direction = relationshipPattern.Direction.Reverse() + } + } +} diff --git a/cypher/models/pgsql/optimize/optimizer.go b/cypher/models/pgsql/optimize/optimizer.go index d115167d..feb2942c 100644 --- a/cypher/models/pgsql/optimize/optimizer.go +++ b/cypher/models/pgsql/optimize/optimizer.go @@ -50,6 +50,7 @@ func NewOptimizer(rules ...Rule) Optimizer { func DefaultRules() []Rule { return []Rule{ ConservativePatternReorderingRule{}, + InboundTraversalReversalRule{}, PredicateAttachmentRule{}, } } diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index 33848399..f9d65314 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -7,6 +7,7 @@ import ( "github.com/specterops/dawgs/cypher/models" "github.com/specterops/dawgs/cypher/models/cypher" "github.com/specterops/dawgs/cypher/models/pgsql" + "github.com/specterops/dawgs/graph" "github.com/stretchr/testify/require" ) @@ -43,6 +44,7 @@ func TestOptimizeCopiesAndAnalyzesQuery(t *testing.T) { require.Equal(t, []string{"p1", "p2"}, plan.Analysis.QueryParts[0].ProjectionDependencies) require.Equal(t, []RuleResult{ {Name: "ConservativePatternReordering", Applied: false}, + {Name: "InboundTraversalReversal", Applied: false}, {Name: "PredicateAttachment", Applied: true}, }, plan.Rules) require.Len(t, plan.PredicateAttachments, 2) @@ -158,6 +160,7 @@ func TestDefaultPredicateAttachmentRuleReportsSkippedWhenNoPredicatesExist(t *te require.NoError(t, err) require.Equal(t, []RuleResult{ {Name: "ConservativePatternReordering", Applied: false}, + {Name: "InboundTraversalReversal", Applied: false}, {Name: "PredicateAttachment", Applied: false}, }, plan.Rules) require.Empty(t, plan.PredicateAttachments) @@ -855,14 +858,17 @@ func TestLoweringPlanPlacesBindingPredicates(t *testing.T) { require.NoError(t, err) require.Contains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringPredicatePlacement}) require.Len(t, plan.LoweringPlan.PredicatePlacement, 1) + // InboundTraversalReversal drives this pattern from the constrained ca:EnterpriseCA terminal + // inward, so the ca predicate anchors at the now-leading step (StepIndex 0) rather than being + // pushed into an expansion suffix. require.Equal(t, TraversalStepTarget{ QueryPartIndex: 0, ClauseIndex: 0, PatternIndex: 0, - StepIndex: 1, + StepIndex: 0, }, plan.LoweringPlan.PredicatePlacement[0].Target) require.Equal(t, []string{"ca"}, plan.LoweringPlan.PredicatePlacement[0].Attachment.BindingSymbols) - require.Equal(t, []PredicateAttachment{plan.LoweringPlan.PredicatePlacement[0].Attachment}, plan.LoweringPlan.ExpansionSuffixPushdown[0].PredicateAttachments) + require.Empty(t, plan.LoweringPlan.ExpansionSuffixPushdown) } func TestLoweringPlanDoesNotPlaceCrossClauseBindingPredicates(t *testing.T) { @@ -1812,6 +1818,10 @@ func TestConservativePatternReorderingMovesIndependentNodeAnchorsEarlier(t *test Name: "ConservativePatternReordering", Applied: true, }, + { + Name: "InboundTraversalReversal", + Applied: false, + }, { Name: "PredicateAttachment", Applied: false, @@ -1842,6 +1852,10 @@ func TestConservativePatternReorderingKeepsDependentAnchorsInPlace(t *testing.T) Name: "ConservativePatternReordering", Applied: false, }, + { + Name: "InboundTraversalReversal", + Applied: false, + }, { Name: "PredicateAttachment", Applied: true, @@ -1871,6 +1885,10 @@ func TestConservativePatternReorderingUsesSelectivityWithinDependencySafeRegion( Name: "ConservativePatternReordering", Applied: true, }, + { + Name: "InboundTraversalReversal", + Applied: false, + }, { Name: "PredicateAttachment", Applied: false, @@ -1901,6 +1919,10 @@ func TestConservativePatternReorderingPinsUnresolvedExternalDependencies(t *test Name: "ConservativePatternReordering", Applied: false, }, + { + Name: "InboundTraversalReversal", + Applied: false, + }, { Name: "PredicateAttachment", Applied: true, @@ -1911,3 +1933,129 @@ func TestConservativePatternReorderingPinsUnresolvedExternalDependencies(t *test require.Equal(t, "a", firstNodeSymbol(readingClauses[0])) require.Equal(t, "b", firstNodeSymbol(readingClauses[1])) } + +// patternNodeSymbols returns the variable symbols of each node pattern in element order. +func patternNodeSymbols(patternPart *cypher.PatternPart) []string { + var symbols []string + + for _, element := range patternPart.PatternElements { + if nodePattern, ok := element.AsNodePattern(); ok { + symbols = append(symbols, variableSymbol(nodePattern.Variable)) + } + } + + return symbols +} + +// patternRelationshipDirections returns the direction of each relationship pattern in element order. +func patternRelationshipDirections(patternPart *cypher.PatternPart) []graph.Direction { + var directions []graph.Direction + + for _, element := range patternPart.PatternElements { + if relationshipPattern, ok := element.AsRelationshipPattern(); ok { + directions = append(directions, relationshipPattern.Direction) + } + } + + return directions +} + +func TestInboundTraversalReversalReversesElementsAndDirectionsForSelectiveTerminal(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH p = (s:User)-[:MemberOf*0..]->(g:Group)-[:AdminTo]->(d:Computer) + WHERE s.samaccountname =~ '(?i).*[ge]$' AND d.operatingsystem CONTAINS 'WINDOWS SERVER' + RETURN p + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Contains(t, plan.Rules, RuleResult{Name: "InboundTraversalReversal", Applied: true}) + + patternPart := plan.Query.SingleQuery.SinglePartQuery.ReadingClauses[0].Match.Pattern[0] + require.True(t, patternPart.PathDirectionReversed) + + // The pattern is reversed so the traversal is driven from the constrained d:Computer terminal + // inward toward s:User, with each relationship direction flipped from outbound to inbound. + require.Equal(t, []string{"d", "g", "s"}, patternNodeSymbols(patternPart)) + require.Equal(t, []graph.Direction{graph.DirectionInbound, graph.DirectionInbound}, patternRelationshipDirections(patternPart)) +} + +func TestInboundTraversalReversalSkipsWhenSourceBoundByPriorClause(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH (s:User) + MATCH p = (s)-[:MemberOf*0..]->(g:Group)-[:AdminTo]->(d:Computer) + WHERE d.operatingsystem CONTAINS 'WINDOWS SERVER' + RETURN p + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Contains(t, plan.Rules, RuleResult{Name: "InboundTraversalReversal", Applied: false}) + + patternPart := plan.Query.SingleQuery.SinglePartQuery.ReadingClauses[1].Match.Pattern[0] + require.False(t, patternPart.PathDirectionReversed) + require.Equal(t, []string{"s", "g", "d"}, patternNodeSymbols(patternPart)) +} + +func TestInboundTraversalReversalSkipsWhenTerminalLacksSearchConstraint(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH p = (s:User)-[:MemberOf*0..]->(g:Group)-[:AdminTo]->(d:Computer) + WHERE s.samaccountname =~ '(?i).*[ge]$' + RETURN p + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Contains(t, plan.Rules, RuleResult{Name: "InboundTraversalReversal", Applied: false}) + + patternPart := plan.Query.SingleQuery.SinglePartQuery.ReadingClauses[0].Match.Pattern[0] + require.False(t, patternPart.PathDirectionReversed) + require.Equal(t, []string{"s", "g", "d"}, patternNodeSymbols(patternPart)) +} + +func TestInboundTraversalReversalSkipsWhenLeadingStepNotVariableLength(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH p = (s:User)-[:MemberOf]->(g:Group)-[:AdminTo]->(d:Computer) + WHERE d.operatingsystem CONTAINS 'WINDOWS SERVER' + RETURN p + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Contains(t, plan.Rules, RuleResult{Name: "InboundTraversalReversal", Applied: false}) + + patternPart := plan.Query.SingleQuery.SinglePartQuery.ReadingClauses[0].Match.Pattern[0] + require.False(t, patternPart.PathDirectionReversed) + require.Equal(t, []string{"s", "g", "d"}, patternNodeSymbols(patternPart)) +} + +func TestInboundTraversalReversalSkipsShortestPathPattern(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH p = shortestPath((s:User)-[:MemberOf*0..]->(g:Group)-[:AdminTo]->(d:Computer)) + WHERE d.operatingsystem CONTAINS 'WINDOWS SERVER' + RETURN p + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Contains(t, plan.Rules, RuleResult{Name: "InboundTraversalReversal", Applied: false}) + + patternPart := plan.Query.SingleQuery.SinglePartQuery.ReadingClauses[0].Match.Pattern[0] + require.False(t, patternPart.PathDirectionReversed) + require.Equal(t, []string{"s", "g", "d"}, patternNodeSymbols(patternPart)) +} diff --git a/cypher/models/pgsql/translate/model.go b/cypher/models/pgsql/translate/model.go index b29e8c14..09e08c95 100644 --- a/cypher/models/pgsql/translate/model.go +++ b/cypher/models/pgsql/translate/model.go @@ -505,13 +505,17 @@ type PatternPart struct { IsTraversal bool ShortestPath bool AllShortestPaths bool - PatternBinding *BoundIdentifier - Target optimize.PatternTarget - HasTarget bool - TraversalSteps []*TraversalStep - NodeSelect NodeSelect - Constraints *ConstraintTracker - nextSourceStep int + // PathDirectionReversed is set when the optimizer reversed the originating cypher pattern's + // element order and relationship directions. Path materialization uses it to restore the + // original left-to-right logical order for a bound path. + PathDirectionReversed bool + PatternBinding *BoundIdentifier + Target optimize.PatternTarget + HasTarget bool + TraversalSteps []*TraversalStep + NodeSelect NodeSelect + Constraints *ConstraintTracker + nextSourceStep int } func (s *PatternPart) nextSourceTarget() (optimize.TraversalStepTarget, bool) { diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index 5e1786a2..eedc5028 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -468,7 +468,7 @@ RETURN p require.Contains(t, normalizedQuery, "n2.kind_ids operator (pg_catalog.@>) array [5]::int2[]") } -func TestOptimizerSafetySuffixPredicatePlacementStaysInsideTerminalExists(t *testing.T) { +func TestOptimizerSafetyReversalAnchorsTerminalPredicateAtDriveRoot(t *testing.T) { t.Parallel() normalizedQuery := optimizerSafetySQL(t, ` @@ -477,11 +477,16 @@ WHERE ca.name = 'target' RETURN p `) + // InboundTraversalReversal drives this pattern from the constrained ca:EnterpriseCA terminal + // inward, so the ca.name predicate anchors at the leading s0 segment rather than being pushed + // into a recursive terminal exists check. requireSQLContainsInOrder(t, normalizedQuery, - "exists (select 1 from edge e1 join node n2", - "properties -> 'name'", - "where n1.id = e1.start_id", + "(n0.properties ->> 'name') = 'target'", + "n0.kind_ids operator (pg_catalog.@>) array [5]::int2[]", + "n0.id = e0.end_id", + "e0.kind_id = any (array [4]::int2[])", ) + require.Contains(t, normalizedQuery, "e1.kind_id = any (array [10]::int2[])") } func TestOptimizerSafetyPredicatePlacementRecordsExpansionRootConstraint(t *testing.T) { @@ -1267,15 +1272,16 @@ RETURN p require.NotNil(t, translation.Optimization.LoweringPlan) require.NotEmpty(t, translation.Optimization.LoweringPlan.ProjectionPruning) require.NotEmpty(t, translation.Optimization.LoweringPlan.LatePathMaterialization) - require.NotEmpty(t, translation.Optimization.LoweringPlan.ExpansionSuffixPushdown) require.NotEmpty(t, translation.Optimization.LoweringPlan.PredicatePlacement) + // InboundTraversalReversal drives this pattern from the constrained ca:EnterpriseCA terminal + // inward, superseding expansion-suffix pushdown for this shape. + require.Contains(t, translation.Optimization.Rules, optimize.RuleResult{Name: "InboundTraversalReversal", Applied: true}) + require.Empty(t, translation.Optimization.LoweringPlan.ExpansionSuffixPushdown) requirePlannedOptimizationLowering(t, translation.Optimization, "ProjectionPruning") requirePlannedOptimizationLowering(t, translation.Optimization, "LatePathMaterialization") - requirePlannedOptimizationLowering(t, translation.Optimization, "ExpansionSuffixPushdown") requirePlannedOptimizationLowering(t, translation.Optimization, "PredicatePlacement") requireOptimizationLowering(t, translation.Optimization, "ProjectionPruning") requireOptimizationLowering(t, translation.Optimization, "LatePathMaterialization") - requireOptimizationLowering(t, translation.Optimization, "ExpansionSuffixPushdown") requireOptimizationLowering(t, translation.Optimization, "PredicatePlacement") } diff --git a/cypher/models/pgsql/translate/path_functions.go b/cypher/models/pgsql/translate/path_functions.go index ad2e77e9..f9deacab 100644 --- a/cypher/models/pgsql/translate/path_functions.go +++ b/cypher/models/pgsql/translate/path_functions.go @@ -33,6 +33,11 @@ func pathCompositeEdgesExpression(scope *Scope, pathBinding *BoundIdentifier) (p } } + // Restore original logical edge order when the originating pattern was reversed by the optimizer. + if pathBinding.PathDirectionReversed { + reversePathCompositeExpressions(edgeArrayReferences) + } + if edgeArrayExpression := concatenatePathCompositeParts(edgeArrayReferences); edgeArrayExpression != nil { return edgeArrayExpression, nil } @@ -69,6 +74,11 @@ func pathCompositeEdgeIDArrayExpression(scope *Scope, pathBinding *BoundIdentifi } } + // Restore original logical edge order when the originating pattern was reversed by the optimizer. + if pathBinding.PathDirectionReversed { + reversePathCompositeExpressions(edgeIDArrayReferences) + } + if edgeIDArrayExpression := concatenatePathCompositeParts(edgeIDArrayReferences); edgeIDArrayExpression != nil { return edgeIDArrayExpression, nil } diff --git a/cypher/models/pgsql/translate/pattern.go b/cypher/models/pgsql/translate/pattern.go index a77d03ce..45450523 100644 --- a/cypher/models/pgsql/translate/pattern.go +++ b/cypher/models/pgsql/translate/pattern.go @@ -38,6 +38,7 @@ func (s *Translator) translatePatternPart(patternPart *cypher.PatternPart) error newPatternPart.IsTraversal = len(patternPart.PatternElements) > 1 newPatternPart.ShortestPath = patternPart.ShortestPathPattern newPatternPart.AllShortestPaths = patternPart.AllShortestPathsPattern + newPatternPart.PathDirectionReversed = patternPart.PathDirectionReversed if target, hasTarget := s.patternTargets[patternPart]; hasTarget { newPatternPart.Target = target newPatternPart.HasTarget = true @@ -52,6 +53,10 @@ func (s *Translator) translatePatternPart(patternPart *cypher.PatternPart) error // Generate an alias for this binding s.scope.Alias(cypherBinding, pathBinding) + // Propagate the optimizer's pattern reversal so path materialization can restore the + // original left-to-right logical order for this bound path. + pathBinding.PathDirectionReversed = patternPart.PathDirectionReversed + // Record the new binding in the traversal pattern being built newPatternPart.PatternBinding = pathBinding } diff --git a/cypher/models/pgsql/translate/projection.go b/cypher/models/pgsql/translate/projection.go index d83f7a4a..74adafa3 100644 --- a/cypher/models/pgsql/translate/projection.go +++ b/cypher/models/pgsql/translate/projection.go @@ -296,6 +296,15 @@ func nullGuardPathCompositeExpression(expression, nullGuard pgsql.Expression) pg } } +// reversePathCompositeExpressions reverses a slice of path composite references in place. It is +// used to restore original logical path order when materializing a path bound to an +// optimizer-reversed pattern. +func reversePathCompositeExpressions(expressions []pgsql.Expression) { + for left, right := 0, len(expressions)-1; left < right; left, right = left+1, right-1 { + expressions[left], expressions[right] = expressions[right], expressions[left] + } +} + func expressionForPathComposite(projected *BoundIdentifier, scope *Scope) (pgsql.Expression, error) { if projected.LastProjection != nil { return pgsql.CompoundIdentifier{projected.LastProjection.Binding.Identifier, projected.Identifier}, nil @@ -348,6 +357,18 @@ func expressionForPathComposite(projected *BoundIdentifier, scope *Scope) (pgsql } } + // The optimizer reversed the originating pattern so the traversal could be driven from the + // more selective terminal endpoint. The path dependencies were therefore accumulated in + // reversed physical order. Restore the original left-to-right logical order by reversing the + // assembled cross-segment references. Within-expansion edge order is already restored by the + // expansion step's PathReversed flag. + if projected.PathDirectionReversed { + reversePathCompositeExpressions(edgeArrayReferences) + reversePathCompositeExpressions(nodeReferences) + reversePathCompositeExpressions(directNodeReferences) + reversePathCompositeExpressions(directEdgeReferences) + } + // Direct, non-expansion path bindings already have their node and edge composites in scope. Keep // those explicit components instead of reconstructing the path from edge IDs: this preserves path // order and duplicate nodes, and it also works for rows produced by data-modifying CTEs where diff --git a/cypher/models/pgsql/translate/tracking.go b/cypher/models/pgsql/translate/tracking.go index 38f9058e..8707cd06 100644 --- a/cypher/models/pgsql/translate/tracking.go +++ b/cypher/models/pgsql/translate/tracking.go @@ -384,6 +384,11 @@ type BoundIdentifier struct { LastProjection *Frame Dependencies []*BoundIdentifier DataType pgsql.DataType + + // PathDirectionReversed marks a path composite binding whose dependency order was produced + // from an optimizer-reversed pattern. Path materialization reverses the assembled node and + // edge references so the path renders in its original left-to-right logical order. + PathDirectionReversed bool } func (s *BoundIdentifier) MaterializedBy(frame *Frame) { @@ -395,12 +400,13 @@ func (s *BoundIdentifier) Copy() *BoundIdentifier { copy(dependenciesCopy, s.Dependencies) return &BoundIdentifier{ - Identifier: s.Identifier, - Alias: s.Alias, - Parameter: s.Parameter, - LastProjection: s.LastProjection, - Dependencies: dependenciesCopy, - DataType: s.DataType, + Identifier: s.Identifier, + Alias: s.Alias, + Parameter: s.Parameter, + LastProjection: s.LastProjection, + Dependencies: dependenciesCopy, + DataType: s.DataType, + PathDirectionReversed: s.PathDirectionReversed, } } diff --git a/cypher/models/pgsql/translate/traversal.go b/cypher/models/pgsql/translate/traversal.go index 3a6eb873..0bdfb500 100644 --- a/cypher/models/pgsql/translate/traversal.go +++ b/cypher/models/pgsql/translate/traversal.go @@ -638,6 +638,14 @@ func (s *Translator) translateTraversalPatternPart(part *PatternPart, isolatedPr s.recordLowering(optimize.LoweringExpandIntoDetection) } + // The optimizer reversed this pattern's element order and relationship directions so the + // traversal is driven from the terminal endpoint inward. Each expansion accumulates its + // edges in that reversed walk order, so mark the step path-reversed to restore the + // original within-segment edge order for a bound path. + if part.PathDirectionReversed && traversalStep.Expansion != nil { + traversalStep.PathReversed = true + } + s.prepareProjectionPruning(part, idx, traversalStep) if traversalStepFrame, err := s.scope.PushFrame(); err != nil { From 0857f83cef51a20deb44454ca4618d7cfebd48f1 Mon Sep 17 00:00:00 2001 From: Ulises Rangel Date: Wed, 12 Aug 2026 16:14:56 -0500 Subject: [PATCH 2/3] fix: check range index for proper unbound condition --- cypher/models/pgsql/optimize/direction.go | 1 + .../models/pgsql/optimize/optimizer_test.go | 19 +++++++++++++++++++ cypher/models/pgsql/optimize/reordering.go | 5 +++++ 3 files changed, 25 insertions(+) diff --git a/cypher/models/pgsql/optimize/direction.go b/cypher/models/pgsql/optimize/direction.go index 561c8d00..175c9b12 100644 --- a/cypher/models/pgsql/optimize/direction.go +++ b/cypher/models/pgsql/optimize/direction.go @@ -90,6 +90,7 @@ func inboundTraversalReversalCandidate(patternPart *cypher.PatternPart, declared leadingStep := steps[0] if leadingStep.Relationship == nil || leadingStep.Relationship.Range == nil || + leadingStep.Relationship.Range.EndIndex != nil || leadingStep.Relationship.Direction == graph.DirectionBoth || leadingStep.Relationship.Variable != nil { return false diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index f9d65314..8bac10da 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -2041,6 +2041,25 @@ func TestInboundTraversalReversalSkipsWhenLeadingStepNotVariableLength(t *testin require.Equal(t, []string{"s", "g", "d"}, patternNodeSymbols(patternPart)) } +func TestInboundTraversalReversalSkipsWhenLeadingExpansionBounded(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH p = (s:User)-[:MemberOf*1..3]->(g:Group)-[:AdminTo]->(d:Computer) + WHERE s.samaccountname =~ '(?i).*[ge]$' AND d.operatingsystem CONTAINS 'WINDOWS SERVER' + RETURN p + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Contains(t, plan.Rules, RuleResult{Name: "InboundTraversalReversal", Applied: false}) + + patternPart := plan.Query.SingleQuery.SinglePartQuery.ReadingClauses[0].Match.Pattern[0] + require.False(t, patternPart.PathDirectionReversed) + require.Equal(t, []string{"s", "g", "d"}, patternNodeSymbols(patternPart)) +} + func TestInboundTraversalReversalSkipsShortestPathPattern(t *testing.T) { t.Parallel() diff --git a/cypher/models/pgsql/optimize/reordering.go b/cypher/models/pgsql/optimize/reordering.go index 83ae4bda..5027894b 100644 --- a/cypher/models/pgsql/optimize/reordering.go +++ b/cypher/models/pgsql/optimize/reordering.go @@ -2,6 +2,11 @@ package optimize import "github.com/specterops/dawgs/cypher/models/cypher" +// ConservativePatternReorderingRule reorders reading clauses within a dependency-safe region so +// that more selective anchors are scheduled earlier, driving traversals from the endpoints +// expected to prune the search soonest. It reorders whole clauses relative to one another without +// mutating the internals of any pattern; a clause is only moved when its dependencies remain +// satisfied by the clauses that precede it in the new order. type ConservativePatternReorderingRule struct{} func (s ConservativePatternReorderingRule) Name() string { From b89473ca5b707fe2cefebc0f4e74e28b51b25fd3 Mon Sep 17 00:00:00 2001 From: Ulises Rangel Date: Wed, 12 Aug 2026 17:06:30 -0500 Subject: [PATCH 3/3] fix: uniqueness constraint for edge reuse in expansion after direction reversal --- .../test/translation_cases/multipart.sql | 2 +- .../translation_cases/pattern_binding.sql | 4 +-- .../translation_cases/pattern_expansion.sql | 10 +++--- cypher/models/pgsql/translate/expansion.go | 10 ++++++ cypher/models/pgsql/translate/model.go | 1 + cypher/models/pgsql/translate/traversal.go | 31 +++++++++++++++++++ 6 files changed, 51 insertions(+), 7 deletions(-) diff --git a/cypher/models/pgsql/test/translation_cases/multipart.sql b/cypher/models/pgsql/test/translation_cases/multipart.sql index e7995068..1317707c 100644 --- a/cypher/models/pgsql/test/translation_cases/multipart.sql +++ b/cypher/models/pgsql/test/translation_cases/multipart.sql @@ -51,7 +51,7 @@ with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposit with s0 as (select 'a' as i0), s1 as (select s0.i0 as i0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from s0, node n0 where ((jsonb_typeof((n0.properties -> 'domain')) = 'string' and (n0.properties ->> 'domain') = ' ') and cypher_starts_with((n0.properties ->> 'name'), (i0)::text)::bool) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s1.n0 as o from s1; -- case: match (dc)-[r:EdgeKind1*0..]->(g:NodeKind1) where g.objectid ends with '-516' with collect(dc) as exclude match p = (c:NodeKind2)-[n:EdgeKind2]->(u:NodeKind2)-[:EdgeKind2*1..]->(g:NodeKind1) where g.objectid ends with '-512' and not c in exclude return p limit 100 -with s0 as (with s1 as (with recursive s2_seed(root_id) as not materialized (select n1.id as root_id from node n1 where ((n1.properties ->> 'objectid') like '%-516') and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select s2_seed.root_id, s2_seed.root_id, 0, false, false, array []::int8[] from s2_seed union all select e0.end_id, e0.start_id, 1, false, e0.end_id = e0.start_id, array [e0.id] from s2_seed join edge e0 on e0.end_id = s2_seed.root_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.start_id, s2.depth + 1, false, false, e0.id || s2.path from s2 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.end_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true where s2.depth < 15 and not s2.is_cycle and s2.depth > 0) select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s2 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s2.next_id offset 0) n0 on true) select array_remove(coalesce(array_agg((n0).id)::int8[], array []::int8[])::int8[], null)::int8[] as i0 from s1), s3 as (select e1.id as e1, s0.i0 as i0, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s0, edge e1 join node n3 on n3.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n3.id = e1.end_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e1.start_id where (not n2.id = any (s0.i0)) and e1.kind_id = any (array [4]::int2[])), s4 as (with recursive s5_seed(root_id) as not materialized (select distinct (s3.n3).id as root_id from s3), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, ((n4.properties ->> 'objectid') like '%-512') and n4.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.start_id = e2.end_id, array [e2.id] from s5_seed join edge e2 on e2.start_id = s5_seed.root_id join node n4 on n4.id = e2.end_id where e2.kind_id = any (array [4]::int2[]) union all select s5.root_id, e2.end_id, s5.depth + 1, ((n4.properties ->> 'objectid') like '%-512') and n4.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, s5.path || e2.id from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.start_id = s5.next_id and e2.id != all (s5.path) and e2.kind_id = any (array [4]::int2[]) offset 0) e2 on true join node n4 on n4.id = e2.end_id where s5.depth < 15 and not s5.is_cycle) select s3.e1 as e1, s5.path as ep1, s3.i0 as i0, s3.n2 as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3, (n4.id, n4.kind_ids, n4.properties)::nodecomposite as n4 from s3, s5 join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.root_id offset 0) n3 on true join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.next_id offset 0) n4 on true where s5.satisfied and (s3.n3).id = s5.root_id limit 100) select case when (s4.n2).id is null or s4.e1 is null or (s4.n3).id is null or s4.ep1 is null or (s4.n4).id is null then null else ordered_edges_to_path(s4.n2, (select coalesce(array_agg((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(array [s4.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id) || (select coalesce(array_agg((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s4.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n2, s4.n3, s4.n4]::nodecomposite[])::pathcomposite end as p from s4 limit 100; +with s0 as (with s1 as (with recursive s2_seed(root_id) as not materialized (select n1.id as root_id from node n1 where ((n1.properties ->> 'objectid') like '%-516') and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select s2_seed.root_id, s2_seed.root_id, 0, false, false, array []::int8[] from s2_seed union all select e0.end_id, e0.start_id, 1, false, e0.end_id = e0.start_id, array [e0.id] from s2_seed join edge e0 on e0.end_id = s2_seed.root_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.start_id, s2.depth + 1, false, false, e0.id || s2.path from s2 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.end_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true where s2.depth < 15 and not s2.is_cycle and s2.depth > 0) select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s2 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s2.next_id offset 0) n0 on true) select array_remove(coalesce(array_agg((n0).id)::int8[], array []::int8[])::int8[], null)::int8[] as i0 from s1), s3 as (select e1.id as e1, s0.i0 as i0, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s0, edge e1 join node n3 on n3.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n3.id = e1.end_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e1.start_id where (not n2.id = any (s0.i0)) and e1.kind_id = any (array [4]::int2[])), s4 as (with recursive s5_seed(root_id) as not materialized (select distinct (s3.n3).id as root_id from s3), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, ((n4.properties ->> 'objectid') like '%-512') and n4.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.start_id = e2.end_id, array [e2.id] from s5_seed join edge e2 on e2.start_id = s5_seed.root_id join node n4 on n4.id = e2.end_id where e2.kind_id = any (array [4]::int2[]) union all select s5.root_id, e2.end_id, s5.depth + 1, ((n4.properties ->> 'objectid') like '%-512') and n4.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, s5.path || e2.id from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.start_id = s5.next_id and e2.id != all (s5.path) and e2.kind_id = any (array [4]::int2[]) offset 0) e2 on true join node n4 on n4.id = e2.end_id where s5.depth < 15 and not s5.is_cycle) select s3.e1 as e1, s5.path as ep1, s3.i0 as i0, s3.n2 as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3, (n4.id, n4.kind_ids, n4.properties)::nodecomposite as n4 from s3, s5 join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.root_id offset 0) n3 on true join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.next_id offset 0) n4 on true where s5.satisfied and (s3.n3).id = s5.root_id and s3.e1 != all (s5.path) limit 100) select case when (s4.n2).id is null or s4.e1 is null or (s4.n3).id is null or s4.ep1 is null or (s4.n4).id is null then null else ordered_edges_to_path(s4.n2, (select coalesce(array_agg((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(array [s4.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id) || (select coalesce(array_agg((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s4.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n2, s4.n3, s4.n4]::nodecomposite[])::pathcomposite end as p from s4 limit 100; -- case: match (n:NodeKind1)<-[:EdgeKind1]-(:NodeKind2) where n.objectid ends with '-516' with n, count(n) as dc_count where dc_count = 1 return n with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from edge e0 join node n0 on ((n0.properties ->> 'objectid') like '%-516') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id = e0.end_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.start_id where e0.kind_id = any (array [3]::int2[])) select s1.n0 as n0, count(s1.n0)::int8 as i0 from s1 group by n0) select s0.n0 as n from s0 where (s0.i0 = 1); diff --git a/cypher/models/pgsql/test/translation_cases/pattern_binding.sql b/cypher/models/pgsql/test/translation_cases/pattern_binding.sql index c32946cb..370b5caf 100644 --- a/cypher/models/pgsql/test/translation_cases/pattern_binding.sql +++ b/cypher/models/pgsql/test/translation_cases/pattern_binding.sql @@ -51,7 +51,7 @@ with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path with s0 as (with recursive s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where ((jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'n3'))), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, (n0.id = 1), e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id join node n0 on n0.id = e0.start_id union all select s1.root_id, e0.start_id, s1.depth + 1, (n0.id = 1), false, e0.id || s1.path from s1 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.end_id = s1.next_id and e0.id != all (s1.path) offset 0) e0 on true join node n0 on n0.id = e0.start_id where s1.depth < 15 and not s1.is_cycle) select s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s1.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s1.next_id offset 0) n0 on true where s1.satisfied), s2 as (select e1.id as e1, s0.ep0 as ep0, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.id != all (s0.ep0) limit 1) select case when (s2.n0).id is null or s2.ep0 is null or (s2.n1).id is null or s2.e1 is null or (s2.n2).id is null then null else ordered_edges_to_path(s2.n0, (select coalesce(array_agg((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s2.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id) || (select coalesce(array_agg((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(array [s2.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1, s2.n2]::nodecomposite[])::pathcomposite end as p from s2 limit 1; -- case: match p = ()-[e:EdgeKind1]->()-[:EdgeKind1*..]->() return e, p -with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[])), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.start_id, e1.end_id, 1, false, e1.start_id = e1.end_id, array [e1.id] from s2_seed join edge e1 on e1.start_id = s2_seed.root_id where e1.kind_id = any (array [3]::int2[]) union all select s2.root_id, e1.end_id, s2.depth + 1, false, false, s2.path || e1.id from s2 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.start_id = s2.next_id and e1.id != all (s2.path) and e1.kind_id = any (array [3]::int2[]) offset 0) e1 on true where s2.depth < 15 and not s2.is_cycle) select s0.e0 as e0, s2.path as ep0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s2.next_id offset 0) n2 on true where (s0.n1).id = s2.root_id) select s1.e0 as e, case when (s1.n0).id is null or (s1.e0).id is null or (s1.n1).id is null or s1.ep0 is null or (s1.n2).id is null then null else ordered_edges_to_path(s1.n0, array [s1.e0]::edgecomposite[] || (select coalesce(array_agg((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1, s1.n2]::nodecomposite[])::pathcomposite end as p from s1; +with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[])), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.start_id, e1.end_id, 1, false, e1.start_id = e1.end_id, array [e1.id] from s2_seed join edge e1 on e1.start_id = s2_seed.root_id where e1.kind_id = any (array [3]::int2[]) union all select s2.root_id, e1.end_id, s2.depth + 1, false, false, s2.path || e1.id from s2 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.start_id = s2.next_id and e1.id != all (s2.path) and e1.kind_id = any (array [3]::int2[]) offset 0) e1 on true where s2.depth < 15 and not s2.is_cycle) select s0.e0 as e0, s2.path as ep0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s2.next_id offset 0) n2 on true where (s0.n1).id = s2.root_id and (s0.e0).id != all (s2.path)) select s1.e0 as e, case when (s1.n0).id is null or (s1.e0).id is null or (s1.n1).id is null or s1.ep0 is null or (s1.n2).id is null then null else ordered_edges_to_path(s1.n0, array [s1.e0]::edgecomposite[] || (select coalesce(array_agg((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1, s1.n2]::nodecomposite[])::pathcomposite end as p from s1; -- case: match p = (m:NodeKind1)-[:EdgeKind1]->(c:NodeKind2) where m.objectid ends with "-513" and not toUpper(c.operatingsystem) contains "SERVER" return p limit 1000 with s0 as (select e0.id as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on ((n0.properties ->> 'objectid') like '%-513') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id = e0.start_id join node n1 on (not upper((n1.properties ->> 'operatingsystem'))::text like '%SERVER%') and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) limit 1000) select case when (s0.n0).id is null or s0.e0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (select coalesce(array_agg((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(array [s0.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 limit 1000; @@ -63,7 +63,7 @@ with s0 as (select e0.id as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposi with s0 as (select e0.id as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (n0.id = e0.end_id or n0.id = e0.start_id) join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (n1.id = e0.end_id or n1.id = e0.start_id) where (n0.id <> n1.id)) select case when (s0.n0).id is null or s0.e0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (select coalesce(array_agg((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(array [s0.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0; -- case: match p = (:NodeKind1)-[:EdgeKind1]->(:NodeKind2)-[:EdgeKind2*1..]->(t:NodeKind2) where coalesce(t.system_tags, '') contains 'admin_tier_0' return p limit 1000 -with s0 as (select e0.id as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[])), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.start_id, e1.end_id, 1, (coalesce((n2.properties ->> 'system_tags'), '')::text like '%admin_tier_0%') and n2.kind_ids operator (pg_catalog.@>) array [2]::int2[], e1.start_id = e1.end_id, array [e1.id] from s2_seed join edge e1 on e1.start_id = s2_seed.root_id join node n2 on n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[]) union all select s2.root_id, e1.end_id, s2.depth + 1, (coalesce((n2.properties ->> 'system_tags'), '')::text like '%admin_tier_0%') and n2.kind_ids operator (pg_catalog.@>) array [2]::int2[], false, s2.path || e1.id from s2 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.start_id = s2.next_id and e1.id != all (s2.path) and e1.kind_id = any (array [4]::int2[]) offset 0) e1 on true join node n2 on n2.id = e1.end_id where s2.depth < 15 and not s2.is_cycle) select s0.e0 as e0, s2.path as ep0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s2.next_id offset 0) n2 on true where s2.satisfied and (s0.n1).id = s2.root_id limit 1000) select case when (s1.n0).id is null or s1.e0 is null or (s1.n1).id is null or s1.ep0 is null or (s1.n2).id is null then null else ordered_edges_to_path(s1.n0, (select coalesce(array_agg((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(array [s1.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id) || (select coalesce(array_agg((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1, s1.n2]::nodecomposite[])::pathcomposite end as p from s1 limit 1000; +with s0 as (select e0.id as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[])), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.start_id, e1.end_id, 1, (coalesce((n2.properties ->> 'system_tags'), '')::text like '%admin_tier_0%') and n2.kind_ids operator (pg_catalog.@>) array [2]::int2[], e1.start_id = e1.end_id, array [e1.id] from s2_seed join edge e1 on e1.start_id = s2_seed.root_id join node n2 on n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[]) union all select s2.root_id, e1.end_id, s2.depth + 1, (coalesce((n2.properties ->> 'system_tags'), '')::text like '%admin_tier_0%') and n2.kind_ids operator (pg_catalog.@>) array [2]::int2[], false, s2.path || e1.id from s2 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.start_id = s2.next_id and e1.id != all (s2.path) and e1.kind_id = any (array [4]::int2[]) offset 0) e1 on true join node n2 on n2.id = e1.end_id where s2.depth < 15 and not s2.is_cycle) select s0.e0 as e0, s2.path as ep0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s2.next_id offset 0) n2 on true where s2.satisfied and (s0.n1).id = s2.root_id and s0.e0 != all (s2.path) limit 1000) select case when (s1.n0).id is null or s1.e0 is null or (s1.n1).id is null or s1.ep0 is null or (s1.n2).id is null then null else ordered_edges_to_path(s1.n0, (select coalesce(array_agg((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(array [s1.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id) || (select coalesce(array_agg((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1, s1.n2]::nodecomposite[])::pathcomposite end as p from s1 limit 1000; -- case: match (u:NodeKind1) where u.samaccountname in ["foo", "bar"] match p = (u)-[:EdgeKind1|EdgeKind2*1..3]->(t) where coalesce(t.system_tags, '') contains 'admin_tier_0' return p limit 1000 with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((n0.properties ->> 'samaccountname') = any (array ['foo', 'bar']::text[])) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n0).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (coalesce((n1.properties ->> 'system_tags'), '')::text like '%admin_tier_0%'), e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, (coalesce((n1.properties ->> 'system_tags'), '')::text like '%admin_tier_0%'), false, s2.path || e0.id from s2 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.start_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3, 4]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 3 and not s2.is_cycle) select s2.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, s2 join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s2.root_id offset 0) n0 on true join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.next_id offset 0) n1 on true where s2.satisfied and (s0.n0).id = s2.root_id) select case when (s1.n0).id is null or s1.ep0 is null or (s1.n1).id is null then null else ordered_edges_to_path(s1.n0, (select coalesce(array_agg((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite end as p from s1 limit 1000; diff --git a/cypher/models/pgsql/test/translation_cases/pattern_expansion.sql b/cypher/models/pgsql/test/translation_cases/pattern_expansion.sql index 895d3c41..947909fb 100644 --- a/cypher/models/pgsql/test/translation_cases/pattern_expansion.sql +++ b/cypher/models/pgsql/test/translation_cases/pattern_expansion.sql @@ -42,10 +42,13 @@ with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = 'n1'))), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, n1.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.start_id = e0.end_id, array [e0.id] from s1_seed join edge e0 on e0.start_id = s1_seed.root_id join node n1 on n1.id = e0.end_id union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, s1.path || e0.id from s1 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.start_id = s1.next_id and e0.id != all (s1.path) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 3 and not s1.is_cycle) select s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s1.root_id offset 0) n0 on true join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s1.next_id offset 0) n1 on true where s1.depth >= 2 and s1.satisfied), s2 as (select s0.ep0 as ep0, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.id != all (s0.ep0)) select s2.n2 as l from s2; -- case: match (n)-[]->(e:NodeKind1)-[*2..3]->(l) where n.name = 'n1' return l -with s0 as (select e0.id as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = 'n1')) and n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.end_id), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.start_id, e1.end_id, 1, false, e1.start_id = e1.end_id, array [e1.id] from s2_seed join edge e1 on e1.start_id = s2_seed.root_id union all select s2.root_id, e1.end_id, s2.depth + 1, false, false, s2.path || e1.id from s2 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.start_id = s2.next_id and e1.id != all (s2.path) offset 0) e1 on true where s2.depth < 3 and not s2.is_cycle) select s0.e0 as e0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s2.next_id offset 0) n2 on true where s2.depth >= 2 and (s0.n1).id = s2.root_id) select s1.n2 as l from s1; +with s0 as (select e0.id as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = 'n1')) and n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.end_id), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.start_id, e1.end_id, 1, false, e1.start_id = e1.end_id, array [e1.id] from s2_seed join edge e1 on e1.start_id = s2_seed.root_id union all select s2.root_id, e1.end_id, s2.depth + 1, false, false, s2.path || e1.id from s2 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.start_id = s2.next_id and e1.id != all (s2.path) offset 0) e1 on true where s2.depth < 3 and not s2.is_cycle) select s0.e0 as e0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s2.next_id offset 0) n2 on true where s2.depth >= 2 and (s0.n1).id = s2.root_id and s0.e0 != all (s2.path)) select s1.n2 as l from s1; + +-- case: match p = (src:NodeKind1)-[:EdgeKind1*1..]->(mid)-[:EdgeKind1]-(dst:NodeKind1) where src.name = 'reuse-source' and dst.name = 'reuse-source' return p +with s0 as (select e0.id as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = 'reuse-source')) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (n0.id = e0.end_id or n0.id = e0.start_id) join node n1 on (n1.id = e0.end_id or n1.id = e0.start_id) where (n0.id <> n1.id) and e0.kind_id = any (array [3]::int2[])), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.end_id, e1.start_id, 1, ((jsonb_typeof((n2.properties -> 'name')) = 'string' and (n2.properties ->> 'name') = 'reuse-source')) and n2.kind_ids operator (pg_catalog.@>) array [1]::int2[], e1.end_id = e1.start_id, array [e1.id] from s2_seed join edge e1 on e1.end_id = s2_seed.root_id join node n2 on n2.id = e1.start_id where e1.kind_id = any (array [3]::int2[]) union all select s2.root_id, e1.start_id, s2.depth + 1, ((jsonb_typeof((n2.properties -> 'name')) = 'string' and (n2.properties ->> 'name') = 'reuse-source')) and n2.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e1.id || s2.path from s2 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.end_id = s2.next_id and e1.id != all (s2.path) and e1.kind_id = any (array [3]::int2[]) offset 0) e1 on true join node n2 on n2.id = e1.start_id where s2.depth < 15 and not s2.is_cycle) select s0.e0 as e0, s2.path as ep0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s2.next_id offset 0) n2 on true where s2.satisfied and (s0.n1).id = s2.root_id and s0.e0 != all (s2.path)) select case when (s1.n0).id is null or s1.e0 is null or (s1.n1).id is null or s1.ep0 is null or (s1.n2).id is null then null else ordered_edges_to_path(s1.n2, (select coalesce(array_agg((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id) || (select coalesce(array_agg((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(array [s1.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n2, s1.n1, s1.n0]::nodecomposite[])::pathcomposite end as p from s1; -- case: match (n)-[*..]->(e)-[:EdgeKind1|EdgeKind2]->()-[*..]->(l) where n.name = 'n1' and e.name = 'n2' return l -with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = 'n1'))), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, ((jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'n2')), e0.start_id = e0.end_id, array [e0.id] from s1_seed join edge e0 on e0.start_id = s1_seed.root_id join node n1 on n1.id = e0.end_id union all select s1.root_id, e0.end_id, s1.depth + 1, ((jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'n2')), false, s1.path || e0.id from s1 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.start_id = s1.next_id and e0.id != all (s1.path) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s1.root_id offset 0) n0 on true join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s1.next_id offset 0) n1 on true where s1.satisfied and exists (select 1 from edge e1 join node n2 on n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [3, 4]::int2[]))), s2 as (select e1.id as e1, s0.ep0 as ep0, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.kind_id = any (array [3, 4]::int2[]) and e1.id != all (s0.ep0)), s3 as (with recursive s4_seed(root_id) as not materialized (select distinct (s2.n2).id as root_id from s2), s4(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, false, e2.start_id = e2.end_id, array [e2.id] from s4_seed join edge e2 on e2.start_id = s4_seed.root_id union all select s4.root_id, e2.end_id, s4.depth + 1, false, false, s4.path || e2.id from s4 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.start_id = s4.next_id and e2.id != all (s4.path) offset 0) e2 on true where s4.depth < 15 and not s4.is_cycle) select s2.e1 as e1, s2.ep0 as ep0, s2.n0 as n0, s2.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s2, s4 join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s4.root_id offset 0) n2 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s4.next_id offset 0) n3 on true where (s2.n2).id = s4.root_id) select s3.n3 as l from s3; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = 'n1'))), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, ((jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'n2')), e0.start_id = e0.end_id, array [e0.id] from s1_seed join edge e0 on e0.start_id = s1_seed.root_id join node n1 on n1.id = e0.end_id union all select s1.root_id, e0.end_id, s1.depth + 1, ((jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'n2')), false, s1.path || e0.id from s1 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.start_id = s1.next_id and e0.id != all (s1.path) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s1.root_id offset 0) n0 on true join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s1.next_id offset 0) n1 on true where s1.satisfied and exists (select 1 from edge e1 join node n2 on n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [3, 4]::int2[]))), s2 as (select e1.id as e1, s0.ep0 as ep0, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.kind_id = any (array [3, 4]::int2[]) and e1.id != all (s0.ep0)), s3 as (with recursive s4_seed(root_id) as not materialized (select distinct (s2.n2).id as root_id from s2), s4(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, false, e2.start_id = e2.end_id, array [e2.id] from s4_seed join edge e2 on e2.start_id = s4_seed.root_id union all select s4.root_id, e2.end_id, s4.depth + 1, false, false, s4.path || e2.id from s4 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.start_id = s4.next_id and e2.id != all (s4.path) offset 0) e2 on true where s4.depth < 15 and not s4.is_cycle) select s2.e1 as e1, s2.ep0 as ep0, s2.n0 as n0, s2.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s2, s4 join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s4.root_id offset 0) n2 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s4.next_id offset 0) n3 on true where (s2.n2).id = s4.root_id and s2.e1 != all (s4.path)) select s3.n3 as l from s3; -- case: match p = (:NodeKind1)-[:EdgeKind1*1..]->(n:NodeKind2) where 'admin_tier_0' in split(n.system_tags, ' ') return p limit 1000 with s0 as (with recursive s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where ('admin_tier_0' = any (string_to_array((n1.properties ->> 'system_tags'), ' ')::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, n0.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id join node n0 on n0.id = e0.start_id where e0.kind_id = any (array [3]::int2[]) union all select s1.root_id, e0.start_id, s1.depth + 1, n0.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e0.id || s1.path from s1 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.end_id = s1.next_id and e0.id != all (s1.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n0 on n0.id = e0.start_id where s1.depth < 15 and not s1.is_cycle) select s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s1.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s1.next_id offset 0) n0 on true where s1.satisfied limit 1000) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (select coalesce(array_agg((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 limit 1000; @@ -75,11 +78,10 @@ with s0 as (with recursive s1_seed(root_id) as not materialized (select n1.id as with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where ((n0.properties ->> 'objectid') like '%-512') and n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, ((n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n1.kind_ids operator (pg_catalog.@>) array [2]::int2[])), e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id join node n1 on n1.id = e0.start_id where e0.kind_id = any (array [3]::int2[]) union all select s1.root_id, e0.start_id, s1.depth + 1, ((n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n1.kind_ids operator (pg_catalog.@>) array [2]::int2[])), false, s1.path || e0.id from s1 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.end_id = s1.next_id and e0.id != all (s1.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.start_id where s1.depth < 15 and not s1.is_cycle) select s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s1.root_id offset 0) n0 on true join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s1.next_id offset 0) n1 on true where s1.satisfied limit 1000) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (select coalesce(array_agg((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 limit 1000; -- case: match p=(n:NodeKind1)-[:EdgeKind1|EdgeKind2]->(g:NodeKind1)-[:EdgeKind2]->(:NodeKind2)-[:EdgeKind1*1..]->(m:NodeKind1) where n.objectid = m.objectid return p limit 100 -with s0 as (select e0.id as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[])), s1 as (select s0.e0 as e0, e1.id as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[]) and e1.id != s0.e0), s2 as (with recursive s3_seed(root_id) as not materialized (select distinct (s1.n2).id as root_id from s1), s3(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.start_id = e2.end_id, array [e2.id] from s3_seed join edge e2 on e2.start_id = s3_seed.root_id join node n3 on n3.id = e2.end_id where e2.kind_id = any (array [3]::int2[]) union all select s3.root_id, e2.end_id, s3.depth + 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, s3.path || e2.id from s3 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.start_id = s3.next_id and e2.id != all (s3.path) and e2.kind_id = any (array [3]::int2[]) offset 0) e2 on true join node n3 on n3.id = e2.end_id where s3.depth < 15 and not s3.is_cycle) select s1.e0 as e0, s1.e1 as e1, s3.path as ep0, s1.n0 as n0, s1.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s1, s3 join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s3.root_id offset 0) n2 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s3.next_id offset 0) n3 on true where s3.satisfied and (s1.n2).id = s3.root_id and (((s1.n0).properties -> 'objectid') = (n3.properties -> 'objectid')) limit 100) select case when (s2.n0).id is null or s2.e0 is null or (s2.n1).id is null or s2.e1 is null or (s2.n2).id is null or s2.ep0 is null or (s2.n3).id is null then null else ordered_edges_to_path(s2.n0, (select coalesce(array_agg((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(array [s2.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id) || (select coalesce(array_agg((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(array [s2.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id) || (select coalesce(array_agg((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s2.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1, s2.n2, s2.n3]::nodecomposite[])::pathcomposite end as p from s2 limit 100; +with s0 as (select e0.id as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[])), s1 as (select s0.e0 as e0, e1.id as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[]) and e1.id != s0.e0), s2 as (with recursive s3_seed(root_id) as not materialized (select distinct (s1.n2).id as root_id from s1), s3(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.start_id = e2.end_id, array [e2.id] from s3_seed join edge e2 on e2.start_id = s3_seed.root_id join node n3 on n3.id = e2.end_id where e2.kind_id = any (array [3]::int2[]) union all select s3.root_id, e2.end_id, s3.depth + 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, s3.path || e2.id from s3 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.start_id = s3.next_id and e2.id != all (s3.path) and e2.kind_id = any (array [3]::int2[]) offset 0) e2 on true join node n3 on n3.id = e2.end_id where s3.depth < 15 and not s3.is_cycle) select s1.e0 as e0, s1.e1 as e1, s3.path as ep0, s1.n0 as n0, s1.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s1, s3 join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s3.root_id offset 0) n2 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s3.next_id offset 0) n3 on true where s3.satisfied and (s1.n2).id = s3.root_id and (((s1.n0).properties -> 'objectid') = (n3.properties -> 'objectid')) and s1.e0 != all (s3.path) and s1.e1 != all (s3.path) limit 100) select case when (s2.n0).id is null or s2.e0 is null or (s2.n1).id is null or s2.e1 is null or (s2.n2).id is null or s2.ep0 is null or (s2.n3).id is null then null else ordered_edges_to_path(s2.n0, (select coalesce(array_agg((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(array [s2.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id) || (select coalesce(array_agg((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(array [s2.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id) || (select coalesce(array_agg((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s2.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1, s2.n2, s2.n3]::nodecomposite[])::pathcomposite end as p from s2 limit 100; -- case: match (a:NodeKind1)-[:EdgeKind1*0..]->(b:NodeKind1) where a.name = 'solo' and b.name = 'solo' return a.name, b.name with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = 'solo')) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select s1_seed.root_id, s1_seed.root_id, 0, ((jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'solo')) and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, array []::int8[] from s1_seed join node n1 on n1.id = s1_seed.root_id union all select e0.start_id, e0.end_id, 1, ((jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'solo')) and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.start_id = e0.end_id, array [e0.id] from s1_seed join edge e0 on e0.start_id = s1_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s1.root_id, e0.end_id, s1.depth + 1, ((jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'solo')) and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, s1.path || e0.id from s1 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.start_id = s1.next_id and e0.id != all (s1.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle and s1.depth > 0) select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s1.root_id offset 0) n0 on true join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s1.next_id offset 0) n1 on true where s1.satisfied) select ((s0.n0).properties -> 'name'), ((s0.n1).properties -> 'name') from s0; -- case: match (a:NodeKind1)-[:EdgeKind1*0..]->(b:NodeKind1) where a.name = 'zero-source' and b.name = 'zero-target' return count(b) with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = 'zero-source')) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select s1_seed.root_id, s1_seed.root_id, 0, ((jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'zero-target')) and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, array []::int8[] from s1_seed join node n1 on n1.id = s1_seed.root_id union all select e0.start_id, e0.end_id, 1, ((jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'zero-target')) and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.start_id = e0.end_id, array [e0.id] from s1_seed join edge e0 on e0.start_id = s1_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s1.root_id, e0.end_id, s1.depth + 1, ((jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'zero-target')) and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, s1.path || e0.id from s1 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.start_id = s1.next_id and e0.id != all (s1.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle and s1.depth > 0) select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s1.root_id offset 0) n0 on true join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s1.next_id offset 0) n1 on true where s1.satisfied) select count(s0.n1)::int8 from s0; - diff --git a/cypher/models/pgsql/translate/expansion.go b/cypher/models/pgsql/translate/expansion.go index c7d27587..8dabd95a 100644 --- a/cypher/models/pgsql/translate/expansion.go +++ b/cypher/models/pgsql/translate/expansion.go @@ -3108,6 +3108,11 @@ func (s *Translator) buildExpansionProjectionConstraints(traversalStepContext Tr ) } + // Exclude expansion paths that reuse a relationship consumed by a preceding fixed step. + if expansionModel.PreviousRelationshipUniqueness != nil { + projectionConstraints = pgsql.OptionalAnd(projectionConstraints, expansionModel.PreviousRelationshipUniqueness) + } + return projectionConstraints, nil } @@ -3141,6 +3146,11 @@ func (s *Translator) translateTraversalPatternPartWithExpansion(part *PatternPar expansionModel.Frame = expansionFrame } + // Enforce relationship uniqueness against any preceding fixed steps. The expansion's own path + // array already excludes edges reused within the recursion; this additionally excludes edges + // consumed by fixed steps that precede the expansion (e.g. after a pattern reversal). + expansionModel.PreviousRelationshipUniqueness = expansionPreviousRelationshipUniquenessConstraint(s.scope, part, stepIndex, traversalStep) + if expansionModel.TerminalNodeConstraints != nil { if terminalCriteriaProjection, err := pgsql.As[pgsql.SelectItem](expansionModel.TerminalNodeConstraints); err != nil { return err diff --git a/cypher/models/pgsql/translate/model.go b/cypher/models/pgsql/translate/model.go index 09e08c95..9315b327 100644 --- a/cypher/models/pgsql/translate/model.go +++ b/cypher/models/pgsql/translate/model.go @@ -66,6 +66,7 @@ type Expansion struct { PrimerNodeSatisfactionProjection pgsql.SelectItem PrimerNodeJoinCondition pgsql.Expression EdgeConstraints pgsql.Expression + PreviousRelationshipUniqueness pgsql.Expression EdgeJoinCondition pgsql.Expression RecursiveConstraints pgsql.Expression ExpansionNodeJoinCondition pgsql.Expression diff --git a/cypher/models/pgsql/translate/traversal.go b/cypher/models/pgsql/translate/traversal.go index 0bdfb500..8b4e0a0f 100644 --- a/cypher/models/pgsql/translate/traversal.go +++ b/cypher/models/pgsql/translate/traversal.go @@ -809,6 +809,37 @@ func previousRelationshipUniquenessConstraint(scope *Scope, part *PatternPart, s return constraint } +// expansionPreviousRelationshipUniquenessConstraint enforces Cypher relationship uniqueness for an +// expansion step against any preceding fixed steps. Where previousRelationshipUniquenessConstraint +// handles a fixed step that follows an expansion, this handles the mirrored ordering (a fixed step +// that precedes an expansion, e.g. after the optimizer reverses a pattern) by requiring that none +// of the preceding fixed relationships appear in the expansion's accumulated path. +func expansionPreviousRelationshipUniquenessConstraint(scope *Scope, part *PatternPart, stepIndex int, traversalStep *TraversalStep) pgsql.Expression { + if scope == nil || part == nil || stepIndex <= 0 || traversalStep == nil || + traversalStep.Expansion == nil || traversalStep.Expansion.Frame == nil { + return nil + } + + var ( + pathIDs = pgsql.CompoundIdentifier{traversalStep.Expansion.Frame.Binding.Identifier, expansionPath} + + constraint pgsql.Expression + ) + + for _, previousStep := range part.TraversalSteps[:stepIndex] { + if previousStep == nil || previousStep.Edge == nil || previousStep.Expansion != nil { + continue + } + + constraint = pgsql.OptionalAnd( + constraint, + relationshipIDNotInPath(relationshipIDReference(scope, previousStep.Edge), pathIDs), + ) + } + + return constraint +} + func (s *Translator) projectionPruningDecision(part *PatternPart, stepIndex int) (optimize.ProjectionPruningDecision, bool) { target, hasTarget := sourceTargetForTraversalStep(part, stepIndex) if !hasTarget {