From 8de104718f6596b3fd526df9a41a049986b0c68c Mon Sep 17 00:00:00 2001 From: Sean Johnson Date: Tue, 9 Jun 2026 12:27:17 -0500 Subject: [PATCH 1/5] fix: exhaust all bindings in OptimizedRelationshipExist builder --- cypher/models/pgsql/model.go | 12 +- cypher/models/pgsql/translate/predicate.go | 103 +++++++++----- integration/testdata/bed8320.json | 128 ++++++++++++++++++ ...timized_undirected_pattern_predicates.json | 127 +++++++++++++++++ 4 files changed, 330 insertions(+), 40 deletions(-) create mode 100644 integration/testdata/bed8320.json create mode 100644 integration/testdata/cases/bed8320-optimized_undirected_pattern_predicates.json diff --git a/cypher/models/pgsql/model.go b/cypher/models/pgsql/model.go index 54854c50..5c7096f4 100644 --- a/cypher/models/pgsql/model.go +++ b/cypher/models/pgsql/model.go @@ -264,9 +264,7 @@ func (s Future[U]) TypeHint() DataType { } func (s Future[U]) NodeType() string { - var ( - emptyU U - ) + var emptyU U return fmt.Sprintf("syntax_node_future[%T]", emptyU) } @@ -1297,3 +1295,11 @@ func OptionalAnd(leftOperand Expression, rightOperand Expression) Expression { return NewBinaryExpression(leftOperand, OperatorAnd, rightOperand) } + +func OptionalParenthetical(optional Expression) Expression { + if optional == nil { + return nil + } + + return NewParenthetical(optional) +} diff --git a/cypher/models/pgsql/translate/predicate.go b/cypher/models/pgsql/translate/predicate.go index c233aac6..9aac4fa6 100644 --- a/cypher/models/pgsql/translate/predicate.go +++ b/cypher/models/pgsql/translate/predicate.go @@ -28,51 +28,80 @@ func (s *Translator) preparePatternPredicate(predicate *cypher.PatternPredicate) } func (s *Translator) buildOptimizedRelationshipExistPredicate(part *PatternPart, traversalStep *TraversalStep) (pgsql.Expression, error) { - var whereClause pgsql.Expression = pgsql.NewBinaryExpression( - pgsql.NewBinaryExpression( - pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnStartID}, - pgsql.OperatorEquals, - pgsql.CompoundIdentifier{traversalStep.LeftNode.Identifier, pgsql.ColumnID}), - pgsql.OperatorOr, - pgsql.NewBinaryExpression( - pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnEndID}, - pgsql.OperatorEquals, - pgsql.CompoundIdentifier{traversalStep.LeftNode.Identifier, pgsql.ColumnID}), - ) - - if traversalStep.RightNodeBound { - var ( - forward = pgsql.NewBinaryExpression( + var whereClause pgsql.Expression + if traversalStep.LeftNodeBound && traversalStep.RightNodeBound { + // Pair-wise bounds on the directionless relationship + whereClause = pgsql.NewBinaryExpression( + pgsql.NewParenthetical( pgsql.NewBinaryExpression( - pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnStartID}, - pgsql.OperatorEquals, - pgsql.CompoundIdentifier{traversalStep.LeftNode.Identifier, pgsql.ColumnID}), - pgsql.OperatorAnd, - pgsql.NewBinaryExpression( - pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnEndID}, - pgsql.OperatorEquals, - pgsql.CompoundIdentifier{traversalStep.RightNode.Identifier, pgsql.ColumnID}), - ) - reverse = pgsql.NewBinaryExpression( - pgsql.NewBinaryExpression( - pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnEndID}, - pgsql.OperatorEquals, - pgsql.CompoundIdentifier{traversalStep.LeftNode.Identifier, pgsql.ColumnID}), - pgsql.OperatorAnd, + pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnStartID}, + pgsql.OperatorEquals, + pgsql.CompoundIdentifier{traversalStep.LeftNode.Identifier, pgsql.ColumnID}, + ), + pgsql.OperatorAnd, + pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnEndID}, + pgsql.OperatorEquals, + pgsql.CompoundIdentifier{traversalStep.RightNode.Identifier, pgsql.ColumnID}, + ), + ), + ), + pgsql.OperatorOr, + pgsql.NewParenthetical( pgsql.NewBinaryExpression( - pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnStartID}, - pgsql.OperatorEquals, - pgsql.CompoundIdentifier{traversalStep.RightNode.Identifier, pgsql.ColumnID}), - ) + pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnStartID}, + pgsql.OperatorEquals, + pgsql.CompoundIdentifier{traversalStep.RightNode.Identifier, pgsql.ColumnID}, + ), + pgsql.OperatorAnd, + pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnEndID}, + pgsql.OperatorEquals, + pgsql.CompoundIdentifier{traversalStep.LeftNode.Identifier, pgsql.ColumnID}, + ), + ), + ), ) - - whereClause = pgsql.NewBinaryExpression(forward, pgsql.OperatorOr, reverse) + } else if traversalStep.RightNodeBound { + whereClause = pgsql.NewBinaryExpression( + pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnStartID}, + pgsql.OperatorEquals, + pgsql.CompoundIdentifier{traversalStep.RightNode.Identifier, pgsql.ColumnID}, + ), + pgsql.OperatorOr, + pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnEndID}, + pgsql.OperatorEquals, + pgsql.CompoundIdentifier{traversalStep.RightNode.Identifier, pgsql.ColumnID}, + ), + ) + } else if traversalStep.LeftNodeBound { + whereClause = pgsql.NewBinaryExpression( + pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnStartID}, + pgsql.OperatorEquals, + pgsql.CompoundIdentifier{traversalStep.LeftNode.Identifier, pgsql.ColumnID}, + ), + pgsql.OperatorOr, + pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnEndID}, + pgsql.OperatorEquals, + pgsql.CompoundIdentifier{traversalStep.LeftNode.Identifier, pgsql.ColumnID}, + ), + ) + } else { + // Neither side of the traversal is bound, so this becomes a "select everything from edges" + whereClause = nil } + // Pull edge constraints in to the exists check if constraint, err := s.treeTranslator.ConsumeConstraintsFromVisibleSet(pgsql.AsIdentifierSet(traversalStep.Edge.Identifier)); err != nil { return nil, err } else { - whereClause = pgsql.OptionalAnd(constraint.Expression, pgsql.NewParenthetical(whereClause)) + whereClause = pgsql.OptionalAnd(constraint.Expression, pgsql.OptionalParenthetical(whereClause)) } if err := RewriteFrameBindings(s.scope, whereClause); err != nil { diff --git a/integration/testdata/bed8320.json b/integration/testdata/bed8320.json new file mode 100644 index 00000000..51d2b447 --- /dev/null +++ b/integration/testdata/bed8320.json @@ -0,0 +1,128 @@ +{ + "graph": { + "nodes": [ + { + "id": "key_admins_empty", + "kinds": [ + "NodeKind1" + ], + "properties": { + "name": "BED-6695 KEY ADMINS EMPTY" + } + }, + { + "id": "key_admins_membered", + "kinds": [ + "NodeKind1" + ], + "properties": { + "name": "BED-6695 KEY ADMINS MEMBERED" + } + }, + { + "id": "member_user", + "kinds": [ + "NodeKind2" + ], + "properties": { + "name": "BED-6695 MEMBER USER" + } + }, + { + "id": "u1", + "kinds": [ + "NodeKind1" + ], + "properties": { + "name": "User A" + } + }, + { + "id": "u2", + "kinds": [ + "NodeKind1" + ], + "properties": { + "name": "User B" + } + }, + { + "id": "g1", + "kinds": [ + "NodeKind2" + ], + "properties": { + "name": "KEY ADMINS ALPHA" + } + }, + { + "id": "g2", + "kinds": [ + "NodeKind2" + ], + "properties": { + "name": "KEY ADMINS BETA" + } + }, + { + "id": "g3", + "kinds": [ + "NodeKind2" + ], + "properties": { + "name": "OPERATORS" + } + }, + { + "id": "g4", + "kinds": [ + "NodeKind2" + ], + "properties": { + "name": "KEY ADMINS GAMMA" + } + }, + { + "id": "g5", + "kinds": [ + "NodeKind2" + ], + "properties": { + "name": "KEY ADMINS DELTA" + } + } + ], + "edges": [ + { + "start_id": "member_user", + "end_id": "key_admins_membered", + "kind": "EdgeKind1" + }, + { + "start_id": "u1", + "end_id": "g2", + "kind": "EdgeKind1" + }, + { + "start_id": "u2", + "end_id": "g3", + "kind": "EdgeKind1" + }, + { + "start_id": "g3", + "end_id": "g1", + "kind": "EdgeKind1" + }, + { + "start_id": "u1", + "end_id": "g1", + "kind": "EdgeKind2" + }, + { + "start_id": "u2", + "end_id": "g5", + "kind": "EdgeKind2" + } + ] + } +} diff --git a/integration/testdata/cases/bed8320-optimized_undirected_pattern_predicates.json b/integration/testdata/cases/bed8320-optimized_undirected_pattern_predicates.json new file mode 100644 index 00000000..8cb9733e --- /dev/null +++ b/integration/testdata/cases/bed8320-optimized_undirected_pattern_predicates.json @@ -0,0 +1,127 @@ +{ + "dataset": "bed8320", + "cases": [ + { + "name": "BED-8320 optimized negated undirected predicate remains row-correlated from left-bound node", + "cypher": "match (g:NodeKind2) where not ((g)--()) return g.name", + "assert": { + "scalar_values": [ + "KEY ADMINS GAMMA" + ] + } + }, + { + "name": "BED-8320 optimized negated undirected predicate remains row-correlated from right-bound node", + "cypher": "match (g:NodeKind2) where not (()--(g)) return g.name", + "assert": { + "scalar_values": [ + "KEY ADMINS GAMMA" + ] + } + }, + { + "name": "BED-8320 optimized positive undirected predicate keeps incident nodes", + "cypher": "match (g:NodeKind2) where (g)--() return count(g)", + "assert": { + "exact_int": 5 + } + }, + { + "name": "BED-8320 optimized both-bound undirected predicate finds connected pair", + "cypher": "match (a:NodeKind1), (b:NodeKind2) where a.name = 'User A' and b.name = 'KEY ADMINS BETA' and (a)--(b) return count(a)", + "assert": { + "exact_int": 1 + } + }, + { + "name": "BED-8320 optimized both-bound undirected predicate rejects disconnected pair", + "cypher": "match (a:NodeKind1), (b:NodeKind2) where a.name = 'User A' and b.name = 'KEY ADMINS GAMMA' and (a)--(b) return count(a)", + "assert": { + "exact_int": 0 + } + }, + { + "name": "BED-8320 optimized unbound undirected predicate is true when any relationship exists", + "cypher": "match (g:NodeKind2) where ()--() return count(g)", + "assert": { + "exact_int": 6 + } + }, + { + "name": "BED-8320 optimized negated unbound undirected predicate is false when any relationship exists", + "cypher": "match (g:NodeKind2) where not (()--()) return count(g)", + "assert": { + "exact_int": 0 + } + }, + { + "name": "BED-8320 optimized typed undirected predicate keeps EdgeKind1 incident nodes from left-bound node", + "cypher": "match (g:NodeKind2) where (g)-[:EdgeKind1]-() return count(g)", + "assert": { + "exact_int": 4 + } + }, + { + "name": "BED-8320 optimized typed undirected predicate keeps EdgeKind1 incident nodes from right-bound node", + "cypher": "match (g:NodeKind2) where ()-[:EdgeKind1]-(g) return count(g)", + "assert": { + "exact_int": 4 + } + }, + { + "name": "BED-8320 optimized negated typed undirected predicate excludes EdgeKind1 incident nodes from right-bound node", + "cypher": "match (g:NodeKind2) where not (()-[:EdgeKind1]-(g)) return g.name", + "assert": { + "scalar_values": [ + "KEY ADMINS DELTA", + "KEY ADMINS GAMMA" + ] + } + }, + { + "name": "BED-8320 optimized negated typed undirected predicate excludes EdgeKind1 incident nodes from left-bound node", + "cypher": "match (g:NodeKind2) where not ((g)-[:EdgeKind1]-()) return g.name", + "assert": { + "scalar_values": [ + "KEY ADMINS DELTA", + "KEY ADMINS GAMMA" + ] + } + }, + { + "name": "BED-8320 optimized typed both-bound undirected predicate finds connected pair", + "cypher": "match (a:NodeKind1), (b:NodeKind2) where a.name = 'User A' and b.name = 'KEY ADMINS BETA' and (a)-[:EdgeKind1]-(b) return count(a)", + "assert": { + "exact_int": 1 + } + }, + { + "name": "BED-8320 optimized typed both-bound undirected predicate rejects wrong edge kind", + "cypher": "match (a:NodeKind1), (b:NodeKind2) where a.name = 'User A' and b.name = 'KEY ADMINS ALPHA' and (a)-[:EdgeKind1]-(b) return count(a)", + "assert": { + "exact_int": 0 + } + }, + { + "name": "BED-8320 optimized typed both-bound undirected predicate rejects disconnected pair", + "cypher": "match (a:NodeKind1), (b:NodeKind2) where a.name = 'User A' and b.name = 'KEY ADMINS GAMMA' and (a)-[:EdgeKind1]-(b) return count(a)", + "assert": { + "exact_int": 0 + } + }, + { + "name": "BED-8320 optimized typed unbound undirected predicate is true when any matching relationship exists", + "cypher": "match (g:NodeKind2) where ()-[:EdgeKind1]-() return count(g)", + "assert": { + "exact_int": 6 + } + }, + { + "name": "BED-8320 optimized negated typed unbound undirected predicate is false when matching relationships exist", + "cypher": "match (g:NodeKind2) where not (()-[:EdgeKind1]-()) return count(g)", + "assert": { + "exact_int": 0 + } + } + ] +} From 8f559b6ef0566226fc4d863dd34d2a51fe8a435c Mon Sep 17 00:00:00 2001 From: Sean Johnson Date: Tue, 16 Jun 2026 15:48:00 -0500 Subject: [PATCH 2/5] fix: exhaust all bindings in buildDirectionlessTraversalRoot --- cypher/models/pgsql/translate/predicate.go | 2 +- cypher/models/pgsql/translate/traversal.go | 140 +------ .../translate/traversal_directionless.go | 381 ++++++++++++++++++ 3 files changed, 383 insertions(+), 140 deletions(-) create mode 100644 cypher/models/pgsql/translate/traversal_directionless.go diff --git a/cypher/models/pgsql/translate/predicate.go b/cypher/models/pgsql/translate/predicate.go index 9aac4fa6..642b1651 100644 --- a/cypher/models/pgsql/translate/predicate.go +++ b/cypher/models/pgsql/translate/predicate.go @@ -220,7 +220,7 @@ func (s *Translator) buildPatternPredicates() error { traversalStepQuery pgsql.Query err error ) - if traversalStep.Direction != graph.DirectionBoth && (traversalStep.LeftNodeBound || traversalStep.RightNodeBound) { + if traversalStep.LeftNodeBound || traversalStep.RightNodeBound { traversalStepQuery, err = s.buildTraversalPatternRootWithOuterCorrelation(traversalStep.Frame, traversalStep) } else { traversalStepQuery, err = s.buildTraversalPatternRoot(traversalStep.Frame, traversalStep) diff --git a/cypher/models/pgsql/translate/traversal.go b/cypher/models/pgsql/translate/traversal.go index 25b133c6..07495d53 100644 --- a/cypher/models/pgsql/translate/traversal.go +++ b/cypher/models/pgsql/translate/traversal.go @@ -218,149 +218,11 @@ func (s *Translator) buildBoundEndpointTraversalPattern(partFrame *Frame, traver }, nil } -func (s *Translator) buildDirectionlessTraversalPatternRoot(traversalStep *TraversalStep) (pgsql.Query, error) { - if traversalStep.UseExpandInto { - return s.buildBoundEndpointTraversalPattern(traversalStep.Frame, traversalStep) - } - - var ( - // Partition node constraints - rightJoinLocal, rightJoinExternal = partitionConstraintByLocality( - traversalStep.RightNodeConstraints, - pgsql.AsIdentifierSet(traversalStep.RightNode.Identifier, traversalStep.Edge.Identifier), - ) - - leftJoinLocal, leftJoinExternal = partitionConstraintByLocality( - traversalStep.LeftNodeConstraints, - pgsql.AsIdentifierSet(traversalStep.LeftNode.Identifier, traversalStep.Edge.Identifier), - ) - - nextSelect = pgsql.Select{ - Projection: traversalStep.Projection, - } - ) - - if traversalStep.LeftNodeBound { - if traversalStep.Frame.Previous == nil { - return pgsql.Query{}, fmt.Errorf("left node is marked as bound but there is no previous frame to reference") - } - - // Left node was already materialized in the previous frame. Promote that frame and join only the terminal node here. - // - // prevFrame is the join root so LeftNodeConstraints can safely reference it in the edge ON clause without partitioning. - nextSelect.From = append(nextSelect.From, pgsql.FromClause{ - Source: pgsql.TableReference{ - Name: pgsql.CompoundIdentifier{traversalStep.Frame.Previous.Binding.Identifier}, - }, - Joins: []pgsql.Join{{ - Table: pgsql.TableReference{ - Name: pgsql.CompoundIdentifier{pgsql.TableEdge}, - Binding: models.OptionalValue(traversalStep.Edge.Identifier), - }, - JoinOperator: pgsql.JoinOperator{ - JoinType: pgsql.JoinTypeInner, - Constraint: pgsql.OptionalAnd(traversalStep.LeftNodeConstraints, traversalStep.LeftNodeJoinCondition), - }, - }, { - Table: pgsql.TableReference{ - Name: pgsql.CompoundIdentifier{pgsql.TableNode}, - Binding: models.OptionalValue(traversalStep.RightNode.Identifier), - }, - JoinOperator: pgsql.JoinOperator{ - JoinType: pgsql.JoinTypeInner, - Constraint: pgsql.OptionalAnd(rightJoinLocal, traversalStep.RightNodeJoinCondition), - }, - }}, - }) - - nextSelect.Where = pgsql.OptionalAnd(traversalStep.EdgeConstraints.Expression, nextSelect.Where) - nextSelect.Where = pgsql.OptionalAnd(rightJoinExternal, nextSelect.Where) - - // left node is not joined here, so the guard must reference the bound node through the previous frame - nextSelect.Where = pgsql.OptionalAnd( - pgsql.NewParenthetical( - pgsql.NewBinaryExpression( - pgsql.RowColumnReference{ - Identifier: pgsql.CompoundIdentifier{ - traversalStep.Frame.Previous.Binding.Identifier, - traversalStep.LeftNode.Identifier, - }, - Column: pgsql.ColumnID, - }, - pgsql.OperatorCypherNotEquals, - pgsql.CompoundIdentifier{traversalStep.RightNode.Identifier, pgsql.ColumnID}, - ), - ), - nextSelect.Where, - ) - - return pgsql.Query{ - Body: nextSelect, - }, nil - } - - if previousFrame, hasPrevious := s.previousValidFrame(traversalStep.Frame); hasPrevious { - nextSelect.From = append(nextSelect.From, pgsql.FromClause{ - Source: pgsql.TableReference{ - Name: pgsql.CompoundIdentifier{previousFrame.Binding.Identifier}, - }, - }) - } - - nextSelect.From = append(nextSelect.From, pgsql.FromClause{ - Source: pgsql.TableReference{ - Name: pgsql.CompoundIdentifier{pgsql.TableEdge}, - Binding: models.OptionalValue(traversalStep.Edge.Identifier), - }, - Joins: []pgsql.Join{{ - Table: pgsql.TableReference{ - Name: pgsql.CompoundIdentifier{pgsql.TableNode}, - Binding: models.OptionalValue(traversalStep.LeftNode.Identifier), - }, - JoinOperator: pgsql.JoinOperator{ - JoinType: pgsql.JoinTypeInner, - Constraint: pgsql.OptionalAnd(leftJoinLocal, traversalStep.LeftNodeJoinCondition), - }, - }, { - Table: pgsql.TableReference{ - Name: pgsql.CompoundIdentifier{pgsql.TableNode}, - Binding: models.OptionalValue(traversalStep.RightNode.Identifier), - }, - JoinOperator: pgsql.JoinOperator{ - JoinType: pgsql.JoinTypeInner, - Constraint: pgsql.OptionalAnd(rightJoinLocal, traversalStep.RightNodeJoinCondition), - }, - }}, - }) - - // For an inner join, PostgreSQL's optimizer can push start and end predicates into the join if they're part - // of the where clause below, but it requires additional planning work and may not do so reliably when multiple - // CTEs are involved or the planner's cost model is off. - // - // Emitting them directly in the JOIN ON constraint makes the intent unambiguous and enables the planner to - // apply the GIN kind index during the join, before materializing the intermediate result. - nextSelect.Where = pgsql.OptionalAnd(leftJoinExternal, nextSelect.Where) - nextSelect.Where = pgsql.OptionalAnd(traversalStep.EdgeConstraints.Expression, nextSelect.Where) - nextSelect.Where = pgsql.OptionalAnd(rightJoinExternal, nextSelect.Where) - - // AND (n0.id <> n1.id) - ensures edges are properly constrained to the specified nodes - nextSelect.Where = pgsql.OptionalAnd( - pgsql.NewParenthetical( - pgsql.NewBinaryExpression( - pgsql.CompoundIdentifier{traversalStep.LeftNode.Identifier, pgsql.ColumnID}, - pgsql.OperatorCypherNotEquals, - pgsql.CompoundIdentifier{traversalStep.RightNode.Identifier, pgsql.ColumnID})), - nextSelect.Where) - return pgsql.Query{ - Body: nextSelect, - }, nil -} - // buildTraversalPatternRootWithOuterCorrelation constructs a traversal pattern root, preserving the correlation to // the outer query part's context func (s *Translator) buildTraversalPatternRootWithOuterCorrelation(partFrame *Frame, traversalStep *TraversalStep) (pgsql.Query, error) { if traversalStep.Direction == graph.DirectionBoth { - return s.buildDirectionlessTraversalPatternRoot(traversalStep) + return s.buildDirectionlessTraversalPatternRootWithOuterCorrelation(traversalStep) } var ( diff --git a/cypher/models/pgsql/translate/traversal_directionless.go b/cypher/models/pgsql/translate/traversal_directionless.go new file mode 100644 index 00000000..d41d6c1e --- /dev/null +++ b/cypher/models/pgsql/translate/traversal_directionless.go @@ -0,0 +1,381 @@ +package translate + +import ( + "fmt" + + "github.com/specterops/dawgs/cypher/models" + "github.com/specterops/dawgs/cypher/models/pgsql" +) + +// +// DIRECTIONLESS TRAVERSALS WITHOUT OUTER CORRELATION +// + +// buildDirectionlessTraversalPatternRoot constructs query parts covering an undirected traversal without taking outer +// correlation into consideration for situations where an outer correlation is not necessary (ie., MATCHing on an undirected traversal pattern, +// traversal patterns where neither endpoint is bound). +func (s *Translator) buildDirectionlessTraversalPatternRoot(traversalStep *TraversalStep) (pgsql.Query, error) { + if traversalStep.UseExpandInto { + return s.buildBoundEndpointTraversalPattern(traversalStep.Frame, traversalStep) + } + + if traversalStep.LeftNodeBound && traversalStep.RightNodeBound { + // Both sides are bound, build a strict pairwise join on the edge + return s.buildPairwiseDirectionlessTraversalPatternRoot(traversalStep) + } else if traversalStep.LeftNodeBound || traversalStep.RightNodeBound { + // One of the traversal step nodes is bound by the outer query, so + // generate internal constraints to "bind" the inner and outer queries + return s.buildSingleBoundDirectionlessTraversalRoot(traversalStep) + } + + return s.buildUnboundDirectionlessTraversalPatternRoot(traversalStep) +} + +func buildDirectionlessPairwiseEdgeConstraint(traversalStep *TraversalStep) pgsql.Expression { + prevFrame := traversalStep.Frame.Previous + + // ((sN.nLeft).id = (eN).start_id AND (sN.nRight).id = (eN).end_id) + leftToRight := pgsql.NewParenthetical( + pgsql.NewBinaryExpression( + pgsql.NewBinaryExpression( + // (sN.nLeft).id + boundEndpointIDReference(prevFrame, traversalStep.LeftNode), + pgsql.OperatorEquals, + // (eN).start_id + pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnStartID}, + ), + pgsql.OperatorAnd, + pgsql.NewBinaryExpression( + // (sN.nRight).id + boundEndpointIDReference(prevFrame, traversalStep.RightNode), + pgsql.OperatorEquals, + // (eN).end_id + pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnEndID}, + ), + ), + ) + + // ((sN.nRight).id = (eN).start_id AND (sN.nLeft).id = (eN).end_id) + rightToLeft := pgsql.NewParenthetical( + pgsql.NewBinaryExpression( + pgsql.NewBinaryExpression( + // (sN.nRight).id + boundEndpointIDReference(prevFrame, traversalStep.RightNode), + pgsql.OperatorEquals, + // (eN).start_id + pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnStartID}, + ), + pgsql.OperatorAnd, + pgsql.NewBinaryExpression( + // (sN.nLeft).id + boundEndpointIDReference(prevFrame, traversalStep.LeftNode), + pgsql.OperatorEquals, + // (eN).end_id + pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnEndID}, + ), + ), + ) + + return pgsql.NewParenthetical( + pgsql.NewBinaryExpression( + leftToRight, pgsql.OperatorOr, rightToLeft, + ), + ) +} + +func (s *Translator) buildPairwiseDirectionlessTraversalPatternRoot(traversalStep *TraversalStep) (pgsql.Query, error) { + var ( + // Partition node constraints + _, rightJoinExternal = partitionConstraintByLocality( + traversalStep.RightNodeConstraints, + pgsql.AsIdentifierSet(traversalStep.RightNode.Identifier, traversalStep.Edge.Identifier), + ) + + _, leftJoinExternal = partitionConstraintByLocality( + traversalStep.LeftNodeConstraints, + pgsql.AsIdentifierSet(traversalStep.LeftNode.Identifier, traversalStep.Edge.Identifier), + ) + + nextSelect = pgsql.Select{ + Projection: traversalStep.Projection, + } + ) + + pairwiseEdgeConstraint := buildDirectionlessPairwiseEdgeConstraint(traversalStep) + nextSelect.From = append(nextSelect.From, pgsql.FromClause{ + Source: pgsql.TableReference{ + Name: pgsql.CompoundIdentifier{traversalStep.Frame.Previous.Binding.Identifier}, + }, + Joins: []pgsql.Join{{ + Table: pgsql.TableReference{ + Name: pgsql.CompoundIdentifier{pgsql.TableEdge}, + Binding: models.OptionalValue(traversalStep.Edge.Identifier), + }, + JoinOperator: pgsql.JoinOperator{ + JoinType: pgsql.JoinTypeInner, + Constraint: pairwiseEdgeConstraint, + }, + }}, + }) + + // Dual-bound: both endpoint external constraints apply. + nextSelect.Where = pgsql.OptionalAnd(traversalStep.EdgeConstraints.Expression, nextSelect.Where) + nextSelect.Where = pgsql.OptionalAnd(leftJoinExternal, nextSelect.Where) + nextSelect.Where = pgsql.OptionalAnd(rightJoinExternal, nextSelect.Where) + + // Only apply endpoint inequality when the bound nodes are different, to allow for self-referential relationships + if traversalStep.LeftNode.Identifier != traversalStep.RightNode.Identifier { + nextSelect.Where = pgsql.OptionalAnd(boundEndpointInequality(traversalStep.Frame.Previous, traversalStep), nextSelect.Where) + } + + return pgsql.Query{Body: nextSelect}, nil +} + +// buildUnboundDirectionlessTraversalPatternRoot builds a traversal pattern for an UNBOUND path predicate +func (s *Translator) buildUnboundDirectionlessTraversalPatternRoot(traversalStep *TraversalStep) (pgsql.Query, error) { + var ( + // Partition node constraints + rightJoinLocal, rightJoinExternal = partitionConstraintByLocality( + traversalStep.RightNodeConstraints, + pgsql.AsIdentifierSet(traversalStep.RightNode.Identifier, traversalStep.Edge.Identifier), + ) + + leftJoinLocal, leftJoinExternal = partitionConstraintByLocality( + traversalStep.LeftNodeConstraints, + pgsql.AsIdentifierSet(traversalStep.LeftNode.Identifier, traversalStep.Edge.Identifier), + ) + + nextSelect = pgsql.Select{ + Projection: traversalStep.Projection, + } + ) + + if previousFrame, hasPrevious := s.previousValidFrame(traversalStep.Frame); hasPrevious { + nextSelect.From = append(nextSelect.From, pgsql.FromClause{ + Source: pgsql.TableReference{ + Name: pgsql.CompoundIdentifier{previousFrame.Binding.Identifier}, + }, + }) + } + + nextSelect.From = append(nextSelect.From, pgsql.FromClause{ + Source: pgsql.TableReference{ + Name: pgsql.CompoundIdentifier{pgsql.TableEdge}, + Binding: models.OptionalValue(traversalStep.Edge.Identifier), + }, + Joins: []pgsql.Join{{ + Table: pgsql.TableReference{ + Name: pgsql.CompoundIdentifier{pgsql.TableNode}, + Binding: models.OptionalValue(traversalStep.LeftNode.Identifier), + }, + JoinOperator: pgsql.JoinOperator{ + JoinType: pgsql.JoinTypeInner, + Constraint: pgsql.OptionalAnd(leftJoinLocal, traversalStep.LeftNodeJoinCondition), + }, + }, { + Table: pgsql.TableReference{ + Name: pgsql.CompoundIdentifier{pgsql.TableNode}, + Binding: models.OptionalValue(traversalStep.RightNode.Identifier), + }, + JoinOperator: pgsql.JoinOperator{ + JoinType: pgsql.JoinTypeInner, + Constraint: pgsql.OptionalAnd(rightJoinLocal, traversalStep.RightNodeJoinCondition), + }, + }}, + }) + + // For an inner join, PostgreSQL's optimizer can push start and end predicates into the join if they're part + // of the where clause below, but it requires additional planning work and may not do so reliably when multiple + // CTEs are involved or the planner's cost model is off. + // + // Emitting them directly in the JOIN ON constraint makes the intent unambiguous and enables the planner to + // apply the GIN kind index during the join, before materializing the intermediate result. + nextSelect.Where = pgsql.OptionalAnd(leftJoinExternal, nextSelect.Where) + nextSelect.Where = pgsql.OptionalAnd(traversalStep.EdgeConstraints.Expression, nextSelect.Where) + nextSelect.Where = pgsql.OptionalAnd(rightJoinExternal, nextSelect.Where) + + // AND (n0.id <> n1.id) - ensures edges are properly constrained to the specified nodes + nextSelect.Where = pgsql.OptionalAnd( + pgsql.NewParenthetical( + pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{traversalStep.LeftNode.Identifier, pgsql.ColumnID}, + pgsql.OperatorCypherNotEquals, + pgsql.CompoundIdentifier{traversalStep.RightNode.Identifier, pgsql.ColumnID})), + nextSelect.Where) + return pgsql.Query{ + Body: nextSelect, + }, nil +} + +// buildSingleBoundDirectionlessTraversalRoot checks that the bound previous frame exists and generates the binding +// parameters necessary to generate the SQL query for a single-bound undirected traversal. +func (s *Translator) buildSingleBoundDirectionlessTraversalRoot(traversalStep *TraversalStep) (pgsql.Query, error) { + referenceFrame := traversalStep.Frame + previousFrame, hasPreviousFrame := s.previousValidFrame(referenceFrame) + + // If left node is bound and there is no previous frame, this is a bug in the bounds generation + if traversalStep.LeftNodeBound && !hasPreviousFrame { + return pgsql.Query{}, fmt.Errorf("left node is marked as bound but there is no previous frame to reference") + } + + // Special-case for self-referential right-bound form + if traversalStep.RightNodeBound && !hasPreviousFrame { + return s.buildSelfReferentialDirectionlessTraversalRoot(traversalStep) + } + + var ( + // Partition node constraints + rightJoinLocal, rightJoinExternal = partitionConstraintByLocality( + traversalStep.RightNodeConstraints, + pgsql.AsIdentifierSet(traversalStep.RightNode.Identifier, traversalStep.Edge.Identifier), + ) + + leftJoinLocal, leftJoinExternal = partitionConstraintByLocality( + traversalStep.LeftNodeConstraints, + pgsql.AsIdentifierSet(traversalStep.LeftNode.Identifier, traversalStep.Edge.Identifier), + ) + + edgeJoinConstraint pgsql.Expression + nodeJoinConstraint pgsql.Expression + whereConstraint pgsql.Expression + nodeJoinBinding pgsql.Identifier + boundNodeIdentifier pgsql.Identifier + unboundNodeIdentifier pgsql.Identifier + + pivotEdgeIdentifier = traversalStep.Edge.Identifier + projection = traversalStep.Projection + ) + + if traversalStep.LeftNodeBound { + edgeJoinConstraint = pgsql.OptionalAnd(traversalStep.LeftNodeConstraints, traversalStep.LeftNodeJoinCondition) + nodeJoinBinding = traversalStep.RightNode.Identifier + nodeJoinConstraint = pgsql.OptionalAnd(rightJoinLocal, traversalStep.RightNodeJoinCondition) + whereConstraint = pgsql.OptionalAnd(rightJoinExternal, traversalStep.EdgeConstraints.Expression) + boundNodeIdentifier = traversalStep.LeftNode.Identifier + unboundNodeIdentifier = traversalStep.RightNode.Identifier + } else if traversalStep.RightNodeBound { + edgeJoinConstraint = pgsql.OptionalAnd(traversalStep.RightNodeConstraints, traversalStep.RightNodeJoinCondition) + nodeJoinBinding = traversalStep.LeftNode.Identifier + nodeJoinConstraint = pgsql.OptionalAnd(leftJoinLocal, traversalStep.LeftNodeJoinCondition) + whereConstraint = pgsql.OptionalAnd(leftJoinExternal, traversalStep.EdgeConstraints.Expression) + boundNodeIdentifier = traversalStep.RightNode.Identifier + unboundNodeIdentifier = traversalStep.LeftNode.Identifier + } + + nextSelect := pgsql.Select{ + Projection: projection, + } + + // The selected node was already materialized in the previous frame. Promote that frame and join only the terminal node here. + // + // prevFrame is the join root so NodeConstraints can safely reference it in the edge ON clause without partitioning. + nextSelect.From = append(nextSelect.From, pgsql.FromClause{ + Source: pgsql.TableReference{ + Name: pgsql.CompoundIdentifier{previousFrame.Binding.Identifier}, + }, + Joins: []pgsql.Join{{ + Table: pgsql.TableReference{ + Name: pgsql.CompoundIdentifier{pgsql.TableEdge}, + Binding: models.OptionalValue(pivotEdgeIdentifier), + }, + JoinOperator: pgsql.JoinOperator{ + JoinType: pgsql.JoinTypeInner, + Constraint: edgeJoinConstraint, + }, + }, { + Table: pgsql.TableReference{ + Name: pgsql.CompoundIdentifier{pgsql.TableNode}, + Binding: models.OptionalValue(nodeJoinBinding), + }, + JoinOperator: pgsql.JoinOperator{ + JoinType: pgsql.JoinTypeInner, + Constraint: nodeJoinConstraint, + }, + }}, + }) + + nextSelect.Where = whereConstraint + + // selected node is not joined here, so the guard must reference the bound node through the previous frame + nextSelect.Where = pgsql.OptionalAnd( + pgsql.NewParenthetical( + pgsql.NewBinaryExpression( + pgsql.RowColumnReference{ + Identifier: pgsql.CompoundIdentifier{previousFrame.Binding.Identifier, boundNodeIdentifier}, + Column: pgsql.ColumnID, + }, + pgsql.OperatorCypherNotEquals, + pgsql.CompoundIdentifier{unboundNodeIdentifier, pgsql.ColumnID}, + ), + ), + nextSelect.Where, + ) + + return pgsql.Query{ + Body: nextSelect, + }, nil +} + +func (s *Translator) buildSelfReferentialDirectionlessTraversalRoot(traversalStep *TraversalStep) (pgsql.Query, error) { + var ( + // Partition node constraints + _, rightJoinExternal = partitionConstraintByLocality( + traversalStep.RightNodeConstraints, + pgsql.AsIdentifierSet(traversalStep.RightNode.Identifier, traversalStep.Edge.Identifier), + ) + + leftJoinLocal, leftJoinExternal = partitionConstraintByLocality( + traversalStep.LeftNodeConstraints, + pgsql.AsIdentifierSet(traversalStep.LeftNode.Identifier, traversalStep.Edge.Identifier), + ) + nextSelect = pgsql.Select{ + From: []pgsql.FromClause{}, + Projection: traversalStep.Projection, + } + ) + + // Self-referential pattern: the right node reuses the left node's variable (e.g. (u)-[]-(u)). + // There is no previous frame to promote as a FROM source. Join only the left node table and + // push the right-node join condition into WHERE so that start_id and end_id both reference + // the same node. + nextSelect.From = append(nextSelect.From, pgsql.FromClause{ + Source: pgsql.TableReference{ + Name: pgsql.CompoundIdentifier{pgsql.TableEdge}, + Binding: models.OptionalValue(traversalStep.Edge.Identifier), + }, + Joins: []pgsql.Join{{ + Table: pgsql.TableReference{ + Name: pgsql.CompoundIdentifier{pgsql.TableNode}, + Binding: models.OptionalValue(traversalStep.LeftNode.Identifier), + }, + JoinOperator: pgsql.JoinOperator{ + JoinType: pgsql.JoinTypeInner, + Constraint: pgsql.OptionalAnd(leftJoinLocal, traversalStep.LeftNodeJoinCondition), + }, + }}, + }) + + // The right node's join condition (e.g. n0.id = e0.end_id) goes to WHERE since + // both endpoints reference the same node binding. + nextSelect.Where = pgsql.OptionalAnd(traversalStep.RightNodeJoinCondition, nextSelect.Where) + nextSelect.Where = pgsql.OptionalAnd(leftJoinExternal, nextSelect.Where) + + nextSelect.Where = pgsql.OptionalAnd(traversalStep.EdgeConstraints.Expression, nextSelect.Where) + nextSelect.Where = pgsql.OptionalAnd(rightJoinExternal, nextSelect.Where) + + return pgsql.Query{ + Body: nextSelect, + }, nil +} + +// +// UNDIRECTED TRAVERSALS **WITH** OUTER CORRELATION +// + +func (s *Translator) buildDirectionlessTraversalPatternRootWithOuterCorrelation(traversalStep *TraversalStep) (pgsql.Query, error) { + if traversalStep.UseExpandInto { + return s.buildBoundEndpointTraversalPattern(traversalStep.Frame, traversalStep) + } + + return pgsql.Query{}, fmt.Errorf("not implemented") +} From 8d699754846b5d794ed4ada69d7d708f99c5034c Mon Sep 17 00:00:00 2001 From: Sean Johnson Date: Wed, 17 Jun 2026 13:53:31 -0500 Subject: [PATCH 3/5] fix: add outer correlation for directionless traversals --- .../translate/traversal_directionless.go | 260 +++++++++++++----- 1 file changed, 196 insertions(+), 64 deletions(-) diff --git a/cypher/models/pgsql/translate/traversal_directionless.go b/cypher/models/pgsql/translate/traversal_directionless.go index d41d6c1e..0a590c8f 100644 --- a/cypher/models/pgsql/translate/traversal_directionless.go +++ b/cypher/models/pgsql/translate/traversal_directionless.go @@ -7,6 +7,52 @@ import ( "github.com/specterops/dawgs/cypher/models/pgsql" ) +// directionlessSingleBoundPlan is a "generic" plan to hold the details necessary for constructing +// a single-bound traversal step +type directionlessSingleBoundPlan struct { + boundNodeConstraints pgsql.Expression + boundNodeJoinCondition pgsql.Expression + nodeJoinBinding pgsql.Identifier + nodeJoinConstraint pgsql.Expression + whereConstraint pgsql.Expression + boundNode *BoundIdentifier + unboundNodeIdentifier pgsql.Identifier +} + +func buildDirectionlessSingleBoundPlan(traversalStep *TraversalStep) directionlessSingleBoundPlan { + // Partition node constraints + rightJoinLocal, rightJoinExternal := partitionConstraintByLocality( + traversalStep.RightNodeConstraints, + pgsql.AsIdentifierSet(traversalStep.RightNode.Identifier, traversalStep.Edge.Identifier), + ) + + leftJoinLocal, leftJoinExternal := partitionConstraintByLocality( + traversalStep.LeftNodeConstraints, + pgsql.AsIdentifierSet(traversalStep.LeftNode.Identifier, traversalStep.Edge.Identifier), + ) + + plan := directionlessSingleBoundPlan{} + if traversalStep.LeftNodeBound { + plan.nodeJoinBinding = traversalStep.RightNode.Identifier + plan.nodeJoinConstraint = pgsql.OptionalAnd(rightJoinLocal, traversalStep.RightNodeJoinCondition) + plan.whereConstraint = pgsql.OptionalAnd(rightJoinExternal, traversalStep.EdgeConstraints.Expression) + plan.boundNodeConstraints = traversalStep.LeftNodeConstraints + plan.boundNodeJoinCondition = traversalStep.LeftNodeJoinCondition + plan.boundNode = traversalStep.LeftNode + plan.unboundNodeIdentifier = traversalStep.RightNode.Identifier + } else if traversalStep.RightNodeBound { + plan.nodeJoinBinding = traversalStep.LeftNode.Identifier + plan.nodeJoinConstraint = pgsql.OptionalAnd(leftJoinLocal, traversalStep.LeftNodeJoinCondition) + plan.whereConstraint = pgsql.OptionalAnd(leftJoinExternal, traversalStep.EdgeConstraints.Expression) + plan.boundNodeConstraints = traversalStep.RightNodeConstraints + plan.boundNodeJoinCondition = traversalStep.RightNodeJoinCondition + plan.boundNode = traversalStep.RightNode + plan.unboundNodeIdentifier = traversalStep.LeftNode.Identifier + } + + return plan +} + // // DIRECTIONLESS TRAVERSALS WITHOUT OUTER CORRELATION // @@ -28,50 +74,48 @@ func (s *Translator) buildDirectionlessTraversalPatternRoot(traversalStep *Trave return s.buildSingleBoundDirectionlessTraversalRoot(traversalStep) } + // Neither side is bound return s.buildUnboundDirectionlessTraversalPatternRoot(traversalStep) } -func buildDirectionlessPairwiseEdgeConstraint(traversalStep *TraversalStep) pgsql.Expression { - prevFrame := traversalStep.Frame.Previous - - // ((sN.nLeft).id = (eN).start_id AND (sN.nRight).id = (eN).end_id) +func buildDirectionlessPairwiseEdgeConstraintForRefs(left pgsql.Expression, right pgsql.Expression, edge pgsql.Identifier) pgsql.Expression { + // ((left).id = (eN).start_id AND (right).id = (eN).end_id) leftToRight := pgsql.NewParenthetical( pgsql.NewBinaryExpression( pgsql.NewBinaryExpression( - // (sN.nLeft).id - boundEndpointIDReference(prevFrame, traversalStep.LeftNode), + left, pgsql.OperatorEquals, // (eN).start_id - pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnStartID}, + pgsql.CompoundIdentifier{edge, pgsql.ColumnStartID}, ), pgsql.OperatorAnd, pgsql.NewBinaryExpression( - // (sN.nRight).id - boundEndpointIDReference(prevFrame, traversalStep.RightNode), + // (right).id + right, pgsql.OperatorEquals, // (eN).end_id - pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnEndID}, + pgsql.CompoundIdentifier{edge, pgsql.ColumnEndID}, ), ), ) - // ((sN.nRight).id = (eN).start_id AND (sN.nLeft).id = (eN).end_id) + // ((right).id = (eN).start_id AND (left).id = (eN).end_id) rightToLeft := pgsql.NewParenthetical( pgsql.NewBinaryExpression( pgsql.NewBinaryExpression( - // (sN.nRight).id - boundEndpointIDReference(prevFrame, traversalStep.RightNode), + // (right).id + right, pgsql.OperatorEquals, // (eN).start_id - pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnStartID}, + pgsql.CompoundIdentifier{edge, pgsql.ColumnStartID}, ), pgsql.OperatorAnd, pgsql.NewBinaryExpression( - // (sN.nLeft).id - boundEndpointIDReference(prevFrame, traversalStep.LeftNode), + // (left).id + left, pgsql.OperatorEquals, // (eN).end_id - pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnEndID}, + pgsql.CompoundIdentifier{edge, pgsql.ColumnEndID}, ), ), ) @@ -83,6 +127,8 @@ func buildDirectionlessPairwiseEdgeConstraint(traversalStep *TraversalStep) pgsq ) } +// buildPairwiseDirectionlessTraversalPatternRoot builds a traversal pattern for a path predicate where both side of +// the traversal root are bound to external nodes func (s *Translator) buildPairwiseDirectionlessTraversalPatternRoot(traversalStep *TraversalStep) (pgsql.Query, error) { var ( // Partition node constraints @@ -101,7 +147,12 @@ func (s *Translator) buildPairwiseDirectionlessTraversalPatternRoot(traversalSte } ) - pairwiseEdgeConstraint := buildDirectionlessPairwiseEdgeConstraint(traversalStep) + previousFrame := traversalStep.Frame.Previous + pairwiseEdgeConstraint := buildDirectionlessPairwiseEdgeConstraintForRefs( + boundEndpointIDReference(previousFrame, traversalStep.LeftNode), + boundEndpointIDReference(previousFrame, traversalStep.RightNode), + traversalStep.Edge.Identifier, + ) nextSelect.From = append(nextSelect.From, pgsql.FromClause{ Source: pgsql.TableReference{ Name: pgsql.CompoundIdentifier{traversalStep.Frame.Previous.Binding.Identifier}, @@ -200,8 +251,12 @@ func (s *Translator) buildUnboundDirectionlessTraversalPatternRoot(traversalStep pgsql.NewBinaryExpression( pgsql.CompoundIdentifier{traversalStep.LeftNode.Identifier, pgsql.ColumnID}, pgsql.OperatorCypherNotEquals, - pgsql.CompoundIdentifier{traversalStep.RightNode.Identifier, pgsql.ColumnID})), - nextSelect.Where) + pgsql.CompoundIdentifier{traversalStep.RightNode.Identifier, pgsql.ColumnID}, + ), + ), + nextSelect.Where, + ) + return pgsql.Query{ Body: nextSelect, }, nil @@ -210,8 +265,7 @@ func (s *Translator) buildUnboundDirectionlessTraversalPatternRoot(traversalStep // buildSingleBoundDirectionlessTraversalRoot checks that the bound previous frame exists and generates the binding // parameters necessary to generate the SQL query for a single-bound undirected traversal. func (s *Translator) buildSingleBoundDirectionlessTraversalRoot(traversalStep *TraversalStep) (pgsql.Query, error) { - referenceFrame := traversalStep.Frame - previousFrame, hasPreviousFrame := s.previousValidFrame(referenceFrame) + previousFrame, hasPreviousFrame := s.previousValidFrame(traversalStep.Frame) // If left node is bound and there is no previous frame, this is a bug in the bounds generation if traversalStep.LeftNodeBound && !hasPreviousFrame { @@ -224,44 +278,11 @@ func (s *Translator) buildSingleBoundDirectionlessTraversalRoot(traversalStep *T } var ( - // Partition node constraints - rightJoinLocal, rightJoinExternal = partitionConstraintByLocality( - traversalStep.RightNodeConstraints, - pgsql.AsIdentifierSet(traversalStep.RightNode.Identifier, traversalStep.Edge.Identifier), - ) - - leftJoinLocal, leftJoinExternal = partitionConstraintByLocality( - traversalStep.LeftNodeConstraints, - pgsql.AsIdentifierSet(traversalStep.LeftNode.Identifier, traversalStep.Edge.Identifier), - ) - - edgeJoinConstraint pgsql.Expression - nodeJoinConstraint pgsql.Expression - whereConstraint pgsql.Expression - nodeJoinBinding pgsql.Identifier - boundNodeIdentifier pgsql.Identifier - unboundNodeIdentifier pgsql.Identifier - + plan = buildDirectionlessSingleBoundPlan(traversalStep) pivotEdgeIdentifier = traversalStep.Edge.Identifier projection = traversalStep.Projection ) - if traversalStep.LeftNodeBound { - edgeJoinConstraint = pgsql.OptionalAnd(traversalStep.LeftNodeConstraints, traversalStep.LeftNodeJoinCondition) - nodeJoinBinding = traversalStep.RightNode.Identifier - nodeJoinConstraint = pgsql.OptionalAnd(rightJoinLocal, traversalStep.RightNodeJoinCondition) - whereConstraint = pgsql.OptionalAnd(rightJoinExternal, traversalStep.EdgeConstraints.Expression) - boundNodeIdentifier = traversalStep.LeftNode.Identifier - unboundNodeIdentifier = traversalStep.RightNode.Identifier - } else if traversalStep.RightNodeBound { - edgeJoinConstraint = pgsql.OptionalAnd(traversalStep.RightNodeConstraints, traversalStep.RightNodeJoinCondition) - nodeJoinBinding = traversalStep.LeftNode.Identifier - nodeJoinConstraint = pgsql.OptionalAnd(leftJoinLocal, traversalStep.LeftNodeJoinCondition) - whereConstraint = pgsql.OptionalAnd(leftJoinExternal, traversalStep.EdgeConstraints.Expression) - boundNodeIdentifier = traversalStep.RightNode.Identifier - unboundNodeIdentifier = traversalStep.LeftNode.Identifier - } - nextSelect := pgsql.Select{ Projection: projection, } @@ -280,32 +301,32 @@ func (s *Translator) buildSingleBoundDirectionlessTraversalRoot(traversalStep *T }, JoinOperator: pgsql.JoinOperator{ JoinType: pgsql.JoinTypeInner, - Constraint: edgeJoinConstraint, + Constraint: pgsql.OptionalAnd(plan.boundNodeConstraints, plan.boundNodeJoinCondition), }, }, { Table: pgsql.TableReference{ Name: pgsql.CompoundIdentifier{pgsql.TableNode}, - Binding: models.OptionalValue(nodeJoinBinding), + Binding: models.OptionalValue(plan.nodeJoinBinding), }, JoinOperator: pgsql.JoinOperator{ JoinType: pgsql.JoinTypeInner, - Constraint: nodeJoinConstraint, + Constraint: plan.nodeJoinConstraint, }, }}, }) - nextSelect.Where = whereConstraint + nextSelect.Where = plan.whereConstraint // selected node is not joined here, so the guard must reference the bound node through the previous frame nextSelect.Where = pgsql.OptionalAnd( pgsql.NewParenthetical( pgsql.NewBinaryExpression( pgsql.RowColumnReference{ - Identifier: pgsql.CompoundIdentifier{previousFrame.Binding.Identifier, boundNodeIdentifier}, + Identifier: pgsql.CompoundIdentifier{previousFrame.Binding.Identifier, plan.boundNode.Identifier}, Column: pgsql.ColumnID, }, pgsql.OperatorCypherNotEquals, - pgsql.CompoundIdentifier{unboundNodeIdentifier, pgsql.ColumnID}, + pgsql.CompoundIdentifier{plan.unboundNodeIdentifier, pgsql.ColumnID}, ), ), nextSelect.Where, @@ -359,7 +380,6 @@ func (s *Translator) buildSelfReferentialDirectionlessTraversalRoot(traversalSte // both endpoints reference the same node binding. nextSelect.Where = pgsql.OptionalAnd(traversalStep.RightNodeJoinCondition, nextSelect.Where) nextSelect.Where = pgsql.OptionalAnd(leftJoinExternal, nextSelect.Where) - nextSelect.Where = pgsql.OptionalAnd(traversalStep.EdgeConstraints.Expression, nextSelect.Where) nextSelect.Where = pgsql.OptionalAnd(rightJoinExternal, nextSelect.Where) @@ -377,5 +397,117 @@ func (s *Translator) buildDirectionlessTraversalPatternRootWithOuterCorrelation( return s.buildBoundEndpointTraversalPattern(traversalStep.Frame, traversalStep) } - return pgsql.Query{}, fmt.Errorf("not implemented") + if traversalStep.LeftNodeBound && traversalStep.RightNodeBound { + // Both sides are bound, build a strict pairwise join on the edge + return s.buildPairwiseDirectionlessTraversalPatternRootWithOuterCorrelation(traversalStep) + } else if traversalStep.LeftNodeBound || traversalStep.RightNodeBound { + // One of the traversal step nodes is bound by the outer query, so + // generate internal constraints to "bind" the inner and outer queries + return s.buildSingleBoundDirectionlessTraversalRootWithOuterCorrelation(traversalStep) + } + + // When unbound, fall back to the uncorrelated, unbound, directionless builder + return s.buildUnboundDirectionlessTraversalPatternRoot(traversalStep) +} + +func (s *Translator) buildSingleBoundDirectionlessTraversalRootWithOuterCorrelation(traversalStep *TraversalStep) (pgsql.Query, error) { + previousFrame, hasPreviousFrame := s.previousValidFrame(traversalStep.Frame) + + // If left node is bound and there is no previous frame, this is a bug in the bounds generation + if traversalStep.LeftNodeBound && !hasPreviousFrame { + return pgsql.Query{}, fmt.Errorf("left node is marked as bound but there is no previous frame to reference") + } + + // Special-case for self-referential right-bound form + if traversalStep.RightNodeBound && !hasPreviousFrame { + return s.buildSelfReferentialDirectionlessTraversalRoot(traversalStep) + } + + var ( + plan = buildDirectionlessSingleBoundPlan(traversalStep) + projection = traversalStep.Projection + ) + + nextSelect := pgsql.Select{Projection: projection} + nextSelect.From = append(nextSelect.From, pgsql.FromClause{ + Source: pgsql.TableReference{ + Name: pgsql.CompoundIdentifier{pgsql.TableEdge}, + Binding: models.OptionalValue(traversalStep.Edge.Identifier), + }, + Joins: []pgsql.Join{{ + Table: pgsql.TableReference{ + Name: pgsql.CompoundIdentifier{pgsql.TableNode}, + Binding: models.OptionalValue(plan.nodeJoinBinding), + }, + JoinOperator: pgsql.JoinOperator{ + JoinType: pgsql.JoinTypeInner, + Constraint: plan.nodeJoinConstraint, + }, + }}, + }) + + nextSelect.Where = pgsql.OptionalAnd(plan.boundNodeConstraints, nextSelect.Where) + nextSelect.Where = pgsql.OptionalAnd(plan.boundNodeJoinCondition, nextSelect.Where) + nextSelect.Where = pgsql.OptionalAnd(plan.whereConstraint, nextSelect.Where) + + // selected node is not joined here, so the guard must reference the bound node through the previous frame + nextSelect.Where = pgsql.OptionalAnd( + pgsql.NewParenthetical( + pgsql.NewBinaryExpression( + boundEndpointIDReference(previousFrame, plan.boundNode), + pgsql.OperatorCypherNotEquals, + pgsql.CompoundIdentifier{plan.unboundNodeIdentifier, pgsql.ColumnID}, + ), + ), + nextSelect.Where, + ) + + return pgsql.Query{Body: nextSelect}, nil +} + +// buildPairwiseDirectionlessTraversalPatternRootWithOuterCorrelation builds a traversal pattern for a path predicate where both side of +// the traversal root are bound to external nodes +func (s *Translator) buildPairwiseDirectionlessTraversalPatternRootWithOuterCorrelation(traversalStep *TraversalStep) (pgsql.Query, error) { + var ( + // Partition node constraints + _, rightJoinExternal = partitionConstraintByLocality( + traversalStep.RightNodeConstraints, + pgsql.AsIdentifierSet(traversalStep.RightNode.Identifier, traversalStep.Edge.Identifier), + ) + + _, leftJoinExternal = partitionConstraintByLocality( + traversalStep.LeftNodeConstraints, + pgsql.AsIdentifierSet(traversalStep.LeftNode.Identifier, traversalStep.Edge.Identifier), + ) + + nextSelect = pgsql.Select{ + Projection: traversalStep.Projection, + } + ) + + previousFrame := traversalStep.Frame.Previous + pairwiseEdgeConstraint := buildDirectionlessPairwiseEdgeConstraintForRefs( + boundEndpointIDReference(previousFrame, traversalStep.LeftNode), + boundEndpointIDReference(previousFrame, traversalStep.RightNode), + traversalStep.Edge.Identifier, + ) + nextSelect.From = append(nextSelect.From, pgsql.FromClause{ + Source: pgsql.TableReference{ + Name: pgsql.CompoundIdentifier{pgsql.TableEdge}, + Binding: models.OptionalValue(traversalStep.Edge.Identifier), + }, + }) + + // Dual-bound: both endpoint external constraints apply. + nextSelect.Where = pgsql.OptionalAnd(pairwiseEdgeConstraint, nextSelect.Where) + nextSelect.Where = pgsql.OptionalAnd(traversalStep.EdgeConstraints.Expression, nextSelect.Where) + nextSelect.Where = pgsql.OptionalAnd(leftJoinExternal, nextSelect.Where) + nextSelect.Where = pgsql.OptionalAnd(rightJoinExternal, nextSelect.Where) + + // Only apply endpoint inequality when the bound nodes are different, to allow for self-referential relationships + if traversalStep.LeftNode.Identifier != traversalStep.RightNode.Identifier { + nextSelect.Where = pgsql.OptionalAnd(boundEndpointInequality(traversalStep.Frame.Previous, traversalStep), nextSelect.Where) + } + + return pgsql.Query{Body: nextSelect}, nil } From 257954401d0b6811e38f31a1236ce37d4102f958 Mon Sep 17 00:00:00 2001 From: Sean Johnson Date: Wed, 17 Jun 2026 16:50:09 -0500 Subject: [PATCH 4/5] test: update translation test for undirected traversal w/ outer correlation --- cypher/models/pgsql/test/translation_cases/nodes.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cypher/models/pgsql/test/translation_cases/nodes.sql b/cypher/models/pgsql/test/translation_cases/nodes.sql index 8565f7b4..d3262681 100644 --- a/cypher/models/pgsql/test/translation_cases/nodes.sql +++ b/cypher/models/pgsql/test/translation_cases/nodes.sql @@ -214,7 +214,7 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select s0.n0 as s from s0 where (not (with s1 as (select e0.id as e0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n1 on n1.id = e0.end_id where (s0.n0).id = e0.start_id), s2 as (select s1.e0 as e0, s1.n0 as n0, s1.n1 as n1 from s1 join edge e1 on (s1.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.id != s1.e0) select count(*) > 0 from s2)); -- case: match (s) where not (s)-[{prop: 'a'}]-({name: 'n3'}) return s -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select s0.n0 as s from s0 where (not (with s1 as (select s0.n0 as n0 from s0 join edge e0 on ((s0.n0).id = e0.end_id or (s0.n0).id = e0.start_id) join node n1 on (jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'n3') and (n1.id = e0.end_id or n1.id = e0.start_id) where ((s0.n0).id <> n1.id) and (jsonb_typeof((e0.properties -> 'prop')) = 'string' and (e0.properties ->> 'prop') = 'a')) select count(*) > 0 from s1)); +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select s0.n0 as s from s0 where (not (with s1 as (select s0.n0 as n0 from edge e0 join node n1 on (jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'n3') and (n1.id = e0.end_id or n1.id = e0.start_id) where ((s0.n0).id <> n1.id) and (jsonb_typeof((e0.properties -> 'prop')) = 'string' and (e0.properties ->> 'prop') = 'a') and ((s0.n0).id = e0.end_id or (s0.n0).id = e0.start_id)) select count(*) > 0 from s1)); -- case: match (s) where not (s)<-[{prop: 'a'}]-({name: 'n3'}) return s with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select s0.n0 as s from s0 where (not (with s1 as (select s0.n0 as n0 from edge e0 join node n1 on (jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'n3') and n1.id = e0.start_id where (jsonb_typeof((e0.properties -> 'prop')) = 'string' and (e0.properties ->> 'prop') = 'a') and (s0.n0).id = e0.end_id) select count(*) > 0 from s1)); From 4b400cefc31d68171be12dfeb84d56cfb8268bab Mon Sep 17 00:00:00 2001 From: Sean Johnson Date: Wed, 17 Jun 2026 18:10:26 -0500 Subject: [PATCH 5/5] test: new translation tests for directionless traversals --- .../test/translation_cases/multipart.sql | 4 +- .../pgsql/test/translation_cases/nodes.sql | 25 +++++++- .../translation_cases/pattern_binding.sql | 10 ++- ...related_undirected_pattern_predicates.json | 63 +++++++++++++++++++ 4 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 integration/testdata/cases/bed8320-correlated_undirected_pattern_predicates.json diff --git a/cypher/models/pgsql/test/translation_cases/multipart.sql b/cypher/models/pgsql/test/translation_cases/multipart.sql index 48741b90..e7995068 100644 --- a/cypher/models/pgsql/test/translation_cases/multipart.sql +++ b/cypher/models/pgsql/test/translation_cases/multipart.sql @@ -41,6 +41,9 @@ with recursive candidate_sources(root_id) as (select source_node.id as root_id f -- case: match (n:NodeKind1) where n.objectid = 'S-1-5-21-1260426776-3623580948-1897206385-23225' match p = (n)-[:EdgeKind1|EdgeKind2*1..]->(c:NodeKind2) return p with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((jsonb_typeof((n0.properties -> 'objectid')) = 'string' and (n0.properties ->> 'objectid') = 'S-1-5-21-1260426776-3623580948-1897206385-23225')) 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, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], 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, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], 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 < 15 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; +-- case: match (a) with a match (b) with a, b match (a)-[]-(b) return a +with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select s1.n0 as n0 from s1), s2 as (with s3 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1) select s3.n0 as n0, s3.n1 as n1 from s3), s4 as (select s2.n0 as n0, s2.n1 as n1 from s2 join edge e0 on (((s2.n0).id = e0.start_id and (s2.n1).id = e0.end_id) or ((s2.n1).id = e0.start_id and (s2.n0).id = e0.end_id)) where ((s2.n0).id <> (s2.n1).id)) select s4.n0 as a from s4; + -- case: match (g1:NodeKind1) where g1.name starts with 'test' with collect (g1.domain) as excludes match (d:NodeKind2) where d.name starts with 'other' and not d.name in excludes return d with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((n0.properties ->> 'name') like 'test%') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select array_remove(coalesce(array_agg(((s1.n0).properties ->> 'domain'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s1), s2 as (select s0.i0 as i0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where (not (n1.properties ->> 'name') = any (s0.i0) and (n1.properties ->> 'name') like 'other%') and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[]) select s2.n1 as d from s2; @@ -91,4 +94,3 @@ with s0 as (with s1 as (select e0.id as e0, (n0.id, n0.kind_ids, n0.properties): -- case: match (g:NodeKind1) optional match (g)<-[r:EdgeKind1]-(m:NodeKind2) with g, count(r) as memberCount where memberCount = 0 return g with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, s1.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join edge e0 on (s1.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[])), s3 as (select s1.n0 as n0, s2.e0 as e0, s2.n1 as n1 from s1 left outer join s2 on (s1.n0 = s2.n0)) select s3.n0 as n0, count(s3.e0)::int8 as i0 from s3 group by n0) select s0.n0 as g from s0 where (s0.i0 = 0); - diff --git a/cypher/models/pgsql/test/translation_cases/nodes.sql b/cypher/models/pgsql/test/translation_cases/nodes.sql index d3262681..0f3ba8d2 100644 --- a/cypher/models/pgsql/test/translation_cases/nodes.sql +++ b/cypher/models/pgsql/test/translation_cases/nodes.sql @@ -237,6 +237,30 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from -- case: match (s) where not (s)-[]-() return id(s) with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select (s0.n0).id from s0 where (not exists (select 1 from edge e0 where (e0.start_id = (s0.n0).id or e0.end_id = (s0.n0).id))); +-- case: match (s) where ()--(s) return s +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select s0.n0 as s from s0 where (exists (select 1 from edge e0 where (e0.start_id = (s0.n0).id or e0.end_id = (s0.n0).id))); + +-- case: match (a), (b) where (a)--(b) return a +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0), s1 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1) select s1.n0 as a from s1 where (exists (select 1 from edge e0 where ((e0.start_id = (s1.n0).id and e0.end_id = (s1.n1).id) or (e0.start_id = (s1.n1).id and e0.end_id = (s1.n0).id)))); + +-- case: match (s) where ()--() return s +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select s0.n0 as s from s0 where (exists (select 1 from edge e0)); + +-- case: match (g) where ({name: 'n3'})-[{prop: 'a'}]-(g) return g +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select s0.n0 as g from s0 where ((with s1 as (select s0.n0 as n0 from edge e0 join node n1 on (jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'n3') and (n1.id = e0.end_id or n1.id = e0.start_id) where ((s0.n0).id <> n1.id) and (jsonb_typeof((e0.properties -> 'prop')) = 'string' and (e0.properties ->> 'prop') = 'a') and ((s0.n0).id = e0.end_id or (s0.n0).id = e0.start_id)) select count(*) > 0 from s1)); + +-- case: match (a:NodeKind1), (b:NodeKind2) where (a:NodeKind1)-[]-(b:NodeKind2) return a +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[]) select s1.n0 as a from s1 where ((with s2 as (select s1.n0 as n0, s1.n1 as n1 from edge e0 where ((s1.n0).id <> (s1.n1).id) and (((s1.n0).id = e0.start_id and (s1.n1).id = e0.end_id) or ((s1.n1).id = e0.start_id and (s1.n0).id = e0.end_id))) select count(*) > 0 from s2)); + +-- case: match (x:NodeKind1{name:'foo'}) match (x)-[]-(y:NodeKind2{name:'bar'}) return x +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = 'foo')), s1 as (select s0.n0 as n0 from s0 join edge e0 on ((s0.n0).id = e0.end_id or (s0.n0).id = e0.start_id) join node n1 on n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and (jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'bar') and (n1.id = e0.end_id or n1.id = e0.start_id) where ((s0.n0).id <> n1.id)) select s1.n0 as x from s1; + +-- case: match (y:NodeKind2{name:'bar'}) match ()-[]-(y) return y +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where n0.kind_ids operator (pg_catalog.@>) array [2]::int2[] and (jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = 'bar')), s1 as (select s0.n0 as n0 from s0 join edge e0 on ((s0.n0).id = e0.end_id or (s0.n0).id = e0.start_id) join node n1 on (n1.id = e0.end_id or n1.id = e0.start_id) where ((s0.n0).id <> n1.id)) select s1.n0 as y from s1; + +-- case: match (x:NodeKind1{name:'foo'}) match (y:NodeKind2{name:'bar'}) match (x)-[]-(y) return x +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = 'foo')), s1 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and (jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'bar')), s2 as (select s1.n0 as n0, s1.n1 as n1 from s1 join edge e0 on ((s1.n0).id = e0.start_id or (s1.n0).id = e0.end_id) and ((s1.n1).id = e0.end_id or (s1.n1).id = e0.start_id) where ((s1.n0).id <> (s1.n1).id)) select s2.n0 as x from s2; + -- case: match (n) where n.system_tags contains ($param) return n -- pgsql_params:{"pi0":null} with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (cypher_contains((n0.properties ->> 'system_tags'), (@pi0)::text)::bool)) select s0.n0 as n from s0; @@ -348,4 +372,3 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from -- case: match (g:NodeKind2) where not ((g)<-[:EdgeKind1]-(:NodeKind1)) return g with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]) select s0.n0 as g from s0 where (not ((with s1 as (select s0.n0 as n0 from edge e0 join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.start_id where e0.kind_id = any (array [3]::int2[]) and (s0.n0).id = e0.end_id) select count(*) > 0 from s1))); - diff --git a/cypher/models/pgsql/test/translation_cases/pattern_binding.sql b/cypher/models/pgsql/test/translation_cases/pattern_binding.sql index 35883e93..2d97d25f 100644 --- a/cypher/models/pgsql/test/translation_cases/pattern_binding.sql +++ b/cypher/models/pgsql/test/translation_cases/pattern_binding.sql @@ -74,6 +74,15 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from -- case: match (x:NodeKind1{name:'foo'}) match p=(x)-[:EdgeKind1]->(y:NodeKind2{name:'bar'}) return p with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = 'foo')), s1 as (select e0.id as e0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0 join edge e0 on (s0.n0).id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and (jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'bar') and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select case when (s1.n0).id is null or s1.e0 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(array [s1.e0]::int8[]) 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; +-- case: match (x:NodeKind1{name:'foo'}) match p=(x)-[]-(y:NodeKind2{name:'bar'}) return p +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = 'foo')), s1 as (select e0.id as e0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0 join edge e0 on ((s0.n0).id = e0.end_id or (s0.n0).id = e0.start_id) join node n1 on n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and (jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'bar') and (n1.id = e0.end_id or n1.id = e0.start_id) where ((s0.n0).id <> n1.id)) select case when (s1.n0).id is null or s1.e0 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(array [s1.e0]::int8[]) 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; + +-- case: match (e) match p = ()-[]-(e) return p limit 1 +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0), s1 as (select e0.id as e0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0 join edge e0 on ((s0.n0).id = e0.end_id or (s0.n0).id = e0.start_id) join node n1 on (n1.id = e0.end_id or n1.id = e0.start_id) where ((s0.n0).id <> n1.id)) select case when (s1.n1).id is null or s1.e0 is null or (s1.n0).id is null then null else ordered_edges_to_path(s1.n1, (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.n1, s1.n0]::nodecomposite[])::pathcomposite end as p from s1 limit 1; + +-- case: match (x:NodeKind1{name:'foo'}) match (y:NodeKind2{name:'bar'}) match p=(x)-[]-(y) return p +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = 'foo')), s1 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and (jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'bar')), s2 as (select e0.id as e0, s1.n0 as n0, s1.n1 as n1 from s1 join edge e0 on ((s1.n0).id = e0.start_id or (s1.n0).id = e0.end_id) and ((s1.n1).id = e0.end_id or (s1.n1).id = e0.start_id) where ((s1.n0).id <> (s1.n1).id)) select case when (s2.n0).id is null or s2.e0 is null or (s2.n1).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), array [s2.n0, s2.n1]::nodecomposite[])::pathcomposite end as p from s2; + -- case: match (e) match p = ()-[]->(e) return p limit 1 with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0), s1 as (select e0.id as e0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0 join edge e0 on (s0.n0).id = e0.end_id join node n1 on n1.id = e0.start_id) select case when (s1.n1).id is null or s1.e0 is null or (s1.n0).id is null then null else ordered_edges_to_path(s1.n1, (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.n1, s1.n0]::nodecomposite[])::pathcomposite end as p from s1 limit 1; @@ -91,4 +100,3 @@ with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as -- case: MATCH p=(:GPO)-[r:GPLink|Contains*1..]->(:Base) WHERE NONE(x in TAIL(r) WHERE NOT type(x) = 'Contains') RETURN p with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where n0.kind_ids operator (pg_catalog.@>) array [8]::int2[]), 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 [10]::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 [11, 12]::int2[]) union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [10]::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 [11, 12]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (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.path) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id) as e0, 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) 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 where (((select count(*)::int from unnest(coalesce((s0.e0)[2:], array []::edgecomposite[])::edgecomposite[]) as i0 where (not i0.kind_id = 12)) = 0 and coalesce((s0.e0)[2:], array []::edgecomposite[])::edgecomposite[] is not null)::bool); - diff --git a/integration/testdata/cases/bed8320-correlated_undirected_pattern_predicates.json b/integration/testdata/cases/bed8320-correlated_undirected_pattern_predicates.json new file mode 100644 index 00000000..40bcecd7 --- /dev/null +++ b/integration/testdata/cases/bed8320-correlated_undirected_pattern_predicates.json @@ -0,0 +1,63 @@ +{ + "dataset": "bed8320", + "cases": [ + { + "name": "BED-8320 long-form typed undirected predicate remains row-correlated from left-bound node", + "cypher": "match (g:NodeKind2) where (g)-[:EdgeKind1]-(:NodeKind1) return g.name", + "assert": { + "scalar_values": [ + "BED-6695 MEMBER USER", + "KEY ADMINS BETA", + "OPERATORS" + ] + } + }, + { + "name": "BED-8320 long-form typed undirected predicate remains row-correlated from right-bound node", + "cypher": "match (g:NodeKind2) where (:NodeKind1)-[:EdgeKind1]-(g) return g.name", + "assert": { + "scalar_values": [ + "BED-6695 MEMBER USER", + "KEY ADMINS BETA", + "OPERATORS" + ] + } + }, + { + "name": "BED-8320 long-form negated typed undirected predicate excludes NodeKind1 incident nodes from left-bound node", + "cypher": "match (g:NodeKind2) where not ((g)-[:EdgeKind1]-(:NodeKind1)) return g.name", + "assert": { + "scalar_values": [ + "KEY ADMINS ALPHA", + "KEY ADMINS DELTA", + "KEY ADMINS GAMMA" + ] + } + }, + { + "name": "BED-8320 long-form negated typed undirected predicate excludes NodeKind1 incident nodes from right-bound node", + "cypher": "match (g:NodeKind2) where not ((:NodeKind1)-[:EdgeKind1]-(g)) return g.name", + "assert": { + "scalar_values": [ + "KEY ADMINS ALPHA", + "KEY ADMINS DELTA", + "KEY ADMINS GAMMA" + ] + } + }, + { + "name": "BED-8320 long-form typed both-bound undirected predicate finds connected pair", + "cypher": "match (a:NodeKind1), (b:NodeKind2) where a.name = 'User A' and b.name = 'KEY ADMINS BETA' and (a:NodeKind1)-[:EdgeKind1]-(b:NodeKind2) return count(a)", + "assert": { + "exact_int": 1 + } + }, + { + "name": "BED-8320 long-form typed both-bound undirected predicate rejects wrong edge kind", + "cypher": "match (a:NodeKind1), (b:NodeKind2) where a.name = 'User A' and b.name = 'KEY ADMINS ALPHA' and (a:NodeKind1)-[:EdgeKind1]-(b:NodeKind2) return count(a)", + "assert": { + "exact_int": 0 + } + } + ] +}