diff --git a/cypher/models/pgsql/test/translation_cases/nodes.sql b/cypher/models/pgsql/test/translation_cases/nodes.sql index 38e82fc..d060377 100644 --- a/cypher/models/pgsql/test/translation_cases/nodes.sql +++ b/cypher/models/pgsql/test/translation_cases/nodes.sql @@ -285,4 +285,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 ? 'prop' and not (n0.properties ->> 'prop') = any (array ['null', '[]']::text[])))) select s0.n0 as s from s0; -- case: match (s) where [] <> s.prop return s -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((n0.properties ? 'prop' and not (n0.properties ->> 'prop') = any (array ['null', '[]']::text[])))) select s0.n0 as s from s0; \ No newline at end of file +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((n0.properties ? 'prop' and not (n0.properties ->> 'prop') = any (array ['null', '[]']::text[])))) 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 ->> 'distinguishedname') = ((s0.n0).properties -> 'unknown') + (n1.properties -> '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; + +-- 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 ->> 'distinguishedname') = ((s0.n0).properties -> 'unknown') + (n1.properties -> '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 -> 'distinguishedname') <> ((s2.n0).properties -> '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; diff --git a/cypher/models/pgsql/translate/match.go b/cypher/models/pgsql/translate/match.go index 3f50fa7..e24d40c 100644 --- a/cypher/models/pgsql/translate/match.go +++ b/cypher/models/pgsql/translate/match.go @@ -1,6 +1,13 @@ package translate -func (s *Translator) translateMatch() error { +import ( + "fmt" + + "github.com/specterops/dawgs/cypher/models/cypher" + "github.com/specterops/dawgs/cypher/models/pgsql" +) + +func (s *Translator) translateMatch(match *cypher.Match) error { currentQueryPart := s.query.CurrentPart() for _, part := range currentQueryPart.ConsumeCurrentPattern().Parts { @@ -25,5 +32,128 @@ func (s *Translator) translateMatch() error { } } - return s.buildPatternPredicates() + if err := s.buildPatternPredicates(); err != nil { + return err + } + + // If there is no previous frame, then skip translating an `OPTIONAL MATCH`/treat as plain `MATCH` + if match.Optional && s.scope.CurrentFrame().Previous != nil { + return s.translateOptionalMatch() + } + + return nil +} + +func (s *Translator) translateOptionalMatch() 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) + if err != nil { + return err + } + + // Attach the aggregation step to the current CTE chain + s.query.CurrentPart().Model.AddCTE(pgsql.CommonTableExpression{ + Alias: pgsql.TableAlias{ + Name: aggrFrame.Binding.Identifier, + }, + Query: query, + }) + + // For each identifier that is exported by our new frame, update which frame + // last materialized the identifier, so that references in future and final projections + // are corrected + for _, exported := range aggrFrame.Exported.Slice() { + if boundIdent, exists := s.scope.Lookup(exported); exists { + boundIdent.MaterializedBy(aggrFrame) + } + } + + return nil +} + +// 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) { + // 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. + optMatchFrame := aggregationFrame.Previous + originFrame := optMatchFrame.Previous + // originFrame could be nil if no previous frame is defined (for ex., leading OPTIONAL MATCH, which is + // valid but effectively a plain MATCH) + if originFrame == nil { + return pgsql.Query{}, fmt.Errorf("could not get origin frame prior to OPTIONAL MATCH") + } + + // Construct the join condition based on exports from the "origin" frame + // We expect the OPTIONAL MATCH frame to also export the same, so that becomes + // our join anchor between the two CTEs + var joinConstraints pgsql.Expression + for _, exported := range originFrame.Exported.Slice() { + joinConstraints = pgsql.OptionalAnd( + pgsql.NewParenthetical( + pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{originFrame.Binding.Identifier, exported}, + pgsql.OperatorEquals, + pgsql.CompoundIdentifier{optMatchFrame.Binding.Identifier, exported}, + ), + ), + joinConstraints, + ) + } + + // Construct the projection for this frame. Just take all of the exports for the "origin" frame + // and optional match frame and re-export them + // TODO: Does there need to be additional logic for visible/defined bindings, instead of only exports? + originIDExclusions := map[string]struct{}{} + projection := pgsql.Projection{} + for _, exported := range originFrame.Exported.Slice() { + projection = append(projection, &pgsql.AliasedExpression{ + Expression: pgsql.CompoundIdentifier{originFrame.Binding.Identifier, exported}, + Alias: pgsql.AsOptionalIdentifier(exported), + }) + originIDExclusions[exported.String()] = struct{}{} + aggregationFrame.Export(exported) + } + for _, exported := range optMatchFrame.Exported.Slice() { + // Optional match frame would shadow the origin frame's export with a filtered + // view of the origin's exports, so make sure not to shadow them + if _, ok := originIDExclusions[exported.String()]; ok { + continue + } + + projection = append(projection, &pgsql.AliasedExpression{ + Expression: pgsql.CompoundIdentifier{optMatchFrame.Binding.Identifier, exported}, + Alias: pgsql.AsOptionalIdentifier(exported), + }) + aggregationFrame.Export(exported) + } + + query := pgsql.Query{ + Body: pgsql.Select{ + // The primary source for the aggregation after an OPTIONAL MATCH should be the "origin" frame + From: []pgsql.FromClause{{ + Source: pgsql.TableReference{ + Name: pgsql.CompoundIdentifier{originFrame.Binding.Identifier}, + }, + Joins: []pgsql.Join{{ + Table: pgsql.TableReference{ + Name: pgsql.CompoundIdentifier{optMatchFrame.Binding.Identifier}, + }, + JoinOperator: pgsql.JoinOperator{ + JoinType: pgsql.JoinTypeLeftOuter, + Constraint: joinConstraints, + }, + }}, + }}, + Projection: projection, + }, + } + + return query, nil } diff --git a/cypher/models/pgsql/translate/predicate.go b/cypher/models/pgsql/translate/predicate.go index 6ed92a4..f52cba4 100644 --- a/cypher/models/pgsql/translate/predicate.go +++ b/cypher/models/pgsql/translate/predicate.go @@ -46,11 +46,13 @@ func (s *Translator) buildOptimizedRelationshipExistPredicate(part *PatternPart, Projection: []pgsql.SelectItem{ pgsql.NewLiteral(1, pgsql.Int), }, - From: []pgsql.FromClause{{ - Source: pgsql.TableReference{ - Name: pgsql.CompoundIdentifier{pgsql.TableEdge}, - Binding: models.OptionalValue(traversalStep.Edge.Identifier), - }}, + From: []pgsql.FromClause{ + { + Source: pgsql.TableReference{ + Name: pgsql.CompoundIdentifier{pgsql.TableEdge}, + Binding: models.OptionalValue(traversalStep.Edge.Identifier), + }, + }, }, Where: whereClause, }, diff --git a/cypher/models/pgsql/translate/query.go b/cypher/models/pgsql/translate/query.go index caed4c4..8f8ba1e 100644 --- a/cypher/models/pgsql/translate/query.go +++ b/cypher/models/pgsql/translate/query.go @@ -61,7 +61,7 @@ func (s *Translator) buildMultiPartQuery(singlePartQuery *cypher.SinglePartQuery part.Model.CommonTableExpressions = nil } - // Autor the part as a nested CTE + // Author the part as a nested CTE nextCTE := pgsql.CommonTableExpression{ Query: *part.Model, } diff --git a/cypher/models/pgsql/translate/translator.go b/cypher/models/pgsql/translate/translator.go index 21188a7..3b528ca 100644 --- a/cypher/models/pgsql/translate/translator.go +++ b/cypher/models/pgsql/translate/translator.go @@ -410,7 +410,7 @@ func (s *Translator) Exit(expression cypher.SyntaxNode) { } case *cypher.Match: - if err := s.translateMatch(); err != nil { + if err := s.translateMatch(typedExpression); err != nil { s.SetError(err) }