From 868926e10664b66d8501cc431060d9febb036e8a Mon Sep 17 00:00:00 2001 From: Sean Johnson Date: Thu, 7 May 2026 15:46:48 -0500 Subject: [PATCH 1/7] chore(integration): tests for pattern predicates fix --- integration/testdata/bed6695.json | 34 +++++++++++++++++++ .../testdata/cases/pattern_predicates.json | 25 ++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 integration/testdata/bed6695.json create mode 100644 integration/testdata/cases/pattern_predicates.json diff --git a/integration/testdata/bed6695.json b/integration/testdata/bed6695.json new file mode 100644 index 00000000..a1ec9d28 --- /dev/null +++ b/integration/testdata/bed6695.json @@ -0,0 +1,34 @@ +{ + "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" + } + } + ], + "edges": [ + { + "start_id": "member_user", + "end_id": "key_admins_membered", + "kind": "EdgeKind1" + } + ] + } +} diff --git a/integration/testdata/cases/pattern_predicates.json b/integration/testdata/cases/pattern_predicates.json new file mode 100644 index 00000000..95e9d6f2 --- /dev/null +++ b/integration/testdata/cases/pattern_predicates.json @@ -0,0 +1,25 @@ +{ + "dataset": "bed6695", + "cases": [ + { + "name": "BED-6695 negated incoming pattern predicate with contains filter remains row-correlated", + "cypher": "match (g:NodeKind1) where g.name contains 'BED-6695 KEY ADMINS' and not ((g)<-[:EdgeKind1]-(:NodeKind2)) return count(g)", + "assert": {"exact_int": 1} + }, + { + "name": "BED-6695 flipped equivalent pattern predicate returns the same count", + "cypher": "match (g:NodeKind1) where g.name contains 'BED-6695 KEY ADMINS' and not ((:NodeKind2)-[:EdgeKind1]->(g)) return count(g)", + "assert": {"exact_int": 1} + }, + { + "name": "BED-6695 positive incoming pattern predicate with contains filter remains row-correlated", + "cypher": "match (g:NodeKind1) where g.name contains 'BED-6695 KEY ADMINS' and (g)<-[:EdgeKind1]-(:NodeKind2) return count(g)", + "assert": {"exact_int": 1} + }, + { + "name": "BED-6695 negated incoming pattern predicate without property filter remains row-correlated", + "cypher": "match (g:NodeKind1) where not ((g)<-[:EdgeKind1]-(:NodeKind2)) return count(g)", + "assert": {"exact_int": 1} + } + ] +} From 0887845a696e340d8f678df0794eaf3dbad1c581 Mon Sep 17 00:00:00 2001 From: Sean Johnson Date: Mon, 20 Apr 2026 13:49:59 -0700 Subject: [PATCH 2/7] chore(test): regression tests for pattern predicates bug --- .../test/pattern_predicate_shape_test.go | 115 ++++++++++++++++ .../pattern_predicate_direction_inline.json | 130 ++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 cypher/models/pgsql/test/pattern_predicate_shape_test.go create mode 100644 integration/testdata/cases/pattern_predicate_direction_inline.json diff --git a/cypher/models/pgsql/test/pattern_predicate_shape_test.go b/cypher/models/pgsql/test/pattern_predicate_shape_test.go new file mode 100644 index 00000000..fa123855 --- /dev/null +++ b/cypher/models/pgsql/test/pattern_predicate_shape_test.go @@ -0,0 +1,115 @@ +package test + +import ( + "context" + "strings" + "testing" + + "github.com/specterops/dawgs/cypher/frontend" + "github.com/specterops/dawgs/cypher/models/cypher" + "github.com/specterops/dawgs/cypher/models/pgsql/translate" + "github.com/specterops/dawgs/graph" + "github.com/specterops/dawgs/query" +) + +func normalizeSQL(sqlQuery string) string { + return strings.Join(strings.Fields(strings.ToLower(sqlQuery)), " ") +} + +func assertInboundPatternPredicateShape(t *testing.T, sqlQuery string) { + t.Helper() + + normalized := normalizeSQL(sqlQuery) + + requiredFragments := []string{ + "select count(*) > 0 from s1", + "from edge e0 join node n1", + "(s0.n0).id = e0.end_id", + "n1.kind_ids operator (pg_catalog.@>) array [1]::int2[]", + "e0.kind_id = any (array [3]::int2[])", + } + + for _, fragment := range requiredFragments { + if !strings.Contains(normalized, fragment) { + t.Fatalf("expected SQL to contain fragment %q but it did not:\n%s", fragment, sqlQuery) + } + } + + forbiddenFragments := []string{ + "from s0 join edge e0 on (s0.n0).id = e0.end_id", + } + + for _, fragment := range forbiddenFragments { + if strings.Contains(normalized, fragment) { + t.Fatalf("expected SQL to avoid fragment %q but it was present:\n%s", fragment, sqlQuery) + } + } +} + +func buildInboundNodeKind1EdgeKind1PatternPredicate(symbol string) *cypher.PatternPredicate { + patternPredicate := cypher.NewPatternPredicate() + + patternPredicate.AddElement(&cypher.NodePattern{ + Variable: cypher.NewVariableWithSymbol(symbol), + }) + + patternPredicate.AddElement(&cypher.RelationshipPattern{ + Kinds: graph.Kinds{EdgeKind1}, + Direction: graph.DirectionInbound, + }) + + patternPredicate.AddElement(&cypher.NodePattern{ + Kinds: graph.Kinds{NodeKind1}, + }) + + return patternPredicate +} + +func TestTranslate_PatternPredicateInboundShape_CypherFrontend(t *testing.T) { + regularQuery, err := frontend.ParseCypher( + frontend.NewContext(), + "match (g:NodeKind2) where not ((g)<-[:EdgeKind1]-(:NodeKind1)) return g", + ) + if err != nil { + t.Fatalf("failed to parse cypher query: %v", err) + } + + translatedQuery, err := translate.Translate(context.Background(), regularQuery, newKindMapper(), nil) + if err != nil { + t.Fatalf("failed to translate cypher query: %v", err) + } + + formattedQuery, err := translate.Translated(translatedQuery) + if err != nil { + t.Fatalf("failed to format translated SQL query: %v", err) + } + + assertInboundPatternPredicateShape(t, formattedQuery) +} + +func TestTranslate_PatternPredicateInboundShape_GraphFrontend(t *testing.T) { + builder := query.NewBuilderWithCriteria( + query.Where(query.And( + query.Kind(query.Node(), NodeKind2), + query.Not(buildInboundNodeKind1EdgeKind1PatternPredicate(query.NodeSymbol)), + )), + query.Returning(query.Node()), + ) + + rawQuery, err := builder.Build(false) + if err != nil { + t.Fatalf("failed to build graph frontend query: %v", err) + } + + translatedQuery, err := translate.Translate(context.Background(), rawQuery, newKindMapper(), nil) + if err != nil { + t.Fatalf("failed to translate graph frontend query: %v", err) + } + + formattedQuery, err := translate.Translated(translatedQuery) + if err != nil { + t.Fatalf("failed to format translated SQL query: %v", err) + } + + assertInboundPatternPredicateShape(t, formattedQuery) +} diff --git a/integration/testdata/cases/pattern_predicate_direction_inline.json b/integration/testdata/cases/pattern_predicate_direction_inline.json new file mode 100644 index 00000000..2d1ee60c --- /dev/null +++ b/integration/testdata/cases/pattern_predicate_direction_inline.json @@ -0,0 +1,130 @@ +{ + "cases": [ + { + "name": "regression: incoming negated pattern with contains predicate (left-directed form)", + "cypher": "match (g:NodeKind2) where g.name contains 'KEY ADMINS' and not ((g)<-[:EdgeKind1]-(:NodeKind1)) return g", + "fixture": { + "nodes": [ + {"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"}} + ], + "edges": [ + {"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"} + ] + }, + "assert": {"row_count": 2} + }, + { + "name": "regression: incoming negated pattern with contains predicate (right-directed equivalent)", + "cypher": "match (g:NodeKind2) where g.name contains 'KEY ADMINS' and not ((:NodeKind1)-[:EdgeKind1]->(g)) return g", + "fixture": { + "nodes": [ + {"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"}} + ], + "edges": [ + {"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"} + ] + }, + "assert": {"row_count": 2} + }, + { + "name": "regression: incoming negated pattern without outer selectivity (left-directed form)", + "cypher": "match (g:NodeKind2) where not ((g)<-[:EdgeKind1]-(:NodeKind1)) return g", + "fixture": { + "nodes": [ + {"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"}} + ], + "edges": [ + {"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"} + ] + }, + "assert": {"row_count": 2} + }, + { + "name": "regression: incoming negated pattern without outer selectivity (right-directed equivalent)", + "cypher": "match (g:NodeKind2) where not ((:NodeKind1)-[:EdgeKind1]->(g)) return g", + "fixture": { + "nodes": [ + {"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"}} + ], + "edges": [ + {"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"} + ] + }, + "assert": {"row_count": 2} + }, + { + "name": "regression: connected key admins group should be excluded by negated incoming pattern (left-directed)", + "cypher": "match (g:NodeKind2) where g.name = 'KEY ADMINS BETA' and not ((g)<-[:EdgeKind1]-(:NodeKind1)) return g", + "fixture": { + "nodes": [ + {"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"}} + ], + "edges": [ + {"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"} + ] + }, + "assert": {"row_count": 0} + }, + { + "name": "regression: connected key admins group should be excluded by negated incoming pattern (right-directed equivalent)", + "cypher": "match (g:NodeKind2) where g.name = 'KEY ADMINS BETA' and not ((:NodeKind1)-[:EdgeKind1]->(g)) return g", + "fixture": { + "nodes": [ + {"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"}} + ], + "edges": [ + {"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"} + ] + }, + "assert": {"row_count": 0} + } + ] +} From f901dedb40cf8189a5f0083534b5474c876e0bbb Mon Sep 17 00:00:00 2001 From: Sean Johnson Date: Thu, 7 May 2026 15:36:35 -0500 Subject: [PATCH 3/7] fix(pg): fix sql correlation issue with pattern predicates --- cypher/models/pgsql/translate/predicate.go | 14 +++- cypher/models/pgsql/translate/traversal.go | 93 +++++++++++++++++++++- 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/cypher/models/pgsql/translate/predicate.go b/cypher/models/pgsql/translate/predicate.go index 9738ee87..d14b37f0 100644 --- a/cypher/models/pgsql/translate/predicate.go +++ b/cypher/models/pgsql/translate/predicate.go @@ -83,6 +83,8 @@ func (s *Translator) translatePatternPredicate() error { return nil } +// buildPatternPredicates is used by translateMatch to resolve deferred pattern predicate +// futures collected for the current MATCH/OPTIONAL MATCH query part's WHERE expressions func (s *Translator) buildPatternPredicates() error { for _, predicateFuture := range s.query.CurrentPart().patternPredicates { var ( @@ -142,7 +144,17 @@ func (s *Translator) buildPatternPredicates() error { }) } } else { - if traversalStepQuery, err := s.buildTraversalPatternRoot(traversalStep.Frame, traversalStep); err != nil { + var ( + traversalStepQuery pgsql.Query + err error + ) + if traversalStep.Direction != graph.DirectionBoth && (traversalStep.LeftNodeBound || traversalStep.RightNodeBound) { + traversalStepQuery, err = s.buildTraversalPatternRootWithOuterCorrelation(traversalStep.Frame, traversalStep) + } else { + traversalStepQuery, err = s.buildTraversalPatternRoot(traversalStep.Frame, traversalStep) + } + + if err != nil { return err } else { subQuery.AddCTE(pgsql.CommonTableExpression{ diff --git a/cypher/models/pgsql/translate/traversal.go b/cypher/models/pgsql/translate/traversal.go index b46289bc..9a325403 100644 --- a/cypher/models/pgsql/translate/traversal.go +++ b/cypher/models/pgsql/translate/traversal.go @@ -51,7 +51,8 @@ func (s *Translator) buildDirectionlessTraversalPatternRoot(traversalStep *Trave }, JoinOperator: pgsql.JoinOperator{ JoinType: pgsql.JoinTypeInner, - Constraint: pgsql.OptionalAnd(rightJoinLocal, traversalStep.RightNodeJoinCondition)}, + Constraint: pgsql.OptionalAnd(rightJoinLocal, traversalStep.RightNodeJoinCondition), + }, }}, }) @@ -138,6 +139,96 @@ func (s *Translator) buildDirectionlessTraversalPatternRoot(traversalStep *Trave }, 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) + } + + var ( + // Partition right-node constraints: only locally-scoped terms go into JOIN ON. + // Constraints that reference comma-connected CTEs (e.g. s0.i0 from a prior WITH) + // must remain in WHERE — they are out of scope inside an explicit JOIN chain. + rightJoinLocal, rightJoinExternal = partitionConstraintByLocality( + traversalStep.RightNodeConstraints, + pgsql.AsIdentifierSet(traversalStep.RightNode.Identifier, traversalStep.Edge.Identifier), + ) + + nextSelect = pgsql.Select{ + Projection: traversalStep.Projection, + } + ) + + if traversalStep.LeftNodeBound { + 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.RightNode.Identifier), + }, + JoinOperator: pgsql.JoinOperator{ + JoinType: pgsql.JoinTypeInner, + Constraint: pgsql.OptionalAnd(rightJoinLocal, traversalStep.RightNodeJoinCondition), + }, + }}, + }) + + nextSelect.Where = pgsql.OptionalAnd(traversalStep.LeftNodeConstraints, nextSelect.Where) + nextSelect.Where = pgsql.OptionalAnd(traversalStep.LeftNodeJoinCondition, nextSelect.Where) + nextSelect.Where = pgsql.OptionalAnd(traversalStep.EdgeConstraints.Expression, nextSelect.Where) + nextSelect.Where = pgsql.OptionalAnd(rightJoinExternal, nextSelect.Where) + + return pgsql.Query{ + Body: nextSelect, + }, nil + } else if traversalStep.RightNodeBound { + // Right node was already materialized in a previous frame. + // + // We have to promote that frame to the explicit JOIN root so that RightNodeJoinCondition can reference + // it in the ON clause. PostgreSQL forbids referencing a comma-joined table inside a subsequent + // explicit JOIN's ON clause. + leftJoinLocal, leftJoinExternal := partitionConstraintByLocality( + traversalStep.LeftNodeConstraints, + pgsql.AsIdentifierSet(traversalStep.LeftNode.Identifier, traversalStep.Edge.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), + }, + }}, + }) + + nextSelect.Where = pgsql.OptionalAnd(rightJoinLocal, nextSelect.Where) + 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 + } else { + // There is nothing to do to preserve outer bounds correlation - do the unbound traversal step + return s.buildTraversalPatternRoot(partFrame, traversalStep) + } +} + func (s *Translator) buildTraversalPatternRoot(partFrame *Frame, traversalStep *TraversalStep) (pgsql.Query, error) { if traversalStep.Direction == graph.DirectionBoth { return s.buildDirectionlessTraversalPatternRoot(traversalStep) From cda4bdd4b876c825ce159d5159988663b3230ed3 Mon Sep 17 00:00:00 2001 From: Sean Johnson Date: Thu, 7 May 2026 15:46:27 -0500 Subject: [PATCH 4/7] chore(test): translation tests for pattern predicates fix --- cypher/models/pgsql/test/pattern_predicate_shape_test.go | 4 ++-- cypher/models/pgsql/test/translation_cases/nodes.sql | 6 +++--- .../pgsql/test/translation_cases/stepwise_traversal.sql | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cypher/models/pgsql/test/pattern_predicate_shape_test.go b/cypher/models/pgsql/test/pattern_predicate_shape_test.go index fa123855..fe755f33 100644 --- a/cypher/models/pgsql/test/pattern_predicate_shape_test.go +++ b/cypher/models/pgsql/test/pattern_predicate_shape_test.go @@ -74,7 +74,7 @@ func TestTranslate_PatternPredicateInboundShape_CypherFrontend(t *testing.T) { t.Fatalf("failed to parse cypher query: %v", err) } - translatedQuery, err := translate.Translate(context.Background(), regularQuery, newKindMapper(), nil) + translatedQuery, err := translate.Translate(context.Background(), regularQuery, newKindMapper(), nil, translate.DefaultGraphID) if err != nil { t.Fatalf("failed to translate cypher query: %v", err) } @@ -101,7 +101,7 @@ func TestTranslate_PatternPredicateInboundShape_GraphFrontend(t *testing.T) { t.Fatalf("failed to build graph frontend query: %v", err) } - translatedQuery, err := translate.Translate(context.Background(), rawQuery, newKindMapper(), nil) + translatedQuery, err := translate.Translate(context.Background(), rawQuery, newKindMapper(), nil, translate.DefaultGraphID) if err != nil { t.Fatalf("failed to translate graph frontend query: %v", err) } diff --git a/cypher/models/pgsql/test/translation_cases/nodes.sql b/cypher/models/pgsql/test/translation_cases/nodes.sql index 4aad2149..11472e0c 100644 --- a/cypher/models/pgsql/test/translation_cases/nodes.sql +++ b/cypher/models/pgsql/test/translation_cases/nodes.sql @@ -208,13 +208,13 @@ 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 exists (select 1 from edge e0 where e0.start_id = (s0.n0).id or e0.end_id = (s0.n0).id)); -- case: match (s) where not (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 (not (with s1 as (select 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.id = e0.end_id), s2 as (select 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) select count(*) > 0 from s2)); +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, (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.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) 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 ((n1.properties -> 'name'))::jsonb = to_jsonb(('n3')::text)::jsonb and (n1.id = e0.end_id or n1.id = e0.start_id) where ((s0.n0).id <> n1.id) and ((e0.properties -> 'prop'))::jsonb = to_jsonb(('a')::text)::jsonb) 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 s0 join edge e0 on (s0.n0).id = e0.end_id join node n1 on ((n1.properties -> 'name'))::jsonb = to_jsonb(('n3')::text)::jsonb and n1.id = e0.start_id where ((e0.properties -> 'prop'))::jsonb = to_jsonb(('a')::text)::jsonb) 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 ((n1.properties -> 'name'))::jsonb = to_jsonb(('n3')::text)::jsonb and n1.id = e0.start_id where ((e0.properties -> 'prop'))::jsonb = to_jsonb(('a')::text)::jsonb and (s0.n0).id = e0.end_id) select count(*) > 0 from s1)); -- case: match (n:NodeKind1) where n.distinguishedname = toUpper('admin') return n with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'distinguishedname'))::jsonb = to_jsonb((upper('admin')::text)::text)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s0.n0 as n from s0; @@ -229,7 +229,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 where (cypher_ends_with((n0.properties ->> 'distinguishedname'), (upper('admin')::text)::text)::bool) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s0.n0 as n from s0; -- 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.start_id join node n1 on ((n1.properties -> 'name'))::jsonb = to_jsonb(('n3')::text)::jsonb and n1.id = e0.end_id where ((e0.properties -> 'prop'))::jsonb = to_jsonb(('a')::text)::jsonb) 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 ((n1.properties -> 'name'))::jsonb = to_jsonb(('n3')::text)::jsonb and n1.id = e0.end_id where ((e0.properties -> 'prop'))::jsonb = to_jsonb(('a')::text)::jsonb and (s0.n0).id = e0.start_id) select count(*) > 0 from s1)); -- 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)); diff --git a/cypher/models/pgsql/test/translation_cases/stepwise_traversal.sql b/cypher/models/pgsql/test/translation_cases/stepwise_traversal.sql index 88fa94a9..1369b5f4 100644 --- a/cypher/models/pgsql/test/translation_cases/stepwise_traversal.sql +++ b/cypher/models/pgsql/test/translation_cases/stepwise_traversal.sql @@ -98,7 +98,7 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1 with s0 as (select (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, 4]::int2[])) select ((s0.n0).properties -> 'name'), ((s0.n1).properties -> 'name') from s0; -- case: match (s)-[r:EdgeKind1]->() where (s)-[r {prop: 'a'}]->() return s -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 from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.id = e0.end_id where ((e0.properties -> 'prop'))::jsonb = to_jsonb(('a')::text)::jsonb and e0.kind_id = any (array [3]::int2[])) select s0.n0 as s from s0 where ((with s1 as (select s0.e0 as e0, s0.n0 as n0 from s0 join edge e0 on (s0.n0).id = (s0.e0).start_id join node n2 on n2.id = (s0.e0).end_id) select count(*) > 0 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 from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.id = e0.end_id where ((e0.properties -> 'prop'))::jsonb = to_jsonb(('a')::text)::jsonb and e0.kind_id = any (array [3]::int2[])) select s0.n0 as s from s0 where ((with s1 as (select s0.e0 as e0, s0.n0 as n0 from edge e0 join node n2 on n2.id = (s0.e0).end_id where (s0.n0).id = (s0.e0).start_id) select count(*) > 0 from s1)); -- case: match (s)-[r:EdgeKind1]->(e) where not (s.system_tags contains 'admin_tier_0') and id(e) = 1 return id(s), labels(s), id(r), type(r) 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 n1 on (n1.id = 1) and n1.id = e0.end_id join node n0 on (not (coalesce((n0.properties ->> 'system_tags'), '')::text like '%admin\_tier\_0%')) and n0.id = e0.start_id where e0.kind_id = any (array [3]::int2[])) select (s0.n0).id, (array(select _kind.name from generate_subscripts((s0.n0).kind_ids, 1) as _kind_idx, kind _kind where _kind.id = ((s0.n0).kind_ids)[_kind_idx] order by _kind_idx))::text[], (s0.e0).id, kind_name((s0.e0).kind_id)::text from s0; From afd5d42a7815864de52cde7869cb2849776e55b9 Mon Sep 17 00:00:00 2001 From: Sean Johnson Date: Mon, 11 May 2026 17:54:59 -0500 Subject: [PATCH 5/7] add 'empty' testdata for cases w/ comprehensive fixtures --- .../testdata/cases/pattern_predicate_direction_inline.json | 1 + integration/testdata/empty.json | 6 ++++++ 2 files changed, 7 insertions(+) create mode 100644 integration/testdata/empty.json diff --git a/integration/testdata/cases/pattern_predicate_direction_inline.json b/integration/testdata/cases/pattern_predicate_direction_inline.json index 2d1ee60c..4d254071 100644 --- a/integration/testdata/cases/pattern_predicate_direction_inline.json +++ b/integration/testdata/cases/pattern_predicate_direction_inline.json @@ -1,4 +1,5 @@ { + "dataset": "empty", "cases": [ { "name": "regression: incoming negated pattern with contains predicate (left-directed form)", diff --git a/integration/testdata/empty.json b/integration/testdata/empty.json new file mode 100644 index 00000000..98361e81 --- /dev/null +++ b/integration/testdata/empty.json @@ -0,0 +1,6 @@ +{ + "graph": { + "nodes": [], + "edges": [] + } +} From a5c8d42366a497dc2c017a07ab629e77c4beeffc Mon Sep 17 00:00:00 2001 From: Sean Johnson Date: Tue, 12 May 2026 11:35:01 -0500 Subject: [PATCH 6/7] cleaning up tests --- .../test/pattern_predicate_shape_test.go | 115 --------------- .../pgsql/test/translation_cases/nodes.sql | 3 + integration/testdata/bed6695.json | 86 +++++++++++- .../cases/bed6695-pattern_predicates.json | 61 ++++++++ .../pattern_predicate_direction_inline.json | 131 ------------------ .../testdata/cases/pattern_predicates.json | 25 ---- integration/testdata/empty.json | 6 - 7 files changed, 147 insertions(+), 280 deletions(-) delete mode 100644 cypher/models/pgsql/test/pattern_predicate_shape_test.go create mode 100644 integration/testdata/cases/bed6695-pattern_predicates.json delete mode 100644 integration/testdata/cases/pattern_predicate_direction_inline.json delete mode 100644 integration/testdata/cases/pattern_predicates.json delete mode 100644 integration/testdata/empty.json diff --git a/cypher/models/pgsql/test/pattern_predicate_shape_test.go b/cypher/models/pgsql/test/pattern_predicate_shape_test.go deleted file mode 100644 index fe755f33..00000000 --- a/cypher/models/pgsql/test/pattern_predicate_shape_test.go +++ /dev/null @@ -1,115 +0,0 @@ -package test - -import ( - "context" - "strings" - "testing" - - "github.com/specterops/dawgs/cypher/frontend" - "github.com/specterops/dawgs/cypher/models/cypher" - "github.com/specterops/dawgs/cypher/models/pgsql/translate" - "github.com/specterops/dawgs/graph" - "github.com/specterops/dawgs/query" -) - -func normalizeSQL(sqlQuery string) string { - return strings.Join(strings.Fields(strings.ToLower(sqlQuery)), " ") -} - -func assertInboundPatternPredicateShape(t *testing.T, sqlQuery string) { - t.Helper() - - normalized := normalizeSQL(sqlQuery) - - requiredFragments := []string{ - "select count(*) > 0 from s1", - "from edge e0 join node n1", - "(s0.n0).id = e0.end_id", - "n1.kind_ids operator (pg_catalog.@>) array [1]::int2[]", - "e0.kind_id = any (array [3]::int2[])", - } - - for _, fragment := range requiredFragments { - if !strings.Contains(normalized, fragment) { - t.Fatalf("expected SQL to contain fragment %q but it did not:\n%s", fragment, sqlQuery) - } - } - - forbiddenFragments := []string{ - "from s0 join edge e0 on (s0.n0).id = e0.end_id", - } - - for _, fragment := range forbiddenFragments { - if strings.Contains(normalized, fragment) { - t.Fatalf("expected SQL to avoid fragment %q but it was present:\n%s", fragment, sqlQuery) - } - } -} - -func buildInboundNodeKind1EdgeKind1PatternPredicate(symbol string) *cypher.PatternPredicate { - patternPredicate := cypher.NewPatternPredicate() - - patternPredicate.AddElement(&cypher.NodePattern{ - Variable: cypher.NewVariableWithSymbol(symbol), - }) - - patternPredicate.AddElement(&cypher.RelationshipPattern{ - Kinds: graph.Kinds{EdgeKind1}, - Direction: graph.DirectionInbound, - }) - - patternPredicate.AddElement(&cypher.NodePattern{ - Kinds: graph.Kinds{NodeKind1}, - }) - - return patternPredicate -} - -func TestTranslate_PatternPredicateInboundShape_CypherFrontend(t *testing.T) { - regularQuery, err := frontend.ParseCypher( - frontend.NewContext(), - "match (g:NodeKind2) where not ((g)<-[:EdgeKind1]-(:NodeKind1)) return g", - ) - if err != nil { - t.Fatalf("failed to parse cypher query: %v", err) - } - - translatedQuery, err := translate.Translate(context.Background(), regularQuery, newKindMapper(), nil, translate.DefaultGraphID) - if err != nil { - t.Fatalf("failed to translate cypher query: %v", err) - } - - formattedQuery, err := translate.Translated(translatedQuery) - if err != nil { - t.Fatalf("failed to format translated SQL query: %v", err) - } - - assertInboundPatternPredicateShape(t, formattedQuery) -} - -func TestTranslate_PatternPredicateInboundShape_GraphFrontend(t *testing.T) { - builder := query.NewBuilderWithCriteria( - query.Where(query.And( - query.Kind(query.Node(), NodeKind2), - query.Not(buildInboundNodeKind1EdgeKind1PatternPredicate(query.NodeSymbol)), - )), - query.Returning(query.Node()), - ) - - rawQuery, err := builder.Build(false) - if err != nil { - t.Fatalf("failed to build graph frontend query: %v", err) - } - - translatedQuery, err := translate.Translate(context.Background(), rawQuery, newKindMapper(), nil, translate.DefaultGraphID) - if err != nil { - t.Fatalf("failed to translate graph frontend query: %v", err) - } - - formattedQuery, err := translate.Translated(translatedQuery) - if err != nil { - t.Fatalf("failed to format translated SQL query: %v", err) - } - - assertInboundPatternPredicateShape(t, formattedQuery) -} diff --git a/cypher/models/pgsql/test/translation_cases/nodes.sql b/cypher/models/pgsql/test/translation_cases/nodes.sql index 11472e0c..116db752 100644 --- a/cypher/models/pgsql/test/translation_cases/nodes.sql +++ b/cypher/models/pgsql/test/translation_cases/nodes.sql @@ -338,3 +338,6 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from -- case: match (n) where n.name = "alpha' || (SELECT inet_server_addr()::text::int) || '" return n with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('alpha'' || (SELECT inet_server_addr()::text::int) || ''')::text)::jsonb)) select s0.n0 as n from s0; + +-- 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/integration/testdata/bed6695.json b/integration/testdata/bed6695.json index a1ec9d28..5523d8c1 100644 --- a/integration/testdata/bed6695.json +++ b/integration/testdata/bed6695.json @@ -3,24 +3,84 @@ "nodes": [ { "id": "key_admins_empty", - "kinds": ["NodeKind1"], + "kinds": [ + "NodeKind1" + ], "properties": { "name": "BED-6695 KEY ADMINS EMPTY" } }, { "id": "key_admins_membered", - "kinds": ["NodeKind1"], + "kinds": [ + "NodeKind1" + ], "properties": { "name": "BED-6695 KEY ADMINS MEMBERED" } }, { "id": "member_user", - "kinds": ["NodeKind2"], + "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" + } } ], "edges": [ @@ -28,6 +88,26 @@ "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" } ] } diff --git a/integration/testdata/cases/bed6695-pattern_predicates.json b/integration/testdata/cases/bed6695-pattern_predicates.json new file mode 100644 index 00000000..956bbe9f --- /dev/null +++ b/integration/testdata/cases/bed6695-pattern_predicates.json @@ -0,0 +1,61 @@ +{ + "dataset": "bed6695", + "cases": [ + { + "name": "BED-6695 negated incoming pattern predicate with contains filter remains row-correlated", + "cypher": "match (g:NodeKind1) where g.name contains 'BED-6695 KEY ADMINS' and not ((g)<-[:EdgeKind1]-(:NodeKind2)) return count(g)", + "assert": { + "exact_int": 1 + } + }, + { + "name": "BED-6695 flipped equivalent pattern predicate returns the same count", + "cypher": "match (g:NodeKind1) where g.name contains 'BED-6695 KEY ADMINS' and not ((:NodeKind2)-[:EdgeKind1]->(g)) return count(g)", + "assert": { + "exact_int": 1 + } + }, + { + "name": "BED-6695 positive incoming pattern predicate with contains filter remains row-correlated", + "cypher": "match (g:NodeKind1) where g.name contains 'BED-6695 KEY ADMINS' and (g)<-[:EdgeKind1]-(:NodeKind2) return count(g)", + "assert": { + "exact_int": 1 + } + }, + { + "name": "BED-6695 negated incoming pattern predicate without property filter remains row-correlated", + "cypher": "match (g:NodeKind1) where not ((g)<-[:EdgeKind1]-(:NodeKind2)) return count(g)", + "assert": { + "exact_int": 3 + } + }, + { + "name": "BED-6695 incoming negated pattern without outer selectivity (left-directed form)", + "cypher": "match (g:NodeKind2) where not ((g)<-[:EdgeKind1]-(:NodeKind1)) return g", + "assert": { + "row_count": 3 + } + }, + { + "name": "BED-6695 incoming negated pattern without outer selectivity (right-directed equivalent)", + "cypher": "match (g:NodeKind2) where not ((:NodeKind1)-[:EdgeKind1]->(g)) return g", + "assert": { + "row_count": 3 + } + }, + { + "name": "BED-6695 connected key admins group should be excluded by negated incoming pattern (left-directed)", + "cypher": "match (g:NodeKind2) where g.name = 'KEY ADMINS BETA' and not ((g)<-[:EdgeKind1]-(:NodeKind1)) return g", + "assert": { + "row_count": 0 + } + }, + { + "name": "BED-6695 connected key admins group should be excluded by negated incoming pattern (right-directed equivalent)", + "cypher": "match (g:NodeKind2) where g.name = 'KEY ADMINS BETA' and not ((:NodeKind1)-[:EdgeKind1]->(g)) return g", + "assert": { + "row_count": 0 + } + } + ] +} diff --git a/integration/testdata/cases/pattern_predicate_direction_inline.json b/integration/testdata/cases/pattern_predicate_direction_inline.json deleted file mode 100644 index 4d254071..00000000 --- a/integration/testdata/cases/pattern_predicate_direction_inline.json +++ /dev/null @@ -1,131 +0,0 @@ -{ - "dataset": "empty", - "cases": [ - { - "name": "regression: incoming negated pattern with contains predicate (left-directed form)", - "cypher": "match (g:NodeKind2) where g.name contains 'KEY ADMINS' and not ((g)<-[:EdgeKind1]-(:NodeKind1)) return g", - "fixture": { - "nodes": [ - {"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"}} - ], - "edges": [ - {"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"} - ] - }, - "assert": {"row_count": 2} - }, - { - "name": "regression: incoming negated pattern with contains predicate (right-directed equivalent)", - "cypher": "match (g:NodeKind2) where g.name contains 'KEY ADMINS' and not ((:NodeKind1)-[:EdgeKind1]->(g)) return g", - "fixture": { - "nodes": [ - {"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"}} - ], - "edges": [ - {"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"} - ] - }, - "assert": {"row_count": 2} - }, - { - "name": "regression: incoming negated pattern without outer selectivity (left-directed form)", - "cypher": "match (g:NodeKind2) where not ((g)<-[:EdgeKind1]-(:NodeKind1)) return g", - "fixture": { - "nodes": [ - {"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"}} - ], - "edges": [ - {"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"} - ] - }, - "assert": {"row_count": 2} - }, - { - "name": "regression: incoming negated pattern without outer selectivity (right-directed equivalent)", - "cypher": "match (g:NodeKind2) where not ((:NodeKind1)-[:EdgeKind1]->(g)) return g", - "fixture": { - "nodes": [ - {"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"}} - ], - "edges": [ - {"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"} - ] - }, - "assert": {"row_count": 2} - }, - { - "name": "regression: connected key admins group should be excluded by negated incoming pattern (left-directed)", - "cypher": "match (g:NodeKind2) where g.name = 'KEY ADMINS BETA' and not ((g)<-[:EdgeKind1]-(:NodeKind1)) return g", - "fixture": { - "nodes": [ - {"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"}} - ], - "edges": [ - {"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"} - ] - }, - "assert": {"row_count": 0} - }, - { - "name": "regression: connected key admins group should be excluded by negated incoming pattern (right-directed equivalent)", - "cypher": "match (g:NodeKind2) where g.name = 'KEY ADMINS BETA' and not ((:NodeKind1)-[:EdgeKind1]->(g)) return g", - "fixture": { - "nodes": [ - {"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"}} - ], - "edges": [ - {"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"} - ] - }, - "assert": {"row_count": 0} - } - ] -} diff --git a/integration/testdata/cases/pattern_predicates.json b/integration/testdata/cases/pattern_predicates.json deleted file mode 100644 index 95e9d6f2..00000000 --- a/integration/testdata/cases/pattern_predicates.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "dataset": "bed6695", - "cases": [ - { - "name": "BED-6695 negated incoming pattern predicate with contains filter remains row-correlated", - "cypher": "match (g:NodeKind1) where g.name contains 'BED-6695 KEY ADMINS' and not ((g)<-[:EdgeKind1]-(:NodeKind2)) return count(g)", - "assert": {"exact_int": 1} - }, - { - "name": "BED-6695 flipped equivalent pattern predicate returns the same count", - "cypher": "match (g:NodeKind1) where g.name contains 'BED-6695 KEY ADMINS' and not ((:NodeKind2)-[:EdgeKind1]->(g)) return count(g)", - "assert": {"exact_int": 1} - }, - { - "name": "BED-6695 positive incoming pattern predicate with contains filter remains row-correlated", - "cypher": "match (g:NodeKind1) where g.name contains 'BED-6695 KEY ADMINS' and (g)<-[:EdgeKind1]-(:NodeKind2) return count(g)", - "assert": {"exact_int": 1} - }, - { - "name": "BED-6695 negated incoming pattern predicate without property filter remains row-correlated", - "cypher": "match (g:NodeKind1) where not ((g)<-[:EdgeKind1]-(:NodeKind2)) return count(g)", - "assert": {"exact_int": 1} - } - ] -} diff --git a/integration/testdata/empty.json b/integration/testdata/empty.json deleted file mode 100644 index 98361e81..00000000 --- a/integration/testdata/empty.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "graph": { - "nodes": [], - "edges": [] - } -} From 88b2098c15e92913866b1a9c1da4c4953bcf1cf6 Mon Sep 17 00:00:00 2001 From: Sean Johnson Date: Wed, 13 May 2026 19:41:36 -0500 Subject: [PATCH 7/7] chore(integration): tests for undirected pattern predicates fix --- .../cases/bed6695-pattern_predicates.json | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/integration/testdata/cases/bed6695-pattern_predicates.json b/integration/testdata/cases/bed6695-pattern_predicates.json index 956bbe9f..c02c40c8 100644 --- a/integration/testdata/cases/bed6695-pattern_predicates.json +++ b/integration/testdata/cases/bed6695-pattern_predicates.json @@ -22,6 +22,27 @@ "exact_int": 1 } }, + { + "name": "BED-6695 negated undirected pattern predicate with contains filter remains row-correlated", + "cypher": "match (g:NodeKind1) where g.name contains 'BED-6695 KEY ADMINS' and not ((g)-[:EdgeKind1]-(:NodeKind2)) return count(g)", + "assert": { + "exact_int": 1 + } + }, + { + "name": "BED-6695 flipped undirected pattern predicate with contains filter returns the same count", + "cypher": "match (g:NodeKind1) where g.name contains 'BED-6695 KEY ADMINS' and not ((:NodeKind2)-[:EdgeKind1]-(g)) return count(g)", + "assert": { + "exact_int": 1 + } + }, + { + "name": "BED-6695 positive undirected pattern predicate with contains filter remains row-correlated", + "cypher": "match (g:NodeKind1) where g.name contains 'BED-6695 KEY ADMINS' and (g)-[:EdgeKind1]-(:NodeKind2) return count(g)", + "assert": { + "exact_int": 1 + } + }, { "name": "BED-6695 negated incoming pattern predicate without property filter remains row-correlated", "cypher": "match (g:NodeKind1) where not ((g)<-[:EdgeKind1]-(:NodeKind2)) return count(g)", @@ -29,6 +50,13 @@ "exact_int": 3 } }, + { + "name": "BED-6695 negated undirected pattern predicate without property filter remains row-correlated", + "cypher": "match (g:NodeKind1) where not ((g)-[:EdgeKind1]-(:NodeKind2)) return count(g)", + "assert": { + "exact_int": 1 + } + }, { "name": "BED-6695 incoming negated pattern without outer selectivity (left-directed form)", "cypher": "match (g:NodeKind2) where not ((g)<-[:EdgeKind1]-(:NodeKind1)) return g", @@ -43,6 +71,20 @@ "row_count": 3 } }, + { + "name": "BED-6695 undirected negated pattern without outer selectivity (left-bound form)", + "cypher": "match (g:NodeKind2) where not ((g)-[:EdgeKind1]-(:NodeKind1)) return g", + "assert": { + "row_count": 2 + } + }, + { + "name": "BED-6695 undirected negated pattern without outer selectivity (right-bound equivalent)", + "cypher": "match (g:NodeKind2) where not ((:NodeKind1)-[:EdgeKind1]-(g)) return g", + "assert": { + "row_count": 2 + } + }, { "name": "BED-6695 connected key admins group should be excluded by negated incoming pattern (left-directed)", "cypher": "match (g:NodeKind2) where g.name = 'KEY ADMINS BETA' and not ((g)<-[:EdgeKind1]-(:NodeKind1)) return g", @@ -56,6 +98,20 @@ "assert": { "row_count": 0 } + }, + { + "name": "BED-6695 connected key admins group should be excluded by negated undirected pattern (left-bound form)", + "cypher": "match (g:NodeKind2) where g.name = 'KEY ADMINS BETA' and not ((g)-[:EdgeKind1]-(:NodeKind1)) return g", + "assert": { + "row_count": 0 + } + }, + { + "name": "BED-6695 connected key admins group should be excluded by negated undirected pattern (right-bound equivalent)", + "cypher": "match (g:NodeKind2) where g.name = 'KEY ADMINS BETA' and not ((:NodeKind1)-[:EdgeKind1]-(g)) return g", + "assert": { + "row_count": 0 + } } ] }