From 86f7b73ae64cc1b23e48df545f53dbc8376b902a Mon Sep 17 00:00:00 2001 From: AlyaEru Date: Mon, 5 Oct 2026 17:51:44 -0400 Subject: [PATCH 1/5] fix: multi-part optional matches were malfunctioning due to simplistic frame selection Optional matches with more complex selections requiring multiple frames would break since the join logic was simplistically getting the prior frame, not the last pre-optional-merge-selection result. --- cypher/models/pgsql/translate/match.go | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/cypher/models/pgsql/translate/match.go b/cypher/models/pgsql/translate/match.go index 55c955e0..ce47c1e2 100644 --- a/cypher/models/pgsql/translate/match.go +++ b/cypher/models/pgsql/translate/match.go @@ -10,6 +10,10 @@ import ( func (s *Translator) translateMatch(match *cypher.Match) error { currentQueryPart := s.query.CurrentPart() + // Note the current frame before adding frames from the match + // so we can use this point for optional match outer joins + joinFrame := s.scope.CurrentFrame() + for _, part := range currentQueryPart.ConsumeCurrentPattern().Parts { if !part.IsTraversal { if err := s.translateNonTraversalPatternPart(part); err != nil { @@ -42,21 +46,21 @@ func (s *Translator) translateMatch(match *cypher.Match) error { // If there is no valid previous frame, skip translating an `OPTIONAL MATCH`/treat as plain `MATCH` if match.Optional { if _, hasValidPrevious := s.previousValidFrame(s.scope.CurrentFrame()); hasValidPrevious { - return s.translateOptionalMatch() + return s.translateOptionalMatch(joinFrame) } } return nil } -func (s *Translator) translateOptionalMatch() error { +func (s *Translator) translateOptionalMatch(joinFrame *Frame) error { // Building this aggregation step requires pushing another frame onto the scope aggrFrame, err := s.scope.PushFrame() if err != nil { return err } - query, err := s.buildOptionalMatchAggregationStep(aggrFrame) + query, err := s.buildOptionalMatchAggregationStep(aggrFrame, joinFrame) if err != nil { return err } @@ -83,13 +87,12 @@ func (s *Translator) translateOptionalMatch() error { // buildOptionalMatchAggregationStep constructs a "merge" frame to insert after an `OPTIONAL MATCH`, // which requires a subsequent "aggregation" step to collate the optional match to the initial result set. -func (s *Translator) buildOptionalMatchAggregationStep(aggregationFrame *Frame) (pgsql.Query, error) { +func (s *Translator) buildOptionalMatchAggregationStep(aggregationFrame *Frame, originFrame *Frame) (pgsql.Query, error) { // An "aggregation" frame like this will only be triggered after an OPTIONAL MATCH, which should only // take place AFTER `n>=1` previous MATCH expressions. To properly base the aggregation, we need to // join to the origin frame (prior to the OPTIONAL MATCH) based on the OPTIONAL MATCH's frame. var ( optMatchFrame = aggregationFrame.Previous - originFrame = optMatchFrame.Previous ) // originFrame could be nil if no previous frame is defined (for ex., leading OPTIONAL MATCH, which is From ec45cff678168a77e1d87dac259e2d06b25f520e Mon Sep 17 00:00:00 2001 From: AlyaEru Date: Mon, 5 Oct 2026 17:55:55 -0400 Subject: [PATCH 2/5] fix: multiple optional match was missing results This was due to earlier populated null results failing the join operator since Null=Null evaluates to false. --- cypher/models/pgsql/operators.go | 2 ++ cypher/models/pgsql/translate/match.go | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/cypher/models/pgsql/operators.go b/cypher/models/pgsql/operators.go index 14970398..9af83f70 100644 --- a/cypher/models/pgsql/operators.go +++ b/cypher/models/pgsql/operators.go @@ -90,6 +90,8 @@ const ( OperatorRegexMatch Operator = "~" OperatorAssignment Operator = "=" OperatorAdditionAssignment Operator = "+=" + OperatorIsDistinctFrom Operator = "is distinct from" + OperatorIsNotDistinctFrom Operator = "is not distinct from" OperatorCypherRegexMatch Operator = "=~" OperatorCypherStartsWith Operator = "starts with" diff --git a/cypher/models/pgsql/translate/match.go b/cypher/models/pgsql/translate/match.go index ce47c1e2..fcc6d408 100644 --- a/cypher/models/pgsql/translate/match.go +++ b/cypher/models/pgsql/translate/match.go @@ -106,11 +106,13 @@ func (s *Translator) buildOptionalMatchAggregationStep(aggregationFrame *Frame, // our join anchor between the two CTEs var joinConstraints pgsql.Expression for _, exported := range originFrame.Exported.Slice() { + // Note using this operator will match Null=Null. This is needed to handle OPTIONAL MATCH + // correctly. It assumes exports will always have appropriate non-null join keys. joinConstraints = pgsql.OptionalAnd( pgsql.NewParenthetical( pgsql.NewBinaryExpression( pgsql.CompoundIdentifier{originFrame.Binding.Identifier, exported}, - pgsql.OperatorEquals, + pgsql.OperatorIsNotDistinctFrom, pgsql.CompoundIdentifier{optMatchFrame.Binding.Identifier, exported}, ), ), From 3217cc27dd9d589567a59479b26095a47509a2b9 Mon Sep 17 00:00:00 2001 From: AlyaEru Date: Tue, 6 Oct 2026 15:02:08 -0400 Subject: [PATCH 3/5] test: fixed tests and added a couple more --- .../models/pgsql/test/translation_cases/multipart.sql | 11 ++++++++++- cypher/models/pgsql/test/translation_cases/nodes.sql | 4 ++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/cypher/models/pgsql/test/translation_cases/multipart.sql b/cypher/models/pgsql/test/translation_cases/multipart.sql index 919e83e5..fc43134a 100644 --- a/cypher/models/pgsql/test/translation_cases/multipart.sql +++ b/cypher/models/pgsql/test/translation_cases/multipart.sql @@ -108,4 +108,13 @@ with s0 as (with s1 as (with recursive s2_seed(root_id) as not materialized (sel with s0 as (with s1 as (select e0.id as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on n0.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[])), s2 as (select s1.e0 as e0, s1.n0 as n0, s1.n1 as n1 from s1 join edge e1 on (s1.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[]) and e1.id != s1.e0) select count(s2.n0)::int8 as i0 from s2), s3 as (select e2.id as e2, s0.i0 as i0, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3, (n4.id, n4.kind_ids, n4.properties)::nodecomposite as n4 from s0, edge e2 join node n3 on n3.id = e2.start_id join node n4 on n4.id = e2.end_id where e2.kind_id = any (array [3]::int2[]) and (s0.i0 > 5)), s4 as (select s3.e2 as e2, e3.id as e3, s3.i0 as i0, s3.n3 as n3, s3.n4 as n4, (n5.id, n5.kind_ids, n5.properties)::nodecomposite as n5 from s3 join edge e3 on (s3.n4).id = e3.start_id join node n5 on n5.id = e3.end_id where e3.kind_id = any (array [4]::int2[]) and e3.id != s3.e2) select case when (s4.n3).id is null or s4.e2 is null or (s4.n4).id is null or s4.e3 is null or (s4.n5).id is null then null else ordered_edges_to_path(s4.n3, (select coalesce(array_agg((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(array [s4.e2]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id) || (select coalesce(array_agg((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(array [s4.e3]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n3, s4.n4, s4.n5]::nodecomposite[])::pathcomposite end as p from s4; -- case: match (g:NodeKind1) optional match (g)<-[r:EdgeKind1]-(m:NodeKind2) with g, count(r) as memberCount where memberCount = 0 return g -with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, s1.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join edge e0 on (s1.n0).id = e0.end_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.start_id where e0.kind_id = any (array [3]::int2[])), s3 as (select s1.n0 as n0, s2.e0 as e0, s2.n1 as n1 from s1 left outer join s2 on (s1.n0 = s2.n0)) select s3.n0 as n0, count(s3.e0)::int8 as i0 from s3 group by n0) select s0.n0 as g from s0 where (s0.i0 = 0); +with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, s1.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join edge e0 on (s1.n0).id = e0.end_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.start_id where e0.kind_id = any (array [3]::int2[])), s3 as (select s1.n0 as n0, s2.e0 as e0, s2.n1 as n1 from s1 left outer join s2 on (s1.n0 is not distinct from s2.n0)) select s3.n0 as n0, count(s3.e0)::int8 as i0 from s3 group by n0) select s0.n0 as g from s0 where (s0.i0 = 0); + +-- case: match (g:NodeKind1) optional match (g)<-[r:EdgeKind1]-(m:NodeKind2) with g, count(r) as memberCount where memberCount = 0 return g +with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, s1.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join edge e0 on (s1.n0).id = e0.end_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.start_id where e0.kind_id = any (array [3]::int2[])), s3 as (select s1.n0 as n0, s2.e0 as e0, s2.n1 as n1 from s1 left outer join s2 on (s1.n0 is not distinct from s2.n0)) select s3.n0 as n0, count(s3.e0)::int8 as i0 from s3 group by n0) select s0.n0 as g from s0 where (s0.i0 = 0); + +-- case: match (g:NodeKind1) optional match (m:NodeKind2)-[:EdgeKind1]->(g)-[:EdgeKind1]->(m2:NodeKind2) return g +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0 join edge e0 on (s0.n0).id = e0.end_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.start_id where e0.kind_id = any (array [3]::int2[])), s2 as (select s1.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s1.n0 as n0, s1.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s1 join edge e1 on (s1.n0).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [3]::int2[]) and e1.id != (s1.e0).id), s3 as (select s0.n0 as n0, s2.e0 as e0, s2.e1 as e1, s2.n1 as n1, s2.n2 as n2 from s0 left outer join s2 on (s0.n0 is not distinct from s2.n0)) select s3.n0 as g from s3; + +-- case: match (g:NodeKind1) optional match (m:NodeKind2)-[:EdgeKind1]->(g) optional match (g)-[:EdgeKind1]->(m2:NodeKind2) return g +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0 join edge e0 on (s0.n0).id = e0.end_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.start_id where e0.kind_id = any (array [3]::int2[])), s2 as (select s0.n0 as n0, s1.e0 as e0, s1.n1 as n1 from s0 left outer join s1 on (s0.n0 is not distinct from s1.n0)), s3 as (select s2.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s2.n0 as n0, s2.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s2 join edge e1 on (s2.n0).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [3]::int2[])), s4 as (select s2.e0 as e0, s2.n0 as n0, s2.n1 as n1, s3.e1 as e1, s3.n2 as n2 from s2 left outer join s3 on (s2.n1 is not distinct from s3.n1) and (s2.n0 is not distinct from s3.n0) and (s2.e0 is not distinct from s3.e0)) select s4.n0 as g from s4; diff --git a/cypher/models/pgsql/test/translation_cases/nodes.sql b/cypher/models/pgsql/test/translation_cases/nodes.sql index 96a1b431..6cf3c52f 100644 --- a/cypher/models/pgsql/test/translation_cases/nodes.sql +++ b/cypher/models/pgsql/test/translation_cases/nodes.sql @@ -467,13 +467,13 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> E'prop') != (@__strlit0::text)::jsonb and (n0.properties -> E'prop') != (@__strlit1::text)::jsonb or (n0.properties -> E'prop') = (@__strlit1::text)::jsonb and null))) select s0.n0 as s from s0; -- case: match (n:NodeKind1) optional match (m:NodeKind2) where m.distinguishedname = n.unknown + m.unknown return n, m -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where ((n1.properties ->> E'distinguishedname') = ((s0.n0).properties ->> E'unknown') || (n1.properties ->> E'unknown')) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s2 as (select s0.n0 as n0, s1.n1 as n1 from s0 left outer join s1 on (s0.n0 = s1.n0)) select s2.n0 as n, s2.n1 as m from s2; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where ((n1.properties ->> E'distinguishedname') = ((s0.n0).properties ->> E'unknown') || (n1.properties ->> E'unknown')) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s2 as (select s0.n0 as n0, s1.n1 as n1 from s0 left outer join s1 on (s0.n0 is not distinct from s1.n0)) select s2.n0 as n, s2.n1 as m from s2; -- case: optional match (n:NodeKind1) return n with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s0.n0 as n from s0; -- case: match (n:NodeKind1) optional match (m:NodeKind2) where m.distinguishedname = n.unknown + m.unknown optional match (o:NodeKind2) where o.distinguishedname <> n.otherunknown return n, m, o -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where ((n1.properties ->> E'distinguishedname') = ((s0.n0).properties ->> E'unknown') || (n1.properties ->> E'unknown')) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s2 as (select s0.n0 as n0, s1.n1 as n1 from s0 left outer join s1 on (s0.n0 = s1.n0)), s3 as (select s2.n0 as n0, s2.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s2, node n2 where ((n2.properties -> E'distinguishedname') <> ((s2.n0).properties -> E'otherunknown')) and n2.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s4 as (select s2.n0 as n0, s2.n1 as n1, s3.n2 as n2 from s2 left outer join s3 on (s2.n1 = s3.n1) and (s2.n0 = s3.n0)) select s4.n0 as n, s4.n1 as m, s4.n2 as o from s4; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where ((n1.properties ->> E'distinguishedname') = ((s0.n0).properties ->> E'unknown') || (n1.properties ->> E'unknown')) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s2 as (select s0.n0 as n0, s1.n1 as n1 from s0 left outer join s1 on (s0.n0 is not distinct from s1.n0)), s3 as (select s2.n0 as n0, s2.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s2, node n2 where ((n2.properties -> E'distinguishedname') <> ((s2.n0).properties -> E'otherunknown')) and n2.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s4 as (select s2.n0 as n0, s2.n1 as n1, s3.n2 as n2 from s2 left outer join s3 on (s2.n1 is not distinct from s3.n1) and (s2.n0 is not distinct from s3.n0)) select s4.n0 as n, s4.n1 as m, s4.n2 as o from s4; -- case: match (n) where n.name = "alpha' || (SELECT inet_server_addr()::text::int) || '" return n -- pgsql_params:{"__strlit0":"string","__strlit1":"alpha' || (SELECT inet_server_addr()::text::int) || '"} From fc029e1bcae7e583cea3c7048883de8d4cf849ec Mon Sep 17 00:00:00 2001 From: AlyaEru Date: Tue, 6 Oct 2026 17:19:42 -0400 Subject: [PATCH 4/5] fix: fixed handling of leading optional match and tested --- cypher/models/pgsql/test/translation_cases/multipart.sql | 3 +++ cypher/models/pgsql/translate/match.go | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/cypher/models/pgsql/test/translation_cases/multipart.sql b/cypher/models/pgsql/test/translation_cases/multipart.sql index fc43134a..d8117b65 100644 --- a/cypher/models/pgsql/test/translation_cases/multipart.sql +++ b/cypher/models/pgsql/test/translation_cases/multipart.sql @@ -118,3 +118,6 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from -- case: match (g:NodeKind1) optional match (m:NodeKind2)-[:EdgeKind1]->(g) optional match (g)-[:EdgeKind1]->(m2:NodeKind2) return g with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0 join edge e0 on (s0.n0).id = e0.end_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.start_id where e0.kind_id = any (array [3]::int2[])), s2 as (select s0.n0 as n0, s1.e0 as e0, s1.n1 as n1 from s0 left outer join s1 on (s0.n0 is not distinct from s1.n0)), s3 as (select s2.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s2.n0 as n0, s2.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s2 join edge e1 on (s2.n0).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [3]::int2[])), s4 as (select s2.e0 as e0, s2.n0 as n0, s2.n1 as n1, s3.e1 as e1, s3.n2 as n2 from s2 left outer join s3 on (s2.n1 is not distinct from s3.n1) and (s2.n0 is not distinct from s3.n0) and (s2.e0 is not distinct from s3.e0)) select s4.n0 as g from s4; + +-- case: optional match (g:NodeKind1) optional match (m:NodeKind2)-[:EdgeKind1]->(g)-[:EdgeKind1]->(m2:NodeKind2) return g +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0 join edge e0 on (s0.n0).id = e0.end_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.start_id where e0.kind_id = any (array [3]::int2[])), s2 as (select s1.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s1.n0 as n0, s1.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s1 join edge e1 on (s1.n0).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [3]::int2[]) and e1.id != (s1.e0).id), s3 as (select s0.n0 as n0, s2.e0 as e0, s2.e1 as e1, s2.n1 as n1, s2.n2 as n2 from s0 left outer join s2 on (s0.n0 is not distinct from s2.n0)) select s3.n0 as g from s3; diff --git a/cypher/models/pgsql/translate/match.go b/cypher/models/pgsql/translate/match.go index fcc6d408..946a2eb5 100644 --- a/cypher/models/pgsql/translate/match.go +++ b/cypher/models/pgsql/translate/match.go @@ -45,7 +45,7 @@ func (s *Translator) translateMatch(match *cypher.Match) error { // If there is no valid previous frame, skip translating an `OPTIONAL MATCH`/treat as plain `MATCH` if match.Optional { - if _, hasValidPrevious := s.previousValidFrame(s.scope.CurrentFrame()); hasValidPrevious { + if joinFrame != nil { return s.translateOptionalMatch(joinFrame) } } From e5890302ab84de437ae6fe5fa3c7f67c6dd89084 Mon Sep 17 00:00:00 2001 From: AlyaEru Date: Wed, 7 Oct 2026 10:29:17 -0400 Subject: [PATCH 5/5] test: added integration tests to confirm fixes Ran this against main and confirmed both tests failed in the expected ways on main. --- .../testdata/templates/optional_shapes.json | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/integration/testdata/templates/optional_shapes.json b/integration/testdata/templates/optional_shapes.json index a0fe134a..386489e2 100644 --- a/integration/testdata/templates/optional_shapes.json +++ b/integration/testdata/templates/optional_shapes.json @@ -40,13 +40,15 @@ {"id": "unlinked", "kinds": ["TemplateNodeKind1"], "properties": {"name": "unlinked", "group": "optional-filter"}}, {"id": "target", "kinds": ["TemplateNodeKind2"], "properties": {"name": "target"}}, {"id": "wrong", "kinds": ["TemplateNodeKind2"], "properties": {"name": "wrong"}}, - {"id": "tail", "kinds": ["TemplateNodeKind1"], "properties": {"name": "tail"}} + {"id": "tail", "kinds": ["TemplateNodeKind1"], "properties": {"name": "tail"}}, + {"id": "prefiltered", "kinds": ["TemplateNodeKind2"], "properties": {"name": "prefiltered"}} ], "edges": [ {"start_id": "linked", "end_id": "target", "kind": "TemplateEdgeKind1", "properties": {"label": "accepted"}}, {"start_id": "filtered", "end_id": "wrong", "kind": "TemplateEdgeKind1", "properties": {"label": "rejected"}}, {"start_id": "target", "end_id": "tail", "kind": "TemplateEdgeKind2", "properties": {"label": "tail"}}, - {"start_id": "wrong", "end_id": "tail", "kind": "TemplateEdgeKind2", "properties": {"label": "tail"}} + {"start_id": "wrong", "end_id": "tail", "kind": "TemplateEdgeKind2", "properties": {"label": "tail"}}, + {"start_id": "prefiltered", "end_id": "filtered", "kind": "TemplateEdgeKind2", "properties": {"label": "prefiltered"}} ] }, "variants": [ @@ -93,6 +95,20 @@ "query": "match (a:TemplateNodeKind1) where a.group = 'optional-filter' optional match (a)-[:TemplateEdgeKind1]->(b:TemplateNodeKind2) where b.name = 'target' return a.name, count(b) order by a.name" }, "assert": {"ordered_row_values": [["filtered", 0], ["linked", 1], ["unlinked", 0]]} + }, + { + "name": "chained pattern optional", + "vars": { + "query": "match (a:TemplateNodeKind1) where a.group = 'optional-filter' optional match (a)-[:TemplateEdgeKind1]->(b:TemplateNodeKind2)-[:TemplateEdgeKind2]->(:TemplateNodeKind1) where b.name = 'target' return a.name, count(b) order by a.name" + }, + "assert": {"ordered_row_values": [["filtered", 0], ["linked", 1], ["unlinked", 0]]} + }, + { + "name": "second chained optional can find values when first optional did not", + "vars": { + "query": "match (a:TemplateNodeKind1) where a.group = 'optional-filter' optional match (a)-[:TemplateEdgeKind1]->(b:TemplateNodeKind2) where b.name = 'target' optional match (c)-[:TemplateEdgeKind2]->(a) return c.name, a.name, b.name order by a.name" + }, + "assert": {"ordered_row_values": [["prefiltered", "filtered", null], [null, "linked", "target"], [null, "unlinked", null]]} } ] }