diff --git a/cypher/models/pgsql/test/translation_cases/nodes.sql b/cypher/models/pgsql/test/translation_cases/nodes.sql index 4aad2149..116db752 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)); @@ -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/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; 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) diff --git a/integration/testdata/bed6695.json b/integration/testdata/bed6695.json new file mode 100644 index 00000000..5523d8c1 --- /dev/null +++ b/integration/testdata/bed6695.json @@ -0,0 +1,114 @@ +{ + "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" + } + } + ], + "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" + } + ] + } +} diff --git a/integration/testdata/cases/bed6695-pattern_predicates.json b/integration/testdata/cases/bed6695-pattern_predicates.json new file mode 100644 index 00000000..c02c40c8 --- /dev/null +++ b/integration/testdata/cases/bed6695-pattern_predicates.json @@ -0,0 +1,117 @@ +{ + "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 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)", + "assert": { + "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", + "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 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", + "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 + } + }, + { + "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 + } + } + ] +}