From 2063eaba925e8fe10e8cf900aceb4d9482142e93 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Mon, 11 May 2026 15:59:43 -0700 Subject: [PATCH 1/6] fix (pgsql): fix cypher semantic drift for labels function --- cypher/models/pgsql/functions.go | 1 + cypher/models/pgsql/pgtypes.go | 2 + .../pgsql/test/translation_cases/nodes.sql | 14 ++++- .../translation_cases/stepwise_traversal.sql | 3 +- .../pgsql/test/translation_cases/unwind.sql | 3 ++ cypher/models/pgsql/translate/expression.go | 14 +++++ cypher/models/pgsql/translate/function.go | 53 ++++++++++++++++++- integration/testdata/cases/nodes.json | 29 +++++++++- integration/testdata/cases/unwind_inline.json | 12 +++++ 9 files changed, 125 insertions(+), 6 deletions(-) diff --git a/cypher/models/pgsql/functions.go b/cypher/models/pgsql/functions.go index 89edb0f..c42fef2 100644 --- a/cypher/models/pgsql/functions.go +++ b/cypher/models/pgsql/functions.go @@ -43,6 +43,7 @@ const ( FunctionOrderedEdgesToPath Identifier = "ordered_edges_to_path" FunctionNodesToPath Identifier = "nodes_to_path" FunctionExtract Identifier = "extract" + FunctionGenerateSubscripts Identifier = "generate_subscripts" ) func IsAggregateFunction(function Identifier) bool { diff --git a/cypher/models/pgsql/pgtypes.go b/cypher/models/pgsql/pgtypes.go index 70bad2a..4e94ca5 100644 --- a/cypher/models/pgsql/pgtypes.go +++ b/cypher/models/pgsql/pgtypes.go @@ -18,8 +18,10 @@ const ( TableNode Identifier = "node" TableEdge Identifier = "edge" + TableKind Identifier = "kind" ColumnID Identifier = "id" + ColumnName Identifier = "name" ColumnPath Identifier = "path" ColumnProperties Identifier = "properties" ColumnKindIDs Identifier = "kind_ids" diff --git a/cypher/models/pgsql/test/translation_cases/nodes.sql b/cypher/models/pgsql/test/translation_cases/nodes.sql index 308130f..6c2961f 100644 --- a/cypher/models/pgsql/test/translation_cases/nodes.sql +++ b/cypher/models/pgsql/test/translation_cases/nodes.sql @@ -15,7 +15,19 @@ -- SPDX-License-Identifier: Apache-2.0 -- case: match (n) return labels(n) -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select (s0.n0).kind_ids from s0; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select (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[] from s0; + +-- case: match (n) where 'NodeKind1' in labels(n) return n +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select s0.n0 as n from s0 where ('NodeKind1' = any ((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[])); + +-- case: match (n) where labels(n) = ['NodeKind1', 'NodeKind2'] return n +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select s0.n0 as n from s0 where ((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[] = array ['NodeKind1', 'NodeKind2']::text[]); + +-- case: match (n) where n.name = 'n3' with labels(n) as labels return labels, size(labels) +with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n3')::text)::jsonb)) select (array(select _kind.name from generate_subscripts((s1.n0).kind_ids, 1) as _kind_idx, kind _kind where _kind.id = ((s1.n0).kind_ids)[_kind_idx] order by _kind_idx))::text[] as i0 from s1) select s0.i0 as labels, cardinality(s0.i0)::int from s0; + +-- case: match (n) with 1 as _kind_idx, n return labels(n), _kind_idx +with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select 1 as i0, s1.n0 as n0 from s1) select (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.i0 as _kind_idx from s0; -- case: match (n) where ID(n) = 1 return n with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (n0.id = 1)) select s0.n0 as n from s0; diff --git a/cypher/models/pgsql/test/translation_cases/stepwise_traversal.sql b/cypher/models/pgsql/test/translation_cases/stepwise_traversal.sql index a7e5a2f..e1bd333 100644 --- a/cypher/models/pgsql/test/translation_cases/stepwise_traversal.sql +++ b/cypher/models/pgsql/test/translation_cases/stepwise_traversal.sql @@ -89,7 +89,7 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1 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)); -- 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, (s0.n0).kind_ids, (s0.e0).id, (s0.e0).kind_id from s0; +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, (s0.e0).kind_id from s0; -- case: match (s)-[r]->(e) where s:NodeKind1 and toLower(s.name) starts with 'test' and r:EdgeKind1 and id(e) in [1, 2] return r limit 1 with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on (n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and lower((n0.properties ->> 'name'))::text like 'test%') and n0.id = e0.start_id join node n1 on (n1.id = any (array [1, 2]::int8[])) and n1.id = e0.end_id where (e0.kind_id = any (array [3]::int2[])) limit 1) select s0.e0 as r from s0 limit 1; @@ -105,4 +105,3 @@ with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::e -- case: match (s:NodeKind1:NodeKind2)-[r:EdgeKind1|EdgeKind2]->(e:NodeKind2:NodeKind1) return s.name, e.name 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, 2]::int2[] and n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [2, 1]::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; - diff --git a/cypher/models/pgsql/test/translation_cases/unwind.sql b/cypher/models/pgsql/test/translation_cases/unwind.sql index 04a96df..9129234 100644 --- a/cypher/models/pgsql/test/translation_cases/unwind.sql +++ b/cypher/models/pgsql/test/translation_cases/unwind.sql @@ -46,3 +46,6 @@ with s0 as (select array [1, 2, 3]::int8[] as i0) select i1 as x from s0, unnest -- case: unwind [1, 2, 3] as x return x select i0 as x from unnest(array [1, 2, 3]::int8[]) as i0; + +-- case: MATCH (n) WHERE n.environmentid = '1234' UNWIND labels(n) AS kind RETURN kind, count(n) AS count +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'environmentid'))::jsonb = to_jsonb(('1234')::text)::jsonb)) select i0 as kind, count(s0.n0)::int8 as count from s0, unnest((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[]) as i0 group by i0; diff --git a/cypher/models/pgsql/translate/expression.go b/cypher/models/pgsql/translate/expression.go index cbf737c..49f8381 100644 --- a/cypher/models/pgsql/translate/expression.go +++ b/cypher/models/pgsql/translate/expression.go @@ -1064,6 +1064,8 @@ func (s *ExpressionTreeTranslator) rewriteBinaryExpression(newExpression *pgsql. switch typedROperand := newExpression.ROperand.(type) { case pgsql.TypeCast: + rewroteTypeCast := false + switch typedInnerOperand := typedROperand.Expression.(type) { case *pgsql.BinaryExpression: if propertyLookup, isPropertyLookup := expressionToPropertyLookupBinaryExpression(typedInnerOperand); isPropertyLookup { @@ -1086,10 +1088,22 @@ func (s *ExpressionTreeTranslator) rewriteBinaryExpression(newExpression *pgsql. CastType: leftArrayHint, }, ) + rewroteTypeCast = true } } } + if !rewroteTypeCast { + if lOperandTypeHint, err := InferExpressionType(newExpression.LOperand); err != nil { + return err + } else if lOperandTypeHint.IsArrayType() { + s.PushOperand(pgsql.NewLiteral(false, pgsql.Boolean)) + return nil + } else { + newExpression.ROperand = pgsql.NewAnyExpression(newExpression.ROperand, typedROperand.TypeHint()) + } + } + case pgsql.TypeHinted: if lOperandTypeHint, err := InferExpressionType(newExpression.LOperand); err != nil { return err diff --git a/cypher/models/pgsql/translate/function.go b/cypher/models/pgsql/translate/function.go index bf7f9cb..8862c1d 100644 --- a/cypher/models/pgsql/translate/function.go +++ b/cypher/models/pgsql/translate/function.go @@ -233,6 +233,57 @@ func (s *Translator) translatePathComponentFunction(functionInvocation *cypher.F return nil } +func translateNodeLabelsExpression(identifier pgsql.Identifier) pgsql.TypeHinted { + const ( + kindAlias pgsql.Identifier = "_kind" + kindIndexAlias pgsql.Identifier = "_kind_idx" + ) + + kindIDs := pgsql.CompoundIdentifier{identifier, pgsql.ColumnKindIDs} + + return pgsql.NewTypeCast(pgsql.ArrayExpression{ + Expression: pgsql.Query{ + Body: pgsql.Select{ + Projection: pgsql.Projection{ + pgsql.CompoundIdentifier{kindAlias, pgsql.ColumnName}, + }, + From: []pgsql.FromClause{ + { + Source: pgsql.AliasedExpression{ + Expression: pgsql.FunctionCall{ + Function: pgsql.FunctionGenerateSubscripts, + Parameters: []pgsql.Expression{ + kindIDs, + pgsql.NewLiteral(1, pgsql.Int), + }, + }, + Alias: pgsql.AsOptionalIdentifier(kindIndexAlias), + }, + }, + { + Source: pgsql.TableReference{ + Name: pgsql.CompoundIdentifier{pgsql.TableKind}, + Binding: pgsql.AsOptionalIdentifier(kindAlias), + }, + }, + }, + Where: pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{kindAlias, pgsql.ColumnID}, + pgsql.OperatorEquals, + &pgsql.ArrayIndex{ + Expression: pgsql.NewParenthetical(kindIDs), + Indexes: []pgsql.Expression{kindIndexAlias}, + }, + ), + }, + OrderBy: []*pgsql.OrderBy{{ + Expression: kindIndexAlias, + Ascending: true, + }}, + }, + }, pgsql.TextArray) +} + func (s *Translator) translateFunction(typedExpression *cypher.FunctionInvocation) { switch formattedName := strings.ToLower(typedExpression.Name); formattedName { case cypher.DurationFunction: @@ -301,7 +352,7 @@ func (s *Translator) translateFunction(typedExpression *cypher.FunctionInvocatio } else if identifier, isIdentifier := argument.(pgsql.Identifier); !isIdentifier { s.SetErrorf("expected an identifier for the cypher function: %s but received %T", typedExpression.Name, argument) } else { - s.treeTranslator.PushOperand(pgsql.CompoundIdentifier{identifier, pgsql.ColumnKindIDs}) + s.treeTranslator.PushOperand(translateNodeLabelsExpression(identifier)) } case cypher.CountFunction: diff --git a/integration/testdata/cases/nodes.json b/integration/testdata/cases/nodes.json index 42f44a8..881db79 100644 --- a/integration/testdata/cases/nodes.json +++ b/integration/testdata/cases/nodes.json @@ -3,8 +3,33 @@ "cases": [ { "name": "return kind labels for all nodes", - "cypher": "match (n) return labels(n)", - "assert": {"row_count": 3} + "cypher": "match (n) return labels(n) order by n.name", + "assert": {"ordered_scalar_values": [["NodeKind2"], ["NodeKind1"], ["NodeKind1", "NodeKind2"]]} + }, + { + "name": "return string kind labels from labels()", + "cypher": "match (n) where n.name = 'n3' unwind labels(n) as label return label order by label", + "assert": {"ordered_scalar_values": ["NodeKind1", "NodeKind2"]} + }, + { + "name": "filter nodes by string membership in labels()", + "cypher": "match (n) where 'NodeKind1' in labels(n) return n", + "assert": {"node_ids": ["n1", "n3"]} + }, + { + "name": "filter nodes by exact labels() list equality", + "cypher": "match (n) where labels(n) = ['NodeKind1', 'NodeKind2'] return n", + "assert": {"node_ids": ["n3"]} + }, + { + "name": "carry labels() through with and compute list size", + "cypher": "match (n) where n.name = 'n3' with labels(n) as labels return labels, size(labels)", + "assert": {"row_values": [[["NodeKind1", "NodeKind2"], 2]]} + }, + { + "name": "labels() lookup is isolated from generated alias collisions", + "cypher": "match (n) where n.name = 'n3' with 1 as _kind_idx, n unwind labels(n) as label return _kind_idx, label order by label", + "assert": {"ordered_row_values": [[1, "NodeKind1"], [1, "NodeKind2"]]} }, { "name": "filter any node by string property equality", diff --git a/integration/testdata/cases/unwind_inline.json b/integration/testdata/cases/unwind_inline.json index 87a1822..3cb8cdd 100644 --- a/integration/testdata/cases/unwind_inline.json +++ b/integration/testdata/cases/unwind_inline.json @@ -56,6 +56,18 @@ "edges": [] }, "assert": {"contains_node_with_prop": ["name", "shared"]} + }, + { + "name": "unwind labels and aggregate by string kind", + "cypher": "MATCH (n) WHERE n.environmentid = '1234' UNWIND labels(n) AS kind RETURN kind, count(n) AS count", + "fixture": { + "nodes": [ + {"id": "a", "kinds": ["NodeKind1", "NodeKind2"], "properties": {"environmentid": "1234"}}, + {"id": "b", "kinds": ["NodeKind1"], "properties": {"environmentid": "other"}} + ], + "edges": [] + }, + "assert": {"row_values": [["NodeKind1", 1], ["NodeKind2", 1]]} } ] } From 096e1b0b8f2e83e349afa71e38c26bad1daf248f Mon Sep 17 00:00:00 2001 From: John Hopper Date: Mon, 11 May 2026 16:39:50 -0700 Subject: [PATCH 2/6] feat (pgsql): support relationships, startNode and endNode functions - BP-526 --- cypher/Cypher Syntax Support.md | 26 ++ cypher/models/cypher/functions.go | 3 + cypher/models/pgsql/functions.go | 2 + .../test/translation_cases/multipart.sql | 6 +- .../pgsql/test/translation_cases/nodes.sql | 6 + .../translation_cases/scalar_aggregation.sql | 3 + .../test/translation_cases/shortest_paths.sql | 4 + cypher/models/pgsql/test/translation_test.go | 27 ++ cypher/models/pgsql/translate/expression.go | 28 ++ cypher/models/pgsql/translate/function.go | 89 +++- .../models/pgsql/translate/path_functions.go | 388 ++++++++++++++++++ cypher/models/pgsql/translate/projection.go | 24 +- cypher/models/pgsql/translate/with.go | 6 +- drivers/pg/query/sql/schema_down.sql | 2 + drivers/pg/query/sql/schema_up.sql | 24 ++ integration/testdata/cases/aggregation.json | 5 + integration/testdata/cases/nodes.json | 10 + .../testdata/templates/pattern_shapes.json | 29 ++ 18 files changed, 659 insertions(+), 23 deletions(-) create mode 100644 cypher/models/pgsql/translate/path_functions.go diff --git a/cypher/Cypher Syntax Support.md b/cypher/Cypher Syntax Support.md index c78c2ad..a75bf11 100644 --- a/cypher/Cypher Syntax Support.md +++ b/cypher/Cypher Syntax Support.md @@ -241,6 +241,32 @@ Type checks utilizing this function will not be index accelerated and may exhibi match ()-[r]->() where type(r) = 'EdgeKind1' return r ``` +### `relationships` + +Returns the ordered relationship list for a path. + +``` +match p = (a)-[*1..]->(b) return relationships(p) +``` + +### `startNode` + +Returns the start node for a relationship reference. + +``` +match p = (a)-[*1..]->(b) +where none(r in relationships(p) where startNode(r).name = 'blocked') +return p +``` + +### `endNode` + +Returns the end node for a relationship reference. + +``` +match ()-[r]->() return endNode(r) +``` + ### `split` Takes a given expression and text delimiter and returns a text array containing split components, if any. If the diff --git a/cypher/models/cypher/functions.go b/cypher/models/cypher/functions.go index 20daead..bd36f5b 100644 --- a/cypher/models/cypher/functions.go +++ b/cypher/models/cypher/functions.go @@ -13,6 +13,9 @@ const ( ToUpperFunction = "toupper" NodeLabelsFunction = "labels" EdgeTypeFunction = "type" + RelationshipsFunction = "relationships" + StartNodeFunction = "startnode" + EndNodeFunction = "endnode" StringSplitToArrayFunction = "split" ToStringFunction = "tostring" ToIntegerFunction = "toint" diff --git a/cypher/models/pgsql/functions.go b/cypher/models/pgsql/functions.go index c42fef2..0506cf1 100644 --- a/cypher/models/pgsql/functions.go +++ b/cypher/models/pgsql/functions.go @@ -42,6 +42,8 @@ const ( FunctionEdgesToPath Identifier = "edges_to_path" FunctionOrderedEdgesToPath Identifier = "ordered_edges_to_path" FunctionNodesToPath Identifier = "nodes_to_path" + FunctionStartNode Identifier = "start_node" + FunctionEndNode Identifier = "end_node" FunctionExtract Identifier = "extract" FunctionGenerateSubscripts Identifier = "generate_subscripts" ) diff --git a/cypher/models/pgsql/test/translation_cases/multipart.sql b/cypher/models/pgsql/test/translation_cases/multipart.sql index cbe8c8e..fb4aa14 100644 --- a/cypher/models/pgsql/test/translation_cases/multipart.sql +++ b/cypher/models/pgsql/test/translation_cases/multipart.sql @@ -57,13 +57,13 @@ with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposit with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from edge e0 join node n0 on (((n0.properties -> 'enabled'))::jsonb = to_jsonb((true)::bool)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select s1.n0 as n0, array_remove(coalesce(array_agg(distinct (s1.n0))::nodecomposite[], array []::nodecomposite[])::nodecomposite[], null)::nodecomposite[] as i0 from s1 group by n0), s2 as (select (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.i0 as i0, s0.n0 as n0, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (cardinality(s0.i0)::int >= 100) and (s0.n0).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.kind_id = any (array [3]::int2[]) limit 10) select (array [s2.n0, s2.n2]::nodecomposite[], array [s2.e1]::edgecomposite[])::pathcomposite as p from s2 limit 10; -- case: with "a" as check, "b" as ref match p = (u)-[:EdgeKind1]->(g:NodeKind1) where u.name starts with check and u.domain = ref with collect(tolower(g.samaccountname)) as refmembership, tolower(u.samaccountname) as samname return refmembership, samname -with s0 as (select 'a' as i0, 'b' as i1), s1 as (with s2 as (select s0.i0 as i0, s0.i1 as i1, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) and ((n0.properties ->> 'domain') = s0.i1 and cypher_starts_with((n0.properties ->> 'name'), (i0)::text)::bool)) select array_remove(coalesce(array_agg(lower(((s2.n1).properties ->> 'samaccountname'))::text)::anyarray, array []::text[])::anyarray, null)::anyarray as i2, lower(((s2.n0).properties ->> 'samaccountname'))::text as i3 from s2 group by s2.n0) select s1.i2 as refmembership, s1.i3 as samname from s1; +with s0 as (select 'a' as i0, 'b' as i1), s1 as (with s2 as (select s0.i0 as i0, s0.i1 as i1, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) and ((n0.properties ->> 'domain') = s0.i1 and cypher_starts_with((n0.properties ->> 'name'), (i0)::text)::bool)) select array_remove(coalesce(array_agg(lower(((s2.n1).properties ->> 'samaccountname'))::text)::text[], array []::text[])::text[], null)::text[] as i2, lower(((s2.n0).properties ->> 'samaccountname'))::text as i3 from s2 group by s2.n0) select s1.i2 as refmembership, s1.i3 as samname from s1; -- case: with "a" as check, "b" as ref match p = (u)-[:EdgeKind1]->(g:NodeKind1) where u.name starts with check and u.domain = ref with collect(tolower(g.samaccountname)) as refmembership, tolower(u.samaccountname) as samname match (u)-[:EdgeKind2]-(g:NodeKind1) where tolower(u.samaccountname) = samname and not tolower(g.samaccountname) IN refmembership return g -with s0 as (select 'a' as i0, 'b' as i1), s1 as (with s2 as (select s0.i0 as i0, s0.i1 as i1, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) and ((n0.properties ->> 'domain') = s0.i1 and cypher_starts_with((n0.properties ->> 'name'), (i0)::text)::bool)) select array_remove(coalesce(array_agg(lower(((s2.n1).properties ->> 'samaccountname'))::text)::anyarray, array []::text[])::anyarray, null)::anyarray as i2, lower(((s2.n0).properties ->> 'samaccountname'))::text as i3 from s2 group by s2.n0), s3 as (select s1.i2 as i2, s1.i3 as i3, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s1, edge e1 join node n2 on (n2.id = e1.end_id or n2.id = e1.start_id) join node n3 on n3.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (n3.id = e1.end_id or n3.id = e1.start_id) where (n2.id <> n3.id) and (not lower((n3.properties ->> 'samaccountname'))::text = any (s1.i2)) and e1.kind_id = any (array [4]::int2[]) and (lower((n2.properties ->> 'samaccountname'))::text = s1.i3)) select s3.n3 as g from s3; +with s0 as (select 'a' as i0, 'b' as i1), s1 as (with s2 as (select s0.i0 as i0, s0.i1 as i1, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) and ((n0.properties ->> 'domain') = s0.i1 and cypher_starts_with((n0.properties ->> 'name'), (i0)::text)::bool)) select array_remove(coalesce(array_agg(lower(((s2.n1).properties ->> 'samaccountname'))::text)::text[], array []::text[])::text[], null)::text[] as i2, lower(((s2.n0).properties ->> 'samaccountname'))::text as i3 from s2 group by s2.n0), s3 as (select s1.i2 as i2, s1.i3 as i3, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s1, edge e1 join node n2 on (n2.id = e1.end_id or n2.id = e1.start_id) join node n3 on n3.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (n3.id = e1.end_id or n3.id = e1.start_id) where (n2.id <> n3.id) and (not lower((n3.properties ->> 'samaccountname'))::text = any (s1.i2)) and e1.kind_id = any (array [4]::int2[]) and (lower((n2.properties ->> 'samaccountname'))::text = s1.i3)) select s3.n3 as g from s3; -- case: with "a" as check, "b" as ref match p = (u)-[:EdgeKind1]->(g:NodeKind1) where u.name starts with check and u.domain = ref with collect(tolower(g.samaccountname)) as refmembership, tolower(u.samaccountname) as samname match (u)-[:EdgeKind2]->(g:NodeKind1) where tolower(u.samaccountname) = samname and not tolower(g.samaccountname) IN refmembership return g -with s0 as (select 'a' as i0, 'b' as i1), s1 as (with s2 as (select s0.i0 as i0, s0.i1 as i1, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) and ((n0.properties ->> 'domain') = s0.i1 and cypher_starts_with((n0.properties ->> 'name'), (i0)::text)::bool)) select array_remove(coalesce(array_agg(lower(((s2.n1).properties ->> 'samaccountname'))::text)::anyarray, array []::text[])::anyarray, null)::anyarray as i2, lower(((s2.n0).properties ->> 'samaccountname'))::text as i3 from s2 group by s2.n0), s3 as (select s1.i2 as i2, s1.i3 as i3, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s1, edge e1 join node n2 on n2.id = e1.start_id join node n3 on n3.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n3.id = e1.end_id where (not lower((n3.properties ->> 'samaccountname'))::text = any (s1.i2)) and e1.kind_id = any (array [4]::int2[]) and (lower((n2.properties ->> 'samaccountname'))::text = s1.i3)) select s3.n3 as g from s3; +with s0 as (select 'a' as i0, 'b' as i1), s1 as (with s2 as (select s0.i0 as i0, s0.i1 as i1, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) and ((n0.properties ->> 'domain') = s0.i1 and cypher_starts_with((n0.properties ->> 'name'), (i0)::text)::bool)) select array_remove(coalesce(array_agg(lower(((s2.n1).properties ->> 'samaccountname'))::text)::text[], array []::text[])::text[], null)::text[] as i2, lower(((s2.n0).properties ->> 'samaccountname'))::text as i3 from s2 group by s2.n0), s3 as (select s1.i2 as i2, s1.i3 as i3, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s1, edge e1 join node n2 on n2.id = e1.start_id join node n3 on n3.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n3.id = e1.end_id where (not lower((n3.properties ->> 'samaccountname'))::text = any (s1.i2)) and e1.kind_id = any (array [4]::int2[]) and (lower((n2.properties ->> 'samaccountname'))::text = s1.i3)) select s3.n3 as g from s3; -- case: match p =(n:NodeKind1)<-[r:EdgeKind1|EdgeKind2*..3]-(u:NodeKind1) where n.domain = 'test' with n, count(r) as incomingCount where incomingCount > 90 with collect(n) as lotsOfAdmins match p =(n:NodeKind1)<-[:EdgeKind1]-() where n in lotsOfAdmins return p with s0 as (with s1 as (with recursive s2_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'domain'))::jsonb = to_jsonb(('test')::text)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, n1.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.end_id = e0.start_id, array [e0.id] from s2_seed join edge e0 on e0.end_id = s2_seed.root_id join node n1 on n1.id = e0.start_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s2.root_id, e0.start_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::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.end_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.start_id where s2.depth < 3 and not s2.is_cycle) select (select coalesce(array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s2.path) with ordinality as _path(id, ordinality) join edge e0 on e0.id = _path.id) as e0, 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 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) select s1.n0 as n0, count(s1.e0)::int8 as i0 from s1 group by n0), s3 as (select array_remove(coalesce(array_agg(s0.n0)::nodecomposite[], array []::nodecomposite[])::nodecomposite[], null)::nodecomposite[] as i1 from s0 where (s0.i0 > 90)), s4 as (select (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s3.i1 as i1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s3, edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id join node n3 on n3.id = e1.start_id where e1.kind_id = any (array [3]::int2[])) select (array [s4.n2, s4.n3]::nodecomposite[], array [s4.e1]::edgecomposite[])::pathcomposite as p from s4 where ((s4.n2).id = any ((select (_unnest_elem).id from unnest(s4.i1) as _unnest_elem))); diff --git a/cypher/models/pgsql/test/translation_cases/nodes.sql b/cypher/models/pgsql/test/translation_cases/nodes.sql index 6c2961f..328c8e2 100644 --- a/cypher/models/pgsql/test/translation_cases/nodes.sql +++ b/cypher/models/pgsql/test/translation_cases/nodes.sql @@ -29,6 +29,12 @@ with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposit -- case: match (n) with 1 as _kind_idx, n return labels(n), _kind_idx with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select 1 as i0, s1.n0 as n0 from s1) select (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.i0 as _kind_idx from s0; +-- case: match (n) where any(label in labels(n) where label = 'NodeKind2') return n +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select s0.n0 as n from s0 where (((select count(*)::int from unnest((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[]) as i0 where (i0 = 'NodeKind2')) >= 1)::bool); + +-- case: match (n) where none(label in labels(n) where label = 'NodeKind2') return n +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select s0.n0 as n from s0 where (((select count(*)::int from unnest((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[]) as i0 where (i0 = 'NodeKind2')) = 0 and (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[] is not null)::bool); + -- case: match (n) where ID(n) = 1 return n with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (n0.id = 1)) select s0.n0 as n from s0; diff --git a/cypher/models/pgsql/test/translation_cases/scalar_aggregation.sql b/cypher/models/pgsql/test/translation_cases/scalar_aggregation.sql index 4519105..cd84c9b 100644 --- a/cypher/models/pgsql/test/translation_cases/scalar_aggregation.sql +++ b/cypher/models/pgsql/test/translation_cases/scalar_aggregation.sql @@ -56,6 +56,9 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from -- case: MATCH (n) RETURN size(collect(n.name)) with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select cardinality(array_remove(coalesce(array_agg(((s0.n0).properties ->> 'name'))::anyarray, array []::text[])::anyarray, null)::anyarray)::int from s0; +-- case: MATCH (n) WITH collect(labels(n)) as label_sets RETURN size(label_sets) +with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select array_remove(coalesce(array_agg(to_jsonb((array(select _kind.name from generate_subscripts((s1.n0).kind_ids, 1) as _kind_idx, kind _kind where _kind.id = ((s1.n0).kind_ids)[_kind_idx] order by _kind_idx))::text[])::jsonb)::jsonb[], array []::jsonb[])::jsonb[], null)::jsonb[] as i0 from s1) select cardinality(s0.i0)::int from s0; + -- case: MATCH (n) WHERE size(n.permissions) > 2 RETURN n with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (jsonb_array_length((n0.properties -> 'permissions'))::int > 2)) select s0.n0 as n from s0; diff --git a/cypher/models/pgsql/test/translation_cases/shortest_paths.sql b/cypher/models/pgsql/test/translation_cases/shortest_paths.sql index e120b0e..24d5c97 100644 --- a/cypher/models/pgsql/test/translation_cases/shortest_paths.sql +++ b/cypher/models/pgsql/test/translation_cases/shortest_paths.sql @@ -85,3 +85,7 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from -- case: match p=shortestPath((u:NodeKind1)-[:EdgeKind1*1..]->(g:NodeKind2)) with distinct g as Group, count(u) as UserCount return Group.name, UserCount order by UserCount desc limit 5 -- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s2_seed(root_id) as not materialized (select n0.id as root_id from node n0 where n0.kind_ids operator (pg_catalog.@\u003e) array [1]::int2[]) select e0.start_id, e0.end_id, 1, exists (select 1 from traversal_terminal_filter where traversal_terminal_filter.id = e0.end_id), e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id where e0.kind_id = any (array [3]::int2[]) and case when (select count(*)::int8 from traversal_terminal_filter where traversal_terminal_filter.id = e0.start_id) = 0 then true else shortest_path_self_endpoint_error(e0.start_id, e0.start_id) end;","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s2.root_id, e0.end_id, s2.depth + 1, exists (select 1 from traversal_terminal_filter where traversal_terminal_filter.id = e0.end_id), false, s2.path || e0.id from forward_front s2 join edge e0 on e0.start_id = s2.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s2.path) and not exists (select 1 from visited where visited.root_id = s2.root_id and visited.id = e0.end_id);"} with s0 as (with s1 as (with s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_sp_harness(@pi0::text, @pi1::text, 15, ('')::text, ('insert into traversal_terminal_filter (id) select distinct n1.id from node n1 where n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id is not null;')::text)) select (select coalesce(array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s2.path) with ordinality as _path(id, ordinality) join edge e0 on e0.id = _path.id) as e0, 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 s2 join node n0 on n0.id = s2.root_id join node n1 on n1.id = s2.next_id where case when s2.root_id != s2.next_id then true else shortest_path_self_endpoint_error(s2.root_id, s2.next_id) end) select distinct s1.n1 as n2, count(s1.n0)::int8 as i0 from s1 group by n1) select ((s0.n2).properties -> 'name'), s0.i0 as UserCount from s0 order by s0.i0 desc limit 5; + +-- case: MATCH (g1:Group) MATCH (g2:Group) WHERE g1.name STARTS WITH 'DOMAIN USERS@' AND g2.name STARTS WITH 'DOMAIN ADMINS@' MATCH p=shortestPath((g1)-[:AddAllowedToAct|AddMember|AdminTo|AllExtendedRights|AllowedToDelegate|CanRDP|Contains|ForceChangePassword|GenericAll|GenericWrite|GetChangesAll|GetChanges|HasSession|MemberOf|Owns|ReadLAPSPassword|SQLAdmin|TrustedBy|WriteAccountRestrictions|WriteOwner*1..]->(g2)) WHERE NONE(r IN relationships(p) WHERE type(r) = 'HasSession' AND startNode(r).name = 'DF-WIN10-DEV01.DUMPSTER.FIRE') RETURN p +-- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s3_seed(root_id) as not materialized (select s3_seed_filter.id as root_id from traversal_root_filter s3_seed_filter) select e0.start_id, e0.end_id, 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = e0.start_id and traversal_pair_filter.terminal_id = e0.end_id), e0.start_id = e0.end_id, array [e0.id] from s3_seed join edge e0 on e0.start_id = s3_seed.root_id where e0.kind_id = any (array [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25]::int2[]) and case when (select count(*)::int8 from traversal_terminal_filter where traversal_terminal_filter.id = e0.start_id) = 0 then true else shortest_path_self_endpoint_error(e0.start_id, e0.start_id) end;","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s3.root_id, e0.end_id, s3.depth + 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = s3.root_id and traversal_pair_filter.terminal_id = e0.end_id), false, s3.path || e0.id from forward_front s3 join edge e0 on e0.start_id = s3.next_id where e0.kind_id = any (array [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25]::int2[]) and e0.id != all (s3.path) and not exists (select 1 from forward_visited where forward_visited.root_id = s3.root_id and forward_visited.id = e0.end_id);","pi2":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s3_seed(root_id) as not materialized (select s3_seed_filter.id as root_id from traversal_terminal_filter s3_seed_filter) select e0.end_id, e0.start_id, 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = e0.start_id and traversal_pair_filter.terminal_id = e0.end_id), e0.start_id = e0.end_id, array [e0.id] from s3_seed join edge e0 on e0.end_id = s3_seed.root_id where e0.kind_id = any (array [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25]::int2[]);","pi3":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s3.root_id, e0.start_id, s3.depth + 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = e0.start_id and traversal_pair_filter.terminal_id = s3.root_id), false, e0.id || s3.path from backward_front s3 join edge e0 on e0.end_id = s3.next_id where e0.kind_id = any (array [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25]::int2[]) and e0.id != all (s3.path) and not exists (select 1 from backward_visited where backward_visited.root_id = s3.root_id and backward_visited.id = e0.start_id);"} +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 [5]::int2[]), s1 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where ((n1.properties ->> 'name') like 'DOMAIN ADMINS@%' and ((s0.n0).properties ->> 'name') like 'DOMAIN USERS@%') and n1.kind_ids operator (pg_catalog.@>) array [5]::int2[]), s2 as (with s3(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from bidirectional_sp_harness(@pi0::text, @pi1::text, @pi2::text, @pi3::text, 15, ('')::text, ('')::text, ('insert into traversal_pair_filter (root_id, terminal_id) select distinct (s1.n0).id, (s1.n1).id from s1 where (s1.n0).id is not null and (s1.n1).id is not null;')::text)) select (select coalesce(array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s3.path) with ordinality as _path(id, ordinality) join edge e0 on e0.id = _path.id) as e0, s3.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, s3 join node n0 on n0.id = s3.root_id join node n1 on n1.id = s3.next_id where (s1.n0).id = s3.root_id and (s1.n1).id = s3.next_id and case when s3.root_id != s3.next_id then true else shortest_path_self_endpoint_error(s3.root_id, s3.next_id) end) select ordered_edges_to_path(s2.n0, s2.e0, array [s2.n0, s2.n1]::nodecomposite[])::pathcomposite as p from s2 where (((select count(*)::int from unnest((s2.e0)::edgecomposite[]) as i0 where ((((start_node(i0)::nodecomposite).properties -> 'name'))::jsonb = to_jsonb(('DF-WIN10-DEV01.DUMPSTER.FIRE')::text)::jsonb and i0.kind_id = 18)) = 0 and (s2.e0)::edgecomposite[] is not null)::bool); diff --git a/cypher/models/pgsql/test/translation_test.go b/cypher/models/pgsql/test/translation_test.go index c9fb43d..ea040df 100644 --- a/cypher/models/pgsql/test/translation_test.go +++ b/cypher/models/pgsql/test/translation_test.go @@ -11,6 +11,7 @@ import ( "github.com/specterops/dawgs/graph" "github.com/specterops/dawgs/cypher/models/pgsql" + "github.com/specterops/dawgs/graph" ) func newKindMapper() pgsql.KindMapper { @@ -30,6 +31,32 @@ func newKindMapper() pgsql.KindMapper { mapper.Put(graph.StringKind("GPLink")) mapper.Put(graph.StringKind("Contains")) + for _, kind := range []string{ + "Group", + "AddAllowedToAct", + "AddMember", + "AdminTo", + "AllExtendedRights", + "AllowedToDelegate", + "CanRDP", + "Contains", + "ForceChangePassword", + "GenericAll", + "GenericWrite", + "GetChangesAll", + "GetChanges", + "HasSession", + "MemberOf", + "Owns", + "ReadLAPSPassword", + "SQLAdmin", + "TrustedBy", + "WriteAccountRestrictions", + "WriteOwner", + } { + mapper.Put(graph.StringKind(kind)) + } + return mapper } diff --git a/cypher/models/pgsql/translate/expression.go b/cypher/models/pgsql/translate/expression.go index 49f8381..840549b 100644 --- a/cypher/models/pgsql/translate/expression.go +++ b/cypher/models/pgsql/translate/expression.go @@ -36,6 +36,24 @@ func expressionHasCompositeProperties(expressionType pgsql.DataType) bool { } } +func isCompositePropertyLookupTarget(expression pgsql.TypeHinted) bool { + return expressionHasCompositeProperties(expression.TypeHint()) +} + +func (s *Translator) translateCompositePropertyLookup(target pgsql.Expression, lookup *cypher.PropertyLookup) error { + if fieldIdentifierLiteral, err := pgsql.AsLiteral(lookup.Symbol); err != nil { + return err + } else { + s.treeTranslator.PushOperand(pgsql.RowColumnReference{ + Identifier: target, + Column: pgsql.ColumnProperties, + }) + s.treeTranslator.PushOperand(fieldIdentifierLiteral) + + return s.treeTranslator.CompleteBinaryExpression(s.scope, pgsql.OperatorPropertyLookup) + } +} + func (s *Translator) translatePropertyLookup(lookup *cypher.PropertyLookup) error { if translatedAtom, err := s.treeTranslator.PopOperand(); err != nil { return err @@ -73,6 +91,10 @@ func (s *Translator) translatePropertyLookup(lookup *cypher.PropertyLookup) erro } case pgsql.FunctionCall: + if isCompositePropertyLookupTarget(typedTranslatedAtom) { + return s.translateCompositePropertyLookup(typedTranslatedAtom, lookup) + } + if fieldIdentifierLiteral, err := pgsql.AsLiteral(lookup.Symbol); err != nil { return err } else if componentName, typeOK := fieldIdentifierLiteral.Value.(string); !typeOK { @@ -121,6 +143,11 @@ func (s *Translator) translatePropertyLookup(lookup *cypher.PropertyLookup) erro return fmt.Errorf("unsupported instant type component %s from function call %s", componentName, typedTranslatedAtom.Function) } } + + case pgsql.TypeCast: + if isCompositePropertyLookupTarget(typedTranslatedAtom) { + return s.translateCompositePropertyLookup(typedTranslatedAtom, lookup) + } } } @@ -687,6 +714,7 @@ func rewriteIdentityOperands(scope *Scope, newExpression *pgsql.BinaryExpression } } } + } } diff --git a/cypher/models/pgsql/translate/function.go b/cypher/models/pgsql/translate/function.go index 8862c1d..7d4c193 100644 --- a/cypher/models/pgsql/translate/function.go +++ b/cypher/models/pgsql/translate/function.go @@ -233,6 +233,37 @@ func (s *Translator) translatePathComponentFunction(functionInvocation *cypher.F return nil } +func prepareCollectExpression(scope *Scope, collectedExpression pgsql.Expression, functionName string) (pgsql.Expression, pgsql.DataType, error) { + castType := pgsql.AnyArray + + switch typedArgument := unwrapParenthetical(collectedExpression).(type) { + case pgsql.Identifier: + if binding, bound := scope.Lookup(typedArgument); !bound { + return nil, pgsql.UnsetDataType, fmt.Errorf("binding not found for collect function argument %s", functionName) + } else if bindingArrayType, err := binding.DataType.ToArrayType(); err != nil { + return nil, pgsql.UnsetDataType, err + } else { + castType = bindingArrayType + } + + case pgsql.TypeHinted: + typeHint := typedArgument.TypeHint() + if typeHint.IsArrayType() { + return pgsql.FunctionCall{ + Function: pgsql.FunctionToJSONB, + Parameters: []pgsql.Expression{collectedExpression}, + CastType: pgsql.JSONB, + }, pgsql.JSONBArray, nil + } + + if arrayType, err := typeHint.ToArrayType(); err == nil { + castType = arrayType + } + } + + return collectedExpression, castType, nil +} + func translateNodeLabelsExpression(identifier pgsql.Identifier) pgsql.TypeHinted { const ( kindAlias pgsql.Identifier = "_kind" @@ -344,6 +375,44 @@ func (s *Translator) translateFunction(typedExpression *cypher.FunctionInvocatio s.treeTranslator.PushOperand(pgsql.CompoundIdentifier{identifier, pgsql.ColumnKindID}) } + case cypher.RelationshipsFunction: + if typedExpression.NumArguments() != 1 { + s.SetError(fmt.Errorf("expected only one argument for cypher function: %s", typedExpression.Name)) + } else if argument, err := s.treeTranslator.PopOperand(); err != nil { + s.SetError(err) + } else { + s.treeTranslator.PushOperand(pgsql.NewTypeCast(pgsql.RowColumnReference{ + Identifier: argument, + Column: pgsql.ColumnEdges, + }, pgsql.EdgeCompositeArray)) + } + + case cypher.StartNodeFunction: + if typedExpression.NumArguments() != 1 { + s.SetError(fmt.Errorf("expected only one argument for cypher function: %s", typedExpression.Name)) + } else if argument, err := s.treeTranslator.PopOperand(); err != nil { + s.SetError(err) + } else { + s.treeTranslator.PushOperand(pgsql.FunctionCall{ + Function: pgsql.FunctionStartNode, + Parameters: []pgsql.Expression{argument}, + CastType: pgsql.NodeComposite, + }) + } + + case cypher.EndNodeFunction: + if typedExpression.NumArguments() != 1 { + s.SetError(fmt.Errorf("expected only one argument for cypher function: %s", typedExpression.Name)) + } else if argument, err := s.treeTranslator.PopOperand(); err != nil { + s.SetError(err) + } else { + s.treeTranslator.PushOperand(pgsql.FunctionCall{ + Function: pgsql.FunctionEndNode, + Parameters: []pgsql.Expression{argument}, + CastType: pgsql.NodeComposite, + }) + } + case cypher.NodeLabelsFunction: if typedExpression.NumArguments() != 1 { s.SetError(fmt.Errorf("expected only one argument for cypher function: %s", typedExpression.Name)) @@ -512,22 +581,11 @@ func (s *Translator) translateFunction(typedExpression *cypher.FunctionInvocatio s.SetError(fmt.Errorf("expected only one argument for cypher function: %s", typedExpression.Name)) } else if collectedExpression, err := s.treeTranslator.PopOperand(); err != nil { s.SetError(err) + } else if preparedExpression, castType, err := prepareCollectExpression(s.scope, collectedExpression, typedExpression.Name); err != nil { + s.SetError(err) } else { - castType := pgsql.AnyArray - - switch typedArgument := unwrapParenthetical(collectedExpression).(type) { - case pgsql.Identifier: - if binding, bound := s.scope.Lookup(typedArgument); !bound { - s.SetError(fmt.Errorf("binding not found for collect function argument %s", typedExpression.Name)) - } else if bindingArrayType, err := binding.DataType.ToArrayType(); err != nil { - s.SetError(err) - } else { - castType = bindingArrayType - } - } - s.treeTranslator.PushOperand( - functionWrapCollectToArray(typedExpression.Distinct, collectedExpression, castType), + functionWrapCollectToArray(typedExpression.Distinct, preparedExpression, castType), ) } @@ -613,9 +671,8 @@ func (s *Translator) translateFunction(typedExpression *cypher.FunctionInvocatio // Current semantic issues: // * `null` values are stripped during aggregation. This may not be the case in Neo4j's query execution pipeline. func functionWrapCollectToArray(distinct bool, collectedExpression pgsql.Expression, castType pgsql.DataType) pgsql.FunctionCall { - // TODO: Review this potential bug, nodecomposite array cant be coalesced with text array var coalesceType = pgsql.TextArray - if castType == pgsql.NodeCompositeArray { + if castType != pgsql.AnyArray { coalesceType = castType } diff --git a/cypher/models/pgsql/translate/path_functions.go b/cypher/models/pgsql/translate/path_functions.go new file mode 100644 index 0000000..5321624 --- /dev/null +++ b/cypher/models/pgsql/translate/path_functions.go @@ -0,0 +1,388 @@ +package translate + +import ( + "fmt" + + "github.com/specterops/dawgs/cypher/models/pgsql" +) + +func pathCompositeEdgesExpression(scope *Scope, pathBinding *BoundIdentifier) (pgsql.Expression, error) { + var edgeArrayReferences []pgsql.Expression + + for _, dependency := range pathBinding.Dependencies { + switch dependency.DataType { + case pgsql.ExpansionPath: + if edgeArrayReference, err := expansionPathEdgeArrayReference(scope, dependency); err != nil { + return nil, err + } else { + edgeArrayReferences = append(edgeArrayReferences, edgeArrayReference) + } + + case pgsql.EdgeComposite: + edgeReference := bindingFrameReference(scope, dependency) + edgeArrayReferences = append(edgeArrayReferences, pgsql.ArrayLiteral{ + Values: []pgsql.Expression{edgeReference}, + CastType: pgsql.EdgeCompositeArray, + }) + + default: + // Path bindings also depend on their node endpoints. Those are not part of relationships(p). + } + } + + if edgeArrayExpression := concatenatePathCompositeParts(edgeArrayReferences); edgeArrayExpression != nil { + return edgeArrayExpression, nil + } + + return pgsql.ArrayLiteral{CastType: pgsql.EdgeCompositeArray}, nil +} + +func resolvePathCompositeFieldReference(scope *Scope, reference pgsql.RowColumnReference) (pgsql.Expression, bool, error) { + identifier, isIdentifier := reference.Identifier.(pgsql.Identifier) + if !isIdentifier { + return nil, false, nil + } + + binding, bound := scope.Lookup(identifier) + if !bound || binding.DataType != pgsql.PathComposite { + return nil, false, nil + } + + if binding.LastProjection != nil { + return pgsql.RowColumnReference{ + Identifier: pgsql.CompoundIdentifier{binding.LastProjection.Binding.Identifier, binding.Identifier}, + Column: reference.Column, + }, true, nil + } + + switch reference.Column { + case pgsql.ColumnEdges: + expression, err := pathCompositeEdgesExpression(scope, binding) + return expression, true, err + default: + return nil, false, fmt.Errorf("unsupported path composite field reference: %s", reference.Column) + } +} + +func resolvePathCompositeFieldReferencesInProjection(scope *Scope, projection pgsql.Projection) (pgsql.Projection, error) { + rewritten := make(pgsql.Projection, len(projection)) + + for idx, item := range projection { + expression, isExpression := item.(pgsql.Expression) + if !isExpression { + rewritten[idx] = item + continue + } + + resolved, err := resolvePathCompositeFieldReferences(scope, expression) + if err != nil { + return nil, err + } + + selectItem, isSelectItem := resolved.(pgsql.SelectItem) + if !isSelectItem { + return nil, fmt.Errorf("resolved projection item is not selectable: %T", resolved) + } + + rewritten[idx] = selectItem + } + + return rewritten, nil +} + +func resolvePathCompositeFieldReferencesInFromClause(scope *Scope, fromClause pgsql.FromClause) (pgsql.FromClause, error) { + if resolvedSource, err := resolvePathCompositeFieldReferences(scope, fromClause.Source); err != nil { + return pgsql.FromClause{}, err + } else { + fromClause.Source = resolvedSource + } + + for idx, join := range fromClause.Joins { + if resolvedTable, err := resolvePathCompositeFieldReferences(scope, join.Table); err != nil { + return pgsql.FromClause{}, err + } else { + join.Table = resolvedTable + } + + if join.JoinOperator.Constraint != nil { + if resolvedConstraint, err := resolvePathCompositeFieldReferences(scope, join.JoinOperator.Constraint); err != nil { + return pgsql.FromClause{}, err + } else { + join.JoinOperator.Constraint = resolvedConstraint + } + } + + fromClause.Joins[idx] = join + } + + return fromClause, nil +} + +func resolvePathCompositeFieldReferencesInFromClauses(scope *Scope, fromClauses []pgsql.FromClause) ([]pgsql.FromClause, error) { + rewritten := make([]pgsql.FromClause, len(fromClauses)) + + for idx, fromClause := range fromClauses { + resolved, err := resolvePathCompositeFieldReferencesInFromClause(scope, fromClause) + if err != nil { + return nil, err + } + + rewritten[idx] = resolved + } + + return rewritten, nil +} + +func resolvePathCompositeFieldReferences(scope *Scope, expression pgsql.Expression) (pgsql.Expression, error) { + switch typedExpression := expression.(type) { + case nil: + return nil, nil + + case pgsql.RowColumnReference: + if resolved, rewritten, err := resolvePathCompositeFieldReference(scope, typedExpression); rewritten || err != nil { + return resolved, err + } + + if resolvedIdentifier, err := resolvePathCompositeFieldReferences(scope, typedExpression.Identifier); err != nil { + return nil, err + } else { + typedExpression.Identifier = resolvedIdentifier + } + + return typedExpression, nil + + case pgsql.TypeCast: + if resolved, err := resolvePathCompositeFieldReferences(scope, typedExpression.Expression); err != nil { + return nil, err + } else { + typedExpression.Expression = resolved + return typedExpression, nil + } + + case pgsql.FunctionCall: + for idx, parameter := range typedExpression.Parameters { + if resolved, err := resolvePathCompositeFieldReferences(scope, parameter); err != nil { + return nil, err + } else { + typedExpression.Parameters[idx] = resolved + } + } + + return typedExpression, nil + + case *pgsql.FunctionCall: + if typedExpression == nil { + return nil, nil + } + + resolved, err := resolvePathCompositeFieldReferences(scope, *typedExpression) + if err != nil { + return nil, err + } + + functionCall := resolved.(pgsql.FunctionCall) + return &functionCall, nil + + case pgsql.AliasedExpression: + if resolved, err := resolvePathCompositeFieldReferences(scope, typedExpression.Expression); err != nil { + return nil, err + } else { + typedExpression.Expression = resolved + return typedExpression, nil + } + + case *pgsql.AliasedExpression: + if typedExpression == nil { + return nil, nil + } + + if resolved, err := resolvePathCompositeFieldReferences(scope, typedExpression.Expression); err != nil { + return nil, err + } else { + typedExpression.Expression = resolved + return typedExpression, nil + } + + case pgsql.ArrayExpression: + if resolved, err := resolvePathCompositeFieldReferences(scope, typedExpression.Expression); err != nil { + return nil, err + } else { + typedExpression.Expression = resolved + return typedExpression, nil + } + + case pgsql.ArrayLiteral: + for idx, value := range typedExpression.Values { + if resolved, err := resolvePathCompositeFieldReferences(scope, value); err != nil { + return nil, err + } else { + typedExpression.Values[idx] = resolved + } + } + + return typedExpression, nil + + case pgsql.ArrayIndex: + if resolved, err := resolvePathCompositeFieldReferences(scope, typedExpression.Expression); err != nil { + return nil, err + } else { + typedExpression.Expression = resolved + } + + for idx, index := range typedExpression.Indexes { + if resolved, err := resolvePathCompositeFieldReferences(scope, index); err != nil { + return nil, err + } else { + typedExpression.Indexes[idx] = resolved + } + } + + return typedExpression, nil + + case *pgsql.ArrayIndex: + if typedExpression == nil { + return nil, nil + } + + resolved, err := resolvePathCompositeFieldReferences(scope, *typedExpression) + if err != nil { + return nil, err + } + + arrayIndex := resolved.(pgsql.ArrayIndex) + return &arrayIndex, nil + + case pgsql.AnyExpression: + if resolved, err := resolvePathCompositeFieldReferences(scope, typedExpression.Expression); err != nil { + return nil, err + } else { + typedExpression.Expression = resolved + return typedExpression, nil + } + + case *pgsql.AnyExpression: + if typedExpression == nil { + return nil, nil + } + + resolved, err := resolvePathCompositeFieldReferences(scope, *typedExpression) + if err != nil { + return nil, err + } + + anyExpression := resolved.(pgsql.AnyExpression) + return &anyExpression, nil + + case pgsql.AllExpression: + if resolved, err := resolvePathCompositeFieldReferences(scope, typedExpression.Expression); err != nil { + return nil, err + } else { + typedExpression.Expression = resolved + return typedExpression, nil + } + + case *pgsql.UnaryExpression: + if typedExpression == nil { + return nil, nil + } + + if resolved, err := resolvePathCompositeFieldReferences(scope, typedExpression.Operand); err != nil { + return nil, err + } else { + typedExpression.Operand = resolved + return typedExpression, nil + } + + case pgsql.UnaryExpression: + if resolved, err := resolvePathCompositeFieldReferences(scope, typedExpression.Operand); err != nil { + return nil, err + } else { + typedExpression.Operand = resolved + return typedExpression, nil + } + + case *pgsql.BinaryExpression: + if typedExpression == nil { + return nil, nil + } + + if resolved, err := resolvePathCompositeFieldReferences(scope, typedExpression.LOperand); err != nil { + return nil, err + } else { + typedExpression.LOperand = resolved + } + + if resolved, err := resolvePathCompositeFieldReferences(scope, typedExpression.ROperand); err != nil { + return nil, err + } else { + typedExpression.ROperand = resolved + } + + return typedExpression, nil + + case pgsql.BinaryExpression: + if resolved, err := resolvePathCompositeFieldReferences(scope, typedExpression.LOperand); err != nil { + return nil, err + } else { + typedExpression.LOperand = resolved + } + + if resolved, err := resolvePathCompositeFieldReferences(scope, typedExpression.ROperand); err != nil { + return nil, err + } else { + typedExpression.ROperand = resolved + } + + return typedExpression, nil + + case *pgsql.Parenthetical: + if typedExpression == nil { + return nil, nil + } + + if resolved, err := resolvePathCompositeFieldReferences(scope, typedExpression.Expression); err != nil { + return nil, err + } else { + typedExpression.Expression = resolved + return typedExpression, nil + } + + case pgsql.Select: + if resolvedProjection, err := resolvePathCompositeFieldReferencesInProjection(scope, typedExpression.Projection); err != nil { + return nil, err + } else { + typedExpression.Projection = resolvedProjection + } + + if resolvedFrom, err := resolvePathCompositeFieldReferencesInFromClauses(scope, typedExpression.From); err != nil { + return nil, err + } else { + typedExpression.From = resolvedFrom + } + + if resolvedWhere, err := resolvePathCompositeFieldReferences(scope, typedExpression.Where); err != nil { + return nil, err + } else { + typedExpression.Where = resolvedWhere + } + + for idx, groupBy := range typedExpression.GroupBy { + if resolved, err := resolvePathCompositeFieldReferences(scope, groupBy); err != nil { + return nil, err + } else { + typedExpression.GroupBy[idx] = resolved + } + } + + if resolvedHaving, err := resolvePathCompositeFieldReferences(scope, typedExpression.Having); err != nil { + return nil, err + } else { + typedExpression.Having = resolvedHaving + } + + return typedExpression, nil + + default: + return expression, nil + } +} diff --git a/cypher/models/pgsql/translate/projection.go b/cypher/models/pgsql/translate/projection.go index 69195db..c860224 100644 --- a/cypher/models/pgsql/translate/projection.go +++ b/cypher/models/pgsql/translate/projection.go @@ -67,6 +67,12 @@ func buildExternalProjection(scope *Scope, projections []*Projection) (pgsql.Pro } } + if resolvedProjection, err := resolvePathCompositeFieldReferencesInProjection(scope, sqlProjection); err != nil { + return nil, err + } else { + sqlProjection = resolvedProjection + } + if err := RewriteFrameBindings(scope, sqlProjection); err != nil { return nil, err } @@ -519,6 +525,18 @@ func (s *Translator) buildInlineProjection(part *QueryPart) (pgsql.Select, error } } + if resolvedWhere, err := resolvePathCompositeFieldReferences(s.scope, sqlSelect.Where); err != nil { + return pgsql.Select{}, err + } else { + sqlSelect.Where = resolvedWhere + } + + if resolvedProjection, err := resolvePathCompositeFieldReferencesInProjection(s.scope, sqlSelect.Projection); err != nil { + return pgsql.Select{}, err + } else { + sqlSelect.Projection = resolvedProjection + } + return sqlSelect, nil } @@ -973,11 +991,13 @@ func (s *Translator) buildTailProjection() error { return err } else if projection, err := buildExternalProjection(s.scope, currentPart.projections.Items); err != nil { return err - } else if err := RewriteFrameBindings(s.scope, projectionConstraint.Expression); err != nil { + } else if resolvedConstraint, err := resolvePathCompositeFieldReferences(s.scope, projectionConstraint.Expression); err != nil { + return err + } else if err := RewriteFrameBindings(s.scope, resolvedConstraint); err != nil { return err } else { singlePartQuerySelect.Projection = projection - singlePartQuerySelect.Where = projectionConstraint.Expression + singlePartQuerySelect.Where = resolvedConstraint // Apply GROUP BY logic after projections are built and frame bindings are rewritten if currentPart.HasProjections() { diff --git a/cypher/models/pgsql/translate/with.go b/cypher/models/pgsql/translate/with.go index ed09287..d3eb695 100644 --- a/cypher/models/pgsql/translate/with.go +++ b/cypher/models/pgsql/translate/with.go @@ -54,10 +54,12 @@ func (s *Translator) translateWith() error { } if projectionConstraint, err := s.treeTranslator.ConsumeConstraintsFromVisibleSet(set); err != nil { return err - } else if err := RewriteFrameBindings(s.scope, projectionConstraint.Expression); err != nil { + } else if resolvedConstraint, err := resolvePathCompositeFieldReferences(s.scope, projectionConstraint.Expression); err != nil { + return err + } else if err := RewriteFrameBindings(s.scope, resolvedConstraint); err != nil { return err } else { - currentPart.projections.Constraints = projectionConstraint.Expression + currentPart.projections.Constraints = resolvedConstraint } for idx, projectionItem := range currentPart.projections.Items { diff --git a/drivers/pg/query/sql/schema_down.sql b/drivers/pg/query/sql/schema_down.sql index 2fe6923..d1c53c0 100644 --- a/drivers/pg/query/sql/schema_down.sql +++ b/drivers/pg/query/sql/schema_down.sql @@ -10,6 +10,8 @@ drop function if exists jsonb_to_text_array; drop function if exists cypher_contains(text, text); drop function if exists cypher_starts_with(text, text); drop function if exists cypher_ends_with(text, text); +drop function if exists start_node(edgeComposite); +drop function if exists end_node(edgeComposite); drop function if exists get_node; drop function if exists node_prop; drop function if exists kinds; diff --git a/drivers/pg/query/sql/schema_up.sql b/drivers/pg/query/sql/schema_up.sql index c75b451..6772a57 100644 --- a/drivers/pg/query/sql/schema_up.sql +++ b/drivers/pg/query/sql/schema_up.sql @@ -206,6 +206,30 @@ $$ $$; -- Database helper functions +create or replace function public.start_node(rel edgeComposite) returns nodeComposite as +$$ +select (n.id, n.kind_ids, n.properties)::nodeComposite +from node n +where n.id = (rel).start_id +limit 1; +$$ + language sql + stable + parallel safe + strict; + +create or replace function public.end_node(rel edgeComposite) returns nodeComposite as +$$ +select (n.id, n.kind_ids, n.properties)::nodeComposite +from node n +where n.id = (rel).end_id +limit 1; +$$ + language sql + stable + parallel safe + strict; + create or replace function public.lock_details() returns table ( diff --git a/integration/testdata/cases/aggregation.json b/integration/testdata/cases/aggregation.json index 2fdf437..4f641c9 100644 --- a/integration/testdata/cases/aggregation.json +++ b/integration/testdata/cases/aggregation.json @@ -26,6 +26,11 @@ "cypher": "MATCH (n) RETURN size(collect(n.name))", "assert": "non_empty" }, + { + "name": "return the size of collected labels lists", + "cypher": "MATCH (n) WITH collect(labels(n)) as label_sets RETURN size(label_sets)", + "assert": {"exact_int": 3} + }, { "name": "filter on an aggregate result using WITH and WHERE", "cypher": "MATCH (n) WITH count(n) as cnt WHERE cnt > 1 RETURN cnt", diff --git a/integration/testdata/cases/nodes.json b/integration/testdata/cases/nodes.json index 881db79..e962579 100644 --- a/integration/testdata/cases/nodes.json +++ b/integration/testdata/cases/nodes.json @@ -31,6 +31,16 @@ "cypher": "match (n) where n.name = 'n3' with 1 as _kind_idx, n unwind labels(n) as label return _kind_idx, label order by label", "assert": {"ordered_row_values": [[1, "NodeKind1"], [1, "NodeKind2"]]} }, + { + "name": "filter nodes using ANY over labels()", + "cypher": "match (n) where any(label in labels(n) where label = 'NodeKind2') return n", + "assert": {"node_ids": ["n2", "n3"]} + }, + { + "name": "filter nodes using NONE over labels()", + "cypher": "match (n) where none(label in labels(n) where label = 'NodeKind2') return n", + "assert": {"node_ids": ["n1"]} + }, { "name": "filter any node by string property equality", "cypher": "match (n) where n.name = '1234' return n", diff --git a/integration/testdata/templates/pattern_shapes.json b/integration/testdata/templates/pattern_shapes.json index d0a2b3f..12d2c41 100644 --- a/integration/testdata/templates/pattern_shapes.json +++ b/integration/testdata/templates/pattern_shapes.json @@ -75,6 +75,35 @@ "assert": {"path_node_ids": [["a", "b"]], "path_edge_kinds": [["TemplateEdgeKind1"]]} } ] + }, + { + "name": "shortest path relationship predicate functions", + "template": "{{query}}", + "fixture": { + "nodes": [ + {"id": "src", "kinds": ["TemplateNodeKind1"], "properties": {"name": "path-filter-src"}}, + {"id": "blocked", "kinds": ["TemplateNodeKind2"], "properties": {"name": "blocked-session-host"}}, + {"id": "allowed", "kinds": ["TemplateNodeKind2"], "properties": {"name": "allowed-session-host"}}, + {"id": "long", "kinds": ["TemplateNodeKind2"], "properties": {"name": "longer-route"}}, + {"id": "dst", "kinds": ["TemplateNodeKind1"], "properties": {"name": "path-filter-dst"}} + ], + "edges": [ + {"start_id": "src", "end_id": "blocked", "kind": "TemplateEdgeKind1"}, + {"start_id": "blocked", "end_id": "dst", "kind": "HasSession"}, + {"start_id": "src", "end_id": "allowed", "kind": "TemplateEdgeKind1"}, + {"start_id": "allowed", "end_id": "long", "kind": "TemplateEdgeKind1"}, + {"start_id": "long", "end_id": "dst", "kind": "TemplateEdgeKind1"} + ] + }, + "variants": [ + { + "name": "post-filtered shortest path is not replaced by longer path", + "vars": { + "query": "match p=shortestPath((s:TemplateNodeKind1)-[:TemplateEdgeKind1|HasSession*1..]->(d:TemplateNodeKind1)) where s.name = 'path-filter-src' and d.name = 'path-filter-dst' and none(r in relationships(p) where type(r) = 'HasSession' and startNode(r).name = 'blocked-session-host') return p" + }, + "assert": "empty" + } + ] } ] } From 952dff13c4657b486e57cd500cdd9f16a418e903 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Mon, 11 May 2026 16:47:54 -0700 Subject: [PATCH 3/6] fix (pgsql): update type call to return relationship types as a string value to align with expected cypher behavior --- cypher/models/pgsql/functions.go | 1 + .../translation_cases/pattern_binding.sql | 3 + .../translation_cases/stepwise_traversal.sql | 14 ++- cypher/models/pgsql/translate/function.go | 8 +- cypher/models/pgsql/translate/hinting.go | 105 +++++++++++++----- drivers/pg/query/sql/schema_down.sql | 1 + drivers/pg/query/sql/schema_up.sql | 12 ++ integration/testdata/cases/stepwise.json | 20 ++++ .../testdata/templates/pattern_shapes.json | 7 ++ 9 files changed, 143 insertions(+), 28 deletions(-) diff --git a/cypher/models/pgsql/functions.go b/cypher/models/pgsql/functions.go index 0506cf1..aba20de 100644 --- a/cypher/models/pgsql/functions.go +++ b/cypher/models/pgsql/functions.go @@ -42,6 +42,7 @@ const ( FunctionEdgesToPath Identifier = "edges_to_path" FunctionOrderedEdgesToPath Identifier = "ordered_edges_to_path" FunctionNodesToPath Identifier = "nodes_to_path" + FunctionKindName Identifier = "kind_name" FunctionStartNode Identifier = "start_node" FunctionEndNode Identifier = "end_node" FunctionExtract Identifier = "extract" diff --git a/cypher/models/pgsql/test/translation_cases/pattern_binding.sql b/cypher/models/pgsql/test/translation_cases/pattern_binding.sql index afa9861..05c284c 100644 --- a/cypher/models/pgsql/test/translation_cases/pattern_binding.sql +++ b/cypher/models/pgsql/test/translation_cases/pattern_binding.sql @@ -23,6 +23,9 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from -- case: match p = ()-[]->() return p with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.id = e0.end_id) select (array [s0.n0, s0.n1]::nodecomposite[], array [s0.e0]::edgecomposite[])::pathcomposite as p from s0; +-- case: match p = (:NodeKind1)-[:EdgeKind1|EdgeKind2*1..1]->(:NodeKind2) where any(r in relationships(p) where type(r) STARTS WITH 'EdgeKind') 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 [1]::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 [2]::int2[], e0.start_id = e0.end_id, array [e0.id] from s1_seed join edge e0 on e0.start_id = s1_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], false, s1.path || e0.id from s1 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.start_id = s1.next_id and e0.id != all (s1.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 s1.depth < 1 and not s1.is_cycle) select (select coalesce(array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s1.path) with ordinality as _path(id, ordinality) join edge e0 on e0.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 ordered_edges_to_path(s0.n0, s0.e0, array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 where (((select count(*)::int from unnest((s0.e0)::edgecomposite[]) as i0 where (kind_name(i0.kind_id)::text like 'EdgeKind%')) >= 1)::bool); + -- case: match p=(:NodeKind1)-[r]->(:NodeKind1) where r.isacl return p limit 100 with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.end_id where (((e0.properties ->> 'isacl'))::bool) limit 100) select (array [s0.n0, s0.n1]::nodecomposite[], array [s0.e0]::edgecomposite[])::pathcomposite as p from s0 limit 100; diff --git a/cypher/models/pgsql/test/translation_cases/stepwise_traversal.sql b/cypher/models/pgsql/test/translation_cases/stepwise_traversal.sql index e1bd333..88fa94a 100644 --- a/cypher/models/pgsql/test/translation_cases/stepwise_traversal.sql +++ b/cypher/models/pgsql/test/translation_cases/stepwise_traversal.sql @@ -20,6 +20,18 @@ with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::e -- case: match ()-[r]->() where type(r) = 'EdgeKind1' return r with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0 from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.id = e0.end_id where (e0.kind_id = 3)) select s0.e0 as r from s0; +-- case: match ()-[r]->() return type(r) order by type(r) +with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0 from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.id = e0.end_id) select kind_name((s0.e0).kind_id)::text from s0 order by kind_name((s0.e0).kind_id)::text; + +-- case: match ()-[r]->() where type(r) <> 'EdgeKind1' return type(r) order by type(r) +with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0 from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.id = e0.end_id where (e0.kind_id <> 3)) select kind_name((s0.e0).kind_id)::text from s0 order by kind_name((s0.e0).kind_id)::text; + +-- case: match ()-[r]->() where type(r) in ['EdgeKind2'] return type(r) order by type(r) +with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0 from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.id = e0.end_id where (kind_name(e0.kind_id)::text = any (array ['EdgeKind2']::text[]))) select kind_name((s0.e0).kind_id)::text from s0 order by kind_name((s0.e0).kind_id)::text; + +-- case: match ()-[r]->() where type(r) STARTS WITH 'EdgeKind' return type(r) order by type(r) +with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0 from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.id = e0.end_id where (kind_name(e0.kind_id)::text like 'EdgeKind%')) select kind_name((s0.e0).kind_id)::text from s0 order by kind_name((s0.e0).kind_id)::text; + -- case: match ()-[r]->() where 'EdgeKind1' = type(r) return r with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0 from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.id = e0.end_id where (3 = e0.kind_id)) select s0.e0 as r from s0; @@ -89,7 +101,7 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1 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)); -- 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, (s0.e0).kind_id from s0; +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; -- case: match (s)-[r]->(e) where s:NodeKind1 and toLower(s.name) starts with 'test' and r:EdgeKind1 and id(e) in [1, 2] return r limit 1 with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on (n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and lower((n0.properties ->> 'name'))::text like 'test%') and n0.id = e0.start_id join node n1 on (n1.id = any (array [1, 2]::int8[])) and n1.id = e0.end_id where (e0.kind_id = any (array [3]::int2[])) limit 1) select s0.e0 as r from s0 limit 1; diff --git a/cypher/models/pgsql/translate/function.go b/cypher/models/pgsql/translate/function.go index 7d4c193..f52f677 100644 --- a/cypher/models/pgsql/translate/function.go +++ b/cypher/models/pgsql/translate/function.go @@ -372,7 +372,13 @@ func (s *Translator) translateFunction(typedExpression *cypher.FunctionInvocatio } else if identifier, isIdentifier := argument.(pgsql.Identifier); !isIdentifier { s.SetErrorf("expected an identifier for the cypher function: %s but received %T", typedExpression.Name, argument) } else { - s.treeTranslator.PushOperand(pgsql.CompoundIdentifier{identifier, pgsql.ColumnKindID}) + s.treeTranslator.PushOperand(pgsql.FunctionCall{ + Function: pgsql.FunctionKindName, + Parameters: []pgsql.Expression{ + pgsql.CompoundIdentifier{identifier, pgsql.ColumnKindID}, + }, + CastType: pgsql.Text, + }) } case cypher.RelationshipsFunction: diff --git a/cypher/models/pgsql/translate/hinting.go b/cypher/models/pgsql/translate/hinting.go index 821cd68..b50b6ca 100644 --- a/cypher/models/pgsql/translate/hinting.go +++ b/cypher/models/pgsql/translate/hinting.go @@ -173,25 +173,68 @@ func (s *contextAwareKindMapper) AssertKinds(kinds graph.Kinds) ([]int16, error) return s.kindMapper.AssertKinds(s.ctx, kinds) } +func relationshipTypeKindIDExpression(expression pgsql.Expression) (pgsql.Expression, bool) { + functionCall, isFunctionCall := unwrapParenthetical(expression).(pgsql.FunctionCall) + if !isFunctionCall || functionCall.Function != pgsql.FunctionKindName || len(functionCall.Parameters) != 1 { + return nil, false + } + + return functionCall.Parameters[0], true +} + +func literalKindID(kindMapper *contextAwareKindMapper, literal pgsql.Literal) (pgsql.Literal, bool, error) { + if literal.CastType != pgsql.Text { + return pgsql.Literal{}, false, nil + } + + kinds, err := graph.AsKinds([]any{literal.Value}) + if err != nil { + return pgsql.Literal{}, false, err + } + + kindIDs, err := kindMapper.MapKinds(kinds) + if err != nil { + return pgsql.Literal{}, false, err + } + + return pgsql.NewLiteral(kindIDs[0], pgsql.Int2), true, nil +} + +func mapsRelationshipTypeLiteralToKindID(operator pgsql.Operator) bool { + return operator.IsIn(pgsql.OperatorEquals, pgsql.OperatorNotEquals, pgsql.OperatorCypherNotEquals) +} + func applyTypeFunctionLikeTypeHints(kindMapper *contextAwareKindMapper, expression *pgsql.BinaryExpression) error { + mapTypeLiteralToKindID := mapsRelationshipTypeLiteralToKindID(expression.Operator) + + if kindIDExpression, isRelationshipTypeName := relationshipTypeKindIDExpression(expression.LOperand); mapTypeLiteralToKindID && isRelationshipTypeName { + switch typedROperand := expression.ROperand.(type) { + case pgsql.Literal: + if kindIDLiteral, converted, err := literalKindID(kindMapper, typedROperand); err != nil { + return err + } else if converted { + expression.LOperand = kindIDExpression + expression.ROperand = kindIDLiteral + return nil + } + } + } + switch typedLOperand := expression.LOperand.(type) { case pgsql.CompoundIdentifier: switch typedLOperand.Field() { case pgsql.ColumnKindID: - // In the case where the left operand is a reference to the kind ID column of an edge entity - // it is assumed that the query is utilizing the type(...) cypher function. This indicates - // a need to look at the right operand to inspect if it must be marshalled from the text - // representation of a kind to its associated kind ID. - switch typedROperand := expression.ROperand.(type) { - case pgsql.Literal: - if typedROperand.CastType == pgsql.Text { - // Only translate the right operand if it is a text literal - if kinds, err := graph.AsKinds([]any{typedROperand.Value}); err != nil { + if mapTypeLiteralToKindID { + // In the case where the left operand is a reference to the kind ID column of an edge entity + // it is assumed that the query is utilizing the type(...) cypher function. This indicates + // a need to look at the right operand to inspect if it must be marshalled from the text + // representation of a kind to its associated kind ID. + switch typedROperand := expression.ROperand.(type) { + case pgsql.Literal: + if kindIDLiteral, converted, err := literalKindID(kindMapper, typedROperand); err != nil { return err - } else if kindIDs, err := kindMapper.MapKinds(kinds); err != nil { - return err - } else { - expression.ROperand = pgsql.NewLiteral(kindIDs[0], pgsql.Int2) + } else if converted { + expression.ROperand = kindIDLiteral } } } @@ -236,24 +279,34 @@ func applyTypeFunctionLikeTypeHints(kindMapper *contextAwareKindMapper, expressi } } + if kindIDExpression, isRelationshipTypeName := relationshipTypeKindIDExpression(expression.ROperand); mapTypeLiteralToKindID && isRelationshipTypeName { + switch typedLOperand := expression.LOperand.(type) { + case pgsql.Literal: + if kindIDLiteral, converted, err := literalKindID(kindMapper, typedLOperand); err != nil { + return err + } else if converted { + expression.LOperand = kindIDLiteral + expression.ROperand = kindIDExpression + return nil + } + } + } + switch typedROperand := expression.ROperand.(type) { case pgsql.CompoundIdentifier: switch typedROperand.Field() { case pgsql.ColumnKindID: - // In the case where the left operand is a reference to the kind ID column of an edge entity - // it is assumed that the query is utilizing the type(...) cypher function. This indicates - // a need to look at the right operand to inspect if it must be marshalled from the text - // representation of a kind to its associated kind ID. - switch typedLOperand := expression.LOperand.(type) { - case pgsql.Literal: - if typedLOperand.CastType == pgsql.Text { - // Only translate the right operand if it is a text literal - if kinds, err := graph.AsKinds([]any{typedLOperand.Value}); err != nil { - return err - } else if kindIDs, err := kindMapper.MapKinds(kinds); err != nil { + if mapTypeLiteralToKindID { + // In the case where the left operand is a reference to the kind ID column of an edge entity + // it is assumed that the query is utilizing the type(...) cypher function. This indicates + // a need to look at the right operand to inspect if it must be marshalled from the text + // representation of a kind to its associated kind ID. + switch typedLOperand := expression.LOperand.(type) { + case pgsql.Literal: + if kindIDLiteral, converted, err := literalKindID(kindMapper, typedLOperand); err != nil { return err - } else { - expression.LOperand = pgsql.NewLiteral(kindIDs[0], pgsql.Int2) + } else if converted { + expression.LOperand = kindIDLiteral } } } diff --git a/drivers/pg/query/sql/schema_down.sql b/drivers/pg/query/sql/schema_down.sql index d1c53c0..5455ea9 100644 --- a/drivers/pg/query/sql/schema_down.sql +++ b/drivers/pg/query/sql/schema_down.sql @@ -10,6 +10,7 @@ drop function if exists jsonb_to_text_array; drop function if exists cypher_contains(text, text); drop function if exists cypher_starts_with(text, text); drop function if exists cypher_ends_with(text, text); +drop function if exists kind_name(smallint); drop function if exists start_node(edgeComposite); drop function if exists end_node(edgeComposite); drop function if exists get_node; diff --git a/drivers/pg/query/sql/schema_up.sql b/drivers/pg/query/sql/schema_up.sql index 6772a57..7a4f209 100644 --- a/drivers/pg/query/sql/schema_up.sql +++ b/drivers/pg/query/sql/schema_up.sql @@ -206,6 +206,18 @@ $$ $$; -- Database helper functions +create or replace function public.kind_name(_kind_id smallint) returns text as +$$ +select k.name::text +from kind k +where k.id = _kind_id +limit 1; +$$ + language sql + stable + parallel safe + strict; + create or replace function public.start_node(rel edgeComposite) returns nodeComposite as $$ select (n.id, n.kind_ids, n.properties)::nodeComposite diff --git a/integration/testdata/cases/stepwise.json b/integration/testdata/cases/stepwise.json index cf5aef3..1749d52 100644 --- a/integration/testdata/cases/stepwise.json +++ b/integration/testdata/cases/stepwise.json @@ -11,6 +11,26 @@ "cypher": "match ()-[r]->() where type(r) = 'EdgeKind1' return r", "assert": "non_empty" }, + { + "name": "return relationship type() string names", + "cypher": "match ()-[r]->() return type(r) order by type(r)", + "assert": {"ordered_scalar_values": ["EdgeKind1", "EdgeKind2"]} + }, + { + "name": "filter edges by type() inequality and return string names", + "cypher": "match ()-[r]->() where type(r) <> 'EdgeKind1' return type(r)", + "assert": {"ordered_scalar_values": ["EdgeKind2"]} + }, + { + "name": "filter edges by type() list membership and return string names", + "cypher": "match ()-[r]->() where type(r) in ['EdgeKind2'] return type(r)", + "assert": {"ordered_scalar_values": ["EdgeKind2"]} + }, + { + "name": "filter edges by type() string prefix and return string names", + "cypher": "match ()-[r]->() where type(r) STARTS WITH 'EdgeKind' return type(r) order by type(r)", + "assert": {"ordered_scalar_values": ["EdgeKind1", "EdgeKind2"]} + }, { "name": "count edges of a specific kind", "cypher": "match ()-[r:EdgeKind1]->() return count(r) as the_count", diff --git a/integration/testdata/templates/pattern_shapes.json b/integration/testdata/templates/pattern_shapes.json index 12d2c41..da32a86 100644 --- a/integration/testdata/templates/pattern_shapes.json +++ b/integration/testdata/templates/pattern_shapes.json @@ -102,6 +102,13 @@ "query": "match p=shortestPath((s:TemplateNodeKind1)-[:TemplateEdgeKind1|HasSession*1..]->(d:TemplateNodeKind1)) where s.name = 'path-filter-src' and d.name = 'path-filter-dst' and none(r in relationships(p) where type(r) = 'HasSession' and startNode(r).name = 'blocked-session-host') return p" }, "assert": "empty" + }, + { + "name": "path relationship type() supports string prefix predicates", + "vars": { + "query": "match p=(s:TemplateNodeKind1)-[:TemplateEdgeKind1|HasSession*1..1]->(n:TemplateNodeKind2) where s.name = 'path-filter-src' and n.name = 'blocked-session-host' and any(r in relationships(p) where type(r) STARTS WITH 'TemplateEdge') return p" + }, + "assert": {"path_node_ids": [["src", "blocked"]], "path_edge_kinds": [["TemplateEdgeKind1"]]} } ] } From b07abd7920d3786bab40d2e9af700a9535325335 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Mon, 11 May 2026 19:16:28 -0700 Subject: [PATCH 4/6] fix(pgsql): harden Cypher function translation edge cases and regressions --- cypher/models/cypher/functions.go | 1 - .../test/translation_cases/shortest_paths.sql | 4 +- cypher/models/pgsql/test/translation_test.go | 4 +- cypher/models/pgsql/translate/function.go | 49 ++++++++++++------- .../models/pgsql/translate/function_test.go | 10 ++++ cypher/models/pgsql/translate/hinting.go | 3 ++ cypher/models/pgsql/translate/hinting_test.go | 33 +++++++++++++ .../models/pgsql/translate/path_functions.go | 3 ++ integration/testdata/cases/aggregation.json | 2 +- 9 files changed, 83 insertions(+), 26 deletions(-) create mode 100644 cypher/models/pgsql/translate/hinting_test.go diff --git a/cypher/models/cypher/functions.go b/cypher/models/cypher/functions.go index bd36f5b..94e3bc2 100644 --- a/cypher/models/cypher/functions.go +++ b/cypher/models/cypher/functions.go @@ -13,7 +13,6 @@ const ( ToUpperFunction = "toupper" NodeLabelsFunction = "labels" EdgeTypeFunction = "type" - RelationshipsFunction = "relationships" StartNodeFunction = "startnode" EndNodeFunction = "endnode" StringSplitToArrayFunction = "split" diff --git a/cypher/models/pgsql/test/translation_cases/shortest_paths.sql b/cypher/models/pgsql/test/translation_cases/shortest_paths.sql index 24d5c97..60b87bc 100644 --- a/cypher/models/pgsql/test/translation_cases/shortest_paths.sql +++ b/cypher/models/pgsql/test/translation_cases/shortest_paths.sql @@ -87,5 +87,5 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from with s0 as (with s1 as (with s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_sp_harness(@pi0::text, @pi1::text, 15, ('')::text, ('insert into traversal_terminal_filter (id) select distinct n1.id from node n1 where n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id is not null;')::text)) select (select coalesce(array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s2.path) with ordinality as _path(id, ordinality) join edge e0 on e0.id = _path.id) as e0, 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 s2 join node n0 on n0.id = s2.root_id join node n1 on n1.id = s2.next_id where case when s2.root_id != s2.next_id then true else shortest_path_self_endpoint_error(s2.root_id, s2.next_id) end) select distinct s1.n1 as n2, count(s1.n0)::int8 as i0 from s1 group by n1) select ((s0.n2).properties -> 'name'), s0.i0 as UserCount from s0 order by s0.i0 desc limit 5; -- case: MATCH (g1:Group) MATCH (g2:Group) WHERE g1.name STARTS WITH 'DOMAIN USERS@' AND g2.name STARTS WITH 'DOMAIN ADMINS@' MATCH p=shortestPath((g1)-[:AddAllowedToAct|AddMember|AdminTo|AllExtendedRights|AllowedToDelegate|CanRDP|Contains|ForceChangePassword|GenericAll|GenericWrite|GetChangesAll|GetChanges|HasSession|MemberOf|Owns|ReadLAPSPassword|SQLAdmin|TrustedBy|WriteAccountRestrictions|WriteOwner*1..]->(g2)) WHERE NONE(r IN relationships(p) WHERE type(r) = 'HasSession' AND startNode(r).name = 'DF-WIN10-DEV01.DUMPSTER.FIRE') RETURN p --- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s3_seed(root_id) as not materialized (select s3_seed_filter.id as root_id from traversal_root_filter s3_seed_filter) select e0.start_id, e0.end_id, 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = e0.start_id and traversal_pair_filter.terminal_id = e0.end_id), e0.start_id = e0.end_id, array [e0.id] from s3_seed join edge e0 on e0.start_id = s3_seed.root_id where e0.kind_id = any (array [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25]::int2[]) and case when (select count(*)::int8 from traversal_terminal_filter where traversal_terminal_filter.id = e0.start_id) = 0 then true else shortest_path_self_endpoint_error(e0.start_id, e0.start_id) end;","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s3.root_id, e0.end_id, s3.depth + 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = s3.root_id and traversal_pair_filter.terminal_id = e0.end_id), false, s3.path || e0.id from forward_front s3 join edge e0 on e0.start_id = s3.next_id where e0.kind_id = any (array [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25]::int2[]) and e0.id != all (s3.path) and not exists (select 1 from forward_visited where forward_visited.root_id = s3.root_id and forward_visited.id = e0.end_id);","pi2":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s3_seed(root_id) as not materialized (select s3_seed_filter.id as root_id from traversal_terminal_filter s3_seed_filter) select e0.end_id, e0.start_id, 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = e0.start_id and traversal_pair_filter.terminal_id = e0.end_id), e0.start_id = e0.end_id, array [e0.id] from s3_seed join edge e0 on e0.end_id = s3_seed.root_id where e0.kind_id = any (array [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25]::int2[]);","pi3":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s3.root_id, e0.start_id, s3.depth + 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = e0.start_id and traversal_pair_filter.terminal_id = s3.root_id), false, e0.id || s3.path from backward_front s3 join edge e0 on e0.end_id = s3.next_id where e0.kind_id = any (array [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25]::int2[]) and e0.id != all (s3.path) and not exists (select 1 from backward_visited where backward_visited.root_id = s3.root_id and backward_visited.id = e0.start_id);"} -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 [5]::int2[]), s1 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where ((n1.properties ->> 'name') like 'DOMAIN ADMINS@%' and ((s0.n0).properties ->> 'name') like 'DOMAIN USERS@%') and n1.kind_ids operator (pg_catalog.@>) array [5]::int2[]), s2 as (with s3(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from bidirectional_sp_harness(@pi0::text, @pi1::text, @pi2::text, @pi3::text, 15, ('')::text, ('')::text, ('insert into traversal_pair_filter (root_id, terminal_id) select distinct (s1.n0).id, (s1.n1).id from s1 where (s1.n0).id is not null and (s1.n1).id is not null;')::text)) select (select coalesce(array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s3.path) with ordinality as _path(id, ordinality) join edge e0 on e0.id = _path.id) as e0, s3.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, s3 join node n0 on n0.id = s3.root_id join node n1 on n1.id = s3.next_id where (s1.n0).id = s3.root_id and (s1.n1).id = s3.next_id and case when s3.root_id != s3.next_id then true else shortest_path_self_endpoint_error(s3.root_id, s3.next_id) end) select ordered_edges_to_path(s2.n0, s2.e0, array [s2.n0, s2.n1]::nodecomposite[])::pathcomposite as p from s2 where (((select count(*)::int from unnest((s2.e0)::edgecomposite[]) as i0 where ((((start_node(i0)::nodecomposite).properties -> 'name'))::jsonb = to_jsonb(('DF-WIN10-DEV01.DUMPSTER.FIRE')::text)::jsonb and i0.kind_id = 18)) = 0 and (s2.e0)::edgecomposite[] is not null)::bool); +-- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s3_seed(root_id) as not materialized (select s3_seed_filter.id as root_id from traversal_root_filter s3_seed_filter) select e0.start_id, e0.end_id, 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = e0.start_id and traversal_pair_filter.terminal_id = e0.end_id), e0.start_id = e0.end_id, array [e0.id] from s3_seed join edge e0 on e0.start_id = s3_seed.root_id where e0.kind_id = any (array [14, 15, 16, 17, 18, 19, 12, 20, 21, 22, 23, 24, 7, 25, 26, 27, 28, 29, 30, 31]::int2[]) and case when (select count(*)::int8 from traversal_terminal_filter where traversal_terminal_filter.id = e0.start_id) = 0 then true else shortest_path_self_endpoint_error(e0.start_id, e0.start_id) end;","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s3.root_id, e0.end_id, s3.depth + 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = s3.root_id and traversal_pair_filter.terminal_id = e0.end_id), false, s3.path || e0.id from forward_front s3 join edge e0 on e0.start_id = s3.next_id where e0.kind_id = any (array [14, 15, 16, 17, 18, 19, 12, 20, 21, 22, 23, 24, 7, 25, 26, 27, 28, 29, 30, 31]::int2[]) and e0.id != all (s3.path) and not exists (select 1 from forward_visited where forward_visited.root_id = s3.root_id and forward_visited.id = e0.end_id);","pi2":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s3_seed(root_id) as not materialized (select s3_seed_filter.id as root_id from traversal_terminal_filter s3_seed_filter) select e0.end_id, e0.start_id, 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = e0.start_id and traversal_pair_filter.terminal_id = e0.end_id), e0.start_id = e0.end_id, array [e0.id] from s3_seed join edge e0 on e0.end_id = s3_seed.root_id where e0.kind_id = any (array [14, 15, 16, 17, 18, 19, 12, 20, 21, 22, 23, 24, 7, 25, 26, 27, 28, 29, 30, 31]::int2[]);","pi3":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s3.root_id, e0.start_id, s3.depth + 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = e0.start_id and traversal_pair_filter.terminal_id = s3.root_id), false, e0.id || s3.path from backward_front s3 join edge e0 on e0.end_id = s3.next_id where e0.kind_id = any (array [14, 15, 16, 17, 18, 19, 12, 20, 21, 22, 23, 24, 7, 25, 26, 27, 28, 29, 30, 31]::int2[]) and e0.id != all (s3.path) and not exists (select 1 from backward_visited where backward_visited.root_id = s3.root_id and backward_visited.id = e0.start_id);"} +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 [13]::int2[]), s1 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where ((n1.properties ->> 'name') like 'DOMAIN ADMINS@%' and ((s0.n0).properties ->> 'name') like 'DOMAIN USERS@%') and n1.kind_ids operator (pg_catalog.@>) array [13]::int2[]), s2 as (with s3(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from bidirectional_sp_harness(@pi0::text, @pi1::text, @pi2::text, @pi3::text, 15, ('')::text, ('')::text, ('insert into traversal_pair_filter (root_id, terminal_id) select distinct (s1.n0).id, (s1.n1).id from s1 where (s1.n0).id is not null and (s1.n1).id is not null;')::text)) select (select coalesce(array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s3.path) with ordinality as _path(id, ordinality) join edge e0 on e0.id = _path.id) as e0, s3.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, s3 join node n0 on n0.id = s3.root_id join node n1 on n1.id = s3.next_id where (s1.n0).id = s3.root_id and (s1.n1).id = s3.next_id and case when s3.root_id != s3.next_id then true else shortest_path_self_endpoint_error(s3.root_id, s3.next_id) end) select ordered_edges_to_path(s2.n0, s2.e0, array [s2.n0, s2.n1]::nodecomposite[])::pathcomposite as p from s2 where (((select count(*)::int from unnest((s2.e0)::edgecomposite[]) as i0 where ((((start_node(i0)::nodecomposite).properties -> 'name'))::jsonb = to_jsonb(('DF-WIN10-DEV01.DUMPSTER.FIRE')::text)::jsonb and i0.kind_id = 7)) = 0 and (s2.e0)::edgecomposite[] is not null)::bool); diff --git a/cypher/models/pgsql/test/translation_test.go b/cypher/models/pgsql/test/translation_test.go index ea040df..2d81701 100644 --- a/cypher/models/pgsql/test/translation_test.go +++ b/cypher/models/pgsql/test/translation_test.go @@ -7,10 +7,8 @@ import ( "strings" "testing" - "github.com/specterops/dawgs/drivers/pg/pgutil" - "github.com/specterops/dawgs/graph" - "github.com/specterops/dawgs/cypher/models/pgsql" + "github.com/specterops/dawgs/drivers/pg/pgutil" "github.com/specterops/dawgs/graph" ) diff --git a/cypher/models/pgsql/translate/function.go b/cypher/models/pgsql/translate/function.go index f52f677..a116ee2 100644 --- a/cypher/models/pgsql/translate/function.go +++ b/cypher/models/pgsql/translate/function.go @@ -221,13 +221,36 @@ func (s *Translator) translatePathComponentFunction(functionInvocation *cypher.F if argument, err := s.treeTranslator.PopOperand(); err != nil { return err - } else if pathExpression, err := s.expressionForPath(argument); err != nil { - return err } else { - s.treeTranslator.PushOperand(pgsql.NewTypeCast(pgsql.RowColumnReference{ - Identifier: pathExpression, - Column: column, - }, castType)) + if column == pgsql.ColumnEdges { + if identifier, isIdentifier := unwrapParenthetical(argument).(pgsql.Identifier); isIdentifier { + binding, bound := s.scope.Lookup(identifier) + if !bound { + binding, bound = s.scope.AliasedLookup(identifier) + } + + if !bound { + return fmt.Errorf("unable to resolve path identifier %s", identifier) + } else if binding.DataType != pgsql.PathComposite { + return fmt.Errorf("expected path expression but received %s", binding.DataType) + } + + s.treeTranslator.PushOperand(pgsql.NewTypeCast(pgsql.RowColumnReference{ + Identifier: argument, + Column: column, + }, castType)) + return nil + } + } + + if pathExpression, err := s.expressionForPath(argument); err != nil { + return err + } else { + s.treeTranslator.PushOperand(pgsql.NewTypeCast(pgsql.RowColumnReference{ + Identifier: pathExpression, + Column: column, + }, castType)) + } } return nil @@ -239,7 +262,7 @@ func prepareCollectExpression(scope *Scope, collectedExpression pgsql.Expression switch typedArgument := unwrapParenthetical(collectedExpression).(type) { case pgsql.Identifier: if binding, bound := scope.Lookup(typedArgument); !bound { - return nil, pgsql.UnsetDataType, fmt.Errorf("binding not found for collect function argument %s", functionName) + return nil, pgsql.UnsetDataType, fmt.Errorf("binding not found for %s function argument %s", functionName, typedArgument) } else if bindingArrayType, err := binding.DataType.ToArrayType(); err != nil { return nil, pgsql.UnsetDataType, err } else { @@ -381,18 +404,6 @@ func (s *Translator) translateFunction(typedExpression *cypher.FunctionInvocatio }) } - case cypher.RelationshipsFunction: - if typedExpression.NumArguments() != 1 { - s.SetError(fmt.Errorf("expected only one argument for cypher function: %s", typedExpression.Name)) - } else if argument, err := s.treeTranslator.PopOperand(); err != nil { - s.SetError(err) - } else { - s.treeTranslator.PushOperand(pgsql.NewTypeCast(pgsql.RowColumnReference{ - Identifier: argument, - Column: pgsql.ColumnEdges, - }, pgsql.EdgeCompositeArray)) - } - case cypher.StartNodeFunction: if typedExpression.NumArguments() != 1 { s.SetError(fmt.Errorf("expected only one argument for cypher function: %s", typedExpression.Name)) diff --git a/cypher/models/pgsql/translate/function_test.go b/cypher/models/pgsql/translate/function_test.go index 7bd1ff0..c94249a 100644 --- a/cypher/models/pgsql/translate/function_test.go +++ b/cypher/models/pgsql/translate/function_test.go @@ -5,6 +5,8 @@ import ( "testing" "github.com/specterops/dawgs/cypher/frontend" + "github.com/specterops/dawgs/cypher/models/cypher" + "github.com/specterops/dawgs/cypher/models/pgsql" "github.com/specterops/dawgs/drivers/pg/pgutil" "github.com/stretchr/testify/require" ) @@ -23,3 +25,11 @@ func TestPathComponentFunctionsResolvePathAliases(t *testing.T) { require.Contains(t, formatted, ".nodes") require.Contains(t, formatted, ".edges") } + +func TestPrepareCollectExpressionMissingBindingErrorNamesArgument(t *testing.T) { + t.Parallel() + + _, _, err := prepareCollectExpression(NewScope(), pgsql.Identifier("missing"), cypher.CollectFunction) + + require.EqualError(t, err, "binding not found for collect function argument missing") +} diff --git a/cypher/models/pgsql/translate/hinting.go b/cypher/models/pgsql/translate/hinting.go index b50b6ca..256f389 100644 --- a/cypher/models/pgsql/translate/hinting.go +++ b/cypher/models/pgsql/translate/hinting.go @@ -196,6 +196,9 @@ func literalKindID(kindMapper *contextAwareKindMapper, literal pgsql.Literal) (p if err != nil { return pgsql.Literal{}, false, err } + if len(kindIDs) == 0 { + return pgsql.Literal{}, false, nil + } return pgsql.NewLiteral(kindIDs[0], pgsql.Int2), true, nil } diff --git a/cypher/models/pgsql/translate/hinting_test.go b/cypher/models/pgsql/translate/hinting_test.go new file mode 100644 index 0000000..ffebf02 --- /dev/null +++ b/cypher/models/pgsql/translate/hinting_test.go @@ -0,0 +1,33 @@ +package translate + +import ( + "context" + "testing" + + "github.com/specterops/dawgs/cypher/models/pgsql" + "github.com/specterops/dawgs/graph" + "github.com/stretchr/testify/require" +) + +type emptyKindMapper struct{} + +func (s emptyKindMapper) MapKinds(context.Context, graph.Kinds) ([]int16, error) { + return nil, nil +} + +func (s emptyKindMapper) AssertKinds(context.Context, graph.Kinds) ([]int16, error) { + return nil, nil +} + +func TestLiteralKindIDEmptyMappedKindsIsNotConverted(t *testing.T) { + t.Parallel() + + literal, converted, err := literalKindID( + newContextAwareKindMapper(context.Background(), emptyKindMapper{}), + pgsql.NewLiteral("MissingKind", pgsql.Text), + ) + + require.NoError(t, err) + require.False(t, converted) + require.Equal(t, pgsql.Literal{}, literal) +} diff --git a/cypher/models/pgsql/translate/path_functions.go b/cypher/models/pgsql/translate/path_functions.go index 5321624..a73be81 100644 --- a/cypher/models/pgsql/translate/path_functions.go +++ b/cypher/models/pgsql/translate/path_functions.go @@ -44,6 +44,9 @@ func resolvePathCompositeFieldReference(scope *Scope, reference pgsql.RowColumnR } binding, bound := scope.Lookup(identifier) + if !bound { + binding, bound = scope.AliasedLookup(identifier) + } if !bound || binding.DataType != pgsql.PathComposite { return nil, false, nil } diff --git a/integration/testdata/cases/aggregation.json b/integration/testdata/cases/aggregation.json index 4f641c9..278869c 100644 --- a/integration/testdata/cases/aggregation.json +++ b/integration/testdata/cases/aggregation.json @@ -29,7 +29,7 @@ { "name": "return the size of collected labels lists", "cypher": "MATCH (n) WITH collect(labels(n)) as label_sets RETURN size(label_sets)", - "assert": {"exact_int": 3} + "assert": {"at_least_int": 3} }, { "name": "filter on an aggregate result using WITH and WHERE", From 621a51b2351f3f5b2fa0a2cfe885b5e2d1f25c99 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Mon, 11 May 2026 22:16:22 -0700 Subject: [PATCH 5/6] fix(pgsql): materialize path bindings across WITH projections --- .../pgsql/test/translation_cases/shortest_paths.sql | 5 +++++ cypher/models/pgsql/translate/with.go | 13 ++++++++++++- integration/testdata/templates/pattern_shapes.json | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/cypher/models/pgsql/test/translation_cases/shortest_paths.sql b/cypher/models/pgsql/test/translation_cases/shortest_paths.sql index 60b87bc..f4c4555 100644 --- a/cypher/models/pgsql/test/translation_cases/shortest_paths.sql +++ b/cypher/models/pgsql/test/translation_cases/shortest_paths.sql @@ -89,3 +89,8 @@ with s0 as (with s1 as (with s2(root_id, next_id, depth, satisfied, is_cycle, pa -- case: MATCH (g1:Group) MATCH (g2:Group) WHERE g1.name STARTS WITH 'DOMAIN USERS@' AND g2.name STARTS WITH 'DOMAIN ADMINS@' MATCH p=shortestPath((g1)-[:AddAllowedToAct|AddMember|AdminTo|AllExtendedRights|AllowedToDelegate|CanRDP|Contains|ForceChangePassword|GenericAll|GenericWrite|GetChangesAll|GetChanges|HasSession|MemberOf|Owns|ReadLAPSPassword|SQLAdmin|TrustedBy|WriteAccountRestrictions|WriteOwner*1..]->(g2)) WHERE NONE(r IN relationships(p) WHERE type(r) = 'HasSession' AND startNode(r).name = 'DF-WIN10-DEV01.DUMPSTER.FIRE') RETURN p -- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s3_seed(root_id) as not materialized (select s3_seed_filter.id as root_id from traversal_root_filter s3_seed_filter) select e0.start_id, e0.end_id, 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = e0.start_id and traversal_pair_filter.terminal_id = e0.end_id), e0.start_id = e0.end_id, array [e0.id] from s3_seed join edge e0 on e0.start_id = s3_seed.root_id where e0.kind_id = any (array [14, 15, 16, 17, 18, 19, 12, 20, 21, 22, 23, 24, 7, 25, 26, 27, 28, 29, 30, 31]::int2[]) and case when (select count(*)::int8 from traversal_terminal_filter where traversal_terminal_filter.id = e0.start_id) = 0 then true else shortest_path_self_endpoint_error(e0.start_id, e0.start_id) end;","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s3.root_id, e0.end_id, s3.depth + 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = s3.root_id and traversal_pair_filter.terminal_id = e0.end_id), false, s3.path || e0.id from forward_front s3 join edge e0 on e0.start_id = s3.next_id where e0.kind_id = any (array [14, 15, 16, 17, 18, 19, 12, 20, 21, 22, 23, 24, 7, 25, 26, 27, 28, 29, 30, 31]::int2[]) and e0.id != all (s3.path) and not exists (select 1 from forward_visited where forward_visited.root_id = s3.root_id and forward_visited.id = e0.end_id);","pi2":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s3_seed(root_id) as not materialized (select s3_seed_filter.id as root_id from traversal_terminal_filter s3_seed_filter) select e0.end_id, e0.start_id, 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = e0.start_id and traversal_pair_filter.terminal_id = e0.end_id), e0.start_id = e0.end_id, array [e0.id] from s3_seed join edge e0 on e0.end_id = s3_seed.root_id where e0.kind_id = any (array [14, 15, 16, 17, 18, 19, 12, 20, 21, 22, 23, 24, 7, 25, 26, 27, 28, 29, 30, 31]::int2[]);","pi3":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s3.root_id, e0.start_id, s3.depth + 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = e0.start_id and traversal_pair_filter.terminal_id = s3.root_id), false, e0.id || s3.path from backward_front s3 join edge e0 on e0.end_id = s3.next_id where e0.kind_id = any (array [14, 15, 16, 17, 18, 19, 12, 20, 21, 22, 23, 24, 7, 25, 26, 27, 28, 29, 30, 31]::int2[]) and e0.id != all (s3.path) and not exists (select 1 from backward_visited where backward_visited.root_id = s3.root_id and backward_visited.id = e0.start_id);"} 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 [13]::int2[]), s1 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where ((n1.properties ->> 'name') like 'DOMAIN ADMINS@%' and ((s0.n0).properties ->> 'name') like 'DOMAIN USERS@%') and n1.kind_ids operator (pg_catalog.@>) array [13]::int2[]), s2 as (with s3(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from bidirectional_sp_harness(@pi0::text, @pi1::text, @pi2::text, @pi3::text, 15, ('')::text, ('')::text, ('insert into traversal_pair_filter (root_id, terminal_id) select distinct (s1.n0).id, (s1.n1).id from s1 where (s1.n0).id is not null and (s1.n1).id is not null;')::text)) select (select coalesce(array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s3.path) with ordinality as _path(id, ordinality) join edge e0 on e0.id = _path.id) as e0, s3.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, s3 join node n0 on n0.id = s3.root_id join node n1 on n1.id = s3.next_id where (s1.n0).id = s3.root_id and (s1.n1).id = s3.next_id and case when s3.root_id != s3.next_id then true else shortest_path_self_endpoint_error(s3.root_id, s3.next_id) end) select ordered_edges_to_path(s2.n0, s2.e0, array [s2.n0, s2.n1]::nodecomposite[])::pathcomposite as p from s2 where (((select count(*)::int from unnest((s2.e0)::edgecomposite[]) as i0 where ((((start_node(i0)::nodecomposite).properties -> 'name'))::jsonb = to_jsonb(('DF-WIN10-DEV01.DUMPSTER.FIRE')::text)::jsonb and i0.kind_id = 7)) = 0 and (s2.e0)::edgecomposite[] is not null)::bool); + +-- case: match p=shortestPath((s:NodeKind1)-[:EdgeKind1|HasSession*1..]->(d:NodeKind1)) where s.name = 'path-filter-src' and d.name = 'path-filter-dst' with p where none(r in relationships(p) where type(r) = 'HasSession' and startNode(r).name = 'blocked-session-host') return p +-- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s2_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -\u003e 'name'))::jsonb = to_jsonb(('path-filter-src')::text)::jsonb) and n0.kind_ids operator (pg_catalog.@\u003e) array [1]::int2[]) select e0.start_id, e0.end_id, 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = e0.start_id and traversal_pair_filter.terminal_id = e0.end_id), e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id where e0.kind_id = any (array [3, 7]::int2[]) and case when (select count(*)::int8 from traversal_pair_filter where traversal_pair_filter.root_id = e0.start_id and traversal_pair_filter.terminal_id = e0.start_id) = 0 then true else shortest_path_self_endpoint_error(e0.start_id, e0.start_id) end;","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s2.root_id, e0.end_id, s2.depth + 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = s2.root_id and traversal_pair_filter.terminal_id = e0.end_id), false, s2.path || e0.id from forward_front s2 join edge e0 on e0.start_id = s2.next_id where e0.kind_id = any (array [3, 7]::int2[]) and e0.id != all (s2.path) and not exists (select 1 from forward_visited where forward_visited.root_id = s2.root_id and forward_visited.id = e0.end_id);","pi2":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s2_seed(root_id) as not materialized (select n1.id as root_id from node n1 where (((n1.properties -\u003e 'name'))::jsonb = to_jsonb(('path-filter-dst')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@\u003e) array [1]::int2[]) select e0.end_id, e0.start_id, 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = e0.start_id and traversal_pair_filter.terminal_id = e0.end_id), e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.end_id = s2_seed.root_id where e0.kind_id = any (array [3, 7]::int2[]);","pi3":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s2.root_id, e0.start_id, s2.depth + 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = e0.start_id and traversal_pair_filter.terminal_id = s2.root_id), false, e0.id || s2.path from backward_front s2 join edge e0 on e0.end_id = s2.next_id where e0.kind_id = any (array [3, 7]::int2[]) and e0.id != all (s2.path) and not exists (select 1 from backward_visited where backward_visited.root_id = s2.root_id and backward_visited.id = e0.start_id);"} +with s0 as (with s1 as (with s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from bidirectional_sp_harness(@pi0::text, @pi1::text, @pi2::text, @pi3::text, 15, ('')::text, ('')::text, ('insert into traversal_pair_filter (root_id, terminal_id) select distinct n0.id, n1.id from node n0, node n1 where (((n0.properties -> ''name''))::jsonb = to_jsonb((''path-filter-src'')::text)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (((n1.properties -> ''name''))::jsonb = to_jsonb((''path-filter-dst'')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id is not null and n1.id is not null;')::text)) select (select coalesce(array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s2.path) with ordinality as _path(id, ordinality) join edge e0 on e0.id = _path.id) as e0, 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 s2 join node n0 on n0.id = s2.root_id join node n1 on n1.id = s2.next_id where case when s2.root_id != s2.next_id then true else shortest_path_self_endpoint_error(s2.root_id, s2.next_id) end) select ordered_edges_to_path(s1.n0, s1.e0, array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite as pc0 from s1) select s0.pc0 as p from s0 where (((select count(*)::int from unnest(((s0.pc0).edges)::edgecomposite[]) as i0 where ((((start_node(i0)::nodecomposite).properties -> 'name'))::jsonb = to_jsonb(('blocked-session-host')::text)::jsonb and i0.kind_id = 7)) = 0 and ((s0.pc0).edges)::edgecomposite[] is not null)::bool); + diff --git a/cypher/models/pgsql/translate/with.go b/cypher/models/pgsql/translate/with.go index d3eb695..88358e8 100644 --- a/cypher/models/pgsql/translate/with.go +++ b/cypher/models/pgsql/translate/with.go @@ -96,6 +96,15 @@ func (s *Translator) translateWith() error { selectItem = pgsql.CompoundIdentifier{ binding.LastProjection.Binding.Identifier, typedSelectItem, } + } else if projectedBinding.DataType == pgsql.PathComposite { + builtProjection, err := buildProjection(projectedBinding.Identifier, projectedBinding, s.scope, nil) + if err != nil { + return err + } + if len(builtProjection) != 1 { + return fmt.Errorf("expected path projection %s to produce one select item, got %d", projectedBinding.Identifier, len(builtProjection)) + } + selectItem = builtProjection[0] } else { // A WITH can project bindings introduced in the same query // part before they have a materialized frame back-reference. @@ -108,7 +117,9 @@ func (s *Translator) translateWith() error { // Create a new projection that maps the identifier currentPart.projections.Items[idx] = &Projection{ SelectItem: selectItem, - Alias: pgsql.AsOptionalIdentifier(projectedBinding.Identifier), + } + if projectedBinding.DataType != pgsql.PathComposite || binding.LastProjection != nil { + currentPart.projections.Items[idx].Alias = pgsql.AsOptionalIdentifier(projectedBinding.Identifier) } // Assign the frame to the binding's last projection backref diff --git a/integration/testdata/templates/pattern_shapes.json b/integration/testdata/templates/pattern_shapes.json index da32a86..a2980c0 100644 --- a/integration/testdata/templates/pattern_shapes.json +++ b/integration/testdata/templates/pattern_shapes.json @@ -99,7 +99,7 @@ { "name": "post-filtered shortest path is not replaced by longer path", "vars": { - "query": "match p=shortestPath((s:TemplateNodeKind1)-[:TemplateEdgeKind1|HasSession*1..]->(d:TemplateNodeKind1)) where s.name = 'path-filter-src' and d.name = 'path-filter-dst' and none(r in relationships(p) where type(r) = 'HasSession' and startNode(r).name = 'blocked-session-host') return p" + "query": "match p=shortestPath((s:TemplateNodeKind1)-[:TemplateEdgeKind1|HasSession*1..]->(d:TemplateNodeKind1)) where s.name = 'path-filter-src' and d.name = 'path-filter-dst' with p where none(r in relationships(p) where type(r) = 'HasSession' and startNode(r).name = 'blocked-session-host') return p" }, "assert": "empty" }, From ae5d313bf2a41b8dd8ce5d8766fea8b9775f6b47 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Mon, 11 May 2026 22:25:05 -0700 Subject: [PATCH 6/6] test (cysql): make labels ordering fixture collation independent --- integration/testdata/cases/nodes.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/integration/testdata/cases/nodes.json b/integration/testdata/cases/nodes.json index e962579..8c177fe 100644 --- a/integration/testdata/cases/nodes.json +++ b/integration/testdata/cases/nodes.json @@ -3,8 +3,8 @@ "cases": [ { "name": "return kind labels for all nodes", - "cypher": "match (n) return labels(n) order by n.name", - "assert": {"ordered_scalar_values": [["NodeKind2"], ["NodeKind1"], ["NodeKind1", "NodeKind2"]]} + "cypher": "match (n) return labels(n) order by n.value", + "assert": {"ordered_scalar_values": [["NodeKind1"], ["NodeKind2"], ["NodeKind1", "NodeKind2"]]} }, { "name": "return string kind labels from labels()",