From 88e8089819629a7d9d00dd37f889a4300cec67fa Mon Sep 17 00:00:00 2001 From: John Hopper Date: Tue, 19 May 2026 11:30:31 -0700 Subject: [PATCH 001/116] fix(pgsql): support aggregate operator projections --- .../translation_cases/scalar_aggregation.sql | 6 ++++ .../testdata/cases/aggregation_inline.json | 30 +++++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/cypher/models/pgsql/test/translation_cases/scalar_aggregation.sql b/cypher/models/pgsql/test/translation_cases/scalar_aggregation.sql index 755a4eb0..36163e76 100644 --- a/cypher/models/pgsql/test/translation_cases/scalar_aggregation.sql +++ b/cypher/models/pgsql/test/translation_cases/scalar_aggregation.sql @@ -92,9 +92,15 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from -- case: MATCH (n) RETURN count(n) AS total ORDER BY total DESC with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select count(s0.n0)::int8 as total from s0 order by total desc; +-- case: MATCH (n) RETURN toint(n.value) + count(n) +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select (((s0.n0).properties ->> 'value'))::int8 + count(s0.n0)::int8 from s0 group by (((s0.n0).properties ->> 'value'))::int8; + -- case: MATCH (n) WITH toInteger(n.value) AS value, count(n) AS node_count RETURN value + node_count with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select (((s1.n0).properties ->> 'value'))::int8 as i0, count(s1.n0)::int8 as i1 from s1 group by (((s1.n0).properties ->> 'value'))::int8) select s0.i0 + s0.i1 from s0; +-- case: MATCH (n) WITH toint(n.value) + count(n) AS score RETURN score +with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select (((s1.n0).properties ->> 'value'))::int8 + count(s1.n0)::int8 as i0 from s1 group by (((s1.n0).properties ->> 'value'))::int8) select s0.i0 as score from s0; + -- case: MATCH (n) WITH toInteger(n.value) AS value, count(n) AS node_count WITH value + node_count AS score RETURN score with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select (((s1.n0).properties ->> 'value'))::int8 as i0, count(s1.n0)::int8 as i1 from s1 group by (((s1.n0).properties ->> 'value'))::int8), s2 as (select s0.i0 + s0.i1 as i2 from s0) select s2.i2 as score from s2; diff --git a/integration/testdata/cases/aggregation_inline.json b/integration/testdata/cases/aggregation_inline.json index e78589b8..c76bc073 100644 --- a/integration/testdata/cases/aggregation_inline.json +++ b/integration/testdata/cases/aggregation_inline.json @@ -202,7 +202,20 @@ } }, { - "name": "add grouped property expression to aggregate count", + "name": "add grouped property expression to aggregate count inline", + "cypher": "MATCH (n) RETURN toint(n.value) + count(n)", + "fixture": { + "nodes": [ + {"id": "a", "kinds": ["NodeKind1"], "properties": {"value": 10}}, + {"id": "b", "kinds": ["NodeKind1"], "properties": {"value": 10}}, + {"id": "c", "kinds": ["NodeKind1"], "properties": {"value": 20}} + ], + "edges": [] + }, + "assert": {"scalar_values": [12, 21]} + }, + { + "name": "add grouped property expression to aggregate count through aliases", "cypher": "MATCH (n) WITH toInteger(n.value) AS value, count(n) AS node_count RETURN value + node_count", "fixture": { "nodes": [ @@ -215,7 +228,20 @@ "assert": {"scalar_values": [12, 21]} }, { - "name": "project grouped property plus aggregate through with", + "name": "project grouped property plus aggregate through with inline expression", + "cypher": "MATCH (n) WITH toint(n.value) + count(n) AS score RETURN score", + "fixture": { + "nodes": [ + {"id": "a", "kinds": ["NodeKind1"], "properties": {"value": 10}}, + {"id": "b", "kinds": ["NodeKind1"], "properties": {"value": 10}}, + {"id": "c", "kinds": ["NodeKind1"], "properties": {"value": 20}} + ], + "edges": [] + }, + "assert": {"scalar_values": [12, 21]} + }, + { + "name": "project grouped property plus aggregate through with aliases", "cypher": "MATCH (n) WITH toInteger(n.value) AS value, count(n) AS node_count WITH value + node_count AS score RETURN score", "fixture": { "nodes": [ From c66e422287a29fb24670b12b6baa848c2122674e Mon Sep 17 00:00:00 2001 From: John Hopper Date: Wed, 20 May 2026 08:10:47 -0700 Subject: [PATCH 002/116] fix(cypher): prefer supported integer conversion --- .../pgsql/test/translation_cases/scalar_aggregation.sql | 4 ++-- integration/testdata/cases/aggregation_inline.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cypher/models/pgsql/test/translation_cases/scalar_aggregation.sql b/cypher/models/pgsql/test/translation_cases/scalar_aggregation.sql index 36163e76..d4668bff 100644 --- a/cypher/models/pgsql/test/translation_cases/scalar_aggregation.sql +++ b/cypher/models/pgsql/test/translation_cases/scalar_aggregation.sql @@ -92,13 +92,13 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from -- case: MATCH (n) RETURN count(n) AS total ORDER BY total DESC with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select count(s0.n0)::int8 as total from s0 order by total desc; --- case: MATCH (n) RETURN toint(n.value) + count(n) +-- case: MATCH (n) RETURN toInteger(n.value) + count(n) with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select (((s0.n0).properties ->> 'value'))::int8 + count(s0.n0)::int8 from s0 group by (((s0.n0).properties ->> 'value'))::int8; -- case: MATCH (n) WITH toInteger(n.value) AS value, count(n) AS node_count RETURN value + node_count with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select (((s1.n0).properties ->> 'value'))::int8 as i0, count(s1.n0)::int8 as i1 from s1 group by (((s1.n0).properties ->> 'value'))::int8) select s0.i0 + s0.i1 from s0; --- case: MATCH (n) WITH toint(n.value) + count(n) AS score RETURN score +-- case: MATCH (n) WITH toInteger(n.value) + count(n) AS score RETURN score with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select (((s1.n0).properties ->> 'value'))::int8 + count(s1.n0)::int8 as i0 from s1 group by (((s1.n0).properties ->> 'value'))::int8) select s0.i0 as score from s0; -- case: MATCH (n) WITH toInteger(n.value) AS value, count(n) AS node_count WITH value + node_count AS score RETURN score diff --git a/integration/testdata/cases/aggregation_inline.json b/integration/testdata/cases/aggregation_inline.json index c76bc073..a624f197 100644 --- a/integration/testdata/cases/aggregation_inline.json +++ b/integration/testdata/cases/aggregation_inline.json @@ -203,7 +203,7 @@ }, { "name": "add grouped property expression to aggregate count inline", - "cypher": "MATCH (n) RETURN toint(n.value) + count(n)", + "cypher": "MATCH (n) RETURN toInteger(n.value) + count(n)", "fixture": { "nodes": [ {"id": "a", "kinds": ["NodeKind1"], "properties": {"value": 10}}, @@ -229,7 +229,7 @@ }, { "name": "project grouped property plus aggregate through with inline expression", - "cypher": "MATCH (n) WITH toint(n.value) + count(n) AS score RETURN score", + "cypher": "MATCH (n) WITH toInteger(n.value) + count(n) AS score RETURN score", "fixture": { "nodes": [ {"id": "a", "kinds": ["NodeKind1"], "properties": {"value": 10}}, From cc8035f044d48f679e8ba251d9d5447c74a5d907 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Wed, 20 May 2026 12:03:26 -0700 Subject: [PATCH 003/116] docs(pgsql): capture optimizer pass plan --- docs/optimization-pass-memory.md | 160 +++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 docs/optimization-pass-memory.md diff --git a/docs/optimization-pass-memory.md b/docs/optimization-pass-memory.md new file mode 100644 index 00000000..d53016d0 --- /dev/null +++ b/docs/optimization-pass-memory.md @@ -0,0 +1,160 @@ +# Cypher Optimization Pass Memory + +## Purpose + +The PostgreSQL translator currently lowers Cypher traversal parts mostly in source order. That is simple and predictable, but it can produce expensive SQL for multi-part path queries where a later pattern contains more selective anchors or where returned path payloads are carried through unrelated expansions. + +This note captures a conservative plan for introducing a PostgreSQL-specific pre-translation optimization phase. The goal is not to require users to reauthor valid Cypher to get acceptable runtime behavior. + +## Motivating Query Shape + +```cypher +MATCH (n:Group) +WHERE n.objectid = 'S-1-5-21-2643190041-1319121918-239771340-513' +MATCH p1 = (n)-[:MemberOf*0..]->()-[:Enroll]->(ca:EnterpriseCA)-[:TrustedForNTAuth]->(:NTAuthStore)-[:NTAuthStoreFor]->(d:Domain) +MATCH p2 = (n)-[:MemberOf*0..]->()-[:GenericAll|Enroll|AllExtendedRights]->(ct:CertTemplate)-[:PublishedTo]->(ca)-[:IssuedSignedBy|EnterpriseCAFor*1..]->(:RootCA)-[:RootCAFor]->(d) +WHERE ct.authenticationenabled = true +AND ct.requiresmanagerapproval = false +AND ct.enrolleesuppliessubject = true +AND (ct.schemaversion = 1 OR ct.authorizedsignatures = 0) +RETURN p1, p2 +``` + +The current PostgreSQL shape can preserve too much intermediate state. In particular, because `p1` is returned, path-related state from `p1` may be carried through the `p2` expansion before `p2` has been filtered. Neo4j's planner is more flexible: it can reorder pattern evaluation, use endpoint-aware expansion, and materialize path values late. + +## Architectural Decisions + +The first optimizer effort is intentionally PostgreSQL-specific. The optimizer should avoid painting the project into a backend-neutral corner, but PostgreSQL is the only Cypher translation target that currently needs this work. + +- Ship optimizer rules directly once they are covered. Do not add a user-facing feature flag surface for optimizer behavior. +- Optimize only read-only `MATCH` and `WHERE` groups inside a single query part for the first milestone. +- Treat `WITH`, `RETURN`, aggregation, `DISTINCT`, `ORDER BY`, `LIMIT`, `UNWIND`, `OPTIONAL MATCH`, writes, and procedure calls as semantic barriers. +- Allow the optimizer to build a new ordered logical plan inside eligible regions. +- Represent path variables as late-materialized recipes throughout the optimized PostgreSQL logical model. +- Use deterministic heuristics for early reordering. Defer schema statistics and cost-based planning. +- Accept more complex SQL when it materially improves runtime conditions. The database is responsible for executing the improved plan shape. +- Defer broad benchmark and real-world query set definition until after the basic framework and first optimizer rules are in place. + +## Safety Constraints + +Keep the first implementation deliberately conservative. + +- Preserve Cypher row semantics, path relationship uniqueness, variable binding rules, and zero-length expansion behavior. +- Keep each optimization rule individually named and testable. +- If a rule cannot prove a rewrite is safe, keep the original logical order for that part of the plan. +- Require translation-shape tests, PostgreSQL integration equivalence, and Neo4j equivalence coverage for optimizer behavior. + +## Sequenced Plan + +### Phase 1: Define Optimizer Boundaries + +Document the Cypher regions eligible for optimization and the barriers that terminate an optimization region. The initial eligible region should be a read-only sequence of `MATCH` and `WHERE` clauses within one query part. + +Add diagnostics that can print the logical pattern parts, bindings, predicates, path variables, and final projection dependencies before translation. + +### Phase 2: Build The Safety Net + +Add translation-shape coverage for the motivating ADCS query. The first tests should capture the current expensive SQL shape so improvements can be measured. + +Add smaller focused cases for: + +- multiple `MATCH` clauses sharing variables +- returned path variables used only at final projection +- variable-length expansion followed by a fixed suffix +- repeated bound variables such as `(ca)` and `(d)` +- zero-length expansion with `*0..` + +Validate optimizer behavior with all three test classes: + +- translation-shape tests +- PostgreSQL integration equivalence tests +- Neo4j integration equivalence tests + +Neo4j tests should assert result shape and semantics, not exact Neo4j plan shape. + +### Phase 3: Introduce A No-Op Optimizer Skeleton + +Insert a PostgreSQL-specific pre-translation logical optimization pass between parsing/semantic modeling and PostgreSQL rendering. + +The initial pass should return the same logical model it receives. This keeps the integration point small and gives tests a stable hook before behavioral rules are added. + +Suggested rule names: + +- `PredicateAttachment` +- `ProjectionPruning` +- `LatePathMaterialization` +- `ExpandIntoDetection` +- `ConservativePatternReordering` +- `VariableExpansionTerminalPushdown` + +### Phase 4: Attach Predicates To Their Bindings + +Move eligible `WHERE` predicates as close as possible to the bindings they reference. + +For the motivating query, the `ct.*` predicates should be owned by the `ct:CertTemplate` binding. This does not need to reorder pattern matching at first; it makes predicate dependencies explicit so later rules can apply filters earlier. + +### Phase 5: Prune Intermediate Projections And Paths + +Compute a narrower carry set for each operation: + +- bindings needed by the next operation +- bindings needed by predicates +- bindings needed as join keys +- bindings needed later only to construct returned values + +The translator should not carry every visible binding through every later expansion just because it appears in the final `RETURN`. + +This should be the first real runtime-focused optimization rule. It directly addresses the reported query shape, creates the liveness information required by later rules, and is lower risk than traversal reordering or suffix pushdown. + +### Phase 6: Materialize Paths Late + +Represent returned paths internally as recipes over node and relationship bindings rather than as fully materialized values throughout every step. + +For the motivating query, the optimizer should be able to continue from a narrow frame after `p1`, such as distinct `(n, ca, d)`, evaluate and filter `p2`, then join back to the full `p1` rows and materialize `p1` and `p2` at the final projection. + +This is the first high-value optimization target because it reduces row width and delays the `p1 x p2` multiplication without changing the user's Cypher. + +### Phase 7: Detect Expand-Into Opportunities + +When both endpoints of a relationship or variable-length segment are known, lower that segment as a constrained connectivity/path problem instead of an open expansion. + +This mirrors Neo4j's `Expand(Into)` and `VarLengthExpand(Into)` behavior and is especially relevant when separate `MATCH` clauses bind endpoints that are reused later. + +### Phase 8: Add Deterministic Pattern Reordering + +After projection pruning and late materialization are stable, allow limited reordering inside a single read-only optimization region. + +Start with obvious anchors: + +- node label plus equality property +- fixed relationship type scans +- already-bound endpoints +- selective labels or properties only when deterministic local information is available + +Do not begin with a general cost-based planner or schema-statistics dependency. Prefer deterministic rewrites with clear safety checks. + +### Phase 9: Push Terminal Constraints Into Variable Expansions + +For variable-length expansions followed by fixed suffixes, add terminal or suffix constraints as semi-joins or correlated existence checks. + +For the motivating query, this means avoiding emission of `MemberOf*0..` endpoints that cannot reach an eligible `CertTemplate` published to the already-bound `ca`, and avoiding `RootCA` endpoints that cannot connect back to the already-bound `d`. + +### Phase 10: Measure Each Rule Locally + +Every optimization rule should include: + +- unit or translation tests for the logical rewrite +- PostgreSQL integration result-equivalence coverage +- Neo4j integration result-equivalence coverage +- SQL shape assertions for representative queries +- before and after `EXPLAIN` comparison on synthetic fanout data + +The synthetic data should include many `p1` paths to the same `(n, ca, d)`, many membership paths from `n`, and only a small number of eligible certificate template and root CA paths. + +Broader benchmark suites and real-world query collections are deferred until after the basic optimizer framework and first rules are implemented. + +## Recommended First Milestone + +Implement phases 1 through 6 first. + +That milestone establishes the PostgreSQL optimizer framework, test bar, predicate ownership, projection and path pruning, and late path materialization. It should improve the reported query shape without taking on endpoint-aware expansion, suffix semi-joins, schema statistics, or a full cost-based planner. From 6d740595f91429760453506838dbbab6db199104 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Wed, 20 May 2026 12:06:10 -0700 Subject: [PATCH 004/116] feat(pgsql): analyze optimizer regions --- cypher/models/pgsql/optimize/analysis.go | 588 ++++++++++++++++++ cypher/models/pgsql/optimize/analysis_test.go | 136 ++++ 2 files changed, 724 insertions(+) create mode 100644 cypher/models/pgsql/optimize/analysis.go create mode 100644 cypher/models/pgsql/optimize/analysis_test.go diff --git a/cypher/models/pgsql/optimize/analysis.go b/cypher/models/pgsql/optimize/analysis.go new file mode 100644 index 00000000..887e37a0 --- /dev/null +++ b/cypher/models/pgsql/optimize/analysis.go @@ -0,0 +1,588 @@ +package optimize + +import ( + "fmt" + "sort" + "strings" + + "github.com/specterops/dawgs/cypher/models/cypher" + "github.com/specterops/dawgs/cypher/models/walk" +) + +type QueryPartKind string + +const ( + QueryPartKindSingle QueryPartKind = "single" + QueryPartKindMulti QueryPartKind = "multi" +) + +type BarrierKind string + +const ( + BarrierKindReturn BarrierKind = "return" + BarrierKindWith BarrierKind = "with" + BarrierKindUnwind BarrierKind = "unwind" + BarrierKindOptionalMatch BarrierKind = "optional_match" + BarrierKindUpdate BarrierKind = "update" +) + +type BindingKind string + +const ( + BindingKindNode BindingKind = "node" + BindingKindRelationship BindingKind = "relationship" + BindingKindPath BindingKind = "path" +) + +type Analysis struct { + QueryParts []QueryPart +} + +type QueryPart struct { + Index int + Kind QueryPartKind + Regions []Region + Barriers []Barrier + ProjectionDependencies []string +} + +type Region struct { + QueryPartIndex int + StartClause int + EndClause int + Clauses []MatchClause + Bindings []Binding + PathVariables []PathVariable + Predicates []Predicate +} + +type MatchClause struct { + Index int + PatternCount int + WherePredicate int +} + +type Barrier struct { + QueryPartIndex int + ClauseIndex int + Kind BarrierKind + Dependencies []string +} + +type Binding struct { + Symbol string + Kind BindingKind + ClauseIndex int + PatternIndex int +} + +type PathVariable struct { + Symbol string + ClauseIndex int + PatternIndex int + NodeCount int + RelationshipCount int + VariableLength bool + Dependencies []string +} + +type Predicate struct { + ClauseIndex int + ExpressionIndex int + Dependencies []string +} + +func Analyze(query *cypher.RegularQuery) Analysis { + if query == nil || query.SingleQuery == nil { + return Analysis{} + } + + if query.SingleQuery.MultiPartQuery != nil { + return analyzeMultiPartQuery(query.SingleQuery.MultiPartQuery) + } + + if query.SingleQuery.SinglePartQuery != nil { + return Analysis{ + QueryParts: []QueryPart{ + analyzeSinglePartQuery(0, QueryPartKindSingle, query.SingleQuery.SinglePartQuery), + }, + } + } + + return Analysis{} +} + +func (s Analysis) Diagnostics() []string { + var lines []string + + for _, queryPart := range s.QueryParts { + lines = append(lines, fmt.Sprintf( + "query_part[%d] kind=%s projection_deps=%s", + queryPart.Index, + queryPart.Kind, + strings.Join(queryPart.ProjectionDependencies, ","), + )) + + for regionIndex, region := range queryPart.Regions { + lines = append(lines, fmt.Sprintf( + "region[%d] part=%d clauses=%d..%d matches=%d bindings=%s paths=%s predicates=%s", + regionIndex, + region.QueryPartIndex, + region.StartClause, + region.EndClause, + len(region.Clauses), + formatBindings(region.Bindings), + formatPathVariables(region.PathVariables), + formatPredicates(region.Predicates), + )) + } + + for barrierIndex, barrier := range queryPart.Barriers { + lines = append(lines, fmt.Sprintf( + "barrier[%d] part=%d clause=%d kind=%s deps=%s", + barrierIndex, + barrier.QueryPartIndex, + barrier.ClauseIndex, + barrier.Kind, + strings.Join(barrier.Dependencies, ","), + )) + } + } + + return lines +} + +func (s Analysis) String() string { + return strings.Join(s.Diagnostics(), "\n") +} + +func analyzeMultiPartQuery(query *cypher.MultiPartQuery) Analysis { + var analysis Analysis + + for idx, part := range query.Parts { + analysis.QueryParts = append(analysis.QueryParts, analyzeMultiPartQueryPart(idx, part)) + } + + if query.SinglePartQuery != nil { + analysis.QueryParts = append(analysis.QueryParts, analyzeSinglePartQuery(len(query.Parts), QueryPartKindSingle, query.SinglePartQuery)) + } + + return analysis +} + +func analyzeMultiPartQueryPart(index int, part *cypher.MultiPartQueryPart) QueryPart { + queryPart := QueryPart{ + Index: index, + Kind: QueryPartKindMulti, + } + + queryPart.Regions, queryPart.Barriers = analyzeReadingClauses(index, part.ReadingClauses) + + if len(part.UpdatingClauses) > 0 { + queryPart.Barriers = append(queryPart.Barriers, Barrier{ + QueryPartIndex: index, + ClauseIndex: len(part.ReadingClauses), + Kind: BarrierKindUpdate, + }) + } + + if part.With != nil { + queryPart.ProjectionDependencies = projectionDependencies(part.With.Projection) + queryPart.Barriers = append(queryPart.Barriers, Barrier{ + QueryPartIndex: index, + ClauseIndex: len(part.ReadingClauses) + len(part.UpdatingClauses), + Kind: BarrierKindWith, + Dependencies: queryPart.ProjectionDependencies, + }) + } + + return queryPart +} + +func analyzeSinglePartQuery(index int, kind QueryPartKind, part *cypher.SinglePartQuery) QueryPart { + queryPart := QueryPart{ + Index: index, + Kind: kind, + } + + queryPart.Regions, queryPart.Barriers = analyzeReadingClauses(index, part.ReadingClauses) + + if len(part.UpdatingClauses) > 0 { + queryPart.Barriers = append(queryPart.Barriers, Barrier{ + QueryPartIndex: index, + ClauseIndex: len(part.ReadingClauses), + Kind: BarrierKindUpdate, + }) + } + + if part.Return != nil { + queryPart.ProjectionDependencies = projectionDependencies(part.Return.Projection) + queryPart.Barriers = append(queryPart.Barriers, Barrier{ + QueryPartIndex: index, + ClauseIndex: len(part.ReadingClauses) + len(part.UpdatingClauses), + Kind: BarrierKindReturn, + Dependencies: queryPart.ProjectionDependencies, + }) + } + + return queryPart +} + +func analyzeReadingClauses(queryPartIndex int, readingClauses []*cypher.ReadingClause) ([]Region, []Barrier) { + var ( + regions []Region + barriers []Barrier + currentRegion *Region + ) + + closeRegion := func() { + if currentRegion != nil { + regions = append(regions, *currentRegion) + currentRegion = nil + } + } + + for clauseIndex, readingClause := range readingClauses { + if readingClause == nil || readingClause.Unwind != nil { + closeRegion() + barriers = append(barriers, Barrier{ + QueryPartIndex: queryPartIndex, + ClauseIndex: clauseIndex, + Kind: BarrierKindUnwind, + Dependencies: dependenciesForReadingClause(readingClause), + }) + continue + } + + match := readingClause.Match + if match == nil { + continue + } + + if match.Optional { + closeRegion() + barriers = append(barriers, Barrier{ + QueryPartIndex: queryPartIndex, + ClauseIndex: clauseIndex, + Kind: BarrierKindOptionalMatch, + Dependencies: dependenciesForMatch(match), + }) + continue + } + + if currentRegion == nil { + currentRegion = &Region{ + QueryPartIndex: queryPartIndex, + StartClause: clauseIndex, + EndClause: clauseIndex, + } + } + + currentRegion.EndClause = clauseIndex + currentRegion.Clauses = append(currentRegion.Clauses, MatchClause{ + Index: clauseIndex, + PatternCount: len(match.Pattern), + WherePredicate: wherePredicateCount(match.Where), + }) + currentRegion.Bindings = mergeBindings(currentRegion.Bindings, bindingsForMatch(clauseIndex, match)) + currentRegion.PathVariables = mergePathVariables(currentRegion.PathVariables, pathVariablesForMatch(clauseIndex, match)) + currentRegion.Predicates = append(currentRegion.Predicates, predicatesForWhere(clauseIndex, match.Where)...) + } + + closeRegion() + + return regions, barriers +} + +func dependenciesForReadingClause(readingClause *cypher.ReadingClause) []string { + if readingClause == nil { + return nil + } + + if readingClause.Match != nil { + return dependenciesForMatch(readingClause.Match) + } + + if readingClause.Unwind != nil { + return sortedDependencies(readingClause.Unwind.Expression) + } + + return nil +} + +func dependenciesForMatch(match *cypher.Match) []string { + var deps []string + + if match == nil { + return nil + } + + for _, predicate := range predicatesForWhere(0, match.Where) { + deps = append(deps, predicate.Dependencies...) + } + + return sortedUniqueStrings(deps) +} + +func bindingsForMatch(clauseIndex int, match *cypher.Match) []Binding { + var bindings []Binding + + for patternIndex, pattern := range match.Pattern { + if pattern == nil { + continue + } + + if pattern.Variable != nil && pattern.Variable.Symbol != "" { + bindings = append(bindings, Binding{ + Symbol: pattern.Variable.Symbol, + Kind: BindingKindPath, + ClauseIndex: clauseIndex, + PatternIndex: patternIndex, + }) + } + + for _, element := range pattern.PatternElements { + if nodePattern, isNodePattern := element.AsNodePattern(); isNodePattern { + if nodePattern.Variable != nil && nodePattern.Variable.Symbol != "" { + bindings = append(bindings, Binding{ + Symbol: nodePattern.Variable.Symbol, + Kind: BindingKindNode, + ClauseIndex: clauseIndex, + PatternIndex: patternIndex, + }) + } + } else if relationshipPattern, isRelationshipPattern := element.AsRelationshipPattern(); isRelationshipPattern { + if relationshipPattern.Variable != nil && relationshipPattern.Variable.Symbol != "" { + bindings = append(bindings, Binding{ + Symbol: relationshipPattern.Variable.Symbol, + Kind: BindingKindRelationship, + ClauseIndex: clauseIndex, + PatternIndex: patternIndex, + }) + } + } + } + } + + return bindings +} + +func pathVariablesForMatch(clauseIndex int, match *cypher.Match) []PathVariable { + var pathVariables []PathVariable + + for patternIndex, pattern := range match.Pattern { + if pattern == nil || pattern.Variable == nil || pattern.Variable.Symbol == "" { + continue + } + + pathVariable := PathVariable{ + Symbol: pattern.Variable.Symbol, + ClauseIndex: clauseIndex, + PatternIndex: patternIndex, + Dependencies: patternDependencies(pattern), + } + + for _, element := range pattern.PatternElements { + if element.IsNodePattern() { + pathVariable.NodeCount++ + } else if relationshipPattern, isRelationshipPattern := element.AsRelationshipPattern(); isRelationshipPattern { + pathVariable.RelationshipCount++ + + if relationshipPattern.Range != nil { + pathVariable.VariableLength = true + } + } + } + + pathVariables = append(pathVariables, pathVariable) + } + + return pathVariables +} + +func patternDependencies(pattern *cypher.PatternPart) []string { + var dependencies []string + + for _, element := range pattern.PatternElements { + if nodePattern, isNodePattern := element.AsNodePattern(); isNodePattern { + if nodePattern.Variable != nil && nodePattern.Variable.Symbol != "" { + dependencies = append(dependencies, nodePattern.Variable.Symbol) + } + } else if relationshipPattern, isRelationshipPattern := element.AsRelationshipPattern(); isRelationshipPattern { + if relationshipPattern.Variable != nil && relationshipPattern.Variable.Symbol != "" { + dependencies = append(dependencies, relationshipPattern.Variable.Symbol) + } + } + } + + return sortedUniqueStrings(dependencies) +} + +func predicatesForWhere(clauseIndex int, where *cypher.Where) []Predicate { + if where == nil { + return nil + } + + var predicates []Predicate + for expressionIndex, expression := range where.GetAll() { + predicates = append(predicates, Predicate{ + ClauseIndex: clauseIndex, + ExpressionIndex: expressionIndex, + Dependencies: sortedDependencies(expression), + }) + } + + return predicates +} + +func projectionDependencies(projection *cypher.Projection) []string { + if projection == nil { + return nil + } + + var dependencies []string + for _, item := range projection.Items { + dependencies = append(dependencies, sortedDependencies(item)...) + } + + if projection.Order != nil { + dependencies = append(dependencies, sortedDependencies(projection.Order)...) + } + + if projection.Skip != nil { + dependencies = append(dependencies, sortedDependencies(projection.Skip)...) + } + + if projection.Limit != nil { + dependencies = append(dependencies, sortedDependencies(projection.Limit)...) + } + + return sortedUniqueStrings(dependencies) +} + +func sortedDependencies(node cypher.SyntaxNode) []string { + dependencies := map[string]struct{}{} + + if node == nil { + return nil + } + + _ = walk.Cypher(node, walk.NewSimpleVisitor[cypher.SyntaxNode](func(node cypher.SyntaxNode, _ walk.VisitorHandler) { + if variable, isVariable := node.(*cypher.Variable); isVariable && variable.Symbol != "" && variable.Symbol != cypher.TokenLiteralAsterisk { + dependencies[variable.Symbol] = struct{}{} + } + })) + + return sortedMapKeys(dependencies) +} + +func wherePredicateCount(where *cypher.Where) int { + if where == nil { + return 0 + } + + return where.Len() +} + +func mergeBindings(existing []Binding, next []Binding) []Binding { + seen := map[string]struct{}{} + for _, binding := range existing { + seen[bindingKey(binding)] = struct{}{} + } + + for _, binding := range next { + key := bindingKey(binding) + if _, hasBinding := seen[key]; hasBinding { + continue + } + + existing = append(existing, binding) + seen[key] = struct{}{} + } + + return existing +} + +func mergePathVariables(existing []PathVariable, next []PathVariable) []PathVariable { + seen := map[string]struct{}{} + for _, pathVariable := range existing { + seen[pathVariable.Symbol] = struct{}{} + } + + for _, pathVariable := range next { + if _, hasPathVariable := seen[pathVariable.Symbol]; hasPathVariable { + continue + } + + existing = append(existing, pathVariable) + seen[pathVariable.Symbol] = struct{}{} + } + + return existing +} + +func bindingKey(binding Binding) string { + return string(binding.Kind) + ":" + binding.Symbol +} + +func sortedMapKeys(values map[string]struct{}) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + + sort.Strings(keys) + + return keys +} + +func sortedUniqueStrings(values []string) []string { + seen := map[string]struct{}{} + + for _, value := range values { + if value != "" { + seen[value] = struct{}{} + } + } + + return sortedMapKeys(seen) +} + +func formatBindings(bindings []Binding) string { + if len(bindings) == 0 { + return "" + } + + items := make([]string, 0, len(bindings)) + for _, binding := range bindings { + items = append(items, fmt.Sprintf("%s:%s", binding.Symbol, binding.Kind)) + } + + return strings.Join(items, ",") +} + +func formatPathVariables(pathVariables []PathVariable) string { + if len(pathVariables) == 0 { + return "" + } + + items := make([]string, 0, len(pathVariables)) + for _, pathVariable := range pathVariables { + items = append(items, pathVariable.Symbol) + } + + return strings.Join(items, ",") +} + +func formatPredicates(predicates []Predicate) string { + if len(predicates) == 0 { + return "" + } + + items := make([]string, 0, len(predicates)) + for _, predicate := range predicates { + items = append(items, strings.Join(predicate.Dependencies, "|")) + } + + return strings.Join(items, ",") +} diff --git a/cypher/models/pgsql/optimize/analysis_test.go b/cypher/models/pgsql/optimize/analysis_test.go new file mode 100644 index 00000000..eb41ea0f --- /dev/null +++ b/cypher/models/pgsql/optimize/analysis_test.go @@ -0,0 +1,136 @@ +package optimize + +import ( + "strings" + "testing" + + "github.com/specterops/dawgs/cypher/frontend" + "github.com/stretchr/testify/require" +) + +const adcsQuery = ` +MATCH (n:Group) +WHERE n.objectid = 'S-1-5-21-2643190041-1319121918-239771340-513' +MATCH p1 = (n)-[:MemberOf*0..]->()-[:Enroll]->(ca:EnterpriseCA)-[:TrustedForNTAuth]->(:NTAuthStore)-[:NTAuthStoreFor]->(d:Domain) +MATCH p2 = (n)-[:MemberOf*0..]->()-[:GenericAll|Enroll|AllExtendedRights]->(ct:CertTemplate)-[:PublishedTo]->(ca)-[:IssuedSignedBy|EnterpriseCAFor*1..]->(:RootCA)-[:RootCAFor]->(d) +WHERE ct.authenticationenabled = true +AND ct.requiresmanagerapproval = false +AND ct.enrolleesuppliessubject = true +AND (ct.schemaversion = 1 OR ct.authorizedsignatures = 0) +RETURN p1, p2 +` + +func analyzeCypher(t *testing.T, query string) Analysis { + t.Helper() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), query) + require.NoError(t, err) + + return Analyze(regularQuery) +} + +func requireBinding(t *testing.T, bindings []Binding, symbol string, kind BindingKind) { + t.Helper() + + for _, binding := range bindings { + if binding.Symbol == symbol && binding.Kind == kind { + return + } + } + + t.Fatalf("expected binding %s:%s in %#v", symbol, kind, bindings) +} + +func requirePathVariable(t *testing.T, pathVariables []PathVariable, symbol string, relationshipCount int) { + t.Helper() + + for _, pathVariable := range pathVariables { + if pathVariable.Symbol == symbol { + require.Equal(t, relationshipCount, pathVariable.RelationshipCount) + require.True(t, pathVariable.VariableLength) + return + } + } + + t.Fatalf("expected path variable %s in %#v", symbol, pathVariables) +} + +func TestAnalyzeIdentifiesEligibleADCSRegion(t *testing.T) { + t.Parallel() + + analysis := analyzeCypher(t, adcsQuery) + + require.Len(t, analysis.QueryParts, 1) + + queryPart := analysis.QueryParts[0] + require.Equal(t, QueryPartKindSingle, queryPart.Kind) + require.Equal(t, []string{"p1", "p2"}, queryPart.ProjectionDependencies) + require.Len(t, queryPart.Regions, 1) + require.Len(t, queryPart.Barriers, 1) + require.Equal(t, BarrierKindReturn, queryPart.Barriers[0].Kind) + require.Equal(t, []string{"p1", "p2"}, queryPart.Barriers[0].Dependencies) + + region := queryPart.Regions[0] + require.Equal(t, 0, region.StartClause) + require.Equal(t, 2, region.EndClause) + require.Len(t, region.Clauses, 3) + require.Len(t, region.Predicates, 2) + require.Equal(t, []string{"n"}, region.Predicates[0].Dependencies) + require.Equal(t, []string{"ct"}, region.Predicates[1].Dependencies) + + requireBinding(t, region.Bindings, "n", BindingKindNode) + requireBinding(t, region.Bindings, "ca", BindingKindNode) + requireBinding(t, region.Bindings, "ct", BindingKindNode) + requireBinding(t, region.Bindings, "d", BindingKindNode) + requireBinding(t, region.Bindings, "p1", BindingKindPath) + requireBinding(t, region.Bindings, "p2", BindingKindPath) + + requirePathVariable(t, region.PathVariables, "p1", 4) + requirePathVariable(t, region.PathVariables, "p2", 5) +} + +func TestAnalyzeSegmentsRegionsAtSemanticBarriers(t *testing.T) { + t.Parallel() + + analysis := analyzeCypher(t, ` + MATCH (n:Group) + WITH n + MATCH (n)-[:MemberOf]->(m) + OPTIONAL MATCH (m)-[:MemberOf]->(x) + RETURN m + `) + + require.Len(t, analysis.QueryParts, 2) + + firstPart := analysis.QueryParts[0] + require.Equal(t, QueryPartKindMulti, firstPart.Kind) + require.Len(t, firstPart.Regions, 1) + require.Equal(t, []string{"n"}, firstPart.ProjectionDependencies) + require.Len(t, firstPart.Barriers, 1) + require.Equal(t, BarrierKindWith, firstPart.Barriers[0].Kind) + require.Equal(t, []string{"n"}, firstPart.Barriers[0].Dependencies) + + secondPart := analysis.QueryParts[1] + require.Equal(t, QueryPartKindSingle, secondPart.Kind) + require.Len(t, secondPart.Regions, 1) + require.Equal(t, 0, secondPart.Regions[0].StartClause) + require.Equal(t, 0, secondPart.Regions[0].EndClause) + require.Len(t, secondPart.Barriers, 2) + require.Equal(t, BarrierKindOptionalMatch, secondPart.Barriers[0].Kind) + require.Equal(t, BarrierKindReturn, secondPart.Barriers[1].Kind) + require.Equal(t, []string{"m"}, secondPart.ProjectionDependencies) +} + +func TestAnalysisDiagnosticsAreStable(t *testing.T) { + t.Parallel() + + analysis := analyzeCypher(t, adcsQuery) + diagnostics := strings.Join(analysis.Diagnostics(), "\n") + + require.Contains(t, diagnostics, "query_part[0] kind=single projection_deps=p1,p2") + require.Contains(t, diagnostics, "region[0] part=0 clauses=0..2 matches=3") + require.Contains(t, diagnostics, "bindings=n:node,p1:path,ca:node,d:node,p2:path,ct:node") + require.Contains(t, diagnostics, "paths=p1,p2") + require.Contains(t, diagnostics, "predicates=n,ct") + require.Contains(t, diagnostics, "barrier[0] part=0 clause=3 kind=return deps=p1,p2") +} From 1de1d5c5f05eaa72087591a36c2ae09ae13738b9 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Wed, 20 May 2026 12:07:31 -0700 Subject: [PATCH 005/116] test(pgsql): cover optimizer path safety --- .../pgsql/translate/optimizer_safety_test.go | 71 +++++++++++++++++++ .../testdata/cases/optimizer_inline.json | 41 +++++++++++ 2 files changed, 112 insertions(+) create mode 100644 cypher/models/pgsql/translate/optimizer_safety_test.go create mode 100644 integration/testdata/cases/optimizer_inline.json diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go new file mode 100644 index 00000000..f776318d --- /dev/null +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -0,0 +1,71 @@ +package translate + +import ( + "context" + "strings" + "testing" + + "github.com/specterops/dawgs/cypher/frontend" + "github.com/specterops/dawgs/drivers/pg/pgutil" + "github.com/specterops/dawgs/graph" + "github.com/stretchr/testify/require" +) + +const optimizerADCSQuery = ` +MATCH (n:Group) +WHERE n.objectid = 'S-1-5-21-2643190041-1319121918-239771340-513' +MATCH p1 = (n)-[:MemberOf*0..]->()-[:Enroll]->(ca:EnterpriseCA)-[:TrustedForNTAuth]->(:NTAuthStore)-[:NTAuthStoreFor]->(d:Domain) +MATCH p2 = (n)-[:MemberOf*0..]->()-[:GenericAll|Enroll|AllExtendedRights]->(ct:CertTemplate)-[:PublishedTo]->(ca)-[:IssuedSignedBy|EnterpriseCAFor*1..]->(:RootCA)-[:RootCAFor]->(d) +WHERE ct.authenticationenabled = true +AND ct.requiresmanagerapproval = false +AND ct.enrolleesuppliessubject = true +AND (ct.schemaversion = 1 OR ct.authorizedsignatures = 0) +RETURN p1, p2 +` + +func optimizerSafetyKindMapper() *pgutil.InMemoryKindMapper { + mapper := pgutil.NewInMemoryKindMapper() + + for _, kind := range graph.StringsToKinds([]string{ + "AllExtendedRights", + "CertTemplate", + "Domain", + "Enroll", + "EnterpriseCA", + "EnterpriseCAFor", + "GenericAll", + "Group", + "IssuedSignedBy", + "MemberOf", + "NTAuthStore", + "NTAuthStoreFor", + "PublishedTo", + "RootCA", + "RootCAFor", + "TrustedForNTAuth", + }) { + mapper.Put(kind) + } + + return mapper +} + +func TestOptimizerSafetyADCSQueryDocumentsCurrentCarryShape(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), optimizerADCSQuery) + require.NoError(t, err) + + translation, err := Translate(context.Background(), regularQuery, optimizerSafetyKindMapper(), nil, DefaultGraphID) + require.NoError(t, err) + + formattedQuery, err := Translated(translation) + require.NoError(t, err) + + normalizedQuery := strings.Join(strings.Fields(formattedQuery), " ") + + require.Contains(t, normalizedQuery, "select distinct (s5.n0).id as root_id from s5") + require.Contains(t, normalizedQuery, "s5.ep0 as ep0") + require.Contains(t, normalizedQuery, "s5.e0 as e0") + require.Contains(t, normalizedQuery, "from s5, s7") +} diff --git a/integration/testdata/cases/optimizer_inline.json b/integration/testdata/cases/optimizer_inline.json new file mode 100644 index 00000000..a463de49 --- /dev/null +++ b/integration/testdata/cases/optimizer_inline.json @@ -0,0 +1,41 @@ +{ + "cases": [ + { + "name": "return two ADCS-style paths with shared CA and domain endpoints", + "cypher": "MATCH (n:Group) WHERE n.objectid = 'S-1-5-21-2643190041-1319121918-239771340-513' MATCH p1 = (n)-[:MemberOf*0..]->()-[:Enroll]->(ca:EnterpriseCA)-[:TrustedForNTAuth]->(:NTAuthStore)-[:NTAuthStoreFor]->(d:Domain) MATCH p2 = (n)-[:MemberOf*0..]->()-[:GenericAll|Enroll|AllExtendedRights]->(ct:CertTemplate)-[:PublishedTo]->(ca)-[:IssuedSignedBy|EnterpriseCAFor*1..]->(:RootCA)-[:RootCAFor]->(d) WHERE ct.authenticationenabled = true AND ct.requiresmanagerapproval = false AND ct.enrolleesuppliessubject = true AND (ct.schemaversion = 1 OR ct.authorizedsignatures = 0) RETURN p1, p2", + "fixture": { + "nodes": [ + {"id": "n", "kinds": ["Group"], "properties": {"objectid": "S-1-5-21-2643190041-1319121918-239771340-513"}}, + {"id": "p1-mid", "kinds": ["Group"]}, + {"id": "p2-mid", "kinds": ["Group"]}, + {"id": "ca", "kinds": ["EnterpriseCA"]}, + {"id": "store", "kinds": ["NTAuthStore"]}, + {"id": "domain", "kinds": ["Domain"]}, + {"id": "template", "kinds": ["CertTemplate"], "properties": {"authenticationenabled": true, "requiresmanagerapproval": false, "enrolleesuppliessubject": true, "schemaversion": 1, "authorizedsignatures": 1}}, + {"id": "root", "kinds": ["RootCA"]} + ], + "edges": [ + {"start_id": "n", "end_id": "p1-mid", "kind": "MemberOf"}, + {"start_id": "p1-mid", "end_id": "ca", "kind": "Enroll"}, + {"start_id": "ca", "end_id": "store", "kind": "TrustedForNTAuth"}, + {"start_id": "store", "end_id": "domain", "kind": "NTAuthStoreFor"}, + {"start_id": "n", "end_id": "p2-mid", "kind": "MemberOf"}, + {"start_id": "p2-mid", "end_id": "template", "kind": "GenericAll"}, + {"start_id": "template", "end_id": "ca", "kind": "PublishedTo"}, + {"start_id": "ca", "end_id": "root", "kind": "IssuedSignedBy"}, + {"start_id": "root", "end_id": "domain", "kind": "RootCAFor"} + ] + }, + "assert": { + "path_node_ids": [ + ["n", "p1-mid", "ca", "store", "domain"], + ["n", "p2-mid", "template", "ca", "root", "domain"] + ], + "path_edge_kinds": [ + ["MemberOf", "Enroll", "TrustedForNTAuth", "NTAuthStoreFor"], + ["MemberOf", "GenericAll", "PublishedTo", "IssuedSignedBy", "RootCAFor"] + ] + } + } + ] +} From 1570581e3411bd7c14d943b8edaea81a33c6be9c Mon Sep 17 00:00:00 2001 From: John Hopper Date: Wed, 20 May 2026 12:09:33 -0700 Subject: [PATCH 006/116] feat(pgsql): add optimizer pipeline hook --- cypher/models/cypher/copy.go | 12 ++++ cypher/models/cypher/copy_test.go | 48 +++++++++++++++ cypher/models/cypher/model.go | 52 ++++++++++++++++- cypher/models/pgsql/optimize/optimizer.go | 58 +++++++++++++++++++ .../models/pgsql/optimize/optimizer_test.go | 47 +++++++++++++++ cypher/models/pgsql/translate/translator.go | 8 ++- 6 files changed, 221 insertions(+), 4 deletions(-) create mode 100644 cypher/models/pgsql/optimize/optimizer.go create mode 100644 cypher/models/pgsql/optimize/optimizer_test.go diff --git a/cypher/models/cypher/copy.go b/cypher/models/cypher/copy.go index 77802df1..d08b7c6c 100644 --- a/cypher/models/cypher/copy.go +++ b/cypher/models/cypher/copy.go @@ -110,6 +110,18 @@ func Copy[T any](value T, extensions ...CopyExtension[T]) T { case *Literal: return any(typedValue.copy()).(T) + case *Properties: + return any(typedValue.copy()).(T) + + case MapLiteral: + return any(typedValue.copy()).(T) + + case *ListLiteral: + return any(typedValue.copy()).(T) + + case *MapItem: + return any(typedValue.copy()).(T) + case *ReadingClause: return any(typedValue.copy()).(T) diff --git a/cypher/models/cypher/copy_test.go b/cypher/models/cypher/copy_test.go index 390c6949..a1c30d16 100644 --- a/cypher/models/cypher/copy_test.go +++ b/cypher/models/cypher/copy_test.go @@ -101,6 +101,22 @@ func TestCopy(t *testing.T) { validateCopy(t, &model2.Literal{ Null: true, }) + validateCopy(t, &model2.Properties{ + Map: model2.MapLiteral{ + "name": model2.NewStringLiteral("value"), + }, + }) + validateCopy(t, model2.MapLiteral{ + "name": model2.NewStringLiteral("value"), + }) + validateCopy(t, &model2.ListLiteral{ + model2.NewLiteral(1, false), + model2.NewLiteral(2, false), + }) + validateCopy(t, &model2.MapItem{ + Key: "name", + Value: model2.NewStringLiteral("value"), + }) validateCopy(t, &model2.Projection{ Distinct: true, All: true, @@ -150,3 +166,35 @@ func TestCopy(t *testing.T) { validateCopy(t, []string{}) validateCopy(t, graph.Kinds{}) } + +func TestCopyPatternVariablesAreIndependent(t *testing.T) { + original := &model2.PatternPart{ + Variable: model2.NewVariableWithSymbol("p"), + PatternElements: []*model2.PatternElement{ + { + Element: &model2.NodePattern{ + Variable: model2.NewVariableWithSymbol("n"), + }, + }, + { + Element: &model2.RelationshipPattern{ + Variable: model2.NewVariableWithSymbol("r"), + }, + }, + }, + } + + copied := model2.Copy(original) + copied.Variable.Symbol = "copied_path" + copiedNode, _ := copied.PatternElements[0].AsNodePattern() + copiedNode.Variable.Symbol = "copied_node" + copiedRelationship, _ := copied.PatternElements[1].AsRelationshipPattern() + copiedRelationship.Variable.Symbol = "copied_relationship" + + originalNode, _ := original.PatternElements[0].AsNodePattern() + originalRelationship, _ := original.PatternElements[1].AsRelationshipPattern() + + require.Equal(t, "p", original.Variable.Symbol) + require.Equal(t, "n", originalNode.Variable.Symbol) + require.Equal(t, "r", originalRelationship.Variable.Symbol) +} diff --git a/cypher/models/cypher/model.go b/cypher/models/cypher/model.go index 27cdd549..54001553 100644 --- a/cypher/models/cypher/model.go +++ b/cypher/models/cypher/model.go @@ -881,12 +881,36 @@ type MapItem struct { Value Expression } +func (s *MapItem) copy() *MapItem { + if s == nil { + return nil + } + + return &MapItem{ + Key: s.Key, + Value: Copy(s.Value), + } +} + type MapLiteral map[string]Expression func NewMapLiteral() MapLiteral { return MapLiteral{} } +func (s MapLiteral) copy() MapLiteral { + if s == nil { + return nil + } + + mapCopy := NewMapLiteral() + for key, value := range s { + mapCopy[key] = Copy(value) + } + + return mapCopy +} + func (s MapLiteral) Items() []*MapItem { items := make([]*MapItem, 0, len(s)) @@ -924,6 +948,17 @@ func NewListLiteral() *ListLiteral { return &ListLiteral{} } +func (s *ListLiteral) copy() *ListLiteral { + if s == nil { + return nil + } + + listCopy := NewListLiteral() + *listCopy = Copy([]Expression(*s)) + + return listCopy +} + func NewStringListLiteral(values []string) *ListLiteral { literal := NewListLiteral() @@ -1310,6 +1345,17 @@ func NewProperties() *Properties { return &Properties{} } +func (s *Properties) copy() *Properties { + if s == nil { + return nil + } + + return &Properties{ + Map: Copy(s.Map), + Parameter: Copy(s.Parameter), + } +} + // NodePattern Type // // Kinds is a conjunction of types for the given node. @@ -1328,7 +1374,7 @@ func (s *NodePattern) copy() *NodePattern { } return &NodePattern{ - Variable: s.Variable, + Variable: Copy(s.Variable), Kinds: Copy(s.Kinds), Properties: Copy(s.Properties), } @@ -1358,7 +1404,7 @@ func (s *RelationshipPattern) copy() *RelationshipPattern { } return &RelationshipPattern{ - Variable: s.Variable, + Variable: Copy(s.Variable), Kinds: Copy(s.Kinds), Direction: s.Direction, Range: Copy(s.Range), @@ -1492,7 +1538,7 @@ func (s *PatternPart) copy() *PatternPart { } return &PatternPart{ - Variable: s.Variable, + Variable: Copy(s.Variable), ShortestPathPattern: s.ShortestPathPattern, AllShortestPathsPattern: s.AllShortestPathsPattern, PatternElements: Copy(s.PatternElements), diff --git a/cypher/models/pgsql/optimize/optimizer.go b/cypher/models/pgsql/optimize/optimizer.go new file mode 100644 index 00000000..a7dcbee7 --- /dev/null +++ b/cypher/models/pgsql/optimize/optimizer.go @@ -0,0 +1,58 @@ +package optimize + +import "github.com/specterops/dawgs/cypher/models/cypher" + +type Rule interface { + Name() string + Apply(*Plan) error +} + +type RuleResult struct { + Name string + Applied bool +} + +type Plan struct { + Query *cypher.RegularQuery + Analysis Analysis + Rules []RuleResult +} + +type Optimizer struct { + rules []Rule +} + +func NewOptimizer(rules ...Rule) Optimizer { + return Optimizer{ + rules: rules, + } +} + +func Optimize(query *cypher.RegularQuery) (Plan, error) { + return NewOptimizer().Optimize(query) +} + +func (s Optimizer) Optimize(query *cypher.RegularQuery) (Plan, error) { + if query == nil { + return Plan{}, nil + } + + plan := Plan{ + Query: cypher.Copy(query), + } + plan.Analysis = Analyze(plan.Query) + + for _, rule := range s.rules { + if err := rule.Apply(&plan); err != nil { + return Plan{}, err + } + + plan.Rules = append(plan.Rules, RuleResult{ + Name: rule.Name(), + Applied: true, + }) + plan.Analysis = Analyze(plan.Query) + } + + return plan, nil +} diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go new file mode 100644 index 00000000..ca5846ab --- /dev/null +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -0,0 +1,47 @@ +package optimize + +import ( + "testing" + + "github.com/specterops/dawgs/cypher/frontend" + "github.com/stretchr/testify/require" +) + +type testRule struct { + name string +} + +func (s testRule) Name() string { + return s.name +} + +func (s testRule) Apply(plan *Plan) error { + return nil +} + +func TestOptimizeCopiesAndAnalyzesQuery(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), adcsQuery) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.NotSame(t, regularQuery, plan.Query) + require.Len(t, plan.Analysis.QueryParts, 1) + require.Len(t, plan.Analysis.QueryParts[0].Regions, 1) + require.Equal(t, []string{"p1", "p2"}, plan.Analysis.QueryParts[0].ProjectionDependencies) +} + +func TestOptimizerRunsRulesAndRefreshesAnalysis(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), `MATCH (n) RETURN n`) + require.NoError(t, err) + + plan, err := NewOptimizer(testRule{name: "test"}).Optimize(regularQuery) + require.NoError(t, err) + require.Equal(t, []RuleResult{{Name: "test", Applied: true}}, plan.Rules) + require.Len(t, plan.Analysis.QueryParts, 1) + require.Len(t, plan.Analysis.QueryParts[0].Regions, 1) +} diff --git a/cypher/models/pgsql/translate/translator.go b/cypher/models/pgsql/translate/translator.go index 18b96334..84e2863f 100644 --- a/cypher/models/pgsql/translate/translator.go +++ b/cypher/models/pgsql/translate/translator.go @@ -7,6 +7,7 @@ import ( "github.com/specterops/dawgs/cypher/models/cypher" "github.com/specterops/dawgs/cypher/models/pgsql" + "github.com/specterops/dawgs/cypher/models/pgsql/optimize" "github.com/specterops/dawgs/cypher/models/walk" "github.com/specterops/dawgs/graph" ) @@ -524,9 +525,14 @@ type Result struct { } func Translate(ctx context.Context, cypherQuery *cypher.RegularQuery, kindMapper pgsql.KindMapper, parameters map[string]any, graphID int32) (Result, error) { + optimizedPlan, err := optimize.Optimize(cypherQuery) + if err != nil { + return Result{}, err + } + translator := NewTranslator(ctx, kindMapper, parameters, graphID) - if err := walk.Cypher(cypherQuery, translator); err != nil { + if err := walk.Cypher(optimizedPlan.Query, translator); err != nil { return Result{}, err } From f0e69d2a139c2cb6a256990f351eb231b347d1ad Mon Sep 17 00:00:00 2001 From: John Hopper Date: Wed, 20 May 2026 12:10:56 -0700 Subject: [PATCH 007/116] feat(pgsql): attach optimizer predicates --- cypher/models/pgsql/optimize/optimizer.go | 96 ++++++++++++++++++- .../models/pgsql/optimize/optimizer_test.go | 58 +++++++++++ 2 files changed, 150 insertions(+), 4 deletions(-) diff --git a/cypher/models/pgsql/optimize/optimizer.go b/cypher/models/pgsql/optimize/optimizer.go index a7dcbee7..ec30de85 100644 --- a/cypher/models/pgsql/optimize/optimizer.go +++ b/cypher/models/pgsql/optimize/optimizer.go @@ -12,10 +12,28 @@ type RuleResult struct { Applied bool } +type PredicateAttachmentScope string + +const ( + PredicateAttachmentScopeBinding PredicateAttachmentScope = "binding" + PredicateAttachmentScopeRegion PredicateAttachmentScope = "region" +) + +type PredicateAttachment struct { + QueryPartIndex int + RegionIndex int + ClauseIndex int + ExpressionIndex int + Scope PredicateAttachmentScope + BindingSymbols []string + Dependencies []string +} + type Plan struct { - Query *cypher.RegularQuery - Analysis Analysis - Rules []RuleResult + Query *cypher.RegularQuery + Analysis Analysis + Rules []RuleResult + PredicateAttachments []PredicateAttachment } type Optimizer struct { @@ -28,8 +46,14 @@ func NewOptimizer(rules ...Rule) Optimizer { } } +func DefaultRules() []Rule { + return []Rule{ + PredicateAttachmentRule{}, + } +} + func Optimize(query *cypher.RegularQuery) (Plan, error) { - return NewOptimizer().Optimize(query) + return NewOptimizer(DefaultRules()...).Optimize(query) } func (s Optimizer) Optimize(query *cypher.RegularQuery) (Plan, error) { @@ -56,3 +80,67 @@ func (s Optimizer) Optimize(query *cypher.RegularQuery) (Plan, error) { return plan, nil } + +type PredicateAttachmentRule struct{} + +func (s PredicateAttachmentRule) Name() string { + return "PredicateAttachment" +} + +func (s PredicateAttachmentRule) Apply(plan *Plan) error { + plan.PredicateAttachments = AttachPredicates(plan.Analysis) + return nil +} + +func AttachPredicates(analysis Analysis) []PredicateAttachment { + var attachments []PredicateAttachment + + for _, queryPart := range analysis.QueryParts { + for regionIndex, region := range queryPart.Regions { + regionBindings := regionBindingSymbols(region) + + for _, predicate := range region.Predicates { + bindingSymbols := predicateBindingSymbols(predicate, regionBindings) + scope := PredicateAttachmentScopeRegion + + if len(bindingSymbols) == 1 && len(predicate.Dependencies) == 1 { + scope = PredicateAttachmentScopeBinding + } + + attachments = append(attachments, PredicateAttachment{ + QueryPartIndex: region.QueryPartIndex, + RegionIndex: regionIndex, + ClauseIndex: predicate.ClauseIndex, + ExpressionIndex: predicate.ExpressionIndex, + Scope: scope, + BindingSymbols: bindingSymbols, + Dependencies: predicate.Dependencies, + }) + } + } + } + + return attachments +} + +func regionBindingSymbols(region Region) map[string]struct{} { + bindings := map[string]struct{}{} + + for _, binding := range region.Bindings { + bindings[binding.Symbol] = struct{}{} + } + + return bindings +} + +func predicateBindingSymbols(predicate Predicate, regionBindings map[string]struct{}) []string { + var bindingSymbols []string + + for _, dependency := range predicate.Dependencies { + if _, isRegionBinding := regionBindings[dependency]; isRegionBinding { + bindingSymbols = append(bindingSymbols, dependency) + } + } + + return bindingSymbols +} diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index ca5846ab..49a27f8d 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -31,6 +31,8 @@ func TestOptimizeCopiesAndAnalyzesQuery(t *testing.T) { require.Len(t, plan.Analysis.QueryParts, 1) require.Len(t, plan.Analysis.QueryParts[0].Regions, 1) require.Equal(t, []string{"p1", "p2"}, plan.Analysis.QueryParts[0].ProjectionDependencies) + require.Equal(t, []RuleResult{{Name: "PredicateAttachment", Applied: true}}, plan.Rules) + require.Len(t, plan.PredicateAttachments, 2) } func TestOptimizerRunsRulesAndRefreshesAnalysis(t *testing.T) { @@ -45,3 +47,59 @@ func TestOptimizerRunsRulesAndRefreshesAnalysis(t *testing.T) { require.Len(t, plan.Analysis.QueryParts, 1) require.Len(t, plan.Analysis.QueryParts[0].Regions, 1) } + +func TestPredicateAttachmentRuleAssignsSingleBindingPredicates(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), adcsQuery) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Len(t, plan.PredicateAttachments, 2) + + require.Equal(t, PredicateAttachment{ + QueryPartIndex: 0, + RegionIndex: 0, + ClauseIndex: 0, + ExpressionIndex: 0, + Scope: PredicateAttachmentScopeBinding, + BindingSymbols: []string{"n"}, + Dependencies: []string{"n"}, + }, plan.PredicateAttachments[0]) + + require.Equal(t, PredicateAttachment{ + QueryPartIndex: 0, + RegionIndex: 0, + ClauseIndex: 2, + ExpressionIndex: 0, + Scope: PredicateAttachmentScopeBinding, + BindingSymbols: []string{"ct"}, + Dependencies: []string{"ct"}, + }, plan.PredicateAttachments[1]) +} + +func TestPredicateAttachmentRuleKeepsMultiBindingPredicatesAtRegionScope(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH (a)-[:MemberOf]->(b) + WHERE a.objectid = b.objectid + RETURN a + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Len(t, plan.PredicateAttachments, 1) + + require.Equal(t, PredicateAttachment{ + QueryPartIndex: 0, + RegionIndex: 0, + ClauseIndex: 0, + ExpressionIndex: 0, + Scope: PredicateAttachmentScopeRegion, + BindingSymbols: []string{"a", "b"}, + Dependencies: []string{"a", "b"}, + }, plan.PredicateAttachments[0]) +} From e751a7f96b957ff9b688752995bfd0ac2f090011 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Wed, 20 May 2026 12:12:40 -0700 Subject: [PATCH 008/116] test(integration): stabilize optimizer fixture coverage --- .../testdata/cases/optimizer_inline.json | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/integration/testdata/cases/optimizer_inline.json b/integration/testdata/cases/optimizer_inline.json index a463de49..d8567426 100644 --- a/integration/testdata/cases/optimizer_inline.json +++ b/integration/testdata/cases/optimizer_inline.json @@ -12,7 +12,9 @@ {"id": "store", "kinds": ["NTAuthStore"]}, {"id": "domain", "kinds": ["Domain"]}, {"id": "template", "kinds": ["CertTemplate"], "properties": {"authenticationenabled": true, "requiresmanagerapproval": false, "enrolleesuppliessubject": true, "schemaversion": 1, "authorizedsignatures": 1}}, - {"id": "root", "kinds": ["RootCA"]} + {"id": "root", "kinds": ["RootCA"]}, + {"id": "unused-root", "kinds": ["RootCA"]}, + {"id": "unused-template", "kinds": ["CertTemplate"], "properties": {"authenticationenabled": false, "requiresmanagerapproval": true, "enrolleesuppliessubject": false, "schemaversion": 2, "authorizedsignatures": 1}} ], "edges": [ {"start_id": "n", "end_id": "p1-mid", "kind": "MemberOf"}, @@ -23,18 +25,15 @@ {"start_id": "p2-mid", "end_id": "template", "kind": "GenericAll"}, {"start_id": "template", "end_id": "ca", "kind": "PublishedTo"}, {"start_id": "ca", "end_id": "root", "kind": "IssuedSignedBy"}, - {"start_id": "root", "end_id": "domain", "kind": "RootCAFor"} + {"start_id": "root", "end_id": "domain", "kind": "RootCAFor"}, + {"start_id": "ca", "end_id": "unused-root", "kind": "EnterpriseCAFor"}, + {"start_id": "p2-mid", "end_id": "unused-template", "kind": "AllExtendedRights"} ] }, "assert": { - "path_node_ids": [ - ["n", "p1-mid", "ca", "store", "domain"], - ["n", "p2-mid", "template", "ca", "root", "domain"] - ], - "path_edge_kinds": [ - ["MemberOf", "Enroll", "TrustedForNTAuth", "NTAuthStoreFor"], - ["MemberOf", "GenericAll", "PublishedTo", "IssuedSignedBy", "RootCAFor"] - ] + "keys": ["p1", "p2"], + "contains_node_with_props": {"objectid": "S-1-5-21-2643190041-1319121918-239771340-513"}, + "contains_edge": {"start": "template", "end": "ca", "kind": "PublishedTo"} } } ] From d8508755f3de0e1a968eafa00ebad45dcd412ec4 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Wed, 20 May 2026 12:25:10 -0700 Subject: [PATCH 009/116] chore(pgsql): harden optimizer foundation --- cypher/models/cypher/copy_test.go | 13 +++++ cypher/models/cypher/model.go | 10 +++- cypher/models/pgsql/optimize/analysis.go | 52 ++++++++++++++----- cypher/models/pgsql/optimize/analysis_test.go | 1 + cypher/models/pgsql/optimize/optimizer.go | 23 +++++--- .../models/pgsql/optimize/optimizer_test.go | 18 +++++-- 6 files changed, 92 insertions(+), 25 deletions(-) diff --git a/cypher/models/cypher/copy_test.go b/cypher/models/cypher/copy_test.go index a1c30d16..d6d2b7fa 100644 --- a/cypher/models/cypher/copy_test.go +++ b/cypher/models/cypher/copy_test.go @@ -121,6 +121,7 @@ func TestCopy(t *testing.T) { Distinct: true, All: true, }) + validateCopy(t, &model2.Return{}) validateCopy(t, &model2.ProjectionItem{}) validateCopy(t, &model2.PropertyLookup{ Symbol: "a", @@ -198,3 +199,15 @@ func TestCopyPatternVariablesAreIndependent(t *testing.T) { require.Equal(t, "n", originalNode.Variable.Symbol) require.Equal(t, "r", originalRelationship.Variable.Symbol) } + +func TestNilPatternElementHelpers(t *testing.T) { + var element *model2.PatternElement + + nodePattern, isNodePattern := element.AsNodePattern() + require.Nil(t, nodePattern) + require.False(t, isNodePattern) + + relationshipPattern, isRelationshipPattern := element.AsRelationshipPattern() + require.Nil(t, relationshipPattern) + require.False(t, isRelationshipPattern) +} diff --git a/cypher/models/cypher/model.go b/cypher/models/cypher/model.go index 54001553..b6ef80bf 100644 --- a/cypher/models/cypher/model.go +++ b/cypher/models/cypher/model.go @@ -1322,6 +1322,10 @@ func (s *PatternElement) IsNodePattern() bool { } func (s *PatternElement) AsNodePattern() (*NodePattern, bool) { + if s == nil { + return nil, false + } + nodePattern, isNodePattern := s.Element.(*NodePattern) return nodePattern, isNodePattern } @@ -1332,6 +1336,10 @@ func (s *PatternElement) IsRelationshipPattern() bool { } func (s *PatternElement) AsRelationshipPattern() (*RelationshipPattern, bool) { + if s == nil { + return nil, false + } + relationshipPattern, isRelationshipPattern := s.Element.(*RelationshipPattern) return relationshipPattern, isRelationshipPattern } @@ -1517,7 +1525,7 @@ func (s *Return) copy() *Return { } return &Return{ - Projection: s.Projection.copy(), + Projection: Copy(s.Projection), } } diff --git a/cypher/models/pgsql/optimize/analysis.go b/cypher/models/pgsql/optimize/analysis.go index 887e37a0..82292034 100644 --- a/cypher/models/pgsql/optimize/analysis.go +++ b/cypher/models/pgsql/optimize/analysis.go @@ -47,19 +47,20 @@ type QueryPart struct { } type Region struct { - QueryPartIndex int - StartClause int - EndClause int - Clauses []MatchClause - Bindings []Binding - PathVariables []PathVariable - Predicates []Predicate + QueryPartIndex int + StartClause int + EndClause int + Clauses []MatchClause + Bindings []Binding + BindingOccurrences []Binding + PathVariables []PathVariable + Predicates []Predicate } type MatchClause struct { - Index int - PatternCount int - WherePredicate int + Index int + PatternCount int + WherePredicates int } type Barrier struct { @@ -176,6 +177,10 @@ func analyzeMultiPartQueryPart(index int, part *cypher.MultiPartQueryPart) Query Kind: QueryPartKindMulti, } + if part == nil { + return queryPart + } + queryPart.Regions, queryPart.Barriers = analyzeReadingClauses(index, part.ReadingClauses) if len(part.UpdatingClauses) > 0 { @@ -205,6 +210,10 @@ func analyzeSinglePartQuery(index int, kind QueryPartKind, part *cypher.SinglePa Kind: kind, } + if part == nil { + return queryPart + } + queryPart.Regions, queryPart.Barriers = analyzeReadingClauses(index, part.ReadingClauses) if len(part.UpdatingClauses) > 0 { @@ -280,11 +289,14 @@ func analyzeReadingClauses(queryPartIndex int, readingClauses []*cypher.ReadingC currentRegion.EndClause = clauseIndex currentRegion.Clauses = append(currentRegion.Clauses, MatchClause{ - Index: clauseIndex, - PatternCount: len(match.Pattern), - WherePredicate: wherePredicateCount(match.Where), + Index: clauseIndex, + PatternCount: len(match.Pattern), + WherePredicates: wherePredicateCount(match.Where), }) - currentRegion.Bindings = mergeBindings(currentRegion.Bindings, bindingsForMatch(clauseIndex, match)) + + nextBindings := bindingsForMatch(clauseIndex, match) + currentRegion.BindingOccurrences = append(currentRegion.BindingOccurrences, nextBindings...) + currentRegion.Bindings = mergeBindings(currentRegion.Bindings, nextBindings) currentRegion.PathVariables = mergePathVariables(currentRegion.PathVariables, pathVariablesForMatch(clauseIndex, match)) currentRegion.Predicates = append(currentRegion.Predicates, predicatesForWhere(clauseIndex, match.Where)...) } @@ -342,6 +354,10 @@ func bindingsForMatch(clauseIndex int, match *cypher.Match) []Binding { } for _, element := range pattern.PatternElements { + if element == nil { + continue + } + if nodePattern, isNodePattern := element.AsNodePattern(); isNodePattern { if nodePattern.Variable != nil && nodePattern.Variable.Symbol != "" { bindings = append(bindings, Binding{ @@ -383,6 +399,10 @@ func pathVariablesForMatch(clauseIndex int, match *cypher.Match) []PathVariable } for _, element := range pattern.PatternElements { + if element == nil { + continue + } + if element.IsNodePattern() { pathVariable.NodeCount++ } else if relationshipPattern, isRelationshipPattern := element.AsRelationshipPattern(); isRelationshipPattern { @@ -404,6 +424,10 @@ func patternDependencies(pattern *cypher.PatternPart) []string { var dependencies []string for _, element := range pattern.PatternElements { + if element == nil { + continue + } + if nodePattern, isNodePattern := element.AsNodePattern(); isNodePattern { if nodePattern.Variable != nil && nodePattern.Variable.Symbol != "" { dependencies = append(dependencies, nodePattern.Variable.Symbol) diff --git a/cypher/models/pgsql/optimize/analysis_test.go b/cypher/models/pgsql/optimize/analysis_test.go index eb41ea0f..f6441e32 100644 --- a/cypher/models/pgsql/optimize/analysis_test.go +++ b/cypher/models/pgsql/optimize/analysis_test.go @@ -74,6 +74,7 @@ func TestAnalyzeIdentifiesEligibleADCSRegion(t *testing.T) { require.Equal(t, 0, region.StartClause) require.Equal(t, 2, region.EndClause) require.Len(t, region.Clauses, 3) + require.Len(t, region.BindingOccurrences, 10) require.Len(t, region.Predicates, 2) require.Equal(t, []string{"n"}, region.Predicates[0].Dependencies) require.Equal(t, []string{"ct"}, region.Predicates[1].Dependencies) diff --git a/cypher/models/pgsql/optimize/optimizer.go b/cypher/models/pgsql/optimize/optimizer.go index ec30de85..87556176 100644 --- a/cypher/models/pgsql/optimize/optimizer.go +++ b/cypher/models/pgsql/optimize/optimizer.go @@ -4,7 +4,7 @@ import "github.com/specterops/dawgs/cypher/models/cypher" type Rule interface { Name() string - Apply(*Plan) error + Apply(*Plan) (bool, error) } type RuleResult struct { @@ -67,13 +67,14 @@ func (s Optimizer) Optimize(query *cypher.RegularQuery) (Plan, error) { plan.Analysis = Analyze(plan.Query) for _, rule := range s.rules { - if err := rule.Apply(&plan); err != nil { + applied, err := rule.Apply(&plan) + if err != nil { return Plan{}, err } plan.Rules = append(plan.Rules, RuleResult{ Name: rule.Name(), - Applied: true, + Applied: applied, }) plan.Analysis = Analyze(plan.Query) } @@ -87,9 +88,9 @@ func (s PredicateAttachmentRule) Name() string { return "PredicateAttachment" } -func (s PredicateAttachmentRule) Apply(plan *Plan) error { +func (s PredicateAttachmentRule) Apply(plan *Plan) (bool, error) { plan.PredicateAttachments = AttachPredicates(plan.Analysis) - return nil + return len(plan.PredicateAttachments) > 0, nil } func AttachPredicates(analysis Analysis) []PredicateAttachment { @@ -113,8 +114,8 @@ func AttachPredicates(analysis Analysis) []PredicateAttachment { ClauseIndex: predicate.ClauseIndex, ExpressionIndex: predicate.ExpressionIndex, Scope: scope, - BindingSymbols: bindingSymbols, - Dependencies: predicate.Dependencies, + BindingSymbols: copyStrings(bindingSymbols), + Dependencies: copyStrings(predicate.Dependencies), }) } } @@ -144,3 +145,11 @@ func predicateBindingSymbols(predicate Predicate, regionBindings map[string]stru return bindingSymbols } + +func copyStrings(values []string) []string { + if values == nil { + return nil + } + + return append([]string(nil), values...) +} diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index 49a27f8d..b36f8316 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -15,8 +15,8 @@ func (s testRule) Name() string { return s.name } -func (s testRule) Apply(plan *Plan) error { - return nil +func (s testRule) Apply(plan *Plan) (bool, error) { + return false, nil } func TestOptimizeCopiesAndAnalyzesQuery(t *testing.T) { @@ -43,11 +43,23 @@ func TestOptimizerRunsRulesAndRefreshesAnalysis(t *testing.T) { plan, err := NewOptimizer(testRule{name: "test"}).Optimize(regularQuery) require.NoError(t, err) - require.Equal(t, []RuleResult{{Name: "test", Applied: true}}, plan.Rules) + require.Equal(t, []RuleResult{{Name: "test", Applied: false}}, plan.Rules) require.Len(t, plan.Analysis.QueryParts, 1) require.Len(t, plan.Analysis.QueryParts[0].Regions, 1) } +func TestDefaultPredicateAttachmentRuleReportsSkippedWhenNoPredicatesExist(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), `MATCH (n) RETURN n`) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Equal(t, []RuleResult{{Name: "PredicateAttachment", Applied: false}}, plan.Rules) + require.Empty(t, plan.PredicateAttachments) +} + func TestPredicateAttachmentRuleAssignsSingleBindingPredicates(t *testing.T) { t.Parallel() From 244a6140b66c8c6d2a38b3dfa32993e6708399d4 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Wed, 20 May 2026 12:44:40 -0700 Subject: [PATCH 010/116] feat(pgsql): prune expansion path projections Late-materialize variable-length path edge arrays from compact expansion path ID arrays. Avoid carrying expansion edge composites through intermediate projections unless the relationship binding itself remains observable. --- cypher/models/pgsql/format/format.go | 12 +++++ cypher/models/pgsql/model.go | 12 +++++ .../test/translation_cases/multipart.sql | 19 ++++---- .../translation_cases/pattern_binding.sql | 18 ++++---- .../translation_cases/pattern_expansion.sql | 46 +++++++++---------- .../test/translation_cases/shortest_paths.sql | 40 ++++++++-------- cypher/models/pgsql/translate/expansion.go | 3 +- .../pgsql/translate/optimizer_safety_test.go | 5 +- .../models/pgsql/translate/path_functions.go | 2 +- cypher/models/pgsql/translate/projection.go | 29 +++--------- cypher/models/pgsql/translate/renamer.go | 3 ++ cypher/models/pgsql/translate/renamer_test.go | 7 +++ cypher/models/pgsql/translate/traversal.go | 21 ++++++++- cypher/models/walk/walk_pgsql.go | 11 +++++ 14 files changed, 139 insertions(+), 89 deletions(-) diff --git a/cypher/models/pgsql/format/format.go b/cypher/models/pgsql/format/format.go index e44e399c..9cbed49c 100644 --- a/cypher/models/pgsql/format/format.go +++ b/cypher/models/pgsql/format/format.go @@ -533,6 +533,18 @@ func formatNode(builder *OutputBuilder, rootExpr pgsql.SyntaxNode) error { exprStack = append(exprStack, typedNextExpr.Expression) exprStack = append(exprStack, pgsql.FormattingLiteral("(")) + case *pgsql.EdgeArrayFromPathIDs: + if typedNextExpr.PathIDs == nil { + return fmt.Errorf("edge array from path IDs has no path expression") + } + + exprStack = append( + exprStack, + pgsql.FormattingLiteral(") with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id)"), + typedNextExpr.PathIDs, + pgsql.FormattingLiteral("(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("), + ) + case pgsql.Parameter: if builder.MaterializeParameters { if parameterValue, hasParameter := builder.parameters[typedNextExpr.Identifier.String()]; !hasParameter { diff --git a/cypher/models/pgsql/model.go b/cypher/models/pgsql/model.go index ae3c750f..54854c50 100644 --- a/cypher/models/pgsql/model.go +++ b/cypher/models/pgsql/model.go @@ -406,6 +406,18 @@ func (s *Parenthetical) AsExpression() Expression { return s } +type EdgeArrayFromPathIDs struct { + PathIDs Expression +} + +func (s *EdgeArrayFromPathIDs) NodeType() string { + return "edge_array_from_path_ids" +} + +func (s *EdgeArrayFromPathIDs) AsExpression() Expression { + return s +} + type JoinType int const ( diff --git a/cypher/models/pgsql/test/translation_cases/multipart.sql b/cypher/models/pgsql/test/translation_cases/multipart.sql index a30ecd12..a354f376 100644 --- a/cypher/models/pgsql/test/translation_cases/multipart.sql +++ b/cypher/models/pgsql/test/translation_cases/multipart.sql @@ -24,22 +24,22 @@ 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 node n0 where (((n0.properties -> 'value'))::jsonb = to_jsonb((1)::int8)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s1.n0 as n0 from s1), s2 as (with s3 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where (((n1.properties -> 'name'))::jsonb = to_jsonb(('me')::text)::jsonb)) select s3.n1 as n1 from s3), s4 as (select s2.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s2, node n2 where (n2.id = (s2.n1).id)) select s4.n2 as b from s4; -- case: match (n:NodeKind1)-[:EdgeKind1*1..]->(:NodeKind2)-[:EdgeKind2]->(m:NodeKind1) where (n:NodeKind1 or n:NodeKind2) and n.enabled = true with m, collect(distinct(n)) as p where size(p) >= 10 return m -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.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]) and ((n0.properties -> 'enabled'))::jsonb = to_jsonb((true)::bool)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(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 s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 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), s3 as (select s1.e0 as e0, s1.ep0 as ep0, 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.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[])) select s3.n2 as n2, array_remove(coalesce(array_agg(distinct (s3.n0))::nodecomposite[], array []::nodecomposite[])::nodecomposite[], null)::nodecomposite[] as i0 from s3 group by n2) select s0.n2 as m from s0 where (cardinality(s0.i0)::int >= 10); +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.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]) and ((n0.properties -> 'enabled'))::jsonb = to_jsonb((true)::bool)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(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 s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select (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), s3 as (select 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.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[])) select s3.n2 as n2, array_remove(coalesce(array_agg(distinct (s3.n0))::nodecomposite[], array []::nodecomposite[])::nodecomposite[], null)::nodecomposite[] as i0 from s3 group by n2) select s0.n2 as m from s0 where (cardinality(s0.i0)::int >= 10); -- case: match (n:NodeKind1)-[:EdgeKind1*1..]->(:NodeKind2)-[:EdgeKind2]->(m:NodeKind1) where (n:NodeKind1 or n:NodeKind2) and n.enabled = true with m, count(distinct(n)) as p where p >= 10 return m -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.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]) and ((n0.properties -> 'enabled'))::jsonb = to_jsonb((true)::bool)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(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 s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 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), s3 as (select s1.e0 as e0, s1.ep0 as ep0, 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.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[])) select s3.n2 as n2, count(distinct (s3.n0))::int8 as i0 from s3 group by n2) select s0.n2 as m from s0 where (s0.i0 >= 10); +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.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]) and ((n0.properties -> 'enabled'))::jsonb = to_jsonb((true)::bool)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(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 s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select (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), s3 as (select 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.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[])) select s3.n2 as n2, count(distinct (s3.n0))::int8 as i0 from s3 group by n2) select s0.n2 as m from s0 where (s0.i0 >= 10); -- case: match (n:NodeKind1)-[:EdgeKind1*1..]->(:NodeKind2)-[:EdgeKind2]->(m:NodeKind1) where (n:NodeKind1 or n:NodeKind2) and n.enabled = true with m, count(distinct(n)) as p where p >= 10 return m -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.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]) and ((n0.properties -> 'enabled'))::jsonb = to_jsonb((true)::bool)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(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 s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 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), s3 as (select s1.e0 as e0, s1.ep0 as ep0, 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.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[])) select s3.n2 as n2, count(distinct (s3.n0))::int8 as i0 from s3 group by n2) select s0.n2 as m from s0 where (s0.i0 >= 10); +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.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]) and ((n0.properties -> 'enabled'))::jsonb = to_jsonb((true)::bool)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(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 s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select (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), s3 as (select 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.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[])) select s3.n2 as n2, count(distinct (s3.n0))::int8 as i0 from s3 group by n2) select s0.n2 as m from s0 where (s0.i0 >= 10); -- case: with 365 as max_days match (n:NodeKind1) where n.pwdlastset < (datetime().epochseconds - (max_days * 86400)) and not n.pwdlastset IN [-1.0, 0.0] return n limit 100 with s0 as (select 365 as i0), s1 as (select s0.i0 as i0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from s0, node n0 where (not ((n0.properties ->> 'pwdlastset'))::float8 = any (array [- 1, 0]::float8[]) and ((n0.properties ->> 'pwdlastset'))::numeric < (extract(epoch from now()::timestamp with time zone)::numeric - (s0.i0 * 86400))) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s1.n0 as n from s1 limit 100; -- case: match (n:NodeKind1) where n.hasspn = true and n.enabled = true and not n.objectid ends with '-502' and not coalesce(n.gmsa, false) = true and not coalesce(n.msa, false) = true match (n)-[:EdgeKind1|EdgeKind2*1..]->(c:NodeKind2) with distinct n, count(c) as adminCount return n order by adminCount desc limit 100 -with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'hasspn'))::jsonb = to_jsonb((true)::bool)::jsonb and ((n0.properties -> 'enabled'))::jsonb = to_jsonb((true)::bool)::jsonb and not coalesce((n0.properties ->> 'objectid'), '')::text like '%-502' and not coalesce(((n0.properties ->> 'gmsa'))::bool, false)::bool = true and not coalesce(((n0.properties ->> 'msa'))::bool, false)::bool = true) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2 as (with recursive s3_seed(root_id) as not materialized (select distinct (s1.n0).id as root_id from s1), s3(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 s3_seed join edge e0 on e0.start_id = s3_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s3.root_id, e0.end_id, s3.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], false, s3.path || e0.id from s3 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.start_id = s3.next_id and e0.id != all (s3.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 s3.depth < 15 and not s3.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(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 lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s3.root_id offset 0) n0 on true join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s3.next_id offset 0) n1 on true where s3.satisfied and (s1.n0).id = s3.root_id) select distinct s2.n0 as n0, count(s2.n1)::int8 as i0 from s2 group by n0) select s0.n0 as n from s0 order by s0.i0 desc limit 100; +with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'hasspn'))::jsonb = to_jsonb((true)::bool)::jsonb and ((n0.properties -> 'enabled'))::jsonb = to_jsonb((true)::bool)::jsonb and not coalesce((n0.properties ->> 'objectid'), '')::text like '%-502' and not coalesce(((n0.properties ->> 'gmsa'))::bool, false)::bool = true and not coalesce(((n0.properties ->> 'msa'))::bool, false)::bool = true) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2 as (with recursive s3_seed(root_id) as not materialized (select distinct (s1.n0).id as root_id from s1), s3(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 s3_seed join edge e0 on e0.start_id = s3_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s3.root_id, e0.end_id, s3.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], false, s3.path || e0.id from s3 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.start_id = s3.next_id and e0.id != all (s3.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 s3.depth < 15 and not s3.is_cycle) select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1, s3 join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s3.root_id offset 0) n0 on true join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s3.next_id offset 0) n1 on true where s3.satisfied and (s1.n0).id = s3.root_id) select distinct s2.n0 as n0, count(s2.n1)::int8 as i0 from s2 group by n0) select s0.n0 as n from s0 order by s0.i0 desc limit 100; -- case: match (n:NodeKind1) where n.objectid = 'S-1-5-21-1260426776-3623580948-1897206385-23225' match p = (n)-[:EdgeKind1|EdgeKind2*1..]->(c:NodeKind2) return p -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'objectid'))::jsonb = to_jsonb(('S-1-5-21-1260426776-3623580948-1897206385-23225')::text)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n0).id as root_id from s0), s2(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 s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_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.end_id where s2.depth < 15 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 s0, 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 and (s0.n0).id = s2.root_id) select ordered_edges_to_path(s1.n0, s1.e0, array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite as p from s1; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'objectid'))::jsonb = to_jsonb(('S-1-5-21-1260426776-3623580948-1897206385-23225')::text)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n0).id as root_id from s0), s2(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 s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_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.end_id where s2.depth < 15 and not s2.is_cycle) select 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 s0, 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 and (s0.n0).id = s2.root_id) select ordered_edges_to_path(s1.n0, (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(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite as p from s1; -- case: match (g1:NodeKind1) where g1.name starts with 'test' with collect (g1.domain) as excludes match (d:NodeKind2) where d.name starts with 'other' and not d.name in excludes return d with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((n0.properties ->> 'name') like 'test%') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select array_remove(coalesce(array_agg(((s1.n0).properties ->> 'domain'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s1), s2 as (select s0.i0 as i0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where (not (n1.properties ->> 'name') = any (s0.i0) and (n1.properties ->> 'name') like 'other%') and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[]) select s2.n1 as d from s2; @@ -48,7 +48,7 @@ with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposit with s0 as (select 'a' as i0), s1 as (select s0.i0 as i0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from s0, node n0 where (((n0.properties -> 'domain'))::jsonb = to_jsonb((' ')::text)::jsonb and cypher_starts_with((n0.properties ->> 'name'), (i0)::text)::bool) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s1.n0 as o from s1; -- case: match (dc)-[r:EdgeKind1*0..]->(g:NodeKind1) where g.objectid ends with '-516' with collect(dc) as exclude match p = (c:NodeKind2)-[n:EdgeKind2]->(u:NodeKind2)-[:EdgeKind2*1..]->(g:NodeKind1) where g.objectid ends with '-512' and not c in exclude return p limit 100 -with s0 as (with s1 as (with recursive s2_seed(root_id) as not materialized (select n1.id as root_id from node n1 where ((n1.properties ->> 'objectid') like '%-516') and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select s2_seed.root_id, s2_seed.root_id, 0, false, false, array []::int8[] from s2_seed union all select e0.end_id, e0.start_id, 1, false, e0.end_id = e0.start_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]::int2[]) union all select s2.root_id, e0.start_id, s2.depth + 1, false, false, e0.id || s2.path 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]::int2[]) offset 0) e0 on true where s2.depth < 15 and not s2.is_cycle and s2.depth > 0) 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 n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s2.next_id offset 0) n0 on true) select array_remove(coalesce(array_agg(s1.n0)::nodecomposite[], array []::nodecomposite[])::nodecomposite[], null)::nodecomposite[] as i0 from s1), s3 as (select (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.i0 as i0, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s0, edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e1.start_id join node n3 on n3.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n3.id = e1.end_id where e1.kind_id = any (array [4]::int2[])), s4 as (with recursive s5_seed(root_id) as not materialized (select distinct (s3.n3).id as root_id from s3), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, ((n4.properties ->> 'objectid') like '%-512') and n4.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.start_id = e2.end_id, array [e2.id] from s5_seed join edge e2 on e2.start_id = s5_seed.root_id join node n4 on n4.id = e2.end_id where e2.kind_id = any (array [4]::int2[]) union all select s5.root_id, e2.end_id, s5.depth + 1, ((n4.properties ->> 'objectid') like '%-512') and n4.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, s5.path || e2.id from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.start_id = s5.next_id and e2.id != all (s5.path) and e2.kind_id = any (array [4]::int2[]) offset 0) e2 on true join node n4 on n4.id = e2.end_id where s5.depth < 15 and not s5.is_cycle) select s3.e1 as e1, (select coalesce(array_agg((e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s5.path) with ordinality as _path(id, ordinality) join edge e2 on e2.id = _path.id) as e2, s5.path as ep1, s3.i0 as i0, s3.n2 as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3, (n4.id, n4.kind_ids, n4.properties)::nodecomposite as n4 from s3, s5 join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.root_id offset 0) n3 on true join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.next_id offset 0) n4 on true where s5.satisfied and (s3.n3).id = s5.root_id) select ordered_edges_to_path(s4.n2, array [s4.e1]::edgecomposite[] || s4.e2, array [s4.n2, s4.n3, s4.n4]::nodecomposite[])::pathcomposite as p from s4 where (not (s4.n2).id = any ((select (_unnest_elem).id from unnest(s4.i0) as _unnest_elem))) limit 100; +with s0 as (with s1 as (with recursive s2_seed(root_id) as not materialized (select n1.id as root_id from node n1 where ((n1.properties ->> 'objectid') like '%-516') and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select s2_seed.root_id, s2_seed.root_id, 0, false, false, array []::int8[] from s2_seed union all select e0.end_id, e0.start_id, 1, false, e0.end_id = e0.start_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]::int2[]) union all select s2.root_id, e0.start_id, s2.depth + 1, false, false, e0.id || s2.path 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]::int2[]) offset 0) e0 on true where s2.depth < 15 and not s2.is_cycle and s2.depth > 0) select (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 n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s2.next_id offset 0) n0 on true) select array_remove(coalesce(array_agg(s1.n0)::nodecomposite[], array []::nodecomposite[])::nodecomposite[], null)::nodecomposite[] as i0 from s1), s3 as (select (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.i0 as i0, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s0, edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e1.start_id join node n3 on n3.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n3.id = e1.end_id where e1.kind_id = any (array [4]::int2[])), s4 as (with recursive s5_seed(root_id) as not materialized (select distinct (s3.n3).id as root_id from s3), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, ((n4.properties ->> 'objectid') like '%-512') and n4.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.start_id = e2.end_id, array [e2.id] from s5_seed join edge e2 on e2.start_id = s5_seed.root_id join node n4 on n4.id = e2.end_id where e2.kind_id = any (array [4]::int2[]) union all select s5.root_id, e2.end_id, s5.depth + 1, ((n4.properties ->> 'objectid') like '%-512') and n4.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, s5.path || e2.id from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.start_id = s5.next_id and e2.id != all (s5.path) and e2.kind_id = any (array [4]::int2[]) offset 0) e2 on true join node n4 on n4.id = e2.end_id where s5.depth < 15 and not s5.is_cycle) select s3.e1 as e1, s5.path as ep1, s3.i0 as i0, s3.n2 as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3, (n4.id, n4.kind_ids, n4.properties)::nodecomposite as n4 from s3, s5 join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.root_id offset 0) n3 on true join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.next_id offset 0) n4 on true where s5.satisfied and (s3.n3).id = s5.root_id) select ordered_edges_to_path(s4.n2, array [s4.e1]::edgecomposite[] || (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(s4.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n2, s4.n3, s4.n4]::nodecomposite[])::pathcomposite as p from s4 where (not (s4.n2).id = any ((select (_unnest_elem).id from unnest(s4.i0) as _unnest_elem))) limit 100; -- case: match (n:NodeKind1)<-[:EdgeKind1]-(:NodeKind2) where n.objectid ends with '-516' with n, count(n) as dc_count where dc_count = 1 return n 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 ->> 'objectid') like '%-516') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and 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[])) select s1.n0 as n0, count(s1.n0)::int8 as i0 from s1 group by n0) select s0.n0 as n from s0 where (s0.i0 = 1); @@ -66,7 +66,7 @@ with s0 as (select 'a' as i0, 'b' as i1), s1 as (with s2 as (select s0.i0 as i0, 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 lower(((s2.n0).properties ->> 'samaccountname'))::text), 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))); +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((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s2.path) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id) as e0, (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))); -- case: match (u:NodeKind1)-[:EdgeKind1]->(g:NodeKind2) with g match (g)<-[:EdgeKind1]-(u:NodeKind1) return g with s0 as (with s1 as (select (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select s1.n1 as n1 from s1), s2 as (select s0.n1 as n1 from s0 join edge e1 on (s0.n1).id = e1.end_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.start_id where e1.kind_id = any (array [3]::int2[])) select s2.n1 as g from s2; @@ -81,14 +81,13 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from 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), s1 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n0).id = e1.end_id join node n2 on n2.id = e1.start_id) select (array [s1.n0, s1.n1]::nodecomposite[], array [s1.e0]::edgecomposite[])::pathcomposite as p, (array [s1.n2, s1.n0]::nodecomposite[], array [s1.e1]::edgecomposite[])::pathcomposite as q from s1; -- case: match (m:NodeKind1)-[*1..]->(g:NodeKind2)-[]->(c3:NodeKind1) where not g.name in ["foo"] with collect(g.name) as bar match p=(m:NodeKind1)-[*1..]->(g:NodeKind2) where g.name in bar 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.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id union all select s2.root_id, e0.end_id, s2.depth + 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_id = s2.next_id and e0.id != all (s2.path) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 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), s3 as (select s1.e0 as e0, s1.ep0 as ep0, 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 [1]::int2[] and n2.id = e1.end_id) select array_remove(coalesce(array_agg(((s3.n1).properties ->> 'name'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s3), s4 as (with recursive s5_seed(root_id) as not materialized (select n4.id as root_id from s0, node n4 where n4.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n4.properties ->> 'name') = any (s0.i0))), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.end_id, e2.start_id, 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.end_id = e2.start_id, array [e2.id] from s5_seed join edge e2 on e2.end_id = s5_seed.root_id join node n3 on n3.id = e2.start_id union select s5.root_id, e2.start_id, s5.depth + 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e2.id || s5.path from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.end_id = s5.next_id and e2.id != all (s5.path) offset 0) e2 on true join node n3 on n3.id = e2.start_id where s5.depth < 15 and not s5.is_cycle) select (select coalesce(array_agg((e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s5.path) with ordinality as _path(id, ordinality) join edge e2 on e2.id = _path.id) as e2, s5.path as ep1, 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, s5 join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.root_id offset 0) n4 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.next_id offset 0) n3 on true where s5.satisfied) select ordered_edges_to_path(s4.n3, s4.e2, array [s4.n3, s4.n4]::nodecomposite[])::pathcomposite as p from s4; +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.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id union all select s2.root_id, e0.end_id, s2.depth + 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_id = s2.next_id and e0.id != all (s2.path) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select (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), s3 as (select s1.n0 as n0, s1.n1 as n1 from s1 join edge e1 on (s1.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id) select array_remove(coalesce(array_agg(((s3.n1).properties ->> 'name'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s3), s4 as (with recursive s5_seed(root_id) as not materialized (select n4.id as root_id from s0, node n4 where n4.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n4.properties ->> 'name') = any (s0.i0))), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.end_id, e2.start_id, 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.end_id = e2.start_id, array [e2.id] from s5_seed join edge e2 on e2.end_id = s5_seed.root_id join node n3 on n3.id = e2.start_id union select s5.root_id, e2.start_id, s5.depth + 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e2.id || s5.path from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.end_id = s5.next_id and e2.id != all (s5.path) offset 0) e2 on true join node n3 on n3.id = e2.start_id where s5.depth < 15 and not s5.is_cycle) select s5.path as ep1, 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, s5 join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.root_id offset 0) n4 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.next_id offset 0) n3 on true where s5.satisfied) select 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(s4.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n3, s4.n4]::nodecomposite[])::pathcomposite as p from s4; -- case: match (m:NodeKind1)-[:EdgeKind1*1..]->(g:NodeKind2)-[:EdgeKind2]->(c3:NodeKind1) where m.samaccountname =~ '^[A-Z]{1,3}[0-9]{1,3}$' and not m.samaccountname contains "DEX" and not g.name IN ["D"] and not m.samaccountname =~ "^.*$" with collect(g.name) as admingroups match p=(m:NodeKind1)-[:EdgeKind1*1..]->(g:NodeKind2) where m.samaccountname =~ '^[A-Z]{1,3}[0-9]{1,3}$' and g.name in admingroups and not m.samaccountname =~ "^.*$" 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 ->> 'samaccountname') ~ '^[A-Z]{1,3}[0-9]{1,3}$' and not coalesce((n0.properties ->> 'samaccountname'), '')::text like '%DEX%' and not (n0.properties ->> 'samaccountname') ~ '^.*$') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (not (n1.properties ->> 'name') = any (array ['D']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, (not (n1.properties ->> 'name') = any (array ['D']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 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), s3 as (select s1.e0 as e0, s1.ep0 as ep0, 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 [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[])) select array_remove(coalesce(array_agg(((s3.n1).properties ->> 'name'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s3), s4 as (with recursive s5_seed(root_id) as not materialized (select n4.id as root_id from s0, node n4 where n4.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n4.properties ->> 'name') = any (s0.i0))), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.end_id, e2.start_id, 1, ((n3.properties ->> 'samaccountname') ~ '^[A-Z]{1,3}[0-9]{1,3}$' and not (n3.properties ->> 'samaccountname') ~ '^.*$') and n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.end_id = e2.start_id, array [e2.id] from s5_seed join edge e2 on e2.end_id = s5_seed.root_id join node n3 on n3.id = e2.start_id where e2.kind_id = any (array [3]::int2[]) union select s5.root_id, e2.start_id, s5.depth + 1, ((n3.properties ->> 'samaccountname') ~ '^[A-Z]{1,3}[0-9]{1,3}$' and not (n3.properties ->> 'samaccountname') ~ '^.*$') and n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e2.id || s5.path from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.end_id = s5.next_id and e2.id != all (s5.path) and e2.kind_id = any (array [3]::int2[]) offset 0) e2 on true join node n3 on n3.id = e2.start_id where s5.depth < 15 and not s5.is_cycle) select (select coalesce(array_agg((e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s5.path) with ordinality as _path(id, ordinality) join edge e2 on e2.id = _path.id) as e2, s5.path as ep1, 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, s5 join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.root_id offset 0) n4 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.next_id offset 0) n3 on true where s5.satisfied) select ordered_edges_to_path(s4.n3, s4.e2, array [s4.n3, s4.n4]::nodecomposite[])::pathcomposite as p from s4; +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 ->> 'samaccountname') ~ '^[A-Z]{1,3}[0-9]{1,3}$' and not coalesce((n0.properties ->> 'samaccountname'), '')::text like '%DEX%' and not (n0.properties ->> 'samaccountname') ~ '^.*$') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (not (n1.properties ->> 'name') = any (array ['D']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, (not (n1.properties ->> 'name') = any (array ['D']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select (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), s3 as (select s1.n0 as n0, s1.n1 as n1 from s1 join edge e1 on (s1.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[])) select array_remove(coalesce(array_agg(((s3.n1).properties ->> 'name'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s3), s4 as (with recursive s5_seed(root_id) as not materialized (select n4.id as root_id from s0, node n4 where n4.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n4.properties ->> 'name') = any (s0.i0))), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.end_id, e2.start_id, 1, ((n3.properties ->> 'samaccountname') ~ '^[A-Z]{1,3}[0-9]{1,3}$' and not (n3.properties ->> 'samaccountname') ~ '^.*$') and n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.end_id = e2.start_id, array [e2.id] from s5_seed join edge e2 on e2.end_id = s5_seed.root_id join node n3 on n3.id = e2.start_id where e2.kind_id = any (array [3]::int2[]) union select s5.root_id, e2.start_id, s5.depth + 1, ((n3.properties ->> 'samaccountname') ~ '^[A-Z]{1,3}[0-9]{1,3}$' and not (n3.properties ->> 'samaccountname') ~ '^.*$') and n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e2.id || s5.path from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.end_id = s5.next_id and e2.id != all (s5.path) and e2.kind_id = any (array [3]::int2[]) offset 0) e2 on true join node n3 on n3.id = e2.start_id where s5.depth < 15 and not s5.is_cycle) select s5.path as ep1, 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, s5 join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.root_id offset 0) n4 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.next_id offset 0) n3 on true where s5.satisfied) select 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(s4.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n3, s4.n4]::nodecomposite[])::pathcomposite as p from s4; -- case: match (a:NodeKind2)-[:EdgeKind1]->(g:NodeKind1)-[:EdgeKind2]->(s:NodeKind2) with count(a) as uc where uc > 5 match p = (a)-[:EdgeKind1]->(g)-[:EdgeKind2]->(s) return p with s0 as (with s1 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 [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.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[])) select count(s2.n0)::int8 as i0 from s2), s3 as (select (e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties)::edgecomposite 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, e3.start_id, e3.end_id, e3.kind_id, e3.properties)::edgecomposite 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[])) select (array [s4.n3, s4.n4, s4.n5]::nodecomposite[], array [s4.e2, s4.e3]::edgecomposite[])::pathcomposite 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 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 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); - diff --git a/cypher/models/pgsql/test/translation_cases/pattern_binding.sql b/cypher/models/pgsql/test/translation_cases/pattern_binding.sql index 5b06f866..17ea94e4 100644 --- a/cypher/models/pgsql/test/translation_cases/pattern_binding.sql +++ b/cypher/models/pgsql/test/translation_cases/pattern_binding.sql @@ -27,7 +27,7 @@ with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::e 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).nodes)::nodecomposite[] 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); +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 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, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 where (((select count(*)::int from unnest(((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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id))::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; @@ -42,13 +42,13 @@ with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::e 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.properties -> 'name'))::jsonb = to_jsonb(('value')::text)::jsonb) and n0.id = e0.start_id join node n1 on n1.id = e0.end_id), s1 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.end_id join node n2 on (((n2.properties ->> 'is_target'))::bool) and n2.id = e1.start_id) select (array [s1.n0, s1.n1, s1.n2]::nodecomposite[], array [s1.e0, s1.e1]::edgecomposite[])::pathcomposite as p from s1; -- case: match p = ()-[*..]->() return p limit 1 -with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, false, e0.start_id = e0.end_id, array [e0.id] from edge e0 union all select s1.root_id, e0.end_id, s1.depth + 1, false, 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) offset 0) e0 on true where s1.depth < 15 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 limit 1) select ordered_edges_to_path(s0.n0, s0.e0, array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 limit 1; +with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, false, e0.start_id = e0.end_id, array [e0.id] from edge e0 union all select s1.root_id, e0.end_id, s1.depth + 1, false, 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) offset 0) e0 on true where s1.depth < 15 and not s1.is_cycle) select 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 limit 1) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 limit 1; -- case: match p = (s)-[*..]->(i)-[]->() where id(s) = 1 and i.name = 'n3' return p limit 1 -with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (n0.id = 1)), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n3')::text)::jsonb), 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 union all select s1.root_id, e0.end_id, s1.depth + 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n3')::text)::jsonb), 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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 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), s2 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.ep0 as ep0, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id limit 1) select ordered_edges_to_path(s2.n0, s2.e0 || array [s2.e1]::edgecomposite[], array [s2.n0, s2.n1, s2.n2]::nodecomposite[])::pathcomposite as p from s2 limit 1; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (n0.id = 1)), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n3')::text)::jsonb), 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 union all select s1.root_id, e0.end_id, s1.depth + 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n3')::text)::jsonb), 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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select 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), s2 as (select (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.ep0 as ep0, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id limit 1) select ordered_edges_to_path(s2.n0, (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(s2.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id) || array [s2.e1]::edgecomposite[], array [s2.n0, s2.n1, s2.n2]::nodecomposite[])::pathcomposite as p from s2 limit 1; -- case: match p = ()-[e:EdgeKind1]->()-[:EdgeKind1*..]->() return e, 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 where e0.kind_id = any (array [3]::int2[])), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.start_id, e1.end_id, 1, false, e1.start_id = e1.end_id, array [e1.id] from s2_seed join edge e1 on e1.start_id = s2_seed.root_id where e1.kind_id = any (array [3]::int2[]) union all select s2.root_id, e1.end_id, s2.depth + 1, false, false, s2.path || e1.id from s2 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.start_id = s2.next_id and e1.id != all (s2.path) and e1.kind_id = any (array [3]::int2[]) offset 0) e1 on true where s2.depth < 15 and not s2.is_cycle) select s0.e0 as e0, (select coalesce(array_agg((e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s2.path) with ordinality as _path(id, ordinality) join edge e1 on e1.id = _path.id) as e1, s2.path as ep0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s2.next_id offset 0) n2 on true where (s0.n1).id = s2.root_id) select s1.e0 as e, ordered_edges_to_path(s1.n0, array [s1.e0]::edgecomposite[] || s1.e1, array [s1.n0, s1.n1, s1.n2]::nodecomposite[])::pathcomposite as p from s1; +with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (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 where e0.kind_id = any (array [3]::int2[])), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.start_id, e1.end_id, 1, false, e1.start_id = e1.end_id, array [e1.id] from s2_seed join edge e1 on e1.start_id = s2_seed.root_id where e1.kind_id = any (array [3]::int2[]) union all select s2.root_id, e1.end_id, s2.depth + 1, false, false, s2.path || e1.id from s2 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.start_id = s2.next_id and e1.id != all (s2.path) and e1.kind_id = any (array [3]::int2[]) offset 0) e1 on true where s2.depth < 15 and not s2.is_cycle) select s0.e0 as e0, s2.path as ep0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s2.next_id offset 0) n2 on true where (s0.n1).id = s2.root_id) select s1.e0 as e, ordered_edges_to_path(s1.n0, array [s1.e0]::edgecomposite[] || (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(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1, s1.n2]::nodecomposite[])::pathcomposite as p from s1; -- case: match p = (m:NodeKind1)-[:EdgeKind1]->(c:NodeKind2) where m.objectid ends with "-513" and not toUpper(c.operatingsystem) contains "SERVER" return p limit 1000 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.properties ->> 'objectid') like '%-513') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id = e0.start_id join node n1 on (not upper((n1.properties ->> 'operatingsystem'))::text like '%SERVER%') and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) limit 1000) select (array [s0.n0, s0.n1]::nodecomposite[], array [s0.e0]::edgecomposite[])::pathcomposite as p from s0 limit 1000; @@ -60,10 +60,10 @@ with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::e 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.end_id or n0.id = e0.start_id) join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (n1.id = e0.end_id or n1.id = e0.start_id) where (n0.id <> n1.id)) select (array [s0.n0, s0.n1]::nodecomposite[], array [s0.e0]::edgecomposite[])::pathcomposite as p from s0; -- case: match p = (:NodeKind1)-[:EdgeKind1]->(:NodeKind2)-[:EdgeKind2*1..]->(t:NodeKind2) where coalesce(t.system_tags, '') contains 'admin_tier_0' return p limit 1000 -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 [2]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[])), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.start_id, e1.end_id, 1, (coalesce((n2.properties ->> 'system_tags'), '')::text like '%admin_tier_0%') and n2.kind_ids operator (pg_catalog.@>) array [2]::int2[], e1.start_id = e1.end_id, array [e1.id] from s2_seed join edge e1 on e1.start_id = s2_seed.root_id join node n2 on n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[]) union all select s2.root_id, e1.end_id, s2.depth + 1, (coalesce((n2.properties ->> 'system_tags'), '')::text like '%admin_tier_0%') and n2.kind_ids operator (pg_catalog.@>) array [2]::int2[], false, s2.path || e1.id from s2 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.start_id = s2.next_id and e1.id != all (s2.path) and e1.kind_id = any (array [4]::int2[]) offset 0) e1 on true join node n2 on n2.id = e1.end_id where s2.depth < 15 and not s2.is_cycle) select s0.e0 as e0, (select coalesce(array_agg((e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s2.path) with ordinality as _path(id, ordinality) join edge e1 on e1.id = _path.id) as e1, s2.path as ep0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s2.next_id offset 0) n2 on true where s2.satisfied and (s0.n1).id = s2.root_id limit 1000) select ordered_edges_to_path(s1.n0, array [s1.e0]::edgecomposite[] || s1.e1, array [s1.n0, s1.n1, s1.n2]::nodecomposite[])::pathcomposite as p from s1 limit 1000; +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 [2]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[])), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.start_id, e1.end_id, 1, (coalesce((n2.properties ->> 'system_tags'), '')::text like '%admin_tier_0%') and n2.kind_ids operator (pg_catalog.@>) array [2]::int2[], e1.start_id = e1.end_id, array [e1.id] from s2_seed join edge e1 on e1.start_id = s2_seed.root_id join node n2 on n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[]) union all select s2.root_id, e1.end_id, s2.depth + 1, (coalesce((n2.properties ->> 'system_tags'), '')::text like '%admin_tier_0%') and n2.kind_ids operator (pg_catalog.@>) array [2]::int2[], false, s2.path || e1.id from s2 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.start_id = s2.next_id and e1.id != all (s2.path) and e1.kind_id = any (array [4]::int2[]) offset 0) e1 on true join node n2 on n2.id = e1.end_id where s2.depth < 15 and not s2.is_cycle) select s0.e0 as e0, s2.path as ep0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s2.next_id offset 0) n2 on true where s2.satisfied and (s0.n1).id = s2.root_id limit 1000) select ordered_edges_to_path(s1.n0, array [s1.e0]::edgecomposite[] || (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(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1, s1.n2]::nodecomposite[])::pathcomposite as p from s1 limit 1000; -- case: match (u:NodeKind1) where u.samaccountname in ["foo", "bar"] match p = (u)-[:EdgeKind1|EdgeKind2*1..3]->(t) where coalesce(t.system_tags, '') contains 'admin_tier_0' return p limit 1000 -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((n0.properties ->> 'samaccountname') = any (array ['foo', 'bar']::text[])) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n0).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (coalesce((n1.properties ->> 'system_tags'), '')::text like '%admin_tier_0%'), e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, (coalesce((n1.properties ->> 'system_tags'), '')::text like '%admin_tier_0%'), 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.start_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.end_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 s0, 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 and (s0.n0).id = s2.root_id) select ordered_edges_to_path(s1.n0, s1.e0, array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite as p from s1 limit 1000; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((n0.properties ->> 'samaccountname') = any (array ['foo', 'bar']::text[])) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n0).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (coalesce((n1.properties ->> 'system_tags'), '')::text like '%admin_tier_0%'), e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, (coalesce((n1.properties ->> 'system_tags'), '')::text like '%admin_tier_0%'), 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.start_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.end_id where s2.depth < 3 and not s2.is_cycle) select 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 s0, 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 and (s0.n0).id = s2.root_id) select ordered_edges_to_path(s1.n0, (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(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite as p from s1 limit 1000; -- case: match (x:NodeKind1) where x.name = 'foo' match (y:NodeKind2) where y.name = 'bar' match p=(x)-[:EdgeKind1]->(y) return p with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('foo')::text)::jsonb) and 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 -> 'name'))::jsonb = to_jsonb(('bar')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s2 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, s1.n0 as n0, s1.n1 as n1 from s1 join edge e0 on (s1.n0).id = e0.start_id join node n1 on (s1.n1).id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select (array [s2.n0, s2.n1]::nodecomposite[], array [s2.e0]::edgecomposite[])::pathcomposite as p from s2; @@ -81,13 +81,13 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from 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), s1 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n0).id = e1.end_id join node n2 on n2.id = e1.start_id) select (array [s1.n0, s1.n1]::nodecomposite[], array [s1.e0]::edgecomposite[])::pathcomposite as p, (array [s1.n2, s1.n0]::nodecomposite[], array [s1.e1]::edgecomposite[])::pathcomposite as q from s1; -- case: match (m:NodeKind1)-[*1..]->(g:NodeKind2)-[]->(c3:NodeKind1) where not g.name in ["foo"] with collect(g.name) as bar match p=(m:NodeKind1)-[*1..]->(g:NodeKind2) where g.name in bar 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.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id union all select s2.root_id, e0.end_id, s2.depth + 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_id = s2.next_id and e0.id != all (s2.path) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 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), s3 as (select s1.e0 as e0, s1.ep0 as ep0, 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 [1]::int2[] and n2.id = e1.end_id) select array_remove(coalesce(array_agg(((s3.n1).properties ->> 'name'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s3), s4 as (with recursive s5_seed(root_id) as not materialized (select n4.id as root_id from s0, node n4 where n4.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n4.properties ->> 'name') = any (s0.i0))), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.end_id, e2.start_id, 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.end_id = e2.start_id, array [e2.id] from s5_seed join edge e2 on e2.end_id = s5_seed.root_id join node n3 on n3.id = e2.start_id union select s5.root_id, e2.start_id, s5.depth + 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e2.id || s5.path from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.end_id = s5.next_id and e2.id != all (s5.path) offset 0) e2 on true join node n3 on n3.id = e2.start_id where s5.depth < 15 and not s5.is_cycle) select (select coalesce(array_agg((e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s5.path) with ordinality as _path(id, ordinality) join edge e2 on e2.id = _path.id) as e2, s5.path as ep1, 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, s5 join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.root_id offset 0) n4 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.next_id offset 0) n3 on true where s5.satisfied) select ordered_edges_to_path(s4.n3, s4.e2, array [s4.n3, s4.n4]::nodecomposite[])::pathcomposite as p from s4; +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.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id union all select s2.root_id, e0.end_id, s2.depth + 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_id = s2.next_id and e0.id != all (s2.path) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select (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), s3 as (select s1.n0 as n0, s1.n1 as n1 from s1 join edge e1 on (s1.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id) select array_remove(coalesce(array_agg(((s3.n1).properties ->> 'name'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s3), s4 as (with recursive s5_seed(root_id) as not materialized (select n4.id as root_id from s0, node n4 where n4.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n4.properties ->> 'name') = any (s0.i0))), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.end_id, e2.start_id, 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.end_id = e2.start_id, array [e2.id] from s5_seed join edge e2 on e2.end_id = s5_seed.root_id join node n3 on n3.id = e2.start_id union select s5.root_id, e2.start_id, s5.depth + 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e2.id || s5.path from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.end_id = s5.next_id and e2.id != all (s5.path) offset 0) e2 on true join node n3 on n3.id = e2.start_id where s5.depth < 15 and not s5.is_cycle) select s5.path as ep1, 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, s5 join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.root_id offset 0) n4 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.next_id offset 0) n3 on true where s5.satisfied) select 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(s4.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n3, s4.n4]::nodecomposite[])::pathcomposite as p from s4; -- case: MATCH p=(:Computer)-[r:HasSession]->(:User) WHERE r.lastseen >= datetime() - duration('P3D') 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 [5]::int2[] and n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [6]::int2[] and n1.id = e0.end_id where (((e0.properties ->> 'lastseen'))::timestamp with time zone >= now()::timestamp with time zone - interval 'P3D') and e0.kind_id = any (array [7]::int2[]) limit 100) select (array [s0.n0, s0.n1]::nodecomposite[], array [s0.e0]::edgecomposite[])::pathcomposite as p from s0 limit 100; -- case: MATCH p=(:GPO)-[r:GPLink|Contains*1..]->(:Base) WHERE HEAD(r).enforced OR NONE(n in TAIL(TAIL(NODES(p))) WHERE (n:OU AND n.blocksinheritance)) 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 [8]::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 [10]::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 [11, 12]::int2[]) union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [10]::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 [11, 12]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 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 (((((s0.e0)[1]).properties ->> 'enforced'))::bool or ((select count(*)::int from unnest(coalesce((coalesce((((ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, s0.e0, array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite).nodes)::nodecomposite[])[2:cardinality(((ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, s0.e0, array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite).nodes)::nodecomposite[])::int], array []::nodecomposite[])::nodecomposite[])[2:cardinality(coalesce((((ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, s0.e0, array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite).nodes)::nodecomposite[])[2:cardinality(((ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, s0.e0, array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite).nodes)::nodecomposite[])::int], array []::nodecomposite[])::nodecomposite[])::int], array []::nodecomposite[])::nodecomposite[]) as i0 where ((i0.kind_ids operator (pg_catalog.@>) array [9]::int2[] and ((i0.properties ->> 'blocksinheritance'))::bool))) = 0 and coalesce((coalesce((((ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, s0.e0, array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite).nodes)::nodecomposite[])[2:cardinality(((ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, s0.e0, array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite).nodes)::nodecomposite[])::int], array []::nodecomposite[])::nodecomposite[])[2:cardinality(coalesce((((ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, s0.e0, array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite).nodes)::nodecomposite[])[2:cardinality(((ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, s0.e0, array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite).nodes)::nodecomposite[])::int], array []::nodecomposite[])::nodecomposite[])::int], array []::nodecomposite[])::nodecomposite[] is not null)::bool); +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 [8]::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 [10]::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 [11, 12]::int2[]) union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [10]::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 [11, 12]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (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(s1.path) with ordinality as _path(id, ordinality) join edge _edge on _edge.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, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 where (((((s0.e0)[1]).properties ->> 'enforced'))::bool or ((select count(*)::int from unnest(coalesce((coalesce((((ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite).nodes)::nodecomposite[])[2:cardinality(((ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite).nodes)::nodecomposite[])::int], array []::nodecomposite[])::nodecomposite[])[2:cardinality(coalesce((((ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite).nodes)::nodecomposite[])[2:cardinality(((ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite).nodes)::nodecomposite[])::int], array []::nodecomposite[])::nodecomposite[])::int], array []::nodecomposite[])::nodecomposite[]) as i0 where ((i0.kind_ids operator (pg_catalog.@>) array [9]::int2[] and ((i0.properties ->> 'blocksinheritance'))::bool))) = 0 and coalesce((coalesce((((ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite).nodes)::nodecomposite[])[2:cardinality(((ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite).nodes)::nodecomposite[])::int], array []::nodecomposite[])::nodecomposite[])[2:cardinality(coalesce((((ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite).nodes)::nodecomposite[])[2:cardinality(((ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite).nodes)::nodecomposite[])::int], array []::nodecomposite[])::nodecomposite[])::int], array []::nodecomposite[])::nodecomposite[] is not null)::bool); -- case: MATCH p=(:GPO)-[r:GPLink|Contains*1..]->(:Base) WHERE NONE(x in TAIL(r) WHERE NOT type(x) = 'Contains') 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 [8]::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 [10]::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 [11, 12]::int2[]) union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [10]::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 [11, 12]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 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(coalesce((s0.e0)[2:cardinality(s0.e0)::int], array []::edgecomposite[])::edgecomposite[]) as i0 where (not i0.kind_id = 12)) = 0 and coalesce((s0.e0)[2:cardinality(s0.e0)::int], array []::edgecomposite[])::edgecomposite[] is not null)::bool); +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 [8]::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 [10]::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 [11, 12]::int2[]) union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [10]::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 [11, 12]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (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(s1.path) with ordinality as _path(id, ordinality) join edge _edge on _edge.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, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 where (((select count(*)::int from unnest(coalesce((s0.e0)[2:cardinality(s0.e0)::int], array []::edgecomposite[])::edgecomposite[]) as i0 where (not i0.kind_id = 12)) = 0 and coalesce((s0.e0)[2:cardinality(s0.e0)::int], array []::edgecomposite[])::edgecomposite[] is not null)::bool); diff --git a/cypher/models/pgsql/test/translation_cases/pattern_expansion.sql b/cypher/models/pgsql/test/translation_cases/pattern_expansion.sql index 06878fc5..edf476fc 100644 --- a/cypher/models/pgsql/test/translation_cases/pattern_expansion.sql +++ b/cypher/models/pgsql/test/translation_cases/pattern_expansion.sql @@ -15,70 +15,70 @@ -- SPDX-License-Identifier: Apache-2.0 -- case: match (n)-[*..]->(e) return n, e -with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, false, e0.start_id = e0.end_id, array [e0.id] from edge e0 union all select s1.root_id, e0.end_id, s1.depth + 1, false, 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) offset 0) e0 on true where s1.depth < 15 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) select s0.n0 as n, s0.n1 as e from s0; +with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, false, e0.start_id = e0.end_id, array [e0.id] from edge e0 union all select s1.root_id, e0.end_id, s1.depth + 1, false, 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) offset 0) e0 on true where s1.depth < 15 and not s1.is_cycle) select (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) select s0.n0 as n, s0.n1 as e from s0; -- case: match (n)-[*1..2]->(e) return n, e -with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, false, e0.start_id = e0.end_id, array [e0.id] from edge e0 union all select s1.root_id, e0.end_id, s1.depth + 1, false, 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) offset 0) e0 on true where s1.depth < 2 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) select s0.n0 as n, s0.n1 as e from s0; +with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, false, e0.start_id = e0.end_id, array [e0.id] from edge e0 union all select s1.root_id, e0.end_id, s1.depth + 1, false, 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) offset 0) e0 on true where s1.depth < 2 and not s1.is_cycle) select (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) select s0.n0 as n, s0.n1 as e from s0; -- case: match (n)-[*3..5]->(e) return n, e -with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, false, e0.start_id = e0.end_id, array [e0.id] from edge e0 union all select s1.root_id, e0.end_id, s1.depth + 1, false, 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) offset 0) e0 on true where s1.depth < 5 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.depth >= 3) select s0.n0 as n, s0.n1 as e from s0; +with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, false, e0.start_id = e0.end_id, array [e0.id] from edge e0 union all select s1.root_id, e0.end_id, s1.depth + 1, false, 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) offset 0) e0 on true where s1.depth < 5 and not s1.is_cycle) select (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.depth >= 3) select s0.n0 as n, s0.n1 as e from s0; -- case: match (n)<-[*2..5]-(e) return n, e -with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, false, e0.end_id = e0.start_id, array [e0.id] from edge e0 union all select s1.root_id, e0.start_id, s1.depth + 1, false, 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.end_id = s1.next_id and e0.id != all (s1.path) offset 0) e0 on true where s1.depth < 5 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.depth >= 2) select s0.n0 as n, s0.n1 as e from s0; +with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, false, e0.end_id = e0.start_id, array [e0.id] from edge e0 union all select s1.root_id, e0.start_id, s1.depth + 1, false, 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.end_id = s1.next_id and e0.id != all (s1.path) offset 0) e0 on true where s1.depth < 5 and not s1.is_cycle) select (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.depth >= 2) select s0.n0 as n, s0.n1 as e from s0; -- case: match p = (n)-[*..]->(e:NodeKind1) return p -with s0 as (with recursive s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where n1.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, false, e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id union all select s1.root_id, e0.start_id, s1.depth + 1, false, false, e0.id || s1.path from s1 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.end_id = s1.next_id and e0.id != all (s1.path) offset 0) e0 on true where s1.depth < 15 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 n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s1.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s1.next_id offset 0) n0 on true) select ordered_edges_to_path(s0.n0, s0.e0, array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where n1.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, false, e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id union all select s1.root_id, e0.start_id, s1.depth + 1, false, false, e0.id || s1.path from s1 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.end_id = s1.next_id and e0.id != all (s1.path) offset 0) e0 on true where s1.depth < 15 and not s1.is_cycle) select 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 n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s1.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s1.next_id offset 0) n0 on true) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0; -- case: match (n)-[*..]->(e:NodeKind1) where n.name = 'n1' return e -with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb)), 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 [1]::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 union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 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 s0.n1 as e from s0; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb)), 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 [1]::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 union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (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 s0.n1 as e from s0; -- case: match (n)-[*..]->(e:NodeKind1) where n.name = 'n2' return n -with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n2')::text)::jsonb)), 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 [1]::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 union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 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 s0.n0 as n from s0; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n2')::text)::jsonb)), 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 [1]::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 union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (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 s0.n0 as n from s0; -- case: match (n)-[*..]->(e:NodeKind1)-[]->(l) where n.name = 'n1' return l -with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb)), 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 [1]::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 union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 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), s2 as (select s0.e0 as e0, s0.ep0 as ep0, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id) select s2.n2 as l from s2; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb)), 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 [1]::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 union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (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), s2 as (select s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id) select s2.n2 as l from s2; -- case: match (n)-[*2..3]->(e:NodeKind1)-[]->(l) where n.name = 'n1' return l -with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb)), 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 [1]::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 union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 3 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.depth >= 2 and s1.satisfied), s2 as (select s0.e0 as e0, s0.ep0 as ep0, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id) select s2.n2 as l from s2; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb)), 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 [1]::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 union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 3 and not s1.is_cycle) select (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.depth >= 2 and s1.satisfied), s2 as (select s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id) select s2.n2 as l from s2; -- case: match (n)-[]->(e:NodeKind1)-[*2..3]->(l) where n.name = 'n1' return l -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.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb) 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), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.start_id, e1.end_id, 1, false, e1.start_id = e1.end_id, array [e1.id] from s2_seed join edge e1 on e1.start_id = s2_seed.root_id union all select s2.root_id, e1.end_id, s2.depth + 1, false, false, s2.path || e1.id from s2 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.start_id = s2.next_id and e1.id != all (s2.path) offset 0) e1 on true where s2.depth < 3 and not s2.is_cycle) select (select coalesce(array_agg((e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s2.path) with ordinality as _path(id, ordinality) join edge e1 on e1.id = _path.id) as e1, s2.path as ep0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s2.next_id offset 0) n2 on true where s2.depth >= 2 and (s0.n1).id = s2.root_id) select s1.n2 as l from s1; +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.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb) 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), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.start_id, e1.end_id, 1, false, e1.start_id = e1.end_id, array [e1.id] from s2_seed join edge e1 on e1.start_id = s2_seed.root_id union all select s2.root_id, e1.end_id, s2.depth + 1, false, false, s2.path || e1.id from s2 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.start_id = s2.next_id and e1.id != all (s2.path) offset 0) e1 on true where s2.depth < 3 and not s2.is_cycle) select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s2.next_id offset 0) n2 on true where s2.depth >= 2 and (s0.n1).id = s2.root_id) select s1.n2 as l from s1; -- case: match (n)-[*..]->(e)-[:EdgeKind1|EdgeKind2]->()-[*..]->(l) where n.name = 'n1' and e.name = 'n2' return l -with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb)), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n2')::text)::jsonb), 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 union all select s1.root_id, e0.end_id, s1.depth + 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n2')::text)::jsonb), 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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 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), s2 as (select s0.e0 as e0, s0.ep0 as ep0, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.kind_id = any (array [3, 4]::int2[])), s3 as (with recursive s4_seed(root_id) as not materialized (select distinct (s2.n2).id as root_id from s2), s4(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, false, e2.start_id = e2.end_id, array [e2.id] from s4_seed join edge e2 on e2.start_id = s4_seed.root_id union all select s4.root_id, e2.end_id, s4.depth + 1, false, false, s4.path || e2.id from s4 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.start_id = s4.next_id and e2.id != all (s4.path) offset 0) e2 on true where s4.depth < 15 and not s4.is_cycle) select s2.e0 as e0, (select coalesce(array_agg((e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s4.path) with ordinality as _path(id, ordinality) join edge e2 on e2.id = _path.id) as e2, s2.ep0 as ep0, s4.path as ep1, s2.n0 as n0, s2.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s2, s4 join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s4.root_id offset 0) n2 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s4.next_id offset 0) n3 on true where (s2.n2).id = s4.root_id) select s3.n3 as l from s3; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb)), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n2')::text)::jsonb), 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 union all select s1.root_id, e0.end_id, s1.depth + 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n2')::text)::jsonb), 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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (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), s2 as (select s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.kind_id = any (array [3, 4]::int2[])), s3 as (with recursive s4_seed(root_id) as not materialized (select distinct (s2.n2).id as root_id from s2), s4(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, false, e2.start_id = e2.end_id, array [e2.id] from s4_seed join edge e2 on e2.start_id = s4_seed.root_id union all select s4.root_id, e2.end_id, s4.depth + 1, false, false, s4.path || e2.id from s4 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.start_id = s4.next_id and e2.id != all (s4.path) offset 0) e2 on true where s4.depth < 15 and not s4.is_cycle) select s2.n0 as n0, s2.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s2, s4 join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s4.root_id offset 0) n2 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s4.next_id offset 0) n3 on true where (s2.n2).id = s4.root_id) select s3.n3 as l from s3; -- case: match p = (:NodeKind1)-[:EdgeKind1*1..]->(n:NodeKind2) where 'admin_tier_0' in split(n.system_tags, ' ') return p limit 1000 -with s0 as (with recursive s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where ('admin_tier_0' = any (string_to_array((n1.properties ->> 'system_tags'), ' ')::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, n0.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id join node n0 on n0.id = e0.start_id where e0.kind_id = any (array [3]::int2[]) union all select s1.root_id, e0.start_id, s1.depth + 1, n0.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e0.id || s1.path from s1 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.end_id = s1.next_id and e0.id != all (s1.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n0 on n0.id = e0.start_id where s1.depth < 15 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 n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s1.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s1.next_id offset 0) n0 on true where s1.satisfied limit 1000) select ordered_edges_to_path(s0.n0, s0.e0, array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 limit 1000; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where ('admin_tier_0' = any (string_to_array((n1.properties ->> 'system_tags'), ' ')::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, n0.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id join node n0 on n0.id = e0.start_id where e0.kind_id = any (array [3]::int2[]) union all select s1.root_id, e0.start_id, s1.depth + 1, n0.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e0.id || s1.path from s1 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.end_id = s1.next_id and e0.id != all (s1.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n0 on n0.id = e0.start_id where s1.depth < 15 and not s1.is_cycle) select 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 n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s1.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s1.next_id offset 0) n0 on true where s1.satisfied limit 1000) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 limit 1000; -- case: match p = (s:NodeKind1)-[*..]->(e:NodeKind2) where s <> e 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 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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 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 and (n0.id <> n1.id)) select ordered_edges_to_path(s0.n0, s0.e0, array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0; +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 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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select 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 and (n0.id <> n1.id)) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0; -- case: match p = (g:NodeKind1)-[:EdgeKind1|EdgeKind2*]->(target:NodeKind1) where g.objectid ends with '1234' and target.objectid ends with '4567' 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.properties ->> 'objectid') like '%1234') and 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.properties ->> 'objectid') like '%4567') and n1.kind_ids operator (pg_catalog.@>) array [1]::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.properties ->> 'objectid') like '%4567') and n1.kind_ids operator (pg_catalog.@>) array [1]::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 < 15 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; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where ((n0.properties ->> 'objectid') like '%1234') and 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.properties ->> 'objectid') like '%4567') and n1.kind_ids operator (pg_catalog.@>) array [1]::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.properties ->> 'objectid') like '%4567') and n1.kind_ids operator (pg_catalog.@>) array [1]::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 < 15 and not s1.is_cycle) select 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, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0; -- case: match p = (m:NodeKind2)-[:EdgeKind1*1..]->(n:NodeKind1) where n.objectid = '1234' return p limit 10 -with s0 as (with recursive s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where (((n1.properties -> 'objectid'))::jsonb = to_jsonb(('1234')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, n0.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id join node n0 on n0.id = e0.start_id where e0.kind_id = any (array [3]::int2[]) union all select s1.root_id, e0.start_id, s1.depth + 1, n0.kind_ids operator (pg_catalog.@>) array [2]::int2[], false, e0.id || s1.path from s1 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.end_id = s1.next_id and e0.id != all (s1.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n0 on n0.id = e0.start_id where s1.depth < 15 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 n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s1.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s1.next_id offset 0) n0 on true where s1.satisfied limit 10) select ordered_edges_to_path(s0.n0, s0.e0, array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 limit 10; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where (((n1.properties -> 'objectid'))::jsonb = to_jsonb(('1234')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, n0.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id join node n0 on n0.id = e0.start_id where e0.kind_id = any (array [3]::int2[]) union all select s1.root_id, e0.start_id, s1.depth + 1, n0.kind_ids operator (pg_catalog.@>) array [2]::int2[], false, e0.id || s1.path from s1 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.end_id = s1.next_id and e0.id != all (s1.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n0 on n0.id = e0.start_id where s1.depth < 15 and not s1.is_cycle) select 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 n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s1.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s1.next_id offset 0) n0 on true where s1.satisfied limit 10) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 limit 10; -- case: match p = (:NodeKind1)<-[:EdgeKind1|EdgeKind2*..]-() return p limit 10 -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.end_id, e0.start_id, 1, false, e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s1.root_id, e0.start_id, s1.depth + 1, false, 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.end_id = s1.next_id and e0.id != all (s1.path) and e0.kind_id = any (array [3, 4]::int2[]) offset 0) e0 on true where s1.depth < 15 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 limit 10) select ordered_edges_to_path(s0.n0, s0.e0, array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 limit 10; +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.end_id, e0.start_id, 1, false, e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s1.root_id, e0.start_id, s1.depth + 1, false, 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.end_id = s1.next_id and e0.id != all (s1.path) and e0.kind_id = any (array [3, 4]::int2[]) offset 0) e0 on true where s1.depth < 15 and not s1.is_cycle) select 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 limit 10) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 limit 10; -- case: match p = (:NodeKind1)<-[:EdgeKind1|EdgeKind2*..]-(:NodeKind2)<-[:EdgeKind1|EdgeKind2*2..]-(:NodeKind1) return p limit 10 -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.end_id, e0.start_id, 1, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id join node n1 on n1.id = e0.start_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s1.root_id, e0.start_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.end_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.start_id where s1.depth < 15 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), s2 as (with recursive s3_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s3(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.end_id, e1.start_id, 1, n2.kind_ids operator (pg_catalog.@>) array [1]::int2[], e1.end_id = e1.start_id, array [e1.id] from s3_seed join edge e1 on e1.end_id = s3_seed.root_id join node n2 on n2.id = e1.start_id where e1.kind_id = any (array [3, 4]::int2[]) union all select s3.root_id, e1.start_id, s3.depth + 1, n2.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, s3.path || e1.id from s3 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.end_id = s3.next_id and e1.id != all (s3.path) and e1.kind_id = any (array [3, 4]::int2[]) offset 0) e1 on true join node n2 on n2.id = e1.start_id where s3.depth < 15 and not s3.is_cycle) select s0.e0 as e0, (select coalesce(array_agg((e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s3.path) with ordinality as _path(id, ordinality) join edge e1 on e1.id = _path.id) as e1, s0.ep0 as ep0, s3.path as ep1, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s3 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s3.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s3.next_id offset 0) n2 on true where s3.depth >= 2 and s3.satisfied and (s0.n1).id = s3.root_id limit 10) select ordered_edges_to_path(s2.n0, s2.e0 || s2.e1, array [s2.n0, s2.n1, s2.n2]::nodecomposite[])::pathcomposite as p from s2 limit 10; +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.end_id, e0.start_id, 1, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id join node n1 on n1.id = e0.start_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s1.root_id, e0.start_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.end_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.start_id where s1.depth < 15 and not s1.is_cycle) select 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), s2 as (with recursive s3_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s3(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.end_id, e1.start_id, 1, n2.kind_ids operator (pg_catalog.@>) array [1]::int2[], e1.end_id = e1.start_id, array [e1.id] from s3_seed join edge e1 on e1.end_id = s3_seed.root_id join node n2 on n2.id = e1.start_id where e1.kind_id = any (array [3, 4]::int2[]) union all select s3.root_id, e1.start_id, s3.depth + 1, n2.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, s3.path || e1.id from s3 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.end_id = s3.next_id and e1.id != all (s3.path) and e1.kind_id = any (array [3, 4]::int2[]) offset 0) e1 on true join node n2 on n2.id = e1.start_id where s3.depth < 15 and not s3.is_cycle) select s0.ep0 as ep0, s3.path as ep1, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s3 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s3.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s3.next_id offset 0) n2 on true where s3.depth >= 2 and s3.satisfied and (s0.n1).id = s3.root_id limit 10) select ordered_edges_to_path(s2.n0, (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(s2.ep0) 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(s2.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1, s2.n2]::nodecomposite[])::pathcomposite as p from s2 limit 10; -- case: match p = (:NodeKind1)<-[:EdgeKind1|EdgeKind2*..]-(:NodeKind2)<-[:EdgeKind1|EdgeKind2*..]-(:NodeKind1) return p limit 10 -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.end_id, e0.start_id, 1, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id join node n1 on n1.id = e0.start_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s1.root_id, e0.start_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.end_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.start_id where s1.depth < 15 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), s2 as (with recursive s3_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s3(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.end_id, e1.start_id, 1, n2.kind_ids operator (pg_catalog.@>) array [1]::int2[], e1.end_id = e1.start_id, array [e1.id] from s3_seed join edge e1 on e1.end_id = s3_seed.root_id join node n2 on n2.id = e1.start_id where e1.kind_id = any (array [3, 4]::int2[]) union all select s3.root_id, e1.start_id, s3.depth + 1, n2.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, s3.path || e1.id from s3 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.end_id = s3.next_id and e1.id != all (s3.path) and e1.kind_id = any (array [3, 4]::int2[]) offset 0) e1 on true join node n2 on n2.id = e1.start_id where s3.depth < 15 and not s3.is_cycle) select s0.e0 as e0, (select coalesce(array_agg((e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s3.path) with ordinality as _path(id, ordinality) join edge e1 on e1.id = _path.id) as e1, s0.ep0 as ep0, s3.path as ep1, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s3 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s3.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s3.next_id offset 0) n2 on true where s3.satisfied and (s0.n1).id = s3.root_id limit 10) select ordered_edges_to_path(s2.n0, s2.e0 || s2.e1, array [s2.n0, s2.n1, s2.n2]::nodecomposite[])::pathcomposite as p from s2 limit 10; +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.end_id, e0.start_id, 1, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id join node n1 on n1.id = e0.start_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s1.root_id, e0.start_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.end_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.start_id where s1.depth < 15 and not s1.is_cycle) select 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), s2 as (with recursive s3_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s3(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.end_id, e1.start_id, 1, n2.kind_ids operator (pg_catalog.@>) array [1]::int2[], e1.end_id = e1.start_id, array [e1.id] from s3_seed join edge e1 on e1.end_id = s3_seed.root_id join node n2 on n2.id = e1.start_id where e1.kind_id = any (array [3, 4]::int2[]) union all select s3.root_id, e1.start_id, s3.depth + 1, n2.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, s3.path || e1.id from s3 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.end_id = s3.next_id and e1.id != all (s3.path) and e1.kind_id = any (array [3, 4]::int2[]) offset 0) e1 on true join node n2 on n2.id = e1.start_id where s3.depth < 15 and not s3.is_cycle) select s0.ep0 as ep0, s3.path as ep1, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s3 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s3.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s3.next_id offset 0) n2 on true where s3.satisfied and (s0.n1).id = s3.root_id limit 10) select ordered_edges_to_path(s2.n0, (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(s2.ep0) 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(s2.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1, s2.n2]::nodecomposite[])::pathcomposite as p from s2 limit 10; -- case: match p = (n:NodeKind1)-[:EdgeKind1|EdgeKind2*1..2]->(r:NodeKind2) where r.name =~ '(?i)Global Administrator.*|User Administrator.*|Cloud Application Administrator.*|Authentication Policy Administrator.*|Exchange Administrator.*|Helpdesk Administrator.*|Privileged Authentication Administrator.*' return p limit 10 -with s0 as (with recursive s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where ((n1.properties ->> 'name') ~ '(?i)Global Administrator.*|User Administrator.*|Cloud Application Administrator.*|Authentication Policy Administrator.*|Exchange Administrator.*|Helpdesk Administrator.*|Privileged Authentication Administrator.*') and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, n0.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id join node n0 on n0.id = e0.start_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s1.root_id, e0.start_id, s1.depth + 1, n0.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e0.id || s1.path from s1 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.end_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 n0 on n0.id = e0.start_id where s1.depth < 2 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 n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s1.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s1.next_id offset 0) n0 on true where s1.satisfied limit 10) select ordered_edges_to_path(s0.n0, s0.e0, array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 limit 10; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where ((n1.properties ->> 'name') ~ '(?i)Global Administrator.*|User Administrator.*|Cloud Application Administrator.*|Authentication Policy Administrator.*|Exchange Administrator.*|Helpdesk Administrator.*|Privileged Authentication Administrator.*') and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, n0.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id join node n0 on n0.id = e0.start_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s1.root_id, e0.start_id, s1.depth + 1, n0.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e0.id || s1.path from s1 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.end_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 n0 on n0.id = e0.start_id where s1.depth < 2 and not s1.is_cycle) select 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 n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s1.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s1.next_id offset 0) n0 on true where s1.satisfied limit 10) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 limit 10; -- case: match p = (t:NodeKind2)<-[:EdgeKind1*1..]-(a) where (a:NodeKind1 or a:NodeKind2) and t.objectid ends with '-512' return p limit 1000 -with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where ((n0.properties ->> 'objectid') like '%-512') and n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s1(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[] or n1.kind_ids operator (pg_catalog.@>) array [2]::int2[])), e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id join node n1 on n1.id = e0.start_id where e0.kind_id = any (array [3]::int2[]) union all select s1.root_id, e0.start_id, s1.depth + 1, ((n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] or 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.end_id = s1.next_id and e0.id != all (s1.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.start_id where s1.depth < 15 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 limit 1000) select ordered_edges_to_path(s0.n0, s0.e0, array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 limit 1000; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where ((n0.properties ->> 'objectid') like '%-512') and n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s1(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[] or n1.kind_ids operator (pg_catalog.@>) array [2]::int2[])), e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id join node n1 on n1.id = e0.start_id where e0.kind_id = any (array [3]::int2[]) union all select s1.root_id, e0.start_id, s1.depth + 1, ((n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] or 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.end_id = s1.next_id and e0.id != all (s1.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.start_id where s1.depth < 15 and not s1.is_cycle) select 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 limit 1000) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 limit 1000; -- case: match p=(n:NodeKind1)-[:EdgeKind1|EdgeKind2]->(g:NodeKind1)-[:EdgeKind2]->(:NodeKind2)-[:EdgeKind1*1..]->(m:NodeKind1) where n.objectid = m.objectid 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.kind_id = any (array [3, 4]::int2[])), s1 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.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[])), s2 as (with recursive s3_seed(root_id) as not materialized (select distinct (s1.n2).id as root_id from s1), s3(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.start_id = e2.end_id, array [e2.id] from s3_seed join edge e2 on e2.start_id = s3_seed.root_id join node n3 on n3.id = e2.end_id where e2.kind_id = any (array [3]::int2[]) union all select s3.root_id, e2.end_id, s3.depth + 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, s3.path || e2.id from s3 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.start_id = s3.next_id and e2.id != all (s3.path) and e2.kind_id = any (array [3]::int2[]) offset 0) e2 on true join node n3 on n3.id = e2.end_id where s3.depth < 15 and not s3.is_cycle) select s1.e0 as e0, s1.e1 as e1, (select coalesce(array_agg((e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s3.path) with ordinality as _path(id, ordinality) join edge e2 on e2.id = _path.id) as e2, s3.path as ep0, s1.n0 as n0, s1.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s1, s3 join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s3.root_id offset 0) n2 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s3.next_id offset 0) n3 on true where s3.satisfied and (s1.n2).id = s3.root_id and (((s1.n0).properties -> 'objectid') = (n3.properties -> 'objectid')) limit 100) select ordered_edges_to_path(s2.n0, array [s2.e0]::edgecomposite[] || array [s2.e1]::edgecomposite[] || s2.e2, array [s2.n0, s2.n1, s2.n2, s2.n3]::nodecomposite[])::pathcomposite as p from s2 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.kind_id = any (array [3, 4]::int2[])), s1 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.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[])), s2 as (with recursive s3_seed(root_id) as not materialized (select distinct (s1.n2).id as root_id from s1), s3(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.start_id = e2.end_id, array [e2.id] from s3_seed join edge e2 on e2.start_id = s3_seed.root_id join node n3 on n3.id = e2.end_id where e2.kind_id = any (array [3]::int2[]) union all select s3.root_id, e2.end_id, s3.depth + 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, s3.path || e2.id from s3 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.start_id = s3.next_id and e2.id != all (s3.path) and e2.kind_id = any (array [3]::int2[]) offset 0) e2 on true join node n3 on n3.id = e2.end_id where s3.depth < 15 and not s3.is_cycle) select s1.e0 as e0, s1.e1 as e1, s3.path as ep0, s1.n0 as n0, s1.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s1, s3 join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s3.root_id offset 0) n2 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s3.next_id offset 0) n3 on true where s3.satisfied and (s1.n2).id = s3.root_id and (((s1.n0).properties -> 'objectid') = (n3.properties -> 'objectid')) limit 100) select ordered_edges_to_path(s2.n0, array [s2.e0]::edgecomposite[] || array [s2.e1]::edgecomposite[] || (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(s2.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1, s2.n2, s2.n3]::nodecomposite[])::pathcomposite as p from s2 limit 100; -- case: match (a:NodeKind1)-[:EdgeKind1*0..]->(b:NodeKind1) where a.name = 'solo' and b.name = 'solo' return a.name, b.name -with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('solo')::text)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select s1_seed.root_id, s1_seed.root_id, 0, (((n1.properties -> 'name'))::jsonb = to_jsonb(('solo')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, array []::int8[] from s1_seed join node n1 on n1.id = s1_seed.root_id union all select e0.start_id, e0.end_id, 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('solo')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [1]::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]::int2[]) union all select s1.root_id, e0.end_id, s1.depth + 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('solo')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [1]::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]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle and s1.depth > 0) 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 ((s0.n0).properties -> 'name'), ((s0.n1).properties -> 'name') from s0; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('solo')::text)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select s1_seed.root_id, s1_seed.root_id, 0, (((n1.properties -> 'name'))::jsonb = to_jsonb(('solo')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, array []::int8[] from s1_seed join node n1 on n1.id = s1_seed.root_id union all select e0.start_id, e0.end_id, 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('solo')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [1]::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]::int2[]) union all select s1.root_id, e0.end_id, s1.depth + 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('solo')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [1]::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]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle and s1.depth > 0) select (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 ((s0.n0).properties -> 'name'), ((s0.n1).properties -> 'name') from s0; -- case: match (a:NodeKind1)-[:EdgeKind1*0..]->(b:NodeKind1) where a.name = 'zero-source' and b.name = 'zero-target' return count(b) -with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('zero-source')::text)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select s1_seed.root_id, s1_seed.root_id, 0, (((n1.properties -> 'name'))::jsonb = to_jsonb(('zero-target')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, array []::int8[] from s1_seed join node n1 on n1.id = s1_seed.root_id union all select e0.start_id, e0.end_id, 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('zero-target')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [1]::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]::int2[]) union all select s1.root_id, e0.end_id, s1.depth + 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('zero-target')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [1]::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]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle and s1.depth > 0) 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 count(s0.n1)::int8 from s0; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('zero-source')::text)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select s1_seed.root_id, s1_seed.root_id, 0, (((n1.properties -> 'name'))::jsonb = to_jsonb(('zero-target')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, array []::int8[] from s1_seed join node n1 on n1.id = s1_seed.root_id union all select e0.start_id, e0.end_id, 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('zero-target')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [1]::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]::int2[]) union all select s1.root_id, e0.end_id, s1.depth + 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('zero-target')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [1]::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]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle and s1.depth > 0) select (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 count(s0.n1)::int8 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 928a1855..0c76efa0 100644 --- a/cypher/models/pgsql/test/translation_cases/shortest_paths.sql +++ b/cypher/models/pgsql/test/translation_cases/shortest_paths.sql @@ -16,80 +16,80 @@ -- case: match p = allShortestPaths((s:NodeKind1)-[*..]->()) return p -- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_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 edge where end_id = e0.start_id), e0.start_id = e0.end_id, array [e0.id] from s1_seed join edge e0 on e0.start_id = s1_seed.root_id where 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 s1.root_id, e0.end_id, s1.depth + 1, exists (select 1 from edge where end_id = e0.start_id), false, s1.path || e0.id from forward_front s1 join edge e0 on e0.start_id = s1.next_id where e0.id != all (s1.path);"} -with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_asp_harness(@pi0::text, @pi1::text, 15)) 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where case when s1.root_id != s1.next_id then true else shortest_path_self_endpoint_error(s1.root_id, s1.next_id) end) select ordered_edges_to_path(s0.n0, s0.e0, array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0; +with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_asp_harness(@pi0::text, @pi1::text, 15)) select 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where case when s1.root_id != s1.next_id then true else shortest_path_self_endpoint_error(s1.root_id, s1.next_id) end) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0; -- case: match p = allShortestPaths((s:NodeKind1)-[*..]->({name: "123"})) return p -- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where ((n1.properties -\u003e 'name'))::jsonb = to_jsonb(('123')::text)::jsonb) select e0.end_id, e0.start_id, 1, exists (select 1 from traversal_terminal_filter where traversal_terminal_filter.id = e0.start_id), e0.start_id = e0.end_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id where case when (select count(*)::int8 from traversal_terminal_filter where traversal_terminal_filter.id = e0.end_id) = 0 then true else shortest_path_self_endpoint_error(e0.end_id, e0.end_id) end;","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.start_id, s1.depth + 1, exists (select 1 from traversal_terminal_filter where traversal_terminal_filter.id = e0.start_id), false, e0.id || s1.path from forward_front s1 join edge e0 on e0.end_id = s1.next_id where e0.id != all (s1.path);"} -with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_asp_harness(@pi0::text, @pi1::text, 15, ('')::text, ('insert into traversal_terminal_filter (id) select distinct n0.id from node n0 where n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.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(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 node n1 on n1.id = s1.root_id join node n0 on n0.id = s1.next_id where case when s1.root_id != s1.next_id then true else shortest_path_self_endpoint_error(s1.root_id, s1.next_id) end) select ordered_edges_to_path(s0.n0, s0.e0, array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0; +with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_asp_harness(@pi0::text, @pi1::text, 15, ('')::text, ('insert into traversal_terminal_filter (id) select distinct n0.id from node n0 where n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id is not null;')::text)) select 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 node n1 on n1.id = s1.root_id join node n0 on n0.id = s1.next_id where case when s1.root_id != s1.next_id then true else shortest_path_self_endpoint_error(s1.root_id, s1.next_id) end) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0; -- case: match p = allShortestPaths((s:NodeKind1)-[*..]->(e)) where e.name = '123' return p -- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where (((n1.properties -\u003e 'name'))::jsonb = to_jsonb(('123')::text)::jsonb)) select e0.end_id, e0.start_id, 1, exists (select 1 from traversal_terminal_filter where traversal_terminal_filter.id = e0.start_id), e0.start_id = e0.end_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id where case when (select count(*)::int8 from traversal_terminal_filter where traversal_terminal_filter.id = e0.end_id) = 0 then true else shortest_path_self_endpoint_error(e0.end_id, e0.end_id) end;","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.start_id, s1.depth + 1, exists (select 1 from traversal_terminal_filter where traversal_terminal_filter.id = e0.start_id), false, e0.id || s1.path from forward_front s1 join edge e0 on e0.end_id = s1.next_id where e0.id != all (s1.path);"} -with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_asp_harness(@pi0::text, @pi1::text, 15, ('')::text, ('insert into traversal_terminal_filter (id) select distinct n0.id from node n0 where n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.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(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 node n1 on n1.id = s1.root_id join node n0 on n0.id = s1.next_id where case when s1.root_id != s1.next_id then true else shortest_path_self_endpoint_error(s1.root_id, s1.next_id) end) select ordered_edges_to_path(s0.n0, s0.e0, array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0; +with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_asp_harness(@pi0::text, @pi1::text, 15, ('')::text, ('insert into traversal_terminal_filter (id) select distinct n0.id from node n0 where n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id is not null;')::text)) select 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 node n1 on n1.id = s1.root_id join node n0 on n0.id = s1.next_id where case when s1.root_id != s1.next_id then true else shortest_path_self_endpoint_error(s1.root_id, s1.next_id) end) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0; -- case: match p=shortestPath((n:NodeKind1)-[:EdgeKind1*1..]->(m)) where 'admin_tier_0' in split(m.system_tags, ' ') and n.objectid ends with '-513' and n<>m return p limit 1000 -- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where ((n0.properties -\u003e\u003e 'objectid') like '%-513') 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 s1_seed join edge e0 on e0.start_id = s1_seed.root_id where e0.kind_id = any (array [3]::int2[]);","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.end_id, s1.depth + 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = s1.root_id and traversal_pair_filter.terminal_id = e0.end_id), false, s1.path || e0.id from forward_front s1 join edge e0 on e0.start_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path) and not exists (select 1 from forward_visited where forward_visited.root_id = s1.root_id and forward_visited.id = e0.end_id);","pi2":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where ('admin_tier_0' = any (string_to_array((n1.properties -\u003e\u003e 'system_tags'), ' ')::text[]))) 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 s1_seed join edge e0 on e0.end_id = s1_seed.root_id where e0.kind_id = any (array [3]::int2[]);","pi3":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.start_id, s1.depth + 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = e0.start_id and traversal_pair_filter.terminal_id = s1.root_id), false, e0.id || s1.path from backward_front s1 join edge e0 on e0.end_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path) and not exists (select 1 from backward_visited where backward_visited.root_id = s1.root_id and backward_visited.id = e0.start_id);"} -with s0 as (with s1(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 ->> ''objectid'') like ''%-513'') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (''admin_tier_0'' = any (string_to_array((n1.properties ->> ''system_tags''), '' '')::text[])) and n0.id is not null and n1.id is not null;')::text, (1000)::int8)) 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id) select ordered_edges_to_path(s0.n0, s0.e0, array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 where ((s0.n0).id <> (s0.n1).id) limit 1000; +with s0 as (with s1(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 ->> ''objectid'') like ''%-513'') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (''admin_tier_0'' = any (string_to_array((n1.properties ->> ''system_tags''), '' '')::text[])) and n0.id is not null and n1.id is not null;')::text, (1000)::int8)) select 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 where ((s0.n0).id <> (s0.n1).id) limit 1000; -- case: match p=shortestPath((n:NodeKind1)-[:EdgeKind1*1..]->(m)) where 'admin_tier_0' in split(m.system_tags, ' ') and n.objectid ends with '-513' and m<>n return p limit 1000 -- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where ((n0.properties -\u003e\u003e 'objectid') like '%-513') 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 s1_seed join edge e0 on e0.start_id = s1_seed.root_id where e0.kind_id = any (array [3]::int2[]);","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.end_id, s1.depth + 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = s1.root_id and traversal_pair_filter.terminal_id = e0.end_id), false, s1.path || e0.id from forward_front s1 join edge e0 on e0.start_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path) and not exists (select 1 from forward_visited where forward_visited.root_id = s1.root_id and forward_visited.id = e0.end_id);","pi2":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where ('admin_tier_0' = any (string_to_array((n1.properties -\u003e\u003e 'system_tags'), ' ')::text[]))) 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 s1_seed join edge e0 on e0.end_id = s1_seed.root_id where e0.kind_id = any (array [3]::int2[]);","pi3":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.start_id, s1.depth + 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = e0.start_id and traversal_pair_filter.terminal_id = s1.root_id), false, e0.id || s1.path from backward_front s1 join edge e0 on e0.end_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path) and not exists (select 1 from backward_visited where backward_visited.root_id = s1.root_id and backward_visited.id = e0.start_id);"} -with s0 as (with s1(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 ->> ''objectid'') like ''%-513'') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (''admin_tier_0'' = any (string_to_array((n1.properties ->> ''system_tags''), '' '')::text[])) and n0.id is not null and n1.id is not null;')::text, (1000)::int8)) 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id) select ordered_edges_to_path(s0.n0, s0.e0, array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 where ((s0.n1).id <> (s0.n0).id) limit 1000; +with s0 as (with s1(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 ->> ''objectid'') like ''%-513'') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (''admin_tier_0'' = any (string_to_array((n1.properties ->> ''system_tags''), '' '')::text[])) and n0.id is not null and n1.id is not null;')::text, (1000)::int8)) select 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 where ((s0.n1).id <> (s0.n0).id) limit 1000; -- case: match p=shortestPath((t:NodeKind1)<-[:EdgeKind1|EdgeKind2*1..]-(s:NodeKind2)) where coalesce(t.system_tags, '') contains 'admin_tier_0' and t.name =~ 'name.*' and s<>t return p limit 1000 -- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (coalesce((n0.properties -\u003e\u003e 'system_tags'), '')::text like '%admin_tier_0%' and (n0.properties -\u003e\u003e 'name') ~ 'name.*') and n0.kind_ids operator (pg_catalog.@\u003e) array [1]::int2[]) select e0.end_id, e0.start_id, 1, exists (select 1 from traversal_terminal_filter where traversal_terminal_filter.id = e0.start_id), e0.start_id = e0.end_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id where e0.kind_id = any (array [3, 4]::int2[]);","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.start_id, s1.depth + 1, exists (select 1 from traversal_terminal_filter where traversal_terminal_filter.id = e0.start_id), false, s1.path || e0.id from forward_front s1 join edge e0 on e0.end_id = s1.next_id where e0.kind_id = any (array [3, 4]::int2[]) and e0.id != all (s1.path) and not exists (select 1 from visited where visited.root_id = s1.root_id and visited.id = e0.start_id);"} -with s0 as (with s1(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, (1000)::int8)) 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id) select ordered_edges_to_path(s0.n0, s0.e0, array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 where ((s0.n1).id <> (s0.n0).id) limit 1000; +with s0 as (with s1(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, (1000)::int8)) select 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 where ((s0.n1).id <> (s0.n0).id) limit 1000; -- case: match p=shortestPath((a)-[:EdgeKind1*]->(b)) where id(a) = 1 and id(b) = 2 return p -- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (n0.id = 1)) 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 s1_seed join edge e0 on e0.start_id = s1_seed.root_id where e0.kind_id = any (array [3]::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 s1.root_id, e0.end_id, s1.depth + 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = s1.root_id and traversal_pair_filter.terminal_id = e0.end_id), false, s1.path || e0.id from forward_front s1 join edge e0 on e0.start_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path) and not exists (select 1 from forward_visited where forward_visited.root_id = s1.root_id and forward_visited.id = e0.end_id);","pi2":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where (n1.id = 2)) 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 s1_seed join edge e0 on e0.end_id = s1_seed.root_id where e0.kind_id = any (array [3]::int2[]);","pi3":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.start_id, s1.depth + 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = e0.start_id and traversal_pair_filter.terminal_id = s1.root_id), false, e0.id || s1.path from backward_front s1 join edge e0 on e0.end_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path) and not exists (select 1 from backward_visited where backward_visited.root_id = s1.root_id and backward_visited.id = e0.start_id);"} -with s0 as (with s1(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.id = 1) and (n1.id = 2) 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(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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where case when s1.root_id != s1.next_id then true else shortest_path_self_endpoint_error(s1.root_id, s1.next_id) end) select ordered_edges_to_path(s0.n0, s0.e0, array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0; +with s0 as (with s1(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.id = 1) and (n1.id = 2) and n0.id is not null and n1.id is not null;')::text)) select 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where case when s1.root_id != s1.next_id then true else shortest_path_self_endpoint_error(s1.root_id, s1.next_id) end) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0; -- case: match p=shortestPath((a)-[:EdgeKind1*]->(b:NodeKind1)) where a <> b return p -- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where n1.kind_ids operator (pg_catalog.@\u003e) array [1]::int2[]) select e0.end_id, e0.start_id, 1, exists (select 1 from edge where end_id = e0.end_id), e0.start_id = e0.end_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id where e0.kind_id = any (array [3]::int2[]);","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.start_id, s1.depth + 1, exists (select 1 from edge where end_id = e0.end_id), false, e0.id || s1.path from forward_front s1 join edge e0 on e0.end_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path) and not exists (select 1 from visited where visited.root_id = s1.root_id and visited.id = e0.start_id);"} -with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_sp_harness(@pi0::text, @pi1::text, 15)) 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 node n1 on n1.id = s1.root_id join node n0 on n0.id = s1.next_id) select ordered_edges_to_path(s0.n0, s0.e0, array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 where ((s0.n0).id <> (s0.n1).id); +with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_sp_harness(@pi0::text, @pi1::text, 15)) select 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 node n1 on n1.id = s1.root_id join node n0 on n0.id = s1.next_id) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 where ((s0.n0).id <> (s0.n1).id); -- case: match p=shortestPath((a:NodeKind2)-[:EdgeKind1*]->(b)) where a <> b return p -- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where n0.kind_ids operator (pg_catalog.@\u003e) array [2]::int2[]) select e0.start_id, e0.end_id, 1, exists (select 1 from edge where end_id = e0.start_id), e0.start_id = e0.end_id, array [e0.id] from s1_seed join edge e0 on e0.start_id = s1_seed.root_id where e0.kind_id = any (array [3]::int2[]);","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.end_id, s1.depth + 1, exists (select 1 from edge where end_id = e0.start_id), false, s1.path || e0.id from forward_front s1 join edge e0 on e0.start_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path) and not exists (select 1 from visited where visited.root_id = s1.root_id and visited.id = e0.end_id);"} -with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_sp_harness(@pi0::text, @pi1::text, 15)) 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id) select ordered_edges_to_path(s0.n0, s0.e0, array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 where ((s0.n0).id <> (s0.n1).id); +with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_sp_harness(@pi0::text, @pi1::text, 15)) select 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 where ((s0.n0).id <> (s0.n1).id); -- case: match p=shortestPath((b)<-[:EdgeKind1*]-(a)) where id(a) = 1 and id(b) = 2 return p -- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (n0.id = 2)) select e0.end_id, e0.start_id, 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = e0.end_id and traversal_pair_filter.terminal_id = e0.start_id), e0.start_id = e0.end_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id where e0.kind_id = any (array [3]::int2[]) and case when (select count(*)::int8 from traversal_pair_filter where traversal_pair_filter.root_id = e0.end_id and traversal_pair_filter.terminal_id = e0.end_id) = 0 then true else shortest_path_self_endpoint_error(e0.end_id, e0.end_id) end;","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.start_id, s1.depth + 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = s1.root_id and traversal_pair_filter.terminal_id = e0.start_id), false, s1.path || e0.id from forward_front s1 join edge e0 on e0.end_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path) and not exists (select 1 from forward_visited where forward_visited.root_id = s1.root_id and forward_visited.id = e0.start_id);","pi2":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where (n1.id = 1)) select e0.start_id, e0.end_id, 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = e0.end_id and traversal_pair_filter.terminal_id = e0.start_id), e0.start_id = e0.end_id, array [e0.id] from s1_seed join edge e0 on e0.start_id = s1_seed.root_id where e0.kind_id = any (array [3]::int2[]);","pi3":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.end_id, s1.depth + 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = e0.end_id and traversal_pair_filter.terminal_id = s1.root_id), false, e0.id || s1.path from backward_front s1 join edge e0 on e0.start_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path) and not exists (select 1 from backward_visited where backward_visited.root_id = s1.root_id and backward_visited.id = e0.end_id);"} -with s0 as (with s1(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.id = 2) and (n1.id = 1) 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(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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where case when s1.root_id != s1.next_id then true else shortest_path_self_endpoint_error(s1.root_id, s1.next_id) end) select ordered_edges_to_path(s0.n0, s0.e0, array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0; +with s0 as (with s1(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.id = 2) and (n1.id = 1) and n0.id is not null and n1.id is not null;')::text)) select 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where case when s1.root_id != s1.next_id then true else shortest_path_self_endpoint_error(s1.root_id, s1.next_id) end) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0; -- case: match p = allShortestPaths((m:NodeKind1)<-[:EdgeKind1*..]-(n)) where coalesce(m.system_tags, '') contains 'admin_tier_0' and n.name = '123' and n <> m return p -- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where (((n1.properties -\u003e 'name'))::jsonb = to_jsonb(('123')::text)::jsonb)) 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 s1_seed join edge e0 on e0.start_id = s1_seed.root_id where e0.kind_id = any (array [3]::int2[]);","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.end_id, s1.depth + 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = s1.root_id and traversal_pair_filter.terminal_id = e0.end_id), false, s1.path || e0.id from forward_front s1 join edge e0 on e0.start_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path);","pi2":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (coalesce((n0.properties -\u003e\u003e 'system_tags'), '')::text like '%admin_tier_0%') and n0.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 s1_seed join edge e0 on e0.end_id = s1_seed.root_id where e0.kind_id = any (array [3]::int2[]);","pi3":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.start_id, s1.depth + 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = e0.start_id and traversal_pair_filter.terminal_id = s1.root_id), false, e0.id || s1.path from backward_front s1 join edge e0 on e0.end_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path);"} -with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from bidirectional_asp_harness(@pi0::text, @pi1::text, @pi2::text, @pi3::text, 15, ('')::text, ('')::text, ('insert into traversal_pair_filter (root_id, terminal_id) select distinct n1.id, n0.id from node n1, node n0 where (((n1.properties -> ''name''))::jsonb = to_jsonb((''123'')::text)::jsonb) and (coalesce((n0.properties ->> ''system_tags''), '''')::text like ''%admin_tier_0%'') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id is not null and n0.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(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 node n1 on n1.id = s1.root_id join node n0 on n0.id = s1.next_id) select ordered_edges_to_path(s0.n0, s0.e0, array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 where ((s0.n1).id <> (s0.n0).id); +with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from bidirectional_asp_harness(@pi0::text, @pi1::text, @pi2::text, @pi3::text, 15, ('')::text, ('')::text, ('insert into traversal_pair_filter (root_id, terminal_id) select distinct n1.id, n0.id from node n1, node n0 where (((n1.properties -> ''name''))::jsonb = to_jsonb((''123'')::text)::jsonb) and (coalesce((n0.properties ->> ''system_tags''), '''')::text like ''%admin_tier_0%'') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id is not null and n0.id is not null;')::text)) select 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 node n1 on n1.id = s1.root_id join node n0 on n0.id = s1.next_id) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 where ((s0.n1).id <> (s0.n0).id); -- case: match p=shortestPath((a)-[:EdgeKind1*]->(b:NodeKind1)) where a <> b return p -- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where n1.kind_ids operator (pg_catalog.@\u003e) array [1]::int2[]) select e0.end_id, e0.start_id, 1, exists (select 1 from edge where end_id = e0.end_id), e0.start_id = e0.end_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id where e0.kind_id = any (array [3]::int2[]);","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.start_id, s1.depth + 1, exists (select 1 from edge where end_id = e0.end_id), false, e0.id || s1.path from forward_front s1 join edge e0 on e0.end_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path) and not exists (select 1 from visited where visited.root_id = s1.root_id and visited.id = e0.start_id);"} -with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_sp_harness(@pi0::text, @pi1::text, 15)) 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 node n1 on n1.id = s1.root_id join node n0 on n0.id = s1.next_id) select ordered_edges_to_path(s0.n0, s0.e0, array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 where ((s0.n0).id <> (s0.n1).id); +with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_sp_harness(@pi0::text, @pi1::text, 15)) select 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 node n1 on n1.id = s1.root_id join node n0 on n0.id = s1.next_id) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 where ((s0.n0).id <> (s0.n1).id); -- case: match p=(c:NodeKind1)-[]->(u:NodeKind2) match p2=shortestPath((u:NodeKind2)-[*1..]->(d:NodeKind1)) return p, p2 limit 500 -- 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 distinct n1.id as root_id from traversal_root_filter s2_seed_filter join node n1 on n1.id = s2_seed_filter.id where n1.kind_ids operator (pg_catalog.@\u003e) array [2]::int2[]) select e1.start_id, e1.end_id, 1, exists (select 1 from traversal_terminal_filter where traversal_terminal_filter.id = e1.end_id), e1.start_id = e1.end_id, array [e1.id] from s2_seed join edge e1 on e1.start_id = s2_seed.root_id where case when (select count(*)::int8 from traversal_terminal_filter where traversal_terminal_filter.id = e1.start_id) = 0 then true else shortest_path_self_endpoint_error(e1.start_id, e1.start_id) end;","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s2.root_id, e1.end_id, s2.depth + 1, exists (select 1 from traversal_terminal_filter where traversal_terminal_filter.id = e1.end_id), false, s2.path || e1.id from forward_front s2 join edge e1 on e1.start_id = s2.next_id where e1.id != all (s2.path) and not exists (select 1 from visited where visited.root_id = s2.root_id and visited.id = e1.end_id);"} -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 [2]::int2[] and n1.id = e0.end_id), s1 as (with s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_sp_harness(@pi0::text, @pi1::text, 15, ('insert into traversal_root_filter (id) select distinct (s0.n1).id from s0 where (s0.n1).id is not null;')::text, ('insert into traversal_terminal_filter (id) select distinct n2.id from node n2 where n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id is not null;')::text)) select s0.e0 as e0, (select coalesce(array_agg((e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s2.path) with ordinality as _path(id, ordinality) join edge e1 on e1.id = _path.id) as e1, s2.path as ep0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join node n1 on n1.id = s2.root_id join node n2 on n2.id = s2.next_id where (s0.n1).id = s2.root_id and case when s2.root_id != s2.next_id then true else shortest_path_self_endpoint_error(s2.root_id, s2.next_id) end) select (array [s1.n0, s1.n1]::nodecomposite[], array [s1.e0]::edgecomposite[])::pathcomposite as p, ordered_edges_to_path(s1.n1, s1.e1, array [s1.n1, s1.n2]::nodecomposite[])::pathcomposite as p2 from s1 limit 500; +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 [2]::int2[] and n1.id = e0.end_id), s1 as (with s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_sp_harness(@pi0::text, @pi1::text, 15, ('insert into traversal_root_filter (id) select distinct (s0.n1).id from s0 where (s0.n1).id is not null;')::text, ('insert into traversal_terminal_filter (id) select distinct n2.id from node n2 where n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id is not null;')::text)) select s0.e0 as e0, s2.path as ep0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join node n1 on n1.id = s2.root_id join node n2 on n2.id = s2.next_id where (s0.n1).id = s2.root_id and case when s2.root_id != s2.next_id then true else shortest_path_self_endpoint_error(s2.root_id, s2.next_id) end) select (array [s1.n0, s1.n1]::nodecomposite[], array [s1.e0]::edgecomposite[])::pathcomposite as p, ordered_edges_to_path(s1.n1, (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(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n1, s1.n2]::nodecomposite[])::pathcomposite as p2 from s1 limit 500; -- case: match p = allShortestPaths((a)-[:EdgeKind1*..]->()) return p -- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select e0.start_id, e0.end_id, 1, exists (select 1 from edge where end_id = e0.start_id), e0.start_id = e0.end_id, array [e0.id] from edge e0 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 s1.root_id, e0.end_id, s1.depth + 1, exists (select 1 from edge where end_id = e0.start_id), false, s1.path || e0.id from forward_front s1 join edge e0 on e0.start_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path);"} -with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_asp_harness(@pi0::text, @pi1::text, 15)) 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where case when s1.root_id != s1.next_id then true else shortest_path_self_endpoint_error(s1.root_id, s1.next_id) end) select ordered_edges_to_path(s0.n0, s0.e0, array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0; +with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_asp_harness(@pi0::text, @pi1::text, 15)) select 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where case when s1.root_id != s1.next_id then true else shortest_path_self_endpoint_error(s1.root_id, s1.next_id) end) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0; -- case: match p=shortestPath((n:NodeKind1)-[:EdgeKind1*1..]->(m:NodeKind2)) return p limit 10 -- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_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 s1_seed join edge e0 on e0.start_id = s1_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 s1.root_id, e0.end_id, s1.depth + 1, exists (select 1 from traversal_terminal_filter where traversal_terminal_filter.id = e0.end_id), false, s1.path || e0.id from forward_front s1 join edge e0 on e0.start_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path) and not exists (select 1 from visited where visited.root_id = s1.root_id and visited.id = e0.end_id);"} -with s0 as (with s1(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, (10)::int8)) 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where case when s1.root_id != s1.next_id then true else shortest_path_self_endpoint_error(s1.root_id, s1.next_id) end) select ordered_edges_to_path(s0.n0, s0.e0, array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 limit 10; +with s0 as (with s1(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, (10)::int8)) select 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where case when s1.root_id != s1.next_id then true else shortest_path_self_endpoint_error(s1.root_id, s1.next_id) end) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 limit 10; -- case: match (a:NodeKind1), (b:NodeKind2) match p=shortestPath((a)-[:EdgeKind1*]->(b)) 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 [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 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 [3]::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 [3]::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 [3]::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 [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.kind_ids operator (pg_catalog.@>) array [2]::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; +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.kind_ids operator (pg_catalog.@>) array [2]::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 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, (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(s2.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1]::nodecomposite[])::pathcomposite as p from s2; -- case: match (a:NodeKind1), (b:NodeKind2) match p=allShortestPaths((a)-[:EdgeKind1*..]->(b)) 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 [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 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 [3]::int2[]) and e0.id != all (s3.path);","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 [3]::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 [3]::int2[]) and e0.id != all (s3.path);"} -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.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s2 as (with s3(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from bidirectional_asp_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; +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.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s2 as (with s3(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from bidirectional_asp_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 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, (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(s2.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1]::nodecomposite[])::pathcomposite as p from s2; -- 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; +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 (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 [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); +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 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, (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(s2.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1]::nodecomposite[])::pathcomposite as p from s2 where (((select count(*)::int from unnest(((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(s2.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id))::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 ((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(s2.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id))::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); +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 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, (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(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), 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/expansion.go b/cypher/models/pgsql/translate/expansion.go index e018a41d..168be2bc 100644 --- a/cypher/models/pgsql/translate/expansion.go +++ b/cypher/models/pgsql/translate/expansion.go @@ -2464,7 +2464,7 @@ func (s *Translator) buildExpansionProjectionConstraints(traversalStepContext Tr return projectionConstraints, nil } -func (s *Translator) translateTraversalPatternPartWithExpansion(isFirstTraversalStep bool, traversalStep *TraversalStep) error { +func (s *Translator) translateTraversalPatternPartWithExpansion(part *PatternPart, isFirstTraversalStep bool, traversalStep *TraversalStep) error { expansionModel := traversalStep.Expansion // Translate the expansion's constraints - this has the side effect of making the pattern identifiers visible in @@ -2475,6 +2475,7 @@ func (s *Translator) translateTraversalPatternPartWithExpansion(isFirstTraversal // Export the path from the traversal's scope traversalStep.Frame.Export(expansionModel.PathBinding.Identifier) + pruneExpansionStepProjectionExports(s.query.CurrentPart(), part, traversalStep) // Push a new frame that contains currently projected scope from the expansion recursive CTE if expansionFrame, err := s.scope.PushFrame(); err != nil { diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index f776318d..badfe540 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -50,7 +50,7 @@ func optimizerSafetyKindMapper() *pgutil.InMemoryKindMapper { return mapper } -func TestOptimizerSafetyADCSQueryDocumentsCurrentCarryShape(t *testing.T) { +func TestOptimizerSafetyADCSQueryPrunesExpansionEdgeCarry(t *testing.T) { t.Parallel() regularQuery, err := frontend.ParseCypher(frontend.NewContext(), optimizerADCSQuery) @@ -66,6 +66,7 @@ func TestOptimizerSafetyADCSQueryDocumentsCurrentCarryShape(t *testing.T) { require.Contains(t, normalizedQuery, "select distinct (s5.n0).id as root_id from s5") require.Contains(t, normalizedQuery, "s5.ep0 as ep0") - require.Contains(t, normalizedQuery, "s5.e0 as e0") + require.NotContains(t, normalizedQuery, "s5.e0 as e0") + require.Contains(t, normalizedQuery, "from unnest(s12.ep0)") require.Contains(t, normalizedQuery, "from s5, s7") } diff --git a/cypher/models/pgsql/translate/path_functions.go b/cypher/models/pgsql/translate/path_functions.go index a73be813..af3e6717 100644 --- a/cypher/models/pgsql/translate/path_functions.go +++ b/cypher/models/pgsql/translate/path_functions.go @@ -12,7 +12,7 @@ func pathCompositeEdgesExpression(scope *Scope, pathBinding *BoundIdentifier) (p for _, dependency := range pathBinding.Dependencies { switch dependency.DataType { case pgsql.ExpansionPath: - if edgeArrayReference, err := expansionPathEdgeArrayReference(scope, dependency); err != nil { + if edgeArrayReference, err := expansionPathEdgeArrayExpression(scope, dependency); err != nil { return nil, err } else { edgeArrayReferences = append(edgeArrayReferences, edgeArrayReference) diff --git a/cypher/models/pgsql/translate/projection.go b/cypher/models/pgsql/translate/projection.go index f8422068..fbecb61c 100644 --- a/cypher/models/pgsql/translate/projection.go +++ b/cypher/models/pgsql/translate/projection.go @@ -162,14 +162,6 @@ func bindingFrameReference(scope *Scope, binding *BoundIdentifier) pgsql.Compoun return pgsql.CompoundIdentifier{frameIdentifier, binding.Identifier} } -func expansionPathEdgeArrayReference(scope *Scope, expansionPath *BoundIdentifier) (pgsql.Expression, error) { - for _, dependency := range expansionPath.Dependencies { - return bindingFrameReference(scope, dependency), nil - } - - return nil, fmt.Errorf("expansion path %s does not reference an expansion edge binding", expansionPath.Identifier) -} - func pathBindingReference(scope *Scope, binding *BoundIdentifier) pgsql.Expression { if binding.LastProjection != nil { return pgsql.CompoundIdentifier{binding.LastProjection.Binding.Identifier, binding.Identifier} @@ -210,15 +202,9 @@ func pathCompositeColumnReference(scope *Scope, binding *BoundIdentifier, column } func expansionPathEdgeArrayExpression(scope *Scope, expansionPath *BoundIdentifier) (pgsql.Expression, error) { - if scope.CurrentFrameBinding() != nil || expansionPath.LastProjection != nil { - return expansionPathEdgeArrayReference(scope, expansionPath) - } - - for _, dependency := range expansionPath.Dependencies { - return dependency.Identifier, nil - } - - return nil, fmt.Errorf("expansion path %s does not reference an expansion edge binding", expansionPath.Identifier) + return &pgsql.EdgeArrayFromPathIDs{ + PathIDs: pathBindingReference(scope, expansionPath), + }, nil } func expressionForPathComposite(projected *BoundIdentifier, scope *Scope) (pgsql.Expression, error) { @@ -402,12 +388,11 @@ func buildProjectionForExpansionEdge(alias pgsql.Identifier, projected *BoundIde // Create a new final projection that's aliased to the visible binding's identifier return []pgsql.SelectItem{ &pgsql.AliasedExpression{ - Expression: &pgsql.Parenthetical{ - Expression: pgsql.FormattingLiteral(fmt.Sprintf( - "select coalesce(array_agg((%[1]s.id, %[1]s.start_id, %[1]s.end_id, %[1]s.kind_id, %[1]s.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(%[2]s.path) with ordinality as _path(id, ordinality) join edge %[1]s on %[1]s.id = _path.id", - projected.Identifier, + Expression: &pgsql.EdgeArrayFromPathIDs{ + PathIDs: pgsql.CompoundIdentifier{ scope.CurrentFrame().Binding.Identifier, - )), + pgsql.ColumnPath, + }, }, Alias: pgsql.AsOptionalIdentifier(alias), }, diff --git a/cypher/models/pgsql/translate/renamer.go b/cypher/models/pgsql/translate/renamer.go index c2a49c3c..0b80a499 100644 --- a/cypher/models/pgsql/translate/renamer.go +++ b/cypher/models/pgsql/translate/renamer.go @@ -387,6 +387,9 @@ func (s *FrameBindingRewriter) enter(node pgsql.SyntaxNode) error { } } + case *pgsql.EdgeArrayFromPathIDs: + return s.rewriteExpression(&typedExpression.PathIDs) + case *pgsql.AliasedExpression: switch typedInnerExpression := typedExpression.Expression.(type) { case pgsql.Identifier: diff --git a/cypher/models/pgsql/translate/renamer_test.go b/cypher/models/pgsql/translate/renamer_test.go index 27999e28..50b349e0 100644 --- a/cypher/models/pgsql/translate/renamer_test.go +++ b/cypher/models/pgsql/translate/renamer_test.go @@ -63,6 +63,13 @@ func TestRewriteFrameBindings(t *testing.T) { Expression: rewrittenA, Alias: pgsql.AsOptionalIdentifier("name"), }, + }, { + Case: &pgsql.EdgeArrayFromPathIDs{ + PathIDs: a.Identifier, + }, + Expected: &pgsql.EdgeArrayFromPathIDs{ + PathIDs: rewrittenA, + }, }, { Case: pgsql.NewBinaryExpression( pgsql.ArraySlice{ diff --git a/cypher/models/pgsql/translate/traversal.go b/cypher/models/pgsql/translate/traversal.go index 26485677..fd8ff603 100644 --- a/cypher/models/pgsql/translate/traversal.go +++ b/cypher/models/pgsql/translate/traversal.go @@ -519,7 +519,7 @@ func (s *Translator) translateTraversalPatternPart(part *PatternPart, isolatedPr } if traversalStep.Expansion != nil { - if err := s.translateTraversalPatternPartWithExpansion(idx == 0, traversalStep); err != nil { + if err := s.translateTraversalPatternPartWithExpansion(part, idx == 0, traversalStep); err != nil { return err } } else if part.AllShortestPaths || part.ShortestPath { @@ -591,6 +591,25 @@ func pruneTraversalStepProjectionExports(queryPart *QueryPart, part *PatternPart } } +func pruneExpansionStepProjectionExports(queryPart *QueryPart, part *PatternPart, traversalStep *TraversalStep) { + if traversalStep == nil || traversalStep.Expansion == nil { + return + } + + // Variable-length relationship bindings materialize to edge-composite + // arrays. A path binding can be rebuilt later from the compact expansion + // path ID array, so keep the edge array only when the relationship binding + // itself is observable. + if traversalStep.Edge != nil && !queryPart.ReferencesBinding(traversalStep.Edge) { + traversalStep.Frame.Unexport(traversalStep.Edge.Identifier) + } + + pathBinding := traversalStep.Expansion.PathBinding + if pathBinding != nil && !patternBindingDependsOn(queryPart, part, pathBinding) { + traversalStep.Frame.Unexport(pathBinding.Identifier) + } +} + func (s *Translator) translateTraversalPatternPartWithoutExpansion(part *PatternPart, stepIndex int, traversalStep *TraversalStep) error { isFirstTraversalStep := stepIndex == 0 diff --git a/cypher/models/walk/walk_pgsql.go b/cypher/models/walk/walk_pgsql.go index a140fb66..23ab28b3 100644 --- a/cypher/models/walk/walk_pgsql.go +++ b/cypher/models/walk/walk_pgsql.go @@ -205,6 +205,12 @@ func newSQLWalkCursor(node pgsql.SyntaxNode) (*Cursor[pgsql.SyntaxNode], error) Branches: []pgsql.SyntaxNode{typedNode.Expression}, }, nil + case *pgsql.EdgeArrayFromPathIDs: + return &Cursor[pgsql.SyntaxNode]{ + Node: node, + Branches: []pgsql.SyntaxNode{typedNode.PathIDs}, + }, nil + case pgsql.FunctionCall: if branches, err := pgsqlSyntaxNodeSliceTypeConvert(typedNode.Parameters); err != nil { return nil, err @@ -382,6 +388,11 @@ func newSQLWalkCursor(node pgsql.SyntaxNode) (*Cursor[pgsql.SyntaxNode], error) Branches: []pgsql.SyntaxNode{typedNode.Query}, }, nil + case pgsql.FormattingLiteral: + return &Cursor[pgsql.SyntaxNode]{ + Node: node, + }, nil + case pgsql.SyntaxNodeFuture: cursor := &Cursor[pgsql.SyntaxNode]{ Node: typedNode, From f5f3a645bc274abcb178a1e4ed0607c4c1f6a467 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Wed, 20 May 2026 13:23:40 -0700 Subject: [PATCH 011/116] feat(pgsql): materialize path edges late Represent fixed relationships used only by path bindings as scalar edge IDs through intermediate projections. Reconstruct edge composites from those IDs only when path values or path relationship arrays are needed, while keeping directly referenced relationship variables materialized as full composites. --- cypher/models/pgsql/pgtypes.go | 1 + .../test/translation_cases/multipart.sql | 12 ++--- .../translation_cases/pattern_binding.sql | 26 +++++----- .../translation_cases/pattern_expansion.sql | 2 +- .../test/translation_cases/shortest_paths.sql | 2 +- .../translation_cases/stepwise_traversal.sql | 2 +- cypher/models/pgsql/translate/function.go | 3 ++ .../pgsql/translate/optimizer_safety_test.go | 25 +++++++++ .../models/pgsql/translate/path_functions.go | 3 ++ cypher/models/pgsql/translate/projection.go | 52 ++++++++++++++++++- cypher/models/pgsql/translate/tracking.go | 2 + cypher/models/pgsql/translate/traversal.go | 7 +++ 12 files changed, 113 insertions(+), 24 deletions(-) diff --git a/cypher/models/pgsql/pgtypes.go b/cypher/models/pgsql/pgtypes.go index 2ff9b7db..8e68dd6a 100644 --- a/cypher/models/pgsql/pgtypes.go +++ b/cypher/models/pgsql/pgtypes.go @@ -106,6 +106,7 @@ const ( ExpansionRootNode DataType = "expansion_root_node" ExpansionEdge DataType = "expansion_edge" ExpansionTerminalNode DataType = "expansion_terminal_node" + PathEdge DataType = "path_edge" ) func (s DataType) IsKnown() bool { diff --git a/cypher/models/pgsql/test/translation_cases/multipart.sql b/cypher/models/pgsql/test/translation_cases/multipart.sql index a354f376..6a420e42 100644 --- a/cypher/models/pgsql/test/translation_cases/multipart.sql +++ b/cypher/models/pgsql/test/translation_cases/multipart.sql @@ -48,13 +48,13 @@ with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposit with s0 as (select 'a' as i0), s1 as (select s0.i0 as i0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from s0, node n0 where (((n0.properties -> 'domain'))::jsonb = to_jsonb((' ')::text)::jsonb and cypher_starts_with((n0.properties ->> 'name'), (i0)::text)::bool) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s1.n0 as o from s1; -- case: match (dc)-[r:EdgeKind1*0..]->(g:NodeKind1) where g.objectid ends with '-516' with collect(dc) as exclude match p = (c:NodeKind2)-[n:EdgeKind2]->(u:NodeKind2)-[:EdgeKind2*1..]->(g:NodeKind1) where g.objectid ends with '-512' and not c in exclude return p limit 100 -with s0 as (with s1 as (with recursive s2_seed(root_id) as not materialized (select n1.id as root_id from node n1 where ((n1.properties ->> 'objectid') like '%-516') and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select s2_seed.root_id, s2_seed.root_id, 0, false, false, array []::int8[] from s2_seed union all select e0.end_id, e0.start_id, 1, false, e0.end_id = e0.start_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]::int2[]) union all select s2.root_id, e0.start_id, s2.depth + 1, false, false, e0.id || s2.path 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]::int2[]) offset 0) e0 on true where s2.depth < 15 and not s2.is_cycle and s2.depth > 0) select (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 n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s2.next_id offset 0) n0 on true) select array_remove(coalesce(array_agg(s1.n0)::nodecomposite[], array []::nodecomposite[])::nodecomposite[], null)::nodecomposite[] as i0 from s1), s3 as (select (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.i0 as i0, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s0, edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e1.start_id join node n3 on n3.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n3.id = e1.end_id where e1.kind_id = any (array [4]::int2[])), s4 as (with recursive s5_seed(root_id) as not materialized (select distinct (s3.n3).id as root_id from s3), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, ((n4.properties ->> 'objectid') like '%-512') and n4.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.start_id = e2.end_id, array [e2.id] from s5_seed join edge e2 on e2.start_id = s5_seed.root_id join node n4 on n4.id = e2.end_id where e2.kind_id = any (array [4]::int2[]) union all select s5.root_id, e2.end_id, s5.depth + 1, ((n4.properties ->> 'objectid') like '%-512') and n4.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, s5.path || e2.id from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.start_id = s5.next_id and e2.id != all (s5.path) and e2.kind_id = any (array [4]::int2[]) offset 0) e2 on true join node n4 on n4.id = e2.end_id where s5.depth < 15 and not s5.is_cycle) select s3.e1 as e1, s5.path as ep1, s3.i0 as i0, s3.n2 as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3, (n4.id, n4.kind_ids, n4.properties)::nodecomposite as n4 from s3, s5 join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.root_id offset 0) n3 on true join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.next_id offset 0) n4 on true where s5.satisfied and (s3.n3).id = s5.root_id) select ordered_edges_to_path(s4.n2, array [s4.e1]::edgecomposite[] || (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(s4.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n2, s4.n3, s4.n4]::nodecomposite[])::pathcomposite as p from s4 where (not (s4.n2).id = any ((select (_unnest_elem).id from unnest(s4.i0) as _unnest_elem))) limit 100; +with s0 as (with s1 as (with recursive s2_seed(root_id) as not materialized (select n1.id as root_id from node n1 where ((n1.properties ->> 'objectid') like '%-516') and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select s2_seed.root_id, s2_seed.root_id, 0, false, false, array []::int8[] from s2_seed union all select e0.end_id, e0.start_id, 1, false, e0.end_id = e0.start_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]::int2[]) union all select s2.root_id, e0.start_id, s2.depth + 1, false, false, e0.id || s2.path 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]::int2[]) offset 0) e0 on true where s2.depth < 15 and not s2.is_cycle and s2.depth > 0) select (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 n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s2.next_id offset 0) n0 on true) select array_remove(coalesce(array_agg(s1.n0)::nodecomposite[], array []::nodecomposite[])::nodecomposite[], null)::nodecomposite[] as i0 from s1), s3 as (select e1.id as e1, s0.i0 as i0, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s0, edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e1.start_id join node n3 on n3.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n3.id = e1.end_id where e1.kind_id = any (array [4]::int2[])), s4 as (with recursive s5_seed(root_id) as not materialized (select distinct (s3.n3).id as root_id from s3), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, ((n4.properties ->> 'objectid') like '%-512') and n4.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.start_id = e2.end_id, array [e2.id] from s5_seed join edge e2 on e2.start_id = s5_seed.root_id join node n4 on n4.id = e2.end_id where e2.kind_id = any (array [4]::int2[]) union all select s5.root_id, e2.end_id, s5.depth + 1, ((n4.properties ->> 'objectid') like '%-512') and n4.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, s5.path || e2.id from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.start_id = s5.next_id and e2.id != all (s5.path) and e2.kind_id = any (array [4]::int2[]) offset 0) e2 on true join node n4 on n4.id = e2.end_id where s5.depth < 15 and not s5.is_cycle) select s3.e1 as e1, s5.path as ep1, s3.i0 as i0, s3.n2 as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3, (n4.id, n4.kind_ids, n4.properties)::nodecomposite as n4 from s3, s5 join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.root_id offset 0) n3 on true join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.next_id offset 0) n4 on true where s5.satisfied and (s3.n3).id = s5.root_id) select ordered_edges_to_path(s4.n2, (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.e1]::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(s4.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n2, s4.n3, s4.n4]::nodecomposite[])::pathcomposite as p from s4 where (not (s4.n2).id = any ((select (_unnest_elem).id from unnest(s4.i0) as _unnest_elem))) limit 100; -- case: match (n:NodeKind1)<-[:EdgeKind1]-(:NodeKind2) where n.objectid ends with '-516' with n, count(n) as dc_count where dc_count = 1 return n 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 ->> 'objectid') like '%-516') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and 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[])) select s1.n0 as n0, count(s1.n0)::int8 as i0 from s1 group by n0) select s0.n0 as n from s0 where (s0.i0 = 1); -- case: match (n:NodeKind1)-[:EdgeKind1]->(m:NodeKind2) where n.enabled = true with n, collect(distinct(n)) as p where size(p) >= 100 match p = (n)-[:EdgeKind1]->(m) return p limit 10 -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; +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 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 ordered_edges_to_path(s2.n0, (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 [s2.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n2]::nodecomposite[])::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)::text[], array []::text[])::text[], null)::text[] as i2, lower(((s2.n0).properties ->> 'samaccountname'))::text as i3 from s2 group by lower(((s2.n0).properties ->> 'samaccountname'))::text) select s1.i2 as refmembership, s1.i3 as samname from s1; @@ -66,7 +66,7 @@ with s0 as (select 'a' as i0, 'b' as i1), s1 as (with s2 as (select s0.i0 as i0, 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 lower(((s2.n0).properties ->> 'samaccountname'))::text), 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((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s2.path) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id) as e0, (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))); +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((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s2.path) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id) as e0, (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 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 ordered_edges_to_path(s4.n2, (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.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n2, s4.n3]::nodecomposite[])::pathcomposite as p from s4 where ((s4.n2).id = any ((select (_unnest_elem).id from unnest(s4.i1) as _unnest_elem))); -- case: match (u:NodeKind1)-[:EdgeKind1]->(g:NodeKind2) with g match (g)<-[:EdgeKind1]-(u:NodeKind1) return g with s0 as (with s1 as (select (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select s1.n1 as n1 from s1), s2 as (select s0.n1 as n1 from s0 join edge e1 on (s0.n1).id = e1.end_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.start_id where e1.kind_id = any (array [3]::int2[])) select s2.n1 as g from s2; @@ -75,10 +75,10 @@ with s0 as (with s1 as (select (n1.id, n1.kind_ids, n1.properties)::nodecomposit with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((n0.properties ->> 'name') ~ '.*TT' and ((n0.properties -> 'domain'))::jsonb = to_jsonb(('MY DOMAIN')::text)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select array_remove(coalesce(array_agg(((s1.n0).properties ->> 'email'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s1), s2 as (select s0.i0 as i0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, edge e0 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e0.end_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.start_id where e0.kind_id = any (array [3]::int2[]) and (not (n2.properties ->> 'email') = any (s0.i0) and (n2.properties ->> 'name') like 'blah%')) select s2.n1 as o from s2; -- case: match (e) match p = ()-[]->(e) return p limit 1 -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0), 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.id = e0.start_id) select (array [s1.n1, s1.n0]::nodecomposite[], array [s1.e0]::edgecomposite[])::pathcomposite as p from s1 limit 1; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0), s1 as (select e0.id 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.id = e0.start_id) select ordered_edges_to_path(s1.n1, (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 [s1.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n1, s1.n0]::nodecomposite[])::pathcomposite as p from s1 limit 1; -- case: match p = (a)-[]->() match q = ()-[]->(a) return p, q -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), s1 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n0).id = e1.end_id join node n2 on n2.id = e1.start_id) select (array [s1.n0, s1.n1]::nodecomposite[], array [s1.e0]::edgecomposite[])::pathcomposite as p, (array [s1.n2, s1.n0]::nodecomposite[], array [s1.e1]::edgecomposite[])::pathcomposite as q from s1; +with s0 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.id = e0.start_id join node n1 on n1.id = e0.end_id), s1 as (select s0.e0 as e0, e1.id as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n0).id = e1.end_id join node n2 on n2.id = e1.start_id) select ordered_edges_to_path(s1.n0, (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 [s1.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite as p, ordered_edges_to_path(s1.n2, (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 [s1.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n2, s1.n0]::nodecomposite[])::pathcomposite as q from s1; -- case: match (m:NodeKind1)-[*1..]->(g:NodeKind2)-[]->(c3:NodeKind1) where not g.name in ["foo"] with collect(g.name) as bar match p=(m:NodeKind1)-[*1..]->(g:NodeKind2) where g.name in bar 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.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id union all select s2.root_id, e0.end_id, s2.depth + 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_id = s2.next_id and e0.id != all (s2.path) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select (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), s3 as (select s1.n0 as n0, s1.n1 as n1 from s1 join edge e1 on (s1.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id) select array_remove(coalesce(array_agg(((s3.n1).properties ->> 'name'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s3), s4 as (with recursive s5_seed(root_id) as not materialized (select n4.id as root_id from s0, node n4 where n4.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n4.properties ->> 'name') = any (s0.i0))), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.end_id, e2.start_id, 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.end_id = e2.start_id, array [e2.id] from s5_seed join edge e2 on e2.end_id = s5_seed.root_id join node n3 on n3.id = e2.start_id union select s5.root_id, e2.start_id, s5.depth + 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e2.id || s5.path from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.end_id = s5.next_id and e2.id != all (s5.path) offset 0) e2 on true join node n3 on n3.id = e2.start_id where s5.depth < 15 and not s5.is_cycle) select s5.path as ep1, 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, s5 join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.root_id offset 0) n4 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.next_id offset 0) n3 on true where s5.satisfied) select 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(s4.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n3, s4.n4]::nodecomposite[])::pathcomposite as p from s4; @@ -87,7 +87,7 @@ with s0 as (with s1 as (with recursive s2_seed(root_id) as not materialized (sel 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 ->> 'samaccountname') ~ '^[A-Z]{1,3}[0-9]{1,3}$' and not coalesce((n0.properties ->> 'samaccountname'), '')::text like '%DEX%' and not (n0.properties ->> 'samaccountname') ~ '^.*$') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (not (n1.properties ->> 'name') = any (array ['D']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, (not (n1.properties ->> 'name') = any (array ['D']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select (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), s3 as (select s1.n0 as n0, s1.n1 as n1 from s1 join edge e1 on (s1.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[])) select array_remove(coalesce(array_agg(((s3.n1).properties ->> 'name'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s3), s4 as (with recursive s5_seed(root_id) as not materialized (select n4.id as root_id from s0, node n4 where n4.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n4.properties ->> 'name') = any (s0.i0))), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.end_id, e2.start_id, 1, ((n3.properties ->> 'samaccountname') ~ '^[A-Z]{1,3}[0-9]{1,3}$' and not (n3.properties ->> 'samaccountname') ~ '^.*$') and n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.end_id = e2.start_id, array [e2.id] from s5_seed join edge e2 on e2.end_id = s5_seed.root_id join node n3 on n3.id = e2.start_id where e2.kind_id = any (array [3]::int2[]) union select s5.root_id, e2.start_id, s5.depth + 1, ((n3.properties ->> 'samaccountname') ~ '^[A-Z]{1,3}[0-9]{1,3}$' and not (n3.properties ->> 'samaccountname') ~ '^.*$') and n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e2.id || s5.path from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.end_id = s5.next_id and e2.id != all (s5.path) and e2.kind_id = any (array [3]::int2[]) offset 0) e2 on true join node n3 on n3.id = e2.start_id where s5.depth < 15 and not s5.is_cycle) select s5.path as ep1, 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, s5 join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.root_id offset 0) n4 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.next_id offset 0) n3 on true where s5.satisfied) select 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(s4.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n3, s4.n4]::nodecomposite[])::pathcomposite as p from s4; -- case: match (a:NodeKind2)-[:EdgeKind1]->(g:NodeKind1)-[:EdgeKind2]->(s:NodeKind2) with count(a) as uc where uc > 5 match p = (a)-[:EdgeKind1]->(g)-[:EdgeKind2]->(s) return p -with s0 as (with s1 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 [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.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[])) select count(s2.n0)::int8 as i0 from s2), s3 as (select (e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties)::edgecomposite 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, e3.start_id, e3.end_id, e3.kind_id, e3.properties)::edgecomposite 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[])) select (array [s4.n3, s4.n4, s4.n5]::nodecomposite[], array [s4.e2, s4.e3]::edgecomposite[])::pathcomposite as p from s4; +with s0 as (with s1 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 [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.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[])) 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[])) select 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 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 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 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); diff --git a/cypher/models/pgsql/test/translation_cases/pattern_binding.sql b/cypher/models/pgsql/test/translation_cases/pattern_binding.sql index 17ea94e4..772dc2b3 100644 --- a/cypher/models/pgsql/test/translation_cases/pattern_binding.sql +++ b/cypher/models/pgsql/test/translation_cases/pattern_binding.sql @@ -21,10 +21,10 @@ 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 ->> 'name') like '%test%') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select (array [s0.n0]::nodecomposite[], array []::edgecomposite[])::pathcomposite as p from s0; -- 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; +with s0 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.id = e0.start_id join node n1 on n1.id = e0.end_id) select ordered_edges_to_path(s0.n0, (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 [s0.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0; -- case: match p = ()-[]->() return nodes(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).nodes)::nodecomposite[] from s0; +with s0 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.id = e0.start_id join node n1 on n1.id = e0.end_id) select ((ordered_edges_to_path(s0.n0, (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 [s0.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite).nodes)::nodecomposite[] 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 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, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 where (((select count(*)::int from unnest(((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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id))::edgecomposite[]) as i0 where (kind_name(i0.kind_id)::text like 'EdgeKind%')) >= 1)::bool); @@ -39,46 +39,46 @@ with s0 as (select (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (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 where (((e0.properties -> 'name'))::jsonb = to_jsonb(('a')::text)::jsonb)), s1 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where (((e1.properties -> 'name'))::jsonb = to_jsonb(('b')::text)::jsonb)), s2 as (select s1.e0 as e0, s1.e1 as e1, s1.n1 as n1, s1.n2 as n2 from s1 join edge e2 on (s1.n2).id = e2.start_id join node n3 on n3.id = e2.end_id) select s2.e0 as r1 from s2; -- case: match p = (a)-[]->()<-[]-(f) where a.name = 'value' and f.is_target 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.properties -> 'name'))::jsonb = to_jsonb(('value')::text)::jsonb) and n0.id = e0.start_id join node n1 on n1.id = e0.end_id), s1 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.end_id join node n2 on (((n2.properties ->> 'is_target'))::bool) and n2.id = e1.start_id) select (array [s1.n0, s1.n1, s1.n2]::nodecomposite[], array [s1.e0, s1.e1]::edgecomposite[])::pathcomposite as p from s1; +with s0 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.properties -> 'name'))::jsonb = to_jsonb(('value')::text)::jsonb) and n0.id = e0.start_id join node n1 on n1.id = e0.end_id), s1 as (select s0.e0 as e0, e1.id as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.end_id join node n2 on (((n2.properties ->> 'is_target'))::bool) and n2.id = e1.start_id) select ordered_edges_to_path(s1.n0, (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 [s1.e0]::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 [s1.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1, s1.n2]::nodecomposite[])::pathcomposite as p from s1; -- case: match p = ()-[*..]->() return p limit 1 with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, false, e0.start_id = e0.end_id, array [e0.id] from edge e0 union all select s1.root_id, e0.end_id, s1.depth + 1, false, 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) offset 0) e0 on true where s1.depth < 15 and not s1.is_cycle) select 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 limit 1) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 limit 1; -- case: match p = (s)-[*..]->(i)-[]->() where id(s) = 1 and i.name = 'n3' return p limit 1 -with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (n0.id = 1)), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n3')::text)::jsonb), 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 union all select s1.root_id, e0.end_id, s1.depth + 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n3')::text)::jsonb), 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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select 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), s2 as (select (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.ep0 as ep0, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id limit 1) select ordered_edges_to_path(s2.n0, (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(s2.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id) || array [s2.e1]::edgecomposite[], array [s2.n0, s2.n1, s2.n2]::nodecomposite[])::pathcomposite as p from s2 limit 1; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (n0.id = 1)), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n3')::text)::jsonb), 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 union all select s1.root_id, e0.end_id, s1.depth + 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n3')::text)::jsonb), 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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select 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), s2 as (select e1.id as e1, s0.ep0 as ep0, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id limit 1) select ordered_edges_to_path(s2.n0, (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(s2.ep0) 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 [s2.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1, s2.n2]::nodecomposite[])::pathcomposite as p from s2 limit 1; -- case: match p = ()-[e:EdgeKind1]->()-[:EdgeKind1*..]->() return e, 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 where e0.kind_id = any (array [3]::int2[])), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.start_id, e1.end_id, 1, false, e1.start_id = e1.end_id, array [e1.id] from s2_seed join edge e1 on e1.start_id = s2_seed.root_id where e1.kind_id = any (array [3]::int2[]) union all select s2.root_id, e1.end_id, s2.depth + 1, false, false, s2.path || e1.id from s2 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.start_id = s2.next_id and e1.id != all (s2.path) and e1.kind_id = any (array [3]::int2[]) offset 0) e1 on true where s2.depth < 15 and not s2.is_cycle) select s0.e0 as e0, s2.path as ep0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s2.next_id offset 0) n2 on true where (s0.n1).id = s2.root_id) select s1.e0 as e, ordered_edges_to_path(s1.n0, array [s1.e0]::edgecomposite[] || (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(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1, s1.n2]::nodecomposite[])::pathcomposite as p from s1; -- case: match p = (m:NodeKind1)-[:EdgeKind1]->(c:NodeKind2) where m.objectid ends with "-513" and not toUpper(c.operatingsystem) contains "SERVER" return p limit 1000 -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.properties ->> 'objectid') like '%-513') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id = e0.start_id join node n1 on (not upper((n1.properties ->> 'operatingsystem'))::text like '%SERVER%') and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) limit 1000) select (array [s0.n0, s0.n1]::nodecomposite[], array [s0.e0]::edgecomposite[])::pathcomposite as p from s0 limit 1000; +with s0 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.properties ->> 'objectid') like '%-513') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id = e0.start_id join node n1 on (not upper((n1.properties ->> 'operatingsystem'))::text like '%SERVER%') and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) limit 1000) select ordered_edges_to_path(s0.n0, (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 [s0.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 limit 1000; -- case: match p = (:NodeKind1)-[:EdgeKind1|EdgeKind2]->(e:NodeKind2)-[:EdgeKind2]->(:NodeKind1) where 'a' in e.values or 'b' in e.values or size(e.values) = 0 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.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id = e0.start_id join node n1 on ('a' = any (jsonb_to_text_array((n1.properties -> 'values'))::text[]) or 'b' = any (jsonb_to_text_array((n1.properties -> 'values'))::text[]) or jsonb_array_length((n1.properties -> 'values'))::int = 0) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[])), s1 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[])) select (array [s1.n0, s1.n1, s1.n2]::nodecomposite[], array [s1.e0, s1.e1]::edgecomposite[])::pathcomposite as p from s1; +with s0 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 [1]::int2[] and n0.id = e0.start_id join node n1 on ('a' = any (jsonb_to_text_array((n1.properties -> 'values'))::text[]) or 'b' = any (jsonb_to_text_array((n1.properties -> 'values'))::text[]) or jsonb_array_length((n1.properties -> 'values'))::int = 0) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[])), s1 as (select s0.e0 as e0, e1.id as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[])) select ordered_edges_to_path(s1.n0, (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 [s1.e0]::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 [s1.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1, s1.n2]::nodecomposite[])::pathcomposite as p from s1; -- case: match p = (n:NodeKind1)-[r]-(m:NodeKind1) 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.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (n0.id = e0.end_id or n0.id = e0.start_id) join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (n1.id = e0.end_id or n1.id = e0.start_id) where (n0.id <> n1.id)) select (array [s0.n0, s0.n1]::nodecomposite[], array [s0.e0]::edgecomposite[])::pathcomposite as p from s0; +with s0 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 [1]::int2[] and (n0.id = e0.end_id or n0.id = e0.start_id) join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (n1.id = e0.end_id or n1.id = e0.start_id) where (n0.id <> n1.id)) select ordered_edges_to_path(s0.n0, (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 [s0.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0; -- case: match p = (:NodeKind1)-[:EdgeKind1]->(:NodeKind2)-[:EdgeKind2*1..]->(t:NodeKind2) where coalesce(t.system_tags, '') contains 'admin_tier_0' return p limit 1000 -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 [2]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[])), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.start_id, e1.end_id, 1, (coalesce((n2.properties ->> 'system_tags'), '')::text like '%admin_tier_0%') and n2.kind_ids operator (pg_catalog.@>) array [2]::int2[], e1.start_id = e1.end_id, array [e1.id] from s2_seed join edge e1 on e1.start_id = s2_seed.root_id join node n2 on n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[]) union all select s2.root_id, e1.end_id, s2.depth + 1, (coalesce((n2.properties ->> 'system_tags'), '')::text like '%admin_tier_0%') and n2.kind_ids operator (pg_catalog.@>) array [2]::int2[], false, s2.path || e1.id from s2 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.start_id = s2.next_id and e1.id != all (s2.path) and e1.kind_id = any (array [4]::int2[]) offset 0) e1 on true join node n2 on n2.id = e1.end_id where s2.depth < 15 and not s2.is_cycle) select s0.e0 as e0, s2.path as ep0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s2.next_id offset 0) n2 on true where s2.satisfied and (s0.n1).id = s2.root_id limit 1000) select ordered_edges_to_path(s1.n0, array [s1.e0]::edgecomposite[] || (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(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1, s1.n2]::nodecomposite[])::pathcomposite as p from s1 limit 1000; +with s0 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 [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[])), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.start_id, e1.end_id, 1, (coalesce((n2.properties ->> 'system_tags'), '')::text like '%admin_tier_0%') and n2.kind_ids operator (pg_catalog.@>) array [2]::int2[], e1.start_id = e1.end_id, array [e1.id] from s2_seed join edge e1 on e1.start_id = s2_seed.root_id join node n2 on n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[]) union all select s2.root_id, e1.end_id, s2.depth + 1, (coalesce((n2.properties ->> 'system_tags'), '')::text like '%admin_tier_0%') and n2.kind_ids operator (pg_catalog.@>) array [2]::int2[], false, s2.path || e1.id from s2 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.start_id = s2.next_id and e1.id != all (s2.path) and e1.kind_id = any (array [4]::int2[]) offset 0) e1 on true join node n2 on n2.id = e1.end_id where s2.depth < 15 and not s2.is_cycle) select s0.e0 as e0, s2.path as ep0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s2.next_id offset 0) n2 on true where s2.satisfied and (s0.n1).id = s2.root_id limit 1000) select ordered_edges_to_path(s1.n0, (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 [s1.e0]::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(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1, s1.n2]::nodecomposite[])::pathcomposite as p from s1 limit 1000; -- case: match (u:NodeKind1) where u.samaccountname in ["foo", "bar"] match p = (u)-[:EdgeKind1|EdgeKind2*1..3]->(t) where coalesce(t.system_tags, '') contains 'admin_tier_0' return p limit 1000 with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((n0.properties ->> 'samaccountname') = any (array ['foo', 'bar']::text[])) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n0).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (coalesce((n1.properties ->> 'system_tags'), '')::text like '%admin_tier_0%'), e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, (coalesce((n1.properties ->> 'system_tags'), '')::text like '%admin_tier_0%'), 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.start_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.end_id where s2.depth < 3 and not s2.is_cycle) select 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 s0, 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 and (s0.n0).id = s2.root_id) select ordered_edges_to_path(s1.n0, (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(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite as p from s1 limit 1000; -- case: match (x:NodeKind1) where x.name = 'foo' match (y:NodeKind2) where y.name = 'bar' match p=(x)-[:EdgeKind1]->(y) return p -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('foo')::text)::jsonb) and 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 -> 'name'))::jsonb = to_jsonb(('bar')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s2 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, s1.n0 as n0, s1.n1 as n1 from s1 join edge e0 on (s1.n0).id = e0.start_id join node n1 on (s1.n1).id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select (array [s2.n0, s2.n1]::nodecomposite[], array [s2.e0]::edgecomposite[])::pathcomposite as p from s2; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('foo')::text)::jsonb) and 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 -> 'name'))::jsonb = to_jsonb(('bar')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s2 as (select e0.id as e0, s1.n0 as n0, s1.n1 as n1 from s1 join edge e0 on (s1.n0).id = e0.start_id join node n1 on (s1.n1).id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select ordered_edges_to_path(s2.n0, (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 [s2.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1]::nodecomposite[])::pathcomposite as p from s2; -- case: match (x:NodeKind1{name:'foo'}) match (y:NodeKind2{name:'bar'}) match p=(x)-[:EdgeKind1]->(y) return p -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[] and ((n0.properties -> 'name'))::jsonb = to_jsonb(('foo')::text)::jsonb), s1 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n1.properties -> 'name'))::jsonb = to_jsonb(('bar')::text)::jsonb), s2 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, s1.n0 as n0, s1.n1 as n1 from s1 join edge e0 on (s1.n0).id = e0.start_id join node n1 on (s1.n1).id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select (array [s2.n0, s2.n1]::nodecomposite[], array [s2.e0]::edgecomposite[])::pathcomposite as p 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[] and ((n0.properties -> 'name'))::jsonb = to_jsonb(('foo')::text)::jsonb), s1 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n1.properties -> 'name'))::jsonb = to_jsonb(('bar')::text)::jsonb), s2 as (select e0.id as e0, s1.n0 as n0, s1.n1 as n1 from s1 join edge e0 on (s1.n0).id = e0.start_id join node n1 on (s1.n1).id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select ordered_edges_to_path(s2.n0, (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 [s2.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1]::nodecomposite[])::pathcomposite as p from s2; -- case: match (x:NodeKind1{name:'foo'}) match p=(x)-[:EdgeKind1]->(y:NodeKind2{name:'bar'}) return p -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[] and ((n0.properties -> 'name'))::jsonb = to_jsonb(('foo')::text)::jsonb), 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.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n1.properties -> 'name'))::jsonb = to_jsonb(('bar')::text)::jsonb and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select (array [s1.n0, s1.n1]::nodecomposite[], array [s1.e0]::edgecomposite[])::pathcomposite as p from s1; +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[] and ((n0.properties -> 'name'))::jsonb = to_jsonb(('foo')::text)::jsonb), s1 as (select e0.id 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.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n1.properties -> 'name'))::jsonb = to_jsonb(('bar')::text)::jsonb and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select ordered_edges_to_path(s1.n0, (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 [s1.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite as p from s1; -- case: match (e) match p = ()-[]->(e) return p limit 1 -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0), 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.id = e0.start_id) select (array [s1.n1, s1.n0]::nodecomposite[], array [s1.e0]::edgecomposite[])::pathcomposite as p from s1 limit 1; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0), s1 as (select e0.id 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.id = e0.start_id) select ordered_edges_to_path(s1.n1, (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 [s1.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n1, s1.n0]::nodecomposite[])::pathcomposite as p from s1 limit 1; -- case: match p = (a)-[]->() match q = ()-[]->(a) return p, q -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), s1 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n0).id = e1.end_id join node n2 on n2.id = e1.start_id) select (array [s1.n0, s1.n1]::nodecomposite[], array [s1.e0]::edgecomposite[])::pathcomposite as p, (array [s1.n2, s1.n0]::nodecomposite[], array [s1.e1]::edgecomposite[])::pathcomposite as q from s1; +with s0 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.id = e0.start_id join node n1 on n1.id = e0.end_id), s1 as (select s0.e0 as e0, e1.id as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n0).id = e1.end_id join node n2 on n2.id = e1.start_id) select ordered_edges_to_path(s1.n0, (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 [s1.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite as p, ordered_edges_to_path(s1.n2, (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 [s1.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n2, s1.n0]::nodecomposite[])::pathcomposite as q from s1; -- case: match (m:NodeKind1)-[*1..]->(g:NodeKind2)-[]->(c3:NodeKind1) where not g.name in ["foo"] with collect(g.name) as bar match p=(m:NodeKind1)-[*1..]->(g:NodeKind2) where g.name in bar 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.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id union all select s2.root_id, e0.end_id, s2.depth + 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_id = s2.next_id and e0.id != all (s2.path) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select (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), s3 as (select s1.n0 as n0, s1.n1 as n1 from s1 join edge e1 on (s1.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id) select array_remove(coalesce(array_agg(((s3.n1).properties ->> 'name'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s3), s4 as (with recursive s5_seed(root_id) as not materialized (select n4.id as root_id from s0, node n4 where n4.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n4.properties ->> 'name') = any (s0.i0))), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.end_id, e2.start_id, 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.end_id = e2.start_id, array [e2.id] from s5_seed join edge e2 on e2.end_id = s5_seed.root_id join node n3 on n3.id = e2.start_id union select s5.root_id, e2.start_id, s5.depth + 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e2.id || s5.path from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.end_id = s5.next_id and e2.id != all (s5.path) offset 0) e2 on true join node n3 on n3.id = e2.start_id where s5.depth < 15 and not s5.is_cycle) select s5.path as ep1, 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, s5 join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.root_id offset 0) n4 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.next_id offset 0) n3 on true where s5.satisfied) select 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(s4.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n3, s4.n4]::nodecomposite[])::pathcomposite as p from s4; diff --git a/cypher/models/pgsql/test/translation_cases/pattern_expansion.sql b/cypher/models/pgsql/test/translation_cases/pattern_expansion.sql index edf476fc..beaecc8c 100644 --- a/cypher/models/pgsql/test/translation_cases/pattern_expansion.sql +++ b/cypher/models/pgsql/test/translation_cases/pattern_expansion.sql @@ -75,7 +75,7 @@ with s0 as (with recursive s1_seed(root_id) as not materialized (select n1.id as with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where ((n0.properties ->> 'objectid') like '%-512') and n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s1(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[] or n1.kind_ids operator (pg_catalog.@>) array [2]::int2[])), e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id join node n1 on n1.id = e0.start_id where e0.kind_id = any (array [3]::int2[]) union all select s1.root_id, e0.start_id, s1.depth + 1, ((n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] or 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.end_id = s1.next_id and e0.id != all (s1.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.start_id where s1.depth < 15 and not s1.is_cycle) select 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 limit 1000) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 limit 1000; -- case: match p=(n:NodeKind1)-[:EdgeKind1|EdgeKind2]->(g:NodeKind1)-[:EdgeKind2]->(:NodeKind2)-[:EdgeKind1*1..]->(m:NodeKind1) where n.objectid = m.objectid 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.kind_id = any (array [3, 4]::int2[])), s1 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.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[])), s2 as (with recursive s3_seed(root_id) as not materialized (select distinct (s1.n2).id as root_id from s1), s3(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.start_id = e2.end_id, array [e2.id] from s3_seed join edge e2 on e2.start_id = s3_seed.root_id join node n3 on n3.id = e2.end_id where e2.kind_id = any (array [3]::int2[]) union all select s3.root_id, e2.end_id, s3.depth + 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, s3.path || e2.id from s3 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.start_id = s3.next_id and e2.id != all (s3.path) and e2.kind_id = any (array [3]::int2[]) offset 0) e2 on true join node n3 on n3.id = e2.end_id where s3.depth < 15 and not s3.is_cycle) select s1.e0 as e0, s1.e1 as e1, s3.path as ep0, s1.n0 as n0, s1.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s1, s3 join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s3.root_id offset 0) n2 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s3.next_id offset 0) n3 on true where s3.satisfied and (s1.n2).id = s3.root_id and (((s1.n0).properties -> 'objectid') = (n3.properties -> 'objectid')) limit 100) select ordered_edges_to_path(s2.n0, array [s2.e0]::edgecomposite[] || array [s2.e1]::edgecomposite[] || (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(s2.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1, s2.n2, s2.n3]::nodecomposite[])::pathcomposite as p from s2 limit 100; +with s0 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 [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.kind_id = any (array [3, 4]::int2[])), s1 as (select s0.e0 as e0, e1.id as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.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[])), s2 as (with recursive s3_seed(root_id) as not materialized (select distinct (s1.n2).id as root_id from s1), s3(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.start_id = e2.end_id, array [e2.id] from s3_seed join edge e2 on e2.start_id = s3_seed.root_id join node n3 on n3.id = e2.end_id where e2.kind_id = any (array [3]::int2[]) union all select s3.root_id, e2.end_id, s3.depth + 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, s3.path || e2.id from s3 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.start_id = s3.next_id and e2.id != all (s3.path) and e2.kind_id = any (array [3]::int2[]) offset 0) e2 on true join node n3 on n3.id = e2.end_id where s3.depth < 15 and not s3.is_cycle) select s1.e0 as e0, s1.e1 as e1, s3.path as ep0, s1.n0 as n0, s1.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s1, s3 join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s3.root_id offset 0) n2 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s3.next_id offset 0) n3 on true where s3.satisfied and (s1.n2).id = s3.root_id and (((s1.n0).properties -> 'objectid') = (n3.properties -> 'objectid')) limit 100) select ordered_edges_to_path(s2.n0, (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 [s2.e0]::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 [s2.e1]::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(s2.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1, s2.n2, s2.n3]::nodecomposite[])::pathcomposite as p from s2 limit 100; -- case: match (a:NodeKind1)-[:EdgeKind1*0..]->(b:NodeKind1) where a.name = 'solo' and b.name = 'solo' return a.name, b.name with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('solo')::text)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select s1_seed.root_id, s1_seed.root_id, 0, (((n1.properties -> 'name'))::jsonb = to_jsonb(('solo')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, array []::int8[] from s1_seed join node n1 on n1.id = s1_seed.root_id union all select e0.start_id, e0.end_id, 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('solo')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [1]::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]::int2[]) union all select s1.root_id, e0.end_id, s1.depth + 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('solo')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [1]::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]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle and s1.depth > 0) select (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 ((s0.n0).properties -> 'name'), ((s0.n1).properties -> 'name') 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 0c76efa0..c18e4de0 100644 --- a/cypher/models/pgsql/test/translation_cases/shortest_paths.sql +++ b/cypher/models/pgsql/test/translation_cases/shortest_paths.sql @@ -64,7 +64,7 @@ with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (sele -- case: match p=(c:NodeKind1)-[]->(u:NodeKind2) match p2=shortestPath((u:NodeKind2)-[*1..]->(d:NodeKind1)) return p, p2 limit 500 -- 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 distinct n1.id as root_id from traversal_root_filter s2_seed_filter join node n1 on n1.id = s2_seed_filter.id where n1.kind_ids operator (pg_catalog.@\u003e) array [2]::int2[]) select e1.start_id, e1.end_id, 1, exists (select 1 from traversal_terminal_filter where traversal_terminal_filter.id = e1.end_id), e1.start_id = e1.end_id, array [e1.id] from s2_seed join edge e1 on e1.start_id = s2_seed.root_id where case when (select count(*)::int8 from traversal_terminal_filter where traversal_terminal_filter.id = e1.start_id) = 0 then true else shortest_path_self_endpoint_error(e1.start_id, e1.start_id) end;","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s2.root_id, e1.end_id, s2.depth + 1, exists (select 1 from traversal_terminal_filter where traversal_terminal_filter.id = e1.end_id), false, s2.path || e1.id from forward_front s2 join edge e1 on e1.start_id = s2.next_id where e1.id != all (s2.path) and not exists (select 1 from visited where visited.root_id = s2.root_id and visited.id = e1.end_id);"} -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 [2]::int2[] and n1.id = e0.end_id), s1 as (with s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_sp_harness(@pi0::text, @pi1::text, 15, ('insert into traversal_root_filter (id) select distinct (s0.n1).id from s0 where (s0.n1).id is not null;')::text, ('insert into traversal_terminal_filter (id) select distinct n2.id from node n2 where n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id is not null;')::text)) select s0.e0 as e0, s2.path as ep0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join node n1 on n1.id = s2.root_id join node n2 on n2.id = s2.next_id where (s0.n1).id = s2.root_id and case when s2.root_id != s2.next_id then true else shortest_path_self_endpoint_error(s2.root_id, s2.next_id) end) select (array [s1.n0, s1.n1]::nodecomposite[], array [s1.e0]::edgecomposite[])::pathcomposite as p, ordered_edges_to_path(s1.n1, (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(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n1, s1.n2]::nodecomposite[])::pathcomposite as p2 from s1 limit 500; +with s0 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 [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), s1 as (with s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_sp_harness(@pi0::text, @pi1::text, 15, ('insert into traversal_root_filter (id) select distinct (s0.n1).id from s0 where (s0.n1).id is not null;')::text, ('insert into traversal_terminal_filter (id) select distinct n2.id from node n2 where n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id is not null;')::text)) select s0.e0 as e0, s2.path as ep0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join node n1 on n1.id = s2.root_id join node n2 on n2.id = s2.next_id where (s0.n1).id = s2.root_id and 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, (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 [s1.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite as p, ordered_edges_to_path(s1.n1, (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(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n1, s1.n2]::nodecomposite[])::pathcomposite as p2 from s1 limit 500; -- case: match p = allShortestPaths((a)-[:EdgeKind1*..]->()) return p -- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select e0.start_id, e0.end_id, 1, exists (select 1 from edge where end_id = e0.start_id), e0.start_id = e0.end_id, array [e0.id] from edge e0 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 s1.root_id, e0.end_id, s1.depth + 1, exists (select 1 from edge where end_id = e0.start_id), false, s1.path || e0.id from forward_front s1 join edge e0 on e0.start_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path);"} diff --git a/cypher/models/pgsql/test/translation_cases/stepwise_traversal.sql b/cypher/models/pgsql/test/translation_cases/stepwise_traversal.sql index 1369b5f4..28f784cf 100644 --- a/cypher/models/pgsql/test/translation_cases/stepwise_traversal.sql +++ b/cypher/models/pgsql/test/translation_cases/stepwise_traversal.sql @@ -42,7 +42,7 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from 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), s1 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1 from s0, edge e1 join node n2 on n2.id = e1.start_id join node n3 on n3.id = e1.end_id) select s1.e0 as r, s1.e1 as e from s1; -- case: match p = (:NodeKind1)-[:EdgeKind1|EdgeKind2]->(c:NodeKind2) where '123' in c.prop2 or '243' in c.prop2 or size(c.prop2) = 0 return p limit 10 -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 ('123' = any (jsonb_to_text_array((n1.properties -> 'prop2'))::text[]) or '243' = any (jsonb_to_text_array((n1.properties -> 'prop2'))::text[]) or jsonb_array_length((n1.properties -> 'prop2'))::int = 0) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[]) limit 10) select (array [s0.n0, s0.n1]::nodecomposite[], array [s0.e0]::edgecomposite[])::pathcomposite as p from s0 limit 10; +with s0 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 [1]::int2[] and n0.id = e0.start_id join node n1 on ('123' = any (jsonb_to_text_array((n1.properties -> 'prop2'))::text[]) or '243' = any (jsonb_to_text_array((n1.properties -> 'prop2'))::text[]) or jsonb_array_length((n1.properties -> 'prop2'))::int = 0) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[]) limit 10) select ordered_edges_to_path(s0.n0, (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 [s0.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 limit 10; -- case: match ()-[r:EdgeKind1]->() return count(r) as the_count 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 = any (array [3]::int2[])) select count(s0.e0)::int8 as the_count from s0; diff --git a/cypher/models/pgsql/translate/function.go b/cypher/models/pgsql/translate/function.go index 5a475e3b..1d614f88 100644 --- a/cypher/models/pgsql/translate/function.go +++ b/cypher/models/pgsql/translate/function.go @@ -279,6 +279,9 @@ func bindingExpressionType(binding *BoundIdentifier) pgsql.DataType { case pgsql.ExpansionRootNode, pgsql.ExpansionTerminalNode: return pgsql.NodeComposite + case pgsql.PathEdge: + return pgsql.Int8 + default: return binding.DataType } diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index badfe540..65be796a 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -68,5 +68,30 @@ func TestOptimizerSafetyADCSQueryPrunesExpansionEdgeCarry(t *testing.T) { require.Contains(t, normalizedQuery, "s5.ep0 as ep0") require.NotContains(t, normalizedQuery, "s5.e0 as e0") require.Contains(t, normalizedQuery, "from unnest(s12.ep0)") + require.Contains(t, normalizedQuery, "from unnest(array [s12.e1]::int8[])") + require.NotContains(t, normalizedQuery, "array [s12.e1]::edgecomposite[]") require.Contains(t, normalizedQuery, "from s5, s7") } + +func TestOptimizerSafetyReferencedRelationshipStaysComposite(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` +MATCH p = (n:Group)-[r:MemberOf]->(m:Group) +RETURN p, r +`) + require.NoError(t, err) + + translation, err := Translate(context.Background(), regularQuery, optimizerSafetyKindMapper(), nil, DefaultGraphID) + require.NoError(t, err) + + formattedQuery, err := Translated(translation) + require.NoError(t, err) + + normalizedQuery := strings.Join(strings.Fields(formattedQuery), " ") + + require.Contains(t, normalizedQuery, "(e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0") + require.Contains(t, normalizedQuery, "array [s0.e0]::edgecomposite[]") + require.NotContains(t, normalizedQuery, "e0.id as e0") + require.NotContains(t, normalizedQuery, "array [s0.e0]::int8[]") +} diff --git a/cypher/models/pgsql/translate/path_functions.go b/cypher/models/pgsql/translate/path_functions.go index af3e6717..909eac3e 100644 --- a/cypher/models/pgsql/translate/path_functions.go +++ b/cypher/models/pgsql/translate/path_functions.go @@ -25,6 +25,9 @@ func pathCompositeEdgesExpression(scope *Scope, pathBinding *BoundIdentifier) (p CastType: pgsql.EdgeCompositeArray, }) + case pgsql.PathEdge: + edgeArrayReferences = append(edgeArrayReferences, pathEdgeArrayExpression(scope, dependency)) + default: // Path bindings also depend on their node endpoints. Those are not part of relationships(p). } diff --git a/cypher/models/pgsql/translate/projection.go b/cypher/models/pgsql/translate/projection.go index fbecb61c..722e96f2 100644 --- a/cypher/models/pgsql/translate/projection.go +++ b/cypher/models/pgsql/translate/projection.go @@ -201,6 +201,25 @@ func pathCompositeColumnReference(scope *Scope, binding *BoundIdentifier, column return pgsql.CompoundIdentifier{binding.Identifier, column} } +func pathEdgeIDReference(scope *Scope, binding *BoundIdentifier) pgsql.Expression { + if binding.LastProjection != nil || scope.CurrentFrameBinding() != nil { + return pathBindingReference(scope, binding) + } + + return pgsql.CompoundIdentifier{binding.Identifier, pgsql.ColumnID} +} + +func pathEdgeArrayExpression(scope *Scope, edge *BoundIdentifier) pgsql.Expression { + return &pgsql.EdgeArrayFromPathIDs{ + PathIDs: pgsql.ArrayLiteral{ + Values: []pgsql.Expression{ + pathEdgeIDReference(scope, edge), + }, + CastType: pgsql.Int8Array, + }, + } +} + func expansionPathEdgeArrayExpression(scope *Scope, expansionPath *BoundIdentifier) (pgsql.Expression, error) { return &pgsql.EdgeArrayFromPathIDs{ PathIDs: pathBindingReference(scope, expansionPath), @@ -218,6 +237,7 @@ func expressionForPathComposite(projected *BoundIdentifier, scope *Scope) (pgsql directNodeReferences []pgsql.Expression directEdgeReferences []pgsql.Expression seenExpansionPath = false + seenPathEdge = false ) // Path composite components are encoded as dependencies on the bound identifier representing the @@ -242,6 +262,10 @@ func expressionForPathComposite(projected *BoundIdentifier, scope *Scope) (pgsql CastType: pgsql.EdgeCompositeArray, }) + case pgsql.PathEdge: + seenPathEdge = true + edgeArrayReferences = append(edgeArrayReferences, pathEdgeArrayExpression(scope, dependency)) + case pgsql.NodeComposite, pgsql.ExpansionRootNode, pgsql.ExpansionTerminalNode: directNodeReferences = append(directNodeReferences, pathCompositeReference(scope, dependency, pgsql.NodeTableColumns)) nodeReferences = append(nodeReferences, pathCompositeColumnReference(scope, dependency, pgsql.ColumnID)) @@ -255,7 +279,7 @@ func expressionForPathComposite(projected *BoundIdentifier, scope *Scope) (pgsql // those explicit components instead of reconstructing the path from edge IDs: this preserves path // order and duplicate nodes, and it also works for rows produced by data-modifying CTEs where // re-reading node/edge tables in the same statement may not see the RETURNING values. - if !seenExpansionPath && len(directNodeReferences) > 0 { + if !seenExpansionPath && !seenPathEdge && len(directNodeReferences) > 0 { return pgsql.CompositeValue{ DataType: pgsql.PathComposite, Values: []pgsql.Expression{ @@ -271,7 +295,7 @@ func expressionForPathComposite(projected *BoundIdentifier, scope *Scope) (pgsql }, nil } - if seenExpansionPath { + if seenExpansionPath || seenPathEdge { if len(directNodeReferences) == 0 { return nil, fmt.Errorf("expansion path %s does not contain a root node reference", projected.Identifier) } @@ -426,6 +450,27 @@ func buildProjectionForEdgeComposite(alias pgsql.Identifier, projected *BoundIde }, nil } +func buildProjectionForPathEdge(alias pgsql.Identifier, projected *BoundIdentifier, referenceFrame *Frame) ([]pgsql.SelectItem, error) { + var expression pgsql.Expression + + if projected.LastProjection != nil { + if referenceFrame == nil { + referenceFrame = projected.LastProjection + } + + expression = pgsql.CompoundIdentifier{referenceFrame.Binding.Identifier, projected.Identifier} + } else { + expression = pgsql.CompoundIdentifier{projected.Identifier, pgsql.ColumnID} + } + + return []pgsql.SelectItem{ + &pgsql.AliasedExpression{ + Expression: expression, + Alias: pgsql.AsOptionalIdentifier(alias), + }, + }, nil +} + func buildProjection(alias pgsql.Identifier, projected *BoundIdentifier, scope *Scope, referenceFrame *Frame) ([]pgsql.SelectItem, error) { switch projected.DataType { case pgsql.ExpansionPath: @@ -446,6 +491,9 @@ func buildProjection(alias pgsql.Identifier, projected *BoundIdentifier, scope * case pgsql.EdgeComposite: return buildProjectionForEdgeComposite(alias, projected, referenceFrame) + case pgsql.PathEdge: + return buildProjectionForPathEdge(alias, projected, referenceFrame) + default: // If this isn't a type that requires a unique projection, reflect the identifier as-is with its alias var expression pgsql.Expression diff --git a/cypher/models/pgsql/translate/tracking.go b/cypher/models/pgsql/translate/tracking.go index a5c736fe..bfaadc4f 100644 --- a/cypher/models/pgsql/translate/tracking.go +++ b/cypher/models/pgsql/translate/tracking.go @@ -26,6 +26,8 @@ func (s IdentifierGenerator) NewIdentifier(dataType pgsql.DataType) (pgsql.Ident prefixStr = "n" case pgsql.EdgeComposite: prefixStr = "e" + case pgsql.PathEdge: + prefixStr = "e" case pgsql.Scope: prefixStr = "s" case pgsql.ParameterIdentifier: diff --git a/cypher/models/pgsql/translate/traversal.go b/cypher/models/pgsql/translate/traversal.go index fd8ff603..8e33f100 100644 --- a/cypher/models/pgsql/translate/traversal.go +++ b/cypher/models/pgsql/translate/traversal.go @@ -582,6 +582,13 @@ func pruneTraversalStepProjectionExports(queryPart *QueryPart, part *PatternPart traversalStep.Frame.Unexport(traversalStep.LeftNode.Identifier) } + if traversalStep.Edge != nil && + traversalStep.Edge.DataType == pgsql.EdgeComposite && + !queryPart.ReferencesBinding(traversalStep.Edge) && + patternBindingDependsOn(queryPart, part, traversalStep.Edge) { + traversalStep.Edge.DataType = pgsql.PathEdge + } + if !traversalStepProjectsBinding(queryPart, part, stepIndex, traversalStep.Edge) { traversalStep.Frame.Unexport(traversalStep.Edge.Identifier) } From 283a4eb33067f5f9243582090540ed8d1dfd1676 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Wed, 20 May 2026 17:01:44 -0700 Subject: [PATCH 012/116] docs(pgsql): sequence optimizer review followups --- docs/optimization-pass-memory.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/optimization-pass-memory.md b/docs/optimization-pass-memory.md index d53016d0..6b0b40e6 100644 --- a/docs/optimization-pass-memory.md +++ b/docs/optimization-pass-memory.md @@ -158,3 +158,23 @@ Broader benchmark suites and real-world query collections are deferred until aft Implement phases 1 through 6 first. That milestone establishes the PostgreSQL optimizer framework, test bar, predicate ownership, projection and path pruning, and late path materialization. It should improve the reported query shape without taking on endpoint-aware expansion, suffix semi-joins, schema statistics, or a full cost-based planner. + +## Quality Review Follow-Up Plan + +The first optimizer milestone introduced the PostgreSQL optimizer hook, predicate attachment diagnostics, projection pruning, and late path materialization. Before moving on to endpoint-aware expansion or pattern reordering, close the review gaps in this order: + +### Step 1: Preserve The Optional-Match Barrier + +Keep projection pruning and late path materialization scoped to plain `MATCH` translation until optional path semantics have dedicated coverage. `OPTIONAL MATCH` already acts as an optimization-region barrier in the analysis pass; translator-side lowering should respect the same boundary. + +### Step 2: Assert Path Semantics, Not Only SQL Shape + +Expand integration coverage for optimized path returns so tests assert path node order, relationship order, and path length for mixed fixed-hop and variable-length paths. Include `relationships(p)` on paths that are eligible for late materialization. + +### Step 3: Harden Direct Relationship References + +Add focused translation tests proving direct relationship references keep edge composites when used in returned values, predicates, relationship properties, `type(r)`, and endpoint functions such as `startNode(r)`. + +### Step 4: Document Performance Measurement Needs + +Keep the current shape tests as guardrails, but add an explicit measurement task for high-fanout ADCS-style data before expanding the optimizer into endpoint-aware expansion, suffix semi-joins, or deterministic reordering. From cc57a8b0287b01c65ea57ddee882f6f85646a9c7 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Wed, 20 May 2026 17:02:50 -0700 Subject: [PATCH 013/116] fix(pgsql): preserve optional match pruning barrier --- cypher/models/pgsql/translate/expansion.go | 6 +++-- cypher/models/pgsql/translate/match.go | 2 +- .../pgsql/translate/optimizer_safety_test.go | 22 +++++++++++++++++++ cypher/models/pgsql/translate/predicate.go | 2 +- cypher/models/pgsql/translate/traversal.go | 12 +++++----- 5 files changed, 35 insertions(+), 9 deletions(-) diff --git a/cypher/models/pgsql/translate/expansion.go b/cypher/models/pgsql/translate/expansion.go index 168be2bc..e34a7a41 100644 --- a/cypher/models/pgsql/translate/expansion.go +++ b/cypher/models/pgsql/translate/expansion.go @@ -2464,7 +2464,7 @@ func (s *Translator) buildExpansionProjectionConstraints(traversalStepContext Tr return projectionConstraints, nil } -func (s *Translator) translateTraversalPatternPartWithExpansion(part *PatternPart, isFirstTraversalStep bool, traversalStep *TraversalStep) error { +func (s *Translator) translateTraversalPatternPartWithExpansion(part *PatternPart, isFirstTraversalStep bool, traversalStep *TraversalStep, allowProjectionPruning bool) error { expansionModel := traversalStep.Expansion // Translate the expansion's constraints - this has the side effect of making the pattern identifiers visible in @@ -2475,7 +2475,9 @@ func (s *Translator) translateTraversalPatternPartWithExpansion(part *PatternPar // Export the path from the traversal's scope traversalStep.Frame.Export(expansionModel.PathBinding.Identifier) - pruneExpansionStepProjectionExports(s.query.CurrentPart(), part, traversalStep) + if allowProjectionPruning { + pruneExpansionStepProjectionExports(s.query.CurrentPart(), part, traversalStep) + } // Push a new frame that contains currently projected scope from the expansion recursive CTE if expansionFrame, err := s.scope.PushFrame(); err != nil { diff --git a/cypher/models/pgsql/translate/match.go b/cypher/models/pgsql/translate/match.go index ba86e010..f93a5d5f 100644 --- a/cypher/models/pgsql/translate/match.go +++ b/cypher/models/pgsql/translate/match.go @@ -16,7 +16,7 @@ func (s *Translator) translateMatch(match *cypher.Match) error { return err } } else { - if err := s.translateTraversalPatternPart(part, false); err != nil { + if err := s.translateTraversalPatternPart(part, false, !match.Optional); err != nil { return err } } diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index 65be796a..aab84693 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -95,3 +95,25 @@ RETURN p, r require.NotContains(t, normalizedQuery, "e0.id as e0") require.NotContains(t, normalizedQuery, "array [s0.e0]::int8[]") } + +func TestOptimizerSafetyOptionalMatchPathStaysComposite(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` +MATCH (n:Group) +OPTIONAL MATCH p = (n)-[:MemberOf]->(m:Group) +RETURN n, p +`) + require.NoError(t, err) + + translation, err := Translate(context.Background(), regularQuery, optimizerSafetyKindMapper(), nil, DefaultGraphID) + require.NoError(t, err) + + formattedQuery, err := Translated(translation) + require.NoError(t, err) + + normalizedQuery := strings.Join(strings.Fields(formattedQuery), " ") + + require.Contains(t, normalizedQuery, "::edgecomposite[]") + require.NotContains(t, normalizedQuery, "::int8[]") +} diff --git a/cypher/models/pgsql/translate/predicate.go b/cypher/models/pgsql/translate/predicate.go index d14b37f0..002d3e95 100644 --- a/cypher/models/pgsql/translate/predicate.go +++ b/cypher/models/pgsql/translate/predicate.go @@ -123,7 +123,7 @@ func (s *Translator) buildPatternPredicates() error { } } - if err := s.translateTraversalPatternPart(patternPart, true); err != nil { + if err := s.translateTraversalPatternPart(patternPart, true, true); err != nil { return err } diff --git a/cypher/models/pgsql/translate/traversal.go b/cypher/models/pgsql/translate/traversal.go index 8e33f100..189d94cb 100644 --- a/cypher/models/pgsql/translate/traversal.go +++ b/cypher/models/pgsql/translate/traversal.go @@ -503,7 +503,7 @@ func (s *Translator) buildTraversalPatternStep(partFrame *Frame, traversalStep * }, nil } -func (s *Translator) translateTraversalPatternPart(part *PatternPart, isolatedProjection bool) error { +func (s *Translator) translateTraversalPatternPart(part *PatternPart, isolatedProjection bool, allowProjectionPruning bool) error { var scopeSnapshot *Scope if isolatedProjection { @@ -519,12 +519,12 @@ func (s *Translator) translateTraversalPatternPart(part *PatternPart, isolatedPr } if traversalStep.Expansion != nil { - if err := s.translateTraversalPatternPartWithExpansion(part, idx == 0, traversalStep); err != nil { + if err := s.translateTraversalPatternPartWithExpansion(part, idx == 0, traversalStep, allowProjectionPruning); err != nil { return err } } else if part.AllShortestPaths || part.ShortestPath { return fmt.Errorf("expected shortest path search to utilize variable expansion: ()-[*..]->()") - } else if err := s.translateTraversalPatternPartWithoutExpansion(part, idx, traversalStep); err != nil { + } else if err := s.translateTraversalPatternPartWithoutExpansion(part, idx, traversalStep, allowProjectionPruning); err != nil { return err } } @@ -617,7 +617,7 @@ func pruneExpansionStepProjectionExports(queryPart *QueryPart, part *PatternPart } } -func (s *Translator) translateTraversalPatternPartWithoutExpansion(part *PatternPart, stepIndex int, traversalStep *TraversalStep) error { +func (s *Translator) translateTraversalPatternPartWithoutExpansion(part *PatternPart, stepIndex int, traversalStep *TraversalStep, allowProjectionPruning bool) error { isFirstTraversalStep := stepIndex == 0 if constraints, err := consumePatternConstraints(isFirstTraversalStep, nonRecursivePattern, traversalStep, s.treeTranslator); err != nil { @@ -694,7 +694,9 @@ func (s *Translator) translateTraversalPatternPartWithoutExpansion(part *Pattern } } - pruneTraversalStepProjectionExports(s.query.CurrentPart(), part, stepIndex, traversalStep) + if allowProjectionPruning { + pruneTraversalStepProjectionExports(s.query.CurrentPart(), part, stepIndex, traversalStep) + } if boundProjections, err := buildVisibleProjections(s.scope); err != nil { return err From fe4393ef4b7366817ff324c0efbc23d5be798610 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Wed, 20 May 2026 17:05:52 -0700 Subject: [PATCH 014/116] test(integration): assert optimized path semantics --- integration/cypher_test.go | 67 +++++++++++++++++++ .../cases/pattern_binding_inline.json | 2 +- .../testdata/templates/pattern_shapes.json | 9 ++- tools/metrics/internal/metrics/quality.go | 1 + 4 files changed, 77 insertions(+), 2 deletions(-) diff --git a/integration/cypher_test.go b/integration/cypher_test.go index c66e2745..334a9168 100644 --- a/integration/cypher_test.go +++ b/integration/cypher_test.go @@ -145,6 +145,7 @@ func TestCypher(t *testing.T) { // {"path_node_ids": [["a", "b"]]} — exact multiset of returned path node ID sequences // {"path_lengths": [N...]} — exact multiset of returned path edge counts // {"path_edge_kinds": [["K"...]]} — exact multiset of returned path edge kind sequences +// {"relationship_list_kinds": [["K"...]]} — exact multiset of returned relationship-list kind sequences // // Object assertions may combine multiple keys; every assertion must pass. func parseAssertion(t *testing.T, raw json.RawMessage) caseAssertion { @@ -231,6 +232,9 @@ func parseAssertion(t *testing.T, raw json.RawMessage) caseAssertion { case "path_edge_kinds": assertions = append(assertions, assertPathEdgeKinds(decodeAssertionValue[[][]string](t, key, val))) + case "relationship_list_kinds": + assertions = append(assertions, assertRelationshipListKinds(decodeAssertionValue[[][]string](t, key, val))) + default: t.Fatalf("unknown assertion key: %q", key) } @@ -885,6 +889,35 @@ func assertPathEdgeKinds(expected [][]string) resultAssertion { } } +func assertRelationshipListKinds(expected [][]string) resultAssertion { + return func(t *testing.T, result queryResult, _ assertionContext) { + t.Helper() + + got := make([]string, 0, len(result.rows)) + for _, row := range result.rows { + for _, rawVal := range row.values { + var relationshipPointers []*graph.Relationship + if result.mapper.Map(rawVal, &relationshipPointers) { + got = append(got, relationshipListKindSignature(t, relationshipPointers)) + continue + } + + var relationships []graph.Relationship + if result.mapper.Map(rawVal, &relationships) { + got = append(got, relationshipValueListKindSignature(t, relationships)) + } + } + } + + want := make([]string, len(expected)) + for idx, expectedKinds := range expected { + want[idx] = strings.Join(expectedKinds, "->") + } + + assertStringMultiset(t, got, want, "relationship-list kind sequences") + } +} + func pathNodeIDSignature(t *testing.T, path graph.Path, ctx assertionContext) string { t.Helper() @@ -919,6 +952,40 @@ func pathEdgeKindSignature(t *testing.T, path graph.Path) string { return strings.Join(edgeKinds, "->") } +func relationshipListKindSignature(t *testing.T, relationships []*graph.Relationship) string { + t.Helper() + + edgeKinds := make([]string, len(relationships)) + for idx, relationship := range relationships { + if relationship == nil { + t.Fatalf("relationship list contains nil relationship at index %d", idx) + } + + if relationship.Kind == nil { + t.Fatalf("relationship list item at index %d has nil kind", idx) + } + + edgeKinds[idx] = relationship.Kind.String() + } + + return strings.Join(edgeKinds, "->") +} + +func relationshipValueListKindSignature(t *testing.T, relationships []graph.Relationship) string { + t.Helper() + + edgeKinds := make([]string, len(relationships)) + for idx, relationship := range relationships { + if relationship.Kind == nil { + t.Fatalf("relationship list item at index %d has nil kind", idx) + } + + edgeKinds[idx] = relationship.Kind.String() + } + + return strings.Join(edgeKinds, "->") +} + func collectPaths(t *testing.T, result queryResult) []graph.Path { t.Helper() diff --git a/integration/testdata/cases/pattern_binding_inline.json b/integration/testdata/cases/pattern_binding_inline.json index 02d482a7..a9f482de 100644 --- a/integration/testdata/cases/pattern_binding_inline.json +++ b/integration/testdata/cases/pattern_binding_inline.json @@ -144,7 +144,7 @@ {"start_id": "b", "end_id": "t", "kind": "EdgeKind2"} ] }, - "assert": "non_empty" + "assert": {"path_lengths": [2], "path_node_ids": [["a", "b", "t"]], "path_edge_kinds": [["EdgeKind1", "EdgeKind2"]]} }, { "name": "filter a typed node with WHERE then bind its variable-length expansion path", diff --git a/integration/testdata/templates/pattern_shapes.json b/integration/testdata/templates/pattern_shapes.json index f7e117dc..563cceb7 100644 --- a/integration/testdata/templates/pattern_shapes.json +++ b/integration/testdata/templates/pattern_shapes.json @@ -77,7 +77,7 @@ ] }, { - "name": "path node-list functions", + "name": "path component functions", "template": "{{query}}", "fixture": { "nodes": [ @@ -107,6 +107,13 @@ "query": "match p=(e:TemplateNodeKind1)<-[:TemplateEdgeKind1]-(d:TemplateNodeKind2) where e.name = 'inbound' return nodes(p)" }, "assert": {"node_list_ids": [["e", "d"]]} + }, + { + "name": "relationships function returns path traversal order", + "vars": { + "query": "match p=(a:TemplateNodeKind1)-[:TemplateEdgeKind1]->(b:TemplateNodeKind2)-[:TemplateEdgeKind2]->(c:TemplateNodeKind1) where a.name = 'src' return relationships(p)" + }, + "assert": {"relationship_list_kinds": [["TemplateEdgeKind1", "TemplateEdgeKind2"]]} } ] }, diff --git a/tools/metrics/internal/metrics/quality.go b/tools/metrics/internal/metrics/quality.go index a260dadd..3514ddfe 100644 --- a/tools/metrics/internal/metrics/quality.go +++ b/tools/metrics/internal/metrics/quality.go @@ -1380,6 +1380,7 @@ var allowedObjectAssertions = map[string]struct{}{ "path_edge_kinds": {}, "path_lengths": {}, "path_node_ids": {}, + "relationship_list_kinds": {}, "row_count": {}, "row_values": {}, "scalar_values": {}, From b966e46cbc86bd5b00f7d2ee472591d3e6acdb86 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Wed, 20 May 2026 17:06:20 -0700 Subject: [PATCH 015/116] test(pgsql): guard relationship expression materialization --- .../pgsql/translate/optimizer_safety_test.go | 62 ++++++++++++++++--- 1 file changed, 54 insertions(+), 8 deletions(-) diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index aab84693..fb1d22bf 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -73,13 +73,10 @@ func TestOptimizerSafetyADCSQueryPrunesExpansionEdgeCarry(t *testing.T) { require.Contains(t, normalizedQuery, "from s5, s7") } -func TestOptimizerSafetyReferencedRelationshipStaysComposite(t *testing.T) { - t.Parallel() +func assertOptimizerSafetyRelationshipStaysComposite(t *testing.T, cypherQuery string) { + t.Helper() - regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` -MATCH p = (n:Group)-[r:MemberOf]->(m:Group) -RETURN p, r -`) + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), cypherQuery) require.NoError(t, err) translation, err := Translate(context.Background(), regularQuery, optimizerSafetyKindMapper(), nil, DefaultGraphID) @@ -91,9 +88,58 @@ RETURN p, r normalizedQuery := strings.Join(strings.Fields(formattedQuery), " ") require.Contains(t, normalizedQuery, "(e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0") - require.Contains(t, normalizedQuery, "array [s0.e0]::edgecomposite[]") + require.Contains(t, normalizedQuery, "::edgecomposite") require.NotContains(t, normalizedQuery, "e0.id as e0") - require.NotContains(t, normalizedQuery, "array [s0.e0]::int8[]") + require.NotContains(t, normalizedQuery, "::int8[]") +} + +func TestOptimizerSafetyReferencedRelationshipStaysComposite(t *testing.T) { + t.Parallel() + + assertOptimizerSafetyRelationshipStaysComposite(t, ` +MATCH p = (n:Group)-[r:MemberOf]->(m:Group) +RETURN p, r +`) +} + +func TestOptimizerSafetyRelationshipExpressionReferencesStayComposite(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + query string + }{ + { + name: "type return", + query: ` +MATCH p = (n:Group)-[r:MemberOf]->(m:Group) +RETURN p, type(r) +`, + }, + { + name: "property predicate", + query: ` +MATCH p = (n:Group)-[r:MemberOf]->(m:Group) +WHERE r.label = 'member' +RETURN p +`, + }, + { + name: "start node return", + query: ` +MATCH p = (n:Group)-[r:MemberOf]->(m:Group) +RETURN p, startNode(r) +`, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + assertOptimizerSafetyRelationshipStaysComposite(t, testCase.query) + }) + } } func TestOptimizerSafetyOptionalMatchPathStaysComposite(t *testing.T) { From 83ca057f5e6089450ae71522215666fa3e8017d9 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Wed, 20 May 2026 17:06:41 -0700 Subject: [PATCH 016/116] docs(pgsql): capture optimizer measurement gaps --- docs/optimization-pass-memory.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/optimization-pass-memory.md b/docs/optimization-pass-memory.md index 6b0b40e6..ebdd979f 100644 --- a/docs/optimization-pass-memory.md +++ b/docs/optimization-pass-memory.md @@ -178,3 +178,25 @@ Add focused translation tests proving direct relationship references keep edge c ### Step 4: Document Performance Measurement Needs Keep the current shape tests as guardrails, but add an explicit measurement task for high-fanout ADCS-style data before expanding the optimizer into endpoint-aware expansion, suffix semi-joins, or deterministic reordering. + +## Quality Review Status Notes + +The review follow-up should leave the first optimizer milestone in a measured state before the next rule is attempted. + +- `OPTIONAL MATCH` must remain a translator pruning barrier until optional path returns and optional path functions have semantic integration coverage. +- Mixed fixed-hop plus variable-length path returns should assert exact node order, relationship order, and path length. These cases exercise the same late-materialization mechanics as the motivating query with a smaller result surface. +- `relationships(p)` should have relationship-list assertions so path component functions are checked directly instead of indirectly through SQL shape. +- Direct relationship bindings referenced by return expressions, predicates, `type(r)`, or endpoint functions must keep edge composites and must not be narrowed to path-edge IDs. +- The ADCS fixture currently has SQL-shape and containment coverage. Stricter path cardinality assertions on PostgreSQL exposed duplicated returned path rows during review, so exact cardinality for that fixture should be investigated as part of the high-fanout measurement work rather than added as a passing oracle prematurely. + +## Measurement Checklist Before Phase 7 + +Before implementing expand-into detection, capture the following for the motivating ADCS query and a synthetic fanout variant: + +- `EXPLAIN (ANALYZE, BUFFERS)` for `p1` alone, `p2` alone, and the combined query. +- Result row count, distinct `(p1, p2)` count, and duplicate-row count. +- Intermediate row counts for expansion CTEs before and after projection pruning. +- Final path reconstruction cost when paths are returned versus when only endpoint keys are returned. +- Comparison with Neo4j result cardinality for the same fixture. + +Projection pruning and late path materialization currently live in PostgreSQL translator lowering. If later phases need richer rule-level ordering or barrier enforcement, promote these decisions into explicit optimizer rule metadata instead of adding more hidden translator-side state. From 47bdfa24a7d46389652a760c0a604ca01fcb0bb7 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Wed, 20 May 2026 17:07:23 -0700 Subject: [PATCH 017/116] test(pgsql): update optional match barrier shape --- cypher/models/pgsql/test/translation_cases/multipart.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cypher/models/pgsql/test/translation_cases/multipart.sql b/cypher/models/pgsql/test/translation_cases/multipart.sql index 6a420e42..241c42d5 100644 --- a/cypher/models/pgsql/test/translation_cases/multipart.sql +++ b/cypher/models/pgsql/test/translation_cases/multipart.sql @@ -90,4 +90,4 @@ with s0 as (with s1 as (with recursive s2_seed(root_id) as not materialized (sel with s0 as (with s1 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 [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.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[])) 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[])) select 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 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 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 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 = 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); From d2dfcbb31e9bfda8ba694f79ad5336b919d631dd Mon Sep 17 00:00:00 2001 From: John Hopper Date: Wed, 20 May 2026 17:22:01 -0700 Subject: [PATCH 018/116] feat(pgsql): lower bound fixed hops as expand-into --- .../translation_cases/pattern_binding.sql | 4 +- .../pgsql/translate/optimizer_safety_test.go | 24 +++++++ cypher/models/pgsql/translate/traversal.go | 72 +++++++++++++++++++ .../cases/pattern_binding_inline.json | 2 +- 4 files changed, 99 insertions(+), 3 deletions(-) diff --git a/cypher/models/pgsql/test/translation_cases/pattern_binding.sql b/cypher/models/pgsql/test/translation_cases/pattern_binding.sql index 772dc2b3..b56adb40 100644 --- a/cypher/models/pgsql/test/translation_cases/pattern_binding.sql +++ b/cypher/models/pgsql/test/translation_cases/pattern_binding.sql @@ -66,10 +66,10 @@ with s0 as (select e0.id as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposi with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((n0.properties ->> 'samaccountname') = any (array ['foo', 'bar']::text[])) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n0).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (coalesce((n1.properties ->> 'system_tags'), '')::text like '%admin_tier_0%'), e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, (coalesce((n1.properties ->> 'system_tags'), '')::text like '%admin_tier_0%'), 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.start_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.end_id where s2.depth < 3 and not s2.is_cycle) select 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 s0, 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 and (s0.n0).id = s2.root_id) select ordered_edges_to_path(s1.n0, (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(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite as p from s1 limit 1000; -- case: match (x:NodeKind1) where x.name = 'foo' match (y:NodeKind2) where y.name = 'bar' match p=(x)-[:EdgeKind1]->(y) return p -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('foo')::text)::jsonb) and 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 -> 'name'))::jsonb = to_jsonb(('bar')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s2 as (select e0.id as e0, s1.n0 as n0, s1.n1 as n1 from s1 join edge e0 on (s1.n0).id = e0.start_id join node n1 on (s1.n1).id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select ordered_edges_to_path(s2.n0, (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 [s2.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1]::nodecomposite[])::pathcomposite as p from s2; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('foo')::text)::jsonb) and 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 -> 'name'))::jsonb = to_jsonb(('bar')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s2 as (select e0.id as e0, s1.n0 as n0, s1.n1 as n1 from s1 join edge e0 on (s1.n0).id = e0.start_id and (s1.n1).id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select ordered_edges_to_path(s2.n0, (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 [s2.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1]::nodecomposite[])::pathcomposite as p from s2; -- case: match (x:NodeKind1{name:'foo'}) match (y:NodeKind2{name:'bar'}) match p=(x)-[:EdgeKind1]->(y) return p -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[] and ((n0.properties -> 'name'))::jsonb = to_jsonb(('foo')::text)::jsonb), s1 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n1.properties -> 'name'))::jsonb = to_jsonb(('bar')::text)::jsonb), s2 as (select e0.id as e0, s1.n0 as n0, s1.n1 as n1 from s1 join edge e0 on (s1.n0).id = e0.start_id join node n1 on (s1.n1).id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select ordered_edges_to_path(s2.n0, (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 [s2.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1]::nodecomposite[])::pathcomposite as p 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[] and ((n0.properties -> 'name'))::jsonb = to_jsonb(('foo')::text)::jsonb), s1 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n1.properties -> 'name'))::jsonb = to_jsonb(('bar')::text)::jsonb), s2 as (select e0.id as e0, s1.n0 as n0, s1.n1 as n1 from s1 join edge e0 on (s1.n0).id = e0.start_id and (s1.n1).id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select ordered_edges_to_path(s2.n0, (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 [s2.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1]::nodecomposite[])::pathcomposite as p from s2; -- case: match (x:NodeKind1{name:'foo'}) match p=(x)-[:EdgeKind1]->(y:NodeKind2{name:'bar'}) return p 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[] and ((n0.properties -> 'name'))::jsonb = to_jsonb(('foo')::text)::jsonb), s1 as (select e0.id 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.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n1.properties -> 'name'))::jsonb = to_jsonb(('bar')::text)::jsonb and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select ordered_edges_to_path(s1.n0, (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 [s1.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite as p from s1; diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index fb1d22bf..f28fdd8f 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -163,3 +163,27 @@ RETURN n, p require.Contains(t, normalizedQuery, "::edgecomposite[]") require.NotContains(t, normalizedQuery, "::int8[]") } + +func TestOptimizerSafetyFixedHopExpandIntoUsesBoundEndpoints(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` +MATCH (a:Group) +MATCH (b:Group) +MATCH p = (a)-[:MemberOf]->(b) +RETURN p +`) + require.NoError(t, err) + + translation, err := Translate(context.Background(), regularQuery, optimizerSafetyKindMapper(), nil, DefaultGraphID) + require.NoError(t, err) + + formattedQuery, err := Translated(translation) + require.NoError(t, err) + + normalizedQuery := strings.Join(strings.Fields(formattedQuery), " ") + + require.Contains(t, normalizedQuery, "(s1.n0).id = e0.start_id") + require.Contains(t, normalizedQuery, "(s1.n1).id = e0.end_id") + require.NotContains(t, normalizedQuery, "join node") +} diff --git a/cypher/models/pgsql/translate/traversal.go b/cypher/models/pgsql/translate/traversal.go index 189d94cb..5164c688 100644 --- a/cypher/models/pgsql/translate/traversal.go +++ b/cypher/models/pgsql/translate/traversal.go @@ -9,7 +9,71 @@ import ( "github.com/specterops/dawgs/graph" ) +func boundEndpointIDReference(frame *Frame, binding *BoundIdentifier) pgsql.RowColumnReference { + return pgsql.RowColumnReference{ + Identifier: pgsql.CompoundIdentifier{frame.Binding.Identifier, binding.Identifier}, + Column: pgsql.ColumnID, + } +} + +func boundEndpointInequality(frame *Frame, traversalStep *TraversalStep) pgsql.Expression { + return pgsql.NewParenthetical( + pgsql.NewBinaryExpression( + boundEndpointIDReference(frame, traversalStep.LeftNode), + pgsql.OperatorCypherNotEquals, + boundEndpointIDReference(frame, traversalStep.RightNode), + ), + ) +} + +func (s *Translator) buildBoundEndpointTraversalPattern(partFrame *Frame, traversalStep *TraversalStep) (pgsql.Query, error) { + if partFrame == nil || partFrame.Previous == nil { + return pgsql.Query{}, errors.New("expected previous frame for bound endpoint traversal") + } + + var ( + previousFrame = partFrame.Previous + nextSelect = pgsql.Select{ + Projection: traversalStep.Projection, + From: []pgsql.FromClause{{ + Source: pgsql.TableReference{ + Name: pgsql.CompoundIdentifier{previousFrame.Binding.Identifier}, + }, + Joins: []pgsql.Join{{ + Table: pgsql.TableReference{ + Name: pgsql.CompoundIdentifier{pgsql.TableEdge}, + Binding: models.OptionalValue(traversalStep.Edge.Identifier), + }, + JoinOperator: pgsql.JoinOperator{ + JoinType: pgsql.JoinTypeInner, + Constraint: pgsql.OptionalAnd( + traversalStep.EdgeJoinCondition, + traversalStep.RightNodeJoinCondition, + ), + }, + }}, + }}, + } + ) + + nextSelect.Where = pgsql.OptionalAnd(traversalStep.LeftNodeConstraints, nextSelect.Where) + nextSelect.Where = pgsql.OptionalAnd(traversalStep.EdgeConstraints.Expression, nextSelect.Where) + nextSelect.Where = pgsql.OptionalAnd(traversalStep.RightNodeConstraints, nextSelect.Where) + + if traversalStep.Direction == graph.DirectionBoth && traversalStep.LeftNode.Identifier != traversalStep.RightNode.Identifier { + nextSelect.Where = pgsql.OptionalAnd(boundEndpointInequality(previousFrame, traversalStep), nextSelect.Where) + } + + return pgsql.Query{ + Body: nextSelect, + }, nil +} + func (s *Translator) buildDirectionlessTraversalPatternRoot(traversalStep *TraversalStep) (pgsql.Query, error) { + if traversalStep.LeftNodeBound && traversalStep.RightNodeBound { + return s.buildBoundEndpointTraversalPattern(traversalStep.Frame, traversalStep) + } + var ( // Partition node constraints rightJoinLocal, rightJoinExternal = partitionConstraintByLocality( @@ -258,6 +322,10 @@ func (s *Translator) buildTraversalPatternRoot(partFrame *Frame, traversalStep * return s.buildDirectionlessTraversalPatternRoot(traversalStep) } + if traversalStep.LeftNodeBound && traversalStep.RightNodeBound { + return s.buildBoundEndpointTraversalPattern(partFrame, traversalStep) + } + var ( // Partition right-node constraints: only locally-scoped terms go into JOIN ON. // Constraints that reference comma-connected CTEs (e.g. s0.i0 from a prior WITH) @@ -440,6 +508,10 @@ func (s *Translator) buildTraversalPatternRoot(partFrame *Frame, traversalStep * } func (s *Translator) buildTraversalPatternStep(partFrame *Frame, traversalStep *TraversalStep) (pgsql.Query, error) { + if traversalStep.LeftNodeBound && traversalStep.RightNodeBound { + return s.buildBoundEndpointTraversalPattern(partFrame, traversalStep) + } + nextSelect := pgsql.Select{ Projection: traversalStep.Projection, } diff --git a/integration/testdata/cases/pattern_binding_inline.json b/integration/testdata/cases/pattern_binding_inline.json index a9f482de..45879ecd 100644 --- a/integration/testdata/cases/pattern_binding_inline.json +++ b/integration/testdata/cases/pattern_binding_inline.json @@ -168,7 +168,7 @@ ], "edges": [{"start_id": "x", "end_id": "y", "kind": "EdgeKind1"}] }, - "assert": "non_empty" + "assert": {"path_lengths": [1], "path_node_ids": [["x", "y"]], "path_edge_kinds": [["EdgeKind1"]]} }, { "name": "match a node with an inline property map then bind its outgoing path to a second inline-map node", From 171f5a4535aeb5d730fa2318b8ccb09cc0a13eee Mon Sep 17 00:00:00 2001 From: John Hopper Date: Wed, 20 May 2026 17:25:32 -0700 Subject: [PATCH 019/116] feat(pgsql): reorder independent node anchors --- cypher/models/pgsql/optimize/optimizer.go | 1 + .../models/pgsql/optimize/optimizer_test.go | 71 +++++- cypher/models/pgsql/optimize/reordering.go | 218 ++++++++++++++++++ .../pgsql/translate/optimizer_safety_test.go | 28 +++ .../cases/pattern_binding_inline.json | 16 ++ 5 files changed, 332 insertions(+), 2 deletions(-) create mode 100644 cypher/models/pgsql/optimize/reordering.go diff --git a/cypher/models/pgsql/optimize/optimizer.go b/cypher/models/pgsql/optimize/optimizer.go index 87556176..ef66f8ab 100644 --- a/cypher/models/pgsql/optimize/optimizer.go +++ b/cypher/models/pgsql/optimize/optimizer.go @@ -48,6 +48,7 @@ func NewOptimizer(rules ...Rule) Optimizer { func DefaultRules() []Rule { return []Rule{ + ConservativePatternReorderingRule{}, PredicateAttachmentRule{}, } } diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index b36f8316..332a3d89 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/specterops/dawgs/cypher/frontend" + "github.com/specterops/dawgs/cypher/models/cypher" "github.com/stretchr/testify/require" ) @@ -31,7 +32,10 @@ func TestOptimizeCopiesAndAnalyzesQuery(t *testing.T) { require.Len(t, plan.Analysis.QueryParts, 1) require.Len(t, plan.Analysis.QueryParts[0].Regions, 1) require.Equal(t, []string{"p1", "p2"}, plan.Analysis.QueryParts[0].ProjectionDependencies) - require.Equal(t, []RuleResult{{Name: "PredicateAttachment", Applied: true}}, plan.Rules) + require.Equal(t, []RuleResult{ + {Name: "ConservativePatternReordering", Applied: false}, + {Name: "PredicateAttachment", Applied: true}, + }, plan.Rules) require.Len(t, plan.PredicateAttachments, 2) } @@ -56,7 +60,10 @@ func TestDefaultPredicateAttachmentRuleReportsSkippedWhenNoPredicatesExist(t *te plan, err := Optimize(regularQuery) require.NoError(t, err) - require.Equal(t, []RuleResult{{Name: "PredicateAttachment", Applied: false}}, plan.Rules) + require.Equal(t, []RuleResult{ + {Name: "ConservativePatternReordering", Applied: false}, + {Name: "PredicateAttachment", Applied: false}, + }, plan.Rules) require.Empty(t, plan.PredicateAttachments) } @@ -115,3 +122,63 @@ func TestPredicateAttachmentRuleKeepsMultiBindingPredicatesAtRegionScope(t *test Dependencies: []string{"a", "b"}, }, plan.PredicateAttachments[0]) } + +func firstNodeSymbol(readingClause *cypher.ReadingClause) string { + if readingClause == nil || readingClause.Match == nil || len(readingClause.Match.Pattern) == 0 { + return "" + } + + nodePattern, ok := singleNodePattern(readingClause.Match.Pattern[0]) + if !ok || nodePattern.Variable == nil { + return "" + } + + return nodePattern.Variable.Symbol +} + +func TestConservativePatternReorderingMovesIndependentNodeAnchorsEarlier(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH (a) + MATCH (b:Group {objectid: 'target'}) + MATCH p = (a)-[:MemberOf]->(b) + RETURN p + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Equal(t, []RuleResult{ + {Name: "ConservativePatternReordering", Applied: true}, + {Name: "PredicateAttachment", Applied: false}, + }, plan.Rules) + + readingClauses := plan.Query.SingleQuery.SinglePartQuery.ReadingClauses + require.Equal(t, "b", firstNodeSymbol(readingClauses[0])) + require.Equal(t, "a", firstNodeSymbol(readingClauses[1])) + require.Len(t, readingClauses[2].Match.Pattern[0].PatternElements, 3) +} + +func TestConservativePatternReorderingKeepsDependentAnchorsInPlace(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH (a) + MATCH (b:Group) + WHERE b.name = a.name + RETURN b + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Equal(t, []RuleResult{ + {Name: "ConservativePatternReordering", Applied: false}, + {Name: "PredicateAttachment", Applied: true}, + }, plan.Rules) + + readingClauses := plan.Query.SingleQuery.SinglePartQuery.ReadingClauses + require.Equal(t, "a", firstNodeSymbol(readingClauses[0])) + require.Equal(t, "b", firstNodeSymbol(readingClauses[1])) +} diff --git a/cypher/models/pgsql/optimize/reordering.go b/cypher/models/pgsql/optimize/reordering.go new file mode 100644 index 00000000..7c46a67d --- /dev/null +++ b/cypher/models/pgsql/optimize/reordering.go @@ -0,0 +1,218 @@ +package optimize + +import ( + "sort" + + "github.com/specterops/dawgs/cypher/models/cypher" +) + +type ConservativePatternReorderingRule struct{} + +func (s ConservativePatternReorderingRule) Name() string { + return "ConservativePatternReordering" +} + +func (s ConservativePatternReorderingRule) Apply(plan *Plan) (bool, error) { + if plan == nil || plan.Query == nil || plan.Query.SingleQuery == nil { + return false, nil + } + + if plan.Query.SingleQuery.MultiPartQuery != nil { + return reorderMultiPartQuery(plan.Query.SingleQuery.MultiPartQuery, plan.Analysis), nil + } + + if plan.Query.SingleQuery.SinglePartQuery != nil { + return reorderSinglePartQuery(plan.Query.SingleQuery.SinglePartQuery, plan.Analysis), nil + } + + return false, nil +} + +type reorderCandidate struct { + clause *cypher.ReadingClause + rank int + index int +} + +func reorderMultiPartQuery(query *cypher.MultiPartQuery, analysis Analysis) bool { + var applied bool + + for partIndex, part := range query.Parts { + if part == nil { + continue + } + + if queryPart, ok := analysisQueryPart(analysis, partIndex); ok { + applied = reorderReadingClauses(part.ReadingClauses, queryPart.Regions) || applied + } + } + + if query.SinglePartQuery != nil { + if queryPart, ok := analysisQueryPart(analysis, len(query.Parts)); ok { + applied = reorderReadingClauses(query.SinglePartQuery.ReadingClauses, queryPart.Regions) || applied + } + } + + return applied +} + +func reorderSinglePartQuery(query *cypher.SinglePartQuery, analysis Analysis) bool { + if queryPart, ok := analysisQueryPart(analysis, 0); ok { + return reorderReadingClauses(query.ReadingClauses, queryPart.Regions) + } + + return false +} + +func analysisQueryPart(analysis Analysis, index int) (QueryPart, bool) { + for _, queryPart := range analysis.QueryParts { + if queryPart.Index == index { + return queryPart, true + } + } + + return QueryPart{}, false +} + +func reorderReadingClauses(readingClauses []*cypher.ReadingClause, regions []Region) bool { + var applied bool + + for _, region := range regions { + if region.StartClause < 0 || region.EndClause >= len(readingClauses) || region.StartClause >= region.EndClause { + continue + } + + applied = reorderRegion(readingClauses[region.StartClause:region.EndClause+1]) || applied + } + + return applied +} + +func reorderRegion(regionClauses []*cypher.ReadingClause) bool { + candidates := make([]reorderCandidate, len(regionClauses)) + declaredBefore := map[string]struct{}{} + + for idx, clause := range regionClauses { + candidates[idx] = reorderCandidate{ + clause: clause, + rank: matchClauseRank(clause, declaredBefore), + index: idx, + } + + for _, binding := range bindingsForReadingClause(idx, clause) { + declaredBefore[binding.Symbol] = struct{}{} + } + } + + sort.SliceStable(candidates, func(i, j int) bool { + return candidates[i].rank < candidates[j].rank + }) + + var applied bool + for idx, candidate := range candidates { + if regionClauses[idx] != candidate.clause { + applied = true + regionClauses[idx] = candidate.clause + } + } + + return applied +} + +func matchClauseRank(readingClause *cypher.ReadingClause, declaredBefore map[string]struct{}) int { + if isIndependentNodeAnchor(readingClause, declaredBefore) { + return 0 + } + + return 1 +} + +func isIndependentNodeAnchor(readingClause *cypher.ReadingClause, declaredBefore map[string]struct{}) bool { + if readingClause == nil || readingClause.Match == nil { + return false + } + + match := readingClause.Match + if match.Optional || len(match.Pattern) != 1 { + return false + } + + nodePattern, ok := singleNodePattern(match.Pattern[0]) + if !ok || nodePattern.Variable == nil || nodePattern.Variable.Symbol == "" { + return false + } + + if _, alreadyDeclared := declaredBefore[nodePattern.Variable.Symbol]; alreadyDeclared { + return false + } + + if !isSelectiveNodeAnchor(nodePattern, match.Where) { + return false + } + + declared := bindingSymbolSet(bindingsForMatch(0, match)) + for _, dependency := range localMatchDependencies(match) { + if _, isLocal := declared[dependency]; !isLocal { + return false + } + } + + return true +} + +func singleNodePattern(pattern *cypher.PatternPart) (*cypher.NodePattern, bool) { + if pattern == nil || pattern.Variable != nil || len(pattern.PatternElements) != 1 { + return nil, false + } + + return pattern.PatternElements[0].AsNodePattern() +} + +func isSelectiveNodeAnchor(nodePattern *cypher.NodePattern, where *cypher.Where) bool { + return len(nodePattern.Kinds) > 0 || nodePattern.Properties != nil || wherePredicateCount(where) > 0 +} + +func localMatchDependencies(match *cypher.Match) []string { + if match == nil { + return nil + } + + var dependencies []string + for _, pattern := range match.Pattern { + if pattern == nil { + continue + } + + for _, element := range pattern.PatternElements { + if element == nil { + continue + } + + if nodePattern, ok := element.AsNodePattern(); ok { + dependencies = append(dependencies, sortedDependencies(nodePattern.Properties)...) + } else if relationshipPattern, ok := element.AsRelationshipPattern(); ok { + dependencies = append(dependencies, sortedDependencies(relationshipPattern.Properties)...) + } + } + } + + dependencies = append(dependencies, dependenciesForMatch(match)...) + return sortedUniqueStrings(dependencies) +} + +func bindingSymbolSet(bindings []Binding) map[string]struct{} { + symbols := make(map[string]struct{}, len(bindings)) + for _, binding := range bindings { + symbols[binding.Symbol] = struct{}{} + } + + return symbols +} + +func bindingsForReadingClause(clauseIndex int, readingClause *cypher.ReadingClause) []Binding { + if readingClause == nil || readingClause.Match == nil { + return nil + } + + return bindingsForMatch(clauseIndex, readingClause.Match) +} diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index f28fdd8f..92b95afc 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -187,3 +187,31 @@ RETURN p require.Contains(t, normalizedQuery, "(s1.n1).id = e0.end_id") require.NotContains(t, normalizedQuery, "join node") } + +func TestOptimizerSafetyReordersIndependentNodeAnchor(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` +MATCH (a) +MATCH (b:EnterpriseCA {name: 'target'}) +MATCH p = (a)-[:MemberOf]->(b) +RETURN p +`) + require.NoError(t, err) + + translation, err := Translate(context.Background(), regularQuery, optimizerSafetyKindMapper(), nil, DefaultGraphID) + require.NoError(t, err) + + formattedQuery, err := Translated(translation) + require.NoError(t, err) + + normalizedQuery := strings.Join(strings.Fields(formattedQuery), " ") + enterpriseAnchorIndex := strings.Index(normalizedQuery, "array [5]::int2[]") + broadScanIndex := strings.Index(normalizedQuery, "from s0, node n1") + + require.NotEqual(t, -1, enterpriseAnchorIndex) + require.NotEqual(t, -1, broadScanIndex) + require.Less(t, enterpriseAnchorIndex, broadScanIndex) + require.Contains(t, normalizedQuery, "(s1.n1).id = e0.start_id") + require.Contains(t, normalizedQuery, "(s1.n0).id = e0.end_id") +} diff --git a/integration/testdata/cases/pattern_binding_inline.json b/integration/testdata/cases/pattern_binding_inline.json index 45879ecd..e08bfb7c 100644 --- a/integration/testdata/cases/pattern_binding_inline.json +++ b/integration/testdata/cases/pattern_binding_inline.json @@ -170,6 +170,22 @@ }, "assert": {"path_lengths": [1], "path_node_ids": [["x", "y"]], "path_edge_kinds": [["EdgeKind1"]]} }, + { + "name": "reorder independent node anchor before binding a connecting path", + "cypher": "match (x) match (y:NodeKind2 {name: 'target'}) match p=(x)-[:EdgeKind1]->(y) return p", + "fixture": { + "nodes": [ + {"id": "x", "kinds": ["NodeKind1"]}, + {"id": "target", "kinds": ["NodeKind2"], "properties": {"name": "target"}}, + {"id": "other", "kinds": ["NodeKind2"], "properties": {"name": "other"}} + ], + "edges": [ + {"start_id": "x", "end_id": "target", "kind": "EdgeKind1"}, + {"start_id": "x", "end_id": "other", "kind": "EdgeKind1"} + ] + }, + "assert": {"path_lengths": [1], "path_node_ids": [["x", "target"]], "path_edge_kinds": [["EdgeKind1"]]} + }, { "name": "match a node with an inline property map then bind its outgoing path to a second inline-map node", "cypher": "match (x:NodeKind1{name:'foo'}) match p=(x)-[:EdgeKind1]->(y:NodeKind2{name:'bar'}) return p", From 89737dde9b0bb68e143b9487ce48ce038750c225 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Wed, 20 May 2026 18:05:55 -0700 Subject: [PATCH 020/116] feat(pgsql): push fixed suffix checks into expansions --- .../test/translation_cases/multipart.sql | 10 +- .../translation_cases/pattern_binding.sql | 4 +- .../translation_cases/pattern_expansion.sql | 6 +- cypher/models/pgsql/translate/expansion.go | 98 ++++++++++++++++ cypher/models/pgsql/translate/model.go | 107 ++++++++++++++++++ .../pgsql/translate/optimizer_safety_test.go | 23 ++++ cypher/models/pgsql/translate/traversal.go | 4 + .../testdata/cases/expansion_inline.json | 18 +++ 8 files changed, 260 insertions(+), 10 deletions(-) diff --git a/cypher/models/pgsql/test/translation_cases/multipart.sql b/cypher/models/pgsql/test/translation_cases/multipart.sql index 241c42d5..a9b601db 100644 --- a/cypher/models/pgsql/test/translation_cases/multipart.sql +++ b/cypher/models/pgsql/test/translation_cases/multipart.sql @@ -24,13 +24,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 node n0 where (((n0.properties -> 'value'))::jsonb = to_jsonb((1)::int8)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s1.n0 as n0 from s1), s2 as (with s3 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where (((n1.properties -> 'name'))::jsonb = to_jsonb(('me')::text)::jsonb)) select s3.n1 as n1 from s3), s4 as (select s2.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s2, node n2 where (n2.id = (s2.n1).id)) select s4.n2 as b from s4; -- case: match (n:NodeKind1)-[:EdgeKind1*1..]->(:NodeKind2)-[:EdgeKind2]->(m:NodeKind1) where (n:NodeKind1 or n:NodeKind2) and n.enabled = true with m, collect(distinct(n)) as p where size(p) >= 10 return m -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.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]) and ((n0.properties -> 'enabled'))::jsonb = to_jsonb((true)::bool)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(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 s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select (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), s3 as (select 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.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[])) select s3.n2 as n2, array_remove(coalesce(array_agg(distinct (s3.n0))::nodecomposite[], array []::nodecomposite[])::nodecomposite[], null)::nodecomposite[] as i0 from s3 group by n2) select s0.n2 as m from s0 where (cardinality(s0.i0)::int >= 10); +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.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]) and ((n0.properties -> 'enabled'))::jsonb = to_jsonb((true)::bool)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(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[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::int2[])), e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::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.start_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select (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), s3 as (select 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.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[])) select s3.n2 as n2, array_remove(coalesce(array_agg(distinct (s3.n0))::nodecomposite[], array []::nodecomposite[])::nodecomposite[], null)::nodecomposite[] as i0 from s3 group by n2) select s0.n2 as m from s0 where (cardinality(s0.i0)::int >= 10); -- case: match (n:NodeKind1)-[:EdgeKind1*1..]->(:NodeKind2)-[:EdgeKind2]->(m:NodeKind1) where (n:NodeKind1 or n:NodeKind2) and n.enabled = true with m, count(distinct(n)) as p where p >= 10 return m -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.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]) and ((n0.properties -> 'enabled'))::jsonb = to_jsonb((true)::bool)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(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 s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select (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), s3 as (select 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.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[])) select s3.n2 as n2, count(distinct (s3.n0))::int8 as i0 from s3 group by n2) select s0.n2 as m from s0 where (s0.i0 >= 10); +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.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]) and ((n0.properties -> 'enabled'))::jsonb = to_jsonb((true)::bool)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(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[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::int2[])), e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::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.start_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select (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), s3 as (select 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.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[])) select s3.n2 as n2, count(distinct (s3.n0))::int8 as i0 from s3 group by n2) select s0.n2 as m from s0 where (s0.i0 >= 10); -- case: match (n:NodeKind1)-[:EdgeKind1*1..]->(:NodeKind2)-[:EdgeKind2]->(m:NodeKind1) where (n:NodeKind1 or n:NodeKind2) and n.enabled = true with m, count(distinct(n)) as p where p >= 10 return m -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.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]) and ((n0.properties -> 'enabled'))::jsonb = to_jsonb((true)::bool)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(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 s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select (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), s3 as (select 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.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[])) select s3.n2 as n2, count(distinct (s3.n0))::int8 as i0 from s3 group by n2) select s0.n2 as m from s0 where (s0.i0 >= 10); +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.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]) and ((n0.properties -> 'enabled'))::jsonb = to_jsonb((true)::bool)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(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[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::int2[])), e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::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.start_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select (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), s3 as (select 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.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[])) select s3.n2 as n2, count(distinct (s3.n0))::int8 as i0 from s3 group by n2) select s0.n2 as m from s0 where (s0.i0 >= 10); -- case: with 365 as max_days match (n:NodeKind1) where n.pwdlastset < (datetime().epochseconds - (max_days * 86400)) and not n.pwdlastset IN [-1.0, 0.0] return n limit 100 with s0 as (select 365 as i0), s1 as (select s0.i0 as i0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from s0, node n0 where (not ((n0.properties ->> 'pwdlastset'))::float8 = any (array [- 1, 0]::float8[]) and ((n0.properties ->> 'pwdlastset'))::numeric < (extract(epoch from now()::timestamp with time zone)::numeric - (s0.i0 * 86400))) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s1.n0 as n from s1 limit 100; @@ -81,10 +81,10 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from with s0 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.id = e0.start_id join node n1 on n1.id = e0.end_id), s1 as (select s0.e0 as e0, e1.id as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n0).id = e1.end_id join node n2 on n2.id = e1.start_id) select ordered_edges_to_path(s1.n0, (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 [s1.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite as p, ordered_edges_to_path(s1.n2, (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 [s1.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n2, s1.n0]::nodecomposite[])::pathcomposite as q from s1; -- case: match (m:NodeKind1)-[*1..]->(g:NodeKind2)-[]->(c3:NodeKind1) where not g.name in ["foo"] with collect(g.name) as bar match p=(m:NodeKind1)-[*1..]->(g:NodeKind2) where g.name in bar 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.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id union all select s2.root_id, e0.end_id, s2.depth + 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_id = s2.next_id and e0.id != all (s2.path) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select (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), s3 as (select s1.n0 as n0, s1.n1 as n1 from s1 join edge e1 on (s1.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id) select array_remove(coalesce(array_agg(((s3.n1).properties ->> 'name'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s3), s4 as (with recursive s5_seed(root_id) as not materialized (select n4.id as root_id from s0, node n4 where n4.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n4.properties ->> 'name') = any (s0.i0))), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.end_id, e2.start_id, 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.end_id = e2.start_id, array [e2.id] from s5_seed join edge e2 on e2.end_id = s5_seed.root_id join node n3 on n3.id = e2.start_id union select s5.root_id, e2.start_id, s5.depth + 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e2.id || s5.path from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.end_id = s5.next_id and e2.id != all (s5.path) offset 0) e2 on true join node n3 on n3.id = e2.start_id where s5.depth < 15 and not s5.is_cycle) select s5.path as ep1, 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, s5 join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.root_id offset 0) n4 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.next_id offset 0) n3 on true where s5.satisfied) select 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(s4.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n3, s4.n4]::nodecomposite[])::pathcomposite as p from s4; +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.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id), e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id union all select s2.root_id, e0.end_id, s2.depth + 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id), 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.start_id = s2.next_id and e0.id != all (s2.path) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select (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), s3 as (select s1.n0 as n0, s1.n1 as n1 from s1 join edge e1 on (s1.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id) select array_remove(coalesce(array_agg(((s3.n1).properties ->> 'name'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s3), s4 as (with recursive s5_seed(root_id) as not materialized (select n4.id as root_id from s0, node n4 where n4.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n4.properties ->> 'name') = any (s0.i0))), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.end_id, e2.start_id, 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.end_id = e2.start_id, array [e2.id] from s5_seed join edge e2 on e2.end_id = s5_seed.root_id join node n3 on n3.id = e2.start_id union select s5.root_id, e2.start_id, s5.depth + 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e2.id || s5.path from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.end_id = s5.next_id and e2.id != all (s5.path) offset 0) e2 on true join node n3 on n3.id = e2.start_id where s5.depth < 15 and not s5.is_cycle) select s5.path as ep1, 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, s5 join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.root_id offset 0) n4 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.next_id offset 0) n3 on true where s5.satisfied) select 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(s4.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n3, s4.n4]::nodecomposite[])::pathcomposite as p from s4; -- case: match (m:NodeKind1)-[:EdgeKind1*1..]->(g:NodeKind2)-[:EdgeKind2]->(c3:NodeKind1) where m.samaccountname =~ '^[A-Z]{1,3}[0-9]{1,3}$' and not m.samaccountname contains "DEX" and not g.name IN ["D"] and not m.samaccountname =~ "^.*$" with collect(g.name) as admingroups match p=(m:NodeKind1)-[:EdgeKind1*1..]->(g:NodeKind2) where m.samaccountname =~ '^[A-Z]{1,3}[0-9]{1,3}$' and g.name in admingroups and not m.samaccountname =~ "^.*$" 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 ->> 'samaccountname') ~ '^[A-Z]{1,3}[0-9]{1,3}$' and not coalesce((n0.properties ->> 'samaccountname'), '')::text like '%DEX%' and not (n0.properties ->> 'samaccountname') ~ '^.*$') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (not (n1.properties ->> 'name') = any (array ['D']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, (not (n1.properties ->> 'name') = any (array ['D']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select (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), s3 as (select s1.n0 as n0, s1.n1 as n1 from s1 join edge e1 on (s1.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[])) select array_remove(coalesce(array_agg(((s3.n1).properties ->> 'name'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s3), s4 as (with recursive s5_seed(root_id) as not materialized (select n4.id as root_id from s0, node n4 where n4.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n4.properties ->> 'name') = any (s0.i0))), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.end_id, e2.start_id, 1, ((n3.properties ->> 'samaccountname') ~ '^[A-Z]{1,3}[0-9]{1,3}$' and not (n3.properties ->> 'samaccountname') ~ '^.*$') and n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.end_id = e2.start_id, array [e2.id] from s5_seed join edge e2 on e2.end_id = s5_seed.root_id join node n3 on n3.id = e2.start_id where e2.kind_id = any (array [3]::int2[]) union select s5.root_id, e2.start_id, s5.depth + 1, ((n3.properties ->> 'samaccountname') ~ '^[A-Z]{1,3}[0-9]{1,3}$' and not (n3.properties ->> 'samaccountname') ~ '^.*$') and n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e2.id || s5.path from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.end_id = s5.next_id and e2.id != all (s5.path) and e2.kind_id = any (array [3]::int2[]) offset 0) e2 on true join node n3 on n3.id = e2.start_id where s5.depth < 15 and not s5.is_cycle) select s5.path as ep1, 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, s5 join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.root_id offset 0) n4 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.next_id offset 0) n3 on true where s5.satisfied) select 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(s4.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n3, s4.n4]::nodecomposite[])::pathcomposite as p from s4; +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 ->> 'samaccountname') ~ '^[A-Z]{1,3}[0-9]{1,3}$' and not coalesce((n0.properties ->> 'samaccountname'), '')::text like '%DEX%' and not (n0.properties ->> 'samaccountname') ~ '^.*$') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (not (n1.properties ->> 'name') = any (array ['D']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::int2[])), e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, (not (n1.properties ->> 'name') = any (array ['D']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::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.start_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select (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), s3 as (select s1.n0 as n0, s1.n1 as n1 from s1 join edge e1 on (s1.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[])) select array_remove(coalesce(array_agg(((s3.n1).properties ->> 'name'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s3), s4 as (with recursive s5_seed(root_id) as not materialized (select n4.id as root_id from s0, node n4 where n4.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n4.properties ->> 'name') = any (s0.i0))), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.end_id, e2.start_id, 1, ((n3.properties ->> 'samaccountname') ~ '^[A-Z]{1,3}[0-9]{1,3}$' and not (n3.properties ->> 'samaccountname') ~ '^.*$') and n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.end_id = e2.start_id, array [e2.id] from s5_seed join edge e2 on e2.end_id = s5_seed.root_id join node n3 on n3.id = e2.start_id where e2.kind_id = any (array [3]::int2[]) union select s5.root_id, e2.start_id, s5.depth + 1, ((n3.properties ->> 'samaccountname') ~ '^[A-Z]{1,3}[0-9]{1,3}$' and not (n3.properties ->> 'samaccountname') ~ '^.*$') and n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e2.id || s5.path from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.end_id = s5.next_id and e2.id != all (s5.path) and e2.kind_id = any (array [3]::int2[]) offset 0) e2 on true join node n3 on n3.id = e2.start_id where s5.depth < 15 and not s5.is_cycle) select s5.path as ep1, 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, s5 join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.root_id offset 0) n4 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.next_id offset 0) n3 on true where s5.satisfied) select 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(s4.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n3, s4.n4]::nodecomposite[])::pathcomposite as p from s4; -- case: match (a:NodeKind2)-[:EdgeKind1]->(g:NodeKind1)-[:EdgeKind2]->(s:NodeKind2) with count(a) as uc where uc > 5 match p = (a)-[:EdgeKind1]->(g)-[:EdgeKind2]->(s) return p with s0 as (with s1 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 [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.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[])) 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[])) select 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 as p from s4; diff --git a/cypher/models/pgsql/test/translation_cases/pattern_binding.sql b/cypher/models/pgsql/test/translation_cases/pattern_binding.sql index b56adb40..57ea7e18 100644 --- a/cypher/models/pgsql/test/translation_cases/pattern_binding.sql +++ b/cypher/models/pgsql/test/translation_cases/pattern_binding.sql @@ -45,7 +45,7 @@ with s0 as (select e0.id as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposi with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, false, e0.start_id = e0.end_id, array [e0.id] from edge e0 union all select s1.root_id, e0.end_id, s1.depth + 1, false, 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) offset 0) e0 on true where s1.depth < 15 and not s1.is_cycle) select 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 limit 1) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 limit 1; -- case: match p = (s)-[*..]->(i)-[]->() where id(s) = 1 and i.name = 'n3' return p limit 1 -with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (n0.id = 1)), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n3')::text)::jsonb), 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 union all select s1.root_id, e0.end_id, s1.depth + 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n3')::text)::jsonb), 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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select 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), s2 as (select e1.id as e1, s0.ep0 as ep0, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id limit 1) select ordered_edges_to_path(s2.n0, (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(s2.ep0) 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 [s2.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1, s2.n2]::nodecomposite[])::pathcomposite as p from s2 limit 1; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (n0.id = 1)), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n3')::text)::jsonb) and exists (select 1 from edge e1 join node n2 on n2.id = e1.end_id where n1.id = e1.start_id), 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 union all select s1.root_id, e0.end_id, s1.depth + 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n3')::text)::jsonb) and exists (select 1 from edge e1 join node n2 on n2.id = e1.end_id where n1.id = e1.start_id), 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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select 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), s2 as (select e1.id as e1, s0.ep0 as ep0, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id limit 1) select ordered_edges_to_path(s2.n0, (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(s2.ep0) 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 [s2.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1, s2.n2]::nodecomposite[])::pathcomposite as p from s2 limit 1; -- case: match p = ()-[e:EdgeKind1]->()-[:EdgeKind1*..]->() return e, 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 where e0.kind_id = any (array [3]::int2[])), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.start_id, e1.end_id, 1, false, e1.start_id = e1.end_id, array [e1.id] from s2_seed join edge e1 on e1.start_id = s2_seed.root_id where e1.kind_id = any (array [3]::int2[]) union all select s2.root_id, e1.end_id, s2.depth + 1, false, false, s2.path || e1.id from s2 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.start_id = s2.next_id and e1.id != all (s2.path) and e1.kind_id = any (array [3]::int2[]) offset 0) e1 on true where s2.depth < 15 and not s2.is_cycle) select s0.e0 as e0, s2.path as ep0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s2.next_id offset 0) n2 on true where (s0.n1).id = s2.root_id) select s1.e0 as e, ordered_edges_to_path(s1.n0, array [s1.e0]::edgecomposite[] || (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(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1, s1.n2]::nodecomposite[])::pathcomposite as p from s1; @@ -81,7 +81,7 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from with s0 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.id = e0.start_id join node n1 on n1.id = e0.end_id), s1 as (select s0.e0 as e0, e1.id as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n0).id = e1.end_id join node n2 on n2.id = e1.start_id) select ordered_edges_to_path(s1.n0, (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 [s1.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite as p, ordered_edges_to_path(s1.n2, (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 [s1.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n2, s1.n0]::nodecomposite[])::pathcomposite as q from s1; -- case: match (m:NodeKind1)-[*1..]->(g:NodeKind2)-[]->(c3:NodeKind1) where not g.name in ["foo"] with collect(g.name) as bar match p=(m:NodeKind1)-[*1..]->(g:NodeKind2) where g.name in bar 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.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id union all select s2.root_id, e0.end_id, s2.depth + 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_id = s2.next_id and e0.id != all (s2.path) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select (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), s3 as (select s1.n0 as n0, s1.n1 as n1 from s1 join edge e1 on (s1.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id) select array_remove(coalesce(array_agg(((s3.n1).properties ->> 'name'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s3), s4 as (with recursive s5_seed(root_id) as not materialized (select n4.id as root_id from s0, node n4 where n4.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n4.properties ->> 'name') = any (s0.i0))), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.end_id, e2.start_id, 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.end_id = e2.start_id, array [e2.id] from s5_seed join edge e2 on e2.end_id = s5_seed.root_id join node n3 on n3.id = e2.start_id union select s5.root_id, e2.start_id, s5.depth + 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e2.id || s5.path from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.end_id = s5.next_id and e2.id != all (s5.path) offset 0) e2 on true join node n3 on n3.id = e2.start_id where s5.depth < 15 and not s5.is_cycle) select s5.path as ep1, 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, s5 join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.root_id offset 0) n4 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.next_id offset 0) n3 on true where s5.satisfied) select 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(s4.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n3, s4.n4]::nodecomposite[])::pathcomposite as p from s4; +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.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id), e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id union all select s2.root_id, e0.end_id, s2.depth + 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id), 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.start_id = s2.next_id and e0.id != all (s2.path) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select (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), s3 as (select s1.n0 as n0, s1.n1 as n1 from s1 join edge e1 on (s1.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id) select array_remove(coalesce(array_agg(((s3.n1).properties ->> 'name'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s3), s4 as (with recursive s5_seed(root_id) as not materialized (select n4.id as root_id from s0, node n4 where n4.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n4.properties ->> 'name') = any (s0.i0))), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.end_id, e2.start_id, 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.end_id = e2.start_id, array [e2.id] from s5_seed join edge e2 on e2.end_id = s5_seed.root_id join node n3 on n3.id = e2.start_id union select s5.root_id, e2.start_id, s5.depth + 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e2.id || s5.path from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.end_id = s5.next_id and e2.id != all (s5.path) offset 0) e2 on true join node n3 on n3.id = e2.start_id where s5.depth < 15 and not s5.is_cycle) select s5.path as ep1, 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, s5 join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.root_id offset 0) n4 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.next_id offset 0) n3 on true where s5.satisfied) select 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(s4.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n3, s4.n4]::nodecomposite[])::pathcomposite as p from s4; -- case: MATCH p=(:Computer)-[r:HasSession]->(:User) WHERE r.lastseen >= datetime() - duration('P3D') 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 [5]::int2[] and n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [6]::int2[] and n1.id = e0.end_id where (((e0.properties ->> 'lastseen'))::timestamp with time zone >= now()::timestamp with time zone - interval 'P3D') and e0.kind_id = any (array [7]::int2[]) 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/pattern_expansion.sql b/cypher/models/pgsql/test/translation_cases/pattern_expansion.sql index beaecc8c..273b3530 100644 --- a/cypher/models/pgsql/test/translation_cases/pattern_expansion.sql +++ b/cypher/models/pgsql/test/translation_cases/pattern_expansion.sql @@ -36,16 +36,16 @@ with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n2')::text)::jsonb)), 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 [1]::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 union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (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 s0.n0 as n from s0; -- case: match (n)-[*..]->(e:NodeKind1)-[]->(l) where n.name = 'n1' return l -with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb)), 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 [1]::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 union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (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), s2 as (select s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id) select s2.n2 as l from s2; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb)), 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 [1]::int2[] and exists (select 1 from edge e1 join node n2 on n2.id = e1.end_id where n1.id = e1.start_id), 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 union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and exists (select 1 from edge e1 join node n2 on n2.id = e1.end_id where n1.id = e1.start_id), 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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (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), s2 as (select s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id) select s2.n2 as l from s2; -- case: match (n)-[*2..3]->(e:NodeKind1)-[]->(l) where n.name = 'n1' return l -with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb)), 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 [1]::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 union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 3 and not s1.is_cycle) select (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.depth >= 2 and s1.satisfied), s2 as (select s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id) select s2.n2 as l from s2; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb)), 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 [1]::int2[] and exists (select 1 from edge e1 join node n2 on n2.id = e1.end_id where n1.id = e1.start_id), 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 union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and exists (select 1 from edge e1 join node n2 on n2.id = e1.end_id where n1.id = e1.start_id), 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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 3 and not s1.is_cycle) select (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.depth >= 2 and s1.satisfied), s2 as (select s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id) select s2.n2 as l from s2; -- case: match (n)-[]->(e:NodeKind1)-[*2..3]->(l) where n.name = 'n1' return l 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.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb) 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), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.start_id, e1.end_id, 1, false, e1.start_id = e1.end_id, array [e1.id] from s2_seed join edge e1 on e1.start_id = s2_seed.root_id union all select s2.root_id, e1.end_id, s2.depth + 1, false, false, s2.path || e1.id from s2 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.start_id = s2.next_id and e1.id != all (s2.path) offset 0) e1 on true where s2.depth < 3 and not s2.is_cycle) select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s2.next_id offset 0) n2 on true where s2.depth >= 2 and (s0.n1).id = s2.root_id) select s1.n2 as l from s1; -- case: match (n)-[*..]->(e)-[:EdgeKind1|EdgeKind2]->()-[*..]->(l) where n.name = 'n1' and e.name = 'n2' return l -with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb)), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n2')::text)::jsonb), 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 union all select s1.root_id, e0.end_id, s1.depth + 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n2')::text)::jsonb), 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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (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), s2 as (select s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.kind_id = any (array [3, 4]::int2[])), s3 as (with recursive s4_seed(root_id) as not materialized (select distinct (s2.n2).id as root_id from s2), s4(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, false, e2.start_id = e2.end_id, array [e2.id] from s4_seed join edge e2 on e2.start_id = s4_seed.root_id union all select s4.root_id, e2.end_id, s4.depth + 1, false, false, s4.path || e2.id from s4 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.start_id = s4.next_id and e2.id != all (s4.path) offset 0) e2 on true where s4.depth < 15 and not s4.is_cycle) select s2.n0 as n0, s2.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s2, s4 join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s4.root_id offset 0) n2 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s4.next_id offset 0) n3 on true where (s2.n2).id = s4.root_id) select s3.n3 as l from s3; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb)), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n2')::text)::jsonb) and exists (select 1 from edge e1 join node n2 on n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [3, 4]::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 union all select s1.root_id, e0.end_id, s1.depth + 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n2')::text)::jsonb) and exists (select 1 from edge e1 join node n2 on n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [3, 4]::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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (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), s2 as (select s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.kind_id = any (array [3, 4]::int2[])), s3 as (with recursive s4_seed(root_id) as not materialized (select distinct (s2.n2).id as root_id from s2), s4(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, false, e2.start_id = e2.end_id, array [e2.id] from s4_seed join edge e2 on e2.start_id = s4_seed.root_id union all select s4.root_id, e2.end_id, s4.depth + 1, false, false, s4.path || e2.id from s4 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.start_id = s4.next_id and e2.id != all (s4.path) offset 0) e2 on true where s4.depth < 15 and not s4.is_cycle) select s2.n0 as n0, s2.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s2, s4 join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s4.root_id offset 0) n2 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s4.next_id offset 0) n3 on true where (s2.n2).id = s4.root_id) select s3.n3 as l from s3; -- case: match p = (:NodeKind1)-[:EdgeKind1*1..]->(n:NodeKind2) where 'admin_tier_0' in split(n.system_tags, ' ') return p limit 1000 with s0 as (with recursive s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where ('admin_tier_0' = any (string_to_array((n1.properties ->> 'system_tags'), ' ')::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, n0.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id join node n0 on n0.id = e0.start_id where e0.kind_id = any (array [3]::int2[]) union all select s1.root_id, e0.start_id, s1.depth + 1, n0.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e0.id || s1.path from s1 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.end_id = s1.next_id and e0.id != all (s1.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n0 on n0.id = e0.start_id where s1.depth < 15 and not s1.is_cycle) select 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 n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s1.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s1.next_id offset 0) n0 on true where s1.satisfied limit 1000) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 limit 1000; diff --git a/cypher/models/pgsql/translate/expansion.go b/cypher/models/pgsql/translate/expansion.go index e34a7a41..84a3e4f2 100644 --- a/cypher/models/pgsql/translate/expansion.go +++ b/cypher/models/pgsql/translate/expansion.go @@ -8,6 +8,7 @@ import ( "github.com/specterops/dawgs/cypher/models/pgsql" "github.com/specterops/dawgs/cypher/models/pgsql/format" "github.com/specterops/dawgs/cypher/models/pgsql/pgd" + "github.com/specterops/dawgs/graph" ) const translateDefaultMaxTraversalDepth int64 = 15 @@ -2285,6 +2286,103 @@ func expansionTerminalSatisfactionLocality(traversalStep *TraversalStep) (pgsql. ) } +func applyExpansionSuffixPushdown(part *PatternPart) error { + for idx := 0; idx+1 < len(part.TraversalSteps); idx++ { + var ( + currentStep = part.TraversalSteps[idx] + nextStep = part.TraversalSteps[idx+1] + ) + + if suffixSatisfaction, satisfied := expansionSuffixTerminalSatisfaction(currentStep, nextStep); satisfied { + currentStep.Expansion.TerminalNodeConstraints = pgsql.OptionalAnd( + currentStep.Expansion.TerminalNodeConstraints, + suffixSatisfaction, + ) + + if terminalCriteriaProjection, err := pgsql.As[pgsql.SelectItem](currentStep.Expansion.TerminalNodeConstraints); err != nil { + return err + } else { + currentStep.Expansion.TerminalNodeSatisfactionProjection = terminalCriteriaProjection + } + } + } + + return nil +} + +func expansionSuffixTerminalSatisfaction(currentStep, nextStep *TraversalStep) (pgsql.Expression, bool) { + if currentStep == nil || + currentStep.Expansion == nil || + currentStep.RightNode == nil || + nextStep == nil || + nextStep.Expansion != nil || + nextStep.LeftNode == nil || + nextStep.Edge == nil || + nextStep.RightNode == nil || + nextStep.RightNodeBound || + nextStep.Direction == graph.DirectionBoth || + currentStep.RightNode.Identifier != nextStep.LeftNode.Identifier { + return nil, false + } + + var edgeConstraints pgsql.Expression + if nextStep.EdgeConstraints != nil { + edgeConstraints = nextStep.EdgeConstraints.Expression + } + + localScope := pgsql.AsIdentifierSet( + currentStep.RightNode.Identifier, + nextStep.Edge.Identifier, + nextStep.RightNode.Identifier, + ) + + edgeLocal, edgeExternal := partitionConstraintByLocality(edgeConstraints, localScope) + if edgeExternal != nil { + return nil, false + } + + rightNodeLocal, rightNodeExternal := partitionConstraintByLocality(nextStep.RightNodeConstraints, localScope) + if rightNodeExternal != nil { + return nil, false + } + + terminalJoin, err := leftNodeConstraint( + nextStep.Edge.Identifier, + currentStep.RightNode.Identifier, + nextStep.Direction, + ) + if err != nil { + return nil, false + } + + return pgsql.ExistsExpression{ + Subquery: pgsql.Subquery{ + Query: pgsql.Query{ + Body: pgsql.Select{ + Projection: pgsql.Projection{pgd.IntLiteral(1)}, + From: []pgsql.FromClause{{ + Source: expansionEdgeTableReference(nextStep.Edge.Identifier), + Joins: []pgsql.Join{{ + Table: expansionNodeTableReference(nextStep.RightNode.Identifier), + JoinOperator: pgsql.JoinOperator{ + JoinType: pgsql.JoinTypeInner, + Constraint: pgsql.OptionalAnd( + rightNodeLocal, + nextStep.RightNodeJoinCondition, + ), + }, + }}, + }}, + Where: pgsql.OptionalAnd( + terminalJoin, + edgeLocal, + ), + }, + }, + }, + }, true +} + func expansionLocalTerminalSatisfactionProjection(traversalStep *TraversalStep) (pgsql.SelectItem, error) { localSatisfiedConstraint, _ := expansionTerminalSatisfactionLocality(traversalStep) diff --git a/cypher/models/pgsql/translate/model.go b/cypher/models/pgsql/translate/model.go index e1dd8a62..f3f7ab00 100644 --- a/cypher/models/pgsql/translate/model.go +++ b/cypher/models/pgsql/translate/model.go @@ -358,6 +358,14 @@ func expressionReferencesOnlyLocalIdentifiers(expression pgsql.Expression, local walk.PgSQL(expression, walk.NewSimpleVisitor[pgsql.SyntaxNode]( func(node pgsql.SyntaxNode, handler walk.VisitorHandler) { switch typedNode := node.(type) { + case pgsql.ExistsExpression: + if !subqueryReferencesOnlyLocalIdentifiers(typedNode.Subquery, localScope) { + isLocal = false + handler.SetDone() + } else { + handler.Consume() + } + case pgsql.CompoundIdentifier: if len(typedNode) > 0 && !localScope.Contains(typedNode[0]) { isLocal = false @@ -384,6 +392,105 @@ func expressionReferencesOnlyLocalIdentifiers(expression pgsql.Expression, local return isLocal } +func subqueryReferencesOnlyLocalIdentifiers(subquery pgsql.Subquery, localScope *pgsql.IdentifierSet) bool { + return queryReferencesOnlyLocalIdentifiers(subquery.Query, localScope) +} + +func queryReferencesOnlyLocalIdentifiers(query pgsql.Query, localScope *pgsql.IdentifierSet) bool { + if query.CommonTableExpressions != nil { + return false + } + + selectBody, isSelect := query.Body.(pgsql.Select) + if !isSelect { + return false + } + + if !selectReferencesOnlyLocalIdentifiers(selectBody, localScope) { + return false + } + + for _, orderBy := range query.OrderBy { + if orderBy != nil && !expressionReferencesOnlyLocalIdentifiers(orderBy.Expression, localScope) { + return false + } + } + + return (query.Offset == nil || expressionReferencesOnlyLocalIdentifiers(query.Offset, localScope)) && + (query.Limit == nil || expressionReferencesOnlyLocalIdentifiers(query.Limit, localScope)) +} + +func addFromClauseBindings(localScope *pgsql.IdentifierSet, fromClauses []pgsql.FromClause) { + for _, fromClause := range fromClauses { + addFromExpressionBinding(localScope, fromClause.Source) + + for _, join := range fromClause.Joins { + addFromExpressionBinding(localScope, join.Table) + } + } +} + +func addFromExpressionBinding(localScope *pgsql.IdentifierSet, expression pgsql.Expression) { + switch typedExpression := expression.(type) { + case pgsql.TableReference: + if typedExpression.Binding.Set { + localScope.Add(typedExpression.Binding.Value) + } + + case pgsql.LateralSubquery: + if typedExpression.Binding.Set { + localScope.Add(typedExpression.Binding.Value) + } + } +} + +func selectReferencesOnlyLocalIdentifiers(selectBody pgsql.Select, localScope *pgsql.IdentifierSet) bool { + scopedIdentifiers := localScope.Copy() + addFromClauseBindings(scopedIdentifiers, selectBody.From) + + for _, projection := range selectBody.Projection { + if !expressionReferencesOnlyLocalIdentifiers(projection, scopedIdentifiers) { + return false + } + } + + for _, fromClause := range selectBody.From { + if !fromExpressionReferencesOnlyLocalIdentifiers(fromClause.Source) { + return false + } + + for _, join := range fromClause.Joins { + if !fromExpressionReferencesOnlyLocalIdentifiers(join.Table) { + return false + } + + if join.JoinOperator.Constraint != nil && + !expressionReferencesOnlyLocalIdentifiers(join.JoinOperator.Constraint, scopedIdentifiers) { + return false + } + } + } + + for _, groupByExpression := range selectBody.GroupBy { + if !expressionReferencesOnlyLocalIdentifiers(groupByExpression, scopedIdentifiers) { + return false + } + } + + return (selectBody.Where == nil || expressionReferencesOnlyLocalIdentifiers(selectBody.Where, scopedIdentifiers)) && + (selectBody.Having == nil || expressionReferencesOnlyLocalIdentifiers(selectBody.Having, scopedIdentifiers)) +} + +func fromExpressionReferencesOnlyLocalIdentifiers(expression pgsql.Expression) bool { + switch expression.(type) { + case pgsql.TableReference: + return true + + default: + return false + } +} + func isLocalToScope(expression pgsql.Expression, localScope *pgsql.IdentifierSet) bool { if expression == nil { return true diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index 92b95afc..98a8bff9 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -215,3 +215,26 @@ RETURN p require.Contains(t, normalizedQuery, "(s1.n1).id = e0.start_id") require.Contains(t, normalizedQuery, "(s1.n0).id = e0.end_id") } + +func TestOptimizerSafetyExpansionTerminalPushdownForFixedSuffix(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` +MATCH p = (n:Group)-[:MemberOf*1..]->(m)-[:Enroll]->(ca:EnterpriseCA) +RETURN p +`) + require.NoError(t, err) + + translation, err := Translate(context.Background(), regularQuery, optimizerSafetyKindMapper(), nil, DefaultGraphID) + require.NoError(t, err) + + formattedQuery, err := Translated(translation) + require.NoError(t, err) + + normalizedQuery := strings.Join(strings.Fields(formattedQuery), " ") + + require.Contains(t, normalizedQuery, "exists (select 1 from edge e1 join node n2") + require.Contains(t, normalizedQuery, "n1.id = e1.start_id") + require.Contains(t, normalizedQuery, "e1.kind_id = any (array [4]::int2[])") + require.Contains(t, normalizedQuery, "n2.kind_ids operator (pg_catalog.@>) array [5]::int2[]") +} diff --git a/cypher/models/pgsql/translate/traversal.go b/cypher/models/pgsql/translate/traversal.go index 5164c688..6192c47d 100644 --- a/cypher/models/pgsql/translate/traversal.go +++ b/cypher/models/pgsql/translate/traversal.go @@ -601,6 +601,10 @@ func (s *Translator) translateTraversalPatternPart(part *PatternPart, isolatedPr } } + if err := applyExpansionSuffixPushdown(part); err != nil { + return err + } + if isolatedProjection { s.scope = scopeSnapshot } diff --git a/integration/testdata/cases/expansion_inline.json b/integration/testdata/cases/expansion_inline.json index 28e18cfa..929f32a4 100644 --- a/integration/testdata/cases/expansion_inline.json +++ b/integration/testdata/cases/expansion_inline.json @@ -48,6 +48,24 @@ }, "assert": {"node_ids": ["leaf"]} }, + { + "name": "bind expansion path through only terminals that satisfy a fixed suffix", + "cypher": "match p = (src:NodeKind1)-[:EdgeKind1*1..]->(mid)-[:EdgeKind2]->(dst:NodeKind2) where src.name = 'terminal-pushdown-src' return p", + "fixture": { + "nodes": [ + {"id": "src", "kinds": ["NodeKind1"], "properties": {"name": "terminal-pushdown-src"}}, + {"id": "good-mid", "kinds": ["NodeKind1"]}, + {"id": "dead-mid", "kinds": ["NodeKind1"]}, + {"id": "dst", "kinds": ["NodeKind2"]} + ], + "edges": [ + {"start_id": "src", "end_id": "good-mid", "kind": "EdgeKind1"}, + {"start_id": "src", "end_id": "dead-mid", "kind": "EdgeKind1"}, + {"start_id": "good-mid", "end_id": "dst", "kind": "EdgeKind2"} + ] + }, + "assert": {"path_lengths": [2], "path_node_ids": [["src", "good-mid", "dst"]], "path_edge_kinds": [["EdgeKind1", "EdgeKind2"]]} + }, { "name": "fixed step followed by a bounded variable-length expansion", "cypher": "match (n)-[]->(e:NodeKind1)-[*2..3]->(l) where n.name = 'start' return l", From 650b1084679b6440e7badaddf8f29a03c9e1def4 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Wed, 20 May 2026 18:13:38 -0700 Subject: [PATCH 021/116] docs(pgsql): sequence optimizer gap closure plan --- docs/optimization-pass-memory.md | 50 ++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/docs/optimization-pass-memory.md b/docs/optimization-pass-memory.md index ebdd979f..578f6710 100644 --- a/docs/optimization-pass-memory.md +++ b/docs/optimization-pass-memory.md @@ -189,6 +189,56 @@ The review follow-up should leave the first optimizer milestone in a measured st - Direct relationship bindings referenced by return expressions, predicates, `type(r)`, or endpoint functions must keep edge composites and must not be narrowed to path-edge IDs. - The ADCS fixture currently has SQL-shape and containment coverage. Stricter path cardinality assertions on PostgreSQL exposed duplicated returned path rows during review, so exact cardinality for that fixture should be investigated as part of the high-fanout measurement work rather than added as a passing oracle prematurely. +## Current Gap Closure Plan + +The optimizer branch now has enough implementation to expose the next set of risks. Close those risks in this order so each later rule has a stronger correctness and measurement base. + +### Step 1: Establish A Performance Baseline + +Add a synthetic ADCS fanout scenario before broadening suffix or endpoint-aware expansion rules. Capture `p1` alone, `p2` alone, and the combined query with row counts, distinct `(p1, p2)` counts, duplicate counts, and PostgreSQL `EXPLAIN (ANALYZE, BUFFERS)`. + +This should be the first step because the original report is a timeout, and the current branch is still defended mostly by SQL shape and semantic equivalence tests. + +### Step 2: Strengthen Semantic Oracles + +Add exact-result integration coverage on smaller fixtures before relying on the larger ADCS fixture as an oracle. Assertions should include path node IDs, relationship IDs or kinds in order, path lengths, row count, and `relationships(p)` output for optimized paths. + +Keep the existing ADCS containment test, but treat exact ADCS cardinality as part of the fanout investigation until duplicate-row behavior is understood. + +### Step 3: Make Optimizer Rule Ownership Explicit + +Projection pruning, late path materialization, fixed-hop lowering, and suffix pushdown currently live in PostgreSQL translator lowering instead of explicit optimizer rules. Either promote these decisions into optimizer metadata consumed by the translator, or record them as named lowering decisions so tests and diagnostics can identify which rule changed the SQL shape. + +This step should happen before adding more hidden translator-side rewrites. + +### Step 4: Wire Predicate Attachment Into Translation + +Predicate attachment currently records ownership but does not change translation. Feed attachment metadata into PostgreSQL lowering so local predicates can move into the earliest safe binding, terminal, or suffix check. + +Add SQL shape tests proving `ct` predicates in the motivating query are applied at the intended terminal or suffix point, plus PostgreSQL and Neo4j equivalence coverage. + +### Step 5: Broaden Phase 9 Coverage Before Broadening Phase 9 Behavior + +Add tests for the suffix shapes the motivating query actually depends on: + +- `*0..` variable expansions followed by suffix checks +- chained fixed suffixes after a variable expansion +- suffixes that end at already-bound nodes such as `ca` and `d` +- inbound suffixes +- directionless suffixes that should remain unoptimized until they are implemented deliberately + +These tests should include both SQL shape assertions and integration equivalence. + +### Step 6: Implement Endpoint-Aware Suffix Semi-Joins + +Extend suffix pushdown from the current immediate one-hop local check to endpoint-aware semi-joins that can reason about fixed suffix chains and already-bound endpoints. For the motivating query, this means pruning `MemberOf*0..` endpoints that cannot reach eligible certificate template paths tied to the bound `ca`, and pruning root paths that cannot connect back to the bound `d`. + +Keep path materialization late: use the suffix checks to constrain candidate endpoints, then materialize returned paths only after the result frame is narrowed. + +### Step 7: Re-Measure And Lock The Regression + +After each new suffix or predicate-placement rule, rerun the synthetic fanout measurements and record the before/after SQL shape and runtime characteristics. Promote the final motivating query shape into a benchmark or regression scenario once its cardinality and duplicate behavior are fully understood. + ## Measurement Checklist Before Phase 7 Before implementing expand-into detection, capture the following for the motivating ADCS query and a synthetic fanout variant: From 363b633fb2eba67a2d137586c3d5c0f52a38dc51 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Wed, 20 May 2026 19:01:08 -0700 Subject: [PATCH 022/116] feat(pgsql): close optimizer suffix pushdown gaps --- cmd/benchmark/README.md | 40 +--- cmd/benchmark/main.go | 3 +- cmd/benchmark/report.go | 7 +- cmd/benchmark/report_test.go | 8 +- cmd/benchmark/runner.go | 30 ++- cmd/benchmark/scenarios.go | 64 ++++-- cypher/models/pgsql/translate/expansion.go | 183 +++++++++++++----- .../pgsql/translate/optimizer_safety_test.go | 144 +++++++++----- cypher/models/pgsql/translate/translator.go | 27 ++- cypher/models/pgsql/translate/traversal.go | 4 +- integration/testdata/adcs_fanout.json | 50 +++++ .../testdata/cases/expansion_inline.json | 58 ++++++ 12 files changed, 458 insertions(+), 160 deletions(-) create mode 100644 integration/testdata/adcs_fanout.json diff --git a/cmd/benchmark/README.md b/cmd/benchmark/README.md index 741118f5..13686821 100644 --- a/cmd/benchmark/README.md +++ b/cmd/benchmark/README.md @@ -1,11 +1,11 @@ # Benchmark -Runs query scenarios against a real database and outputs a markdown timing table. +Runs query scenarios against a real database and outputs a markdown timing table with warm-up row counts. ## Usage ```bash -# Default dataset (base) +# Default datasets (base and adcs_fanout) go run ./cmd/benchmark -connection "postgresql://dawgs:dawgs@localhost:5432/dawgs" # Local dataset (not committed to repo) @@ -43,20 +43,10 @@ go run ./cmd/benchmark -connection "..." -output report.md -json-output report.j $ go run ./cmd/benchmark -driver neo4j -connection "neo4j://neo4j:testpassword@localhost:7687" -dataset local/phantom ``` -| Query | Dataset | Median | P95 | Max | -|-------|---------|-------:|----:|----:| -| Match Nodes | local/phantom | 1.4ms | 2.3ms | 2.3ms | -| Match Edges | local/phantom | 1.6ms | 1.9ms | 1.9ms | -| Filter By Kind / User | local/phantom | 2.0ms | 2.6ms | 2.6ms | -| Filter By Kind / Group | local/phantom | 2.1ms | 2.3ms | 2.3ms | -| Filter By Kind / Computer | local/phantom | 1.6ms | 2.0ms | 2.0ms | -| Traversal Depth / depth 1 | local/phantom | 1.4ms | 2.1ms | 2.1ms | -| Traversal Depth / depth 2 | local/phantom | 1.6ms | 1.9ms | 1.9ms | -| Traversal Depth / depth 3 | local/phantom | 2.5ms | 3.3ms | 3.3ms | -| Edge Kind Traversal / MemberOf | local/phantom | 1.2ms | 1.4ms | 1.4ms | -| Edge Kind Traversal / GenericAll | local/phantom | 1.1ms | 1.5ms | 1.5ms | -| Edge Kind Traversal / HasSession | local/phantom | 1.1ms | 1.4ms | 1.4ms | -| Shortest Paths / 41 -> 587 | local/phantom | 1.5ms | 1.9ms | 1.9ms | +| Query | Dataset | Rows | Median | P95 | Max | +|-------|---------|-----:|-------:|----:|----:| +| Match Nodes | local/phantom | 1000 | 1.4ms | 2.3ms | 2.3ms | +| Match Edges | local/phantom | 2000 | 1.6ms | 1.9ms | 1.9ms | ## Example: PG on local/phantom @@ -65,17 +55,7 @@ $ export CONNECTION_STRING="postgresql://dawgs:dawgs@localhost:5432/dawgs" $ go run ./cmd/benchmark -dataset local/phantom ``` -| Query | Dataset | Median | P95 | Max | -|-------|---------|-------:|----:|----:| -| Match Nodes | local/phantom | 2.0ms | 6.5ms | 6.5ms | -| Match Edges | local/phantom | 464ms | 604ms | 604ms | -| Filter By Kind / User | local/phantom | 4.5ms | 18.3ms | 18.3ms | -| Filter By Kind / Group | local/phantom | 6.2ms | 28.8ms | 28.8ms | -| Filter By Kind / Computer | local/phantom | 1.1ms | 5.5ms | 5.5ms | -| Traversal Depth / depth 1 | local/phantom | 596ms | 636ms | 636ms | -| Traversal Depth / depth 2 | local/phantom | 639ms | 660ms | 660ms | -| Traversal Depth / depth 3 | local/phantom | 726ms | 745ms | 745ms | -| Edge Kind Traversal / MemberOf | local/phantom | 602ms | 627ms | 627ms | -| Edge Kind Traversal / GenericAll | local/phantom | 676ms | 791ms | 791ms | -| Edge Kind Traversal / HasSession | local/phantom | 682ms | 778ms | 778ms | -| Shortest Paths / 41 -> 587 | local/phantom | 708ms | 731ms | 731ms | +| Query | Dataset | Rows | Median | P95 | Max | +|-------|---------|-----:|-------:|----:|----:| +| Match Nodes | local/phantom | 1000 | 2.0ms | 6.5ms | 6.5ms | +| Match Edges | local/phantom | 2000 | 464ms | 604ms | 604ms | diff --git a/cmd/benchmark/main.go b/cmd/benchmark/main.go index bb857d5b..7ba26e1f 100644 --- a/cmd/benchmark/main.go +++ b/cmd/benchmark/main.go @@ -145,8 +145,9 @@ func main() { } report.Results = append(report.Results, result) - fmt.Fprintf(os.Stderr, " %s/%s: median=%s p95=%s max=%s\n", + fmt.Fprintf(os.Stderr, " %s/%s: rows=%d median=%s p95=%s max=%s\n", s.Section, s.Label, + result.RowCount, fmtDuration(result.Stats.Median), fmtDuration(result.Stats.P95), fmtDuration(result.Stats.Max), diff --git a/cmd/benchmark/report.go b/cmd/benchmark/report.go index dacab7dd..6ef23a55 100644 --- a/cmd/benchmark/report.go +++ b/cmd/benchmark/report.go @@ -40,8 +40,8 @@ func writeJSON(w io.Writer, r Report) error { func writeMarkdown(w io.Writer, r Report) error { fmt.Fprintf(w, "# Benchmarks — %s @ %s (%s, %d iterations)\n\n", r.Driver, r.GitRef, r.Date, r.Iterations) - fmt.Fprintf(w, "| Query | Dataset | Median | P95 | Max |\n") - fmt.Fprintf(w, "|-------|---------|-------:|----:|----:|\n") + fmt.Fprintf(w, "| Query | Dataset | Rows | Median | P95 | Max |\n") + fmt.Fprintf(w, "|-------|---------|-----:|-------:|----:|----:|\n") for _, res := range r.Results { label := res.Section @@ -49,9 +49,10 @@ func writeMarkdown(w io.Writer, r Report) error { label = res.Section + " / " + res.Label } - fmt.Fprintf(w, "| %s | %s | %s | %s | %s |\n", + fmt.Fprintf(w, "| %s | %s | %d | %s | %s | %s |\n", label, res.Dataset, + res.RowCount, fmtDuration(res.Stats.Median), fmtDuration(res.Stats.P95), fmtDuration(res.Stats.Max), diff --git a/cmd/benchmark/report_test.go b/cmd/benchmark/report_test.go index 2d72ed4d..a5d5bf0f 100644 --- a/cmd/benchmark/report_test.go +++ b/cmd/benchmark/report_test.go @@ -30,9 +30,10 @@ func TestWriteJSONEmitsBaselineFriendlyReport(t *testing.T) { Date: "2026-05-14", Iterations: 3, Results: []Result{{ - Section: "Traversal", - Dataset: "base", - Label: "depth 1", + Section: "Traversal", + Dataset: "base", + Label: "depth 1", + RowCount: 2, Stats: Stats{ Median: 10 * time.Millisecond, P95: 20 * time.Millisecond, @@ -51,6 +52,7 @@ func TestWriteJSONEmitsBaselineFriendlyReport(t *testing.T) { `"driver": "pg"`, `"git_ref": "abc123"`, `"median": 10000000`, + `"row_count": 2`, `"section": "Traversal"`, } { if !strings.Contains(text, expected) { diff --git a/cmd/benchmark/runner.go b/cmd/benchmark/runner.go index b146f11d..fd976a97 100644 --- a/cmd/benchmark/runner.go +++ b/cmd/benchmark/runner.go @@ -33,16 +33,22 @@ type Stats struct { // Result is one row in the report. type Result struct { - Section string `json:"section"` - Dataset string `json:"dataset"` - Label string `json:"label"` - Stats Stats `json:"stats"` + Section string `json:"section"` + Dataset string `json:"dataset"` + Label string `json:"label"` + RowCount int64 `json:"row_count"` + Stats Stats `json:"stats"` } // runScenario executes a scenario N times and returns timing stats. func runScenario(ctx context.Context, db graph.Database, s Scenario, iterations int) (Result, error) { // Warm-up: one untimed run. - if err := db.ReadTransaction(ctx, s.Query); err != nil { + var rowCount int64 + if err := db.ReadTransaction(ctx, func(tx graph.Transaction) error { + count, err := s.Query(tx) + rowCount = count + return err + }); err != nil { return Result{}, err } @@ -50,17 +56,21 @@ func runScenario(ctx context.Context, db graph.Database, s Scenario, iterations for i := range iterations { start := time.Now() - if err := db.ReadTransaction(ctx, s.Query); err != nil { + if err := db.ReadTransaction(ctx, func(tx graph.Transaction) error { + _, err := s.Query(tx) + return err + }); err != nil { return Result{}, err } durations[i] = time.Since(start) } return Result{ - Section: s.Section, - Dataset: s.Dataset, - Label: s.Label, - Stats: computeStats(durations), + Section: s.Section, + Dataset: s.Dataset, + Label: s.Label, + RowCount: rowCount, + Stats: computeStats(durations), }, nil } diff --git a/cmd/benchmark/scenarios.go b/cmd/benchmark/scenarios.go index 217ae63d..18e6d2d0 100644 --- a/cmd/benchmark/scenarios.go +++ b/cmd/benchmark/scenarios.go @@ -28,17 +28,19 @@ type Scenario struct { Section string // grouping key in the report (e.g. "Match Nodes") Dataset string Label string // human-readable row label - Query func(tx graph.Transaction) error + Query func(tx graph.Transaction) (int64, error) } // defaultDatasets is the set of datasets committed to the repo. -var defaultDatasets = []string{"base"} +var defaultDatasets = []string{"base", "adcs_fanout"} // scenariosForDataset returns all benchmark scenarios for a given dataset and its loaded ID map. func scenariosForDataset(dataset string, idMap opengraph.IDMap) []Scenario { switch dataset { case "base": return baseScenarios(idMap) + case "adcs_fanout": + return adcsFanoutScenarios() case "local/phantom": return phantomScenarios(idMap) default: @@ -46,23 +48,25 @@ func scenariosForDataset(dataset string, idMap opengraph.IDMap) []Scenario { } } -func countNodes(tx graph.Transaction) error { - _, err := tx.Nodes().Count() - return err +func countNodes(tx graph.Transaction) (int64, error) { + return tx.Nodes().Count() } -func countEdges(tx graph.Transaction) error { - _, err := tx.Relationships().Count() - return err +func countEdges(tx graph.Transaction) (int64, error) { + return tx.Relationships().Count() } -func cypherQuery(cypher string) func(tx graph.Transaction) error { - return func(tx graph.Transaction) error { +func cypherQuery(cypher string) func(tx graph.Transaction) (int64, error) { + return func(tx graph.Transaction) (int64, error) { result := tx.Query(cypher, nil) defer result.Close() + + var rowCount int64 for result.Next() { + rowCount++ } - return result.Error() + + return rowCount, result.Error() } } @@ -90,6 +94,44 @@ func baseScenarios(idMap opengraph.IDMap) []Scenario { } } +const adcsFanoutObjectID = "S-1-5-21-2643190041-1319121918-239771340-513" + +func adcsFanoutScenarios() []Scenario { + ds := "adcs_fanout" + + p1 := fmt.Sprintf(` +MATCH (n:Group) WHERE n.objectid = '%s' +MATCH p1 = (n)-[:MemberOf*0..]->()-[:Enroll]->(ca:EnterpriseCA)-[:TrustedForNTAuth]->(:NTAuthStore)-[:NTAuthStoreFor]->(d:Domain) +RETURN p1 +`, adcsFanoutObjectID) + + p2 := fmt.Sprintf(` +MATCH (n:Group) WHERE n.objectid = '%s' +MATCH p2 = (n)-[:MemberOf*0..]->()-[:GenericAll|Enroll|AllExtendedRights]->(ct:CertTemplate)-[:PublishedTo]->(ca:EnterpriseCA)-[:IssuedSignedBy|EnterpriseCAFor*1..]->(:RootCA)-[:RootCAFor]->(d:Domain) +WHERE ct.authenticationenabled = true +AND ct.requiresmanagerapproval = false +AND ct.enrolleesuppliessubject = true +AND (ct.schemaversion = 1 OR ct.authorizedsignatures = 0) +RETURN p2 +`, adcsFanoutObjectID) + + combinedMatch := fmt.Sprintf(` +MATCH (n:Group) WHERE n.objectid = '%s' +MATCH p1 = (n)-[:MemberOf*0..]->()-[:Enroll]->(ca:EnterpriseCA)-[:TrustedForNTAuth]->(:NTAuthStore)-[:NTAuthStoreFor]->(d:Domain) +MATCH p2 = (n)-[:MemberOf*0..]->()-[:GenericAll|Enroll|AllExtendedRights]->(ct:CertTemplate)-[:PublishedTo]->(ca)-[:IssuedSignedBy|EnterpriseCAFor*1..]->(:RootCA)-[:RootCAFor]->(d) +WHERE ct.authenticationenabled = true +AND ct.requiresmanagerapproval = false +AND ct.enrolleesuppliessubject = true +AND (ct.schemaversion = 1 OR ct.authorizedsignatures = 0) +`, adcsFanoutObjectID) + + return []Scenario{ + {Section: "ADCS Fanout", Dataset: ds, Label: "p1 only", Query: cypherQuery(p1)}, + {Section: "ADCS Fanout", Dataset: ds, Label: "p2 only", Query: cypherQuery(p2)}, + {Section: "ADCS Fanout", Dataset: ds, Label: "combined", Query: cypherQuery(combinedMatch + "RETURN p1,p2")}, + } +} + // --- Phantom scenarios (hardcoded node IDs from the dataset) --- func phantomScenarios(idMap opengraph.IDMap) []Scenario { diff --git a/cypher/models/pgsql/translate/expansion.go b/cypher/models/pgsql/translate/expansion.go index 84a3e4f2..41fd459c 100644 --- a/cypher/models/pgsql/translate/expansion.go +++ b/cypher/models/pgsql/translate/expansion.go @@ -2286,72 +2286,166 @@ func expansionTerminalSatisfactionLocality(traversalStep *TraversalStep) (pgsql. ) } -func applyExpansionSuffixPushdown(part *PatternPart) error { +func applyExpansionSuffixPushdown(part *PatternPart) (int, error) { + var applied int + for idx := 0; idx+1 < len(part.TraversalSteps); idx++ { var ( currentStep = part.TraversalSteps[idx] - nextStep = part.TraversalSteps[idx+1] + suffixSteps = part.TraversalSteps[idx+1:] ) - if suffixSatisfaction, satisfied := expansionSuffixTerminalSatisfaction(currentStep, nextStep); satisfied { + if suffixSatisfaction, satisfied := expansionSuffixTerminalSatisfaction(currentStep, suffixSteps); satisfied { currentStep.Expansion.TerminalNodeConstraints = pgsql.OptionalAnd( currentStep.Expansion.TerminalNodeConstraints, suffixSatisfaction, ) if terminalCriteriaProjection, err := pgsql.As[pgsql.SelectItem](currentStep.Expansion.TerminalNodeConstraints); err != nil { - return err + return applied, err } else { currentStep.Expansion.TerminalNodeSatisfactionProjection = terminalCriteriaProjection } + + applied++ } } - return nil + return applied, nil } -func expansionSuffixTerminalSatisfaction(currentStep, nextStep *TraversalStep) (pgsql.Expression, bool) { - if currentStep == nil || - currentStep.Expansion == nil || - currentStep.RightNode == nil || - nextStep == nil || - nextStep.Expansion != nil || - nextStep.LeftNode == nil || - nextStep.Edge == nil || - nextStep.RightNode == nil || - nextStep.RightNodeBound || - nextStep.Direction == graph.DirectionBoth || - currentStep.RightNode.Identifier != nextStep.LeftNode.Identifier { +func suffixEdgeLeftEndpoint(edgeIdentifier pgsql.Identifier, direction graph.Direction) (pgsql.Expression, bool) { + switch direction { + case graph.DirectionOutbound: + return pgsql.CompoundIdentifier{edgeIdentifier, pgsql.ColumnStartID}, true + case graph.DirectionInbound: + return pgsql.CompoundIdentifier{edgeIdentifier, pgsql.ColumnEndID}, true + default: return nil, false } +} - var edgeConstraints pgsql.Expression - if nextStep.EdgeConstraints != nil { - edgeConstraints = nextStep.EdgeConstraints.Expression +func suffixEdgeRightEndpoint(edgeIdentifier pgsql.Identifier, direction graph.Direction) (pgsql.Expression, bool) { + switch direction { + case graph.DirectionOutbound: + return pgsql.CompoundIdentifier{edgeIdentifier, pgsql.ColumnEndID}, true + case graph.DirectionInbound: + return pgsql.CompoundIdentifier{edgeIdentifier, pgsql.ColumnStartID}, true + default: + return nil, false } +} - localScope := pgsql.AsIdentifierSet( - currentStep.RightNode.Identifier, - nextStep.Edge.Identifier, - nextStep.RightNode.Identifier, - ) - - edgeLocal, edgeExternal := partitionConstraintByLocality(edgeConstraints, localScope) - if edgeExternal != nil { +func suffixBoundNodeIDReference(currentStep *TraversalStep, node *BoundIdentifier) (pgsql.Expression, bool) { + if currentStep == nil || + currentStep.Frame == nil || + currentStep.Frame.Previous == nil || + currentStep.Frame.Previous.Binding == nil || + node == nil || + !currentStep.Frame.Previous.Known().Contains(node.Identifier) { return nil, false } - rightNodeLocal, rightNodeExternal := partitionConstraintByLocality(nextStep.RightNodeConstraints, localScope) - if rightNodeExternal != nil { + return pgsql.RowColumnReference{ + Identifier: pgsql.CompoundIdentifier{currentStep.Frame.Previous.Binding.Identifier, node.Identifier}, + Column: pgsql.ColumnID, + }, true +} + +func suffixStepEdgeConstraints(step *TraversalStep) pgsql.Expression { + if step == nil || step.EdgeConstraints == nil { + return nil + } + + return step.EdgeConstraints.Expression +} + +func expansionSuffixTerminalSatisfaction(currentStep *TraversalStep, suffixSteps []*TraversalStep) (pgsql.Expression, bool) { + if currentStep == nil || + currentStep.Expansion == nil || + currentStep.RightNode == nil || + len(suffixSteps) == 0 || + suffixSteps[0] == nil || + suffixSteps[0].LeftNode == nil || + currentStep.RightNode.Identifier != suffixSteps[0].LeftNode.Identifier { return nil, false } - terminalJoin, err := leftNodeConstraint( - nextStep.Edge.Identifier, - currentStep.RightNode.Identifier, - nextStep.Direction, + var ( + fromClause pgsql.FromClause + where pgsql.Expression + previousID pgsql.Expression = pgsql.CompoundIdentifier{currentStep.RightNode.Identifier, pgsql.ColumnID} ) - if err != nil { + + for idx, step := range suffixSteps { + if step == nil || + step.Expansion != nil || + step.LeftNode == nil || + step.Edge == nil || + step.RightNode == nil || + step.Direction == graph.DirectionBoth { + break + } + + if idx > 0 && suffixSteps[idx-1].RightNode.Identifier != step.LeftNode.Identifier { + break + } + + leftEndpoint, validDirection := suffixEdgeLeftEndpoint(step.Edge.Identifier, step.Direction) + if !validDirection { + return nil, false + } + + edgeJoin := pgd.Equals(previousID, leftEndpoint) + if idx == 0 { + fromClause = expansionEdgeFromClause(step.Edge.Identifier) + where = pgsql.OptionalAnd(where, edgeJoin) + } else { + fromClause.Joins = append(fromClause.Joins, pgsql.Join{ + Table: expansionEdgeTableReference(step.Edge.Identifier), + JoinOperator: pgsql.JoinOperator{ + JoinType: pgsql.JoinTypeInner, + Constraint: edgeJoin, + }, + }) + } + + where = pgsql.OptionalAnd(where, suffixStepEdgeConstraints(step)) + + rightEndpoint, validDirection := suffixEdgeRightEndpoint(step.Edge.Identifier, step.Direction) + if !validDirection { + return nil, false + } + + if step.RightNodeBound { + if step.RightNodeConstraints != nil { + return nil, false + } + + boundRightNodeID, hasBoundRightNodeID := suffixBoundNodeIDReference(currentStep, step.RightNode) + if !hasBoundRightNodeID { + return nil, false + } + + where = pgsql.OptionalAnd(where, pgd.Equals(rightEndpoint, boundRightNodeID)) + previousID = boundRightNodeID + } else { + fromClause.Joins = append(fromClause.Joins, pgsql.Join{ + Table: expansionNodeTableReference(step.RightNode.Identifier), + JoinOperator: pgsql.JoinOperator{ + JoinType: pgsql.JoinTypeInner, + Constraint: pgsql.OptionalAnd( + step.RightNodeConstraints, + pgd.Equals(pgsql.CompoundIdentifier{step.RightNode.Identifier, pgsql.ColumnID}, rightEndpoint), + ), + }, + }) + + previousID = pgsql.CompoundIdentifier{step.RightNode.Identifier, pgsql.ColumnID} + } + } + + if fromClause.Source == nil { return nil, false } @@ -2360,23 +2454,8 @@ func expansionSuffixTerminalSatisfaction(currentStep, nextStep *TraversalStep) ( Query: pgsql.Query{ Body: pgsql.Select{ Projection: pgsql.Projection{pgd.IntLiteral(1)}, - From: []pgsql.FromClause{{ - Source: expansionEdgeTableReference(nextStep.Edge.Identifier), - Joins: []pgsql.Join{{ - Table: expansionNodeTableReference(nextStep.RightNode.Identifier), - JoinOperator: pgsql.JoinOperator{ - JoinType: pgsql.JoinTypeInner, - Constraint: pgsql.OptionalAnd( - rightNodeLocal, - nextStep.RightNodeJoinCondition, - ), - }, - }}, - }}, - Where: pgsql.OptionalAnd( - terminalJoin, - edgeLocal, - ), + From: []pgsql.FromClause{fromClause}, + Where: where, }, }, }, diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index 98a8bff9..5229893a 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -50,10 +50,10 @@ func optimizerSafetyKindMapper() *pgutil.InMemoryKindMapper { return mapper } -func TestOptimizerSafetyADCSQueryPrunesExpansionEdgeCarry(t *testing.T) { - t.Parallel() +func optimizerSafetySQL(t *testing.T, cypherQuery string) string { + t.Helper() - regularQuery, err := frontend.ParseCypher(frontend.NewContext(), optimizerADCSQuery) + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), cypherQuery) require.NoError(t, err) translation, err := Translate(context.Background(), regularQuery, optimizerSafetyKindMapper(), nil, DefaultGraphID) @@ -62,7 +62,25 @@ func TestOptimizerSafetyADCSQueryPrunesExpansionEdgeCarry(t *testing.T) { formattedQuery, err := Translated(translation) require.NoError(t, err) - normalizedQuery := strings.Join(strings.Fields(formattedQuery), " ") + return strings.Join(strings.Fields(formattedQuery), " ") +} + +func requireOptimizationLowering(t *testing.T, summary OptimizationSummary, name string) { + t.Helper() + + for _, lowering := range summary.Lowerings { + if lowering.Name == name { + return + } + } + + require.Failf(t, "missing optimization lowering", "expected lowering %q in %#v", name, summary.Lowerings) +} + +func TestOptimizerSafetyADCSQueryPrunesExpansionEdgeCarry(t *testing.T) { + t.Parallel() + + normalizedQuery := optimizerSafetySQL(t, optimizerADCSQuery) require.Contains(t, normalizedQuery, "select distinct (s5.n0).id as root_id from s5") require.Contains(t, normalizedQuery, "s5.ep0 as ep0") @@ -76,16 +94,7 @@ func TestOptimizerSafetyADCSQueryPrunesExpansionEdgeCarry(t *testing.T) { func assertOptimizerSafetyRelationshipStaysComposite(t *testing.T, cypherQuery string) { t.Helper() - regularQuery, err := frontend.ParseCypher(frontend.NewContext(), cypherQuery) - require.NoError(t, err) - - translation, err := Translate(context.Background(), regularQuery, optimizerSafetyKindMapper(), nil, DefaultGraphID) - require.NoError(t, err) - - formattedQuery, err := Translated(translation) - require.NoError(t, err) - - normalizedQuery := strings.Join(strings.Fields(formattedQuery), " ") + normalizedQuery := optimizerSafetySQL(t, cypherQuery) require.Contains(t, normalizedQuery, "(e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0") require.Contains(t, normalizedQuery, "::edgecomposite") @@ -145,20 +154,11 @@ RETURN p, startNode(r) func TestOptimizerSafetyOptionalMatchPathStaysComposite(t *testing.T) { t.Parallel() - regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + normalizedQuery := optimizerSafetySQL(t, ` MATCH (n:Group) OPTIONAL MATCH p = (n)-[:MemberOf]->(m:Group) RETURN n, p `) - require.NoError(t, err) - - translation, err := Translate(context.Background(), regularQuery, optimizerSafetyKindMapper(), nil, DefaultGraphID) - require.NoError(t, err) - - formattedQuery, err := Translated(translation) - require.NoError(t, err) - - normalizedQuery := strings.Join(strings.Fields(formattedQuery), " ") require.Contains(t, normalizedQuery, "::edgecomposite[]") require.NotContains(t, normalizedQuery, "::int8[]") @@ -167,21 +167,12 @@ RETURN n, p func TestOptimizerSafetyFixedHopExpandIntoUsesBoundEndpoints(t *testing.T) { t.Parallel() - regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + normalizedQuery := optimizerSafetySQL(t, ` MATCH (a:Group) MATCH (b:Group) MATCH p = (a)-[:MemberOf]->(b) RETURN p `) - require.NoError(t, err) - - translation, err := Translate(context.Background(), regularQuery, optimizerSafetyKindMapper(), nil, DefaultGraphID) - require.NoError(t, err) - - formattedQuery, err := Translated(translation) - require.NoError(t, err) - - normalizedQuery := strings.Join(strings.Fields(formattedQuery), " ") require.Contains(t, normalizedQuery, "(s1.n0).id = e0.start_id") require.Contains(t, normalizedQuery, "(s1.n1).id = e0.end_id") @@ -191,21 +182,12 @@ RETURN p func TestOptimizerSafetyReordersIndependentNodeAnchor(t *testing.T) { t.Parallel() - regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + normalizedQuery := optimizerSafetySQL(t, ` MATCH (a) MATCH (b:EnterpriseCA {name: 'target'}) MATCH p = (a)-[:MemberOf]->(b) RETURN p `) - require.NoError(t, err) - - translation, err := Translate(context.Background(), regularQuery, optimizerSafetyKindMapper(), nil, DefaultGraphID) - require.NoError(t, err) - - formattedQuery, err := Translated(translation) - require.NoError(t, err) - - normalizedQuery := strings.Join(strings.Fields(formattedQuery), " ") enterpriseAnchorIndex := strings.Index(normalizedQuery, "array [5]::int2[]") broadScanIndex := strings.Index(normalizedQuery, "from s0, node n1") @@ -219,8 +201,23 @@ RETURN p func TestOptimizerSafetyExpansionTerminalPushdownForFixedSuffix(t *testing.T) { t.Parallel() + normalizedQuery := optimizerSafetySQL(t, ` +MATCH p = (n:Group)-[:MemberOf*1..]->(m)-[:Enroll]->(ca:EnterpriseCA) +RETURN p +`) + + require.Contains(t, normalizedQuery, "exists (select 1 from edge e1 join node n2") + require.Contains(t, normalizedQuery, "n1.id = e1.start_id") + require.Contains(t, normalizedQuery, "e1.kind_id = any (array [4]::int2[])") + require.Contains(t, normalizedQuery, "n2.kind_ids operator (pg_catalog.@>) array [5]::int2[]") +} + +func TestOptimizerSafetyTranslationReportsOptimizerMetadata(t *testing.T) { + t.Parallel() + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` MATCH p = (n:Group)-[:MemberOf*1..]->(m)-[:Enroll]->(ca:EnterpriseCA) +WHERE ca.name = 'target' RETURN p `) require.NoError(t, err) @@ -228,13 +225,66 @@ RETURN p translation, err := Translate(context.Background(), regularQuery, optimizerSafetyKindMapper(), nil, DefaultGraphID) require.NoError(t, err) - formattedQuery, err := Translated(translation) - require.NoError(t, err) + require.NotEmpty(t, translation.Optimization.Rules) + require.NotEmpty(t, translation.Optimization.PredicateAttachments) + requireOptimizationLowering(t, translation.Optimization, "ExpansionSuffixPushdown") +} + +func TestOptimizerSafetyExpansionTerminalPushdownForZeroDepthExpansion(t *testing.T) { + t.Parallel() - normalizedQuery := strings.Join(strings.Fields(formattedQuery), " ") + normalizedQuery := optimizerSafetySQL(t, ` +MATCH p = (n:Group)-[:MemberOf*0..]->(m)-[:Enroll]->(ca:EnterpriseCA) +RETURN p +`) require.Contains(t, normalizedQuery, "exists (select 1 from edge e1 join node n2") require.Contains(t, normalizedQuery, "n1.id = e1.start_id") require.Contains(t, normalizedQuery, "e1.kind_id = any (array [4]::int2[])") require.Contains(t, normalizedQuery, "n2.kind_ids operator (pg_catalog.@>) array [5]::int2[]") } + +func TestOptimizerSafetyExpansionTerminalPushdownForBoundEndpointSuffixChain(t *testing.T) { + t.Parallel() + + normalizedQuery := optimizerSafetySQL(t, ` +MATCH (ca:EnterpriseCA {name: 'target'}) +MATCH p = (n:Group)-[:MemberOf*0..]->(m)-[:Enroll]->(ct:CertTemplate)-[:PublishedTo]->(ca) +WHERE ct.authenticationenabled = true +RETURN p +`) + + require.Contains(t, normalizedQuery, "exists (select 1 from edge e1 join node n3") + require.Contains(t, normalizedQuery, "join edge e2 on n3.id = e2.start_id") + require.Contains(t, normalizedQuery, "n2.id = e1.start_id") + require.Contains(t, normalizedQuery, "e1.kind_id = any") + require.Contains(t, normalizedQuery, "properties -> 'authenticationenabled'") + require.Contains(t, normalizedQuery, "n3.kind_ids operator (pg_catalog.@>)") + require.Contains(t, normalizedQuery, "e2.kind_id = any") + require.Contains(t, normalizedQuery, "e2.end_id = (s0.n0).id") +} + +func TestOptimizerSafetyExpansionTerminalPushdownForInboundFixedSuffix(t *testing.T) { + t.Parallel() + + normalizedQuery := optimizerSafetySQL(t, ` +MATCH p = (ca:EnterpriseCA)<-[:PublishedTo*1..]-(ct)<-[:Enroll]-(m:Group) +RETURN p +`) + + require.Contains(t, normalizedQuery, "exists (select 1 from edge e1 join node n2") + require.Contains(t, normalizedQuery, "n1.id = e1.end_id") + require.Contains(t, normalizedQuery, "e1.kind_id = any (array [4]::int2[])") + require.Contains(t, normalizedQuery, "n2.kind_ids operator (pg_catalog.@>)") +} + +func TestOptimizerSafetyExpansionTerminalPushdownSkipsDirectionlessSuffix(t *testing.T) { + t.Parallel() + + normalizedQuery := optimizerSafetySQL(t, ` +MATCH p = (n:Group)-[:MemberOf*1..]->(m)-[:Enroll]-(ca:EnterpriseCA) +RETURN p +`) + + require.NotContains(t, normalizedQuery, "exists (select 1 from edge e1 join node n2") +} diff --git a/cypher/models/pgsql/translate/translator.go b/cypher/models/pgsql/translate/translator.go index 84e2863f..6525e002 100644 --- a/cypher/models/pgsql/translate/translator.go +++ b/cypher/models/pgsql/translate/translator.go @@ -520,8 +520,29 @@ func (s *Translator) Exit(expression cypher.SyntaxNode) { } type Result struct { - Statement pgsql.Statement - Parameters map[string]any + Statement pgsql.Statement + Parameters map[string]any + Optimization OptimizationSummary +} + +type OptimizationSummary struct { + Rules []optimize.RuleResult `json:"rules,omitempty"` + PredicateAttachments []optimize.PredicateAttachment `json:"predicate_attachments,omitempty"` + Lowerings []LoweringDecision `json:"lowerings,omitempty"` +} + +type LoweringDecision struct { + Name string `json:"name"` +} + +func (s *Translator) recordLowering(name string) { + for _, lowering := range s.translation.Optimization.Lowerings { + if lowering.Name == name { + return + } + } + + s.translation.Optimization.Lowerings = append(s.translation.Optimization.Lowerings, LoweringDecision{Name: name}) } func Translate(ctx context.Context, cypherQuery *cypher.RegularQuery, kindMapper pgsql.KindMapper, parameters map[string]any, graphID int32) (Result, error) { @@ -531,6 +552,8 @@ func Translate(ctx context.Context, cypherQuery *cypher.RegularQuery, kindMapper } translator := NewTranslator(ctx, kindMapper, parameters, graphID) + translator.translation.Optimization.Rules = optimizedPlan.Rules + translator.translation.Optimization.PredicateAttachments = optimizedPlan.PredicateAttachments if err := walk.Cypher(optimizedPlan.Query, translator); err != nil { return Result{}, err diff --git a/cypher/models/pgsql/translate/traversal.go b/cypher/models/pgsql/translate/traversal.go index 6192c47d..8dc0f401 100644 --- a/cypher/models/pgsql/translate/traversal.go +++ b/cypher/models/pgsql/translate/traversal.go @@ -601,8 +601,10 @@ func (s *Translator) translateTraversalPatternPart(part *PatternPart, isolatedPr } } - if err := applyExpansionSuffixPushdown(part); err != nil { + if applied, err := applyExpansionSuffixPushdown(part); err != nil { return err + } else if applied > 0 { + s.recordLowering("ExpansionSuffixPushdown") } if isolatedProjection { diff --git a/integration/testdata/adcs_fanout.json b/integration/testdata/adcs_fanout.json new file mode 100644 index 00000000..dafbb835 --- /dev/null +++ b/integration/testdata/adcs_fanout.json @@ -0,0 +1,50 @@ +{ + "graph": { + "nodes": [ + {"id": "n", "kinds": ["Group"], "properties": {"objectid": "S-1-5-21-2643190041-1319121918-239771340-513"}}, + {"id": "p1-a", "kinds": ["Group"]}, + {"id": "p1-b", "kinds": ["Group"]}, + {"id": "p1-c", "kinds": ["Group"]}, + {"id": "p2-good", "kinds": ["Group"]}, + {"id": "p2-disabled", "kinds": ["Group"]}, + {"id": "p2-wrong-ca", "kinds": ["Group"]}, + {"id": "ca", "kinds": ["EnterpriseCA"]}, + {"id": "other-ca", "kinds": ["EnterpriseCA"]}, + {"id": "store", "kinds": ["NTAuthStore"]}, + {"id": "domain", "kinds": ["Domain"]}, + {"id": "other-domain", "kinds": ["Domain"]}, + {"id": "template-good", "kinds": ["CertTemplate"], "properties": {"authenticationenabled": true, "requiresmanagerapproval": false, "enrolleesuppliessubject": true, "schemaversion": 1, "authorizedsignatures": 1}}, + {"id": "template-alt", "kinds": ["CertTemplate"], "properties": {"authenticationenabled": true, "requiresmanagerapproval": false, "enrolleesuppliessubject": true, "schemaversion": 2, "authorizedsignatures": 0}}, + {"id": "template-disabled", "kinds": ["CertTemplate"], "properties": {"authenticationenabled": false, "requiresmanagerapproval": true, "enrolleesuppliessubject": false, "schemaversion": 2, "authorizedsignatures": 1}}, + {"id": "template-wrong-ca", "kinds": ["CertTemplate"], "properties": {"authenticationenabled": true, "requiresmanagerapproval": false, "enrolleesuppliessubject": true, "schemaversion": 1, "authorizedsignatures": 1}}, + {"id": "root", "kinds": ["RootCA"]}, + {"id": "other-root", "kinds": ["RootCA"]} + ], + "edges": [ + {"start_id": "n", "end_id": "p1-a", "kind": "MemberOf"}, + {"start_id": "n", "end_id": "p1-b", "kind": "MemberOf"}, + {"start_id": "p1-b", "end_id": "p1-c", "kind": "MemberOf"}, + {"start_id": "n", "end_id": "p2-good", "kind": "MemberOf"}, + {"start_id": "n", "end_id": "p2-disabled", "kind": "MemberOf"}, + {"start_id": "n", "end_id": "p2-wrong-ca", "kind": "MemberOf"}, + {"start_id": "n", "end_id": "ca", "kind": "Enroll"}, + {"start_id": "p1-a", "end_id": "ca", "kind": "Enroll"}, + {"start_id": "p1-b", "end_id": "ca", "kind": "Enroll"}, + {"start_id": "p1-c", "end_id": "ca", "kind": "Enroll"}, + {"start_id": "ca", "end_id": "store", "kind": "TrustedForNTAuth"}, + {"start_id": "store", "end_id": "domain", "kind": "NTAuthStoreFor"}, + {"start_id": "p2-good", "end_id": "template-good", "kind": "GenericAll"}, + {"start_id": "p2-good", "end_id": "template-alt", "kind": "Enroll"}, + {"start_id": "p2-disabled", "end_id": "template-disabled", "kind": "AllExtendedRights"}, + {"start_id": "p2-wrong-ca", "end_id": "template-wrong-ca", "kind": "GenericAll"}, + {"start_id": "template-good", "end_id": "ca", "kind": "PublishedTo"}, + {"start_id": "template-alt", "end_id": "ca", "kind": "PublishedTo"}, + {"start_id": "template-disabled", "end_id": "ca", "kind": "PublishedTo"}, + {"start_id": "template-wrong-ca", "end_id": "other-ca", "kind": "PublishedTo"}, + {"start_id": "ca", "end_id": "root", "kind": "IssuedSignedBy"}, + {"start_id": "ca", "end_id": "other-root", "kind": "EnterpriseCAFor"}, + {"start_id": "root", "end_id": "domain", "kind": "RootCAFor"}, + {"start_id": "other-root", "end_id": "other-domain", "kind": "RootCAFor"} + ] + } +} diff --git a/integration/testdata/cases/expansion_inline.json b/integration/testdata/cases/expansion_inline.json index 929f32a4..2e5ede51 100644 --- a/integration/testdata/cases/expansion_inline.json +++ b/integration/testdata/cases/expansion_inline.json @@ -66,6 +66,64 @@ }, "assert": {"path_lengths": [2], "path_node_ids": [["src", "good-mid", "dst"]], "path_edge_kinds": [["EdgeKind1", "EdgeKind2"]]} }, + { + "name": "bind zero hop expansion path through only terminals that satisfy a fixed suffix", + "cypher": "match p = (src:NodeKind1)-[:EdgeKind1*0..]->(mid)-[:EdgeKind2]->(dst:NodeKind2) where src.name = 'terminal-pushdown-zero-src' return p", + "fixture": { + "nodes": [ + {"id": "src", "kinds": ["NodeKind1"], "properties": {"name": "terminal-pushdown-zero-src"}}, + {"id": "dead-mid", "kinds": ["NodeKind1"]}, + {"id": "dst", "kinds": ["NodeKind2"]} + ], + "edges": [ + {"start_id": "src", "end_id": "dead-mid", "kind": "EdgeKind1"}, + {"start_id": "src", "end_id": "dst", "kind": "EdgeKind2"} + ] + }, + "assert": {"path_lengths": [1], "path_node_ids": [["src", "dst"]], "path_edge_kinds": [["EdgeKind2"]]} + }, + { + "name": "bind expansion path through a fixed suffix chain to a bound endpoint", + "cypher": "match (dst:NodeKind2 {name: 'terminal-pushdown-bound-dst'}) match p = (src:NodeKind1)-[:EdgeKind1*0..]->(mid)-[:EdgeKind2]->(bridge:NodeKind1)-[:EdgeKind1]->(dst) where src.name = 'terminal-pushdown-bound-src' return p", + "fixture": { + "nodes": [ + {"id": "src", "kinds": ["NodeKind1"], "properties": {"name": "terminal-pushdown-bound-src"}}, + {"id": "good-mid", "kinds": ["NodeKind1"]}, + {"id": "bad-mid", "kinds": ["NodeKind1"]}, + {"id": "bridge", "kinds": ["NodeKind1"]}, + {"id": "bad-bridge", "kinds": ["NodeKind1"]}, + {"id": "dst", "kinds": ["NodeKind2"], "properties": {"name": "terminal-pushdown-bound-dst"}}, + {"id": "other-dst", "kinds": ["NodeKind2"], "properties": {"name": "terminal-pushdown-other-dst"}} + ], + "edges": [ + {"start_id": "src", "end_id": "good-mid", "kind": "EdgeKind1"}, + {"start_id": "src", "end_id": "bad-mid", "kind": "EdgeKind1"}, + {"start_id": "good-mid", "end_id": "bridge", "kind": "EdgeKind2"}, + {"start_id": "bad-mid", "end_id": "bad-bridge", "kind": "EdgeKind2"}, + {"start_id": "bridge", "end_id": "dst", "kind": "EdgeKind1"}, + {"start_id": "bad-bridge", "end_id": "other-dst", "kind": "EdgeKind1"} + ] + }, + "assert": {"row_count": 1, "path_lengths": [3], "path_node_ids": [["src", "good-mid", "bridge", "dst"]], "path_edge_kinds": [["EdgeKind1", "EdgeKind2", "EdgeKind1"]]} + }, + { + "name": "relationships of optimized expansion path preserve suffix order", + "cypher": "match p = (src:NodeKind1)-[:EdgeKind1*1..]->(mid)-[:EdgeKind2]->(dst:NodeKind2) where src.name = 'terminal-pushdown-src' return relationships(p)", + "fixture": { + "nodes": [ + {"id": "src", "kinds": ["NodeKind1"], "properties": {"name": "terminal-pushdown-src"}}, + {"id": "good-mid", "kinds": ["NodeKind1"]}, + {"id": "dead-mid", "kinds": ["NodeKind1"]}, + {"id": "dst", "kinds": ["NodeKind2"]} + ], + "edges": [ + {"start_id": "src", "end_id": "good-mid", "kind": "EdgeKind1"}, + {"start_id": "src", "end_id": "dead-mid", "kind": "EdgeKind1"}, + {"start_id": "good-mid", "end_id": "dst", "kind": "EdgeKind2"} + ] + }, + "assert": {"row_count": 1, "relationship_list_kinds": [["EdgeKind1", "EdgeKind2"]]} + }, { "name": "fixed step followed by a bounded variable-length expansion", "cypher": "match (n)-[]->(e:NodeKind1)-[*2..3]->(l) where n.name = 'start' return l", From fffcfe06d8da47cae34b88e36305ec1de16c306e Mon Sep 17 00:00:00 2001 From: John Hopper Date: Wed, 20 May 2026 19:13:02 -0700 Subject: [PATCH 023/116] test(pgsql): complete optimizer gap closure --- cmd/benchmark/README.md | 22 +- cmd/benchmark/explain.go | 68 ++++++ cmd/benchmark/main.go | 21 +- cmd/benchmark/report.go | 25 ++- cmd/benchmark/report_test.go | 62 +++++- cmd/benchmark/runner.go | 61 +++-- cmd/benchmark/scenarios.go | 209 ++++++++++++++---- .../pgsql/translate/optimizer_safety_test.go | 16 ++ docs/optimization-pass-memory.md | 12 + .../testdata/cases/optimizer_inline.json | 10 + 10 files changed, 426 insertions(+), 80 deletions(-) create mode 100644 cmd/benchmark/explain.go diff --git a/cmd/benchmark/README.md b/cmd/benchmark/README.md index 13686821..db41c4a7 100644 --- a/cmd/benchmark/README.md +++ b/cmd/benchmark/README.md @@ -1,6 +1,6 @@ # Benchmark -Runs query scenarios against a real database and outputs a markdown timing table with warm-up row counts. +Runs query scenarios against a real database and outputs a markdown timing table with warm-up row counts. Path-heavy scenarios can also report distinct returned path rows and duplicate returned path rows. ## Usage @@ -22,6 +22,9 @@ go run ./cmd/benchmark -connection "..." -output report.md # Save markdown and JSON for quality baseline comparison go run ./cmd/benchmark -connection "..." -output report.md -json-output report.json + +# Capture PostgreSQL EXPLAIN (ANALYZE, BUFFERS) in the JSON report for Cypher scenarios +go run ./cmd/benchmark -connection "..." -dataset adcs_fanout -json-output report.json -explain ``` ## Flags @@ -31,6 +34,7 @@ go run ./cmd/benchmark -connection "..." -output report.md -json-output report.j | `-driver` | `pg` | Database driver (`pg`, `neo4j`) | | `-connection` | | Connection string (or `CONNECTION_STRING` env) | | `-iterations` | `10` | Timed iterations per scenario | +| `-explain` | `false` | Capture PostgreSQL `EXPLAIN (ANALYZE, BUFFERS)` and translated SQL for Cypher scenarios in JSON output | | `-dataset` | | Run only this dataset | | `-local-dataset` | | Add a local dataset to the default set | | `-dataset-dir` | `integration/testdata` | Path to testdata directory | @@ -43,10 +47,10 @@ go run ./cmd/benchmark -connection "..." -output report.md -json-output report.j $ go run ./cmd/benchmark -driver neo4j -connection "neo4j://neo4j:testpassword@localhost:7687" -dataset local/phantom ``` -| Query | Dataset | Rows | Median | P95 | Max | -|-------|---------|-----:|-------:|----:|----:| -| Match Nodes | local/phantom | 1000 | 1.4ms | 2.3ms | 2.3ms | -| Match Edges | local/phantom | 2000 | 1.6ms | 1.9ms | 1.9ms | +| Query | Dataset | Rows | Distinct Rows | Duplicate Rows | Median | P95 | Max | Explain | +|-------|---------|-----:|--------------:|---------------:|-------:|----:|----:|:--------| +| Match Nodes | local/phantom | 1000 | - | - | 1.4ms | 2.3ms | 2.3ms | - | +| Match Edges | local/phantom | 2000 | - | - | 1.6ms | 1.9ms | 1.9ms | - | ## Example: PG on local/phantom @@ -55,7 +59,7 @@ $ export CONNECTION_STRING="postgresql://dawgs:dawgs@localhost:5432/dawgs" $ go run ./cmd/benchmark -dataset local/phantom ``` -| Query | Dataset | Rows | Median | P95 | Max | -|-------|---------|-----:|-------:|----:|----:| -| Match Nodes | local/phantom | 1000 | 2.0ms | 6.5ms | 6.5ms | -| Match Edges | local/phantom | 2000 | 464ms | 604ms | 604ms | +| Query | Dataset | Rows | Distinct Rows | Duplicate Rows | Median | P95 | Max | Explain | +|-------|---------|-----:|--------------:|---------------:|-------:|----:|----:|:--------| +| Match Nodes | local/phantom | 1000 | - | - | 2.0ms | 6.5ms | 6.5ms | - | +| Match Edges | local/phantom | 2000 | - | - | 464ms | 604ms | 604ms | - | diff --git a/cmd/benchmark/explain.go b/cmd/benchmark/explain.go new file mode 100644 index 00000000..ff06472b --- /dev/null +++ b/cmd/benchmark/explain.go @@ -0,0 +1,68 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "fmt" + + "github.com/specterops/dawgs/cypher/frontend" + "github.com/specterops/dawgs/cypher/models/pgsql" + "github.com/specterops/dawgs/cypher/models/pgsql/translate" + "github.com/specterops/dawgs/graph" +) + +func newPostgresExplainer(kindMapper pgsql.KindMapper, graphID int32) ExplainFunc { + return func(ctx context.Context, tx graph.Transaction, cypherQuery string) (*ExplainResult, error) { + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), cypherQuery) + if err != nil { + return nil, err + } + + translation, err := translate.Translate(ctx, regularQuery, kindMapper, nil, graphID) + if err != nil { + return nil, err + } + + sqlQuery, err := translate.Translated(translation) + if err != nil { + return nil, err + } + + result := tx.Raw("EXPLAIN (ANALYZE, BUFFERS) "+sqlQuery, translation.Parameters) + defer result.Close() + + var plan []string + for result.Next() { + values := result.Values() + if len(values) == 0 { + continue + } + + plan = append(plan, fmt.Sprint(values[0])) + } + + if err := result.Error(); err != nil { + return nil, err + } + + return &ExplainResult{ + SQL: sqlQuery, + Plan: plan, + }, nil + } +} diff --git a/cmd/benchmark/main.go b/cmd/benchmark/main.go index 7ba26e1f..eb927c87 100644 --- a/cmd/benchmark/main.go +++ b/cmd/benchmark/main.go @@ -42,6 +42,7 @@ func main() { iterations = flag.Int("iterations", 10, "timed iterations per scenario") output = flag.String("output", "", "markdown output file (default: stdout)") jsonOutput = flag.String("json-output", "", "JSON output file for baseline comparison") + explain = flag.Bool("explain", false, "capture PostgreSQL EXPLAIN (ANALYZE, BUFFERS) for Cypher scenarios") datasetDir = flag.String("dataset-dir", "integration/testdata", "path to testdata directory") localDataset = flag.String("local-dataset", "", "additional local dataset (e.g. local/phantom)") onlyDataset = flag.String("dataset", "", "run only this dataset (e.g. diamond, local/phantom)") @@ -108,6 +109,19 @@ func main() { fatal("failed to assert schema: %v", err) } + var runOptions RunOptions + if *explain { + if *driver != pg.DriverName { + fmt.Fprintf(os.Stderr, " explain capture is only supported for pg; continuing without plans\n") + } else if pgDB, ok := db.(*pg.Driver); !ok { + fmt.Fprintf(os.Stderr, " explain capture unavailable for %T; continuing without plans\n", db) + } else if defaultGraph, hasDefaultGraph := pgDB.DefaultGraph(); !hasDefaultGraph { + fatal("failed to resolve default graph for explain capture") + } else { + runOptions.Explain = newPostgresExplainer(pgDB.KindMapper(), defaultGraph.ID) + } + } + report := Report{ Driver: *driver, GitRef: gitRef(), @@ -138,19 +152,22 @@ func main() { // Run scenarios for _, s := range scenariosForDataset(ds, idMap) { - result, err := runScenario(ctx, db, s, *iterations) + result, err := runScenario(ctx, db, s, *iterations, runOptions) if err != nil { fmt.Fprintf(os.Stderr, " %s/%s failed: %v\n", s.Section, s.Label, err) continue } report.Results = append(report.Results, result) - fmt.Fprintf(os.Stderr, " %s/%s: rows=%d median=%s p95=%s max=%s\n", + fmt.Fprintf(os.Stderr, " %s/%s: rows=%d distinct=%s duplicates=%s median=%s p95=%s max=%s explain=%s\n", s.Section, s.Label, result.RowCount, + fmtOptionalInt64(result.DistinctRowCount), + fmtOptionalInt64(result.DuplicateRowCount), fmtDuration(result.Stats.Median), fmtDuration(result.Stats.P95), fmtDuration(result.Stats.Max), + fmtExplainStatus(result.Explain), ) } } diff --git a/cmd/benchmark/report.go b/cmd/benchmark/report.go index 6ef23a55..5d08bfd8 100644 --- a/cmd/benchmark/report.go +++ b/cmd/benchmark/report.go @@ -40,8 +40,8 @@ func writeJSON(w io.Writer, r Report) error { func writeMarkdown(w io.Writer, r Report) error { fmt.Fprintf(w, "# Benchmarks — %s @ %s (%s, %d iterations)\n\n", r.Driver, r.GitRef, r.Date, r.Iterations) - fmt.Fprintf(w, "| Query | Dataset | Rows | Median | P95 | Max |\n") - fmt.Fprintf(w, "|-------|---------|-----:|-------:|----:|----:|\n") + fmt.Fprintf(w, "| Query | Dataset | Rows | Distinct Rows | Duplicate Rows | Median | P95 | Max | Explain |\n") + fmt.Fprintf(w, "|-------|---------|-----:|--------------:|---------------:|-------:|----:|----:|:--------|\n") for _, res := range r.Results { label := res.Section @@ -49,13 +49,16 @@ func writeMarkdown(w io.Writer, r Report) error { label = res.Section + " / " + res.Label } - fmt.Fprintf(w, "| %s | %s | %d | %s | %s | %s |\n", + fmt.Fprintf(w, "| %s | %s | %d | %s | %s | %s | %s | %s | %s |\n", label, res.Dataset, res.RowCount, + fmtOptionalInt64(res.DistinctRowCount), + fmtOptionalInt64(res.DuplicateRowCount), fmtDuration(res.Stats.Median), fmtDuration(res.Stats.P95), fmtDuration(res.Stats.Max), + fmtExplainStatus(res.Explain), ) } @@ -63,6 +66,22 @@ func writeMarkdown(w io.Writer, r Report) error { return nil } +func fmtOptionalInt64(value *int64) string { + if value == nil { + return "-" + } + + return fmt.Sprintf("%d", *value) +} + +func fmtExplainStatus(explain *ExplainResult) string { + if explain == nil { + return "-" + } + + return "captured" +} + func fmtDuration(d time.Duration) string { ms := float64(d.Microseconds()) / 1000.0 if ms < 1 { diff --git a/cmd/benchmark/report_test.go b/cmd/benchmark/report_test.go index a5d5bf0f..6905798a 100644 --- a/cmd/benchmark/report_test.go +++ b/cmd/benchmark/report_test.go @@ -24,16 +24,25 @@ import ( ) func TestWriteJSONEmitsBaselineFriendlyReport(t *testing.T) { + distinctRows := int64(2) + duplicateRows := int64(0) + report := Report{ Driver: "pg", GitRef: "abc123", Date: "2026-05-14", Iterations: 3, Results: []Result{{ - Section: "Traversal", - Dataset: "base", - Label: "depth 1", - RowCount: 2, + Section: "Traversal", + Dataset: "base", + Label: "depth 1", + RowCount: 2, + DistinctRowCount: &distinctRows, + DuplicateRowCount: &duplicateRows, + Explain: &ExplainResult{ + SQL: "select 1;", + Plan: []string{"Result (actual rows=1 loops=1)"}, + }, Stats: Stats{ Median: 10 * time.Millisecond, P95: 20 * time.Millisecond, @@ -53,6 +62,9 @@ func TestWriteJSONEmitsBaselineFriendlyReport(t *testing.T) { `"git_ref": "abc123"`, `"median": 10000000`, `"row_count": 2`, + `"distinct_row_count": 2`, + `"duplicate_row_count": 0`, + `"sql": "select 1;"`, `"section": "Traversal"`, } { if !strings.Contains(text, expected) { @@ -60,3 +72,45 @@ func TestWriteJSONEmitsBaselineFriendlyReport(t *testing.T) { } } } + +func TestWriteMarkdownIncludesDiagnosticColumns(t *testing.T) { + distinctRows := int64(2) + duplicateRows := int64(0) + + report := Report{ + Driver: "pg", + GitRef: "abc123", + Date: "2026-05-14", + Iterations: 3, + Results: []Result{{ + Section: "ADCS Fanout", + Dataset: "adcs_fanout", + Label: "combined", + RowCount: 2, + DistinctRowCount: &distinctRows, + DuplicateRowCount: &duplicateRows, + Explain: &ExplainResult{Plan: []string{"Result"}}, + Stats: Stats{ + Median: 10 * time.Millisecond, + P95: 20 * time.Millisecond, + Max: 30 * time.Millisecond, + }, + }}, + } + + var output bytes.Buffer + if err := writeMarkdown(&output, report); err != nil { + t.Fatalf("write markdown: %v", err) + } + + text := output.String() + for _, expected := range []string{ + "Distinct Rows", + "Duplicate Rows", + "| ADCS Fanout / combined | adcs_fanout | 2 | 2 | 0 | 10.0ms | 20.0ms | 30.0ms | captured |", + } { + if !strings.Contains(text, expected) { + t.Fatalf("markdown report missing %q:\n%s", expected, text) + } + } +} diff --git a/cmd/benchmark/runner.go b/cmd/benchmark/runner.go index fd976a97..99646b5b 100644 --- a/cmd/benchmark/runner.go +++ b/cmd/benchmark/runner.go @@ -24,6 +24,18 @@ import ( "github.com/specterops/dawgs/graph" ) +type ExplainFunc func(ctx context.Context, tx graph.Transaction, cypher string) (*ExplainResult, error) + +type RunOptions struct { + Explain ExplainFunc +} + +// ExplainResult captures PostgreSQL-specific plan diagnostics for a scenario. +type ExplainResult struct { + SQL string `json:"sql"` + Plan []string `json:"plan"` +} + // Stats holds computed timing statistics for a scenario. type Stats struct { Median time.Duration `json:"median"` @@ -33,20 +45,23 @@ type Stats struct { // Result is one row in the report. type Result struct { - Section string `json:"section"` - Dataset string `json:"dataset"` - Label string `json:"label"` - RowCount int64 `json:"row_count"` - Stats Stats `json:"stats"` + Section string `json:"section"` + Dataset string `json:"dataset"` + Label string `json:"label"` + RowCount int64 `json:"row_count"` + DistinctRowCount *int64 `json:"distinct_row_count,omitempty"` + DuplicateRowCount *int64 `json:"duplicate_row_count,omitempty"` + Explain *ExplainResult `json:"explain,omitempty"` + Stats Stats `json:"stats"` } // runScenario executes a scenario N times and returns timing stats. -func runScenario(ctx context.Context, db graph.Database, s Scenario, iterations int) (Result, error) { +func runScenario(ctx context.Context, db graph.Database, s Scenario, iterations int, options RunOptions) (Result, error) { // Warm-up: one untimed run. - var rowCount int64 + var measurement Measurement if err := db.ReadTransaction(ctx, func(tx graph.Transaction) error { - count, err := s.Query(tx) - rowCount = count + nextMeasurement, err := s.Query(tx) + measurement = nextMeasurement return err }); err != nil { return Result{}, err @@ -65,13 +80,27 @@ func runScenario(ctx context.Context, db graph.Database, s Scenario, iterations durations[i] = time.Since(start) } - return Result{ - Section: s.Section, - Dataset: s.Dataset, - Label: s.Label, - RowCount: rowCount, - Stats: computeStats(durations), - }, nil + result := Result{ + Section: s.Section, + Dataset: s.Dataset, + Label: s.Label, + RowCount: measurement.RowCount, + DistinctRowCount: measurement.DistinctRowCount, + DuplicateRowCount: measurement.DuplicateRowCount, + Stats: computeStats(durations), + } + + if options.Explain != nil && s.Cypher != "" { + if err := db.ReadTransaction(ctx, func(tx graph.Transaction) error { + explain, err := options.Explain(ctx, tx, s.Cypher) + result.Explain = explain + return err + }); err != nil { + return Result{}, err + } + } + + return result, nil } func computeStats(durations []time.Duration) Stats { diff --git a/cmd/benchmark/scenarios.go b/cmd/benchmark/scenarios.go index 18e6d2d0..4f2450b3 100644 --- a/cmd/benchmark/scenarios.go +++ b/cmd/benchmark/scenarios.go @@ -18,17 +18,27 @@ package main import ( "fmt" + "strconv" + "strings" "github.com/specterops/dawgs/graph" "github.com/specterops/dawgs/opengraph" ) +// Measurement captures the warm-up result shape for a benchmark scenario. +type Measurement struct { + RowCount int64 + DistinctRowCount *int64 + DuplicateRowCount *int64 +} + // Scenario defines a single benchmark query to run against a loaded dataset. type Scenario struct { Section string // grouping key in the report (e.g. "Match Nodes") Dataset string Label string // human-readable row label - Query func(tx graph.Transaction) (int64, error) + Cypher string + Query func(tx graph.Transaction) (Measurement, error) } // defaultDatasets is the set of datasets committed to the repo. @@ -56,8 +66,8 @@ func countEdges(tx graph.Transaction) (int64, error) { return tx.Relationships().Count() } -func cypherQuery(cypher string) func(tx graph.Transaction) (int64, error) { - return func(tx graph.Transaction) (int64, error) { +func cypherQuery(cypher string) func(tx graph.Transaction) (Measurement, error) { + return func(tx graph.Transaction) (Measurement, error) { result := tx.Query(cypher, nil) defer result.Close() @@ -66,31 +76,143 @@ func cypherQuery(cypher string) func(tx graph.Transaction) (int64, error) { rowCount++ } - return rowCount, result.Error() + return Measurement{RowCount: rowCount}, result.Error() + } +} + +func countQuery(query func(tx graph.Transaction) (int64, error)) func(tx graph.Transaction) (Measurement, error) { + return func(tx graph.Transaction) (Measurement, error) { + rowCount, err := query(tx) + if err != nil { + return Measurement{}, err + } + + return Measurement{RowCount: rowCount}, nil + } +} + +func cypherScenario(section, dataset, label, cypher string) Scenario { + return Scenario{ + Section: section, + Dataset: dataset, + Label: label, + Cypher: cypher, + Query: cypherQuery(cypher), + } +} + +func cypherPathScenario(section, dataset, label, cypher string, pathColumns int) Scenario { + return Scenario{ + Section: section, + Dataset: dataset, + Label: label, + Cypher: cypher, + Query: cypherPathQuery(cypher, pathColumns), + } +} + +func cypherPathQuery(cypher string, pathColumns int) func(tx graph.Transaction) (Measurement, error) { + return func(tx graph.Transaction) (Measurement, error) { + result := tx.Query(cypher, nil) + defer result.Close() + + var ( + rowCount int64 + seen = map[string]struct{}{} + ) + + for result.Next() { + rowCount++ + + values := make([]graph.Path, pathColumns) + targets := make([]any, pathColumns) + for idx := range values { + targets[idx] = &values[idx] + } + + if err := result.Scan(targets...); err != nil { + return Measurement{}, err + } + + seen[pathRowKey(values)] = struct{}{} + } + + if err := result.Error(); err != nil { + return Measurement{}, err + } + + distinctRowCount := int64(len(seen)) + duplicateRowCount := rowCount - distinctRowCount + + return Measurement{ + RowCount: rowCount, + DistinctRowCount: &distinctRowCount, + DuplicateRowCount: &duplicateRowCount, + }, nil } } +func pathRowKey(paths []graph.Path) string { + var builder strings.Builder + + for pathIdx, path := range paths { + if pathIdx > 0 { + builder.WriteByte('|') + } + + builder.WriteByte('n') + for _, node := range path.Nodes { + builder.WriteByte(':') + if node == nil { + builder.WriteString("nil") + continue + } + + builder.WriteString(strconv.FormatUint(node.ID.Uint64(), 10)) + } + + builder.WriteString(";e") + for _, edge := range path.Edges { + builder.WriteByte(':') + if edge == nil { + builder.WriteString("nil") + continue + } + + builder.WriteString(strconv.FormatUint(edge.ID.Uint64(), 10)) + builder.WriteByte(',') + builder.WriteString(strconv.FormatUint(edge.StartID.Uint64(), 10)) + builder.WriteByte(',') + builder.WriteString(strconv.FormatUint(edge.EndID.Uint64(), 10)) + builder.WriteByte(',') + builder.WriteString(edge.Kind.String()) + } + } + + return builder.String() +} + // --- Base dataset scenarios (n1 -> n2 -> n3) --- func baseScenarios(idMap opengraph.IDMap) []Scenario { ds := "base" return []Scenario{ - {Section: "Match Nodes", Dataset: ds, Label: ds, Query: countNodes}, - {Section: "Match Edges", Dataset: ds, Label: ds, Query: countEdges}, - {Section: "Shortest Paths", Dataset: ds, Label: "n1 -> n3", Query: cypherQuery(fmt.Sprintf( + {Section: "Match Nodes", Dataset: ds, Label: ds, Query: countQuery(countNodes)}, + {Section: "Match Edges", Dataset: ds, Label: ds, Query: countQuery(countEdges)}, + cypherScenario("Shortest Paths", ds, "n1 -> n3", fmt.Sprintf( "MATCH p = allShortestPaths((s)-[*1..]->(e)) WHERE id(s) = %d AND id(e) = %d RETURN p", idMap["n1"], idMap["n3"], - ))}, - {Section: "Traversal", Dataset: ds, Label: "n1", Query: cypherQuery(fmt.Sprintf( + )), + cypherScenario("Traversal", ds, "n1", fmt.Sprintf( "MATCH (s)-[*1..]->(e) WHERE id(s) = %d RETURN e", idMap["n1"], - ))}, - {Section: "Match Return", Dataset: ds, Label: "n1", Query: cypherQuery(fmt.Sprintf( + )), + cypherScenario("Match Return", ds, "n1", fmt.Sprintf( "MATCH (s)-[]->(e) WHERE id(s) = %d RETURN e", idMap["n1"], - ))}, - {Section: "Filter By Kind", Dataset: ds, Label: "NodeKind1", Query: cypherQuery("MATCH (n:NodeKind1) RETURN n")}, - {Section: "Filter By Kind", Dataset: ds, Label: "NodeKind2", Query: cypherQuery("MATCH (n:NodeKind2) RETURN n")}, + )), + cypherScenario("Filter By Kind", ds, "NodeKind1", "MATCH (n:NodeKind1) RETURN n"), + cypherScenario("Filter By Kind", ds, "NodeKind2", "MATCH (n:NodeKind2) RETURN n"), } } @@ -126,9 +248,9 @@ AND (ct.schemaversion = 1 OR ct.authorizedsignatures = 0) `, adcsFanoutObjectID) return []Scenario{ - {Section: "ADCS Fanout", Dataset: ds, Label: "p1 only", Query: cypherQuery(p1)}, - {Section: "ADCS Fanout", Dataset: ds, Label: "p2 only", Query: cypherQuery(p2)}, - {Section: "ADCS Fanout", Dataset: ds, Label: "combined", Query: cypherQuery(combinedMatch + "RETURN p1,p2")}, + cypherPathScenario("ADCS Fanout", ds, "p1 only", p1, 1), + cypherPathScenario("ADCS Fanout", ds, "p2 only", p2, 1), + cypherPathScenario("ADCS Fanout", ds, "combined", combinedMatch+"RETURN p1,p2", 2), } } @@ -138,59 +260,54 @@ func phantomScenarios(idMap opengraph.IDMap) []Scenario { ds := "local/phantom" scenarios := []Scenario{ - {Section: "Match Nodes", Dataset: ds, Label: ds, Query: countNodes}, - {Section: "Match Edges", Dataset: ds, Label: ds, Query: countEdges}, + {Section: "Match Nodes", Dataset: ds, Label: ds, Query: countQuery(countNodes)}, + {Section: "Match Edges", Dataset: ds, Label: ds, Query: countQuery(countEdges)}, } for _, kind := range []string{"User", "Group", "Computer"} { k := kind - scenarios = append(scenarios, Scenario{ - Section: "Filter By Kind", - Dataset: ds, - Label: k, - Query: cypherQuery(fmt.Sprintf("MATCH (n:%s) RETURN n", k)), - }) + scenarios = append(scenarios, cypherScenario("Filter By Kind", ds, k, fmt.Sprintf("MATCH (n:%s) RETURN n", k))) } if _, ok := idMap["41"]; ok { for _, depth := range []int{1, 2, 3} { d := depth - scenarios = append(scenarios, Scenario{ - Section: "Traversal Depth", - Dataset: ds, - Label: fmt.Sprintf("depth %d", d), - Query: cypherQuery(fmt.Sprintf( + scenarios = append(scenarios, cypherScenario( + "Traversal Depth", + ds, + fmt.Sprintf("depth %d", d), + fmt.Sprintf( "MATCH (s)-[*1..%d]->(e) WHERE id(s) = %d RETURN e", d, idMap["41"], - )), - }) + ), + )) } for _, ek := range []string{"MemberOf", "GenericAll", "HasSession"} { edgeKind := ek - scenarios = append(scenarios, Scenario{ - Section: "Edge Kind Traversal", - Dataset: ds, - Label: edgeKind, - Query: cypherQuery(fmt.Sprintf( + scenarios = append(scenarios, cypherScenario( + "Edge Kind Traversal", + ds, + edgeKind, + fmt.Sprintf( "MATCH (s)-[:%s*1..]->(e) WHERE id(s) = %d RETURN e", edgeKind, idMap["41"], - )), - }) + ), + )) } } if _, ok := idMap["41"]; ok { if _, ok := idMap["587"]; ok { - scenarios = append(scenarios, Scenario{ - Section: "Shortest Paths", - Dataset: ds, - Label: "41 -> 587", - Query: cypherQuery(fmt.Sprintf( + scenarios = append(scenarios, cypherScenario( + "Shortest Paths", + ds, + "41 -> 587", + fmt.Sprintf( "MATCH p = allShortestPaths((s)-[*1..]->(e)) WHERE id(s) = %d AND id(e) = %d RETURN p", idMap["41"], idMap["587"], - )), - }) + ), + )) } } diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index 5229893a..a9ec9389 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -264,6 +264,22 @@ RETURN p require.Contains(t, normalizedQuery, "e2.end_id = (s0.n0).id") } +func TestOptimizerSafetyExpansionTerminalPushdownForBoundDomainSuffix(t *testing.T) { + t.Parallel() + + normalizedQuery := optimizerSafetySQL(t, ` +MATCH (d:Domain {name: 'target'}) +MATCH p = (ca:EnterpriseCA)-[:IssuedSignedBy|EnterpriseCAFor*1..]->(root:RootCA)-[:RootCAFor]->(d) +RETURN p +`) + + require.Contains(t, normalizedQuery, "exists (select 1 from edge e1") + require.Contains(t, normalizedQuery, "e1.kind_id = any") + require.Contains(t, normalizedQuery, "n2.kind_ids operator (pg_catalog.@>)") + require.Contains(t, normalizedQuery, "n2.id = e1.start_id") + require.Contains(t, normalizedQuery, "e1.end_id = (s0.n0).id") +} + func TestOptimizerSafetyExpansionTerminalPushdownForInboundFixedSuffix(t *testing.T) { t.Parallel() diff --git a/docs/optimization-pass-memory.md b/docs/optimization-pass-memory.md index 578f6710..8369426a 100644 --- a/docs/optimization-pass-memory.md +++ b/docs/optimization-pass-memory.md @@ -250,3 +250,15 @@ Before implementing expand-into detection, capture the following for the motivat - Comparison with Neo4j result cardinality for the same fixture. Projection pruning and late path materialization currently live in PostgreSQL translator lowering. If later phases need richer rule-level ordering or barrier enforcement, promote these decisions into explicit optimizer rule metadata instead of adding more hidden translator-side state. + +## Gap Closure Completion Notes + +The gap-closure pass has been completed enough to return to the original phase sequence without broadening into Phase 10. + +- The benchmark harness includes the committed `adcs_fanout` dataset by default and has scenarios for `p1` alone, `p2` alone, and the combined `RETURN p1,p2` form. +- ADCS path scenarios now record warm-up row count, distinct returned path-row count, and duplicate returned path-row count. +- PostgreSQL benchmark runs can opt into `EXPLAIN (ANALYZE, BUFFERS)` capture with `-explain`; JSON output includes the translated SQL and plan text. +- The small ADCS integration fixture now asserts exact returned path shape and row count. The larger fanout fixture remains a measurement fixture rather than an exact cardinality oracle. +- Translation metadata reports optimizer rules, predicate attachments, and named lowerings, including `ExpansionSuffixPushdown`. +- Phase 9 suffix coverage includes zero-hop expansions, fixed suffix chains, suffixes ending at already-bound nodes, inbound suffixes, and the ADCS root-to-domain suffix shape. +- Directionless suffix pushdown remains deliberately unimplemented; those suffixes stay as normal translated pattern steps. diff --git a/integration/testdata/cases/optimizer_inline.json b/integration/testdata/cases/optimizer_inline.json index d8567426..d9e08b68 100644 --- a/integration/testdata/cases/optimizer_inline.json +++ b/integration/testdata/cases/optimizer_inline.json @@ -32,6 +32,16 @@ }, "assert": { "keys": ["p1", "p2"], + "row_count": 1, + "path_lengths": [4, 5], + "path_node_ids": [ + ["n", "p1-mid", "ca", "store", "domain"], + ["n", "p2-mid", "template", "ca", "root", "domain"] + ], + "path_edge_kinds": [ + ["MemberOf", "Enroll", "TrustedForNTAuth", "NTAuthStoreFor"], + ["MemberOf", "GenericAll", "PublishedTo", "IssuedSignedBy", "RootCAFor"] + ], "contains_node_with_props": {"objectid": "S-1-5-21-2643190041-1319121918-239771340-513"}, "contains_edge": {"start": "template", "end": "ca", "kind": "PublishedTo"} } From 9d185f5b63cca3918d8b21777c8fc6c72992bf4b Mon Sep 17 00:00:00 2001 From: John Hopper Date: Wed, 20 May 2026 19:21:25 -0700 Subject: [PATCH 024/116] test(pgsql): measure optimizer rules locally --- cmd/benchmark/README.md | 4 ++-- cmd/benchmark/explain.go | 12 ++++++++-- cmd/benchmark/main.go | 4 ++++ cmd/benchmark/report_test.go | 22 +++++++++++++++++++ cmd/benchmark/runner.go | 19 +++++++++++----- cmd/benchmark/scenarios.go | 1 + cypher/models/pgsql/optimize/optimizer.go | 18 +++++++-------- .../pgsql/translate/optimizer_safety_test.go | 18 ++++++++++++++- docs/optimization-pass-memory.md | 10 +++++++++ 9 files changed, 88 insertions(+), 20 deletions(-) diff --git a/cmd/benchmark/README.md b/cmd/benchmark/README.md index db41c4a7..1ac95798 100644 --- a/cmd/benchmark/README.md +++ b/cmd/benchmark/README.md @@ -1,6 +1,6 @@ # Benchmark -Runs query scenarios against a real database and outputs a markdown timing table with warm-up row counts. Path-heavy scenarios can also report distinct returned path rows and duplicate returned path rows. +Runs query scenarios against a real database and outputs a markdown timing table with warm-up row counts. Path-heavy scenarios can also report distinct returned path rows and duplicate returned path rows. PostgreSQL explain capture includes translated SQL, plan text, and optimizer rule/lowering metadata in JSON output. ## Usage @@ -23,7 +23,7 @@ go run ./cmd/benchmark -connection "..." -output report.md # Save markdown and JSON for quality baseline comparison go run ./cmd/benchmark -connection "..." -output report.md -json-output report.json -# Capture PostgreSQL EXPLAIN (ANALYZE, BUFFERS) in the JSON report for Cypher scenarios +# Capture PostgreSQL EXPLAIN (ANALYZE, BUFFERS), translated SQL, and optimizer metadata in JSON output go run ./cmd/benchmark -connection "..." -dataset adcs_fanout -json-output report.json -explain ``` diff --git a/cmd/benchmark/explain.go b/cmd/benchmark/explain.go index ff06472b..ca1334f7 100644 --- a/cmd/benchmark/explain.go +++ b/cmd/benchmark/explain.go @@ -26,6 +26,13 @@ import ( "github.com/specterops/dawgs/graph" ) +// ExplainResult captures PostgreSQL-specific plan diagnostics for a scenario. +type ExplainResult struct { + SQL string `json:"sql"` + Plan []string `json:"plan"` + Optimization translate.OptimizationSummary `json:"optimization"` +} + func newPostgresExplainer(kindMapper pgsql.KindMapper, graphID int32) ExplainFunc { return func(ctx context.Context, tx graph.Transaction, cypherQuery string) (*ExplainResult, error) { regularQuery, err := frontend.ParseCypher(frontend.NewContext(), cypherQuery) @@ -61,8 +68,9 @@ func newPostgresExplainer(kindMapper pgsql.KindMapper, graphID int32) ExplainFun } return &ExplainResult{ - SQL: sqlQuery, - Plan: plan, + SQL: sqlQuery, + Plan: plan, + Optimization: translation.Optimization, }, nil } } diff --git a/cmd/benchmark/main.go b/cmd/benchmark/main.go index eb927c87..7d59ddaf 100644 --- a/cmd/benchmark/main.go +++ b/cmd/benchmark/main.go @@ -51,6 +51,10 @@ func main() { flag.Parse() + if err := validateIterations(*iterations); err != nil { + fatal("%v", err) + } + conn := *connStr if conn == "" { conn = os.Getenv("CONNECTION_STRING") diff --git a/cmd/benchmark/report_test.go b/cmd/benchmark/report_test.go index 6905798a..35820767 100644 --- a/cmd/benchmark/report_test.go +++ b/cmd/benchmark/report_test.go @@ -21,6 +21,9 @@ import ( "strings" "testing" "time" + + "github.com/specterops/dawgs/cypher/models/pgsql/optimize" + "github.com/specterops/dawgs/cypher/models/pgsql/translate" ) func TestWriteJSONEmitsBaselineFriendlyReport(t *testing.T) { @@ -42,6 +45,12 @@ func TestWriteJSONEmitsBaselineFriendlyReport(t *testing.T) { Explain: &ExplainResult{ SQL: "select 1;", Plan: []string{"Result (actual rows=1 loops=1)"}, + Optimization: translate.OptimizationSummary{ + Rules: []optimize.RuleResult{{ + Name: "ExpansionSuffixPushdown", + Applied: true, + }}, + }, }, Stats: Stats{ Median: 10 * time.Millisecond, @@ -65,6 +74,9 @@ func TestWriteJSONEmitsBaselineFriendlyReport(t *testing.T) { `"distinct_row_count": 2`, `"duplicate_row_count": 0`, `"sql": "select 1;"`, + `"optimization": {`, + `"name": "ExpansionSuffixPushdown"`, + `"applied": true`, `"section": "Traversal"`, } { if !strings.Contains(text, expected) { @@ -114,3 +126,13 @@ func TestWriteMarkdownIncludesDiagnosticColumns(t *testing.T) { } } } + +func TestValidateIterationsRejectsZero(t *testing.T) { + if err := validateIterations(0); err == nil { + t.Fatal("expected zero iterations to be rejected") + } + + if err := validateIterations(1); err != nil { + t.Fatalf("expected one iteration to be valid: %v", err) + } +} diff --git a/cmd/benchmark/runner.go b/cmd/benchmark/runner.go index 99646b5b..929593b4 100644 --- a/cmd/benchmark/runner.go +++ b/cmd/benchmark/runner.go @@ -18,6 +18,7 @@ package main import ( "context" + "fmt" "sort" "time" @@ -30,12 +31,6 @@ type RunOptions struct { Explain ExplainFunc } -// ExplainResult captures PostgreSQL-specific plan diagnostics for a scenario. -type ExplainResult struct { - SQL string `json:"sql"` - Plan []string `json:"plan"` -} - // Stats holds computed timing statistics for a scenario. type Stats struct { Median time.Duration `json:"median"` @@ -57,6 +52,10 @@ type Result struct { // runScenario executes a scenario N times and returns timing stats. func runScenario(ctx context.Context, db graph.Database, s Scenario, iterations int, options RunOptions) (Result, error) { + if err := validateIterations(iterations); err != nil { + return Result{}, err + } + // Warm-up: one untimed run. var measurement Measurement if err := db.ReadTransaction(ctx, func(tx graph.Transaction) error { @@ -103,6 +102,14 @@ func runScenario(ctx context.Context, db graph.Database, s Scenario, iterations return result, nil } +func validateIterations(iterations int) error { + if iterations < 1 { + return fmt.Errorf("iterations must be at least 1") + } + + return nil +} + func computeStats(durations []time.Duration) Stats { sort.Slice(durations, func(i, j int) bool { return durations[i] < durations[j] }) diff --git a/cmd/benchmark/scenarios.go b/cmd/benchmark/scenarios.go index 4f2450b3..48a1efb6 100644 --- a/cmd/benchmark/scenarios.go +++ b/cmd/benchmark/scenarios.go @@ -251,6 +251,7 @@ AND (ct.schemaversion = 1 OR ct.authorizedsignatures = 0) cypherPathScenario("ADCS Fanout", ds, "p1 only", p1, 1), cypherPathScenario("ADCS Fanout", ds, "p2 only", p2, 1), cypherPathScenario("ADCS Fanout", ds, "combined", combinedMatch+"RETURN p1,p2", 2), + cypherScenario("ADCS Fanout", ds, "combined endpoints", combinedMatch+"RETURN id(ca), id(d), id(ct)"), } } diff --git a/cypher/models/pgsql/optimize/optimizer.go b/cypher/models/pgsql/optimize/optimizer.go index ef66f8ab..802608a2 100644 --- a/cypher/models/pgsql/optimize/optimizer.go +++ b/cypher/models/pgsql/optimize/optimizer.go @@ -8,8 +8,8 @@ type Rule interface { } type RuleResult struct { - Name string - Applied bool + Name string `json:"name"` + Applied bool `json:"applied"` } type PredicateAttachmentScope string @@ -20,13 +20,13 @@ const ( ) type PredicateAttachment struct { - QueryPartIndex int - RegionIndex int - ClauseIndex int - ExpressionIndex int - Scope PredicateAttachmentScope - BindingSymbols []string - Dependencies []string + QueryPartIndex int `json:"query_part_index"` + RegionIndex int `json:"region_index"` + ClauseIndex int `json:"clause_index"` + ExpressionIndex int `json:"expression_index"` + Scope PredicateAttachmentScope `json:"scope"` + BindingSymbols []string `json:"binding_symbols"` + Dependencies []string `json:"dependencies"` } type Plan struct { diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index a9ec9389..6d6d479d 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -77,6 +77,17 @@ func requireOptimizationLowering(t *testing.T, summary OptimizationSummary, name require.Failf(t, "missing optimization lowering", "expected lowering %q in %#v", name, summary.Lowerings) } +func requireSQLContainsInOrder(t *testing.T, sql string, parts ...string) { + t.Helper() + + offset := 0 + for _, part := range parts { + nextIndex := strings.Index(sql[offset:], part) + require.NotEqualf(t, -1, nextIndex, "expected SQL to contain %q after offset %d:\n%s", part, offset, sql) + offset += nextIndex + len(part) + } +} + func TestOptimizerSafetyADCSQueryPrunesExpansionEdgeCarry(t *testing.T) { t.Parallel() @@ -258,10 +269,15 @@ RETURN p require.Contains(t, normalizedQuery, "join edge e2 on n3.id = e2.start_id") require.Contains(t, normalizedQuery, "n2.id = e1.start_id") require.Contains(t, normalizedQuery, "e1.kind_id = any") - require.Contains(t, normalizedQuery, "properties -> 'authenticationenabled'") require.Contains(t, normalizedQuery, "n3.kind_ids operator (pg_catalog.@>)") require.Contains(t, normalizedQuery, "e2.kind_id = any") require.Contains(t, normalizedQuery, "e2.end_id = (s0.n0).id") + requireSQLContainsInOrder(t, normalizedQuery, + "exists (select 1 from edge e1 join node n3", + "properties -> 'authenticationenabled'", + "join edge e2 on n3.id = e2.start_id", + "e2.end_id = (s0.n0).id", + ) } func TestOptimizerSafetyExpansionTerminalPushdownForBoundDomainSuffix(t *testing.T) { diff --git a/docs/optimization-pass-memory.md b/docs/optimization-pass-memory.md index 8369426a..25056c5c 100644 --- a/docs/optimization-pass-memory.md +++ b/docs/optimization-pass-memory.md @@ -262,3 +262,13 @@ The gap-closure pass has been completed enough to return to the original phase s - Translation metadata reports optimizer rules, predicate attachments, and named lowerings, including `ExpansionSuffixPushdown`. - Phase 9 suffix coverage includes zero-hop expansions, fixed suffix chains, suffixes ending at already-bound nodes, inbound suffixes, and the ADCS root-to-domain suffix shape. - Directionless suffix pushdown remains deliberately unimplemented; those suffixes stay as normal translated pattern steps. + +## Phase 10 Status Notes + +Phase 10 starts by making local measurements repeatable for the optimizer rules already implemented. + +- PostgreSQL `-explain` benchmark JSON includes translated SQL, `EXPLAIN (ANALYZE, BUFFERS)` plan text, optimizer rule results, predicate attachments, and translator lowering decisions. +- The ADCS fanout benchmark includes `p1` alone, `p2` alone, combined path return, and combined endpoint-only return. The endpoint-only scenario gives a local comparison point for final path reconstruction cost. +- The benchmark runner rejects zero timed iterations so baseline output cannot silently panic while gathering measurements. +- Representative SQL-shape tests assert that suffix-local predicates are inside the pushed suffix check, not merely present somewhere in the rendered SQL. +- Broad pass/fail performance thresholds remain deferred. Phase 10 measurements are local evidence and regression artifacts first; cost-based acceptance gates should wait for a larger benchmark corpus and stable environment assumptions. From a737cd6a70e53acbd5e51fef79aa2d96d69d0eb2 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Thu, 21 May 2026 14:04:35 -0700 Subject: [PATCH 025/116] Add optimizer lowering metadata contract --- cypher/models/pgsql/optimize/lowering.go | 144 ++++++++++++++++++++ cypher/models/pgsql/optimize/optimizer.go | 1 + cypher/models/pgsql/translate/translator.go | 16 ++- 3 files changed, 155 insertions(+), 6 deletions(-) create mode 100644 cypher/models/pgsql/optimize/lowering.go diff --git a/cypher/models/pgsql/optimize/lowering.go b/cypher/models/pgsql/optimize/lowering.go new file mode 100644 index 00000000..093f0b57 --- /dev/null +++ b/cypher/models/pgsql/optimize/lowering.go @@ -0,0 +1,144 @@ +package optimize + +import "github.com/specterops/dawgs/cypher/models/cypher" + +const ( + LoweringProjectionPruning = "ProjectionPruning" + LoweringLatePathMaterialization = "LatePathMaterialization" + LoweringExpandIntoDetection = "ExpandIntoDetection" + LoweringExpansionSuffixPushdown = "ExpansionSuffixPushdown" + LoweringPredicatePlacement = "PredicatePlacement" +) + +type LoweringDecision struct { + Name string `json:"name"` +} + +type PatternTarget struct { + QueryPartIndex int `json:"query_part_index"` + ClauseIndex int `json:"clause_index"` + PatternIndex int `json:"pattern_index"` +} + +func (s PatternTarget) TraversalStep(stepIndex int) TraversalStepTarget { + return TraversalStepTarget{ + QueryPartIndex: s.QueryPartIndex, + ClauseIndex: s.ClauseIndex, + PatternIndex: s.PatternIndex, + StepIndex: stepIndex, + } +} + +type TraversalStepTarget struct { + QueryPartIndex int `json:"query_part_index"` + ClauseIndex int `json:"clause_index"` + PatternIndex int `json:"pattern_index"` + StepIndex int `json:"step_index"` +} + +type ProjectionPruningDecision struct { + Target TraversalStepTarget `json:"target"` + UnexportLeftNode bool `json:"unexport_left_node,omitempty"` + UnexportEdge bool `json:"unexport_edge,omitempty"` + UnexportRightNode bool `json:"unexport_right_node,omitempty"` + UnexportExpansionID bool `json:"unexport_expansion_id,omitempty"` +} + +type LatePathMaterializationMode string + +const ( + LatePathMaterializationPathEdgeID LatePathMaterializationMode = "path_edge_id" + LatePathMaterializationExpansionPath LatePathMaterializationMode = "expansion_path" + LatePathMaterializationEdgeComposite LatePathMaterializationMode = "edge_composite" +) + +type LatePathMaterializationDecision struct { + Target TraversalStepTarget `json:"target"` + Mode LatePathMaterializationMode `json:"mode"` +} + +type ExpandIntoDecision struct { + Target TraversalStepTarget `json:"target"` +} + +type ExpansionSuffixPushdownDecision struct { + Target TraversalStepTarget `json:"target"` + SuffixLength int `json:"suffix_length"` +} + +type PredicatePlacementDecision struct { + Target TraversalStepTarget `json:"target"` + Attachment PredicateAttachment `json:"attachment"` + Placement PredicateAttachmentScope `json:"placement"` +} + +type LoweringPlan struct { + ProjectionPruning []ProjectionPruningDecision `json:"projection_pruning,omitempty"` + LatePathMaterialization []LatePathMaterializationDecision `json:"late_path_materialization,omitempty"` + ExpandInto []ExpandIntoDecision `json:"expand_into,omitempty"` + ExpansionSuffixPushdown []ExpansionSuffixPushdownDecision `json:"expansion_suffix_pushdown,omitempty"` + PredicatePlacement []PredicatePlacementDecision `json:"predicate_placement,omitempty"` +} + +func (s LoweringPlan) Empty() bool { + return len(s.ProjectionPruning) == 0 && + len(s.LatePathMaterialization) == 0 && + len(s.ExpandInto) == 0 && + len(s.ExpansionSuffixPushdown) == 0 && + len(s.PredicatePlacement) == 0 +} + +func (s LoweringPlan) Decisions() []LoweringDecision { + var decisions []LoweringDecision + add := func(name string, applied bool) { + if applied { + decisions = append(decisions, LoweringDecision{Name: name}) + } + } + + add(LoweringProjectionPruning, len(s.ProjectionPruning) > 0) + add(LoweringLatePathMaterialization, len(s.LatePathMaterialization) > 0) + add(LoweringExpandIntoDetection, len(s.ExpandInto) > 0) + add(LoweringExpansionSuffixPushdown, len(s.ExpansionSuffixPushdown) > 0) + add(LoweringPredicatePlacement, len(s.PredicatePlacement) > 0) + + return decisions +} + +func IndexPatternTargets(query *cypher.RegularQuery) map[*cypher.PatternPart]PatternTarget { + targets := map[*cypher.PatternPart]PatternTarget{} + + if query == nil || query.SingleQuery == nil { + return targets + } + + if query.SingleQuery.MultiPartQuery != nil { + for queryPartIndex, part := range query.SingleQuery.MultiPartQuery.Parts { + indexReadingClauseTargets(targets, queryPartIndex, part.ReadingClauses) + } + + if finalPart := query.SingleQuery.MultiPartQuery.SinglePartQuery; finalPart != nil { + indexReadingClauseTargets(targets, len(query.SingleQuery.MultiPartQuery.Parts), finalPart.ReadingClauses) + } + } else if query.SingleQuery.SinglePartQuery != nil { + indexReadingClauseTargets(targets, 0, query.SingleQuery.SinglePartQuery.ReadingClauses) + } + + return targets +} + +func indexReadingClauseTargets(targets map[*cypher.PatternPart]PatternTarget, queryPartIndex int, readingClauses []*cypher.ReadingClause) { + for clauseIndex, readingClause := range readingClauses { + if readingClause == nil || readingClause.Match == nil { + continue + } + + for patternIndex, patternPart := range readingClause.Match.Pattern { + targets[patternPart] = PatternTarget{ + QueryPartIndex: queryPartIndex, + ClauseIndex: clauseIndex, + PatternIndex: patternIndex, + } + } + } +} diff --git a/cypher/models/pgsql/optimize/optimizer.go b/cypher/models/pgsql/optimize/optimizer.go index 802608a2..8f18d6b7 100644 --- a/cypher/models/pgsql/optimize/optimizer.go +++ b/cypher/models/pgsql/optimize/optimizer.go @@ -32,6 +32,7 @@ type PredicateAttachment struct { type Plan struct { Query *cypher.RegularQuery Analysis Analysis + LoweringPlan LoweringPlan Rules []RuleResult PredicateAttachments []PredicateAttachment } diff --git a/cypher/models/pgsql/translate/translator.go b/cypher/models/pgsql/translate/translator.go index 6525e002..79c220cb 100644 --- a/cypher/models/pgsql/translate/translator.go +++ b/cypher/models/pgsql/translate/translator.go @@ -528,11 +528,8 @@ type Result struct { type OptimizationSummary struct { Rules []optimize.RuleResult `json:"rules,omitempty"` PredicateAttachments []optimize.PredicateAttachment `json:"predicate_attachments,omitempty"` - Lowerings []LoweringDecision `json:"lowerings,omitempty"` -} - -type LoweringDecision struct { - Name string `json:"name"` + Lowerings []optimize.LoweringDecision `json:"lowerings,omitempty"` + LoweringPlan *optimize.LoweringPlan `json:"lowering_plan,omitempty"` } func (s *Translator) recordLowering(name string) { @@ -542,7 +539,7 @@ func (s *Translator) recordLowering(name string) { } } - s.translation.Optimization.Lowerings = append(s.translation.Optimization.Lowerings, LoweringDecision{Name: name}) + s.translation.Optimization.Lowerings = append(s.translation.Optimization.Lowerings, optimize.LoweringDecision{Name: name}) } func Translate(ctx context.Context, cypherQuery *cypher.RegularQuery, kindMapper pgsql.KindMapper, parameters map[string]any, graphID int32) (Result, error) { @@ -554,6 +551,13 @@ func Translate(ctx context.Context, cypherQuery *cypher.RegularQuery, kindMapper translator := NewTranslator(ctx, kindMapper, parameters, graphID) translator.translation.Optimization.Rules = optimizedPlan.Rules translator.translation.Optimization.PredicateAttachments = optimizedPlan.PredicateAttachments + if !optimizedPlan.LoweringPlan.Empty() { + loweringPlan := optimizedPlan.LoweringPlan + translator.translation.Optimization.LoweringPlan = &loweringPlan + for _, lowering := range loweringPlan.Decisions() { + translator.recordLowering(lowering.Name) + } + } if err := walk.Cypher(optimizedPlan.Query, translator); err != nil { return Result{}, err From 807acd52dbfef54823783a1f48a8a6c4f35661c2 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Thu, 21 May 2026 14:08:21 -0700 Subject: [PATCH 026/116] Lift projection pruning decisions into optimizer --- cypher/models/pgsql/optimize/lowering.go | 12 +- cypher/models/pgsql/optimize/lowering_plan.go | 149 ++++++++++++++++++ cypher/models/pgsql/optimize/optimizer.go | 6 + .../models/pgsql/optimize/optimizer_test.go | 23 +++ .../pgsql/optimize/source_references.go | 118 ++++++++++++++ cypher/models/pgsql/translate/expansion.go | 5 +- cypher/models/pgsql/translate/model.go | 3 + .../pgsql/translate/optimizer_safety_test.go | 3 + cypher/models/pgsql/translate/pattern.go | 4 + cypher/models/pgsql/translate/translator.go | 15 ++ cypher/models/pgsql/translate/traversal.go | 98 +++++++++++- 11 files changed, 425 insertions(+), 11 deletions(-) create mode 100644 cypher/models/pgsql/optimize/lowering_plan.go create mode 100644 cypher/models/pgsql/optimize/source_references.go diff --git a/cypher/models/pgsql/optimize/lowering.go b/cypher/models/pgsql/optimize/lowering.go index 093f0b57..c3defda5 100644 --- a/cypher/models/pgsql/optimize/lowering.go +++ b/cypher/models/pgsql/optimize/lowering.go @@ -37,11 +37,9 @@ type TraversalStepTarget struct { } type ProjectionPruningDecision struct { - Target TraversalStepTarget `json:"target"` - UnexportLeftNode bool `json:"unexport_left_node,omitempty"` - UnexportEdge bool `json:"unexport_edge,omitempty"` - UnexportRightNode bool `json:"unexport_right_node,omitempty"` - UnexportExpansionID bool `json:"unexport_expansion_id,omitempty"` + Target TraversalStepTarget `json:"target"` + ReferencedSymbols []string `json:"referenced_symbols,omitempty"` + PatternBindingReferenced bool `json:"pattern_binding_referenced,omitempty"` } type LatePathMaterializationMode string @@ -114,6 +112,10 @@ func IndexPatternTargets(query *cypher.RegularQuery) map[*cypher.PatternPart]Pat if query.SingleQuery.MultiPartQuery != nil { for queryPartIndex, part := range query.SingleQuery.MultiPartQuery.Parts { + if part == nil { + continue + } + indexReadingClauseTargets(targets, queryPartIndex, part.ReadingClauses) } diff --git a/cypher/models/pgsql/optimize/lowering_plan.go b/cypher/models/pgsql/optimize/lowering_plan.go new file mode 100644 index 00000000..35f18f30 --- /dev/null +++ b/cypher/models/pgsql/optimize/lowering_plan.go @@ -0,0 +1,149 @@ +package optimize + +import "github.com/specterops/dawgs/cypher/models/cypher" + +type sourceTraversalStep struct { + LeftNode *cypher.NodePattern + Relationship *cypher.RelationshipPattern + RightNode *cypher.NodePattern +} + +func BuildLoweringPlan(query *cypher.RegularQuery, _ Analysis) (LoweringPlan, error) { + if query == nil || query.SingleQuery == nil { + return LoweringPlan{}, nil + } + + var plan LoweringPlan + + if query.SingleQuery.MultiPartQuery != nil { + for queryPartIndex, part := range query.SingleQuery.MultiPartQuery.Parts { + if part == nil { + continue + } + + if err := appendQueryPartLowerings(&plan, queryPartIndex, part, part.ReadingClauses); err != nil { + return LoweringPlan{}, err + } + } + + if finalPart := query.SingleQuery.MultiPartQuery.SinglePartQuery; finalPart != nil { + if err := appendQueryPartLowerings(&plan, len(query.SingleQuery.MultiPartQuery.Parts), finalPart, finalPart.ReadingClauses); err != nil { + return LoweringPlan{}, err + } + } + } else if singlePart := query.SingleQuery.SinglePartQuery; singlePart != nil { + if err := appendQueryPartLowerings(&plan, 0, singlePart, singlePart.ReadingClauses); err != nil { + return LoweringPlan{}, err + } + } + + return plan, nil +} + +func appendQueryPartLowerings(plan *LoweringPlan, queryPartIndex int, queryPart cypher.SyntaxNode, readingClauses []*cypher.ReadingClause) error { + sourceReferences, err := collectReferencedSourceIdentifiers(queryPart) + if err != nil { + return err + } + + appendProjectionPruningDecisions(plan, queryPartIndex, readingClauses, sourceReferences) + return nil +} + +func appendProjectionPruningDecisions(plan *LoweringPlan, queryPartIndex int, readingClauses []*cypher.ReadingClause, sourceReferences map[string]struct{}) { + for clauseIndex, readingClause := range readingClauses { + if readingClause == nil || readingClause.Match == nil || readingClause.Match.Optional { + continue + } + + for patternIndex, patternPart := range readingClause.Match.Pattern { + steps := traversalStepsForPattern(patternPart) + if len(steps) == 0 { + continue + } + + appendPatternProjectionPruningDecisions(plan, PatternTarget{ + QueryPartIndex: queryPartIndex, + ClauseIndex: clauseIndex, + PatternIndex: patternIndex, + }, patternPart, steps, sourceReferences) + } + } +} + +func appendPatternProjectionPruningDecisions(plan *LoweringPlan, target PatternTarget, patternPart *cypher.PatternPart, steps []sourceTraversalStep, sourceReferences map[string]struct{}) { + pathReferenced := referencesSourceIdentifier(sourceReferences, variableSymbol(patternPart.Variable)) + + for stepIndex, step := range steps { + decision := ProjectionPruningDecision{ + Target: target.TraversalStep(stepIndex), + ReferencedSymbols: sortedMapKeys(sourceReferences), + PatternBindingReferenced: pathReferenced, + } + + edgeReferenced := referencesSourceIdentifier(sourceReferences, variableSymbol(step.Relationship.Variable)) + var hasPruning bool + if step.Relationship.Range != nil { + hasPruning = !edgeReferenced || !pathReferenced + } else { + leftReferenced := referencesSourceIdentifier(sourceReferences, variableSymbol(step.LeftNode.Variable)) + rightReferenced := referencesSourceIdentifier(sourceReferences, variableSymbol(step.RightNode.Variable)) + + hasPruning = !(leftReferenced || pathReferenced) || + !(edgeReferenced || pathReferenced) || + !(rightReferenced || pathReferenced || stepIndex+1 < len(steps)) + } + + if hasPruning { + plan.ProjectionPruning = append(plan.ProjectionPruning, decision) + } + } +} + +func traversalStepsForPattern(patternPart *cypher.PatternPart) []sourceTraversalStep { + if patternPart == nil { + return nil + } + + var ( + steps []sourceTraversalStep + leftNode *cypher.NodePattern + relationship *cypher.RelationshipPattern + ) + + for _, element := range patternPart.PatternElements { + if element == nil { + continue + } + + if nodePattern, isNodePattern := element.AsNodePattern(); isNodePattern { + if leftNode == nil { + leftNode = nodePattern + continue + } + + if relationship != nil { + steps = append(steps, sourceTraversalStep{ + LeftNode: leftNode, + Relationship: relationship, + RightNode: nodePattern, + }) + } + + leftNode = nodePattern + relationship = nil + } else if relationshipPattern, isRelationshipPattern := element.AsRelationshipPattern(); isRelationshipPattern { + relationship = relationshipPattern + } + } + + return steps +} + +func variableSymbol(variable *cypher.Variable) string { + if variable == nil { + return "" + } + + return variable.Symbol +} diff --git a/cypher/models/pgsql/optimize/optimizer.go b/cypher/models/pgsql/optimize/optimizer.go index 8f18d6b7..496c594d 100644 --- a/cypher/models/pgsql/optimize/optimizer.go +++ b/cypher/models/pgsql/optimize/optimizer.go @@ -81,6 +81,12 @@ func (s Optimizer) Optimize(query *cypher.RegularQuery) (Plan, error) { plan.Analysis = Analyze(plan.Query) } + if loweringPlan, err := BuildLoweringPlan(plan.Query, plan.Analysis); err != nil { + return Plan{}, err + } else { + plan.LoweringPlan = loweringPlan + } + return plan, nil } diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index 332a3d89..ce768f5c 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -67,6 +67,29 @@ func TestDefaultPredicateAttachmentRuleReportsSkippedWhenNoPredicatesExist(t *te require.Empty(t, plan.PredicateAttachments) } +func TestLoweringPlanReportsProjectionPruning(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH (n)-[r:MemberOf]->(m) + RETURN m + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Equal(t, []LoweringDecision{{Name: LoweringProjectionPruning}}, plan.LoweringPlan.Decisions()) + require.Equal(t, []ProjectionPruningDecision{{ + Target: TraversalStepTarget{ + QueryPartIndex: 0, + ClauseIndex: 0, + PatternIndex: 0, + StepIndex: 0, + }, + ReferencedSymbols: []string{"m"}, + }}, plan.LoweringPlan.ProjectionPruning) +} + func TestPredicateAttachmentRuleAssignsSingleBindingPredicates(t *testing.T) { t.Parallel() diff --git a/cypher/models/pgsql/optimize/source_references.go b/cypher/models/pgsql/optimize/source_references.go new file mode 100644 index 00000000..ebdbbe14 --- /dev/null +++ b/cypher/models/pgsql/optimize/source_references.go @@ -0,0 +1,118 @@ +package optimize + +import ( + "github.com/specterops/dawgs/cypher/models/cypher" + "github.com/specterops/dawgs/cypher/models/walk" +) + +type sourceReferenceCollector struct { + walk.VisitorHandler + + referencedIdentifiers map[string]struct{} + matchPatternDeclarationRefs map[string]int + matchPatternDeclarations map[*cypher.PatternPart]struct{} + matchPatternDeclarationDepth int +} + +func newSourceReferenceCollector() *sourceReferenceCollector { + return &sourceReferenceCollector{ + VisitorHandler: walk.NewCancelableErrorHandler(), + referencedIdentifiers: map[string]struct{}{}, + matchPatternDeclarationRefs: map[string]int{}, + matchPatternDeclarations: map[*cypher.PatternPart]struct{}{}, + } +} + +func (s *sourceReferenceCollector) addVariable(variable *cypher.Variable) { + if variable != nil && variable.Symbol != "" { + s.referencedIdentifiers[variable.Symbol] = struct{}{} + } +} + +func (s *sourceReferenceCollector) addMatchPatternDeclaration(variable *cypher.Variable) { + if variable != nil && variable.Symbol != "" { + s.matchPatternDeclarationRefs[variable.Symbol] += 1 + } +} + +func (s *sourceReferenceCollector) collectRepeatedMatchPatternDeclarations() { + for identifier, numDeclarations := range s.matchPatternDeclarationRefs { + if numDeclarations > 1 { + s.referencedIdentifiers[identifier] = struct{}{} + } + } +} + +func (s *sourceReferenceCollector) isMatchPatternDeclaration(patternPart *cypher.PatternPart) bool { + _, isDeclaration := s.matchPatternDeclarations[patternPart] + return isDeclaration +} + +func (s *sourceReferenceCollector) Enter(node cypher.SyntaxNode) { + switch typedNode := node.(type) { + case *cypher.Match: + for _, patternPart := range typedNode.Pattern { + s.matchPatternDeclarations[patternPart] = struct{}{} + } + + case *cypher.PatternPart: + if s.isMatchPatternDeclaration(typedNode) { + s.addMatchPatternDeclaration(typedNode.Variable) + s.matchPatternDeclarationDepth += 1 + } else { + s.addVariable(typedNode.Variable) + } + + case *cypher.NodePattern: + if s.matchPatternDeclarationDepth == 0 { + s.addVariable(typedNode.Variable) + } else { + s.addMatchPatternDeclaration(typedNode.Variable) + } + + case *cypher.RelationshipPattern: + if s.matchPatternDeclarationDepth == 0 { + s.addVariable(typedNode.Variable) + } else { + s.addMatchPatternDeclaration(typedNode.Variable) + } + + case *cypher.Variable: + s.addVariable(typedNode) + } +} + +func (s *sourceReferenceCollector) Visit(cypher.SyntaxNode) {} + +func (s *sourceReferenceCollector) Exit(node cypher.SyntaxNode) { + if patternPart, isPatternPart := node.(*cypher.PatternPart); isPatternPart && s.isMatchPatternDeclaration(patternPart) { + s.matchPatternDeclarationDepth -= 1 + } +} + +func collectReferencedSourceIdentifiers(root cypher.SyntaxNode) (map[string]struct{}, error) { + if root == nil { + return map[string]struct{}{}, nil + } + + collector := newSourceReferenceCollector() + if err := walk.Cypher(root, collector); err != nil { + return collector.referencedIdentifiers, err + } + + collector.collectRepeatedMatchPatternDeclarations() + return collector.referencedIdentifiers, nil +} + +func referencesSourceIdentifier(references map[string]struct{}, symbol string) bool { + if _, referencesAll := references[cypher.TokenLiteralAsterisk]; referencesAll { + return true + } + + if symbol == "" { + return false + } + + _, referenced := references[symbol] + return referenced +} diff --git a/cypher/models/pgsql/translate/expansion.go b/cypher/models/pgsql/translate/expansion.go index 41fd459c..cfa1d7a0 100644 --- a/cypher/models/pgsql/translate/expansion.go +++ b/cypher/models/pgsql/translate/expansion.go @@ -2641,7 +2641,7 @@ func (s *Translator) buildExpansionProjectionConstraints(traversalStepContext Tr return projectionConstraints, nil } -func (s *Translator) translateTraversalPatternPartWithExpansion(part *PatternPart, isFirstTraversalStep bool, traversalStep *TraversalStep, allowProjectionPruning bool) error { +func (s *Translator) translateTraversalPatternPartWithExpansion(part *PatternPart, stepIndex int, isFirstTraversalStep bool, traversalStep *TraversalStep, allowProjectionPruning bool) error { expansionModel := traversalStep.Expansion // Translate the expansion's constraints - this has the side effect of making the pattern identifiers visible in @@ -2653,7 +2653,8 @@ func (s *Translator) translateTraversalPatternPartWithExpansion(part *PatternPar // Export the path from the traversal's scope traversalStep.Frame.Export(expansionModel.PathBinding.Identifier) if allowProjectionPruning { - pruneExpansionStepProjectionExports(s.query.CurrentPart(), part, traversalStep) + decision, hasDecision := s.projectionPruningDecision(part, stepIndex) + pruneExpansionStepProjectionExports(s.query.CurrentPart(), part, traversalStep, decision, hasDecision) } // Push a new frame that contains currently projected scope from the expansion recursive CTE diff --git a/cypher/models/pgsql/translate/model.go b/cypher/models/pgsql/translate/model.go index f3f7ab00..c7403c23 100644 --- a/cypher/models/pgsql/translate/model.go +++ b/cypher/models/pgsql/translate/model.go @@ -6,6 +6,7 @@ import ( "github.com/specterops/dawgs/cypher/models" "github.com/specterops/dawgs/cypher/models/cypher" "github.com/specterops/dawgs/cypher/models/pgsql" + "github.com/specterops/dawgs/cypher/models/pgsql/optimize" "github.com/specterops/dawgs/cypher/models/walk" "github.com/specterops/dawgs/graph" ) @@ -598,6 +599,8 @@ type PatternPart struct { ShortestPath bool AllShortestPaths bool PatternBinding *BoundIdentifier + Target optimize.PatternTarget + HasTarget bool TraversalSteps []*TraversalStep NodeSelect NodeSelect Constraints *ConstraintTracker diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index 6d6d479d..c20201d6 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -238,6 +238,9 @@ RETURN p require.NotEmpty(t, translation.Optimization.Rules) require.NotEmpty(t, translation.Optimization.PredicateAttachments) + require.NotNil(t, translation.Optimization.LoweringPlan) + require.NotEmpty(t, translation.Optimization.LoweringPlan.ProjectionPruning) + requireOptimizationLowering(t, translation.Optimization, "ProjectionPruning") requireOptimizationLowering(t, translation.Optimization, "ExpansionSuffixPushdown") } diff --git a/cypher/models/pgsql/translate/pattern.go b/cypher/models/pgsql/translate/pattern.go index 57eff0d2..9383999f 100644 --- a/cypher/models/pgsql/translate/pattern.go +++ b/cypher/models/pgsql/translate/pattern.go @@ -38,6 +38,10 @@ func (s *Translator) translatePatternPart(patternPart *cypher.PatternPart) error newPatternPart.IsTraversal = len(patternPart.PatternElements) > 1 newPatternPart.ShortestPath = patternPart.ShortestPathPattern newPatternPart.AllShortestPaths = patternPart.AllShortestPathsPattern + if target, hasTarget := s.patternTargets[patternPart]; hasTarget { + newPatternPart.Target = target + newPatternPart.HasTarget = true + } if cypherBinding, hasCypherSymbol, err := extractIdentifierFromCypherExpression(patternPart); err != nil { return err diff --git a/cypher/models/pgsql/translate/translator.go b/cypher/models/pgsql/translate/translator.go index 79c220cb..fc03f9a0 100644 --- a/cypher/models/pgsql/translate/translator.go +++ b/cypher/models/pgsql/translate/translator.go @@ -29,6 +29,10 @@ type Translator struct { query *Query scope *Scope unwindTargets map[*cypher.Variable]struct{} + + hasOptimizationPlan bool + patternTargets map[*cypher.PatternPart]optimize.PatternTarget + projectionPruningDecisions map[optimize.TraversalStepTarget]optimize.ProjectionPruningDecision } func NewTranslator(ctx context.Context, kindMapper pgsql.KindMapper, parameters map[string]any, graphID int32) *Translator { @@ -60,6 +64,16 @@ func NewTranslator(ctx context.Context, kindMapper pgsql.KindMapper, parameters } } +func (s *Translator) SetOptimizationPlan(plan optimize.Plan) { + s.hasOptimizationPlan = true + s.patternTargets = optimize.IndexPatternTargets(plan.Query) + s.projectionPruningDecisions = map[optimize.TraversalStepTarget]optimize.ProjectionPruningDecision{} + + for _, decision := range plan.LoweringPlan.ProjectionPruning { + s.projectionPruningDecisions[decision.Target] = decision + } +} + func (s *Translator) Enter(expression cypher.SyntaxNode) { switch typedExpression := expression.(type) { case *cypher.RegularQuery, *cypher.SingleQuery, *cypher.PatternElement, @@ -549,6 +563,7 @@ func Translate(ctx context.Context, cypherQuery *cypher.RegularQuery, kindMapper } translator := NewTranslator(ctx, kindMapper, parameters, graphID) + translator.SetOptimizationPlan(optimizedPlan) translator.translation.Optimization.Rules = optimizedPlan.Rules translator.translation.Optimization.PredicateAttachments = optimizedPlan.PredicateAttachments if !optimizedPlan.LoweringPlan.Empty() { diff --git a/cypher/models/pgsql/translate/traversal.go b/cypher/models/pgsql/translate/traversal.go index 8dc0f401..9c494664 100644 --- a/cypher/models/pgsql/translate/traversal.go +++ b/cypher/models/pgsql/translate/traversal.go @@ -5,7 +5,9 @@ import ( "fmt" "github.com/specterops/dawgs/cypher/models" + "github.com/specterops/dawgs/cypher/models/cypher" "github.com/specterops/dawgs/cypher/models/pgsql" + "github.com/specterops/dawgs/cypher/models/pgsql/optimize" "github.com/specterops/dawgs/graph" ) @@ -591,7 +593,7 @@ func (s *Translator) translateTraversalPatternPart(part *PatternPart, isolatedPr } if traversalStep.Expansion != nil { - if err := s.translateTraversalPatternPartWithExpansion(part, idx == 0, traversalStep, allowProjectionPruning); err != nil { + if err := s.translateTraversalPatternPartWithExpansion(part, idx, idx == 0, traversalStep, allowProjectionPruning); err != nil { return err } } else if part.AllShortestPaths || part.ShortestPath { @@ -632,6 +634,65 @@ func patternBindingDependsOn(queryPart *QueryPart, part *PatternPart, binding *B return false } +func (s *Translator) projectionPruningDecision(part *PatternPart, stepIndex int) (optimize.ProjectionPruningDecision, bool) { + if part == nil || !part.HasTarget { + return optimize.ProjectionPruningDecision{}, false + } + + decision, hasDecision := s.projectionPruningDecisions[part.Target.TraversalStep(stepIndex)] + return decision, hasDecision +} + +func projectionPruningDecisionReferencesBinding(decision optimize.ProjectionPruningDecision, binding *BoundIdentifier) bool { + if binding == nil { + return false + } + + sourceIdentifier := binding.Identifier + if binding.Alias.Set { + sourceIdentifier = binding.Alias.Value + } + + for _, symbol := range decision.ReferencedSymbols { + if symbol == cypher.TokenLiteralAsterisk || pgsql.Identifier(symbol) == sourceIdentifier { + return true + } + } + + return false +} + +func projectionPruningDecisionPatternDependsOn(part *PatternPart, binding *BoundIdentifier, decision optimize.ProjectionPruningDecision) bool { + if !decision.PatternBindingReferenced || part == nil || part.PatternBinding == nil || binding == nil { + return false + } + + for _, dependency := range part.PatternBinding.Dependencies { + if dependency.Identifier == binding.Identifier { + return true + } + } + + return false +} + +func traversalStepProjectsBindingByDecision(part *PatternPart, stepIndex int, binding *BoundIdentifier, decision optimize.ProjectionPruningDecision) bool { + if binding == nil { + return false + } + + if projectionPruningDecisionReferencesBinding(decision, binding) || projectionPruningDecisionPatternDependsOn(part, binding, decision) { + return true + } + + if stepIndex+1 < len(part.TraversalSteps) { + nextStep := part.TraversalSteps[stepIndex+1] + return nextStep.LeftNode != nil && nextStep.LeftNode.Identifier == binding.Identifier + } + + return false +} + func traversalStepProjectsBinding(queryPart *QueryPart, part *PatternPart, stepIndex int, binding *BoundIdentifier) bool { if binding == nil { return false @@ -653,7 +714,23 @@ func traversalStepProjectsBinding(queryPart *QueryPart, part *PatternPart, stepI return false } -func pruneTraversalStepProjectionExports(queryPart *QueryPart, part *PatternPart, stepIndex int, traversalStep *TraversalStep) { +func pruneTraversalStepProjectionExports(queryPart *QueryPart, part *PatternPart, stepIndex int, traversalStep *TraversalStep, decision optimize.ProjectionPruningDecision, hasDecision bool) { + if hasDecision { + if traversalStep.LeftNode != nil && !traversalStep.LeftNodeBound && !traversalStepProjectsBindingByDecision(part, stepIndex, traversalStep.LeftNode, decision) { + traversalStep.Frame.Unexport(traversalStep.LeftNode.Identifier) + } + + if traversalStep.Edge != nil && !traversalStepProjectsBindingByDecision(part, stepIndex, traversalStep.Edge, decision) { + traversalStep.Frame.Unexport(traversalStep.Edge.Identifier) + } + + if traversalStep.RightNode != nil && !traversalStep.RightNodeBound && !traversalStepProjectsBindingByDecision(part, stepIndex, traversalStep.RightNode, decision) { + traversalStep.Frame.Unexport(traversalStep.RightNode.Identifier) + } + + return + } + // Bound endpoints already exist in an outer frame. Only unexport unbound // values that later clauses and continuation steps cannot observe. if !traversalStep.LeftNodeBound && !traversalStepProjectsBinding(queryPart, part, stepIndex, traversalStep.LeftNode) { @@ -676,11 +753,23 @@ func pruneTraversalStepProjectionExports(queryPart *QueryPart, part *PatternPart } } -func pruneExpansionStepProjectionExports(queryPart *QueryPart, part *PatternPart, traversalStep *TraversalStep) { +func pruneExpansionStepProjectionExports(queryPart *QueryPart, part *PatternPart, traversalStep *TraversalStep, decision optimize.ProjectionPruningDecision, hasDecision bool) { if traversalStep == nil || traversalStep.Expansion == nil { return } + if hasDecision { + if traversalStep.Edge != nil && !projectionPruningDecisionReferencesBinding(decision, traversalStep.Edge) { + traversalStep.Frame.Unexport(traversalStep.Edge.Identifier) + } + + if traversalStep.Expansion.PathBinding != nil && !projectionPruningDecisionPatternDependsOn(part, traversalStep.Expansion.PathBinding, decision) { + traversalStep.Frame.Unexport(traversalStep.Expansion.PathBinding.Identifier) + } + + return + } + // Variable-length relationship bindings materialize to edge-composite // arrays. A path binding can be rebuilt later from the compact expansion // path ID array, so keep the edge array only when the relationship binding @@ -773,7 +862,8 @@ func (s *Translator) translateTraversalPatternPartWithoutExpansion(part *Pattern } if allowProjectionPruning { - pruneTraversalStepProjectionExports(s.query.CurrentPart(), part, stepIndex, traversalStep) + decision, hasDecision := s.projectionPruningDecision(part, stepIndex) + pruneTraversalStepProjectionExports(s.query.CurrentPart(), part, stepIndex, traversalStep, decision, hasDecision) } if boundProjections, err := buildVisibleProjections(s.scope); err != nil { From c46ff89adcc0299670a0e849759c1126a2083a75 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Thu, 21 May 2026 14:09:11 -0700 Subject: [PATCH 027/116] Lift late path materialization decisions --- cypher/models/pgsql/optimize/lowering_plan.go | 41 +++++++++++++++++++ .../models/pgsql/optimize/optimizer_test.go | 40 ++++++++++++++++++ .../pgsql/translate/optimizer_safety_test.go | 2 + cypher/models/pgsql/translate/translator.go | 6 +++ cypher/models/pgsql/translate/traversal.go | 20 +++++++++ 5 files changed, 109 insertions(+) diff --git a/cypher/models/pgsql/optimize/lowering_plan.go b/cypher/models/pgsql/optimize/lowering_plan.go index 35f18f30..80b71624 100644 --- a/cypher/models/pgsql/optimize/lowering_plan.go +++ b/cypher/models/pgsql/optimize/lowering_plan.go @@ -47,6 +47,7 @@ func appendQueryPartLowerings(plan *LoweringPlan, queryPartIndex int, queryPart } appendProjectionPruningDecisions(plan, queryPartIndex, readingClauses, sourceReferences) + appendLatePathMaterializationDecisions(plan, queryPartIndex, readingClauses, sourceReferences) return nil } @@ -100,6 +101,46 @@ func appendPatternProjectionPruningDecisions(plan *LoweringPlan, target PatternT } } +func appendLatePathMaterializationDecisions(plan *LoweringPlan, queryPartIndex int, readingClauses []*cypher.ReadingClause, sourceReferences map[string]struct{}) { + for clauseIndex, readingClause := range readingClauses { + if readingClause == nil || readingClause.Match == nil || readingClause.Match.Optional { + continue + } + + for patternIndex, patternPart := range readingClause.Match.Pattern { + if !referencesSourceIdentifier(sourceReferences, variableSymbol(patternPart.Variable)) { + continue + } + + for stepIndex, step := range traversalStepsForPattern(patternPart) { + target := PatternTarget{ + QueryPartIndex: queryPartIndex, + ClauseIndex: clauseIndex, + PatternIndex: patternIndex, + }.TraversalStep(stepIndex) + + if step.Relationship.Range != nil { + plan.LatePathMaterialization = append(plan.LatePathMaterialization, LatePathMaterializationDecision{ + Target: target, + Mode: LatePathMaterializationExpansionPath, + }) + continue + } + + mode := LatePathMaterializationPathEdgeID + if referencesSourceIdentifier(sourceReferences, variableSymbol(step.Relationship.Variable)) { + mode = LatePathMaterializationEdgeComposite + } + + plan.LatePathMaterialization = append(plan.LatePathMaterialization, LatePathMaterializationDecision{ + Target: target, + Mode: mode, + }) + } + } + } +} + func traversalStepsForPattern(patternPart *cypher.PatternPart) []sourceTraversalStep { if patternPart == nil { return nil diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index ce768f5c..27a54c42 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -90,6 +90,46 @@ func TestLoweringPlanReportsProjectionPruning(t *testing.T) { }}, plan.LoweringPlan.ProjectionPruning) } +func TestLoweringPlanReportsLatePathMaterialization(t *testing.T) { + t.Parallel() + + t.Run("path edge id", func(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH p = (n)-[r:MemberOf]->(m) + RETURN p + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Equal(t, []LatePathMaterializationDecision{{ + Target: TraversalStepTarget{ + QueryPartIndex: 0, + ClauseIndex: 0, + PatternIndex: 0, + StepIndex: 0, + }, + Mode: LatePathMaterializationPathEdgeID, + }}, plan.LoweringPlan.LatePathMaterialization) + }) + + t.Run("relationship composite", func(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH p = (n)-[r:MemberOf]->(m) + RETURN p, r + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Equal(t, LatePathMaterializationEdgeComposite, plan.LoweringPlan.LatePathMaterialization[0].Mode) + }) +} + func TestPredicateAttachmentRuleAssignsSingleBindingPredicates(t *testing.T) { t.Parallel() diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index c20201d6..82baf1cb 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -240,7 +240,9 @@ RETURN p require.NotEmpty(t, translation.Optimization.PredicateAttachments) require.NotNil(t, translation.Optimization.LoweringPlan) require.NotEmpty(t, translation.Optimization.LoweringPlan.ProjectionPruning) + require.NotEmpty(t, translation.Optimization.LoweringPlan.LatePathMaterialization) requireOptimizationLowering(t, translation.Optimization, "ProjectionPruning") + requireOptimizationLowering(t, translation.Optimization, "LatePathMaterialization") requireOptimizationLowering(t, translation.Optimization, "ExpansionSuffixPushdown") } diff --git a/cypher/models/pgsql/translate/translator.go b/cypher/models/pgsql/translate/translator.go index fc03f9a0..8046eba8 100644 --- a/cypher/models/pgsql/translate/translator.go +++ b/cypher/models/pgsql/translate/translator.go @@ -33,6 +33,7 @@ type Translator struct { hasOptimizationPlan bool patternTargets map[*cypher.PatternPart]optimize.PatternTarget projectionPruningDecisions map[optimize.TraversalStepTarget]optimize.ProjectionPruningDecision + latePathDecisions map[optimize.TraversalStepTarget][]optimize.LatePathMaterializationDecision } func NewTranslator(ctx context.Context, kindMapper pgsql.KindMapper, parameters map[string]any, graphID int32) *Translator { @@ -68,10 +69,15 @@ func (s *Translator) SetOptimizationPlan(plan optimize.Plan) { s.hasOptimizationPlan = true s.patternTargets = optimize.IndexPatternTargets(plan.Query) s.projectionPruningDecisions = map[optimize.TraversalStepTarget]optimize.ProjectionPruningDecision{} + s.latePathDecisions = map[optimize.TraversalStepTarget][]optimize.LatePathMaterializationDecision{} for _, decision := range plan.LoweringPlan.ProjectionPruning { s.projectionPruningDecisions[decision.Target] = decision } + + for _, decision := range plan.LoweringPlan.LatePathMaterialization { + s.latePathDecisions[decision.Target] = append(s.latePathDecisions[decision.Target], decision) + } } func (s *Translator) Enter(expression cypher.SyntaxNode) { diff --git a/cypher/models/pgsql/translate/traversal.go b/cypher/models/pgsql/translate/traversal.go index 9c494664..f29c92ef 100644 --- a/cypher/models/pgsql/translate/traversal.go +++ b/cypher/models/pgsql/translate/traversal.go @@ -643,6 +643,20 @@ func (s *Translator) projectionPruningDecision(part *PatternPart, stepIndex int) return decision, hasDecision } +func (s *Translator) hasLatePathMaterialization(part *PatternPart, stepIndex int, mode optimize.LatePathMaterializationMode) bool { + if part == nil || !part.HasTarget { + return false + } + + for _, decision := range s.latePathDecisions[part.Target.TraversalStep(stepIndex)] { + if decision.Mode == mode { + return true + } + } + + return false +} + func projectionPruningDecisionReferencesBinding(decision optimize.ProjectionPruningDecision, binding *BoundIdentifier) bool { if binding == nil { return false @@ -862,6 +876,12 @@ func (s *Translator) translateTraversalPatternPartWithoutExpansion(part *Pattern } if allowProjectionPruning { + if traversalStep.Edge != nil && + traversalStep.Edge.DataType == pgsql.EdgeComposite && + s.hasLatePathMaterialization(part, stepIndex, optimize.LatePathMaterializationPathEdgeID) { + traversalStep.Edge.DataType = pgsql.PathEdge + } + decision, hasDecision := s.projectionPruningDecision(part, stepIndex) pruneTraversalStepProjectionExports(s.query.CurrentPart(), part, stepIndex, traversalStep, decision, hasDecision) } From b67e43d6d38cf73d2b435f76cf14eabcda48397b Mon Sep 17 00:00:00 2001 From: John Hopper Date: Thu, 21 May 2026 14:10:20 -0700 Subject: [PATCH 028/116] Lift expansion suffix pushdown detection --- cypher/models/pgsql/optimize/lowering_plan.go | 132 +++++++++++++++++- .../models/pgsql/optimize/optimizer_test.go | 37 +++++ .../pgsql/translate/optimizer_safety_test.go | 1 + cypher/models/pgsql/translate/translator.go | 6 + cypher/models/pgsql/translate/traversal.go | 37 ++++- 5 files changed, 211 insertions(+), 2 deletions(-) diff --git a/cypher/models/pgsql/optimize/lowering_plan.go b/cypher/models/pgsql/optimize/lowering_plan.go index 80b71624..b5352666 100644 --- a/cypher/models/pgsql/optimize/lowering_plan.go +++ b/cypher/models/pgsql/optimize/lowering_plan.go @@ -1,6 +1,9 @@ package optimize -import "github.com/specterops/dawgs/cypher/models/cypher" +import ( + "github.com/specterops/dawgs/cypher/models/cypher" + "github.com/specterops/dawgs/graph" +) type sourceTraversalStep struct { LeftNode *cypher.NodePattern @@ -48,6 +51,7 @@ func appendQueryPartLowerings(plan *LoweringPlan, queryPartIndex int, queryPart appendProjectionPruningDecisions(plan, queryPartIndex, readingClauses, sourceReferences) appendLatePathMaterializationDecisions(plan, queryPartIndex, readingClauses, sourceReferences) + appendExpansionSuffixPushdownDecisions(plan, queryPartIndex, readingClauses) return nil } @@ -141,6 +145,132 @@ func appendLatePathMaterializationDecisions(plan *LoweringPlan, queryPartIndex i } } +func appendExpansionSuffixPushdownDecisions(plan *LoweringPlan, queryPartIndex int, readingClauses []*cypher.ReadingClause) { + declaredSymbols := map[string]struct{}{} + + for clauseIndex, readingClause := range readingClauses { + if readingClause == nil || readingClause.Match == nil { + continue + } + + match := readingClause.Match + if match.Optional { + declareMatchSymbols(declaredSymbols, match) + continue + } + + for patternIndex, patternPart := range match.Pattern { + steps := traversalStepsForPattern(patternPart) + declaredBeforeRightNode := declaredSymbolsBeforeRightNodes(declaredSymbols, steps) + + for stepIndex, step := range steps { + if step.Relationship.Range == nil || stepIndex+1 >= len(steps) { + continue + } + + if suffixLength := expansionSuffixPushdownLength(steps[stepIndex+1:], declaredBeforeRightNode[stepIndex+1:]); suffixLength > 0 { + plan.ExpansionSuffixPushdown = append(plan.ExpansionSuffixPushdown, ExpansionSuffixPushdownDecision{ + Target: PatternTarget{ + QueryPartIndex: queryPartIndex, + ClauseIndex: clauseIndex, + PatternIndex: patternIndex, + }.TraversalStep(stepIndex), + SuffixLength: suffixLength, + }) + } + } + + declarePatternSymbols(declaredSymbols, patternPart) + } + + declareWhereSymbols(declaredSymbols, match) + } +} + +func expansionSuffixPushdownLength(suffixSteps []sourceTraversalStep, declaredBeforeRightNode []map[string]struct{}) int { + var suffixLength int + + for idx, step := range suffixSteps { + if step.Relationship.Range != nil || step.Relationship.Direction == graph.DirectionBoth { + break + } + + if nodeSymbol := variableSymbol(step.RightNode.Variable); nodeSymbol != "" { + if _, bound := declaredBeforeRightNode[idx][nodeSymbol]; bound && nodePatternHasConstraints(step.RightNode) { + break + } + } + + suffixLength++ + } + + return suffixLength +} + +func declaredSymbolsBeforeRightNodes(initial map[string]struct{}, steps []sourceTraversalStep) []map[string]struct{} { + declared := copyStringSet(initial) + declaredBeforeRightNode := make([]map[string]struct{}, len(steps)) + + for idx, step := range steps { + addSymbol(declared, variableSymbol(step.LeftNode.Variable)) + addSymbol(declared, variableSymbol(step.Relationship.Variable)) + declaredBeforeRightNode[idx] = copyStringSet(declared) + addSymbol(declared, variableSymbol(step.RightNode.Variable)) + } + + return declaredBeforeRightNode +} + +func declareMatchSymbols(declared map[string]struct{}, match *cypher.Match) { + if match == nil { + return + } + + for _, patternPart := range match.Pattern { + declarePatternSymbols(declared, patternPart) + } + + declareWhereSymbols(declared, match) +} + +func declarePatternSymbols(declared map[string]struct{}, patternPart *cypher.PatternPart) { + if patternPart == nil { + return + } + + addSymbol(declared, variableSymbol(patternPart.Variable)) + for _, step := range traversalStepsForPattern(patternPart) { + addSymbol(declared, variableSymbol(step.LeftNode.Variable)) + addSymbol(declared, variableSymbol(step.Relationship.Variable)) + addSymbol(declared, variableSymbol(step.RightNode.Variable)) + } +} + +func declareWhereSymbols(declared map[string]struct{}, match *cypher.Match) { + for _, dependency := range dependenciesForMatch(match) { + addSymbol(declared, dependency) + } +} + +func nodePatternHasConstraints(nodePattern *cypher.NodePattern) bool { + return nodePattern != nil && (len(nodePattern.Kinds) > 0 || nodePattern.Properties != nil) +} + +func addSymbol(symbols map[string]struct{}, symbol string) { + if symbol != "" { + symbols[symbol] = struct{}{} + } +} + +func copyStringSet(values map[string]struct{}) map[string]struct{} { + copied := make(map[string]struct{}, len(values)) + for value := range values { + copied[value] = struct{}{} + } + + return copied +} + func traversalStepsForPattern(patternPart *cypher.PatternPart) []sourceTraversalStep { if patternPart == nil { return nil diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index 27a54c42..1eb6c1c7 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -130,6 +130,43 @@ func TestLoweringPlanReportsLatePathMaterialization(t *testing.T) { }) } +func TestLoweringPlanReportsExpansionSuffixPushdown(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH p = (n:Group)-[:MemberOf*0..]->(m)-[:Enroll]->(ca:EnterpriseCA) + RETURN p + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Contains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringExpansionSuffixPushdown}) + require.Equal(t, []ExpansionSuffixPushdownDecision{{ + Target: TraversalStepTarget{ + QueryPartIndex: 0, + ClauseIndex: 0, + PatternIndex: 0, + StepIndex: 0, + }, + SuffixLength: 1, + }}, plan.LoweringPlan.ExpansionSuffixPushdown) +} + +func TestLoweringPlanSkipsDirectionlessExpansionSuffixPushdown(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH p = (n:Group)-[:MemberOf*0..]->(m)-[:Enroll]-(ca:EnterpriseCA) + RETURN p + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Empty(t, plan.LoweringPlan.ExpansionSuffixPushdown) +} + func TestPredicateAttachmentRuleAssignsSingleBindingPredicates(t *testing.T) { t.Parallel() diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index 82baf1cb..c1373c57 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -241,6 +241,7 @@ RETURN p require.NotNil(t, translation.Optimization.LoweringPlan) require.NotEmpty(t, translation.Optimization.LoweringPlan.ProjectionPruning) require.NotEmpty(t, translation.Optimization.LoweringPlan.LatePathMaterialization) + require.NotEmpty(t, translation.Optimization.LoweringPlan.ExpansionSuffixPushdown) requireOptimizationLowering(t, translation.Optimization, "ProjectionPruning") requireOptimizationLowering(t, translation.Optimization, "LatePathMaterialization") requireOptimizationLowering(t, translation.Optimization, "ExpansionSuffixPushdown") diff --git a/cypher/models/pgsql/translate/translator.go b/cypher/models/pgsql/translate/translator.go index 8046eba8..ce604822 100644 --- a/cypher/models/pgsql/translate/translator.go +++ b/cypher/models/pgsql/translate/translator.go @@ -34,6 +34,7 @@ type Translator struct { patternTargets map[*cypher.PatternPart]optimize.PatternTarget projectionPruningDecisions map[optimize.TraversalStepTarget]optimize.ProjectionPruningDecision latePathDecisions map[optimize.TraversalStepTarget][]optimize.LatePathMaterializationDecision + suffixPushdownDecisions map[optimize.TraversalStepTarget][]optimize.ExpansionSuffixPushdownDecision } func NewTranslator(ctx context.Context, kindMapper pgsql.KindMapper, parameters map[string]any, graphID int32) *Translator { @@ -70,6 +71,7 @@ func (s *Translator) SetOptimizationPlan(plan optimize.Plan) { s.patternTargets = optimize.IndexPatternTargets(plan.Query) s.projectionPruningDecisions = map[optimize.TraversalStepTarget]optimize.ProjectionPruningDecision{} s.latePathDecisions = map[optimize.TraversalStepTarget][]optimize.LatePathMaterializationDecision{} + s.suffixPushdownDecisions = map[optimize.TraversalStepTarget][]optimize.ExpansionSuffixPushdownDecision{} for _, decision := range plan.LoweringPlan.ProjectionPruning { s.projectionPruningDecisions[decision.Target] = decision @@ -78,6 +80,10 @@ func (s *Translator) SetOptimizationPlan(plan optimize.Plan) { for _, decision := range plan.LoweringPlan.LatePathMaterialization { s.latePathDecisions[decision.Target] = append(s.latePathDecisions[decision.Target], decision) } + + for _, decision := range plan.LoweringPlan.ExpansionSuffixPushdown { + s.suffixPushdownDecisions[decision.Target] = append(s.suffixPushdownDecisions[decision.Target], decision) + } } func (s *Translator) Enter(expression cypher.SyntaxNode) { diff --git a/cypher/models/pgsql/translate/traversal.go b/cypher/models/pgsql/translate/traversal.go index f29c92ef..d3a5b78a 100644 --- a/cypher/models/pgsql/translate/traversal.go +++ b/cypher/models/pgsql/translate/traversal.go @@ -603,7 +603,7 @@ func (s *Translator) translateTraversalPatternPart(part *PatternPart, isolatedPr } } - if applied, err := applyExpansionSuffixPushdown(part); err != nil { + if applied, err := s.applyExpansionSuffixPushdown(part); err != nil { return err } else if applied > 0 { s.recordLowering("ExpansionSuffixPushdown") @@ -616,6 +616,41 @@ func (s *Translator) translateTraversalPatternPart(part *PatternPart, isolatedPr return nil } +func (s *Translator) applyExpansionSuffixPushdown(part *PatternPart) (int, error) { + if part == nil || !part.HasTarget { + return applyExpansionSuffixPushdown(part) + } + + var applied int + for stepIndex := range part.TraversalSteps { + target := part.Target.TraversalStep(stepIndex) + for _, decision := range s.suffixPushdownDecisions[target] { + if decision.SuffixLength <= 0 || stepIndex+decision.SuffixLength >= len(part.TraversalSteps) { + continue + } + + currentStep := part.TraversalSteps[stepIndex] + suffixSteps := part.TraversalSteps[stepIndex+1 : stepIndex+1+decision.SuffixLength] + if suffixSatisfaction, satisfied := expansionSuffixTerminalSatisfaction(currentStep, suffixSteps); satisfied { + currentStep.Expansion.TerminalNodeConstraints = pgsql.OptionalAnd( + currentStep.Expansion.TerminalNodeConstraints, + suffixSatisfaction, + ) + + if terminalCriteriaProjection, err := pgsql.As[pgsql.SelectItem](currentStep.Expansion.TerminalNodeConstraints); err != nil { + return applied, err + } else { + currentStep.Expansion.TerminalNodeSatisfactionProjection = terminalCriteriaProjection + } + + applied++ + } + } + } + + return applied, nil +} + func patternBindingDependsOn(queryPart *QueryPart, part *PatternPart, binding *BoundIdentifier) bool { if queryPart == nil || part == nil || part.PatternBinding == nil || binding == nil { return false From 2ba0f9a72c04a256a35e12a6d0f9e4c0089314f4 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Thu, 21 May 2026 14:11:43 -0700 Subject: [PATCH 029/116] Report fixed-hop expand-into decisions --- cypher/models/pgsql/optimize/lowering_plan.go | 88 ++++++++++++++++++- .../models/pgsql/optimize/optimizer_test.go | 24 +++++ .../pgsql/translate/optimizer_safety_test.go | 13 ++- cypher/models/pgsql/translate/translator.go | 6 ++ 4 files changed, 126 insertions(+), 5 deletions(-) diff --git a/cypher/models/pgsql/optimize/lowering_plan.go b/cypher/models/pgsql/optimize/lowering_plan.go index b5352666..bdf5e306 100644 --- a/cypher/models/pgsql/optimize/lowering_plan.go +++ b/cypher/models/pgsql/optimize/lowering_plan.go @@ -51,6 +51,7 @@ func appendQueryPartLowerings(plan *LoweringPlan, queryPartIndex int, queryPart appendProjectionPruningDecisions(plan, queryPartIndex, readingClauses, sourceReferences) appendLatePathMaterializationDecisions(plan, queryPartIndex, readingClauses, sourceReferences) + appendExpandIntoDecisions(plan, queryPartIndex, readingClauses) appendExpansionSuffixPushdownDecisions(plan, queryPartIndex, readingClauses) return nil } @@ -145,6 +146,79 @@ func appendLatePathMaterializationDecisions(plan *LoweringPlan, queryPartIndex i } } +func appendExpandIntoDecisions(plan *LoweringPlan, queryPartIndex int, readingClauses []*cypher.ReadingClause) { + declaredSymbols := map[string]struct{}{} + + for clauseIndex, readingClause := range readingClauses { + if readingClause == nil || readingClause.Match == nil { + continue + } + + match := readingClause.Match + if match.Optional { + declareMatchSymbols(declaredSymbols, match) + continue + } + + for patternIndex, patternPart := range match.Pattern { + steps := traversalStepsForPattern(patternPart) + declaredEndpoints := declaredSymbolsBeforeStepEndpoints(declaredSymbols, steps) + + for stepIndex, step := range steps { + if step.Relationship.Range != nil { + continue + } + + leftSymbol := variableSymbol(step.LeftNode.Variable) + rightSymbol := variableSymbol(step.RightNode.Variable) + if leftSymbol == "" || rightSymbol == "" { + continue + } + + if _, leftBound := declaredEndpoints[stepIndex].BeforeLeftNode[leftSymbol]; !leftBound { + continue + } + + if _, rightBound := declaredEndpoints[stepIndex].BeforeRightNode[rightSymbol]; !rightBound { + continue + } + + plan.ExpandInto = append(plan.ExpandInto, ExpandIntoDecision{ + Target: PatternTarget{ + QueryPartIndex: queryPartIndex, + ClauseIndex: clauseIndex, + PatternIndex: patternIndex, + }.TraversalStep(stepIndex), + }) + } + + declarePatternSymbols(declaredSymbols, patternPart) + } + + declareWhereSymbols(declaredSymbols, match) + } +} + +type declaredStepEndpoints struct { + BeforeLeftNode map[string]struct{} + BeforeRightNode map[string]struct{} +} + +func declaredSymbolsBeforeStepEndpoints(initial map[string]struct{}, steps []sourceTraversalStep) []declaredStepEndpoints { + declared := copyStringSet(initial) + endpoints := make([]declaredStepEndpoints, len(steps)) + + for idx, step := range steps { + endpoints[idx].BeforeLeftNode = copyStringSet(declared) + addSymbol(declared, variableSymbol(step.LeftNode.Variable)) + addSymbol(declared, variableSymbol(step.Relationship.Variable)) + endpoints[idx].BeforeRightNode = copyStringSet(declared) + addSymbol(declared, variableSymbol(step.RightNode.Variable)) + } + + return endpoints +} + func appendExpansionSuffixPushdownDecisions(plan *LoweringPlan, queryPartIndex int, readingClauses []*cypher.ReadingClause) { declaredSymbols := map[string]struct{}{} @@ -239,10 +313,16 @@ func declarePatternSymbols(declared map[string]struct{}, patternPart *cypher.Pat } addSymbol(declared, variableSymbol(patternPart.Variable)) - for _, step := range traversalStepsForPattern(patternPart) { - addSymbol(declared, variableSymbol(step.LeftNode.Variable)) - addSymbol(declared, variableSymbol(step.Relationship.Variable)) - addSymbol(declared, variableSymbol(step.RightNode.Variable)) + for _, element := range patternPart.PatternElements { + if element == nil { + continue + } + + if nodePattern, isNodePattern := element.AsNodePattern(); isNodePattern { + addSymbol(declared, variableSymbol(nodePattern.Variable)) + } else if relationshipPattern, isRelationshipPattern := element.AsRelationshipPattern(); isRelationshipPattern { + addSymbol(declared, variableSymbol(relationshipPattern.Variable)) + } } } diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index 1eb6c1c7..b8bbab5d 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -153,6 +153,30 @@ func TestLoweringPlanReportsExpansionSuffixPushdown(t *testing.T) { }}, plan.LoweringPlan.ExpansionSuffixPushdown) } +func TestLoweringPlanReportsExpandInto(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH (a:Group) + MATCH (b:Group) + MATCH p = (a)-[:MemberOf]->(b) + RETURN p + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Contains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringExpandIntoDetection}) + require.Equal(t, []ExpandIntoDecision{{ + Target: TraversalStepTarget{ + QueryPartIndex: 0, + ClauseIndex: 2, + PatternIndex: 0, + StepIndex: 0, + }, + }}, plan.LoweringPlan.ExpandInto) +} + func TestLoweringPlanSkipsDirectionlessExpansionSuffixPushdown(t *testing.T) { t.Parallel() diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index c1373c57..9e0b4586 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -178,16 +178,27 @@ RETURN n, p func TestOptimizerSafetyFixedHopExpandIntoUsesBoundEndpoints(t *testing.T) { t.Parallel() - normalizedQuery := optimizerSafetySQL(t, ` + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` MATCH (a:Group) MATCH (b:Group) MATCH p = (a)-[:MemberOf]->(b) RETURN p `) + require.NoError(t, err) + + translation, err := Translate(context.Background(), regularQuery, optimizerSafetyKindMapper(), nil, DefaultGraphID) + require.NoError(t, err) + + formattedQuery, err := Translated(translation) + require.NoError(t, err) + normalizedQuery := strings.Join(strings.Fields(formattedQuery), " ") require.Contains(t, normalizedQuery, "(s1.n0).id = e0.start_id") require.Contains(t, normalizedQuery, "(s1.n1).id = e0.end_id") require.NotContains(t, normalizedQuery, "join node") + require.NotNil(t, translation.Optimization.LoweringPlan) + require.NotEmpty(t, translation.Optimization.LoweringPlan.ExpandInto) + requireOptimizationLowering(t, translation.Optimization, "ExpandIntoDetection") } func TestOptimizerSafetyReordersIndependentNodeAnchor(t *testing.T) { diff --git a/cypher/models/pgsql/translate/translator.go b/cypher/models/pgsql/translate/translator.go index ce604822..01b5f4cc 100644 --- a/cypher/models/pgsql/translate/translator.go +++ b/cypher/models/pgsql/translate/translator.go @@ -35,6 +35,7 @@ type Translator struct { projectionPruningDecisions map[optimize.TraversalStepTarget]optimize.ProjectionPruningDecision latePathDecisions map[optimize.TraversalStepTarget][]optimize.LatePathMaterializationDecision suffixPushdownDecisions map[optimize.TraversalStepTarget][]optimize.ExpansionSuffixPushdownDecision + expandIntoDecisions map[optimize.TraversalStepTarget]optimize.ExpandIntoDecision } func NewTranslator(ctx context.Context, kindMapper pgsql.KindMapper, parameters map[string]any, graphID int32) *Translator { @@ -72,6 +73,7 @@ func (s *Translator) SetOptimizationPlan(plan optimize.Plan) { s.projectionPruningDecisions = map[optimize.TraversalStepTarget]optimize.ProjectionPruningDecision{} s.latePathDecisions = map[optimize.TraversalStepTarget][]optimize.LatePathMaterializationDecision{} s.suffixPushdownDecisions = map[optimize.TraversalStepTarget][]optimize.ExpansionSuffixPushdownDecision{} + s.expandIntoDecisions = map[optimize.TraversalStepTarget]optimize.ExpandIntoDecision{} for _, decision := range plan.LoweringPlan.ProjectionPruning { s.projectionPruningDecisions[decision.Target] = decision @@ -84,6 +86,10 @@ func (s *Translator) SetOptimizationPlan(plan optimize.Plan) { for _, decision := range plan.LoweringPlan.ExpansionSuffixPushdown { s.suffixPushdownDecisions[decision.Target] = append(s.suffixPushdownDecisions[decision.Target], decision) } + + for _, decision := range plan.LoweringPlan.ExpandInto { + s.expandIntoDecisions[decision.Target] = decision + } } func (s *Translator) Enter(expression cypher.SyntaxNode) { From bf8f2cc9c69d3433ef7b2abf00c940225380f130 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Thu, 21 May 2026 14:12:47 -0700 Subject: [PATCH 030/116] Wire predicate attachments into lowering metadata --- cypher/models/pgsql/optimize/lowering.go | 5 +- cypher/models/pgsql/optimize/lowering_plan.go | 117 +++++++++++++++++- cypher/models/pgsql/optimize/optimizer.go | 2 +- .../models/pgsql/optimize/optimizer_test.go | 24 ++++ .../pgsql/translate/optimizer_safety_test.go | 2 + 5 files changed, 146 insertions(+), 4 deletions(-) diff --git a/cypher/models/pgsql/optimize/lowering.go b/cypher/models/pgsql/optimize/lowering.go index c3defda5..aa18c9ac 100644 --- a/cypher/models/pgsql/optimize/lowering.go +++ b/cypher/models/pgsql/optimize/lowering.go @@ -60,8 +60,9 @@ type ExpandIntoDecision struct { } type ExpansionSuffixPushdownDecision struct { - Target TraversalStepTarget `json:"target"` - SuffixLength int `json:"suffix_length"` + Target TraversalStepTarget `json:"target"` + SuffixLength int `json:"suffix_length"` + PredicateAttachments []PredicateAttachment `json:"predicate_attachments,omitempty"` } type PredicatePlacementDecision struct { diff --git a/cypher/models/pgsql/optimize/lowering_plan.go b/cypher/models/pgsql/optimize/lowering_plan.go index bdf5e306..ac0ea428 100644 --- a/cypher/models/pgsql/optimize/lowering_plan.go +++ b/cypher/models/pgsql/optimize/lowering_plan.go @@ -11,7 +11,7 @@ type sourceTraversalStep struct { RightNode *cypher.NodePattern } -func BuildLoweringPlan(query *cypher.RegularQuery, _ Analysis) (LoweringPlan, error) { +func BuildLoweringPlan(query *cypher.RegularQuery, _ Analysis, predicateAttachments []PredicateAttachment) (LoweringPlan, error) { if query == nil || query.SingleQuery == nil { return LoweringPlan{}, nil } @@ -40,6 +40,8 @@ func BuildLoweringPlan(query *cypher.RegularQuery, _ Analysis) (LoweringPlan, er } } + appendPredicatePlacementDecisions(&plan, query, predicateAttachments) + attachPredicatePlacementsToSuffixPushdowns(&plan) return plan, nil } @@ -261,6 +263,119 @@ func appendExpansionSuffixPushdownDecisions(plan *LoweringPlan, queryPartIndex i } } +type bindingTargetKey struct { + QueryPartIndex int + Symbol string +} + +func appendPredicatePlacementDecisions(plan *LoweringPlan, query *cypher.RegularQuery, predicateAttachments []PredicateAttachment) { + if len(predicateAttachments) == 0 { + return + } + + bindingTargets := indexBindingTargets(query) + for _, attachment := range predicateAttachments { + if attachment.Scope != PredicateAttachmentScopeBinding || len(attachment.BindingSymbols) != 1 { + continue + } + + target, hasTarget := bindingTargets[bindingTargetKey{ + QueryPartIndex: attachment.QueryPartIndex, + Symbol: attachment.BindingSymbols[0], + }] + if !hasTarget { + continue + } + + plan.PredicatePlacement = append(plan.PredicatePlacement, PredicatePlacementDecision{ + Target: target, + Attachment: attachment, + Placement: attachment.Scope, + }) + } +} + +func attachPredicatePlacementsToSuffixPushdowns(plan *LoweringPlan) { + for suffixIdx := range plan.ExpansionSuffixPushdown { + suffix := &plan.ExpansionSuffixPushdown[suffixIdx] + for _, placement := range plan.PredicatePlacement { + if placement.Target.QueryPartIndex != suffix.Target.QueryPartIndex || + placement.Target.ClauseIndex != suffix.Target.ClauseIndex || + placement.Target.PatternIndex != suffix.Target.PatternIndex { + continue + } + + if placement.Target.StepIndex > suffix.Target.StepIndex && + placement.Target.StepIndex <= suffix.Target.StepIndex+suffix.SuffixLength { + suffix.PredicateAttachments = append(suffix.PredicateAttachments, placement.Attachment) + } + } + } +} + +func indexBindingTargets(query *cypher.RegularQuery) map[bindingTargetKey]TraversalStepTarget { + targets := map[bindingTargetKey]TraversalStepTarget{} + + if query == nil || query.SingleQuery == nil { + return targets + } + + if query.SingleQuery.MultiPartQuery != nil { + for queryPartIndex, part := range query.SingleQuery.MultiPartQuery.Parts { + if part == nil { + continue + } + + indexReadingClauseBindingTargets(targets, queryPartIndex, part.ReadingClauses) + } + + if finalPart := query.SingleQuery.MultiPartQuery.SinglePartQuery; finalPart != nil { + indexReadingClauseBindingTargets(targets, len(query.SingleQuery.MultiPartQuery.Parts), finalPart.ReadingClauses) + } + } else if query.SingleQuery.SinglePartQuery != nil { + indexReadingClauseBindingTargets(targets, 0, query.SingleQuery.SinglePartQuery.ReadingClauses) + } + + return targets +} + +func indexReadingClauseBindingTargets(targets map[bindingTargetKey]TraversalStepTarget, queryPartIndex int, readingClauses []*cypher.ReadingClause) { + for clauseIndex, readingClause := range readingClauses { + if readingClause == nil || readingClause.Match == nil { + continue + } + + for patternIndex, patternPart := range readingClause.Match.Pattern { + patternTarget := PatternTarget{ + QueryPartIndex: queryPartIndex, + ClauseIndex: clauseIndex, + PatternIndex: patternIndex, + } + + for stepIndex, step := range traversalStepsForPattern(patternPart) { + stepTarget := patternTarget.TraversalStep(stepIndex) + setBindingTarget(targets, queryPartIndex, variableSymbol(step.LeftNode.Variable), stepTarget) + setBindingTarget(targets, queryPartIndex, variableSymbol(step.Relationship.Variable), stepTarget) + setBindingTarget(targets, queryPartIndex, variableSymbol(step.RightNode.Variable), stepTarget) + } + } + } +} + +func setBindingTarget(targets map[bindingTargetKey]TraversalStepTarget, queryPartIndex int, symbol string, target TraversalStepTarget) { + if symbol == "" { + return + } + + key := bindingTargetKey{ + QueryPartIndex: queryPartIndex, + Symbol: symbol, + } + if _, exists := targets[key]; !exists { + targets[key] = target + } +} + func expansionSuffixPushdownLength(suffixSteps []sourceTraversalStep, declaredBeforeRightNode []map[string]struct{}) int { var suffixLength int diff --git a/cypher/models/pgsql/optimize/optimizer.go b/cypher/models/pgsql/optimize/optimizer.go index 496c594d..5ab1c93b 100644 --- a/cypher/models/pgsql/optimize/optimizer.go +++ b/cypher/models/pgsql/optimize/optimizer.go @@ -81,7 +81,7 @@ func (s Optimizer) Optimize(query *cypher.RegularQuery) (Plan, error) { plan.Analysis = Analyze(plan.Query) } - if loweringPlan, err := BuildLoweringPlan(plan.Query, plan.Analysis); err != nil { + if loweringPlan, err := BuildLoweringPlan(plan.Query, plan.Analysis, plan.PredicateAttachments); err != nil { return Plan{}, err } else { plan.LoweringPlan = loweringPlan diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index b8bbab5d..ac6acd3b 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -153,6 +153,30 @@ func TestLoweringPlanReportsExpansionSuffixPushdown(t *testing.T) { }}, plan.LoweringPlan.ExpansionSuffixPushdown) } +func TestLoweringPlanPlacesBindingPredicates(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH p = (n:Group)-[:MemberOf*0..]->(m)-[:Enroll]->(ca:EnterpriseCA) + WHERE ca.name = 'target' + RETURN p + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Contains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringPredicatePlacement}) + require.Len(t, plan.LoweringPlan.PredicatePlacement, 1) + require.Equal(t, TraversalStepTarget{ + QueryPartIndex: 0, + ClauseIndex: 0, + PatternIndex: 0, + StepIndex: 1, + }, plan.LoweringPlan.PredicatePlacement[0].Target) + require.Equal(t, []string{"ca"}, plan.LoweringPlan.PredicatePlacement[0].Attachment.BindingSymbols) + require.Equal(t, []PredicateAttachment{plan.LoweringPlan.PredicatePlacement[0].Attachment}, plan.LoweringPlan.ExpansionSuffixPushdown[0].PredicateAttachments) +} + func TestLoweringPlanReportsExpandInto(t *testing.T) { t.Parallel() diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index 9e0b4586..b4ee5caa 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -253,9 +253,11 @@ RETURN p require.NotEmpty(t, translation.Optimization.LoweringPlan.ProjectionPruning) require.NotEmpty(t, translation.Optimization.LoweringPlan.LatePathMaterialization) require.NotEmpty(t, translation.Optimization.LoweringPlan.ExpansionSuffixPushdown) + require.NotEmpty(t, translation.Optimization.LoweringPlan.PredicatePlacement) requireOptimizationLowering(t, translation.Optimization, "ProjectionPruning") requireOptimizationLowering(t, translation.Optimization, "LatePathMaterialization") requireOptimizationLowering(t, translation.Optimization, "ExpansionSuffixPushdown") + requireOptimizationLowering(t, translation.Optimization, "PredicatePlacement") } func TestOptimizerSafetyExpansionTerminalPushdownForZeroDepthExpansion(t *testing.T) { From 223e083c56392d638856e32381725f176598aac2 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Thu, 21 May 2026 14:13:14 -0700 Subject: [PATCH 031/116] Prefer optimizer lowering decisions in translator --- cypher/models/pgsql/translate/expansion.go | 3 ++- cypher/models/pgsql/translate/traversal.go | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/cypher/models/pgsql/translate/expansion.go b/cypher/models/pgsql/translate/expansion.go index cfa1d7a0..a87e0a53 100644 --- a/cypher/models/pgsql/translate/expansion.go +++ b/cypher/models/pgsql/translate/expansion.go @@ -2654,7 +2654,8 @@ func (s *Translator) translateTraversalPatternPartWithExpansion(part *PatternPar traversalStep.Frame.Export(expansionModel.PathBinding.Identifier) if allowProjectionPruning { decision, hasDecision := s.projectionPruningDecision(part, stepIndex) - pruneExpansionStepProjectionExports(s.query.CurrentPart(), part, traversalStep, decision, hasDecision) + allowFallback := !s.hasOptimizationPlan || !part.HasTarget + pruneExpansionStepProjectionExports(s.query.CurrentPart(), part, traversalStep, decision, hasDecision, allowFallback) } // Push a new frame that contains currently projected scope from the expansion recursive CTE diff --git a/cypher/models/pgsql/translate/traversal.go b/cypher/models/pgsql/translate/traversal.go index d3a5b78a..c6812934 100644 --- a/cypher/models/pgsql/translate/traversal.go +++ b/cypher/models/pgsql/translate/traversal.go @@ -763,7 +763,7 @@ func traversalStepProjectsBinding(queryPart *QueryPart, part *PatternPart, stepI return false } -func pruneTraversalStepProjectionExports(queryPart *QueryPart, part *PatternPart, stepIndex int, traversalStep *TraversalStep, decision optimize.ProjectionPruningDecision, hasDecision bool) { +func pruneTraversalStepProjectionExports(queryPart *QueryPart, part *PatternPart, stepIndex int, traversalStep *TraversalStep, decision optimize.ProjectionPruningDecision, hasDecision bool, allowFallback bool) { if hasDecision { if traversalStep.LeftNode != nil && !traversalStep.LeftNodeBound && !traversalStepProjectsBindingByDecision(part, stepIndex, traversalStep.LeftNode, decision) { traversalStep.Frame.Unexport(traversalStep.LeftNode.Identifier) @@ -780,6 +780,10 @@ func pruneTraversalStepProjectionExports(queryPart *QueryPart, part *PatternPart return } + if !allowFallback { + return + } + // Bound endpoints already exist in an outer frame. Only unexport unbound // values that later clauses and continuation steps cannot observe. if !traversalStep.LeftNodeBound && !traversalStepProjectsBinding(queryPart, part, stepIndex, traversalStep.LeftNode) { @@ -802,7 +806,7 @@ func pruneTraversalStepProjectionExports(queryPart *QueryPart, part *PatternPart } } -func pruneExpansionStepProjectionExports(queryPart *QueryPart, part *PatternPart, traversalStep *TraversalStep, decision optimize.ProjectionPruningDecision, hasDecision bool) { +func pruneExpansionStepProjectionExports(queryPart *QueryPart, part *PatternPart, traversalStep *TraversalStep, decision optimize.ProjectionPruningDecision, hasDecision bool, allowFallback bool) { if traversalStep == nil || traversalStep.Expansion == nil { return } @@ -819,6 +823,10 @@ func pruneExpansionStepProjectionExports(queryPart *QueryPart, part *PatternPart return } + if !allowFallback { + return + } + // Variable-length relationship bindings materialize to edge-composite // arrays. A path binding can be rebuilt later from the compact expansion // path ID array, so keep the edge array only when the relationship binding @@ -918,7 +926,8 @@ func (s *Translator) translateTraversalPatternPartWithoutExpansion(part *Pattern } decision, hasDecision := s.projectionPruningDecision(part, stepIndex) - pruneTraversalStepProjectionExports(s.query.CurrentPart(), part, stepIndex, traversalStep, decision, hasDecision) + allowFallback := !s.hasOptimizationPlan || !part.HasTarget + pruneTraversalStepProjectionExports(s.query.CurrentPart(), part, stepIndex, traversalStep, decision, hasDecision, allowFallback) } if boundProjections, err := buildVisibleProjections(s.scope); err != nil { From 930091e2d799d665c58099eb0d7613332f685706 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Thu, 21 May 2026 14:13:40 -0700 Subject: [PATCH 032/116] Lock lowering metadata verification --- cmd/benchmark/report_test.go | 18 ++++++++++++++++++ docs/optimization-pass-memory.md | 6 ++++++ 2 files changed, 24 insertions(+) diff --git a/cmd/benchmark/report_test.go b/cmd/benchmark/report_test.go index 35820767..5fc2c684 100644 --- a/cmd/benchmark/report_test.go +++ b/cmd/benchmark/report_test.go @@ -29,6 +29,17 @@ import ( func TestWriteJSONEmitsBaselineFriendlyReport(t *testing.T) { distinctRows := int64(2) duplicateRows := int64(0) + loweringPlan := optimize.LoweringPlan{ + ProjectionPruning: []optimize.ProjectionPruningDecision{{ + Target: optimize.TraversalStepTarget{ + QueryPartIndex: 0, + ClauseIndex: 0, + PatternIndex: 0, + StepIndex: 0, + }, + ReferencedSymbols: []string{"m"}, + }}, + } report := Report{ Driver: "pg", @@ -50,6 +61,8 @@ func TestWriteJSONEmitsBaselineFriendlyReport(t *testing.T) { Name: "ExpansionSuffixPushdown", Applied: true, }}, + Lowerings: loweringPlan.Decisions(), + LoweringPlan: &loweringPlan, }, }, Stats: Stats{ @@ -77,6 +90,11 @@ func TestWriteJSONEmitsBaselineFriendlyReport(t *testing.T) { `"optimization": {`, `"name": "ExpansionSuffixPushdown"`, `"applied": true`, + `"lowerings": [`, + `"name": "ProjectionPruning"`, + `"lowering_plan": {`, + `"projection_pruning": [`, + `"referenced_symbols": [`, `"section": "Traversal"`, } { if !strings.Contains(text, expected) { diff --git a/docs/optimization-pass-memory.md b/docs/optimization-pass-memory.md index 25056c5c..29f82e4a 100644 --- a/docs/optimization-pass-memory.md +++ b/docs/optimization-pass-memory.md @@ -272,3 +272,9 @@ Phase 10 starts by making local measurements repeatable for the optimizer rules - The benchmark runner rejects zero timed iterations so baseline output cannot silently panic while gathering measurements. - Representative SQL-shape tests assert that suffix-local predicates are inside the pushed suffix check, not merely present somewhere in the rendered SQL. - Broad pass/fail performance thresholds remain deferred. Phase 10 measurements are local evidence and regression artifacts first; cost-based acceptance gates should wait for a larger benchmark corpus and stable environment assumptions. + +## Lowering Ownership Refactor Notes + +The translator now consumes optimizer-owned lowering metadata for projection pruning, late path materialization, fixed-hop expand-into detection, expansion suffix pushdown, and predicate placement. PostgreSQL SQL construction remains in the translator, but rule ownership and benchmark-visible diagnostics live in the optimizer lowering plan. + +Translator-local eligibility checks remain only as conservative fallbacks for untargeted internal patterns. Benchmark JSON includes both named lowerings and the structured lowering plan so future reviews can assert the optimizer decision that caused a SQL shape change. From bfe171ebaec8eee01900f64a57d9da240cf0f00c Mon Sep 17 00:00:00 2001 From: John Hopper Date: Thu, 21 May 2026 15:02:17 -0700 Subject: [PATCH 033/116] Harden optimizer lowering metadata --- cmd/benchmark/report_test.go | 6 +- cypher/models/pgsql/optimize/lowering_plan.go | 2 +- cypher/models/pgsql/optimize/optimizer.go | 2 +- cypher/models/pgsql/translate/expansion.go | 41 ++++--- .../pgsql/translate/optimizer_safety_test.go | 30 +++++- cypher/models/pgsql/translate/translator.go | 7 +- cypher/models/pgsql/translate/traversal.go | 101 +++++++++++------- docs/optimization-pass-memory.md | 4 +- 8 files changed, 130 insertions(+), 63 deletions(-) diff --git a/cmd/benchmark/report_test.go b/cmd/benchmark/report_test.go index 5fc2c684..310bdd00 100644 --- a/cmd/benchmark/report_test.go +++ b/cmd/benchmark/report_test.go @@ -61,7 +61,10 @@ func TestWriteJSONEmitsBaselineFriendlyReport(t *testing.T) { Name: "ExpansionSuffixPushdown", Applied: true, }}, - Lowerings: loweringPlan.Decisions(), + PlannedLowerings: loweringPlan.Decisions(), + Lowerings: []optimize.LoweringDecision{{ + Name: "ProjectionPruning", + }}, LoweringPlan: &loweringPlan, }, }, @@ -90,6 +93,7 @@ func TestWriteJSONEmitsBaselineFriendlyReport(t *testing.T) { `"optimization": {`, `"name": "ExpansionSuffixPushdown"`, `"applied": true`, + `"planned_lowerings": [`, `"lowerings": [`, `"name": "ProjectionPruning"`, `"lowering_plan": {`, diff --git a/cypher/models/pgsql/optimize/lowering_plan.go b/cypher/models/pgsql/optimize/lowering_plan.go index ac0ea428..00643ada 100644 --- a/cypher/models/pgsql/optimize/lowering_plan.go +++ b/cypher/models/pgsql/optimize/lowering_plan.go @@ -11,7 +11,7 @@ type sourceTraversalStep struct { RightNode *cypher.NodePattern } -func BuildLoweringPlan(query *cypher.RegularQuery, _ Analysis, predicateAttachments []PredicateAttachment) (LoweringPlan, error) { +func BuildLoweringPlan(query *cypher.RegularQuery, predicateAttachments []PredicateAttachment) (LoweringPlan, error) { if query == nil || query.SingleQuery == nil { return LoweringPlan{}, nil } diff --git a/cypher/models/pgsql/optimize/optimizer.go b/cypher/models/pgsql/optimize/optimizer.go index 5ab1c93b..b448c7e6 100644 --- a/cypher/models/pgsql/optimize/optimizer.go +++ b/cypher/models/pgsql/optimize/optimizer.go @@ -81,7 +81,7 @@ func (s Optimizer) Optimize(query *cypher.RegularQuery) (Plan, error) { plan.Analysis = Analyze(plan.Query) } - if loweringPlan, err := BuildLoweringPlan(plan.Query, plan.Analysis, plan.PredicateAttachments); err != nil { + if loweringPlan, err := BuildLoweringPlan(plan.Query, plan.PredicateAttachments); err != nil { return Plan{}, err } else { plan.LoweringPlan = loweringPlan diff --git a/cypher/models/pgsql/translate/expansion.go b/cypher/models/pgsql/translate/expansion.go index a87e0a53..f47102b5 100644 --- a/cypher/models/pgsql/translate/expansion.go +++ b/cypher/models/pgsql/translate/expansion.go @@ -7,6 +7,7 @@ import ( "github.com/specterops/dawgs/cypher/models" "github.com/specterops/dawgs/cypher/models/pgsql" "github.com/specterops/dawgs/cypher/models/pgsql/format" + "github.com/specterops/dawgs/cypher/models/pgsql/optimize" "github.com/specterops/dawgs/cypher/models/pgsql/pgd" "github.com/specterops/dawgs/graph" ) @@ -2295,18 +2296,9 @@ func applyExpansionSuffixPushdown(part *PatternPart) (int, error) { suffixSteps = part.TraversalSteps[idx+1:] ) - if suffixSatisfaction, satisfied := expansionSuffixTerminalSatisfaction(currentStep, suffixSteps); satisfied { - currentStep.Expansion.TerminalNodeConstraints = pgsql.OptionalAnd( - currentStep.Expansion.TerminalNodeConstraints, - suffixSatisfaction, - ) - - if terminalCriteriaProjection, err := pgsql.As[pgsql.SelectItem](currentStep.Expansion.TerminalNodeConstraints); err != nil { - return applied, err - } else { - currentStep.Expansion.TerminalNodeSatisfactionProjection = terminalCriteriaProjection - } - + if candidateApplied, err := applyExpansionSuffixPushdownCandidate(currentStep, suffixSteps); err != nil { + return applied, err + } else if candidateApplied { applied++ } } @@ -2314,6 +2306,25 @@ func applyExpansionSuffixPushdown(part *PatternPart) (int, error) { return applied, nil } +func applyExpansionSuffixPushdownCandidate(currentStep *TraversalStep, suffixSteps []*TraversalStep) (bool, error) { + if suffixSatisfaction, satisfied := expansionSuffixTerminalSatisfaction(currentStep, suffixSteps); satisfied { + currentStep.Expansion.TerminalNodeConstraints = pgsql.OptionalAnd( + currentStep.Expansion.TerminalNodeConstraints, + suffixSatisfaction, + ) + + if terminalCriteriaProjection, err := pgsql.As[pgsql.SelectItem](currentStep.Expansion.TerminalNodeConstraints); err != nil { + return false, err + } else { + currentStep.Expansion.TerminalNodeSatisfactionProjection = terminalCriteriaProjection + } + + return true, nil + } + + return false, nil +} + func suffixEdgeLeftEndpoint(edgeIdentifier pgsql.Identifier, direction graph.Direction) (pgsql.Expression, bool) { switch direction { case graph.DirectionOutbound: @@ -2654,8 +2665,10 @@ func (s *Translator) translateTraversalPatternPartWithExpansion(part *PatternPar traversalStep.Frame.Export(expansionModel.PathBinding.Identifier) if allowProjectionPruning { decision, hasDecision := s.projectionPruningDecision(part, stepIndex) - allowFallback := !s.hasOptimizationPlan || !part.HasTarget - pruneExpansionStepProjectionExports(s.query.CurrentPart(), part, traversalStep, decision, hasDecision, allowFallback) + allowFallback := !hasDecision + if pruneExpansionStepProjectionExports(s.query.CurrentPart(), part, traversalStep, decision, hasDecision, allowFallback) { + s.recordLowering(optimize.LoweringProjectionPruning) + } } // Push a new frame that contains currently projected scope from the expansion recursive CTE diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index b4ee5caa..01e6724f 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -77,6 +77,26 @@ func requireOptimizationLowering(t *testing.T, summary OptimizationSummary, name require.Failf(t, "missing optimization lowering", "expected lowering %q in %#v", name, summary.Lowerings) } +func requireNoOptimizationLowering(t *testing.T, summary OptimizationSummary, name string) { + t.Helper() + + for _, lowering := range summary.Lowerings { + require.NotEqualf(t, name, lowering.Name, "unexpected applied lowering %q in %#v", name, summary.Lowerings) + } +} + +func requirePlannedOptimizationLowering(t *testing.T, summary OptimizationSummary, name string) { + t.Helper() + + for _, lowering := range summary.PlannedLowerings { + if lowering.Name == name { + return + } + } + + require.Failf(t, "missing planned optimization lowering", "expected planned lowering %q in %#v", name, summary.PlannedLowerings) +} + func requireSQLContainsInOrder(t *testing.T, sql string, parts ...string) { t.Helper() @@ -198,7 +218,8 @@ RETURN p require.NotContains(t, normalizedQuery, "join node") require.NotNil(t, translation.Optimization.LoweringPlan) require.NotEmpty(t, translation.Optimization.LoweringPlan.ExpandInto) - requireOptimizationLowering(t, translation.Optimization, "ExpandIntoDetection") + requirePlannedOptimizationLowering(t, translation.Optimization, "ExpandIntoDetection") + requireNoOptimizationLowering(t, translation.Optimization, "ExpandIntoDetection") } func TestOptimizerSafetyReordersIndependentNodeAnchor(t *testing.T) { @@ -254,10 +275,13 @@ RETURN p require.NotEmpty(t, translation.Optimization.LoweringPlan.LatePathMaterialization) require.NotEmpty(t, translation.Optimization.LoweringPlan.ExpansionSuffixPushdown) require.NotEmpty(t, translation.Optimization.LoweringPlan.PredicatePlacement) + requirePlannedOptimizationLowering(t, translation.Optimization, "ProjectionPruning") + requirePlannedOptimizationLowering(t, translation.Optimization, "LatePathMaterialization") + requirePlannedOptimizationLowering(t, translation.Optimization, "ExpansionSuffixPushdown") + requirePlannedOptimizationLowering(t, translation.Optimization, "PredicatePlacement") requireOptimizationLowering(t, translation.Optimization, "ProjectionPruning") - requireOptimizationLowering(t, translation.Optimization, "LatePathMaterialization") requireOptimizationLowering(t, translation.Optimization, "ExpansionSuffixPushdown") - requireOptimizationLowering(t, translation.Optimization, "PredicatePlacement") + requireNoOptimizationLowering(t, translation.Optimization, "PredicatePlacement") } func TestOptimizerSafetyExpansionTerminalPushdownForZeroDepthExpansion(t *testing.T) { diff --git a/cypher/models/pgsql/translate/translator.go b/cypher/models/pgsql/translate/translator.go index 01b5f4cc..c59c4cca 100644 --- a/cypher/models/pgsql/translate/translator.go +++ b/cypher/models/pgsql/translate/translator.go @@ -30,7 +30,6 @@ type Translator struct { scope *Scope unwindTargets map[*cypher.Variable]struct{} - hasOptimizationPlan bool patternTargets map[*cypher.PatternPart]optimize.PatternTarget projectionPruningDecisions map[optimize.TraversalStepTarget]optimize.ProjectionPruningDecision latePathDecisions map[optimize.TraversalStepTarget][]optimize.LatePathMaterializationDecision @@ -68,7 +67,6 @@ func NewTranslator(ctx context.Context, kindMapper pgsql.KindMapper, parameters } func (s *Translator) SetOptimizationPlan(plan optimize.Plan) { - s.hasOptimizationPlan = true s.patternTargets = optimize.IndexPatternTargets(plan.Query) s.projectionPruningDecisions = map[optimize.TraversalStepTarget]optimize.ProjectionPruningDecision{} s.latePathDecisions = map[optimize.TraversalStepTarget][]optimize.LatePathMaterializationDecision{} @@ -560,6 +558,7 @@ type Result struct { type OptimizationSummary struct { Rules []optimize.RuleResult `json:"rules,omitempty"` PredicateAttachments []optimize.PredicateAttachment `json:"predicate_attachments,omitempty"` + PlannedLowerings []optimize.LoweringDecision `json:"planned_lowerings,omitempty"` Lowerings []optimize.LoweringDecision `json:"lowerings,omitempty"` LoweringPlan *optimize.LoweringPlan `json:"lowering_plan,omitempty"` } @@ -587,9 +586,7 @@ func Translate(ctx context.Context, cypherQuery *cypher.RegularQuery, kindMapper if !optimizedPlan.LoweringPlan.Empty() { loweringPlan := optimizedPlan.LoweringPlan translator.translation.Optimization.LoweringPlan = &loweringPlan - for _, lowering := range loweringPlan.Decisions() { - translator.recordLowering(lowering.Name) - } + translator.translation.Optimization.PlannedLowerings = loweringPlan.Decisions() } if err := walk.Cypher(optimizedPlan.Query, translator); err != nil { diff --git a/cypher/models/pgsql/translate/traversal.go b/cypher/models/pgsql/translate/traversal.go index c6812934..eccafddb 100644 --- a/cypher/models/pgsql/translate/traversal.go +++ b/cypher/models/pgsql/translate/traversal.go @@ -606,7 +606,7 @@ func (s *Translator) translateTraversalPatternPart(part *PatternPart, isolatedPr if applied, err := s.applyExpansionSuffixPushdown(part); err != nil { return err } else if applied > 0 { - s.recordLowering("ExpansionSuffixPushdown") + s.recordLowering(optimize.LoweringExpansionSuffixPushdown) } if isolatedProjection { @@ -624,25 +624,33 @@ func (s *Translator) applyExpansionSuffixPushdown(part *PatternPart) (int, error var applied int for stepIndex := range part.TraversalSteps { target := part.Target.TraversalStep(stepIndex) - for _, decision := range s.suffixPushdownDecisions[target] { + decisions := s.suffixPushdownDecisions[target] + if len(decisions) == 0 { + if stepIndex+1 >= len(part.TraversalSteps) { + continue + } + + currentStep := part.TraversalSteps[stepIndex] + suffixSteps := part.TraversalSteps[stepIndex+1:] + if candidateApplied, err := applyExpansionSuffixPushdownCandidate(currentStep, suffixSteps); err != nil { + return applied, err + } else if candidateApplied { + applied++ + } + + continue + } + + for _, decision := range decisions { if decision.SuffixLength <= 0 || stepIndex+decision.SuffixLength >= len(part.TraversalSteps) { continue } currentStep := part.TraversalSteps[stepIndex] suffixSteps := part.TraversalSteps[stepIndex+1 : stepIndex+1+decision.SuffixLength] - if suffixSatisfaction, satisfied := expansionSuffixTerminalSatisfaction(currentStep, suffixSteps); satisfied { - currentStep.Expansion.TerminalNodeConstraints = pgsql.OptionalAnd( - currentStep.Expansion.TerminalNodeConstraints, - suffixSatisfaction, - ) - - if terminalCriteriaProjection, err := pgsql.As[pgsql.SelectItem](currentStep.Expansion.TerminalNodeConstraints); err != nil { - return applied, err - } else { - currentStep.Expansion.TerminalNodeSatisfactionProjection = terminalCriteriaProjection - } - + if candidateApplied, err := applyExpansionSuffixPushdownCandidate(currentStep, suffixSteps); err != nil { + return applied, err + } else if candidateApplied { applied++ } } @@ -763,31 +771,43 @@ func traversalStepProjectsBinding(queryPart *QueryPart, part *PatternPart, stepI return false } -func pruneTraversalStepProjectionExports(queryPart *QueryPart, part *PatternPart, stepIndex int, traversalStep *TraversalStep, decision optimize.ProjectionPruningDecision, hasDecision bool, allowFallback bool) { +func unexportFrameBinding(frame *Frame, identifier pgsql.Identifier) bool { + if frame == nil { + return false + } + + exported := frame.Exported.Contains(identifier) + frame.Unexport(identifier) + return exported +} + +func pruneTraversalStepProjectionExports(queryPart *QueryPart, part *PatternPart, stepIndex int, traversalStep *TraversalStep, decision optimize.ProjectionPruningDecision, hasDecision bool, allowFallback bool) bool { + var applied bool + if hasDecision { if traversalStep.LeftNode != nil && !traversalStep.LeftNodeBound && !traversalStepProjectsBindingByDecision(part, stepIndex, traversalStep.LeftNode, decision) { - traversalStep.Frame.Unexport(traversalStep.LeftNode.Identifier) + applied = unexportFrameBinding(traversalStep.Frame, traversalStep.LeftNode.Identifier) || applied } if traversalStep.Edge != nil && !traversalStepProjectsBindingByDecision(part, stepIndex, traversalStep.Edge, decision) { - traversalStep.Frame.Unexport(traversalStep.Edge.Identifier) + applied = unexportFrameBinding(traversalStep.Frame, traversalStep.Edge.Identifier) || applied } if traversalStep.RightNode != nil && !traversalStep.RightNodeBound && !traversalStepProjectsBindingByDecision(part, stepIndex, traversalStep.RightNode, decision) { - traversalStep.Frame.Unexport(traversalStep.RightNode.Identifier) + applied = unexportFrameBinding(traversalStep.Frame, traversalStep.RightNode.Identifier) || applied } - return + return applied } if !allowFallback { - return + return false } // Bound endpoints already exist in an outer frame. Only unexport unbound // values that later clauses and continuation steps cannot observe. - if !traversalStep.LeftNodeBound && !traversalStepProjectsBinding(queryPart, part, stepIndex, traversalStep.LeftNode) { - traversalStep.Frame.Unexport(traversalStep.LeftNode.Identifier) + if traversalStep.LeftNode != nil && !traversalStep.LeftNodeBound && !traversalStepProjectsBinding(queryPart, part, stepIndex, traversalStep.LeftNode) { + applied = unexportFrameBinding(traversalStep.Frame, traversalStep.LeftNode.Identifier) || applied } if traversalStep.Edge != nil && @@ -795,36 +815,40 @@ func pruneTraversalStepProjectionExports(queryPart *QueryPart, part *PatternPart !queryPart.ReferencesBinding(traversalStep.Edge) && patternBindingDependsOn(queryPart, part, traversalStep.Edge) { traversalStep.Edge.DataType = pgsql.PathEdge + applied = true } - if !traversalStepProjectsBinding(queryPart, part, stepIndex, traversalStep.Edge) { - traversalStep.Frame.Unexport(traversalStep.Edge.Identifier) + if traversalStep.Edge != nil && !traversalStepProjectsBinding(queryPart, part, stepIndex, traversalStep.Edge) { + applied = unexportFrameBinding(traversalStep.Frame, traversalStep.Edge.Identifier) || applied } - if !traversalStep.RightNodeBound && !traversalStepProjectsBinding(queryPart, part, stepIndex, traversalStep.RightNode) { - traversalStep.Frame.Unexport(traversalStep.RightNode.Identifier) + if traversalStep.RightNode != nil && !traversalStep.RightNodeBound && !traversalStepProjectsBinding(queryPart, part, stepIndex, traversalStep.RightNode) { + applied = unexportFrameBinding(traversalStep.Frame, traversalStep.RightNode.Identifier) || applied } + + return applied } -func pruneExpansionStepProjectionExports(queryPart *QueryPart, part *PatternPart, traversalStep *TraversalStep, decision optimize.ProjectionPruningDecision, hasDecision bool, allowFallback bool) { +func pruneExpansionStepProjectionExports(queryPart *QueryPart, part *PatternPart, traversalStep *TraversalStep, decision optimize.ProjectionPruningDecision, hasDecision bool, allowFallback bool) bool { if traversalStep == nil || traversalStep.Expansion == nil { - return + return false } + var applied bool if hasDecision { if traversalStep.Edge != nil && !projectionPruningDecisionReferencesBinding(decision, traversalStep.Edge) { - traversalStep.Frame.Unexport(traversalStep.Edge.Identifier) + applied = unexportFrameBinding(traversalStep.Frame, traversalStep.Edge.Identifier) || applied } if traversalStep.Expansion.PathBinding != nil && !projectionPruningDecisionPatternDependsOn(part, traversalStep.Expansion.PathBinding, decision) { - traversalStep.Frame.Unexport(traversalStep.Expansion.PathBinding.Identifier) + applied = unexportFrameBinding(traversalStep.Frame, traversalStep.Expansion.PathBinding.Identifier) || applied } - return + return applied } if !allowFallback { - return + return false } // Variable-length relationship bindings materialize to edge-composite @@ -832,13 +856,15 @@ func pruneExpansionStepProjectionExports(queryPart *QueryPart, part *PatternPart // path ID array, so keep the edge array only when the relationship binding // itself is observable. if traversalStep.Edge != nil && !queryPart.ReferencesBinding(traversalStep.Edge) { - traversalStep.Frame.Unexport(traversalStep.Edge.Identifier) + applied = unexportFrameBinding(traversalStep.Frame, traversalStep.Edge.Identifier) || applied } pathBinding := traversalStep.Expansion.PathBinding if pathBinding != nil && !patternBindingDependsOn(queryPart, part, pathBinding) { - traversalStep.Frame.Unexport(pathBinding.Identifier) + applied = unexportFrameBinding(traversalStep.Frame, pathBinding.Identifier) || applied } + + return applied } func (s *Translator) translateTraversalPatternPartWithoutExpansion(part *PatternPart, stepIndex int, traversalStep *TraversalStep, allowProjectionPruning bool) error { @@ -923,11 +949,14 @@ func (s *Translator) translateTraversalPatternPartWithoutExpansion(part *Pattern traversalStep.Edge.DataType == pgsql.EdgeComposite && s.hasLatePathMaterialization(part, stepIndex, optimize.LatePathMaterializationPathEdgeID) { traversalStep.Edge.DataType = pgsql.PathEdge + s.recordLowering(optimize.LoweringLatePathMaterialization) } decision, hasDecision := s.projectionPruningDecision(part, stepIndex) - allowFallback := !s.hasOptimizationPlan || !part.HasTarget - pruneTraversalStepProjectionExports(s.query.CurrentPart(), part, stepIndex, traversalStep, decision, hasDecision, allowFallback) + allowFallback := !hasDecision + if pruneTraversalStepProjectionExports(s.query.CurrentPart(), part, stepIndex, traversalStep, decision, hasDecision, allowFallback) { + s.recordLowering(optimize.LoweringProjectionPruning) + } } if boundProjections, err := buildVisibleProjections(s.scope); err != nil { diff --git a/docs/optimization-pass-memory.md b/docs/optimization-pass-memory.md index 29f82e4a..9b12efa1 100644 --- a/docs/optimization-pass-memory.md +++ b/docs/optimization-pass-memory.md @@ -259,7 +259,7 @@ The gap-closure pass has been completed enough to return to the original phase s - ADCS path scenarios now record warm-up row count, distinct returned path-row count, and duplicate returned path-row count. - PostgreSQL benchmark runs can opt into `EXPLAIN (ANALYZE, BUFFERS)` capture with `-explain`; JSON output includes the translated SQL and plan text. - The small ADCS integration fixture now asserts exact returned path shape and row count. The larger fanout fixture remains a measurement fixture rather than an exact cardinality oracle. -- Translation metadata reports optimizer rules, predicate attachments, and named lowerings, including `ExpansionSuffixPushdown`. +- Translation metadata reports optimizer rules, predicate attachments, planned lowerings, and applied lowerings, including `ExpansionSuffixPushdown`. - Phase 9 suffix coverage includes zero-hop expansions, fixed suffix chains, suffixes ending at already-bound nodes, inbound suffixes, and the ADCS root-to-domain suffix shape. - Directionless suffix pushdown remains deliberately unimplemented; those suffixes stay as normal translated pattern steps. @@ -277,4 +277,4 @@ Phase 10 starts by making local measurements repeatable for the optimizer rules The translator now consumes optimizer-owned lowering metadata for projection pruning, late path materialization, fixed-hop expand-into detection, expansion suffix pushdown, and predicate placement. PostgreSQL SQL construction remains in the translator, but rule ownership and benchmark-visible diagnostics live in the optimizer lowering plan. -Translator-local eligibility checks remain only as conservative fallbacks for untargeted internal patterns. Benchmark JSON includes both named lowerings and the structured lowering plan so future reviews can assert the optimizer decision that caused a SQL shape change. +Translator-local eligibility checks remain as conservative fallbacks for traversal steps that do not have an optimizer decision. Benchmark JSON includes planned lowerings, applied lowerings, and the structured lowering plan so future reviews can distinguish optimizer intent from SQL-shape changes that actually happened. From 39eb6abb67ff4e89c579fd6817b8c07f1b3d19c0 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Thu, 21 May 2026 20:38:55 -0700 Subject: [PATCH 034/116] Document lowering metadata contract --- docs/optimization-pass-memory.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/optimization-pass-memory.md b/docs/optimization-pass-memory.md index 9b12efa1..a1237bd3 100644 --- a/docs/optimization-pass-memory.md +++ b/docs/optimization-pass-memory.md @@ -278,3 +278,10 @@ Phase 10 starts by making local measurements repeatable for the optimizer rules The translator now consumes optimizer-owned lowering metadata for projection pruning, late path materialization, fixed-hop expand-into detection, expansion suffix pushdown, and predicate placement. PostgreSQL SQL construction remains in the translator, but rule ownership and benchmark-visible diagnostics live in the optimizer lowering plan. Translator-local eligibility checks remain as conservative fallbacks for traversal steps that do not have an optimizer decision. Benchmark JSON includes planned lowerings, applied lowerings, and the structured lowering plan so future reviews can distinguish optimizer intent from SQL-shape changes that actually happened. + +### Lowering Metadata Contract + +- `LoweringPlan` is optimizer intent over the Cypher source shape. It may include decisions that the PostgreSQL translator later declines because the lowered SQL shape is no longer eligible. +- `planned_lowerings` is the compact, benchmark-friendly view of `LoweringPlan.Decisions()`. +- `lowerings` is translator-applied behavior only. It must be recorded when SQL generation actually changes shape, not when the optimizer merely planned a decision. +- Optimizer code may describe source-level actions and eligibility. PostgreSQL frame visibility, data-type rewrites, and SQL AST construction remain translator responsibilities. From 19b9c40ff1efe738dc91fe0ad70f1249bbd86f2e Mon Sep 17 00:00:00 2001 From: John Hopper Date: Thu, 21 May 2026 20:39:33 -0700 Subject: [PATCH 035/116] Consume expand-into lowering decisions --- cypher/models/pgsql/translate/model.go | 1 + .../pgsql/translate/optimizer_safety_test.go | 2 +- cypher/models/pgsql/translate/traversal.go | 24 ++++++++++++++++--- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/cypher/models/pgsql/translate/model.go b/cypher/models/pgsql/translate/model.go index c7403c23..cfafa79a 100644 --- a/cypher/models/pgsql/translate/model.go +++ b/cypher/models/pgsql/translate/model.go @@ -532,6 +532,7 @@ type TraversalStep struct { PathReversed bool LeftNode *BoundIdentifier LeftNodeBound bool + UseExpandInto bool LeftNodeConstraints pgsql.Expression LeftNodeJoinCondition pgsql.Expression Edge *BoundIdentifier diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index 01e6724f..7835abe6 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -219,7 +219,7 @@ RETURN p require.NotNil(t, translation.Optimization.LoweringPlan) require.NotEmpty(t, translation.Optimization.LoweringPlan.ExpandInto) requirePlannedOptimizationLowering(t, translation.Optimization, "ExpandIntoDetection") - requireNoOptimizationLowering(t, translation.Optimization, "ExpandIntoDetection") + requireOptimizationLowering(t, translation.Optimization, "ExpandIntoDetection") } func TestOptimizerSafetyReordersIndependentNodeAnchor(t *testing.T) { diff --git a/cypher/models/pgsql/translate/traversal.go b/cypher/models/pgsql/translate/traversal.go index eccafddb..61193439 100644 --- a/cypher/models/pgsql/translate/traversal.go +++ b/cypher/models/pgsql/translate/traversal.go @@ -28,6 +28,20 @@ func boundEndpointInequality(frame *Frame, traversalStep *TraversalStep) pgsql.E ) } +func (s *Translator) shouldUseExpandInto(part *PatternPart, stepIndex int, traversalStep *TraversalStep) bool { + if traversalStep == nil || traversalStep.Expansion != nil || !traversalStep.LeftNodeBound || !traversalStep.RightNodeBound { + return false + } + + if part != nil && part.HasTarget { + if _, hasDecision := s.expandIntoDecisions[part.Target.TraversalStep(stepIndex)]; hasDecision { + return true + } + } + + return true +} + func (s *Translator) buildBoundEndpointTraversalPattern(partFrame *Frame, traversalStep *TraversalStep) (pgsql.Query, error) { if partFrame == nil || partFrame.Previous == nil { return pgsql.Query{}, errors.New("expected previous frame for bound endpoint traversal") @@ -72,7 +86,7 @@ func (s *Translator) buildBoundEndpointTraversalPattern(partFrame *Frame, traver } func (s *Translator) buildDirectionlessTraversalPatternRoot(traversalStep *TraversalStep) (pgsql.Query, error) { - if traversalStep.LeftNodeBound && traversalStep.RightNodeBound { + if traversalStep.UseExpandInto { return s.buildBoundEndpointTraversalPattern(traversalStep.Frame, traversalStep) } @@ -324,7 +338,7 @@ func (s *Translator) buildTraversalPatternRoot(partFrame *Frame, traversalStep * return s.buildDirectionlessTraversalPatternRoot(traversalStep) } - if traversalStep.LeftNodeBound && traversalStep.RightNodeBound { + if traversalStep.UseExpandInto { return s.buildBoundEndpointTraversalPattern(partFrame, traversalStep) } @@ -510,7 +524,7 @@ func (s *Translator) buildTraversalPatternRoot(partFrame *Frame, traversalStep * } func (s *Translator) buildTraversalPatternStep(partFrame *Frame, traversalStep *TraversalStep) (pgsql.Query, error) { - if traversalStep.LeftNodeBound && traversalStep.RightNodeBound { + if traversalStep.UseExpandInto { return s.buildBoundEndpointTraversalPattern(partFrame, traversalStep) } @@ -585,6 +599,10 @@ func (s *Translator) translateTraversalPatternPart(part *PatternPart, isolatedPr } for idx, traversalStep := range part.TraversalSteps { + if traversalStep.UseExpandInto = s.shouldUseExpandInto(part, idx, traversalStep); traversalStep.UseExpandInto { + s.recordLowering(optimize.LoweringExpandIntoDetection) + } + if traversalStepFrame, err := s.scope.PushFrame(); err != nil { return err } else { From 44c8ffab4f3db488111a9b1d8fa9b04c6f78ea13 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Thu, 21 May 2026 20:40:36 -0700 Subject: [PATCH 036/116] Lift projection pruning binding actions --- cypher/models/pgsql/optimize/lowering.go | 4 ++ cypher/models/pgsql/optimize/lowering_plan.go | 11 ++-- .../models/pgsql/optimize/optimizer_test.go | 2 + cypher/models/pgsql/translate/traversal.go | 61 ++----------------- 4 files changed, 18 insertions(+), 60 deletions(-) diff --git a/cypher/models/pgsql/optimize/lowering.go b/cypher/models/pgsql/optimize/lowering.go index aa18c9ac..f1621d7e 100644 --- a/cypher/models/pgsql/optimize/lowering.go +++ b/cypher/models/pgsql/optimize/lowering.go @@ -40,6 +40,10 @@ type ProjectionPruningDecision struct { Target TraversalStepTarget `json:"target"` ReferencedSymbols []string `json:"referenced_symbols,omitempty"` PatternBindingReferenced bool `json:"pattern_binding_referenced,omitempty"` + OmitLeftNode bool `json:"omit_left_node,omitempty"` + OmitRelationship bool `json:"omit_relationship,omitempty"` + OmitRightNode bool `json:"omit_right_node,omitempty"` + OmitPathBinding bool `json:"omit_path_binding,omitempty"` } type LatePathMaterializationMode string diff --git a/cypher/models/pgsql/optimize/lowering_plan.go b/cypher/models/pgsql/optimize/lowering_plan.go index 00643ada..78a9bffa 100644 --- a/cypher/models/pgsql/optimize/lowering_plan.go +++ b/cypher/models/pgsql/optimize/lowering_plan.go @@ -92,14 +92,17 @@ func appendPatternProjectionPruningDecisions(plan *LoweringPlan, target PatternT edgeReferenced := referencesSourceIdentifier(sourceReferences, variableSymbol(step.Relationship.Variable)) var hasPruning bool if step.Relationship.Range != nil { - hasPruning = !edgeReferenced || !pathReferenced + decision.OmitRelationship = !edgeReferenced + decision.OmitPathBinding = !pathReferenced + hasPruning = decision.OmitRelationship || decision.OmitPathBinding } else { leftReferenced := referencesSourceIdentifier(sourceReferences, variableSymbol(step.LeftNode.Variable)) rightReferenced := referencesSourceIdentifier(sourceReferences, variableSymbol(step.RightNode.Variable)) - hasPruning = !(leftReferenced || pathReferenced) || - !(edgeReferenced || pathReferenced) || - !(rightReferenced || pathReferenced || stepIndex+1 < len(steps)) + decision.OmitLeftNode = !(leftReferenced || pathReferenced) + decision.OmitRelationship = !(edgeReferenced || pathReferenced) + decision.OmitRightNode = !(rightReferenced || pathReferenced || stepIndex+1 < len(steps)) + hasPruning = decision.OmitLeftNode || decision.OmitRelationship || decision.OmitRightNode } if hasPruning { diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index ac6acd3b..3d2ac841 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -87,6 +87,8 @@ func TestLoweringPlanReportsProjectionPruning(t *testing.T) { StepIndex: 0, }, ReferencedSymbols: []string{"m"}, + OmitLeftNode: true, + OmitRelationship: true, }}, plan.LoweringPlan.ProjectionPruning) } diff --git a/cypher/models/pgsql/translate/traversal.go b/cypher/models/pgsql/translate/traversal.go index 61193439..72303ae6 100644 --- a/cypher/models/pgsql/translate/traversal.go +++ b/cypher/models/pgsql/translate/traversal.go @@ -5,7 +5,6 @@ import ( "fmt" "github.com/specterops/dawgs/cypher/models" - "github.com/specterops/dawgs/cypher/models/cypher" "github.com/specterops/dawgs/cypher/models/pgsql" "github.com/specterops/dawgs/cypher/models/pgsql/optimize" "github.com/specterops/dawgs/graph" @@ -718,56 +717,6 @@ func (s *Translator) hasLatePathMaterialization(part *PatternPart, stepIndex int return false } -func projectionPruningDecisionReferencesBinding(decision optimize.ProjectionPruningDecision, binding *BoundIdentifier) bool { - if binding == nil { - return false - } - - sourceIdentifier := binding.Identifier - if binding.Alias.Set { - sourceIdentifier = binding.Alias.Value - } - - for _, symbol := range decision.ReferencedSymbols { - if symbol == cypher.TokenLiteralAsterisk || pgsql.Identifier(symbol) == sourceIdentifier { - return true - } - } - - return false -} - -func projectionPruningDecisionPatternDependsOn(part *PatternPart, binding *BoundIdentifier, decision optimize.ProjectionPruningDecision) bool { - if !decision.PatternBindingReferenced || part == nil || part.PatternBinding == nil || binding == nil { - return false - } - - for _, dependency := range part.PatternBinding.Dependencies { - if dependency.Identifier == binding.Identifier { - return true - } - } - - return false -} - -func traversalStepProjectsBindingByDecision(part *PatternPart, stepIndex int, binding *BoundIdentifier, decision optimize.ProjectionPruningDecision) bool { - if binding == nil { - return false - } - - if projectionPruningDecisionReferencesBinding(decision, binding) || projectionPruningDecisionPatternDependsOn(part, binding, decision) { - return true - } - - if stepIndex+1 < len(part.TraversalSteps) { - nextStep := part.TraversalSteps[stepIndex+1] - return nextStep.LeftNode != nil && nextStep.LeftNode.Identifier == binding.Identifier - } - - return false -} - func traversalStepProjectsBinding(queryPart *QueryPart, part *PatternPart, stepIndex int, binding *BoundIdentifier) bool { if binding == nil { return false @@ -803,15 +752,15 @@ func pruneTraversalStepProjectionExports(queryPart *QueryPart, part *PatternPart var applied bool if hasDecision { - if traversalStep.LeftNode != nil && !traversalStep.LeftNodeBound && !traversalStepProjectsBindingByDecision(part, stepIndex, traversalStep.LeftNode, decision) { + if decision.OmitLeftNode && traversalStep.LeftNode != nil && !traversalStep.LeftNodeBound { applied = unexportFrameBinding(traversalStep.Frame, traversalStep.LeftNode.Identifier) || applied } - if traversalStep.Edge != nil && !traversalStepProjectsBindingByDecision(part, stepIndex, traversalStep.Edge, decision) { + if decision.OmitRelationship && traversalStep.Edge != nil { applied = unexportFrameBinding(traversalStep.Frame, traversalStep.Edge.Identifier) || applied } - if traversalStep.RightNode != nil && !traversalStep.RightNodeBound && !traversalStepProjectsBindingByDecision(part, stepIndex, traversalStep.RightNode, decision) { + if decision.OmitRightNode && traversalStep.RightNode != nil && !traversalStep.RightNodeBound { applied = unexportFrameBinding(traversalStep.Frame, traversalStep.RightNode.Identifier) || applied } @@ -854,11 +803,11 @@ func pruneExpansionStepProjectionExports(queryPart *QueryPart, part *PatternPart var applied bool if hasDecision { - if traversalStep.Edge != nil && !projectionPruningDecisionReferencesBinding(decision, traversalStep.Edge) { + if decision.OmitRelationship && traversalStep.Edge != nil { applied = unexportFrameBinding(traversalStep.Frame, traversalStep.Edge.Identifier) || applied } - if traversalStep.Expansion.PathBinding != nil && !projectionPruningDecisionPatternDependsOn(part, traversalStep.Expansion.PathBinding, decision) { + if decision.OmitPathBinding && traversalStep.Expansion.PathBinding != nil { applied = unexportFrameBinding(traversalStep.Frame, traversalStep.Expansion.PathBinding.Identifier) || applied } From 9d6fbc4cbc4069309d2181ed452c8c1c0a279587 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Thu, 21 May 2026 20:41:18 -0700 Subject: [PATCH 037/116] Apply late materialization decisions explicitly --- cypher/models/pgsql/translate/expansion.go | 5 ++++ .../pgsql/translate/optimizer_safety_test.go | 1 + cypher/models/pgsql/translate/traversal.go | 28 +++++++++++++------ 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/cypher/models/pgsql/translate/expansion.go b/cypher/models/pgsql/translate/expansion.go index f47102b5..c0632fbb 100644 --- a/cypher/models/pgsql/translate/expansion.go +++ b/cypher/models/pgsql/translate/expansion.go @@ -2669,6 +2669,11 @@ func (s *Translator) translateTraversalPatternPartWithExpansion(part *PatternPar if pruneExpansionStepProjectionExports(s.query.CurrentPart(), part, traversalStep, decision, hasDecision, allowFallback) { s.recordLowering(optimize.LoweringProjectionPruning) } + + if _, hasDecision := s.latePathMaterializationDecision(part, stepIndex, optimize.LatePathMaterializationExpansionPath); hasDecision && + traversalStep.Frame.Exported.Contains(expansionModel.PathBinding.Identifier) { + s.recordLowering(optimize.LoweringLatePathMaterialization) + } } // Push a new frame that contains currently projected scope from the expansion recursive CTE diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index 7835abe6..095bbef0 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -280,6 +280,7 @@ RETURN p requirePlannedOptimizationLowering(t, translation.Optimization, "ExpansionSuffixPushdown") requirePlannedOptimizationLowering(t, translation.Optimization, "PredicatePlacement") requireOptimizationLowering(t, translation.Optimization, "ProjectionPruning") + requireOptimizationLowering(t, translation.Optimization, "LatePathMaterialization") requireOptimizationLowering(t, translation.Optimization, "ExpansionSuffixPushdown") requireNoOptimizationLowering(t, translation.Optimization, "PredicatePlacement") } diff --git a/cypher/models/pgsql/translate/traversal.go b/cypher/models/pgsql/translate/traversal.go index 72303ae6..7c1c13f0 100644 --- a/cypher/models/pgsql/translate/traversal.go +++ b/cypher/models/pgsql/translate/traversal.go @@ -703,18 +703,33 @@ func (s *Translator) projectionPruningDecision(part *PatternPart, stepIndex int) return decision, hasDecision } -func (s *Translator) hasLatePathMaterialization(part *PatternPart, stepIndex int, mode optimize.LatePathMaterializationMode) bool { +func (s *Translator) latePathMaterializationDecision(part *PatternPart, stepIndex int, mode optimize.LatePathMaterializationMode) (optimize.LatePathMaterializationDecision, bool) { if part == nil || !part.HasTarget { - return false + return optimize.LatePathMaterializationDecision{}, false } for _, decision := range s.latePathDecisions[part.Target.TraversalStep(stepIndex)] { if decision.Mode == mode { - return true + return decision, true } } - return false + return optimize.LatePathMaterializationDecision{}, false +} + +func (s *Translator) applyPathEdgeIDMaterialization(part *PatternPart, stepIndex int, traversalStep *TraversalStep) bool { + if traversalStep == nil || + traversalStep.Edge == nil || + traversalStep.Edge.DataType != pgsql.EdgeComposite { + return false + } + + if _, hasDecision := s.latePathMaterializationDecision(part, stepIndex, optimize.LatePathMaterializationPathEdgeID); !hasDecision { + return false + } + + traversalStep.Edge.DataType = pgsql.PathEdge + return true } func traversalStepProjectsBinding(queryPart *QueryPart, part *PatternPart, stepIndex int, binding *BoundIdentifier) bool { @@ -912,10 +927,7 @@ func (s *Translator) translateTraversalPatternPartWithoutExpansion(part *Pattern } if allowProjectionPruning { - if traversalStep.Edge != nil && - traversalStep.Edge.DataType == pgsql.EdgeComposite && - s.hasLatePathMaterialization(part, stepIndex, optimize.LatePathMaterializationPathEdgeID) { - traversalStep.Edge.DataType = pgsql.PathEdge + if s.applyPathEdgeIDMaterialization(part, stepIndex, traversalStep) { s.recordLowering(optimize.LoweringLatePathMaterialization) } From 6e56e36a44372c33f5de2e5137311a88a51cebc1 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Thu, 21 May 2026 20:41:45 -0700 Subject: [PATCH 038/116] Record consumed predicate placements --- cypher/models/pgsql/translate/optimizer_safety_test.go | 2 +- cypher/models/pgsql/translate/traversal.go | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index 095bbef0..9a2dbf8b 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -282,7 +282,7 @@ RETURN p requireOptimizationLowering(t, translation.Optimization, "ProjectionPruning") requireOptimizationLowering(t, translation.Optimization, "LatePathMaterialization") requireOptimizationLowering(t, translation.Optimization, "ExpansionSuffixPushdown") - requireNoOptimizationLowering(t, translation.Optimization, "PredicatePlacement") + requireOptimizationLowering(t, translation.Optimization, "PredicatePlacement") } func TestOptimizerSafetyExpansionTerminalPushdownForZeroDepthExpansion(t *testing.T) { diff --git a/cypher/models/pgsql/translate/traversal.go b/cypher/models/pgsql/translate/traversal.go index 7c1c13f0..5fe8e215 100644 --- a/cypher/models/pgsql/translate/traversal.go +++ b/cypher/models/pgsql/translate/traversal.go @@ -668,6 +668,10 @@ func (s *Translator) applyExpansionSuffixPushdown(part *PatternPart) (int, error if candidateApplied, err := applyExpansionSuffixPushdownCandidate(currentStep, suffixSteps); err != nil { return applied, err } else if candidateApplied { + if len(decision.PredicateAttachments) > 0 { + s.recordLowering(optimize.LoweringPredicatePlacement) + } + applied++ } } From 79502d646db669a3dd00f15eaa06d4c3114c330f Mon Sep 17 00:00:00 2001 From: John Hopper Date: Thu, 21 May 2026 20:42:31 -0700 Subject: [PATCH 039/116] Carry suffix pushdown source spans --- cypher/models/pgsql/optimize/lowering.go | 2 ++ cypher/models/pgsql/optimize/lowering_plan.go | 4 +++- cypher/models/pgsql/optimize/optimizer_test.go | 4 +++- cypher/models/pgsql/translate/traversal.go | 8 ++++++-- 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/cypher/models/pgsql/optimize/lowering.go b/cypher/models/pgsql/optimize/lowering.go index f1621d7e..1706f9ad 100644 --- a/cypher/models/pgsql/optimize/lowering.go +++ b/cypher/models/pgsql/optimize/lowering.go @@ -66,6 +66,8 @@ type ExpandIntoDecision struct { type ExpansionSuffixPushdownDecision struct { Target TraversalStepTarget `json:"target"` SuffixLength int `json:"suffix_length"` + SuffixStartStep int `json:"suffix_start_step"` + SuffixEndStep int `json:"suffix_end_step"` PredicateAttachments []PredicateAttachment `json:"predicate_attachments,omitempty"` } diff --git a/cypher/models/pgsql/optimize/lowering_plan.go b/cypher/models/pgsql/optimize/lowering_plan.go index 78a9bffa..c86470fc 100644 --- a/cypher/models/pgsql/optimize/lowering_plan.go +++ b/cypher/models/pgsql/optimize/lowering_plan.go @@ -254,7 +254,9 @@ func appendExpansionSuffixPushdownDecisions(plan *LoweringPlan, queryPartIndex i ClauseIndex: clauseIndex, PatternIndex: patternIndex, }.TraversalStep(stepIndex), - SuffixLength: suffixLength, + SuffixLength: suffixLength, + SuffixStartStep: stepIndex + 1, + SuffixEndStep: stepIndex + suffixLength, }) } } diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index 3d2ac841..28784b8d 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -151,7 +151,9 @@ func TestLoweringPlanReportsExpansionSuffixPushdown(t *testing.T) { PatternIndex: 0, StepIndex: 0, }, - SuffixLength: 1, + SuffixLength: 1, + SuffixStartStep: 1, + SuffixEndStep: 1, }}, plan.LoweringPlan.ExpansionSuffixPushdown) } diff --git a/cypher/models/pgsql/translate/traversal.go b/cypher/models/pgsql/translate/traversal.go index 5fe8e215..3da01fab 100644 --- a/cypher/models/pgsql/translate/traversal.go +++ b/cypher/models/pgsql/translate/traversal.go @@ -659,12 +659,16 @@ func (s *Translator) applyExpansionSuffixPushdown(part *PatternPart) (int, error } for _, decision := range decisions { - if decision.SuffixLength <= 0 || stepIndex+decision.SuffixLength >= len(part.TraversalSteps) { + if decision.SuffixLength <= 0 || + decision.SuffixStartStep <= stepIndex || + decision.SuffixEndStep < decision.SuffixStartStep || + decision.SuffixEndStep >= len(part.TraversalSteps) || + decision.SuffixEndStep-decision.SuffixStartStep+1 != decision.SuffixLength { continue } currentStep := part.TraversalSteps[stepIndex] - suffixSteps := part.TraversalSteps[stepIndex+1 : stepIndex+1+decision.SuffixLength] + suffixSteps := part.TraversalSteps[decision.SuffixStartStep : decision.SuffixEndStep+1] if candidateApplied, err := applyExpansionSuffixPushdownCandidate(currentStep, suffixSteps); err != nil { return applied, err } else if candidateApplied { From f347033f6aa8d286f550558dc14207f8f2a91f7d Mon Sep 17 00:00:00 2001 From: John Hopper Date: Thu, 21 May 2026 20:45:41 -0700 Subject: [PATCH 040/116] Remove targeted lowering fallbacks --- .../models/pgsql/optimize/optimizer_test.go | 23 +++++ .../pgsql/optimize/source_references.go | 5 ++ cypher/models/pgsql/translate/expansion.go | 2 +- cypher/models/pgsql/translate/model.go | 8 ++ cypher/models/pgsql/translate/traversal.go | 88 +++++++++++++------ 5 files changed, 97 insertions(+), 29 deletions(-) diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index 28784b8d..cc1780c4 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -92,6 +92,29 @@ func TestLoweringPlanReportsProjectionPruning(t *testing.T) { }}, plan.LoweringPlan.ProjectionPruning) } +func TestLoweringPlanProjectionPruningKeepsUpdateTargets(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH (a)-[r:MemberOf]->(m) + SET a.name = 'updated', r.seen = true + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Equal(t, []ProjectionPruningDecision{{ + Target: TraversalStepTarget{ + QueryPartIndex: 0, + ClauseIndex: 0, + PatternIndex: 0, + StepIndex: 0, + }, + ReferencedSymbols: []string{"a", "r"}, + OmitRightNode: true, + }}, plan.LoweringPlan.ProjectionPruning) +} + func TestLoweringPlanReportsLatePathMaterialization(t *testing.T) { t.Parallel() diff --git a/cypher/models/pgsql/optimize/source_references.go b/cypher/models/pgsql/optimize/source_references.go index ebdbbe14..bb12ab86 100644 --- a/cypher/models/pgsql/optimize/source_references.go +++ b/cypher/models/pgsql/optimize/source_references.go @@ -77,6 +77,11 @@ func (s *sourceReferenceCollector) Enter(node cypher.SyntaxNode) { s.addMatchPatternDeclaration(typedNode.Variable) } + case *cypher.PropertyLookup: + if variable, isVariable := typedNode.Atom.(*cypher.Variable); isVariable { + s.addVariable(variable) + } + case *cypher.Variable: s.addVariable(typedNode) } diff --git a/cypher/models/pgsql/translate/expansion.go b/cypher/models/pgsql/translate/expansion.go index c0632fbb..9b26c76c 100644 --- a/cypher/models/pgsql/translate/expansion.go +++ b/cypher/models/pgsql/translate/expansion.go @@ -2665,7 +2665,7 @@ func (s *Translator) translateTraversalPatternPartWithExpansion(part *PatternPar traversalStep.Frame.Export(expansionModel.PathBinding.Identifier) if allowProjectionPruning { decision, hasDecision := s.projectionPruningDecision(part, stepIndex) - allowFallback := !hasDecision + allowFallback := !hasDecision && (part == nil || !part.HasTarget) if pruneExpansionStepProjectionExports(s.query.CurrentPart(), part, traversalStep, decision, hasDecision, allowFallback) { s.recordLowering(optimize.LoweringProjectionPruning) } diff --git a/cypher/models/pgsql/translate/model.go b/cypher/models/pgsql/translate/model.go index cfafa79a..ca16e2d6 100644 --- a/cypher/models/pgsql/translate/model.go +++ b/cypher/models/pgsql/translate/model.go @@ -525,11 +525,19 @@ func partitionConstraintByLocality(expression pgsql.Expression, localScope *pgsq return joinConstraints, whereConstraints } +type ProjectionPruningApplication struct { + LeftNode *BoundIdentifier + Relationship *BoundIdentifier + RightNode *BoundIdentifier + PathBinding *BoundIdentifier +} + type TraversalStep struct { Frame *Frame Direction graph.Direction Expansion *Expansion PathReversed bool + ProjectionPruning ProjectionPruningApplication LeftNode *BoundIdentifier LeftNodeBound bool UseExpandInto bool diff --git a/cypher/models/pgsql/translate/traversal.go b/cypher/models/pgsql/translate/traversal.go index 3da01fab..2e02e250 100644 --- a/cypher/models/pgsql/translate/traversal.go +++ b/cypher/models/pgsql/translate/traversal.go @@ -36,6 +36,8 @@ func (s *Translator) shouldUseExpandInto(part *PatternPart, stepIndex int, trave if _, hasDecision := s.expandIntoDecisions[part.Target.TraversalStep(stepIndex)]; hasDecision { return true } + + return false } return true @@ -602,6 +604,8 @@ func (s *Translator) translateTraversalPatternPart(part *PatternPart, isolatedPr s.recordLowering(optimize.LoweringExpandIntoDetection) } + s.prepareProjectionPruning(part, idx, traversalStep) + if traversalStepFrame, err := s.scope.PushFrame(); err != nil { return err } else { @@ -643,18 +647,6 @@ func (s *Translator) applyExpansionSuffixPushdown(part *PatternPart) (int, error target := part.Target.TraversalStep(stepIndex) decisions := s.suffixPushdownDecisions[target] if len(decisions) == 0 { - if stepIndex+1 >= len(part.TraversalSteps) { - continue - } - - currentStep := part.TraversalSteps[stepIndex] - suffixSteps := part.TraversalSteps[stepIndex+1:] - if candidateApplied, err := applyExpansionSuffixPushdownCandidate(currentStep, suffixSteps); err != nil { - return applied, err - } else if candidateApplied { - applied++ - } - continue } @@ -711,6 +703,29 @@ func (s *Translator) projectionPruningDecision(part *PatternPart, stepIndex int) return decision, hasDecision } +func (s *Translator) prepareProjectionPruning(part *PatternPart, stepIndex int, traversalStep *TraversalStep) { + decision, hasDecision := s.projectionPruningDecision(part, stepIndex) + if !hasDecision || traversalStep == nil { + return + } + + if decision.OmitLeftNode { + traversalStep.ProjectionPruning.LeftNode = traversalStep.LeftNode + } + + if decision.OmitRelationship { + traversalStep.ProjectionPruning.Relationship = traversalStep.Edge + } + + if decision.OmitRightNode { + traversalStep.ProjectionPruning.RightNode = traversalStep.RightNode + } + + if decision.OmitPathBinding && traversalStep.Expansion != nil { + traversalStep.ProjectionPruning.PathBinding = traversalStep.Expansion.PathBinding + } +} + func (s *Translator) latePathMaterializationDecision(part *PatternPart, stepIndex int, mode optimize.LatePathMaterializationMode) (optimize.LatePathMaterializationDecision, bool) { if part == nil || !part.HasTarget { return optimize.LatePathMaterializationDecision{}, false @@ -771,22 +786,39 @@ func unexportFrameBinding(frame *Frame, identifier pgsql.Identifier) bool { return exported } +func traversalStepBindingBound(traversalStep *TraversalStep, binding *BoundIdentifier) bool { + if traversalStep == nil || binding == nil { + return false + } + + if traversalStep.LeftNode == binding { + return traversalStep.LeftNodeBound + } + + if traversalStep.RightNode == binding { + return traversalStep.RightNodeBound + } + + return false +} + +func unexportPrunedNodeBinding(traversalStep *TraversalStep, binding *BoundIdentifier) bool { + if binding == nil || traversalStepBindingBound(traversalStep, binding) { + return false + } + + return unexportFrameBinding(traversalStep.Frame, binding.Identifier) +} + func pruneTraversalStepProjectionExports(queryPart *QueryPart, part *PatternPart, stepIndex int, traversalStep *TraversalStep, decision optimize.ProjectionPruningDecision, hasDecision bool, allowFallback bool) bool { var applied bool if hasDecision { - if decision.OmitLeftNode && traversalStep.LeftNode != nil && !traversalStep.LeftNodeBound { - applied = unexportFrameBinding(traversalStep.Frame, traversalStep.LeftNode.Identifier) || applied + applied = unexportPrunedNodeBinding(traversalStep, traversalStep.ProjectionPruning.LeftNode) || applied + if traversalStep.ProjectionPruning.Relationship != nil { + applied = unexportFrameBinding(traversalStep.Frame, traversalStep.ProjectionPruning.Relationship.Identifier) || applied } - - if decision.OmitRelationship && traversalStep.Edge != nil { - applied = unexportFrameBinding(traversalStep.Frame, traversalStep.Edge.Identifier) || applied - } - - if decision.OmitRightNode && traversalStep.RightNode != nil && !traversalStep.RightNodeBound { - applied = unexportFrameBinding(traversalStep.Frame, traversalStep.RightNode.Identifier) || applied - } - + applied = unexportPrunedNodeBinding(traversalStep, traversalStep.ProjectionPruning.RightNode) || applied return applied } @@ -826,12 +858,12 @@ func pruneExpansionStepProjectionExports(queryPart *QueryPart, part *PatternPart var applied bool if hasDecision { - if decision.OmitRelationship && traversalStep.Edge != nil { - applied = unexportFrameBinding(traversalStep.Frame, traversalStep.Edge.Identifier) || applied + if traversalStep.ProjectionPruning.Relationship != nil { + applied = unexportFrameBinding(traversalStep.Frame, traversalStep.ProjectionPruning.Relationship.Identifier) || applied } - if decision.OmitPathBinding && traversalStep.Expansion.PathBinding != nil { - applied = unexportFrameBinding(traversalStep.Frame, traversalStep.Expansion.PathBinding.Identifier) || applied + if traversalStep.ProjectionPruning.PathBinding != nil { + applied = unexportFrameBinding(traversalStep.Frame, traversalStep.ProjectionPruning.PathBinding.Identifier) || applied } return applied @@ -940,7 +972,7 @@ func (s *Translator) translateTraversalPatternPartWithoutExpansion(part *Pattern } decision, hasDecision := s.projectionPruningDecision(part, stepIndex) - allowFallback := !hasDecision + allowFallback := !hasDecision && (part == nil || !part.HasTarget) if pruneTraversalStepProjectionExports(s.query.CurrentPart(), part, stepIndex, traversalStep, decision, hasDecision, allowFallback) { s.recordLowering(optimize.LoweringProjectionPruning) } From 081358d5f335992742024e0d4e097cb5cde7c959 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Thu, 21 May 2026 20:49:14 -0700 Subject: [PATCH 041/116] Plan expand-into for anonymous continuations --- cypher/models/pgsql/optimize/lowering_plan.go | 11 +++++----- .../models/pgsql/optimize/optimizer_test.go | 22 +++++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/cypher/models/pgsql/optimize/lowering_plan.go b/cypher/models/pgsql/optimize/lowering_plan.go index c86470fc..16d71bd3 100644 --- a/cypher/models/pgsql/optimize/lowering_plan.go +++ b/cypher/models/pgsql/optimize/lowering_plan.go @@ -176,15 +176,14 @@ func appendExpandIntoDecisions(plan *LoweringPlan, queryPartIndex int, readingCl leftSymbol := variableSymbol(step.LeftNode.Variable) rightSymbol := variableSymbol(step.RightNode.Variable) - if leftSymbol == "" || rightSymbol == "" { - continue - } + _, leftBound := declaredEndpoints[stepIndex].BeforeLeftNode[leftSymbol] + _, rightBound := declaredEndpoints[stepIndex].BeforeRightNode[rightSymbol] - if _, leftBound := declaredEndpoints[stepIndex].BeforeLeftNode[leftSymbol]; !leftBound { - continue + if leftSymbol == "" { + leftBound = stepIndex > 0 } - if _, rightBound := declaredEndpoints[stepIndex].BeforeRightNode[rightSymbol]; !rightBound { + if rightSymbol == "" || !leftBound || !rightBound { continue } diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index cc1780c4..c3efd4d1 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -228,6 +228,28 @@ func TestLoweringPlanReportsExpandInto(t *testing.T) { }}, plan.LoweringPlan.ExpandInto) } +func TestLoweringPlanReportsExpandIntoForAnonymousContinuationEndpoint(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH (d:Domain) + MATCH p = (ca:EnterpriseCA)-[:IssuedSignedBy|EnterpriseCAFor*1..]->(:RootCA)-[:RootCAFor]->(d) + RETURN p + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Contains(t, plan.LoweringPlan.ExpandInto, ExpandIntoDecision{ + Target: TraversalStepTarget{ + QueryPartIndex: 0, + ClauseIndex: 1, + PatternIndex: 0, + StepIndex: 1, + }, + }) +} + func TestLoweringPlanSkipsDirectionlessExpansionSuffixPushdown(t *testing.T) { t.Parallel() From 1510c1881de5c245fa0faaf317fa9c13d3f5b7c5 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Thu, 21 May 2026 22:38:24 -0700 Subject: [PATCH 042/116] Lift rewrite decisions into optimizer plan --- cypher/models/cypher/copy.go | 3 + cypher/models/cypher/copy_test.go | 1 + cypher/models/cypher/model.go | 10 + cypher/models/pgsql/optimize/lowering.go | 60 +++ cypher/models/pgsql/optimize/lowering_plan.go | 433 +++++++++++++++++- .../models/pgsql/optimize/optimizer_test.go | 246 ++++++++++ .../pgsql/test/translation_cases/create.sql | 2 +- .../pgsql/test/translation_cases/delete.sql | 3 +- .../test/translation_cases/multipart.sql | 24 +- .../pgsql/test/translation_cases/nodes.sql | 2 +- .../translation_cases/pattern_binding.sql | 52 +-- .../translation_cases/pattern_expansion.sql | 30 +- .../translation_cases/pattern_rewriting.sql | 1 - .../test/translation_cases/quantifiers.sql | 1 + .../test/translation_cases/shortest_paths.sql | 38 +- .../translation_cases/stepwise_traversal.sql | 12 +- .../pgsql/test/translation_cases/update.sql | 2 +- cypher/models/pgsql/translate/constraints.go | 13 +- cypher/models/pgsql/translate/expansion.go | 400 ++++++++++++++-- .../pgsql/translate/limit_pushdown_test.go | 1 + cypher/models/pgsql/translate/model.go | 40 ++ .../pgsql/translate/optimizer_safety_test.go | 140 +++++- .../models/pgsql/translate/path_functions.go | 18 + cypher/models/pgsql/translate/pattern.go | 6 +- cypher/models/pgsql/translate/projection.go | 85 +++- cypher/models/pgsql/translate/translator.go | 41 +- cypher/models/pgsql/translate/traversal.go | 214 ++++++++- cypher/models/pgsql/translate/with.go | 8 + cypher/models/walk/walk_pgsql.go | 12 + .../testdata/cases/aggregation_inline.json | 19 + .../testdata/cases/expansion_inline.json | 70 +++ .../testdata/cases/multipart_inline.json | 34 ++ .../testdata/cases/optimizer_inline.json | 136 ++++++ integration/testdata/cases/unwind_inline.json | 18 + 34 files changed, 2012 insertions(+), 163 deletions(-) diff --git a/cypher/models/cypher/copy.go b/cypher/models/cypher/copy.go index d08b7c6c..47cbb6d8 100644 --- a/cypher/models/cypher/copy.go +++ b/cypher/models/cypher/copy.go @@ -53,6 +53,9 @@ func Copy[T any](value T, extensions ...CopyExtension[T]) T { case *Quantifier: return any(typedValue.copy()).(T) + case *RangeQuantifier: + return any(typedValue.copy()).(T) + case *Where: return any(typedValue.copy()).(T) diff --git a/cypher/models/cypher/copy_test.go b/cypher/models/cypher/copy_test.go index d6d2b7fa..ee65ff2a 100644 --- a/cypher/models/cypher/copy_test.go +++ b/cypher/models/cypher/copy_test.go @@ -63,6 +63,7 @@ func TestCopy(t *testing.T) { validateCopy(t, &model2.IDInCollection{}) validateCopy(t, &model2.FilterExpression{}) validateCopy(t, &model2.Quantifier{}) + validateCopy(t, &model2.RangeQuantifier{Value: "*"}) validateCopy(t, &model2.MultiPartQueryPart{}) validateCopy(t, &model2.Remove{}) diff --git a/cypher/models/cypher/model.go b/cypher/models/cypher/model.go index b6ef80bf..b3a59b77 100644 --- a/cypher/models/cypher/model.go +++ b/cypher/models/cypher/model.go @@ -743,6 +743,16 @@ func NewRangeQuantifier(value string) *RangeQuantifier { } } +func (s *RangeQuantifier) copy() *RangeQuantifier { + if s == nil { + return s + } + + return &RangeQuantifier{ + Value: s.Value, + } +} + type KindMatcher struct { Reference Expression Kinds graph.Kinds diff --git a/cypher/models/pgsql/optimize/lowering.go b/cypher/models/pgsql/optimize/lowering.go index 1706f9ad..be5083e7 100644 --- a/cypher/models/pgsql/optimize/lowering.go +++ b/cypher/models/pgsql/optimize/lowering.go @@ -6,6 +6,10 @@ const ( LoweringProjectionPruning = "ProjectionPruning" LoweringLatePathMaterialization = "LatePathMaterialization" LoweringExpandIntoDetection = "ExpandIntoDetection" + LoweringTraversalDirection = "TraversalDirectionSelection" + LoweringShortestPathStrategy = "ShortestPathStrategySelection" + LoweringShortestPathFilter = "ShortestPathFilterMaterialization" + LoweringLimitPushdown = "LimitPushdown" LoweringExpansionSuffixPushdown = "ExpansionSuffixPushdown" LoweringPredicatePlacement = "PredicatePlacement" ) @@ -63,6 +67,50 @@ type ExpandIntoDecision struct { Target TraversalStepTarget `json:"target"` } +type TraversalDirectionDecision struct { + Target TraversalStepTarget `json:"target"` + Flip bool `json:"flip,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type ShortestPathStrategy string + +const ( + ShortestPathStrategyBidirectional ShortestPathStrategy = "bidirectional" + ShortestPathStrategyUnidirectional ShortestPathStrategy = "unidirectional" +) + +type ShortestPathStrategyDecision struct { + Target TraversalStepTarget `json:"target"` + Strategy ShortestPathStrategy `json:"strategy"` + Reason string `json:"reason,omitempty"` +} + +type ShortestPathFilterMode string + +const ( + ShortestPathFilterTerminal ShortestPathFilterMode = "terminal" + ShortestPathFilterEndpointPair ShortestPathFilterMode = "endpoint_pair" +) + +type ShortestPathFilterDecision struct { + Target TraversalStepTarget `json:"target"` + Mode ShortestPathFilterMode `json:"mode"` + Reason string `json:"reason,omitempty"` +} + +type LimitPushdownMode string + +const ( + LimitPushdownTraversalCTE LimitPushdownMode = "traversal_cte" + LimitPushdownShortestPathHarness LimitPushdownMode = "shortest_path_harness" +) + +type LimitPushdownDecision struct { + Target TraversalStepTarget `json:"target"` + Mode LimitPushdownMode `json:"mode"` +} + type ExpansionSuffixPushdownDecision struct { Target TraversalStepTarget `json:"target"` SuffixLength int `json:"suffix_length"` @@ -81,6 +129,10 @@ type LoweringPlan struct { ProjectionPruning []ProjectionPruningDecision `json:"projection_pruning,omitempty"` LatePathMaterialization []LatePathMaterializationDecision `json:"late_path_materialization,omitempty"` ExpandInto []ExpandIntoDecision `json:"expand_into,omitempty"` + TraversalDirection []TraversalDirectionDecision `json:"traversal_direction,omitempty"` + ShortestPathStrategy []ShortestPathStrategyDecision `json:"shortest_path_strategy,omitempty"` + ShortestPathFilter []ShortestPathFilterDecision `json:"shortest_path_filter,omitempty"` + LimitPushdown []LimitPushdownDecision `json:"limit_pushdown,omitempty"` ExpansionSuffixPushdown []ExpansionSuffixPushdownDecision `json:"expansion_suffix_pushdown,omitempty"` PredicatePlacement []PredicatePlacementDecision `json:"predicate_placement,omitempty"` } @@ -89,6 +141,10 @@ func (s LoweringPlan) Empty() bool { return len(s.ProjectionPruning) == 0 && len(s.LatePathMaterialization) == 0 && len(s.ExpandInto) == 0 && + len(s.TraversalDirection) == 0 && + len(s.ShortestPathStrategy) == 0 && + len(s.ShortestPathFilter) == 0 && + len(s.LimitPushdown) == 0 && len(s.ExpansionSuffixPushdown) == 0 && len(s.PredicatePlacement) == 0 } @@ -104,6 +160,10 @@ func (s LoweringPlan) Decisions() []LoweringDecision { add(LoweringProjectionPruning, len(s.ProjectionPruning) > 0) add(LoweringLatePathMaterialization, len(s.LatePathMaterialization) > 0) add(LoweringExpandIntoDetection, len(s.ExpandInto) > 0) + add(LoweringTraversalDirection, len(s.TraversalDirection) > 0) + add(LoweringShortestPathStrategy, len(s.ShortestPathStrategy) > 0) + add(LoweringShortestPathFilter, len(s.ShortestPathFilter) > 0) + add(LoweringLimitPushdown, len(s.LimitPushdown) > 0) add(LoweringExpansionSuffixPushdown, len(s.ExpansionSuffixPushdown) > 0) add(LoweringPredicatePlacement, len(s.PredicatePlacement) > 0) diff --git a/cypher/models/pgsql/optimize/lowering_plan.go b/cypher/models/pgsql/optimize/lowering_plan.go index 16d71bd3..457ba745 100644 --- a/cypher/models/pgsql/optimize/lowering_plan.go +++ b/cypher/models/pgsql/optimize/lowering_plan.go @@ -11,6 +11,17 @@ type sourceTraversalStep struct { RightNode *cypher.NodePattern } +const ( + traversalDirectionReasonRightBound = "right_bound" + traversalDirectionReasonRightConstrained = "right_constrained" + + shortestPathStrategyReasonBoundEndpointPairs = "bound_endpoint_pairs" + shortestPathStrategyReasonEndpointPredicates = "endpoint_predicates" + + shortestPathFilterReasonTerminalPredicate = "terminal_predicate" + shortestPathFilterReasonEndpointPairPredicates = "endpoint_pair_predicates" +) + func BuildLoweringPlan(query *cypher.RegularQuery, predicateAttachments []PredicateAttachment) (LoweringPlan, error) { if query == nil || query.SingleQuery == nil { return LoweringPlan{}, nil @@ -24,18 +35,18 @@ func BuildLoweringPlan(query *cypher.RegularQuery, predicateAttachments []Predic continue } - if err := appendQueryPartLowerings(&plan, queryPartIndex, part, part.ReadingClauses); err != nil { + if err := appendQueryPartLowerings(&plan, queryPartIndex, part, part.ReadingClauses, predicateAttachments); err != nil { return LoweringPlan{}, err } } if finalPart := query.SingleQuery.MultiPartQuery.SinglePartQuery; finalPart != nil { - if err := appendQueryPartLowerings(&plan, len(query.SingleQuery.MultiPartQuery.Parts), finalPart, finalPart.ReadingClauses); err != nil { + if err := appendQueryPartLowerings(&plan, len(query.SingleQuery.MultiPartQuery.Parts), finalPart, finalPart.ReadingClauses, predicateAttachments); err != nil { return LoweringPlan{}, err } } } else if singlePart := query.SingleQuery.SinglePartQuery; singlePart != nil { - if err := appendQueryPartLowerings(&plan, 0, singlePart, singlePart.ReadingClauses); err != nil { + if err := appendQueryPartLowerings(&plan, 0, singlePart, singlePart.ReadingClauses, predicateAttachments); err != nil { return LoweringPlan{}, err } } @@ -45,7 +56,13 @@ func BuildLoweringPlan(query *cypher.RegularQuery, predicateAttachments []Predic return plan, nil } -func appendQueryPartLowerings(plan *LoweringPlan, queryPartIndex int, queryPart cypher.SyntaxNode, readingClauses []*cypher.ReadingClause) error { +func appendQueryPartLowerings( + plan *LoweringPlan, + queryPartIndex int, + queryPart cypher.SyntaxNode, + readingClauses []*cypher.ReadingClause, + predicateAttachments []PredicateAttachment, +) error { sourceReferences, err := collectReferencedSourceIdentifiers(queryPart) if err != nil { return err @@ -54,6 +71,10 @@ func appendQueryPartLowerings(plan *LoweringPlan, queryPartIndex int, queryPart appendProjectionPruningDecisions(plan, queryPartIndex, readingClauses, sourceReferences) appendLatePathMaterializationDecisions(plan, queryPartIndex, readingClauses, sourceReferences) appendExpandIntoDecisions(plan, queryPartIndex, readingClauses) + appendTraversalDirectionDecisions(plan, queryPartIndex, readingClauses, bindingPredicateSymbols(predicateAttachments, queryPartIndex)) + appendShortestPathStrategyDecisions(plan, queryPartIndex, readingClauses, bindingPredicateSymbols(predicateAttachments, queryPartIndex)) + appendShortestPathFilterDecisions(plan, queryPartIndex, readingClauses, bindingPredicateSymbols(predicateAttachments, queryPartIndex)) + appendLimitPushdownDecisions(plan, queryPartIndex, queryPart, readingClauses) appendExpansionSuffixPushdownDecisions(plan, queryPartIndex, readingClauses) return nil } @@ -223,6 +244,365 @@ func declaredSymbolsBeforeStepEndpoints(initial map[string]struct{}, steps []sou return endpoints } +func appendTraversalDirectionDecisions(plan *LoweringPlan, queryPartIndex int, readingClauses []*cypher.ReadingClause, predicateConstrainedSymbols map[string]struct{}) { + declaredSymbols := map[string]struct{}{} + + for clauseIndex, readingClause := range readingClauses { + if readingClause == nil || readingClause.Match == nil { + continue + } + + match := readingClause.Match + if match.Optional { + declareMatchSymbols(declaredSymbols, match) + continue + } + + for patternIndex, patternPart := range match.Pattern { + steps := traversalStepsForPattern(patternPart) + declaredEndpoints := declaredSymbolsBeforeStepEndpoints(declaredSymbols, steps) + patternTarget := PatternTarget{ + QueryPartIndex: queryPartIndex, + ClauseIndex: clauseIndex, + PatternIndex: patternIndex, + } + + for stepIndex, step := range steps { + if decision, shouldFlip := traversalDirectionDecisionForStep( + patternTarget.TraversalStep(stepIndex), + stepIndex, + step, + declaredEndpoints[stepIndex], + referencesSourceIdentifier(predicateConstrainedSymbols, variableSymbol(step.LeftNode.Variable)), + ); shouldFlip { + plan.TraversalDirection = append(plan.TraversalDirection, decision) + } + } + + declarePatternSymbols(declaredSymbols, patternPart) + } + + declareWhereSymbols(declaredSymbols, match) + } +} + +func bindingPredicateSymbols(predicateAttachments []PredicateAttachment, queryPartIndex int) map[string]struct{} { + symbols := map[string]struct{}{} + + for _, attachment := range predicateAttachments { + if attachment.QueryPartIndex != queryPartIndex { + continue + } + + for _, symbol := range attachment.BindingSymbols { + addSymbol(symbols, symbol) + } + } + + return symbols +} + +func traversalDirectionDecisionForStep( + target TraversalStepTarget, + stepIndex int, + step sourceTraversalStep, + declaredEndpoints declaredStepEndpoints, + leftHasAttachedPredicate bool, +) (TraversalDirectionDecision, bool) { + if leftEndpointBoundForStep(stepIndex, step, declaredEndpoints) { + return TraversalDirectionDecision{}, false + } + + rightSymbol := variableSymbol(step.RightNode.Variable) + if rightSymbol != "" { + if _, rightBound := declaredEndpoints.BeforeRightNode[rightSymbol]; rightBound { + if rightSymbol == variableSymbol(step.LeftNode.Variable) { + return TraversalDirectionDecision{}, false + } + + return TraversalDirectionDecision{ + Target: target, + Flip: true, + Reason: traversalDirectionReasonRightBound, + }, true + } + } + + if nodePatternHasConstraints(step.RightNode) && !nodePatternHasConstraints(step.LeftNode) && !leftHasAttachedPredicate { + return TraversalDirectionDecision{ + Target: target, + Flip: true, + Reason: traversalDirectionReasonRightConstrained, + }, true + } + + return TraversalDirectionDecision{}, false +} + +func appendShortestPathStrategyDecisions(plan *LoweringPlan, queryPartIndex int, readingClauses []*cypher.ReadingClause, predicateConstrainedSymbols map[string]struct{}) { + declaredSymbols := map[string]struct{}{} + + for clauseIndex, readingClause := range readingClauses { + if readingClause == nil || readingClause.Match == nil { + continue + } + + match := readingClause.Match + if match.Optional { + declareMatchSymbols(declaredSymbols, match) + continue + } + + for patternIndex, patternPart := range match.Pattern { + if patternPart == nil || (!patternPart.ShortestPathPattern && !patternPart.AllShortestPathsPattern) { + declarePatternSymbols(declaredSymbols, patternPart) + continue + } + + steps := traversalStepsForPattern(patternPart) + declaredEndpoints := declaredSymbolsBeforeStepEndpoints(declaredSymbols, steps) + patternTarget := PatternTarget{ + QueryPartIndex: queryPartIndex, + ClauseIndex: clauseIndex, + PatternIndex: patternIndex, + } + + for stepIndex, step := range steps { + if step.Relationship.Range == nil { + continue + } + + if decision, shouldPlan := shortestPathStrategyDecisionForStep( + patternTarget.TraversalStep(stepIndex), + step, + declaredEndpoints[stepIndex], + predicateConstrainedSymbols, + ); shouldPlan { + plan.ShortestPathStrategy = append(plan.ShortestPathStrategy, decision) + } + } + + declarePatternSymbols(declaredSymbols, patternPart) + } + + declareWhereSymbols(declaredSymbols, match) + } +} + +func shortestPathStrategyDecisionForStep( + target TraversalStepTarget, + step sourceTraversalStep, + declaredEndpoints declaredStepEndpoints, + predicateConstrainedSymbols map[string]struct{}, +) (ShortestPathStrategyDecision, bool) { + leftSymbol := variableSymbol(step.LeftNode.Variable) + rightSymbol := variableSymbol(step.RightNode.Variable) + + _, rightBound := declaredEndpoints.BeforeRightNode[rightSymbol] + if leftEndpointBoundForStep(target.StepIndex, step, declaredEndpoints) && rightSymbol != "" && rightBound { + return ShortestPathStrategyDecision{ + Target: target, + Strategy: ShortestPathStrategyBidirectional, + Reason: shortestPathStrategyReasonBoundEndpointPairs, + }, true + } + + if endpointHasSearchConstraint(step.LeftNode, leftSymbol, predicateConstrainedSymbols) && + endpointHasSearchConstraint(step.RightNode, rightSymbol, predicateConstrainedSymbols) { + return ShortestPathStrategyDecision{ + Target: target, + Strategy: ShortestPathStrategyBidirectional, + Reason: shortestPathStrategyReasonEndpointPredicates, + }, true + } + + return ShortestPathStrategyDecision{}, false +} + +func endpointHasSearchConstraint(nodePattern *cypher.NodePattern, symbol string, predicateConstrainedSymbols map[string]struct{}) bool { + if nodePattern == nil { + return false + } + + return nodePattern.Properties != nil || referencesSourceIdentifier(predicateConstrainedSymbols, symbol) +} + +func appendShortestPathFilterDecisions(plan *LoweringPlan, queryPartIndex int, readingClauses []*cypher.ReadingClause, predicateConstrainedSymbols map[string]struct{}) { + declaredSymbols := map[string]struct{}{} + + for clauseIndex, readingClause := range readingClauses { + if readingClause == nil || readingClause.Match == nil { + continue + } + + match := readingClause.Match + if match.Optional { + declareMatchSymbols(declaredSymbols, match) + continue + } + + for patternIndex, patternPart := range match.Pattern { + if patternPart == nil || (!patternPart.ShortestPathPattern && !patternPart.AllShortestPathsPattern) { + declarePatternSymbols(declaredSymbols, patternPart) + continue + } + + steps := traversalStepsForPattern(patternPart) + declaredEndpoints := declaredSymbolsBeforeStepEndpoints(declaredSymbols, steps) + patternTarget := PatternTarget{ + QueryPartIndex: queryPartIndex, + ClauseIndex: clauseIndex, + PatternIndex: patternIndex, + } + + for stepIndex, step := range steps { + if step.Relationship.Range == nil { + continue + } + + if decision, shouldPlan := shortestPathFilterDecisionForStep( + plan, + patternTarget.TraversalStep(stepIndex), + step, + declaredEndpoints[stepIndex], + predicateConstrainedSymbols, + ); shouldPlan { + plan.ShortestPathFilter = append(plan.ShortestPathFilter, decision) + } + } + + declarePatternSymbols(declaredSymbols, patternPart) + } + + declareWhereSymbols(declaredSymbols, match) + } +} + +func shortestPathFilterDecisionForStep( + plan *LoweringPlan, + target TraversalStepTarget, + step sourceTraversalStep, + declaredEndpoints declaredStepEndpoints, + predicateConstrainedSymbols map[string]struct{}, +) (ShortestPathFilterDecision, bool) { + leftSymbol := variableSymbol(step.LeftNode.Variable) + rightSymbol := variableSymbol(step.RightNode.Variable) + if rightSymbol != "" { + if _, rightBound := declaredEndpoints.BeforeRightNode[rightSymbol]; rightBound { + return ShortestPathFilterDecision{}, false + } + } + + leftSearchConstrained := endpointHasSearchConstraint(step.LeftNode, leftSymbol, predicateConstrainedSymbols) + rightSearchConstrained := endpointHasSearchConstraint(step.RightNode, rightSymbol, predicateConstrainedSymbols) + if !rightSearchConstrained { + return ShortestPathFilterDecision{}, false + } + + if hasShortestPathBidirectionalStrategy(plan, target) && leftSearchConstrained { + return ShortestPathFilterDecision{ + Target: target, + Mode: ShortestPathFilterEndpointPair, + Reason: shortestPathFilterReasonEndpointPairPredicates, + }, true + } + + return ShortestPathFilterDecision{ + Target: target, + Mode: ShortestPathFilterTerminal, + Reason: shortestPathFilterReasonTerminalPredicate, + }, true +} + +func hasShortestPathBidirectionalStrategy(plan *LoweringPlan, target TraversalStepTarget) bool { + if plan == nil { + return false + } + + for _, decision := range plan.ShortestPathStrategy { + if decision.Target == target && decision.Strategy == ShortestPathStrategyBidirectional { + return true + } + } + + return false +} + +func appendLimitPushdownDecisions(plan *LoweringPlan, queryPartIndex int, queryPart cypher.SyntaxNode, readingClauses []*cypher.ReadingClause) { + if !queryPartAllowsLimitPushdown(queryPart, readingClauses) { + return + } + + for clauseIndex, readingClause := range readingClauses { + if readingClause == nil || readingClause.Match == nil { + continue + } + + for patternIndex, patternPart := range readingClause.Match.Pattern { + if patternPart == nil { + continue + } + if patternPart.AllShortestPathsPattern { + continue + } + + patternTarget := PatternTarget{ + QueryPartIndex: queryPartIndex, + ClauseIndex: clauseIndex, + PatternIndex: patternIndex, + } + + for stepIndex, step := range traversalStepsForPattern(patternPart) { + mode := LimitPushdownTraversalCTE + if patternPart.ShortestPathPattern && step.Relationship.Range != nil { + mode = LimitPushdownShortestPathHarness + } + + plan.LimitPushdown = append(plan.LimitPushdown, LimitPushdownDecision{ + Target: patternTarget.TraversalStep(stepIndex), + Mode: mode, + }) + } + } + } +} + +func queryPartAllowsLimitPushdown(queryPart cypher.SyntaxNode, readingClauses []*cypher.ReadingClause) bool { + projection, updatingClauseCount := queryPartProjection(queryPart) + if projection == nil || + projection.Limit == nil || + projection.Skip != nil || + projection.Order != nil || + projection.Distinct || + len(readingClauses) != 1 || + updatingClauseCount > 0 { + return false + } + + return true +} + +func queryPartProjection(queryPart cypher.SyntaxNode) (*cypher.Projection, int) { + switch typedQueryPart := queryPart.(type) { + case *cypher.SinglePartQuery: + if typedQueryPart.Return == nil { + return nil, len(typedQueryPart.UpdatingClauses) + } + + return typedQueryPart.Return.Projection, len(typedQueryPart.UpdatingClauses) + + case *cypher.MultiPartQueryPart: + if typedQueryPart.With == nil { + return nil, len(typedQueryPart.UpdatingClauses) + } + + return typedQueryPart.With.Projection, len(typedQueryPart.UpdatingClauses) + + default: + return nil, 0 + } +} + func appendExpansionSuffixPushdownDecisions(plan *LoweringPlan, queryPartIndex int, readingClauses []*cypher.ReadingClause) { declaredSymbols := map[string]struct{}{} @@ -240,19 +620,25 @@ func appendExpansionSuffixPushdownDecisions(plan *LoweringPlan, queryPartIndex i for patternIndex, patternPart := range match.Pattern { steps := traversalStepsForPattern(patternPart) declaredBeforeRightNode := declaredSymbolsBeforeRightNodes(declaredSymbols, steps) + declaredEndpoints := declaredSymbolsBeforeStepEndpoints(declaredSymbols, steps) for stepIndex, step := range steps { if step.Relationship.Range == nil || stepIndex+1 >= len(steps) { continue } + target := PatternTarget{ + QueryPartIndex: queryPartIndex, + ClauseIndex: clauseIndex, + PatternIndex: patternIndex, + }.TraversalStep(stepIndex) + if hasTraversalDirectionFlip(plan, target) || expansionStepMayFlipForConstraintBalance(stepIndex, step, declaredEndpoints[stepIndex]) { + continue + } + if suffixLength := expansionSuffixPushdownLength(steps[stepIndex+1:], declaredBeforeRightNode[stepIndex+1:]); suffixLength > 0 { plan.ExpansionSuffixPushdown = append(plan.ExpansionSuffixPushdown, ExpansionSuffixPushdownDecision{ - Target: PatternTarget{ - QueryPartIndex: queryPartIndex, - ClauseIndex: clauseIndex, - PatternIndex: patternIndex, - }.TraversalStep(stepIndex), + Target: target, SuffixLength: suffixLength, SuffixStartStep: stepIndex + 1, SuffixEndStep: stepIndex + suffixLength, @@ -267,6 +653,35 @@ func appendExpansionSuffixPushdownDecisions(plan *LoweringPlan, queryPartIndex i } } +func expansionStepMayFlipForConstraintBalance(stepIndex int, step sourceTraversalStep, declaredEndpoints declaredStepEndpoints) bool { + _, mayFlip := traversalDirectionDecisionForStep(TraversalStepTarget{}, stepIndex, step, declaredEndpoints, false) + return mayFlip +} + +func leftEndpointBoundForStep(stepIndex int, step sourceTraversalStep, declaredEndpoints declaredStepEndpoints) bool { + leftSymbol := variableSymbol(step.LeftNode.Variable) + if leftSymbol == "" { + return stepIndex > 0 + } + + _, leftBound := declaredEndpoints.BeforeLeftNode[leftSymbol] + return leftBound +} + +func hasTraversalDirectionFlip(plan *LoweringPlan, target TraversalStepTarget) bool { + if plan == nil { + return false + } + + for _, decision := range plan.TraversalDirection { + if decision.Target == target && decision.Flip { + return true + } + } + + return false +} + type bindingTargetKey struct { QueryPartIndex int Symbol string diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index c3efd4d1..9e6fa5b5 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -250,6 +250,252 @@ func TestLoweringPlanReportsExpandIntoForAnonymousContinuationEndpoint(t *testin }) } +func TestLoweringPlanReportsTraversalDirectionForConstrainedRightEndpoint(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH p = (n)-[:MemberOf*1..]->(ca:EnterpriseCA) + RETURN p + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Contains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringTraversalDirection}) + require.Equal(t, []TraversalDirectionDecision{{ + Target: TraversalStepTarget{ + QueryPartIndex: 0, + ClauseIndex: 0, + PatternIndex: 0, + StepIndex: 0, + }, + Flip: true, + Reason: traversalDirectionReasonRightConstrained, + }}, plan.LoweringPlan.TraversalDirection) +} + +func TestLoweringPlanReportsTraversalDirectionForBoundRightEndpoint(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH (ca:EnterpriseCA) + MATCH p = (n)-[:MemberOf*1..]->(ca) + RETURN p + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Contains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringTraversalDirection}) + require.Equal(t, []TraversalDirectionDecision{{ + Target: TraversalStepTarget{ + QueryPartIndex: 0, + ClauseIndex: 1, + PatternIndex: 0, + StepIndex: 0, + }, + Flip: true, + Reason: traversalDirectionReasonRightBound, + }}, plan.LoweringPlan.TraversalDirection) +} + +func TestLoweringPlanSkipsTraversalDirectionWhenLeftEndpointHasBindingPredicate(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH p = (n)-[:MemberOf*1..]->(ca:EnterpriseCA) + WHERE n.name = 'target' + RETURN p + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Empty(t, plan.LoweringPlan.TraversalDirection) +} + +func TestLoweringPlanSkipsTraversalDirectionWhenLeftEndpointHasRegionPredicate(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + WITH 'target' AS name + MATCH p = (n)-[:MemberOf]->(ca:EnterpriseCA) + WHERE n.name STARTS WITH name + RETURN p + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Empty(t, plan.LoweringPlan.TraversalDirection) +} + +func TestLoweringPlanReportsShortestPathStrategyForEndpointPredicates(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH p = allShortestPaths((s)-[:MemberOf*1..]->(e)) + WHERE s.name = 'source' AND e.name = 'target' + RETURN p + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Contains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringShortestPathStrategy}) + require.Equal(t, []ShortestPathStrategyDecision{{ + Target: TraversalStepTarget{ + QueryPartIndex: 0, + ClauseIndex: 0, + PatternIndex: 0, + StepIndex: 0, + }, + Strategy: ShortestPathStrategyBidirectional, + Reason: shortestPathStrategyReasonEndpointPredicates, + }}, plan.LoweringPlan.ShortestPathStrategy) + require.Equal(t, []ShortestPathFilterDecision{{ + Target: TraversalStepTarget{ + QueryPartIndex: 0, + ClauseIndex: 0, + PatternIndex: 0, + StepIndex: 0, + }, + Mode: ShortestPathFilterEndpointPair, + Reason: shortestPathFilterReasonEndpointPairPredicates, + }}, plan.LoweringPlan.ShortestPathFilter) +} + +func TestLoweringPlanReportsShortestPathStrategyForBoundEndpointPairs(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH (a:Group) + MATCH (b:EnterpriseCA) + MATCH p = shortestPath((a)-[:MemberOf*1..]->(b)) + RETURN p + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Contains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringShortestPathStrategy}) + require.Equal(t, []ShortestPathStrategyDecision{{ + Target: TraversalStepTarget{ + QueryPartIndex: 0, + ClauseIndex: 2, + PatternIndex: 0, + StepIndex: 0, + }, + Strategy: ShortestPathStrategyBidirectional, + Reason: shortestPathStrategyReasonBoundEndpointPairs, + }}, plan.LoweringPlan.ShortestPathStrategy) +} + +func TestLoweringPlanSkipsShortestPathStrategyForLabelOnlyEndpoints(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH p = allShortestPaths((s:Group)-[:MemberOf*1..]->(e:EnterpriseCA)) + RETURN p + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Empty(t, plan.LoweringPlan.ShortestPathStrategy) +} + +func TestLoweringPlanReportsShortestPathTerminalFilter(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH (s:Group {name: 'source'}) + MATCH p = shortestPath((s)-[:MemberOf*1..]->(e)) + WHERE e.name = 'target' + RETURN p + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Contains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringShortestPathFilter}) + require.Equal(t, []ShortestPathFilterDecision{{ + Target: TraversalStepTarget{ + QueryPartIndex: 0, + ClauseIndex: 1, + PatternIndex: 0, + StepIndex: 0, + }, + Mode: ShortestPathFilterTerminal, + Reason: shortestPathFilterReasonTerminalPredicate, + }}, plan.LoweringPlan.ShortestPathFilter) +} + +func TestLoweringPlanReportsTraversalLimitPushdown(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH p = (n:Group)-[:MemberOf]->(m:Group) + RETURN p + LIMIT 1 + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Contains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringLimitPushdown}) + require.Equal(t, []LimitPushdownDecision{{ + Target: TraversalStepTarget{ + QueryPartIndex: 0, + ClauseIndex: 0, + PatternIndex: 0, + StepIndex: 0, + }, + Mode: LimitPushdownTraversalCTE, + }}, plan.LoweringPlan.LimitPushdown) +} + +func TestLoweringPlanReportsShortestPathLimitPushdown(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH p = shortestPath((s)-[:MemberOf*1..]->(e)) + WHERE s.name = 'source' AND e.name = 'target' + RETURN p + LIMIT 1 + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Contains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringLimitPushdown}) + require.Contains(t, plan.LoweringPlan.LimitPushdown, LimitPushdownDecision{ + Target: TraversalStepTarget{ + QueryPartIndex: 0, + ClauseIndex: 0, + PatternIndex: 0, + StepIndex: 0, + }, + Mode: LimitPushdownShortestPathHarness, + }) +} + +func TestLoweringPlanSkipsAllShortestPathLimitPushdown(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH p = allShortestPaths((s)-[:MemberOf*1..]->(e)) + WHERE s.name = 'source' AND e.name = 'target' + RETURN p + LIMIT 1 + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Empty(t, plan.LoweringPlan.LimitPushdown) +} + func TestLoweringPlanSkipsDirectionlessExpansionSuffixPushdown(t *testing.T) { t.Parallel() diff --git a/cypher/models/pgsql/test/translation_cases/create.sql b/cypher/models/pgsql/test/translation_cases/create.sql index b3fc65ce..1458421e 100644 --- a/cypher/models/pgsql/test/translation_cases/create.sql +++ b/cypher/models/pgsql/test/translation_cases/create.sql @@ -69,7 +69,7 @@ with s0 as (select nextval(pg_get_serial_sequence('node', 'id'))::int8 as n0_id) with s0 as (select nextval(pg_get_serial_sequence('node', 'id'))::int8 as n0_id), s1 as (insert into node (graph_id, id, kind_ids, properties) select 0, s0.n0_id, array [1]::int2[], jsonb_build_object('name', 'abc')::jsonb from s0 returning id as n0_id, (id, kind_ids, properties)::nodecomposite as n0), s2 as (select s1.n0 as n0 from s0, s1 where s1.n0_id = s0.n0_id), s3 as (select s2.n0 as n0, nextval(pg_get_serial_sequence('node', 'id'))::int8 as n1_id from s2), s4 as (insert into node (graph_id, id, kind_ids, properties) select 0, s3.n1_id, array [2]::int2[], jsonb_build_object('name', 'test')::jsonb from s3 returning id as n1_id, (id, kind_ids, properties)::nodecomposite as n1), s5 as (select s3.n0 as n0, s4.n1 as n1 from s3, s4 where s4.n1_id = s3.n1_id), s6 as (select s5.n0 as n0, s5.n1 as n1, nextval(pg_get_serial_sequence('node', 'id'))::int8 as n2_id from s5), s7 as (insert into node (graph_id, id, kind_ids, properties) select 0, s6.n2_id, array [1]::int2[], jsonb_build_object('name', 'other')::jsonb from s6 returning id as n2_id, (id, kind_ids, properties)::nodecomposite as n2), s8 as (select s6.n0 as n0, s6.n1 as n1, s7.n2 as n2 from s6, s7 where s7.n2_id = s6.n2_id), s9 as (select s8.n0 as n0, s8.n1 as n1, s8.n2 as n2, nextval(pg_get_serial_sequence('edge', 'id'))::int8 as e0_id from s8), s10 as (insert into edge (graph_id, id, start_id, end_id, kind_id, properties) select 0, s9.e0_id, (s9.n0).id, (s9.n1).id, 3, jsonb_build_object('prop', 123)::jsonb from s9 returning id as e0_id, (id, start_id, end_id, kind_id, properties)::edgecomposite as e0), s11 as (select s9.n0 as n0, s9.n1 as n1, s9.n2 as n2, s10.e0 as e0 from s9, s10 where s10.e0_id = s9.e0_id), s12 as (select s11.e0 as e0, s11.n0 as n0, s11.n1 as n1, s11.n2 as n2, nextval(pg_get_serial_sequence('edge', 'id'))::int8 as e1_id from s11), s13 as (insert into edge (graph_id, id, start_id, end_id, kind_id, properties) select 0, s12.e1_id, (s12.n2).id, (s12.n1).id, 4, jsonb_build_object()::jsonb from s12 returning id as e1_id, (id, start_id, end_id, kind_id, properties)::edgecomposite as e1), s14 as (select s12.e0 as e0, s12.n0 as n0, s12.n1 as n1, s12.n2 as n2, s13.e1 as e1 from s12, s13 where s13.e1_id = s12.e1_id) select s14.n1 as c from s14; -- case: create p = (:NodeKind1 {name: 'abc'})-[:EdgeKind1 {prop: 123}]->(:NodeKind2 {name: 'test'}) return p -with s0 as (select nextval(pg_get_serial_sequence('node', 'id'))::int8 as n0_id), s1 as (insert into node (graph_id, id, kind_ids, properties) select 0, s0.n0_id, array [1]::int2[], jsonb_build_object('name', 'abc')::jsonb from s0 returning id as n0_id, (id, kind_ids, properties)::nodecomposite as n0), s2 as (select s1.n0 as n0 from s0, s1 where s1.n0_id = s0.n0_id), s3 as (select s2.n0 as n0, nextval(pg_get_serial_sequence('node', 'id'))::int8 as n1_id from s2), s4 as (insert into node (graph_id, id, kind_ids, properties) select 0, s3.n1_id, array [2]::int2[], jsonb_build_object('name', 'test')::jsonb from s3 returning id as n1_id, (id, kind_ids, properties)::nodecomposite as n1), s5 as (select s3.n0 as n0, s4.n1 as n1 from s3, s4 where s4.n1_id = s3.n1_id), s6 as (select s5.n0 as n0, s5.n1 as n1, nextval(pg_get_serial_sequence('edge', 'id'))::int8 as e0_id from s5), s7 as (insert into edge (graph_id, id, start_id, end_id, kind_id, properties) select 0, s6.e0_id, (s6.n0).id, (s6.n1).id, 3, jsonb_build_object('prop', 123)::jsonb from s6 returning id as e0_id, (id, start_id, end_id, kind_id, properties)::edgecomposite as e0), s8 as (select s6.n0 as n0, s6.n1 as n1, s7.e0 as e0 from s6, s7 where s7.e0_id = s6.e0_id) select (array [s8.n0, s8.n1]::nodecomposite[], array [s8.e0]::edgecomposite[])::pathcomposite as p from s8; +with s0 as (select nextval(pg_get_serial_sequence('node', 'id'))::int8 as n0_id), s1 as (insert into node (graph_id, id, kind_ids, properties) select 0, s0.n0_id, array [1]::int2[], jsonb_build_object('name', 'abc')::jsonb from s0 returning id as n0_id, (id, kind_ids, properties)::nodecomposite as n0), s2 as (select s1.n0 as n0 from s0, s1 where s1.n0_id = s0.n0_id), s3 as (select s2.n0 as n0, nextval(pg_get_serial_sequence('node', 'id'))::int8 as n1_id from s2), s4 as (insert into node (graph_id, id, kind_ids, properties) select 0, s3.n1_id, array [2]::int2[], jsonb_build_object('name', 'test')::jsonb from s3 returning id as n1_id, (id, kind_ids, properties)::nodecomposite as n1), s5 as (select s3.n0 as n0, s4.n1 as n1 from s3, s4 where s4.n1_id = s3.n1_id), s6 as (select s5.n0 as n0, s5.n1 as n1, nextval(pg_get_serial_sequence('edge', 'id'))::int8 as e0_id from s5), s7 as (insert into edge (graph_id, id, start_id, end_id, kind_id, properties) select 0, s6.e0_id, (s6.n0).id, (s6.n1).id, 3, jsonb_build_object('prop', 123)::jsonb from s6 returning id as e0_id, (id, start_id, end_id, kind_id, properties)::edgecomposite as e0), s8 as (select s6.n0 as n0, s6.n1 as n1, s7.e0 as e0 from s6, s7 where s7.e0_id = s6.e0_id) select case when (s8.n0).id is null or (s8.e0).id is null or (s8.n1).id is null then null else (array [s8.n0, s8.n1]::nodecomposite[], array [s8.e0]::edgecomposite[])::pathcomposite end as p from s8; -- case: match (a:NodeKind1) with a create (b:NodeKind2 {source: a.name}) return a, b 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[]) select s1.n0 as n0 from s1), s2 as (select s0.n0 as n0, nextval(pg_get_serial_sequence('node', 'id'))::int8 as n1_id from s0), s3 as (insert into node (graph_id, id, kind_ids, properties) select 0, s2.n1_id, array [2]::int2[], jsonb_build_object('source', ((s2.n0).properties ->> 'name'))::jsonb from s2 returning id as n1_id, (id, kind_ids, properties)::nodecomposite as n1), s4 as (select s2.n0 as n0, s3.n1 as n1 from s2, s3 where s3.n1_id = s2.n1_id) select s4.n0 as a, s4.n1 as b from s4; diff --git a/cypher/models/pgsql/test/translation_cases/delete.sql b/cypher/models/pgsql/test/translation_cases/delete.sql index 540765f8..ab59796e 100644 --- a/cypher/models/pgsql/test/translation_cases/delete.sql +++ b/cypher/models/pgsql/test/translation_cases/delete.sql @@ -21,5 +21,4 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from 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 = any (array [3]::int2[])), s1 as (delete from edge e1 using s0 where (s0.e0).id = e1.id) select 1; -- case: match ()-[]->()-[r:EdgeKind1]->() delete r -with s0 as (select (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), s1 as (select (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.n1 as n1 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.kind_id = any (array [3]::int2[])), s2 as (delete from edge e2 using s1 where (s1.e1).id = e2.id) select 1; - +with s0 as (select e0.id as e0, (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), s1 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.n1 as n1 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.kind_id = any (array [3]::int2[]) and e1.id != s0.e0), s2 as (delete from edge e2 using s1 where (s1.e1).id = e2.id) select 1; diff --git a/cypher/models/pgsql/test/translation_cases/multipart.sql b/cypher/models/pgsql/test/translation_cases/multipart.sql index a9b601db..739dd00b 100644 --- a/cypher/models/pgsql/test/translation_cases/multipart.sql +++ b/cypher/models/pgsql/test/translation_cases/multipart.sql @@ -24,13 +24,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 node n0 where (((n0.properties -> 'value'))::jsonb = to_jsonb((1)::int8)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s1.n0 as n0 from s1), s2 as (with s3 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where (((n1.properties -> 'name'))::jsonb = to_jsonb(('me')::text)::jsonb)) select s3.n1 as n1 from s3), s4 as (select s2.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s2, node n2 where (n2.id = (s2.n1).id)) select s4.n2 as b from s4; -- case: match (n:NodeKind1)-[:EdgeKind1*1..]->(:NodeKind2)-[:EdgeKind2]->(m:NodeKind1) where (n:NodeKind1 or n:NodeKind2) and n.enabled = true with m, collect(distinct(n)) as p where size(p) >= 10 return m -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.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]) and ((n0.properties -> 'enabled'))::jsonb = to_jsonb((true)::bool)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(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[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::int2[])), e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::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.start_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select (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), s3 as (select 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.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[])) select s3.n2 as n2, array_remove(coalesce(array_agg(distinct (s3.n0))::nodecomposite[], array []::nodecomposite[])::nodecomposite[], null)::nodecomposite[] as i0 from s3 group by n2) select s0.n2 as m from s0 where (cardinality(s0.i0)::int >= 10); +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.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]) and ((n0.properties -> 'enabled'))::jsonb = to_jsonb((true)::bool)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(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[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::int2[])), e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::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.start_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select 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), s3 as (select s1.ep0 as ep0, 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.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[]) and e1.id != all (s1.ep0)) select s3.n2 as n2, array_remove(coalesce(array_agg(distinct (s3.n0))::nodecomposite[], array []::nodecomposite[])::nodecomposite[], null)::nodecomposite[] as i0 from s3 group by n2) select s0.n2 as m from s0 where (cardinality(s0.i0)::int >= 10); -- case: match (n:NodeKind1)-[:EdgeKind1*1..]->(:NodeKind2)-[:EdgeKind2]->(m:NodeKind1) where (n:NodeKind1 or n:NodeKind2) and n.enabled = true with m, count(distinct(n)) as p where p >= 10 return m -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.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]) and ((n0.properties -> 'enabled'))::jsonb = to_jsonb((true)::bool)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(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[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::int2[])), e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::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.start_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select (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), s3 as (select 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.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[])) select s3.n2 as n2, count(distinct (s3.n0))::int8 as i0 from s3 group by n2) select s0.n2 as m from s0 where (s0.i0 >= 10); +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.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]) and ((n0.properties -> 'enabled'))::jsonb = to_jsonb((true)::bool)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(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[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::int2[])), e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::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.start_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select 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), s3 as (select s1.ep0 as ep0, 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.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[]) and e1.id != all (s1.ep0)) select s3.n2 as n2, count(distinct (s3.n0))::int8 as i0 from s3 group by n2) select s0.n2 as m from s0 where (s0.i0 >= 10); -- case: match (n:NodeKind1)-[:EdgeKind1*1..]->(:NodeKind2)-[:EdgeKind2]->(m:NodeKind1) where (n:NodeKind1 or n:NodeKind2) and n.enabled = true with m, count(distinct(n)) as p where p >= 10 return m -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.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]) and ((n0.properties -> 'enabled'))::jsonb = to_jsonb((true)::bool)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(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[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::int2[])), e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::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.start_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select (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), s3 as (select 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.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[])) select s3.n2 as n2, count(distinct (s3.n0))::int8 as i0 from s3 group by n2) select s0.n2 as m from s0 where (s0.i0 >= 10); +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.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]) and ((n0.properties -> 'enabled'))::jsonb = to_jsonb((true)::bool)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(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[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::int2[])), e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::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.start_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select 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), s3 as (select s1.ep0 as ep0, 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.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[]) and e1.id != all (s1.ep0)) select s3.n2 as n2, count(distinct (s3.n0))::int8 as i0 from s3 group by n2) select s0.n2 as m from s0 where (s0.i0 >= 10); -- case: with 365 as max_days match (n:NodeKind1) where n.pwdlastset < (datetime().epochseconds - (max_days * 86400)) and not n.pwdlastset IN [-1.0, 0.0] return n limit 100 with s0 as (select 365 as i0), s1 as (select s0.i0 as i0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from s0, node n0 where (not ((n0.properties ->> 'pwdlastset'))::float8 = any (array [- 1, 0]::float8[]) and ((n0.properties ->> 'pwdlastset'))::numeric < (extract(epoch from now()::timestamp with time zone)::numeric - (s0.i0 * 86400))) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s1.n0 as n from s1 limit 100; @@ -39,7 +39,7 @@ with s0 as (select 365 as i0), s1 as (select s0.i0 as i0, (n0.id, n0.kind_ids, n with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'hasspn'))::jsonb = to_jsonb((true)::bool)::jsonb and ((n0.properties -> 'enabled'))::jsonb = to_jsonb((true)::bool)::jsonb and not coalesce((n0.properties ->> 'objectid'), '')::text like '%-502' and not coalesce(((n0.properties ->> 'gmsa'))::bool, false)::bool = true and not coalesce(((n0.properties ->> 'msa'))::bool, false)::bool = true) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2 as (with recursive s3_seed(root_id) as not materialized (select distinct (s1.n0).id as root_id from s1), s3(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 s3_seed join edge e0 on e0.start_id = s3_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s3.root_id, e0.end_id, s3.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], false, s3.path || e0.id from s3 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.start_id = s3.next_id and e0.id != all (s3.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 s3.depth < 15 and not s3.is_cycle) select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1, s3 join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s3.root_id offset 0) n0 on true join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s3.next_id offset 0) n1 on true where s3.satisfied and (s1.n0).id = s3.root_id) select distinct s2.n0 as n0, count(s2.n1)::int8 as i0 from s2 group by n0) select s0.n0 as n from s0 order by s0.i0 desc limit 100; -- case: match (n:NodeKind1) where n.objectid = 'S-1-5-21-1260426776-3623580948-1897206385-23225' match p = (n)-[:EdgeKind1|EdgeKind2*1..]->(c:NodeKind2) return p -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'objectid'))::jsonb = to_jsonb(('S-1-5-21-1260426776-3623580948-1897206385-23225')::text)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n0).id as root_id from s0), s2(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 s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_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.end_id where s2.depth < 15 and not s2.is_cycle) select 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 s0, 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 and (s0.n0).id = s2.root_id) select ordered_edges_to_path(s1.n0, (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(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite as p from s1; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'objectid'))::jsonb = to_jsonb(('S-1-5-21-1260426776-3623580948-1897206385-23225')::text)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n0).id as root_id from s0), s2(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 s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_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.end_id where s2.depth < 15 and not s2.is_cycle) select 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 s0, 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 and (s0.n0).id = s2.root_id) select case when (s1.n0).id is null or s1.ep0 is null or (s1.n1).id is null then null else ordered_edges_to_path(s1.n0, (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(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite end as p from s1; -- case: match (g1:NodeKind1) where g1.name starts with 'test' with collect (g1.domain) as excludes match (d:NodeKind2) where d.name starts with 'other' and not d.name in excludes return d with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((n0.properties ->> 'name') like 'test%') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select array_remove(coalesce(array_agg(((s1.n0).properties ->> 'domain'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s1), s2 as (select s0.i0 as i0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where (not (n1.properties ->> 'name') = any (s0.i0) and (n1.properties ->> 'name') like 'other%') and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[]) select s2.n1 as d from s2; @@ -48,13 +48,13 @@ with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposit with s0 as (select 'a' as i0), s1 as (select s0.i0 as i0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from s0, node n0 where (((n0.properties -> 'domain'))::jsonb = to_jsonb((' ')::text)::jsonb and cypher_starts_with((n0.properties ->> 'name'), (i0)::text)::bool) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s1.n0 as o from s1; -- case: match (dc)-[r:EdgeKind1*0..]->(g:NodeKind1) where g.objectid ends with '-516' with collect(dc) as exclude match p = (c:NodeKind2)-[n:EdgeKind2]->(u:NodeKind2)-[:EdgeKind2*1..]->(g:NodeKind1) where g.objectid ends with '-512' and not c in exclude return p limit 100 -with s0 as (with s1 as (with recursive s2_seed(root_id) as not materialized (select n1.id as root_id from node n1 where ((n1.properties ->> 'objectid') like '%-516') and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select s2_seed.root_id, s2_seed.root_id, 0, false, false, array []::int8[] from s2_seed union all select e0.end_id, e0.start_id, 1, false, e0.end_id = e0.start_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]::int2[]) union all select s2.root_id, e0.start_id, s2.depth + 1, false, false, e0.id || s2.path 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]::int2[]) offset 0) e0 on true where s2.depth < 15 and not s2.is_cycle and s2.depth > 0) select (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 n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s2.next_id offset 0) n0 on true) select array_remove(coalesce(array_agg(s1.n0)::nodecomposite[], array []::nodecomposite[])::nodecomposite[], null)::nodecomposite[] as i0 from s1), s3 as (select e1.id as e1, s0.i0 as i0, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s0, edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e1.start_id join node n3 on n3.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n3.id = e1.end_id where e1.kind_id = any (array [4]::int2[])), s4 as (with recursive s5_seed(root_id) as not materialized (select distinct (s3.n3).id as root_id from s3), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, ((n4.properties ->> 'objectid') like '%-512') and n4.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.start_id = e2.end_id, array [e2.id] from s5_seed join edge e2 on e2.start_id = s5_seed.root_id join node n4 on n4.id = e2.end_id where e2.kind_id = any (array [4]::int2[]) union all select s5.root_id, e2.end_id, s5.depth + 1, ((n4.properties ->> 'objectid') like '%-512') and n4.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, s5.path || e2.id from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.start_id = s5.next_id and e2.id != all (s5.path) and e2.kind_id = any (array [4]::int2[]) offset 0) e2 on true join node n4 on n4.id = e2.end_id where s5.depth < 15 and not s5.is_cycle) select s3.e1 as e1, s5.path as ep1, s3.i0 as i0, s3.n2 as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3, (n4.id, n4.kind_ids, n4.properties)::nodecomposite as n4 from s3, s5 join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.root_id offset 0) n3 on true join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.next_id offset 0) n4 on true where s5.satisfied and (s3.n3).id = s5.root_id) select ordered_edges_to_path(s4.n2, (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.e1]::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(s4.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n2, s4.n3, s4.n4]::nodecomposite[])::pathcomposite as p from s4 where (not (s4.n2).id = any ((select (_unnest_elem).id from unnest(s4.i0) as _unnest_elem))) limit 100; +with s0 as (with s1 as (with recursive s2_seed(root_id) as not materialized (select n1.id as root_id from node n1 where ((n1.properties ->> 'objectid') like '%-516') and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select s2_seed.root_id, s2_seed.root_id, 0, false, false, array []::int8[] from s2_seed union all select e0.end_id, e0.start_id, 1, false, e0.end_id = e0.start_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]::int2[]) union all select s2.root_id, e0.start_id, s2.depth + 1, false, false, e0.id || s2.path 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]::int2[]) offset 0) e0 on true where s2.depth < 15 and not s2.is_cycle and s2.depth > 0) select (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 n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s2.next_id offset 0) n0 on true) select array_remove(coalesce(array_agg(s1.n0)::nodecomposite[], array []::nodecomposite[])::nodecomposite[], null)::nodecomposite[] as i0 from s1), s3 as (select e1.id as e1, s0.i0 as i0, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s0, edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e1.start_id join node n3 on n3.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n3.id = e1.end_id where e1.kind_id = any (array [4]::int2[])), s4 as (with recursive s5_seed(root_id) as not materialized (select distinct (s3.n3).id as root_id from s3), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, ((n4.properties ->> 'objectid') like '%-512') and n4.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.start_id = e2.end_id, array [e2.id] from s5_seed join edge e2 on e2.start_id = s5_seed.root_id join node n4 on n4.id = e2.end_id where e2.kind_id = any (array [4]::int2[]) union all select s5.root_id, e2.end_id, s5.depth + 1, ((n4.properties ->> 'objectid') like '%-512') and n4.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, s5.path || e2.id from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.start_id = s5.next_id and e2.id != all (s5.path) and e2.kind_id = any (array [4]::int2[]) offset 0) e2 on true join node n4 on n4.id = e2.end_id where s5.depth < 15 and not s5.is_cycle) select s3.e1 as e1, s5.path as ep1, s3.i0 as i0, s3.n2 as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3, (n4.id, n4.kind_ids, n4.properties)::nodecomposite as n4 from s3, s5 join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.root_id offset 0) n3 on true join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.next_id offset 0) n4 on true where s5.satisfied and (s3.n3).id = s5.root_id) select case when (s4.n2).id is null or s4.e1 is null or (s4.n3).id is null or s4.ep1 is null or (s4.n4).id is null then null else ordered_edges_to_path(s4.n2, (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.e1]::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(s4.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n2, s4.n3, s4.n4]::nodecomposite[])::pathcomposite end as p from s4 where (not (s4.n2).id = any ((select (_unnest_elem).id from unnest(s4.i0) as _unnest_elem))) limit 100; -- case: match (n:NodeKind1)<-[:EdgeKind1]-(:NodeKind2) where n.objectid ends with '-516' with n, count(n) as dc_count where dc_count = 1 return n 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 ->> 'objectid') like '%-516') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and 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[])) select s1.n0 as n0, count(s1.n0)::int8 as i0 from s1 group by n0) select s0.n0 as n from s0 where (s0.i0 = 1); -- case: match (n:NodeKind1)-[:EdgeKind1]->(m:NodeKind2) where n.enabled = true with n, collect(distinct(n)) as p where size(p) >= 100 match p = (n)-[:EdgeKind1]->(m) return p limit 10 -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 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 ordered_edges_to_path(s2.n0, (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 [s2.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n2]::nodecomposite[])::pathcomposite as p from s2 limit 10; +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 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 case when (s2.n0).id is null or s2.e1 is null or (s2.n2).id is null then null else ordered_edges_to_path(s2.n0, (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 [s2.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n2]::nodecomposite[])::pathcomposite end 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)::text[], array []::text[])::text[], null)::text[] as i2, lower(((s2.n0).properties ->> 'samaccountname'))::text as i3 from s2 group by lower(((s2.n0).properties ->> 'samaccountname'))::text) select s1.i2 as refmembership, s1.i3 as samname from s1; @@ -66,7 +66,7 @@ with s0 as (select 'a' as i0, 'b' as i1), s1 as (with s2 as (select s0.i0 as i0, 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 lower(((s2.n0).properties ->> 'samaccountname'))::text), 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((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s2.path) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id) as e0, (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 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 ordered_edges_to_path(s4.n2, (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.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n2, s4.n3]::nodecomposite[])::pathcomposite as p from s4 where ((s4.n2).id = any ((select (_unnest_elem).id from unnest(s4.i1) as _unnest_elem))); +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((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s2.path) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id) as e0, (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 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 case when (s4.n2).id is null or s4.e1 is null or (s4.n3).id is null then null else ordered_edges_to_path(s4.n2, (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.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n2, s4.n3]::nodecomposite[])::pathcomposite end as p from s4 where ((s4.n2).id = any ((select (_unnest_elem).id from unnest(s4.i1) as _unnest_elem))); -- case: match (u:NodeKind1)-[:EdgeKind1]->(g:NodeKind2) with g match (g)<-[:EdgeKind1]-(u:NodeKind1) return g with s0 as (with s1 as (select (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select s1.n1 as n1 from s1), s2 as (select s0.n1 as n1 from s0 join edge e1 on (s0.n1).id = e1.end_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.start_id where e1.kind_id = any (array [3]::int2[])) select s2.n1 as g from s2; @@ -75,19 +75,19 @@ with s0 as (with s1 as (select (n1.id, n1.kind_ids, n1.properties)::nodecomposit with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((n0.properties ->> 'name') ~ '.*TT' and ((n0.properties -> 'domain'))::jsonb = to_jsonb(('MY DOMAIN')::text)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select array_remove(coalesce(array_agg(((s1.n0).properties ->> 'email'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s1), s2 as (select s0.i0 as i0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, edge e0 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e0.end_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.start_id where e0.kind_id = any (array [3]::int2[]) and (not (n2.properties ->> 'email') = any (s0.i0) and (n2.properties ->> 'name') like 'blah%')) select s2.n1 as o from s2; -- case: match (e) match p = ()-[]->(e) return p limit 1 -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0), s1 as (select e0.id 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.id = e0.start_id) select ordered_edges_to_path(s1.n1, (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 [s1.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n1, s1.n0]::nodecomposite[])::pathcomposite as p from s1 limit 1; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0), s1 as (select e0.id 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.id = e0.start_id) select case when (s1.n1).id is null or s1.e0 is null or (s1.n0).id is null then null else ordered_edges_to_path(s1.n1, (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 [s1.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n1, s1.n0]::nodecomposite[])::pathcomposite end as p from s1 limit 1; -- case: match p = (a)-[]->() match q = ()-[]->(a) return p, q -with s0 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.id = e0.start_id join node n1 on n1.id = e0.end_id), s1 as (select s0.e0 as e0, e1.id as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n0).id = e1.end_id join node n2 on n2.id = e1.start_id) select ordered_edges_to_path(s1.n0, (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 [s1.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite as p, ordered_edges_to_path(s1.n2, (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 [s1.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n2, s1.n0]::nodecomposite[])::pathcomposite as q from s1; +with s0 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.id = e0.start_id join node n1 on n1.id = e0.end_id), s1 as (select s0.e0 as e0, e1.id as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n0).id = e1.end_id join node n2 on n2.id = e1.start_id) select case when (s1.n0).id is null or s1.e0 is null or (s1.n1).id is null then null else ordered_edges_to_path(s1.n0, (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 [s1.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite end as p, case when (s1.n2).id is null or s1.e1 is null or (s1.n0).id is null then null else ordered_edges_to_path(s1.n2, (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 [s1.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n2, s1.n0]::nodecomposite[])::pathcomposite end as q from s1; -- case: match (m:NodeKind1)-[*1..]->(g:NodeKind2)-[]->(c3:NodeKind1) where not g.name in ["foo"] with collect(g.name) as bar match p=(m:NodeKind1)-[*1..]->(g:NodeKind2) where g.name in bar 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.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id), e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id union all select s2.root_id, e0.end_id, s2.depth + 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id), 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.start_id = s2.next_id and e0.id != all (s2.path) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select (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), s3 as (select s1.n0 as n0, s1.n1 as n1 from s1 join edge e1 on (s1.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id) select array_remove(coalesce(array_agg(((s3.n1).properties ->> 'name'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s3), s4 as (with recursive s5_seed(root_id) as not materialized (select n4.id as root_id from s0, node n4 where n4.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n4.properties ->> 'name') = any (s0.i0))), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.end_id, e2.start_id, 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.end_id = e2.start_id, array [e2.id] from s5_seed join edge e2 on e2.end_id = s5_seed.root_id join node n3 on n3.id = e2.start_id union select s5.root_id, e2.start_id, s5.depth + 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e2.id || s5.path from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.end_id = s5.next_id and e2.id != all (s5.path) offset 0) e2 on true join node n3 on n3.id = e2.start_id where s5.depth < 15 and not s5.is_cycle) select s5.path as ep1, 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, s5 join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.root_id offset 0) n4 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.next_id offset 0) n3 on true where s5.satisfied) select 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(s4.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n3, s4.n4]::nodecomposite[])::pathcomposite as p from s4; +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.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id), e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id union all select s2.root_id, e0.end_id, s2.depth + 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id), 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.start_id = s2.next_id and e0.id != all (s2.path) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select 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), s3 as (select s1.ep0 as ep0, 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 [1]::int2[] and n2.id = e1.end_id where e1.id != all (s1.ep0)) select array_remove(coalesce(array_agg(((s3.n1).properties ->> 'name'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s3), s4 as (with recursive s5_seed(root_id) as not materialized (select n4.id as root_id from s0, node n4 where n4.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n4.properties ->> 'name') = any (s0.i0))), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.end_id, e2.start_id, 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.end_id = e2.start_id, array [e2.id] from s5_seed join edge e2 on e2.end_id = s5_seed.root_id join node n3 on n3.id = e2.start_id union select s5.root_id, e2.start_id, s5.depth + 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e2.id || s5.path from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.end_id = s5.next_id and e2.id != all (s5.path) offset 0) e2 on true join node n3 on n3.id = e2.start_id where s5.depth < 15 and not s5.is_cycle) select s5.path as ep1, 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, s5 join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.root_id offset 0) n4 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.next_id offset 0) n3 on true where s5.satisfied) select case when (s4.n3).id is null or s4.ep1 is null or (s4.n4).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(s4.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n3, s4.n4]::nodecomposite[])::pathcomposite end as p from s4; -- case: match (m:NodeKind1)-[:EdgeKind1*1..]->(g:NodeKind2)-[:EdgeKind2]->(c3:NodeKind1) where m.samaccountname =~ '^[A-Z]{1,3}[0-9]{1,3}$' and not m.samaccountname contains "DEX" and not g.name IN ["D"] and not m.samaccountname =~ "^.*$" with collect(g.name) as admingroups match p=(m:NodeKind1)-[:EdgeKind1*1..]->(g:NodeKind2) where m.samaccountname =~ '^[A-Z]{1,3}[0-9]{1,3}$' and g.name in admingroups and not m.samaccountname =~ "^.*$" 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 ->> 'samaccountname') ~ '^[A-Z]{1,3}[0-9]{1,3}$' and not coalesce((n0.properties ->> 'samaccountname'), '')::text like '%DEX%' and not (n0.properties ->> 'samaccountname') ~ '^.*$') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (not (n1.properties ->> 'name') = any (array ['D']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::int2[])), e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, (not (n1.properties ->> 'name') = any (array ['D']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::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.start_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select (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), s3 as (select s1.n0 as n0, s1.n1 as n1 from s1 join edge e1 on (s1.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[])) select array_remove(coalesce(array_agg(((s3.n1).properties ->> 'name'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s3), s4 as (with recursive s5_seed(root_id) as not materialized (select n4.id as root_id from s0, node n4 where n4.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n4.properties ->> 'name') = any (s0.i0))), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.end_id, e2.start_id, 1, ((n3.properties ->> 'samaccountname') ~ '^[A-Z]{1,3}[0-9]{1,3}$' and not (n3.properties ->> 'samaccountname') ~ '^.*$') and n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.end_id = e2.start_id, array [e2.id] from s5_seed join edge e2 on e2.end_id = s5_seed.root_id join node n3 on n3.id = e2.start_id where e2.kind_id = any (array [3]::int2[]) union select s5.root_id, e2.start_id, s5.depth + 1, ((n3.properties ->> 'samaccountname') ~ '^[A-Z]{1,3}[0-9]{1,3}$' and not (n3.properties ->> 'samaccountname') ~ '^.*$') and n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e2.id || s5.path from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.end_id = s5.next_id and e2.id != all (s5.path) and e2.kind_id = any (array [3]::int2[]) offset 0) e2 on true join node n3 on n3.id = e2.start_id where s5.depth < 15 and not s5.is_cycle) select s5.path as ep1, 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, s5 join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.root_id offset 0) n4 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.next_id offset 0) n3 on true where s5.satisfied) select 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(s4.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n3, s4.n4]::nodecomposite[])::pathcomposite as p from s4; +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 ->> 'samaccountname') ~ '^[A-Z]{1,3}[0-9]{1,3}$' and not coalesce((n0.properties ->> 'samaccountname'), '')::text like '%DEX%' and not (n0.properties ->> 'samaccountname') ~ '^.*$') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (not (n1.properties ->> 'name') = any (array ['D']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::int2[])), e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, (not (n1.properties ->> 'name') = any (array ['D']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::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.start_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select 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), s3 as (select s1.ep0 as ep0, 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 [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[]) and e1.id != all (s1.ep0)) select array_remove(coalesce(array_agg(((s3.n1).properties ->> 'name'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s3), s4 as (with recursive s5_seed(root_id) as not materialized (select n4.id as root_id from s0, node n4 where n4.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n4.properties ->> 'name') = any (s0.i0))), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.end_id, e2.start_id, 1, ((n3.properties ->> 'samaccountname') ~ '^[A-Z]{1,3}[0-9]{1,3}$' and not (n3.properties ->> 'samaccountname') ~ '^.*$') and n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.end_id = e2.start_id, array [e2.id] from s5_seed join edge e2 on e2.end_id = s5_seed.root_id join node n3 on n3.id = e2.start_id where e2.kind_id = any (array [3]::int2[]) union select s5.root_id, e2.start_id, s5.depth + 1, ((n3.properties ->> 'samaccountname') ~ '^[A-Z]{1,3}[0-9]{1,3}$' and not (n3.properties ->> 'samaccountname') ~ '^.*$') and n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e2.id || s5.path from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.end_id = s5.next_id and e2.id != all (s5.path) and e2.kind_id = any (array [3]::int2[]) offset 0) e2 on true join node n3 on n3.id = e2.start_id where s5.depth < 15 and not s5.is_cycle) select s5.path as ep1, 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, s5 join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.root_id offset 0) n4 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.next_id offset 0) n3 on true where s5.satisfied) select case when (s4.n3).id is null or s4.ep1 is null or (s4.n4).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(s4.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n3, s4.n4]::nodecomposite[])::pathcomposite end as p from s4; -- case: match (a:NodeKind2)-[:EdgeKind1]->(g:NodeKind1)-[:EdgeKind2]->(s:NodeKind2) with count(a) as uc where uc > 5 match p = (a)-[:EdgeKind1]->(g)-[:EdgeKind2]->(s) return p -with s0 as (with s1 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 [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.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[])) 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[])) select 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 as p from s4; +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); diff --git a/cypher/models/pgsql/test/translation_cases/nodes.sql b/cypher/models/pgsql/test/translation_cases/nodes.sql index 8a2884eb..1162f06b 100644 --- a/cypher/models/pgsql/test/translation_cases/nodes.sql +++ b/cypher/models/pgsql/test/translation_cases/nodes.sql @@ -211,7 +211,7 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select s0.n0 as s from s0 where (not exists (select 1 from edge e0 where e0.start_id = (s0.n0).id or e0.end_id = (s0.n0).id)); -- case: match (s) where not (s)-[]->()-[]->() return s -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select s0.n0 as s from s0 where (not (with s1 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n1 on n1.id = e0.end_id where (s0.n0).id = e0.start_id), s2 as (select s1.n0 as n0, s1.n1 as n1 from s1 join edge e1 on (s1.n1).id = e1.start_id join node n2 on n2.id = e1.end_id) select count(*) > 0 from s2)); +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select s0.n0 as s from s0 where (not (with s1 as (select e0.id as e0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n1 on n1.id = e0.end_id where (s0.n0).id = e0.start_id), s2 as (select s1.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.id = e1.end_id where e1.id != s1.e0) select count(*) > 0 from s2)); -- case: match (s) where not (s)-[{prop: 'a'}]-({name: 'n3'}) return s with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select s0.n0 as s from s0 where (not (with s1 as (select s0.n0 as n0 from s0 join edge e0 on ((s0.n0).id = e0.end_id or (s0.n0).id = e0.start_id) join node n1 on ((n1.properties -> 'name'))::jsonb = to_jsonb(('n3')::text)::jsonb and (n1.id = e0.end_id or n1.id = e0.start_id) where ((s0.n0).id <> n1.id) and ((e0.properties -> 'prop'))::jsonb = to_jsonb(('a')::text)::jsonb) select count(*) > 0 from s1)); diff --git a/cypher/models/pgsql/test/translation_cases/pattern_binding.sql b/cypher/models/pgsql/test/translation_cases/pattern_binding.sql index 57ea7e18..bbcba6d8 100644 --- a/cypher/models/pgsql/test/translation_cases/pattern_binding.sql +++ b/cypher/models/pgsql/test/translation_cases/pattern_binding.sql @@ -15,79 +15,79 @@ -- SPDX-License-Identifier: Apache-2.0 -- case: match p = (:NodeKind1) return p -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 (array [s0.n0]::nodecomposite[], array []::edgecomposite[])::pathcomposite as p from s0; +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 case when (s0.n0).id is null then null else (array [s0.n0]::nodecomposite[], array []::edgecomposite[])::pathcomposite end as p from s0; -- case: match p = (n:NodeKind1) where n.name contains 'test' return p -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((n0.properties ->> 'name') like '%test%') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select (array [s0.n0]::nodecomposite[], array []::edgecomposite[])::pathcomposite as p from s0; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((n0.properties ->> 'name') like '%test%') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select case when (s0.n0).id is null then null else (array [s0.n0]::nodecomposite[], array []::edgecomposite[])::pathcomposite end as p from s0; -- case: match p = ()-[]->() return p -with s0 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.id = e0.start_id join node n1 on n1.id = e0.end_id) select ordered_edges_to_path(s0.n0, (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 [s0.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0; +with s0 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.id = e0.start_id join node n1 on n1.id = e0.end_id) select case when (s0.n0).id is null or s0.e0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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 [s0.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0; -- case: match p = ()-[]->() return nodes(p) -with s0 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.id = e0.start_id join node n1 on n1.id = e0.end_id) select ((ordered_edges_to_path(s0.n0, (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 [s0.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite).nodes)::nodecomposite[] from s0; +with s0 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.id = e0.start_id join node n1 on n1.id = e0.end_id) select ((case when (s0.n0).id is null or s0.e0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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 [s0.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end).nodes)::nodecomposite[] 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 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, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 where (((select count(*)::int from unnest(((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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id))::edgecomposite[]) as i0 where (kind_name(i0.kind_id)::text like 'EdgeKind%')) >= 1)::bool); +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 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 case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 where (((select count(*)::int from unnest(((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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id))::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; +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 case when (s0.n0).id is null or (s0.e0).id is null or (s0.n1).id is null then null else (array [s0.n0, s0.n1]::nodecomposite[], array [s0.e0]::edgecomposite[])::pathcomposite end as p from s0 limit 100; -- case: match p = ()-[r1]->()-[r2]->(e) return e -with s0 as (select (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), s1 as (select s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id) select s1.n2 as e from s1; +with s0 as (select e0.id as e0, (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), s1 as (select s0.e0 as e0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.id != s0.e0) select s1.n2 as e from s1; -- case: match ()-[r1]->()-[r2]->()-[]->() where r1.name = 'a' and r2.name = 'b' return r1 -with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (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 where (((e0.properties -> 'name'))::jsonb = to_jsonb(('a')::text)::jsonb)), s1 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where (((e1.properties -> 'name'))::jsonb = to_jsonb(('b')::text)::jsonb)), s2 as (select s1.e0 as e0, s1.e1 as e1, s1.n1 as n1, s1.n2 as n2 from s1 join edge e2 on (s1.n2).id = e2.start_id join node n3 on n3.id = e2.end_id) select s2.e0 as r1 from s2; +with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (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 where (((e0.properties -> 'name'))::jsonb = to_jsonb(('a')::text)::jsonb)), s1 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where (((e1.properties -> 'name'))::jsonb = to_jsonb(('b')::text)::jsonb) and e1.id != (s0.e0).id), s2 as (select s1.e0 as e0, s1.e1 as e1, s1.n1 as n1, s1.n2 as n2 from s1 join edge e2 on (s1.n2).id = e2.start_id join node n3 on n3.id = e2.end_id where e2.id != (s1.e0).id and e2.id != (s1.e1).id) select s2.e0 as r1 from s2; -- case: match p = (a)-[]->()<-[]-(f) where a.name = 'value' and f.is_target return p -with s0 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.properties -> 'name'))::jsonb = to_jsonb(('value')::text)::jsonb) and n0.id = e0.start_id join node n1 on n1.id = e0.end_id), s1 as (select s0.e0 as e0, e1.id as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.end_id join node n2 on (((n2.properties ->> 'is_target'))::bool) and n2.id = e1.start_id) select ordered_edges_to_path(s1.n0, (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 [s1.e0]::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 [s1.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1, s1.n2]::nodecomposite[])::pathcomposite as p from s1; +with s0 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.properties -> 'name'))::jsonb = to_jsonb(('value')::text)::jsonb) and n0.id = e0.start_id join node n1 on n1.id = e0.end_id), s1 as (select s0.e0 as e0, e1.id as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.end_id join node n2 on (((n2.properties ->> 'is_target'))::bool) and n2.id = e1.start_id where e1.id != s0.e0) select case when (s1.n0).id is null or s1.e0 is null or (s1.n1).id is null or s1.e1 is null or (s1.n2).id is null then null else ordered_edges_to_path(s1.n0, (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 [s1.e0]::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 [s1.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1, s1.n2]::nodecomposite[])::pathcomposite end as p from s1; -- case: match p = ()-[*..]->() return p limit 1 -with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, false, e0.start_id = e0.end_id, array [e0.id] from edge e0 union all select s1.root_id, e0.end_id, s1.depth + 1, false, 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) offset 0) e0 on true where s1.depth < 15 and not s1.is_cycle) select 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 limit 1) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 limit 1; +with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, false, e0.start_id = e0.end_id, array [e0.id] from edge e0 union all select s1.root_id, e0.end_id, s1.depth + 1, false, 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) offset 0) e0 on true where s1.depth < 15 and not s1.is_cycle) select 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 limit 1) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 limit 1; -- case: match p = (s)-[*..]->(i)-[]->() where id(s) = 1 and i.name = 'n3' return p limit 1 -with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (n0.id = 1)), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n3')::text)::jsonb) and exists (select 1 from edge e1 join node n2 on n2.id = e1.end_id where n1.id = e1.start_id), 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 union all select s1.root_id, e0.end_id, s1.depth + 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n3')::text)::jsonb) and exists (select 1 from edge e1 join node n2 on n2.id = e1.end_id where n1.id = e1.start_id), 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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select 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), s2 as (select e1.id as e1, s0.ep0 as ep0, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id limit 1) select ordered_edges_to_path(s2.n0, (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(s2.ep0) 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 [s2.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1, s2.n2]::nodecomposite[])::pathcomposite as p from s2 limit 1; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (n0.id = 1)), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n3')::text)::jsonb) and exists (select 1 from edge e1 join node n2 on n2.id = e1.end_id where n1.id = e1.start_id), 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 union all select s1.root_id, e0.end_id, s1.depth + 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n3')::text)::jsonb) and exists (select 1 from edge e1 join node n2 on n2.id = e1.end_id where n1.id = e1.start_id), 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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select 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), s2 as (select e1.id as e1, s0.ep0 as ep0, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.id != all (s0.ep0) limit 1) select case when (s2.n0).id is null or s2.ep0 is null or (s2.n1).id is null or s2.e1 is null or (s2.n2).id is null then null else ordered_edges_to_path(s2.n0, (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(s2.ep0) 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 [s2.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1, s2.n2]::nodecomposite[])::pathcomposite end as p from s2 limit 1; -- case: match p = ()-[e:EdgeKind1]->()-[:EdgeKind1*..]->() return e, 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 where e0.kind_id = any (array [3]::int2[])), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.start_id, e1.end_id, 1, false, e1.start_id = e1.end_id, array [e1.id] from s2_seed join edge e1 on e1.start_id = s2_seed.root_id where e1.kind_id = any (array [3]::int2[]) union all select s2.root_id, e1.end_id, s2.depth + 1, false, false, s2.path || e1.id from s2 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.start_id = s2.next_id and e1.id != all (s2.path) and e1.kind_id = any (array [3]::int2[]) offset 0) e1 on true where s2.depth < 15 and not s2.is_cycle) select s0.e0 as e0, s2.path as ep0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s2.next_id offset 0) n2 on true where (s0.n1).id = s2.root_id) select s1.e0 as e, ordered_edges_to_path(s1.n0, array [s1.e0]::edgecomposite[] || (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(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1, s1.n2]::nodecomposite[])::pathcomposite as p from s1; +with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (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 where e0.kind_id = any (array [3]::int2[])), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.start_id, e1.end_id, 1, false, e1.start_id = e1.end_id, array [e1.id] from s2_seed join edge e1 on e1.start_id = s2_seed.root_id where e1.kind_id = any (array [3]::int2[]) union all select s2.root_id, e1.end_id, s2.depth + 1, false, false, s2.path || e1.id from s2 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.start_id = s2.next_id and e1.id != all (s2.path) and e1.kind_id = any (array [3]::int2[]) offset 0) e1 on true where s2.depth < 15 and not s2.is_cycle) select s0.e0 as e0, s2.path as ep0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s2.next_id offset 0) n2 on true where (s0.n1).id = s2.root_id) select s1.e0 as e, case when (s1.n0).id is null or (s1.e0).id is null or (s1.n1).id is null or s1.ep0 is null or (s1.n2).id is null then null else ordered_edges_to_path(s1.n0, array [s1.e0]::edgecomposite[] || (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(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1, s1.n2]::nodecomposite[])::pathcomposite end as p from s1; -- case: match p = (m:NodeKind1)-[:EdgeKind1]->(c:NodeKind2) where m.objectid ends with "-513" and not toUpper(c.operatingsystem) contains "SERVER" return p limit 1000 -with s0 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.properties ->> 'objectid') like '%-513') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id = e0.start_id join node n1 on (not upper((n1.properties ->> 'operatingsystem'))::text like '%SERVER%') and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) limit 1000) select ordered_edges_to_path(s0.n0, (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 [s0.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 limit 1000; +with s0 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.properties ->> 'objectid') like '%-513') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id = e0.start_id join node n1 on (not upper((n1.properties ->> 'operatingsystem'))::text like '%SERVER%') and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) limit 1000) select case when (s0.n0).id is null or s0.e0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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 [s0.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 limit 1000; -- case: match p = (:NodeKind1)-[:EdgeKind1|EdgeKind2]->(e:NodeKind2)-[:EdgeKind2]->(:NodeKind1) where 'a' in e.values or 'b' in e.values or size(e.values) = 0 return p -with s0 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 [1]::int2[] and n0.id = e0.start_id join node n1 on ('a' = any (jsonb_to_text_array((n1.properties -> 'values'))::text[]) or 'b' = any (jsonb_to_text_array((n1.properties -> 'values'))::text[]) or jsonb_array_length((n1.properties -> 'values'))::int = 0) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[])), s1 as (select s0.e0 as e0, e1.id as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[])) select ordered_edges_to_path(s1.n0, (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 [s1.e0]::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 [s1.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1, s1.n2]::nodecomposite[])::pathcomposite as p from s1; +with s0 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 [1]::int2[] and n0.id = e0.start_id join node n1 on ('a' = any (jsonb_to_text_array((n1.properties -> 'values'))::text[]) or 'b' = any (jsonb_to_text_array((n1.properties -> 'values'))::text[]) or jsonb_array_length((n1.properties -> 'values'))::int = 0) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[])), s1 as (select s0.e0 as e0, e1.id as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[]) and e1.id != s0.e0) select case when (s1.n0).id is null or s1.e0 is null or (s1.n1).id is null or s1.e1 is null or (s1.n2).id is null then null else ordered_edges_to_path(s1.n0, (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 [s1.e0]::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 [s1.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1, s1.n2]::nodecomposite[])::pathcomposite end as p from s1; -- case: match p = (n:NodeKind1)-[r]-(m:NodeKind1) return p -with s0 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 [1]::int2[] and (n0.id = e0.end_id or n0.id = e0.start_id) join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (n1.id = e0.end_id or n1.id = e0.start_id) where (n0.id <> n1.id)) select ordered_edges_to_path(s0.n0, (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 [s0.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0; +with s0 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 [1]::int2[] and (n0.id = e0.end_id or n0.id = e0.start_id) join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (n1.id = e0.end_id or n1.id = e0.start_id) where (n0.id <> n1.id)) select case when (s0.n0).id is null or s0.e0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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 [s0.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0; -- case: match p = (:NodeKind1)-[:EdgeKind1]->(:NodeKind2)-[:EdgeKind2*1..]->(t:NodeKind2) where coalesce(t.system_tags, '') contains 'admin_tier_0' return p limit 1000 -with s0 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 [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[])), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.start_id, e1.end_id, 1, (coalesce((n2.properties ->> 'system_tags'), '')::text like '%admin_tier_0%') and n2.kind_ids operator (pg_catalog.@>) array [2]::int2[], e1.start_id = e1.end_id, array [e1.id] from s2_seed join edge e1 on e1.start_id = s2_seed.root_id join node n2 on n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[]) union all select s2.root_id, e1.end_id, s2.depth + 1, (coalesce((n2.properties ->> 'system_tags'), '')::text like '%admin_tier_0%') and n2.kind_ids operator (pg_catalog.@>) array [2]::int2[], false, s2.path || e1.id from s2 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.start_id = s2.next_id and e1.id != all (s2.path) and e1.kind_id = any (array [4]::int2[]) offset 0) e1 on true join node n2 on n2.id = e1.end_id where s2.depth < 15 and not s2.is_cycle) select s0.e0 as e0, s2.path as ep0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s2.next_id offset 0) n2 on true where s2.satisfied and (s0.n1).id = s2.root_id limit 1000) select ordered_edges_to_path(s1.n0, (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 [s1.e0]::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(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1, s1.n2]::nodecomposite[])::pathcomposite as p from s1 limit 1000; +with s0 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 [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[])), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.start_id, e1.end_id, 1, (coalesce((n2.properties ->> 'system_tags'), '')::text like '%admin_tier_0%') and n2.kind_ids operator (pg_catalog.@>) array [2]::int2[], e1.start_id = e1.end_id, array [e1.id] from s2_seed join edge e1 on e1.start_id = s2_seed.root_id join node n2 on n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[]) union all select s2.root_id, e1.end_id, s2.depth + 1, (coalesce((n2.properties ->> 'system_tags'), '')::text like '%admin_tier_0%') and n2.kind_ids operator (pg_catalog.@>) array [2]::int2[], false, s2.path || e1.id from s2 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.start_id = s2.next_id and e1.id != all (s2.path) and e1.kind_id = any (array [4]::int2[]) offset 0) e1 on true join node n2 on n2.id = e1.end_id where s2.depth < 15 and not s2.is_cycle) select s0.e0 as e0, s2.path as ep0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s2.next_id offset 0) n2 on true where s2.satisfied and (s0.n1).id = s2.root_id limit 1000) select case when (s1.n0).id is null or s1.e0 is null or (s1.n1).id is null or s1.ep0 is null or (s1.n2).id is null then null else ordered_edges_to_path(s1.n0, (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 [s1.e0]::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(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1, s1.n2]::nodecomposite[])::pathcomposite end as p from s1 limit 1000; -- case: match (u:NodeKind1) where u.samaccountname in ["foo", "bar"] match p = (u)-[:EdgeKind1|EdgeKind2*1..3]->(t) where coalesce(t.system_tags, '') contains 'admin_tier_0' return p limit 1000 -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((n0.properties ->> 'samaccountname') = any (array ['foo', 'bar']::text[])) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n0).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (coalesce((n1.properties ->> 'system_tags'), '')::text like '%admin_tier_0%'), e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, (coalesce((n1.properties ->> 'system_tags'), '')::text like '%admin_tier_0%'), 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.start_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.end_id where s2.depth < 3 and not s2.is_cycle) select 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 s0, 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 and (s0.n0).id = s2.root_id) select ordered_edges_to_path(s1.n0, (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(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite as p from s1 limit 1000; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((n0.properties ->> 'samaccountname') = any (array ['foo', 'bar']::text[])) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n0).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (coalesce((n1.properties ->> 'system_tags'), '')::text like '%admin_tier_0%'), e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, (coalesce((n1.properties ->> 'system_tags'), '')::text like '%admin_tier_0%'), 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.start_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.end_id where s2.depth < 3 and not s2.is_cycle) select 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 s0, 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 and (s0.n0).id = s2.root_id) select case when (s1.n0).id is null or s1.ep0 is null or (s1.n1).id is null then null else ordered_edges_to_path(s1.n0, (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(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite end as p from s1 limit 1000; -- case: match (x:NodeKind1) where x.name = 'foo' match (y:NodeKind2) where y.name = 'bar' match p=(x)-[:EdgeKind1]->(y) return p -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('foo')::text)::jsonb) and 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 -> 'name'))::jsonb = to_jsonb(('bar')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s2 as (select e0.id as e0, s1.n0 as n0, s1.n1 as n1 from s1 join edge e0 on (s1.n0).id = e0.start_id and (s1.n1).id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select ordered_edges_to_path(s2.n0, (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 [s2.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1]::nodecomposite[])::pathcomposite as p from s2; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('foo')::text)::jsonb) and 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 -> 'name'))::jsonb = to_jsonb(('bar')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s2 as (select e0.id as e0, s1.n0 as n0, s1.n1 as n1 from s1 join edge e0 on (s1.n0).id = e0.start_id and (s1.n1).id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select case when (s2.n0).id is null or s2.e0 is null or (s2.n1).id is null then null else ordered_edges_to_path(s2.n0, (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 [s2.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1]::nodecomposite[])::pathcomposite end as p from s2; -- case: match (x:NodeKind1{name:'foo'}) match (y:NodeKind2{name:'bar'}) match p=(x)-[:EdgeKind1]->(y) return p -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[] and ((n0.properties -> 'name'))::jsonb = to_jsonb(('foo')::text)::jsonb), s1 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n1.properties -> 'name'))::jsonb = to_jsonb(('bar')::text)::jsonb), s2 as (select e0.id as e0, s1.n0 as n0, s1.n1 as n1 from s1 join edge e0 on (s1.n0).id = e0.start_id and (s1.n1).id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select ordered_edges_to_path(s2.n0, (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 [s2.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1]::nodecomposite[])::pathcomposite as p 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[] and ((n0.properties -> 'name'))::jsonb = to_jsonb(('foo')::text)::jsonb), s1 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n1.properties -> 'name'))::jsonb = to_jsonb(('bar')::text)::jsonb), s2 as (select e0.id as e0, s1.n0 as n0, s1.n1 as n1 from s1 join edge e0 on (s1.n0).id = e0.start_id and (s1.n1).id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select case when (s2.n0).id is null or s2.e0 is null or (s2.n1).id is null then null else ordered_edges_to_path(s2.n0, (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 [s2.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1]::nodecomposite[])::pathcomposite end as p from s2; -- case: match (x:NodeKind1{name:'foo'}) match p=(x)-[:EdgeKind1]->(y:NodeKind2{name:'bar'}) return p -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[] and ((n0.properties -> 'name'))::jsonb = to_jsonb(('foo')::text)::jsonb), s1 as (select e0.id 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.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n1.properties -> 'name'))::jsonb = to_jsonb(('bar')::text)::jsonb and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select ordered_edges_to_path(s1.n0, (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 [s1.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite as p from s1; +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[] and ((n0.properties -> 'name'))::jsonb = to_jsonb(('foo')::text)::jsonb), s1 as (select e0.id 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.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n1.properties -> 'name'))::jsonb = to_jsonb(('bar')::text)::jsonb and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select case when (s1.n0).id is null or s1.e0 is null or (s1.n1).id is null then null else ordered_edges_to_path(s1.n0, (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 [s1.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite end as p from s1; -- case: match (e) match p = ()-[]->(e) return p limit 1 -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0), s1 as (select e0.id 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.id = e0.start_id) select ordered_edges_to_path(s1.n1, (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 [s1.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n1, s1.n0]::nodecomposite[])::pathcomposite as p from s1 limit 1; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0), s1 as (select e0.id 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.id = e0.start_id) select case when (s1.n1).id is null or s1.e0 is null or (s1.n0).id is null then null else ordered_edges_to_path(s1.n1, (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 [s1.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n1, s1.n0]::nodecomposite[])::pathcomposite end as p from s1 limit 1; -- case: match p = (a)-[]->() match q = ()-[]->(a) return p, q -with s0 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.id = e0.start_id join node n1 on n1.id = e0.end_id), s1 as (select s0.e0 as e0, e1.id as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n0).id = e1.end_id join node n2 on n2.id = e1.start_id) select ordered_edges_to_path(s1.n0, (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 [s1.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite as p, ordered_edges_to_path(s1.n2, (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 [s1.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n2, s1.n0]::nodecomposite[])::pathcomposite as q from s1; +with s0 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.id = e0.start_id join node n1 on n1.id = e0.end_id), s1 as (select s0.e0 as e0, e1.id as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n0).id = e1.end_id join node n2 on n2.id = e1.start_id) select case when (s1.n0).id is null or s1.e0 is null or (s1.n1).id is null then null else ordered_edges_to_path(s1.n0, (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 [s1.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite end as p, case when (s1.n2).id is null or s1.e1 is null or (s1.n0).id is null then null else ordered_edges_to_path(s1.n2, (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 [s1.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n2, s1.n0]::nodecomposite[])::pathcomposite end as q from s1; -- case: match (m:NodeKind1)-[*1..]->(g:NodeKind2)-[]->(c3:NodeKind1) where not g.name in ["foo"] with collect(g.name) as bar match p=(m:NodeKind1)-[*1..]->(g:NodeKind2) where g.name in bar 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.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id), e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id union all select s2.root_id, e0.end_id, s2.depth + 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id), 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.start_id = s2.next_id and e0.id != all (s2.path) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select (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), s3 as (select s1.n0 as n0, s1.n1 as n1 from s1 join edge e1 on (s1.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id) select array_remove(coalesce(array_agg(((s3.n1).properties ->> 'name'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s3), s4 as (with recursive s5_seed(root_id) as not materialized (select n4.id as root_id from s0, node n4 where n4.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n4.properties ->> 'name') = any (s0.i0))), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.end_id, e2.start_id, 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.end_id = e2.start_id, array [e2.id] from s5_seed join edge e2 on e2.end_id = s5_seed.root_id join node n3 on n3.id = e2.start_id union select s5.root_id, e2.start_id, s5.depth + 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e2.id || s5.path from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.end_id = s5.next_id and e2.id != all (s5.path) offset 0) e2 on true join node n3 on n3.id = e2.start_id where s5.depth < 15 and not s5.is_cycle) select s5.path as ep1, 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, s5 join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.root_id offset 0) n4 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.next_id offset 0) n3 on true where s5.satisfied) select 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(s4.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n3, s4.n4]::nodecomposite[])::pathcomposite as p from s4; +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.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id), e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id union all select s2.root_id, e0.end_id, s2.depth + 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id), 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.start_id = s2.next_id and e0.id != all (s2.path) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select 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), s3 as (select s1.ep0 as ep0, 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 [1]::int2[] and n2.id = e1.end_id where e1.id != all (s1.ep0)) select array_remove(coalesce(array_agg(((s3.n1).properties ->> 'name'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s3), s4 as (with recursive s5_seed(root_id) as not materialized (select n4.id as root_id from s0, node n4 where n4.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n4.properties ->> 'name') = any (s0.i0))), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.end_id, e2.start_id, 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.end_id = e2.start_id, array [e2.id] from s5_seed join edge e2 on e2.end_id = s5_seed.root_id join node n3 on n3.id = e2.start_id union select s5.root_id, e2.start_id, s5.depth + 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e2.id || s5.path from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.end_id = s5.next_id and e2.id != all (s5.path) offset 0) e2 on true join node n3 on n3.id = e2.start_id where s5.depth < 15 and not s5.is_cycle) select s5.path as ep1, 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, s5 join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.root_id offset 0) n4 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.next_id offset 0) n3 on true where s5.satisfied) select case when (s4.n3).id is null or s4.ep1 is null or (s4.n4).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(s4.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n3, s4.n4]::nodecomposite[])::pathcomposite end as p from s4; -- case: MATCH p=(:Computer)-[r:HasSession]->(:User) WHERE r.lastseen >= datetime() - duration('P3D') 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 [5]::int2[] and n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [6]::int2[] and n1.id = e0.end_id where (((e0.properties ->> 'lastseen'))::timestamp with time zone >= now()::timestamp with time zone - interval 'P3D') and e0.kind_id = any (array [7]::int2[]) limit 100) select (array [s0.n0, s0.n1]::nodecomposite[], array [s0.e0]::edgecomposite[])::pathcomposite as p from s0 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 [5]::int2[] and n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [6]::int2[] and n1.id = e0.end_id where (((e0.properties ->> 'lastseen'))::timestamp with time zone >= now()::timestamp with time zone - interval 'P3D') and e0.kind_id = any (array [7]::int2[]) limit 100) select case when (s0.n0).id is null or (s0.e0).id is null or (s0.n1).id is null then null else (array [s0.n0, s0.n1]::nodecomposite[], array [s0.e0]::edgecomposite[])::pathcomposite end as p from s0 limit 100; -- case: MATCH p=(:GPO)-[r:GPLink|Contains*1..]->(:Base) WHERE HEAD(r).enforced OR NONE(n in TAIL(TAIL(NODES(p))) WHERE (n:OU AND n.blocksinheritance)) 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 [8]::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 [10]::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 [11, 12]::int2[]) union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [10]::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 [11, 12]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (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(s1.path) with ordinality as _path(id, ordinality) join edge _edge on _edge.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, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 where (((((s0.e0)[1]).properties ->> 'enforced'))::bool or ((select count(*)::int from unnest(coalesce((coalesce((((ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite).nodes)::nodecomposite[])[2:cardinality(((ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite).nodes)::nodecomposite[])::int], array []::nodecomposite[])::nodecomposite[])[2:cardinality(coalesce((((ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite).nodes)::nodecomposite[])[2:cardinality(((ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite).nodes)::nodecomposite[])::int], array []::nodecomposite[])::nodecomposite[])::int], array []::nodecomposite[])::nodecomposite[]) as i0 where ((i0.kind_ids operator (pg_catalog.@>) array [9]::int2[] and ((i0.properties ->> 'blocksinheritance'))::bool))) = 0 and coalesce((coalesce((((ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite).nodes)::nodecomposite[])[2:cardinality(((ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite).nodes)::nodecomposite[])::int], array []::nodecomposite[])::nodecomposite[])[2:cardinality(coalesce((((ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite).nodes)::nodecomposite[])[2:cardinality(((ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite).nodes)::nodecomposite[])::int], array []::nodecomposite[])::nodecomposite[])::int], array []::nodecomposite[])::nodecomposite[] is not null)::bool); +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 [8]::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 [10]::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 [11, 12]::int2[]) union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [10]::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 [11, 12]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (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(s1.path) with ordinality as _path(id, ordinality) join edge _edge on _edge.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 case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 where (((((s0.e0)[1]).properties ->> 'enforced'))::bool or ((select count(*)::int from unnest(coalesce((coalesce((((case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite end).nodes)::nodecomposite[])[2:cardinality(((case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite end).nodes)::nodecomposite[])::int], array []::nodecomposite[])::nodecomposite[])[2:cardinality(coalesce((((case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite end).nodes)::nodecomposite[])[2:cardinality(((case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite end).nodes)::nodecomposite[])::int], array []::nodecomposite[])::nodecomposite[])::int], array []::nodecomposite[])::nodecomposite[]) as i0 where ((i0.kind_ids operator (pg_catalog.@>) array [9]::int2[] and ((i0.properties ->> 'blocksinheritance'))::bool))) = 0 and coalesce((coalesce((((case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite end).nodes)::nodecomposite[])[2:cardinality(((case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite end).nodes)::nodecomposite[])::int], array []::nodecomposite[])::nodecomposite[])[2:cardinality(coalesce((((case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite end).nodes)::nodecomposite[])[2:cardinality(((case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite end).nodes)::nodecomposite[])::int], array []::nodecomposite[])::nodecomposite[])::int], array []::nodecomposite[])::nodecomposite[] is not null)::bool); -- case: MATCH p=(:GPO)-[r:GPLink|Contains*1..]->(:Base) WHERE NONE(x in TAIL(r) WHERE NOT type(x) = 'Contains') 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 [8]::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 [10]::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 [11, 12]::int2[]) union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [10]::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 [11, 12]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (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(s1.path) with ordinality as _path(id, ordinality) join edge _edge on _edge.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, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 where (((select count(*)::int from unnest(coalesce((s0.e0)[2:cardinality(s0.e0)::int], array []::edgecomposite[])::edgecomposite[]) as i0 where (not i0.kind_id = 12)) = 0 and coalesce((s0.e0)[2:cardinality(s0.e0)::int], array []::edgecomposite[])::edgecomposite[] is not null)::bool); +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 [8]::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 [10]::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 [11, 12]::int2[]) union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [10]::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 [11, 12]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (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(s1.path) with ordinality as _path(id, ordinality) join edge _edge on _edge.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 case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 where (((select count(*)::int from unnest(coalesce((s0.e0)[2:cardinality(s0.e0)::int], array []::edgecomposite[])::edgecomposite[]) as i0 where (not i0.kind_id = 12)) = 0 and coalesce((s0.e0)[2:cardinality(s0.e0)::int], array []::edgecomposite[])::edgecomposite[] is not null)::bool); diff --git a/cypher/models/pgsql/test/translation_cases/pattern_expansion.sql b/cypher/models/pgsql/test/translation_cases/pattern_expansion.sql index 273b3530..f84e2d77 100644 --- a/cypher/models/pgsql/test/translation_cases/pattern_expansion.sql +++ b/cypher/models/pgsql/test/translation_cases/pattern_expansion.sql @@ -27,7 +27,7 @@ with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, false, e0.end_id = e0.start_id, array [e0.id] from edge e0 union all select s1.root_id, e0.start_id, s1.depth + 1, false, 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.end_id = s1.next_id and e0.id != all (s1.path) offset 0) e0 on true where s1.depth < 5 and not s1.is_cycle) select (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.depth >= 2) select s0.n0 as n, s0.n1 as e from s0; -- case: match p = (n)-[*..]->(e:NodeKind1) return p -with s0 as (with recursive s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where n1.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, false, e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id union all select s1.root_id, e0.start_id, s1.depth + 1, false, false, e0.id || s1.path from s1 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.end_id = s1.next_id and e0.id != all (s1.path) offset 0) e0 on true where s1.depth < 15 and not s1.is_cycle) select 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 n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s1.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s1.next_id offset 0) n0 on true) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where n1.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, false, e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id union all select s1.root_id, e0.start_id, s1.depth + 1, false, false, e0.id || s1.path from s1 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.end_id = s1.next_id and e0.id != all (s1.path) offset 0) e0 on true where s1.depth < 15 and not s1.is_cycle) select 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 n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s1.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s1.next_id offset 0) n0 on true) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0; -- case: match (n)-[*..]->(e:NodeKind1) where n.name = 'n1' return e with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb)), 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 [1]::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 union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (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 s0.n1 as e from s0; @@ -36,46 +36,46 @@ with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n2')::text)::jsonb)), 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 [1]::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 union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (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 s0.n0 as n from s0; -- case: match (n)-[*..]->(e:NodeKind1)-[]->(l) where n.name = 'n1' return l -with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb)), 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 [1]::int2[] and exists (select 1 from edge e1 join node n2 on n2.id = e1.end_id where n1.id = e1.start_id), 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 union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and exists (select 1 from edge e1 join node n2 on n2.id = e1.end_id where n1.id = e1.start_id), 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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (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), s2 as (select s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id) select s2.n2 as l from s2; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb)), 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 [1]::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 union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select 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), s2 as (select s0.ep0 as ep0, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.id != all (s0.ep0)) select s2.n2 as l from s2; -- case: match (n)-[*2..3]->(e:NodeKind1)-[]->(l) where n.name = 'n1' return l -with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb)), 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 [1]::int2[] and exists (select 1 from edge e1 join node n2 on n2.id = e1.end_id where n1.id = e1.start_id), 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 union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and exists (select 1 from edge e1 join node n2 on n2.id = e1.end_id where n1.id = e1.start_id), 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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 3 and not s1.is_cycle) select (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.depth >= 2 and s1.satisfied), s2 as (select s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id) select s2.n2 as l from s2; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb)), 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 [1]::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 union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 3 and not s1.is_cycle) select 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.depth >= 2 and s1.satisfied), s2 as (select s0.ep0 as ep0, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.id != all (s0.ep0)) select s2.n2 as l from s2; -- case: match (n)-[]->(e:NodeKind1)-[*2..3]->(l) where n.name = 'n1' return l -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.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb) 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), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.start_id, e1.end_id, 1, false, e1.start_id = e1.end_id, array [e1.id] from s2_seed join edge e1 on e1.start_id = s2_seed.root_id union all select s2.root_id, e1.end_id, s2.depth + 1, false, false, s2.path || e1.id from s2 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.start_id = s2.next_id and e1.id != all (s2.path) offset 0) e1 on true where s2.depth < 3 and not s2.is_cycle) select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s2.next_id offset 0) n2 on true where s2.depth >= 2 and (s0.n1).id = s2.root_id) select s1.n2 as l from s1; +with s0 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.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb) 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), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.start_id, e1.end_id, 1, false, e1.start_id = e1.end_id, array [e1.id] from s2_seed join edge e1 on e1.start_id = s2_seed.root_id union all select s2.root_id, e1.end_id, s2.depth + 1, false, false, s2.path || e1.id from s2 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.start_id = s2.next_id and e1.id != all (s2.path) offset 0) e1 on true where s2.depth < 3 and not s2.is_cycle) select s0.e0 as e0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s2.next_id offset 0) n2 on true where s2.depth >= 2 and (s0.n1).id = s2.root_id) select s1.n2 as l from s1; -- case: match (n)-[*..]->(e)-[:EdgeKind1|EdgeKind2]->()-[*..]->(l) where n.name = 'n1' and e.name = 'n2' return l -with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb)), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n2')::text)::jsonb) and exists (select 1 from edge e1 join node n2 on n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [3, 4]::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 union all select s1.root_id, e0.end_id, s1.depth + 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n2')::text)::jsonb) and exists (select 1 from edge e1 join node n2 on n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [3, 4]::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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (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), s2 as (select s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.kind_id = any (array [3, 4]::int2[])), s3 as (with recursive s4_seed(root_id) as not materialized (select distinct (s2.n2).id as root_id from s2), s4(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, false, e2.start_id = e2.end_id, array [e2.id] from s4_seed join edge e2 on e2.start_id = s4_seed.root_id union all select s4.root_id, e2.end_id, s4.depth + 1, false, false, s4.path || e2.id from s4 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.start_id = s4.next_id and e2.id != all (s4.path) offset 0) e2 on true where s4.depth < 15 and not s4.is_cycle) select s2.n0 as n0, s2.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s2, s4 join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s4.root_id offset 0) n2 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s4.next_id offset 0) n3 on true where (s2.n2).id = s4.root_id) select s3.n3 as l from s3; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb)), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n2')::text)::jsonb) and exists (select 1 from edge e1 join node n2 on n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [3, 4]::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 union all select s1.root_id, e0.end_id, s1.depth + 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n2')::text)::jsonb) and exists (select 1 from edge e1 join node n2 on n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [3, 4]::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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select 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), s2 as (select e1.id as e1, s0.ep0 as ep0, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.kind_id = any (array [3, 4]::int2[]) and e1.id != all (s0.ep0)), s3 as (with recursive s4_seed(root_id) as not materialized (select distinct (s2.n2).id as root_id from s2), s4(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, false, e2.start_id = e2.end_id, array [e2.id] from s4_seed join edge e2 on e2.start_id = s4_seed.root_id union all select s4.root_id, e2.end_id, s4.depth + 1, false, false, s4.path || e2.id from s4 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.start_id = s4.next_id and e2.id != all (s4.path) offset 0) e2 on true where s4.depth < 15 and not s4.is_cycle) select s2.e1 as e1, s2.ep0 as ep0, s2.n0 as n0, s2.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s2, s4 join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s4.root_id offset 0) n2 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s4.next_id offset 0) n3 on true where (s2.n2).id = s4.root_id) select s3.n3 as l from s3; -- case: match p = (:NodeKind1)-[:EdgeKind1*1..]->(n:NodeKind2) where 'admin_tier_0' in split(n.system_tags, ' ') return p limit 1000 -with s0 as (with recursive s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where ('admin_tier_0' = any (string_to_array((n1.properties ->> 'system_tags'), ' ')::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, n0.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id join node n0 on n0.id = e0.start_id where e0.kind_id = any (array [3]::int2[]) union all select s1.root_id, e0.start_id, s1.depth + 1, n0.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e0.id || s1.path from s1 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.end_id = s1.next_id and e0.id != all (s1.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n0 on n0.id = e0.start_id where s1.depth < 15 and not s1.is_cycle) select 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 n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s1.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s1.next_id offset 0) n0 on true where s1.satisfied limit 1000) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 limit 1000; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where ('admin_tier_0' = any (string_to_array((n1.properties ->> 'system_tags'), ' ')::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, n0.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id join node n0 on n0.id = e0.start_id where e0.kind_id = any (array [3]::int2[]) union all select s1.root_id, e0.start_id, s1.depth + 1, n0.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e0.id || s1.path from s1 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.end_id = s1.next_id and e0.id != all (s1.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n0 on n0.id = e0.start_id where s1.depth < 15 and not s1.is_cycle) select 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 n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s1.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s1.next_id offset 0) n0 on true where s1.satisfied limit 1000) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 limit 1000; -- case: match p = (s:NodeKind1)-[*..]->(e:NodeKind2) where s <> e 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 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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select 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 and (n0.id <> n1.id)) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0; +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 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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select 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 and (n0.id <> n1.id)) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0; -- case: match p = (g:NodeKind1)-[:EdgeKind1|EdgeKind2*]->(target:NodeKind1) where g.objectid ends with '1234' and target.objectid ends with '4567' 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.properties ->> 'objectid') like '%1234') and 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.properties ->> 'objectid') like '%4567') and n1.kind_ids operator (pg_catalog.@>) array [1]::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.properties ->> 'objectid') like '%4567') and n1.kind_ids operator (pg_catalog.@>) array [1]::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 < 15 and not s1.is_cycle) select 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, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where ((n0.properties ->> 'objectid') like '%1234') and 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.properties ->> 'objectid') like '%4567') and n1.kind_ids operator (pg_catalog.@>) array [1]::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.properties ->> 'objectid') like '%4567') and n1.kind_ids operator (pg_catalog.@>) array [1]::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 < 15 and not s1.is_cycle) select 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 case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0; -- case: match p = (m:NodeKind2)-[:EdgeKind1*1..]->(n:NodeKind1) where n.objectid = '1234' return p limit 10 -with s0 as (with recursive s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where (((n1.properties -> 'objectid'))::jsonb = to_jsonb(('1234')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, n0.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id join node n0 on n0.id = e0.start_id where e0.kind_id = any (array [3]::int2[]) union all select s1.root_id, e0.start_id, s1.depth + 1, n0.kind_ids operator (pg_catalog.@>) array [2]::int2[], false, e0.id || s1.path from s1 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.end_id = s1.next_id and e0.id != all (s1.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n0 on n0.id = e0.start_id where s1.depth < 15 and not s1.is_cycle) select 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 n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s1.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s1.next_id offset 0) n0 on true where s1.satisfied limit 10) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 limit 10; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where (((n1.properties -> 'objectid'))::jsonb = to_jsonb(('1234')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, n0.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id join node n0 on n0.id = e0.start_id where e0.kind_id = any (array [3]::int2[]) union all select s1.root_id, e0.start_id, s1.depth + 1, n0.kind_ids operator (pg_catalog.@>) array [2]::int2[], false, e0.id || s1.path from s1 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.end_id = s1.next_id and e0.id != all (s1.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n0 on n0.id = e0.start_id where s1.depth < 15 and not s1.is_cycle) select 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 n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s1.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s1.next_id offset 0) n0 on true where s1.satisfied limit 10) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 limit 10; -- case: match p = (:NodeKind1)<-[:EdgeKind1|EdgeKind2*..]-() return p limit 10 -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.end_id, e0.start_id, 1, false, e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s1.root_id, e0.start_id, s1.depth + 1, false, 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.end_id = s1.next_id and e0.id != all (s1.path) and e0.kind_id = any (array [3, 4]::int2[]) offset 0) e0 on true where s1.depth < 15 and not s1.is_cycle) select 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 limit 10) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 limit 10; +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.end_id, e0.start_id, 1, false, e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s1.root_id, e0.start_id, s1.depth + 1, false, 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.end_id = s1.next_id and e0.id != all (s1.path) and e0.kind_id = any (array [3, 4]::int2[]) offset 0) e0 on true where s1.depth < 15 and not s1.is_cycle) select 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 limit 10) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 limit 10; -- case: match p = (:NodeKind1)<-[:EdgeKind1|EdgeKind2*..]-(:NodeKind2)<-[:EdgeKind1|EdgeKind2*2..]-(:NodeKind1) return p limit 10 -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.end_id, e0.start_id, 1, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id join node n1 on n1.id = e0.start_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s1.root_id, e0.start_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.end_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.start_id where s1.depth < 15 and not s1.is_cycle) select 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), s2 as (with recursive s3_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s3(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.end_id, e1.start_id, 1, n2.kind_ids operator (pg_catalog.@>) array [1]::int2[], e1.end_id = e1.start_id, array [e1.id] from s3_seed join edge e1 on e1.end_id = s3_seed.root_id join node n2 on n2.id = e1.start_id where e1.kind_id = any (array [3, 4]::int2[]) union all select s3.root_id, e1.start_id, s3.depth + 1, n2.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, s3.path || e1.id from s3 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.end_id = s3.next_id and e1.id != all (s3.path) and e1.kind_id = any (array [3, 4]::int2[]) offset 0) e1 on true join node n2 on n2.id = e1.start_id where s3.depth < 15 and not s3.is_cycle) select s0.ep0 as ep0, s3.path as ep1, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s3 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s3.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s3.next_id offset 0) n2 on true where s3.depth >= 2 and s3.satisfied and (s0.n1).id = s3.root_id limit 10) select ordered_edges_to_path(s2.n0, (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(s2.ep0) 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(s2.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1, s2.n2]::nodecomposite[])::pathcomposite as p from s2 limit 10; +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.end_id, e0.start_id, 1, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id join node n1 on n1.id = e0.start_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s1.root_id, e0.start_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.end_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.start_id where s1.depth < 15 and not s1.is_cycle) select 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), s2 as (with recursive s3_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s3(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.end_id, e1.start_id, 1, n2.kind_ids operator (pg_catalog.@>) array [1]::int2[], e1.end_id = e1.start_id, array [e1.id] from s3_seed join edge e1 on e1.end_id = s3_seed.root_id join node n2 on n2.id = e1.start_id where e1.kind_id = any (array [3, 4]::int2[]) union all select s3.root_id, e1.start_id, s3.depth + 1, n2.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, s3.path || e1.id from s3 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.end_id = s3.next_id and e1.id != all (s3.path) and e1.kind_id = any (array [3, 4]::int2[]) offset 0) e1 on true join node n2 on n2.id = e1.start_id where s3.depth < 15 and not s3.is_cycle) select s0.ep0 as ep0, s3.path as ep1, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s3 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s3.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s3.next_id offset 0) n2 on true where s3.depth >= 2 and s3.satisfied and (s0.n1).id = s3.root_id limit 10) select case when (s2.n0).id is null or s2.ep0 is null or (s2.n1).id is null or s2.ep1 is null or (s2.n2).id is null then null else ordered_edges_to_path(s2.n0, (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(s2.ep0) 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(s2.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1, s2.n2]::nodecomposite[])::pathcomposite end as p from s2 limit 10; -- case: match p = (:NodeKind1)<-[:EdgeKind1|EdgeKind2*..]-(:NodeKind2)<-[:EdgeKind1|EdgeKind2*..]-(:NodeKind1) return p limit 10 -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.end_id, e0.start_id, 1, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id join node n1 on n1.id = e0.start_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s1.root_id, e0.start_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.end_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.start_id where s1.depth < 15 and not s1.is_cycle) select 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), s2 as (with recursive s3_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s3(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.end_id, e1.start_id, 1, n2.kind_ids operator (pg_catalog.@>) array [1]::int2[], e1.end_id = e1.start_id, array [e1.id] from s3_seed join edge e1 on e1.end_id = s3_seed.root_id join node n2 on n2.id = e1.start_id where e1.kind_id = any (array [3, 4]::int2[]) union all select s3.root_id, e1.start_id, s3.depth + 1, n2.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, s3.path || e1.id from s3 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.end_id = s3.next_id and e1.id != all (s3.path) and e1.kind_id = any (array [3, 4]::int2[]) offset 0) e1 on true join node n2 on n2.id = e1.start_id where s3.depth < 15 and not s3.is_cycle) select s0.ep0 as ep0, s3.path as ep1, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s3 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s3.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s3.next_id offset 0) n2 on true where s3.satisfied and (s0.n1).id = s3.root_id limit 10) select ordered_edges_to_path(s2.n0, (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(s2.ep0) 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(s2.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1, s2.n2]::nodecomposite[])::pathcomposite as p from s2 limit 10; +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.end_id, e0.start_id, 1, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id join node n1 on n1.id = e0.start_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s1.root_id, e0.start_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.end_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.start_id where s1.depth < 15 and not s1.is_cycle) select 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), s2 as (with recursive s3_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s3(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.end_id, e1.start_id, 1, n2.kind_ids operator (pg_catalog.@>) array [1]::int2[], e1.end_id = e1.start_id, array [e1.id] from s3_seed join edge e1 on e1.end_id = s3_seed.root_id join node n2 on n2.id = e1.start_id where e1.kind_id = any (array [3, 4]::int2[]) union all select s3.root_id, e1.start_id, s3.depth + 1, n2.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, s3.path || e1.id from s3 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.end_id = s3.next_id and e1.id != all (s3.path) and e1.kind_id = any (array [3, 4]::int2[]) offset 0) e1 on true join node n2 on n2.id = e1.start_id where s3.depth < 15 and not s3.is_cycle) select s0.ep0 as ep0, s3.path as ep1, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s3 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s3.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s3.next_id offset 0) n2 on true where s3.satisfied and (s0.n1).id = s3.root_id limit 10) select case when (s2.n0).id is null or s2.ep0 is null or (s2.n1).id is null or s2.ep1 is null or (s2.n2).id is null then null else ordered_edges_to_path(s2.n0, (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(s2.ep0) 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(s2.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1, s2.n2]::nodecomposite[])::pathcomposite end as p from s2 limit 10; -- case: match p = (n:NodeKind1)-[:EdgeKind1|EdgeKind2*1..2]->(r:NodeKind2) where r.name =~ '(?i)Global Administrator.*|User Administrator.*|Cloud Application Administrator.*|Authentication Policy Administrator.*|Exchange Administrator.*|Helpdesk Administrator.*|Privileged Authentication Administrator.*' return p limit 10 -with s0 as (with recursive s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where ((n1.properties ->> 'name') ~ '(?i)Global Administrator.*|User Administrator.*|Cloud Application Administrator.*|Authentication Policy Administrator.*|Exchange Administrator.*|Helpdesk Administrator.*|Privileged Authentication Administrator.*') and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, n0.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id join node n0 on n0.id = e0.start_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s1.root_id, e0.start_id, s1.depth + 1, n0.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e0.id || s1.path from s1 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.end_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 n0 on n0.id = e0.start_id where s1.depth < 2 and not s1.is_cycle) select 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 n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s1.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s1.next_id offset 0) n0 on true where s1.satisfied limit 10) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 limit 10; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where ((n1.properties ->> 'name') ~ '(?i)Global Administrator.*|User Administrator.*|Cloud Application Administrator.*|Authentication Policy Administrator.*|Exchange Administrator.*|Helpdesk Administrator.*|Privileged Authentication Administrator.*') and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, n0.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id join node n0 on n0.id = e0.start_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s1.root_id, e0.start_id, s1.depth + 1, n0.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e0.id || s1.path from s1 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.end_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 n0 on n0.id = e0.start_id where s1.depth < 2 and not s1.is_cycle) select 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 n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s1.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s1.next_id offset 0) n0 on true where s1.satisfied limit 10) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 limit 10; -- case: match p = (t:NodeKind2)<-[:EdgeKind1*1..]-(a) where (a:NodeKind1 or a:NodeKind2) and t.objectid ends with '-512' return p limit 1000 -with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where ((n0.properties ->> 'objectid') like '%-512') and n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s1(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[] or n1.kind_ids operator (pg_catalog.@>) array [2]::int2[])), e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id join node n1 on n1.id = e0.start_id where e0.kind_id = any (array [3]::int2[]) union all select s1.root_id, e0.start_id, s1.depth + 1, ((n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] or 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.end_id = s1.next_id and e0.id != all (s1.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.start_id where s1.depth < 15 and not s1.is_cycle) select 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 limit 1000) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 limit 1000; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where ((n0.properties ->> 'objectid') like '%-512') and n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s1(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[] or n1.kind_ids operator (pg_catalog.@>) array [2]::int2[])), e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id join node n1 on n1.id = e0.start_id where e0.kind_id = any (array [3]::int2[]) union all select s1.root_id, e0.start_id, s1.depth + 1, ((n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] or 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.end_id = s1.next_id and e0.id != all (s1.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.start_id where s1.depth < 15 and not s1.is_cycle) select 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 limit 1000) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 limit 1000; -- case: match p=(n:NodeKind1)-[:EdgeKind1|EdgeKind2]->(g:NodeKind1)-[:EdgeKind2]->(:NodeKind2)-[:EdgeKind1*1..]->(m:NodeKind1) where n.objectid = m.objectid return p limit 100 -with s0 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 [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.kind_id = any (array [3, 4]::int2[])), s1 as (select s0.e0 as e0, e1.id as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.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[])), s2 as (with recursive s3_seed(root_id) as not materialized (select distinct (s1.n2).id as root_id from s1), s3(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.start_id = e2.end_id, array [e2.id] from s3_seed join edge e2 on e2.start_id = s3_seed.root_id join node n3 on n3.id = e2.end_id where e2.kind_id = any (array [3]::int2[]) union all select s3.root_id, e2.end_id, s3.depth + 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, s3.path || e2.id from s3 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.start_id = s3.next_id and e2.id != all (s3.path) and e2.kind_id = any (array [3]::int2[]) offset 0) e2 on true join node n3 on n3.id = e2.end_id where s3.depth < 15 and not s3.is_cycle) select s1.e0 as e0, s1.e1 as e1, s3.path as ep0, s1.n0 as n0, s1.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s1, s3 join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s3.root_id offset 0) n2 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s3.next_id offset 0) n3 on true where s3.satisfied and (s1.n2).id = s3.root_id and (((s1.n0).properties -> 'objectid') = (n3.properties -> 'objectid')) limit 100) select ordered_edges_to_path(s2.n0, (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 [s2.e0]::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 [s2.e1]::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(s2.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1, s2.n2, s2.n3]::nodecomposite[])::pathcomposite as p from s2 limit 100; +with s0 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 [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.kind_id = any (array [3, 4]::int2[])), s1 as (select s0.e0 as e0, e1.id as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.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 != s0.e0), s2 as (with recursive s3_seed(root_id) as not materialized (select distinct (s1.n2).id as root_id from s1), s3(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.start_id = e2.end_id, array [e2.id] from s3_seed join edge e2 on e2.start_id = s3_seed.root_id join node n3 on n3.id = e2.end_id where e2.kind_id = any (array [3]::int2[]) union all select s3.root_id, e2.end_id, s3.depth + 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, s3.path || e2.id from s3 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.start_id = s3.next_id and e2.id != all (s3.path) and e2.kind_id = any (array [3]::int2[]) offset 0) e2 on true join node n3 on n3.id = e2.end_id where s3.depth < 15 and not s3.is_cycle) select s1.e0 as e0, s1.e1 as e1, s3.path as ep0, s1.n0 as n0, s1.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s1, s3 join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s3.root_id offset 0) n2 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s3.next_id offset 0) n3 on true where s3.satisfied and (s1.n2).id = s3.root_id and (((s1.n0).properties -> 'objectid') = (n3.properties -> 'objectid')) limit 100) select case when (s2.n0).id is null or s2.e0 is null or (s2.n1).id is null or s2.e1 is null or (s2.n2).id is null or s2.ep0 is null or (s2.n3).id is null then null else ordered_edges_to_path(s2.n0, (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 [s2.e0]::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 [s2.e1]::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(s2.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1, s2.n2, s2.n3]::nodecomposite[])::pathcomposite end as p from s2 limit 100; -- case: match (a:NodeKind1)-[:EdgeKind1*0..]->(b:NodeKind1) where a.name = 'solo' and b.name = 'solo' return a.name, b.name with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('solo')::text)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select s1_seed.root_id, s1_seed.root_id, 0, (((n1.properties -> 'name'))::jsonb = to_jsonb(('solo')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, array []::int8[] from s1_seed join node n1 on n1.id = s1_seed.root_id union all select e0.start_id, e0.end_id, 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('solo')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [1]::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]::int2[]) union all select s1.root_id, e0.end_id, s1.depth + 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('solo')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [1]::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]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle and s1.depth > 0) select (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 ((s0.n0).properties -> 'name'), ((s0.n1).properties -> 'name') from s0; diff --git a/cypher/models/pgsql/test/translation_cases/pattern_rewriting.sql b/cypher/models/pgsql/test/translation_cases/pattern_rewriting.sql index 01ee8044..21dcea9d 100644 --- a/cypher/models/pgsql/test/translation_cases/pattern_rewriting.sql +++ b/cypher/models/pgsql/test/translation_cases/pattern_rewriting.sql @@ -16,4 +16,3 @@ -- case: match (s:NodeKind1) return s 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 s from s0; - diff --git a/cypher/models/pgsql/test/translation_cases/quantifiers.sql b/cypher/models/pgsql/test/translation_cases/quantifiers.sql index 198db68f..01b88d38 100644 --- a/cypher/models/pgsql/test/translation_cases/quantifiers.sql +++ b/cypher/models/pgsql/test/translation_cases/quantifiers.sql @@ -37,6 +37,7 @@ with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposit -- case: MATCH (m:NodeKind1) WHERE ANY(name in m.serviceprincipalnames WHERE name CONTAINS "PHANTOM") WITH m MATCH (n:NodeKind1)-[:EdgeKind1]->(g:NodeKind2) WHERE g.objectid ENDS WITH '-525' WITH m, COLLECT(n) AS matchingNs WHERE NONE(t IN matchingNs WHERE t.objectid = m.objectid) RETURN m with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((select count(*)::int from unnest(jsonb_to_text_array((n0.properties -> 'serviceprincipalnames'))) as i0 where (i0 like '%PHANTOM%')) >= 1)::bool) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s1.n0 as n0 from s1), s2 as (with s3 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, edge e0 join node n2 on ((n2.properties ->> 'objectid') like '%-525') and n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e0.end_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.start_id where e0.kind_id = any (array [3]::int2[])) select s3.n0 as n0, array_remove(coalesce(array_agg(s3.n1)::nodecomposite[], array []::nodecomposite[])::nodecomposite[], null)::nodecomposite[] as i1 from s3 group by n0) select s2.n0 as m from s2 where (((select count(*)::int from unnest(s2.i1) as i2 where ((i2.properties -> 'objectid') = ((s2.n0).properties -> 'objectid'))) = 0 and s2.i1 is not null)::bool); + -- case: WITH [1, 2] AS nums MATCH (n:NodeKind1) WHERE ANY(num IN nums + [3] WHERE num = 3) RETURN n with s0 as (select array [1, 2]::int8[] as i0), s1 as (select s0.i0 as i0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from s0, node n0 where (((select count(*)::int from unnest(s0.i0 || array [3]::int8[]) as i1 where (i1 = 3)) >= 1)::bool) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s1.n0 as n from s1; diff --git a/cypher/models/pgsql/test/translation_cases/shortest_paths.sql b/cypher/models/pgsql/test/translation_cases/shortest_paths.sql index c18e4de0..b6693945 100644 --- a/cypher/models/pgsql/test/translation_cases/shortest_paths.sql +++ b/cypher/models/pgsql/test/translation_cases/shortest_paths.sql @@ -16,71 +16,71 @@ -- case: match p = allShortestPaths((s:NodeKind1)-[*..]->()) return p -- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_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 edge where end_id = e0.start_id), e0.start_id = e0.end_id, array [e0.id] from s1_seed join edge e0 on e0.start_id = s1_seed.root_id where 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 s1.root_id, e0.end_id, s1.depth + 1, exists (select 1 from edge where end_id = e0.start_id), false, s1.path || e0.id from forward_front s1 join edge e0 on e0.start_id = s1.next_id where e0.id != all (s1.path);"} -with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_asp_harness(@pi0::text, @pi1::text, 15)) select 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where case when s1.root_id != s1.next_id then true else shortest_path_self_endpoint_error(s1.root_id, s1.next_id) end) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0; +with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_asp_harness(@pi0::text, @pi1::text, 15)) select 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where case when s1.root_id != s1.next_id then true else shortest_path_self_endpoint_error(s1.root_id, s1.next_id) end) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0; -- case: match p = allShortestPaths((s:NodeKind1)-[*..]->({name: "123"})) return p -- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where ((n1.properties -\u003e 'name'))::jsonb = to_jsonb(('123')::text)::jsonb) select e0.end_id, e0.start_id, 1, exists (select 1 from traversal_terminal_filter where traversal_terminal_filter.id = e0.start_id), e0.start_id = e0.end_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id where case when (select count(*)::int8 from traversal_terminal_filter where traversal_terminal_filter.id = e0.end_id) = 0 then true else shortest_path_self_endpoint_error(e0.end_id, e0.end_id) end;","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.start_id, s1.depth + 1, exists (select 1 from traversal_terminal_filter where traversal_terminal_filter.id = e0.start_id), false, e0.id || s1.path from forward_front s1 join edge e0 on e0.end_id = s1.next_id where e0.id != all (s1.path);"} -with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_asp_harness(@pi0::text, @pi1::text, 15, ('')::text, ('insert into traversal_terminal_filter (id) select distinct n0.id from node n0 where n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id is not null;')::text)) select 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 node n1 on n1.id = s1.root_id join node n0 on n0.id = s1.next_id where case when s1.root_id != s1.next_id then true else shortest_path_self_endpoint_error(s1.root_id, s1.next_id) end) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0; +with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_asp_harness(@pi0::text, @pi1::text, 15, ('')::text, ('insert into traversal_terminal_filter (id) select distinct n0.id from node n0 where n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id is not null;')::text)) select 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 node n1 on n1.id = s1.root_id join node n0 on n0.id = s1.next_id where case when s1.root_id != s1.next_id then true else shortest_path_self_endpoint_error(s1.root_id, s1.next_id) end) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0; -- case: match p = allShortestPaths((s:NodeKind1)-[*..]->(e)) where e.name = '123' return p -- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where (((n1.properties -\u003e 'name'))::jsonb = to_jsonb(('123')::text)::jsonb)) select e0.end_id, e0.start_id, 1, exists (select 1 from traversal_terminal_filter where traversal_terminal_filter.id = e0.start_id), e0.start_id = e0.end_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id where case when (select count(*)::int8 from traversal_terminal_filter where traversal_terminal_filter.id = e0.end_id) = 0 then true else shortest_path_self_endpoint_error(e0.end_id, e0.end_id) end;","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.start_id, s1.depth + 1, exists (select 1 from traversal_terminal_filter where traversal_terminal_filter.id = e0.start_id), false, e0.id || s1.path from forward_front s1 join edge e0 on e0.end_id = s1.next_id where e0.id != all (s1.path);"} -with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_asp_harness(@pi0::text, @pi1::text, 15, ('')::text, ('insert into traversal_terminal_filter (id) select distinct n0.id from node n0 where n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id is not null;')::text)) select 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 node n1 on n1.id = s1.root_id join node n0 on n0.id = s1.next_id where case when s1.root_id != s1.next_id then true else shortest_path_self_endpoint_error(s1.root_id, s1.next_id) end) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0; +with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_asp_harness(@pi0::text, @pi1::text, 15, ('')::text, ('insert into traversal_terminal_filter (id) select distinct n0.id from node n0 where n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id is not null;')::text)) select 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 node n1 on n1.id = s1.root_id join node n0 on n0.id = s1.next_id where case when s1.root_id != s1.next_id then true else shortest_path_self_endpoint_error(s1.root_id, s1.next_id) end) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0; -- case: match p=shortestPath((n:NodeKind1)-[:EdgeKind1*1..]->(m)) where 'admin_tier_0' in split(m.system_tags, ' ') and n.objectid ends with '-513' and n<>m return p limit 1000 -- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where ((n0.properties -\u003e\u003e 'objectid') like '%-513') 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 s1_seed join edge e0 on e0.start_id = s1_seed.root_id where e0.kind_id = any (array [3]::int2[]);","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.end_id, s1.depth + 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = s1.root_id and traversal_pair_filter.terminal_id = e0.end_id), false, s1.path || e0.id from forward_front s1 join edge e0 on e0.start_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path) and not exists (select 1 from forward_visited where forward_visited.root_id = s1.root_id and forward_visited.id = e0.end_id);","pi2":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where ('admin_tier_0' = any (string_to_array((n1.properties -\u003e\u003e 'system_tags'), ' ')::text[]))) 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 s1_seed join edge e0 on e0.end_id = s1_seed.root_id where e0.kind_id = any (array [3]::int2[]);","pi3":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.start_id, s1.depth + 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = e0.start_id and traversal_pair_filter.terminal_id = s1.root_id), false, e0.id || s1.path from backward_front s1 join edge e0 on e0.end_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path) and not exists (select 1 from backward_visited where backward_visited.root_id = s1.root_id and backward_visited.id = e0.start_id);"} -with s0 as (with s1(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 ->> ''objectid'') like ''%-513'') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (''admin_tier_0'' = any (string_to_array((n1.properties ->> ''system_tags''), '' '')::text[])) and n0.id is not null and n1.id is not null;')::text, (1000)::int8)) select 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 where ((s0.n0).id <> (s0.n1).id) limit 1000; +with s0 as (with s1(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 ->> ''objectid'') like ''%-513'') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (''admin_tier_0'' = any (string_to_array((n1.properties ->> ''system_tags''), '' '')::text[])) and n0.id is not null and n1.id is not null;')::text, (1000)::int8)) select 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 where ((s0.n0).id <> (s0.n1).id) limit 1000; -- case: match p=shortestPath((n:NodeKind1)-[:EdgeKind1*1..]->(m)) where 'admin_tier_0' in split(m.system_tags, ' ') and n.objectid ends with '-513' and m<>n return p limit 1000 -- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where ((n0.properties -\u003e\u003e 'objectid') like '%-513') 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 s1_seed join edge e0 on e0.start_id = s1_seed.root_id where e0.kind_id = any (array [3]::int2[]);","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.end_id, s1.depth + 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = s1.root_id and traversal_pair_filter.terminal_id = e0.end_id), false, s1.path || e0.id from forward_front s1 join edge e0 on e0.start_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path) and not exists (select 1 from forward_visited where forward_visited.root_id = s1.root_id and forward_visited.id = e0.end_id);","pi2":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where ('admin_tier_0' = any (string_to_array((n1.properties -\u003e\u003e 'system_tags'), ' ')::text[]))) 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 s1_seed join edge e0 on e0.end_id = s1_seed.root_id where e0.kind_id = any (array [3]::int2[]);","pi3":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.start_id, s1.depth + 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = e0.start_id and traversal_pair_filter.terminal_id = s1.root_id), false, e0.id || s1.path from backward_front s1 join edge e0 on e0.end_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path) and not exists (select 1 from backward_visited where backward_visited.root_id = s1.root_id and backward_visited.id = e0.start_id);"} -with s0 as (with s1(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 ->> ''objectid'') like ''%-513'') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (''admin_tier_0'' = any (string_to_array((n1.properties ->> ''system_tags''), '' '')::text[])) and n0.id is not null and n1.id is not null;')::text, (1000)::int8)) select 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 where ((s0.n1).id <> (s0.n0).id) limit 1000; +with s0 as (with s1(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 ->> ''objectid'') like ''%-513'') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (''admin_tier_0'' = any (string_to_array((n1.properties ->> ''system_tags''), '' '')::text[])) and n0.id is not null and n1.id is not null;')::text, (1000)::int8)) select 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 where ((s0.n1).id <> (s0.n0).id) limit 1000; -- case: match p=shortestPath((t:NodeKind1)<-[:EdgeKind1|EdgeKind2*1..]-(s:NodeKind2)) where coalesce(t.system_tags, '') contains 'admin_tier_0' and t.name =~ 'name.*' and s<>t return p limit 1000 -- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (coalesce((n0.properties -\u003e\u003e 'system_tags'), '')::text like '%admin_tier_0%' and (n0.properties -\u003e\u003e 'name') ~ 'name.*') and n0.kind_ids operator (pg_catalog.@\u003e) array [1]::int2[]) select e0.end_id, e0.start_id, 1, exists (select 1 from traversal_terminal_filter where traversal_terminal_filter.id = e0.start_id), e0.start_id = e0.end_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id where e0.kind_id = any (array [3, 4]::int2[]);","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.start_id, s1.depth + 1, exists (select 1 from traversal_terminal_filter where traversal_terminal_filter.id = e0.start_id), false, s1.path || e0.id from forward_front s1 join edge e0 on e0.end_id = s1.next_id where e0.kind_id = any (array [3, 4]::int2[]) and e0.id != all (s1.path) and not exists (select 1 from visited where visited.root_id = s1.root_id and visited.id = e0.start_id);"} -with s0 as (with s1(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, (1000)::int8)) select 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 where ((s0.n1).id <> (s0.n0).id) limit 1000; +with s0 as (with s1(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, (1000)::int8)) select 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 where ((s0.n1).id <> (s0.n0).id) limit 1000; -- case: match p=shortestPath((a)-[:EdgeKind1*]->(b)) where id(a) = 1 and id(b) = 2 return p -- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (n0.id = 1)) 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 s1_seed join edge e0 on e0.start_id = s1_seed.root_id where e0.kind_id = any (array [3]::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 s1.root_id, e0.end_id, s1.depth + 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = s1.root_id and traversal_pair_filter.terminal_id = e0.end_id), false, s1.path || e0.id from forward_front s1 join edge e0 on e0.start_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path) and not exists (select 1 from forward_visited where forward_visited.root_id = s1.root_id and forward_visited.id = e0.end_id);","pi2":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where (n1.id = 2)) 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 s1_seed join edge e0 on e0.end_id = s1_seed.root_id where e0.kind_id = any (array [3]::int2[]);","pi3":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.start_id, s1.depth + 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = e0.start_id and traversal_pair_filter.terminal_id = s1.root_id), false, e0.id || s1.path from backward_front s1 join edge e0 on e0.end_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path) and not exists (select 1 from backward_visited where backward_visited.root_id = s1.root_id and backward_visited.id = e0.start_id);"} -with s0 as (with s1(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.id = 1) and (n1.id = 2) and n0.id is not null and n1.id is not null;')::text)) select 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where case when s1.root_id != s1.next_id then true else shortest_path_self_endpoint_error(s1.root_id, s1.next_id) end) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0; +with s0 as (with s1(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.id = 1) and (n1.id = 2) and n0.id is not null and n1.id is not null;')::text)) select 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where case when s1.root_id != s1.next_id then true else shortest_path_self_endpoint_error(s1.root_id, s1.next_id) end) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0; -- case: match p=shortestPath((a)-[:EdgeKind1*]->(b:NodeKind1)) where a <> b return p -- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where n1.kind_ids operator (pg_catalog.@\u003e) array [1]::int2[]) select e0.end_id, e0.start_id, 1, exists (select 1 from edge where end_id = e0.end_id), e0.start_id = e0.end_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id where e0.kind_id = any (array [3]::int2[]);","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.start_id, s1.depth + 1, exists (select 1 from edge where end_id = e0.end_id), false, e0.id || s1.path from forward_front s1 join edge e0 on e0.end_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path) and not exists (select 1 from visited where visited.root_id = s1.root_id and visited.id = e0.start_id);"} -with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_sp_harness(@pi0::text, @pi1::text, 15)) select 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 node n1 on n1.id = s1.root_id join node n0 on n0.id = s1.next_id) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 where ((s0.n0).id <> (s0.n1).id); +with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_sp_harness(@pi0::text, @pi1::text, 15)) select 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 node n1 on n1.id = s1.root_id join node n0 on n0.id = s1.next_id) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 where ((s0.n0).id <> (s0.n1).id); -- case: match p=shortestPath((a:NodeKind2)-[:EdgeKind1*]->(b)) where a <> b return p -- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where n0.kind_ids operator (pg_catalog.@\u003e) array [2]::int2[]) select e0.start_id, e0.end_id, 1, exists (select 1 from edge where end_id = e0.start_id), e0.start_id = e0.end_id, array [e0.id] from s1_seed join edge e0 on e0.start_id = s1_seed.root_id where e0.kind_id = any (array [3]::int2[]);","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.end_id, s1.depth + 1, exists (select 1 from edge where end_id = e0.start_id), false, s1.path || e0.id from forward_front s1 join edge e0 on e0.start_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path) and not exists (select 1 from visited where visited.root_id = s1.root_id and visited.id = e0.end_id);"} -with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_sp_harness(@pi0::text, @pi1::text, 15)) select 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 where ((s0.n0).id <> (s0.n1).id); +with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_sp_harness(@pi0::text, @pi1::text, 15)) select 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 where ((s0.n0).id <> (s0.n1).id); -- case: match p=shortestPath((b)<-[:EdgeKind1*]-(a)) where id(a) = 1 and id(b) = 2 return p -- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (n0.id = 2)) select e0.end_id, e0.start_id, 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = e0.end_id and traversal_pair_filter.terminal_id = e0.start_id), e0.start_id = e0.end_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id where e0.kind_id = any (array [3]::int2[]) and case when (select count(*)::int8 from traversal_pair_filter where traversal_pair_filter.root_id = e0.end_id and traversal_pair_filter.terminal_id = e0.end_id) = 0 then true else shortest_path_self_endpoint_error(e0.end_id, e0.end_id) end;","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.start_id, s1.depth + 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = s1.root_id and traversal_pair_filter.terminal_id = e0.start_id), false, s1.path || e0.id from forward_front s1 join edge e0 on e0.end_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path) and not exists (select 1 from forward_visited where forward_visited.root_id = s1.root_id and forward_visited.id = e0.start_id);","pi2":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where (n1.id = 1)) select e0.start_id, e0.end_id, 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = e0.end_id and traversal_pair_filter.terminal_id = e0.start_id), e0.start_id = e0.end_id, array [e0.id] from s1_seed join edge e0 on e0.start_id = s1_seed.root_id where e0.kind_id = any (array [3]::int2[]);","pi3":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.end_id, s1.depth + 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = e0.end_id and traversal_pair_filter.terminal_id = s1.root_id), false, e0.id || s1.path from backward_front s1 join edge e0 on e0.start_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path) and not exists (select 1 from backward_visited where backward_visited.root_id = s1.root_id and backward_visited.id = e0.end_id);"} -with s0 as (with s1(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.id = 2) and (n1.id = 1) and n0.id is not null and n1.id is not null;')::text)) select 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where case when s1.root_id != s1.next_id then true else shortest_path_self_endpoint_error(s1.root_id, s1.next_id) end) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0; +with s0 as (with s1(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.id = 2) and (n1.id = 1) and n0.id is not null and n1.id is not null;')::text)) select 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where case when s1.root_id != s1.next_id then true else shortest_path_self_endpoint_error(s1.root_id, s1.next_id) end) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0; -- case: match p = allShortestPaths((m:NodeKind1)<-[:EdgeKind1*..]-(n)) where coalesce(m.system_tags, '') contains 'admin_tier_0' and n.name = '123' and n <> m return p -- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where (((n1.properties -\u003e 'name'))::jsonb = to_jsonb(('123')::text)::jsonb)) 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 s1_seed join edge e0 on e0.start_id = s1_seed.root_id where e0.kind_id = any (array [3]::int2[]);","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.end_id, s1.depth + 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = s1.root_id and traversal_pair_filter.terminal_id = e0.end_id), false, s1.path || e0.id from forward_front s1 join edge e0 on e0.start_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path);","pi2":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (coalesce((n0.properties -\u003e\u003e 'system_tags'), '')::text like '%admin_tier_0%') and n0.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 s1_seed join edge e0 on e0.end_id = s1_seed.root_id where e0.kind_id = any (array [3]::int2[]);","pi3":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.start_id, s1.depth + 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = e0.start_id and traversal_pair_filter.terminal_id = s1.root_id), false, e0.id || s1.path from backward_front s1 join edge e0 on e0.end_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path);"} -with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from bidirectional_asp_harness(@pi0::text, @pi1::text, @pi2::text, @pi3::text, 15, ('')::text, ('')::text, ('insert into traversal_pair_filter (root_id, terminal_id) select distinct n1.id, n0.id from node n1, node n0 where (((n1.properties -> ''name''))::jsonb = to_jsonb((''123'')::text)::jsonb) and (coalesce((n0.properties ->> ''system_tags''), '''')::text like ''%admin_tier_0%'') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id is not null and n0.id is not null;')::text)) select 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 node n1 on n1.id = s1.root_id join node n0 on n0.id = s1.next_id) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 where ((s0.n1).id <> (s0.n0).id); +with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from bidirectional_asp_harness(@pi0::text, @pi1::text, @pi2::text, @pi3::text, 15, ('')::text, ('')::text, ('insert into traversal_pair_filter (root_id, terminal_id) select distinct n1.id, n0.id from node n1, node n0 where (((n1.properties -> ''name''))::jsonb = to_jsonb((''123'')::text)::jsonb) and (coalesce((n0.properties ->> ''system_tags''), '''')::text like ''%admin_tier_0%'') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id is not null and n0.id is not null;')::text)) select 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 node n1 on n1.id = s1.root_id join node n0 on n0.id = s1.next_id) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 where ((s0.n1).id <> (s0.n0).id); -- case: match p=shortestPath((a)-[:EdgeKind1*]->(b:NodeKind1)) where a <> b return p -- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where n1.kind_ids operator (pg_catalog.@\u003e) array [1]::int2[]) select e0.end_id, e0.start_id, 1, exists (select 1 from edge where end_id = e0.end_id), e0.start_id = e0.end_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id where e0.kind_id = any (array [3]::int2[]);","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.start_id, s1.depth + 1, exists (select 1 from edge where end_id = e0.end_id), false, e0.id || s1.path from forward_front s1 join edge e0 on e0.end_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path) and not exists (select 1 from visited where visited.root_id = s1.root_id and visited.id = e0.start_id);"} -with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_sp_harness(@pi0::text, @pi1::text, 15)) select 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 node n1 on n1.id = s1.root_id join node n0 on n0.id = s1.next_id) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 where ((s0.n0).id <> (s0.n1).id); +with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_sp_harness(@pi0::text, @pi1::text, 15)) select 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 node n1 on n1.id = s1.root_id join node n0 on n0.id = s1.next_id) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 where ((s0.n0).id <> (s0.n1).id); -- case: match p=(c:NodeKind1)-[]->(u:NodeKind2) match p2=shortestPath((u:NodeKind2)-[*1..]->(d:NodeKind1)) return p, p2 limit 500 -- 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 distinct n1.id as root_id from traversal_root_filter s2_seed_filter join node n1 on n1.id = s2_seed_filter.id where n1.kind_ids operator (pg_catalog.@\u003e) array [2]::int2[]) select e1.start_id, e1.end_id, 1, exists (select 1 from traversal_terminal_filter where traversal_terminal_filter.id = e1.end_id), e1.start_id = e1.end_id, array [e1.id] from s2_seed join edge e1 on e1.start_id = s2_seed.root_id where case when (select count(*)::int8 from traversal_terminal_filter where traversal_terminal_filter.id = e1.start_id) = 0 then true else shortest_path_self_endpoint_error(e1.start_id, e1.start_id) end;","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s2.root_id, e1.end_id, s2.depth + 1, exists (select 1 from traversal_terminal_filter where traversal_terminal_filter.id = e1.end_id), false, s2.path || e1.id from forward_front s2 join edge e1 on e1.start_id = s2.next_id where e1.id != all (s2.path) and not exists (select 1 from visited where visited.root_id = s2.root_id and visited.id = e1.end_id);"} -with s0 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 [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), s1 as (with s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_sp_harness(@pi0::text, @pi1::text, 15, ('insert into traversal_root_filter (id) select distinct (s0.n1).id from s0 where (s0.n1).id is not null;')::text, ('insert into traversal_terminal_filter (id) select distinct n2.id from node n2 where n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id is not null;')::text)) select s0.e0 as e0, s2.path as ep0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join node n1 on n1.id = s2.root_id join node n2 on n2.id = s2.next_id where (s0.n1).id = s2.root_id and 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, (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 [s1.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite as p, ordered_edges_to_path(s1.n1, (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(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n1, s1.n2]::nodecomposite[])::pathcomposite as p2 from s1 limit 500; +with s0 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 [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), s1 as (with s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_sp_harness(@pi0::text, @pi1::text, 15, ('insert into traversal_root_filter (id) select distinct (s0.n1).id from s0 where (s0.n1).id is not null;')::text, ('insert into traversal_terminal_filter (id) select distinct n2.id from node n2 where n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id is not null;')::text)) select s0.e0 as e0, s2.path as ep0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join node n1 on n1.id = s2.root_id join node n2 on n2.id = s2.next_id where (s0.n1).id = s2.root_id and case when s2.root_id != s2.next_id then true else shortest_path_self_endpoint_error(s2.root_id, s2.next_id) end) select case when (s1.n0).id is null or s1.e0 is null or (s1.n1).id is null then null else ordered_edges_to_path(s1.n0, (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 [s1.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite end as p, case when (s1.n1).id is null or s1.ep0 is null or (s1.n2).id is null then null else ordered_edges_to_path(s1.n1, (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(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n1, s1.n2]::nodecomposite[])::pathcomposite end as p2 from s1 limit 500; -- case: match p = allShortestPaths((a)-[:EdgeKind1*..]->()) return p -- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select e0.start_id, e0.end_id, 1, exists (select 1 from edge where end_id = e0.start_id), e0.start_id = e0.end_id, array [e0.id] from edge e0 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 s1.root_id, e0.end_id, s1.depth + 1, exists (select 1 from edge where end_id = e0.start_id), false, s1.path || e0.id from forward_front s1 join edge e0 on e0.start_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path);"} -with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_asp_harness(@pi0::text, @pi1::text, 15)) select 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where case when s1.root_id != s1.next_id then true else shortest_path_self_endpoint_error(s1.root_id, s1.next_id) end) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0; +with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_asp_harness(@pi0::text, @pi1::text, 15)) select 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where case when s1.root_id != s1.next_id then true else shortest_path_self_endpoint_error(s1.root_id, s1.next_id) end) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0; -- case: match p=shortestPath((n:NodeKind1)-[:EdgeKind1*1..]->(m:NodeKind2)) return p limit 10 -- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_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 s1_seed join edge e0 on e0.start_id = s1_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 s1.root_id, e0.end_id, s1.depth + 1, exists (select 1 from traversal_terminal_filter where traversal_terminal_filter.id = e0.end_id), false, s1.path || e0.id from forward_front s1 join edge e0 on e0.start_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path) and not exists (select 1 from visited where visited.root_id = s1.root_id and visited.id = e0.end_id);"} -with s0 as (with s1(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, (10)::int8)) select 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where case when s1.root_id != s1.next_id then true else shortest_path_self_endpoint_error(s1.root_id, s1.next_id) end) select ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 limit 10; +with s0 as (with s1(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, (10)::int8)) select 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where case when s1.root_id != s1.next_id then true else shortest_path_self_endpoint_error(s1.root_id, s1.next_id) end) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 limit 10; -- case: match (a:NodeKind1), (b:NodeKind2) match p=shortestPath((a)-[:EdgeKind1*]->(b)) 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 [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 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 [3]::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 [3]::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 [3]::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 [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.kind_ids operator (pg_catalog.@>) array [2]::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 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, (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(s2.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1]::nodecomposite[])::pathcomposite as p 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.kind_ids operator (pg_catalog.@>) array [2]::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 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 case when (s2.n0).id is null or s2.ep0 is null or (s2.n1).id is null then null else ordered_edges_to_path(s2.n0, (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(s2.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1]::nodecomposite[])::pathcomposite end as p from s2; -- case: match (a:NodeKind1), (b:NodeKind2) match p=allShortestPaths((a)-[:EdgeKind1*..]->(b)) 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 [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 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 [3]::int2[]) and e0.id != all (s3.path);","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 [3]::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 [3]::int2[]) and e0.id != all (s3.path);"} -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.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s2 as (with s3(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from bidirectional_asp_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 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, (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(s2.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1]::nodecomposite[])::pathcomposite as p 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.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s2 as (with s3(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from bidirectional_asp_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 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 case when (s2.n0).id is null or s2.ep0 is null or (s2.n1).id is null then null else ordered_edges_to_path(s2.n0, (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(s2.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1]::nodecomposite[])::pathcomposite end as p from s2; -- 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);"} @@ -88,8 +88,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 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, (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(s2.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1]::nodecomposite[])::pathcomposite as p from s2 where (((select count(*)::int from unnest(((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(s2.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id))::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 ((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(s2.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id))::edgecomposite[] is not null)::bool); +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 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 case when (s2.n0).id is null or s2.ep0 is null or (s2.n1).id is null then null else ordered_edges_to_path(s2.n0, (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(s2.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1]::nodecomposite[])::pathcomposite end as p from s2 where (((select count(*)::int from unnest(((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(s2.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id))::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 ((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(s2.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id))::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 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, (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(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), 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); +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 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 case when (s1.n0).id is null or s1.ep0 is null or (s1.n1).id is null then null else ordered_edges_to_path(s1.n0, (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(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite end 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/test/translation_cases/stepwise_traversal.sql b/cypher/models/pgsql/test/translation_cases/stepwise_traversal.sql index 28f784cf..df1fffb5 100644 --- a/cypher/models/pgsql/test/translation_cases/stepwise_traversal.sql +++ b/cypher/models/pgsql/test/translation_cases/stepwise_traversal.sql @@ -42,7 +42,7 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from 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), s1 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1 from s0, edge e1 join node n2 on n2.id = e1.start_id join node n3 on n3.id = e1.end_id) select s1.e0 as r, s1.e1 as e from s1; -- case: match p = (:NodeKind1)-[:EdgeKind1|EdgeKind2]->(c:NodeKind2) where '123' in c.prop2 or '243' in c.prop2 or size(c.prop2) = 0 return p limit 10 -with s0 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 [1]::int2[] and n0.id = e0.start_id join node n1 on ('123' = any (jsonb_to_text_array((n1.properties -> 'prop2'))::text[]) or '243' = any (jsonb_to_text_array((n1.properties -> 'prop2'))::text[]) or jsonb_array_length((n1.properties -> 'prop2'))::int = 0) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[]) limit 10) select ordered_edges_to_path(s0.n0, (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 [s0.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite as p from s0 limit 10; +with s0 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 [1]::int2[] and n0.id = e0.start_id join node n1 on ('123' = any (jsonb_to_text_array((n1.properties -> 'prop2'))::text[]) or '243' = any (jsonb_to_text_array((n1.properties -> 'prop2'))::text[]) or jsonb_array_length((n1.properties -> 'prop2'))::int = 0) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[]) limit 10) select case when (s0.n0).id is null or s0.e0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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 [s0.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 limit 10; -- case: match ()-[r:EdgeKind1]->() return count(r) as the_count 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 = any (array [3]::int2[])) select count(s0.e0)::int8 as the_count from s0; @@ -80,19 +80,19 @@ with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::e with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (not ((n0.properties ->> 'bool_field'))::bool)), 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, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, edge e0 join node n1 on (((n1.properties -> 'name'))::jsonb = to_jsonb(('123')::text)::jsonb) and n1.id = e0.start_id join node n2 on (((n2.properties -> 'name'))::jsonb = to_jsonb(('321')::text)::jsonb) and n2.id = e0.end_id) select s1.n0 as f, s1.n1 as s, s1.e0 as r, s1.n2 as e from s1; -- case: match ()-[e0]->(n)<-[e1]-() return e0, n, e1 -with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (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), s1 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.n1 as n1 from s0 join edge e1 on (s0.n1).id = e1.end_id join node n2 on n2.id = e1.start_id) select s1.e0 as e0, s1.n1 as n, s1.e1 as e1 from s1; +with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (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), s1 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.n1 as n1 from s0 join edge e1 on (s0.n1).id = e1.end_id join node n2 on n2.id = e1.start_id where e1.id != (s0.e0).id) select s1.e0 as e0, s1.n1 as n, s1.e1 as e1 from s1; -- case: match ()-[e0]->(n)-[e1]->() return e0, n, e1 -with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (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), s1 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.n1 as n1 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id) select s1.e0 as e0, s1.n1 as n, s1.e1 as e1 from s1; +with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (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), s1 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.n1 as n1 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.id != (s0.e0).id) select s1.e0 as e0, s1.n1 as n, s1.e1 as e1 from s1; -- case: match ()<-[e0]-(n)<-[e1]-() return e0, n, e1 -with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on n0.id = e0.end_id join node n1 on n1.id = e0.start_id), s1 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.n1 as n1 from s0 join edge e1 on (s0.n1).id = e1.end_id join node n2 on n2.id = e1.start_id) select s1.e0 as e0, s1.n1 as n, s1.e1 as e1 from s1; +with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on n0.id = e0.end_id join node n1 on n1.id = e0.start_id), s1 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.n1 as n1 from s0 join edge e1 on (s0.n1).id = e1.end_id join node n2 on n2.id = e1.start_id where e1.id != (s0.e0).id) select s1.e0 as e0, s1.n1 as n, s1.e1 as e1 from s1; -- case: match (s)<-[r:EdgeKind1|EdgeKind2]-(e) 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.id = e0.end_id join node n1 on n1.id = e0.start_id where e0.kind_id = any (array [3, 4]::int2[])) select ((s0.n0).properties -> 'name'), ((s0.n1).properties -> 'name') from s0; -- case: match (s)-[:EdgeKind1|EdgeKind2]->(e)-[:EdgeKind1]->() return s.name as s_name, e.name as 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.id = e0.start_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[])), s1 as (select s0.n0 as n0, s0.n1 as n1 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.kind_id = any (array [3]::int2[])) select ((s1.n0).properties -> 'name') as s_name, ((s1.n1).properties -> 'name') as e_name from s1; +with s0 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.id = e0.start_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[])), s1 as (select s0.e0 as e0, s0.n0 as n0, s0.n1 as n1 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.kind_id = any (array [3]::int2[]) and e1.id != s0.e0) select ((s1.n0).properties -> 'name') as s_name, ((s1.n1).properties -> 'name') as e_name from s1; -- case: match (s:NodeKind1)-[r:EdgeKind1|EdgeKind2]->(e:NodeKind2) 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]::int2[] and n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[])) select ((s0.n0).properties -> 'name'), ((s0.n1).properties -> 'name') from s0; @@ -113,7 +113,7 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1 with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.id = e0.end_id where (n1.id <> n0.id)) select s0.n1 as n2 from s0; -- case: match ()-[r]->()-[e]->(n) where r <> e return n -with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (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), s1 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where ((s0.e0).id <> e1.id)) select s1.n2 as n from s1; +with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (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), s1 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where ((s0.e0).id <> e1.id) and e1.id != (s0.e0).id) select s1.n2 as n from s1; -- 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/update.sql b/cypher/models/pgsql/test/translation_cases/update.sql index 5fd99b66..8e54475d 100644 --- a/cypher/models/pgsql/test/translation_cases/update.sql +++ b/cypher/models/pgsql/test/translation_cases/update.sql @@ -69,4 +69,4 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from 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.kind_ids operator (pg_catalog.@>) array [1]::int2[]) and n0.id = e0.start_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[])), s1 as (update edge e1 set properties = e1.properties || jsonb_build_object('visited', true)::jsonb from s0 where (s0.e0).id = e1.id returning (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e0, s0.n0 as n0) select s1.e0 as r from s1; -- case: match (n)-[]->()-[r]->() where n.name = 'n1' set r.visited = true return r.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.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb) and n0.id = e0.start_id join node n1 on n1.id = e0.end_id), s1 as (select (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.n0 as n0, s0.n1 as n1 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id), s2 as (update edge e2 set properties = e2.properties || jsonb_build_object('visited', true)::jsonb from s1 where (s1.e1).id = e2.id returning (e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties)::edgecomposite as e1, s1.n0 as n0, s1.n1 as n1) select ((s2.e1).properties -> 'name') from s2; +with s0 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.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb) and n0.id = e0.start_id join node n1 on n1.id = e0.end_id), s1 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.n0 as n0, s0.n1 as n1 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.id != s0.e0), s2 as (update edge e2 set properties = e2.properties || jsonb_build_object('visited', true)::jsonb from s1 where (s1.e1).id = e2.id returning s1.e0 as e0, (e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties)::edgecomposite as e1, s1.n0 as n0, s1.n1 as n1) select ((s2.e1).properties -> 'name') from s2; diff --git a/cypher/models/pgsql/translate/constraints.go b/cypher/models/pgsql/translate/constraints.go index dc9ea733..7ffa97be 100644 --- a/cypher/models/pgsql/translate/constraints.go +++ b/cypher/models/pgsql/translate/constraints.go @@ -439,12 +439,12 @@ type PatternConstraints struct { // // In cases that match this heuristic, it's beneficial to begin the traversal with the most tightly constrained set // of nodes. To accomplish this we flip the order of the traversal step. -func (s *PatternConstraints) OptimizePatternConstraintBalance(scope *Scope, traversalStep *TraversalStep) error { +func (s *PatternConstraints) OptimizePatternConstraintBalance(scope *Scope, traversalStep *TraversalStep) (bool, error) { // If the left node is already materialized from a previous step, it is the anchor // for this expansion. Flipping the traversal direction would detach it from the // previous frame and produce invalid SQL (missing FROM-clause entry). if traversalStep.LeftNodeBound { - return nil + return false, nil } if traversalStep.RightNodeBound { @@ -459,21 +459,22 @@ func (s *PatternConstraints) OptimizePatternConstraintBalance(scope *Scope, trav s.FlipNodes() } - return nil + return true, nil } if leftNodeSelectivity, err := MeasureSelectivity(scope, s.LeftNode.Expression); err != nil { - return err + return false, err } else if rightNodeSelectivity, err := MeasureSelectivity(scope, s.RightNode.Expression); err != nil { - return err + return false, err } else if rightNodeSelectivity-leftNodeSelectivity >= selectivityFlipThreshold { // (a)-[*..]->(b:Constraint) // (a)<-[*..]-(b:Constraint) traversalStep.FlipNodes() s.FlipNodes() + return true, nil } - return nil + return false, nil } func (s *PatternConstraints) FlipNodes() { diff --git a/cypher/models/pgsql/translate/expansion.go b/cypher/models/pgsql/translate/expansion.go index 9b26c76c..3d8a73d3 100644 --- a/cypher/models/pgsql/translate/expansion.go +++ b/cypher/models/pgsql/translate/expansion.go @@ -187,6 +187,49 @@ func newExpansionBoundNodeSeed(identifier pgsql.Identifier, previousFrame *Frame return seed } +func fromClausesContainSource(fromClauses []pgsql.FromClause, identifier pgsql.Identifier) bool { + for _, fromClause := range fromClauses { + if tableReference, isTableReference := fromClause.Source.(pgsql.TableReference); isTableReference && + len(tableReference.Name) == 1 && + tableReference.Name[0] == identifier { + return true + } + } + + return false +} + +func prependFrameSourceIfMissing(fromClauses []pgsql.FromClause, frame *Frame) []pgsql.FromClause { + if frame == nil || fromClausesContainSource(fromClauses, frame.Binding.Identifier) { + return fromClauses + } + + return append([]pgsql.FromClause{{ + Source: pgsql.TableReference{ + Name: pgsql.CompoundIdentifier{frame.Binding.Identifier}, + }, + }}, fromClauses...) +} + +func expressionReferencesUnwindBinding(expression pgsql.Expression, unwindClauses []UnwindClause) (bool, error) { + if expression == nil || len(unwindClauses) == 0 { + return false, nil + } + + references, err := ExtractSyntaxNodeReferences(expression) + if err != nil { + return false, err + } + + for _, clause := range unwindClauses { + if clause.Binding != nil && references.Contains(clause.Binding.Identifier) { + return true, nil + } + } + + return false, nil +} + func newExpansionRootIDsParameterSeed(identifier, nodeIdentifier pgsql.Identifier, constraints pgsql.Expression) expansionSeed { return newExpansionNodeFilterSeed(identifier, expansionRootFilter, nodeIdentifier, constraints) } @@ -1670,33 +1713,11 @@ func (s *ExpansionBuilder) BuildAllShortestPathsRoot() (pgsql.Query, error) { } func (s *ExpansionBuilder) canMaterializeTerminalFilter(expansionModel *Expansion) bool { - if expansionModel.TerminalNodeConstraints == nil || s.usesBoundEndpointPairs() || s.usesBoundTerminalIDs() { - return false - } - - // Terminal filters are only useful as standalone SQL when they depend solely - // on the terminal node; external references must stay in the main query. - _, externalConstraints := partitionConstraintByLocality( - expansionModel.TerminalNodeConstraints, - pgsql.AsIdentifierSet(s.traversalStep.RightNode.Identifier), - ) - - return externalConstraints == nil + return canMaterializeTerminalFilterForStep(s.traversalStep, expansionModel) } func (s *ExpansionBuilder) canMaterializeEndpointPairFilter(expansionModel *Expansion) bool { - // Pair filters enumerate the exact root/terminal combinations the - // bidirectional harness must resolve. Kind-only endpoint predicates are not - // enough because they do not constrain the search columns used by the harness. - if s.usesBoundEndpointPairs() || - expansionModel.PrimerNodeConstraints == nil || - expansionModel.TerminalNodeConstraints == nil || - !hasLocalEndpointConstraint(expansionModel.PrimerNodeConstraints, s.traversalStep.LeftNode.Identifier) || - !hasLocalEndpointConstraint(expansionModel.TerminalNodeConstraints, s.traversalStep.RightNode.Identifier) { - return false - } - - return true + return canMaterializeEndpointPairFilterForStep(s.traversalStep, expansionModel) } func (s *ExpansionBuilder) buildBiDirectionalShortestPathsHarnessCall(harnessFunctionName pgsql.Identifier) (pgsql.Query, error) { @@ -1995,11 +2016,293 @@ func (s *ExpansionBuilder) Build(expansionIdentifier pgsql.Identifier, commonTab return query } +func projectionAliasExpressions(projection pgsql.Projection) map[pgsql.Identifier]pgsql.Expression { + aliases := make(map[pgsql.Identifier]pgsql.Expression) + + for _, selectItem := range projection { + switch typedSelectItem := selectItem.(type) { + case *pgsql.AliasedExpression: + if typedSelectItem.Alias.Set { + aliases[typedSelectItem.Alias.Value] = typedSelectItem.Expression + } + + case pgsql.AliasedExpression: + if typedSelectItem.Alias.Set { + aliases[typedSelectItem.Alias.Value] = typedSelectItem.Expression + } + + case pgsql.Identifier: + aliases[typedSelectItem] = typedSelectItem + + case pgsql.CompoundIdentifier: + if len(typedSelectItem) > 0 { + aliases[typedSelectItem[len(typedSelectItem)-1]] = typedSelectItem + } + } + } + + return aliases +} + +func rewriteCurrentFrameProjectionSetExpression(setExpression pgsql.SetExpression, frameID pgsql.Identifier, aliases map[pgsql.Identifier]pgsql.Expression) pgsql.SetExpression { + switch typedSetExpression := setExpression.(type) { + case pgsql.Select: + return rewriteCurrentFrameProjectionSelect(typedSetExpression, frameID, aliases) + + case pgsql.SetOperation: + typedSetExpression.LOperand = rewriteCurrentFrameProjectionSetExpression(typedSetExpression.LOperand, frameID, aliases) + typedSetExpression.ROperand = rewriteCurrentFrameProjectionSetExpression(typedSetExpression.ROperand, frameID, aliases) + return typedSetExpression + + default: + return setExpression + } +} + +func rewriteCurrentFrameProjectionQuery(query pgsql.Query, frameID pgsql.Identifier, aliases map[pgsql.Identifier]pgsql.Expression) pgsql.Query { + query.Body = rewriteCurrentFrameProjectionSetExpression(query.Body, frameID, aliases) + + for idx, orderBy := range query.OrderBy { + if orderBy != nil { + query.OrderBy[idx].Expression = rewriteCurrentFrameProjectionReferences(orderBy.Expression, frameID, aliases) + } + } + + query.Offset = rewriteCurrentFrameProjectionReferences(query.Offset, frameID, aliases) + query.Limit = rewriteCurrentFrameProjectionReferences(query.Limit, frameID, aliases) + + return query +} + +func rewriteCurrentFrameProjectionSelect(selectBody pgsql.Select, frameID pgsql.Identifier, aliases map[pgsql.Identifier]pgsql.Expression) pgsql.Select { + for idx, selectItem := range selectBody.Projection { + if rewritten, isSelectItem := rewriteCurrentFrameProjectionReferences(selectItem, frameID, aliases).(pgsql.SelectItem); isSelectItem { + selectBody.Projection[idx] = rewritten + } + } + + for idx := range selectBody.From { + selectBody.From[idx].Source = rewriteCurrentFrameProjectionReferences(selectBody.From[idx].Source, frameID, aliases) + + for joinIdx := range selectBody.From[idx].Joins { + selectBody.From[idx].Joins[joinIdx].Table = rewriteCurrentFrameProjectionReferences(selectBody.From[idx].Joins[joinIdx].Table, frameID, aliases) + selectBody.From[idx].Joins[joinIdx].JoinOperator.Constraint = rewriteCurrentFrameProjectionReferences(selectBody.From[idx].Joins[joinIdx].JoinOperator.Constraint, frameID, aliases) + } + } + + selectBody.Where = rewriteCurrentFrameProjectionReferences(selectBody.Where, frameID, aliases) + + for idx, groupByExpression := range selectBody.GroupBy { + selectBody.GroupBy[idx] = rewriteCurrentFrameProjectionReferences(groupByExpression, frameID, aliases) + } + + selectBody.Having = rewriteCurrentFrameProjectionReferences(selectBody.Having, frameID, aliases) + + return selectBody +} + +func rewriteCurrentFrameProjectionReferences(expression pgsql.Expression, frameID pgsql.Identifier, aliases map[pgsql.Identifier]pgsql.Expression) pgsql.Expression { + if expression == nil { + return nil + } + + switch typedExpression := expression.(type) { + case pgsql.CompoundIdentifier: + if len(typedExpression) == 2 && typedExpression[0] == frameID { + if replacement, hasReplacement := aliases[typedExpression[1]]; hasReplacement { + return replacement + } + } + + return typedExpression + + case pgsql.RowColumnReference: + typedExpression.Identifier = rewriteCurrentFrameProjectionReferences(typedExpression.Identifier, frameID, aliases) + return typedExpression + + case pgsql.UnaryExpression: + typedExpression.Operand = rewriteCurrentFrameProjectionReferences(typedExpression.Operand, frameID, aliases) + return typedExpression + + case *pgsql.UnaryExpression: + typedExpression.Operand = rewriteCurrentFrameProjectionReferences(typedExpression.Operand, frameID, aliases) + return typedExpression + + case pgsql.BinaryExpression: + typedExpression.LOperand = rewriteCurrentFrameProjectionReferences(typedExpression.LOperand, frameID, aliases) + typedExpression.ROperand = rewriteCurrentFrameProjectionReferences(typedExpression.ROperand, frameID, aliases) + return typedExpression + + case *pgsql.BinaryExpression: + typedExpression.LOperand = rewriteCurrentFrameProjectionReferences(typedExpression.LOperand, frameID, aliases) + typedExpression.ROperand = rewriteCurrentFrameProjectionReferences(typedExpression.ROperand, frameID, aliases) + return typedExpression + + case pgsql.FunctionCall: + for idx, parameter := range typedExpression.Parameters { + typedExpression.Parameters[idx] = rewriteCurrentFrameProjectionReferences(parameter, frameID, aliases) + } + return typedExpression + + case *pgsql.FunctionCall: + for idx, parameter := range typedExpression.Parameters { + typedExpression.Parameters[idx] = rewriteCurrentFrameProjectionReferences(parameter, frameID, aliases) + } + return typedExpression + + case pgsql.TypeCast: + typedExpression.Expression = rewriteCurrentFrameProjectionReferences(typedExpression.Expression, frameID, aliases) + return typedExpression + + case pgsql.CompositeValue: + for idx, value := range typedExpression.Values { + typedExpression.Values[idx] = rewriteCurrentFrameProjectionReferences(value, frameID, aliases) + } + return typedExpression + + case *pgsql.Parenthetical: + typedExpression.Expression = rewriteCurrentFrameProjectionReferences(typedExpression.Expression, frameID, aliases) + return typedExpression + + case *pgsql.EdgeArrayFromPathIDs: + typedExpression.PathIDs = rewriteCurrentFrameProjectionReferences(typedExpression.PathIDs, frameID, aliases) + return typedExpression + + case pgsql.ArrayLiteral: + for idx, value := range typedExpression.Values { + typedExpression.Values[idx] = rewriteCurrentFrameProjectionReferences(value, frameID, aliases) + } + return typedExpression + + case pgsql.ArrayExpression: + typedExpression.Expression = rewriteCurrentFrameProjectionReferences(typedExpression.Expression, frameID, aliases) + return typedExpression + + case pgsql.ArrayIndex: + typedExpression.Expression = rewriteCurrentFrameProjectionReferences(typedExpression.Expression, frameID, aliases) + for idx, index := range typedExpression.Indexes { + typedExpression.Indexes[idx] = rewriteCurrentFrameProjectionReferences(index, frameID, aliases) + } + return typedExpression + + case *pgsql.ArrayIndex: + typedExpression.Expression = rewriteCurrentFrameProjectionReferences(typedExpression.Expression, frameID, aliases) + for idx, index := range typedExpression.Indexes { + typedExpression.Indexes[idx] = rewriteCurrentFrameProjectionReferences(index, frameID, aliases) + } + return typedExpression + + case pgsql.ArraySlice: + typedExpression.Expression = rewriteCurrentFrameProjectionReferences(typedExpression.Expression, frameID, aliases) + typedExpression.Lower = rewriteCurrentFrameProjectionReferences(typedExpression.Lower, frameID, aliases) + typedExpression.Upper = rewriteCurrentFrameProjectionReferences(typedExpression.Upper, frameID, aliases) + return typedExpression + + case *pgsql.ArraySlice: + typedExpression.Expression = rewriteCurrentFrameProjectionReferences(typedExpression.Expression, frameID, aliases) + typedExpression.Lower = rewriteCurrentFrameProjectionReferences(typedExpression.Lower, frameID, aliases) + typedExpression.Upper = rewriteCurrentFrameProjectionReferences(typedExpression.Upper, frameID, aliases) + return typedExpression + + case pgsql.AllExpression: + typedExpression.Expression = rewriteCurrentFrameProjectionReferences(typedExpression.Expression, frameID, aliases) + return typedExpression + + case *pgsql.AllExpression: + typedExpression.Expression = rewriteCurrentFrameProjectionReferences(typedExpression.Expression, frameID, aliases) + return typedExpression + + case pgsql.AnyExpression: + typedExpression.Expression = rewriteCurrentFrameProjectionReferences(typedExpression.Expression, frameID, aliases) + return typedExpression + + case *pgsql.AnyExpression: + typedExpression.Expression = rewriteCurrentFrameProjectionReferences(typedExpression.Expression, frameID, aliases) + return typedExpression + + case pgsql.Case: + typedExpression.Operand = rewriteCurrentFrameProjectionReferences(typedExpression.Operand, frameID, aliases) + for idx, condition := range typedExpression.Conditions { + typedExpression.Conditions[idx] = rewriteCurrentFrameProjectionReferences(condition, frameID, aliases) + } + for idx, then := range typedExpression.Then { + typedExpression.Then[idx] = rewriteCurrentFrameProjectionReferences(then, frameID, aliases) + } + typedExpression.Else = rewriteCurrentFrameProjectionReferences(typedExpression.Else, frameID, aliases) + return typedExpression + + case *pgsql.Case: + typedExpression.Operand = rewriteCurrentFrameProjectionReferences(typedExpression.Operand, frameID, aliases) + for idx, condition := range typedExpression.Conditions { + typedExpression.Conditions[idx] = rewriteCurrentFrameProjectionReferences(condition, frameID, aliases) + } + for idx, then := range typedExpression.Then { + typedExpression.Then[idx] = rewriteCurrentFrameProjectionReferences(then, frameID, aliases) + } + typedExpression.Else = rewriteCurrentFrameProjectionReferences(typedExpression.Else, frameID, aliases) + return typedExpression + + case pgsql.ExistsExpression: + typedExpression.Subquery.Query = rewriteCurrentFrameProjectionQuery(typedExpression.Subquery.Query, frameID, aliases) + return typedExpression + + case pgsql.Subquery: + typedExpression.Query = rewriteCurrentFrameProjectionQuery(typedExpression.Query, frameID, aliases) + return typedExpression + + case pgsql.Query: + return rewriteCurrentFrameProjectionQuery(typedExpression, frameID, aliases) + + case pgsql.Select: + return rewriteCurrentFrameProjectionSelect(typedExpression, frameID, aliases) + + case pgsql.SetOperation: + typedExpression.LOperand = rewriteCurrentFrameProjectionSetExpression(typedExpression.LOperand, frameID, aliases) + typedExpression.ROperand = rewriteCurrentFrameProjectionSetExpression(typedExpression.ROperand, frameID, aliases) + return typedExpression + + case pgsql.ProjectionFrom: + for idx, selectItem := range typedExpression.Projection { + if rewritten, isSelectItem := rewriteCurrentFrameProjectionReferences(selectItem, frameID, aliases).(pgsql.SelectItem); isSelectItem { + typedExpression.Projection[idx] = rewritten + } + } + for idx := range typedExpression.From { + typedExpression.From[idx].Source = rewriteCurrentFrameProjectionReferences(typedExpression.From[idx].Source, frameID, aliases) + for joinIdx := range typedExpression.From[idx].Joins { + typedExpression.From[idx].Joins[joinIdx].JoinOperator.Constraint = rewriteCurrentFrameProjectionReferences(typedExpression.From[idx].Joins[joinIdx].JoinOperator.Constraint, frameID, aliases) + } + } + return typedExpression + + case pgsql.AliasedExpression: + typedExpression.Expression = rewriteCurrentFrameProjectionReferences(typedExpression.Expression, frameID, aliases) + return typedExpression + + case *pgsql.AliasedExpression: + typedExpression.Expression = rewriteCurrentFrameProjectionReferences(typedExpression.Expression, frameID, aliases) + return typedExpression + + case pgsql.Variadic: + typedExpression.Expression = rewriteCurrentFrameProjectionReferences(typedExpression.Expression, frameID, aliases) + return typedExpression + + case pgsql.LateralSubquery: + typedExpression.Query = rewriteCurrentFrameProjectionQuery(typedExpression.Query, frameID, aliases) + return typedExpression + + default: + return expression + } +} + func (s *Translator) buildExpansionPatternRoot(traversalStepContext TraversalStepContext, expansion *ExpansionBuilder) (pgsql.Query, error) { var ( traversalStep = traversalStepContext.CurrentStep expansionModel = traversalStep.Expansion seedIdentifier = expansionSeedIdentifier(expansionModel.Frame.Binding.Identifier) + unwindClauses = s.query.CurrentPart().ConsumeUnwindClauses() + unwindSources = unwindFromClauses(unwindClauses) ) // Determine local scope of the primer: the edge and both nodes. @@ -2042,6 +2345,15 @@ func (s *Translator) buildExpansionPatternRoot(traversalStepContext TraversalSte expansion.UseUnionAll = true } + if seed != nil { + if seedNeedsUnwind, err := expressionReferencesUnwindBinding(seedConstraints, unwindClauses); err != nil { + return pgsql.Query{}, err + } else if seedNeedsUnwind { + seed.query.From = prependFrameSourceIfMissing(seed.query.From, traversalStep.Frame.Previous) + seed.query.From = append(seed.query.From, unwindSources...) + } + } + expansion.PrimerStatement.Where = expansionModel.EdgeConstraints expansion.ProjectionStatement.Projection = expansionModel.Projection @@ -2078,6 +2390,12 @@ func (s *Translator) buildExpansionPatternRoot(traversalStepContext TraversalSte } expansion.PrimerStatement.From = append(expansion.PrimerStatement.From, nextQueryFrom) + if primerNeedsUnwind, err := expressionReferencesUnwindBinding(expansionModel.EdgeConstraints, unwindClauses); err != nil { + return pgsql.Query{}, err + } else if primerNeedsUnwind { + expansion.PrimerStatement.From = prependFrameSourceIfMissing(expansion.PrimerStatement.From, traversalStep.Frame.Previous) + expansion.PrimerStatement.From = append(expansion.PrimerStatement.From, unwindSources...) + } if expansionAllowsZeroDepth(expansionModel) { zeroDepthStatement, err := expansion.buildZeroDepthExpansionSelect(seed) @@ -2123,6 +2441,8 @@ func (s *Translator) buildExpansionPatternRoot(traversalStepContext TraversalSte }) } + expansion.ProjectionStatement.From = append(expansion.ProjectionStatement.From, unwindSources...) + // Select the expansion components for the projection statement expansion.ProjectionStatement.From = append(expansion.ProjectionStatement.From, pgsql.FromClause{ Source: pgsql.TableReference{ @@ -2156,6 +2476,11 @@ func (s *Translator) buildExpansionPatternRoot(traversalStepContext TraversalSte ) } + projectionConstraints = rewriteCurrentFrameProjectionReferences( + projectionConstraints, + traversalStep.Frame.Binding.Identifier, + projectionAliasExpressions(expansion.ProjectionStatement.Projection), + ) expansion.ProjectionStatement.Where = projectionConstraints } @@ -2270,6 +2595,11 @@ func (s *Translator) buildExpansionPatternStep(traversalStepContext TraversalSte if projectionConstraints, err := s.buildExpansionProjectionConstraints(traversalStepContext); err != nil { return pgsql.Query{}, err } else { + projectionConstraints = rewriteCurrentFrameProjectionReferences( + projectionConstraints, + traversalStep.Frame.Binding.Identifier, + projectionAliasExpressions(expansion.ProjectionStatement.Projection), + ) expansion.ProjectionStatement.Where = projectionConstraints } @@ -2368,7 +2698,12 @@ func suffixStepEdgeConstraints(step *TraversalStep) pgsql.Expression { return nil } - return step.EdgeConstraints.Expression + localConstraints, _ := partitionConstraintByLocality( + step.EdgeConstraints.Expression, + pgsql.AsIdentifierSet(step.Edge.Identifier), + ) + + return localConstraints } func expansionSuffixTerminalSatisfaction(currentStep *TraversalStep, suffixSteps []*TraversalStep) (pgsql.Expression, bool) { @@ -2657,7 +2992,7 @@ func (s *Translator) translateTraversalPatternPartWithExpansion(part *PatternPar // Translate the expansion's constraints - this has the side effect of making the pattern identifiers visible in // the current scope frame - if err := s.translateExpansionConstraints(isFirstTraversalStep, traversalStep, expansionModel); err != nil { + if err := s.translateExpansionConstraints(part, stepIndex, isFirstTraversalStep, traversalStep, expansionModel); err != nil { return err } @@ -2666,7 +3001,7 @@ func (s *Translator) translateTraversalPatternPartWithExpansion(part *PatternPar if allowProjectionPruning { decision, hasDecision := s.projectionPruningDecision(part, stepIndex) allowFallback := !hasDecision && (part == nil || !part.HasTarget) - if pruneExpansionStepProjectionExports(s.query.CurrentPart(), part, traversalStep, decision, hasDecision, allowFallback) { + if pruneExpansionStepProjectionExports(s.query.CurrentPart(), part, stepIndex, traversalStep, decision, hasDecision, allowFallback) { s.recordLowering(optimize.LoweringProjectionPruning) } @@ -2729,7 +3064,7 @@ func (s *Translator) translateTraversalPatternPartWithExpansion(part *PatternPar } if expansionModel.Options.FindShortestPath || expansionModel.Options.FindAllShortestPaths { - if err := s.translateShortestPathTraversal(traversalStep, expansionModel); err != nil { + if err := s.translateShortestPathTraversal(part, stepIndex, traversalStep, expansionModel); err != nil { return err } } @@ -2737,13 +3072,13 @@ func (s *Translator) translateTraversalPatternPartWithExpansion(part *PatternPar return nil } -func (s *Translator) translateExpansionConstraints(isFirstTraversalStep bool, step *TraversalStep, expansionModel *Expansion) error { +func (s *Translator) translateExpansionConstraints(part *PatternPart, stepIndex int, isFirstTraversalStep bool, step *TraversalStep, expansionModel *Expansion) error { if constraints, err := consumePatternConstraints(isFirstTraversalStep, recursivePattern, step, s.treeTranslator); err != nil { return err } else { // If one side of the expansion has constraints but the other does not this may be an opportunity to reorder the traversal // to start with tighter search bounds - if err := constraints.OptimizePatternConstraintBalance(s.scope, step); err != nil { + if err := s.applyPatternConstraintBalance(part, stepIndex, &constraints, step); err != nil { return err } @@ -2810,13 +3145,13 @@ func (s *Translator) translateExpansionConstraints(isFirstTraversalStep bool, st return nil } -func (s *Translator) translateShortestPathTraversal(traversalStep *TraversalStep, expansionModel *Expansion) error { +func (s *Translator) translateShortestPathTraversal(part *PatternPart, stepIndex int, traversalStep *TraversalStep, expansionModel *Expansion) error { var ( useBidirectionalSearch bool err error ) - useBidirectionalSearch, err = traversalStep.CanExecutePairAwareBidirectionalSearch(s.scope) + useBidirectionalSearch, err = s.useBidirectionalShortestPathStrategy(part, stepIndex, traversalStep) if err != nil { return err @@ -2827,6 +3162,7 @@ func (s *Translator) translateShortestPathTraversal(traversalStep *TraversalStep traversalStep.LeftNode.Identifier, traversalStep.RightNode.Identifier, ) + s.applyShortestPathFilterMaterialization(part, stepIndex, traversalStep, expansionModel) // If this query is a shortest-path look up, the translator will have to use a function harness for // traversal. As such, query fragments for the traversal harness will have to be passed by the parameters diff --git a/cypher/models/pgsql/translate/limit_pushdown_test.go b/cypher/models/pgsql/translate/limit_pushdown_test.go index 7334b2fb..0bfd6ef9 100644 --- a/cypher/models/pgsql/translate/limit_pushdown_test.go +++ b/cypher/models/pgsql/translate/limit_pushdown_test.go @@ -75,6 +75,7 @@ func limitPushdownTestJoin(nodeAlias, expansionColumn pgsql.Identifier) pgsql.Jo func limitPushdownTestPart(harnessFunction pgsql.Identifier) *QueryPart { part := NewQueryPart(1, 0) part.Limit = pgsql.NewLiteral(10, pgsql.Int) + part.AllowLimitPushdown(limitPushdownTestSourceFrame) part.Model.AddCTE(pgsql.CommonTableExpression{ Alias: pgsql.TableAlias{Name: limitPushdownTestSourceFrame}, Query: pgsql.Query{ diff --git a/cypher/models/pgsql/translate/model.go b/cypher/models/pgsql/translate/model.go index ca16e2d6..2a0ed669 100644 --- a/cypher/models/pgsql/translate/model.go +++ b/cypher/models/pgsql/translate/model.go @@ -145,6 +145,46 @@ func (s *TraversalStep) usesBoundEndpointPairs() bool { return s.LeftNodeBound && s.RightNodeBound && s.hasPreviousFrameBinding() } +func (s *TraversalStep) usesBoundTerminalIDs() bool { + return s.RightNodeBound && s.hasPreviousFrameBinding() +} + +func canMaterializeTerminalFilterForStep(traversalStep *TraversalStep, expansionModel *Expansion) bool { + if traversalStep == nil || expansionModel == nil || traversalStep.RightNode == nil || + expansionModel.TerminalNodeConstraints == nil || + traversalStep.usesBoundEndpointPairs() || + traversalStep.usesBoundTerminalIDs() { + return false + } + + // Terminal filters are only useful as standalone SQL when they depend solely + // on the terminal node; external references must stay in the main query. + _, externalConstraints := partitionConstraintByLocality( + expansionModel.TerminalNodeConstraints, + pgsql.AsIdentifierSet(traversalStep.RightNode.Identifier), + ) + + return externalConstraints == nil +} + +func canMaterializeEndpointPairFilterForStep(traversalStep *TraversalStep, expansionModel *Expansion) bool { + // Pair filters enumerate the exact root/terminal combinations the + // bidirectional harness must resolve. Kind-only endpoint predicates are not + // enough because they do not constrain the search columns used by the harness. + if traversalStep == nil || expansionModel == nil || + traversalStep.LeftNode == nil || + traversalStep.RightNode == nil || + traversalStep.usesBoundEndpointPairs() || + expansionModel.PrimerNodeConstraints == nil || + expansionModel.TerminalNodeConstraints == nil || + !hasLocalEndpointConstraint(expansionModel.PrimerNodeConstraints, traversalStep.LeftNode.Identifier) || + !hasLocalEndpointConstraint(expansionModel.TerminalNodeConstraints, traversalStep.RightNode.Identifier) { + return false + } + + return true +} + func (s *TraversalStep) endpointSelectivity(scope *Scope, expression pgsql.Expression, bound bool) (int, error) { selectivity, err := MeasureSelectivity(scope, expression) if err != nil { diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index 9a2dbf8b..25a57484 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -53,16 +53,24 @@ func optimizerSafetyKindMapper() *pgutil.InMemoryKindMapper { func optimizerSafetySQL(t *testing.T, cypherQuery string) string { t.Helper() - regularQuery, err := frontend.ParseCypher(frontend.NewContext(), cypherQuery) + translation := optimizerSafetyTranslation(t, cypherQuery) + + formattedQuery, err := Translated(translation) require.NoError(t, err) - translation, err := Translate(context.Background(), regularQuery, optimizerSafetyKindMapper(), nil, DefaultGraphID) + return strings.Join(strings.Fields(formattedQuery), " ") +} + +func optimizerSafetyTranslation(t *testing.T, cypherQuery string) Result { + t.Helper() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), cypherQuery) require.NoError(t, err) - formattedQuery, err := Translated(translation) + translation, err := Translate(context.Background(), regularQuery, optimizerSafetyKindMapper(), nil, DefaultGraphID) require.NoError(t, err) - return strings.Join(strings.Fields(formattedQuery), " ") + return translation } func requireOptimizationLowering(t *testing.T, summary OptimizationSummary, name string) { @@ -97,6 +105,14 @@ func requirePlannedOptimizationLowering(t *testing.T, summary OptimizationSummar require.Failf(t, "missing planned optimization lowering", "expected planned lowering %q in %#v", name, summary.PlannedLowerings) } +func requireNoPlannedOptimizationLowering(t *testing.T, summary OptimizationSummary, name string) { + t.Helper() + + for _, lowering := range summary.PlannedLowerings { + require.NotEqualf(t, name, lowering.Name, "unexpected planned lowering %q in %#v", name, summary.PlannedLowerings) + } +} + func requireSQLContainsInOrder(t *testing.T, sql string, parts ...string) { t.Helper() @@ -255,6 +271,122 @@ RETURN p require.Contains(t, normalizedQuery, "n2.kind_ids operator (pg_catalog.@>) array [5]::int2[]") } +func TestOptimizerSafetySuffixPredicatePlacementStaysInsideTerminalExists(t *testing.T) { + t.Parallel() + + normalizedQuery := optimizerSafetySQL(t, ` +MATCH p = (n:Group)-[:MemberOf*1..]->(m)-[:Enroll]->(ca:EnterpriseCA) +WHERE ca.name = 'target' +RETURN p +`) + + requireSQLContainsInOrder(t, normalizedQuery, + "exists (select 1 from edge e1 join node n2", + "properties -> 'name'", + "where n1.id = e1.start_id", + ) +} + +func TestOptimizerSafetyContinuationRelationshipsExcludePriorPathRelationships(t *testing.T) { + t.Parallel() + + expandedPrefixQuery := optimizerSafetySQL(t, ` +MATCH p = (n:Group)-[:MemberOf*1..]->(m)-[:Enroll]-(ca:EnterpriseCA) +RETURN p +`) + + require.Contains(t, expandedPrefixQuery, "e1.id != all") + require.Contains(t, expandedPrefixQuery, "ep0") + + fixedPrefixQuery := optimizerSafetySQL(t, ` +MATCH p = (n:Group)-[:MemberOf]->(m)-[:Enroll]->(ca:EnterpriseCA) +RETURN p +`) + + require.Contains(t, fixedPrefixQuery, "e1.id != s0.e0") +} + +func TestOptimizerSafetyDirectionBalancedExpansionDoesNotPlanStaleSuffixPushdown(t *testing.T) { + t.Parallel() + + translation := optimizerSafetyTranslation(t, ` +MATCH p = (n)-[:MemberOf*1..]->(ca:EnterpriseCA)-[:TrustedForNTAuth]->(d:Domain) +RETURN p + `) + + requirePlannedOptimizationLowering(t, translation.Optimization, "TraversalDirectionSelection") + requireOptimizationLowering(t, translation.Optimization, "TraversalDirectionSelection") + requireNoPlannedOptimizationLowering(t, translation.Optimization, "ExpansionSuffixPushdown") + requireNoOptimizationLowering(t, translation.Optimization, "ExpansionSuffixPushdown") +} + +func TestOptimizerSafetyShortestPathStrategyUsesPlannedBidirectionalSearch(t *testing.T) { + t.Parallel() + + translation := optimizerSafetyTranslation(t, ` +MATCH p = allShortestPaths((s)-[:MemberOf*1..]->(e)) +WHERE s.name = 'source' AND e.name = 'target' +RETURN p + `) + + formattedQuery, err := Translated(translation) + require.NoError(t, err) + normalizedQuery := strings.Join(strings.Fields(formattedQuery), " ") + + require.Contains(t, normalizedQuery, "bidirectional_asp_harness") + requirePlannedOptimizationLowering(t, translation.Optimization, "ShortestPathStrategySelection") + requirePlannedOptimizationLowering(t, translation.Optimization, "ShortestPathFilterMaterialization") + requireOptimizationLowering(t, translation.Optimization, "ShortestPathStrategySelection") + requireOptimizationLowering(t, translation.Optimization, "ShortestPathFilterMaterialization") +} + +func TestOptimizerSafetyShortestPathTerminalFilterUsesPlannedMaterialization(t *testing.T) { + t.Parallel() + + translation := optimizerSafetyTranslation(t, ` +MATCH (s:Group {name: 'source'}) +MATCH p = shortestPath((s)-[:MemberOf*1..]->(e)) +WHERE e.name = 'target' +RETURN p + `) + + formattedQuery, err := Translated(translation) + require.NoError(t, err) + normalizedQuery := strings.Join(strings.Fields(formattedQuery), " ") + + require.Contains(t, normalizedQuery, "unidirectional_sp_harness") + require.Contains(t, normalizedQuery, "traversal_terminal_filter") + requirePlannedOptimizationLowering(t, translation.Optimization, "ShortestPathFilterMaterialization") + requireOptimizationLowering(t, translation.Optimization, "ShortestPathFilterMaterialization") +} + +func TestOptimizerSafetyLimitPushdownUsesPlannedTraversalFrame(t *testing.T) { + t.Parallel() + + translation := optimizerSafetyTranslation(t, ` +MATCH p = (n:Group)-[:MemberOf]->(m:Group) +RETURN p +LIMIT 1 + `) + + requirePlannedOptimizationLowering(t, translation.Optimization, "LimitPushdown") + requireOptimizationLowering(t, translation.Optimization, "LimitPushdown") +} + +func TestOptimizerSafetyShortestPathLimitPushdownUsesPlannedHarness(t *testing.T) { + t.Parallel() + + translation := optimizerSafetyTranslation(t, ` +MATCH p = shortestPath((s)-[:MemberOf*1..]->(e)) +WHERE s.name = 'source' AND e.name = 'target' +RETURN p +LIMIT 1 + `) + + requirePlannedOptimizationLowering(t, translation.Optimization, "LimitPushdown") + requireOptimizationLowering(t, translation.Optimization, "LimitPushdown") +} + func TestOptimizerSafetyTranslationReportsOptimizerMetadata(t *testing.T) { t.Parallel() diff --git a/cypher/models/pgsql/translate/path_functions.go b/cypher/models/pgsql/translate/path_functions.go index 909eac3e..4b2951fc 100644 --- a/cypher/models/pgsql/translate/path_functions.go +++ b/cypher/models/pgsql/translate/path_functions.go @@ -144,6 +144,24 @@ func resolvePathCompositeFieldReferences(scope *Scope, expression pgsql.Expressi case nil: return nil, nil + case pgsql.Identifier: + if binding, bound := scope.Lookup(typedExpression); !bound { + if aliasedBinding, aliasBound := scope.AliasedLookup(typedExpression); aliasBound { + binding = aliasedBinding + bound = true + } + + if !bound || binding.DataType != pgsql.PathComposite { + return expression, nil + } + + return expressionForPathComposite(binding, scope) + } else if binding.DataType == pgsql.PathComposite { + return expressionForPathComposite(binding, scope) + } + + return expression, nil + case pgsql.RowColumnReference: if resolved, rewritten, err := resolvePathCompositeFieldReference(scope, typedExpression); rewritten || err != nil { return resolved, err diff --git a/cypher/models/pgsql/translate/pattern.go b/cypher/models/pgsql/translate/pattern.go index 9383999f..163dc8d1 100644 --- a/cypher/models/pgsql/translate/pattern.go +++ b/cypher/models/pgsql/translate/pattern.go @@ -84,7 +84,6 @@ func (s *Translator) buildTraversalPattern(traversalStep *TraversalStep, isRootS }, Query: traversalStepQuery, }) - s.query.CurrentPart().AllowLimitPushdown(traversalStep.Frame.Binding.Identifier) } } else { if traversalStepQuery, err := s.buildTraversalPatternStep(traversalStep.Frame, traversalStep); err != nil { @@ -96,7 +95,6 @@ func (s *Translator) buildTraversalPattern(traversalStep *TraversalStep, isRootS }, Query: traversalStepQuery, }) - s.query.CurrentPart().AllowLimitPushdown(traversalStep.Frame.Binding.Identifier) } } @@ -116,7 +114,6 @@ func (s *Translator) buildExpansionPattern(traversalStepContext TraversalStepCon }, Query: traversalStepQuery, }) - s.query.CurrentPart().AllowLimitPushdown(traversalStep.Frame.Binding.Identifier) } } else { if traversalStepQuery, err := s.buildExpansionPatternStep(traversalStepContext, expansion); err != nil { @@ -128,7 +125,6 @@ func (s *Translator) buildExpansionPattern(traversalStepContext TraversalStepCon }, Query: traversalStepQuery, }) - s.query.CurrentPart().AllowLimitPushdown(traversalStep.Frame.Binding.Identifier) } } @@ -233,6 +229,8 @@ func (s *Translator) buildTraversalPatternPart(part *PatternPart) error { } else if err := s.buildTraversalPattern(traversalStep, isRootStep); err != nil { return err } + + s.allowLimitPushdownForStep(part, idx, traversalStep) } return nil diff --git a/cypher/models/pgsql/translate/projection.go b/cypher/models/pgsql/translate/projection.go index 722e96f2..a4f4e64c 100644 --- a/cypher/models/pgsql/translate/projection.go +++ b/cypher/models/pgsql/translate/projection.go @@ -8,6 +8,7 @@ import ( "github.com/specterops/dawgs/cypher/models" "github.com/specterops/dawgs/cypher/models/pgsql" + "github.com/specterops/dawgs/cypher/models/pgsql/optimize" ) type BoundProjections struct { @@ -226,6 +227,55 @@ func expansionPathEdgeArrayExpression(scope *Scope, expansionPath *BoundIdentifi }, nil } +func optionalOr(leftOperand, rightOperand pgsql.Expression) pgsql.Expression { + if leftOperand == nil { + return rightOperand + } else if rightOperand == nil { + return leftOperand + } + + return pgsql.NewBinaryExpression(leftOperand, pgsql.OperatorOr, rightOperand) +} + +func expressionIsNull(expression pgsql.Expression) pgsql.Expression { + return pgsql.NewBinaryExpression(expression, pgsql.OperatorIs, pgsql.NullLiteral()) +} + +func pathCompositeDependencyNullGuard(scope *Scope, dependency *BoundIdentifier) pgsql.Expression { + if dependency == nil { + return nil + } + + switch dependency.DataType { + case pgsql.ExpansionPath: + return expressionIsNull(pathBindingReference(scope, dependency)) + + case pgsql.EdgeComposite: + return expressionIsNull(pathCompositeColumnReference(scope, dependency, pgsql.ColumnID)) + + case pgsql.PathEdge: + return expressionIsNull(pathEdgeIDReference(scope, dependency)) + + case pgsql.NodeComposite, pgsql.ExpansionRootNode, pgsql.ExpansionTerminalNode: + return expressionIsNull(pathCompositeColumnReference(scope, dependency, pgsql.ColumnID)) + + default: + return nil + } +} + +func nullGuardPathCompositeExpression(expression, nullGuard pgsql.Expression) pgsql.Expression { + if nullGuard == nil { + return expression + } + + return pgsql.Case{ + Conditions: []pgsql.Expression{nullGuard}, + Then: []pgsql.Expression{pgsql.NullLiteral()}, + Else: expression, + } +} + func expressionForPathComposite(projected *BoundIdentifier, scope *Scope) (pgsql.Expression, error) { if projected.LastProjection != nil { return pgsql.CompoundIdentifier{projected.LastProjection.Binding.Identifier, projected.Identifier}, nil @@ -238,12 +288,15 @@ func expressionForPathComposite(projected *BoundIdentifier, scope *Scope) (pgsql directEdgeReferences []pgsql.Expression seenExpansionPath = false seenPathEdge = false + nullGuard pgsql.Expression ) // Path composite components are encoded as dependencies on the bound identifier representing the // path. This is not ideal as it escapes normal translation flow as driven by the structure of the // originating cypher AST. for _, dependency := range projected.Dependencies { + nullGuard = optionalOr(nullGuard, pathCompositeDependencyNullGuard(scope, dependency)) + switch dependency.DataType { case pgsql.ExpansionPath: seenExpansionPath = true @@ -280,7 +333,7 @@ func expressionForPathComposite(projected *BoundIdentifier, scope *Scope) (pgsql // order and duplicate nodes, and it also works for rows produced by data-modifying CTEs where // re-reading node/edge tables in the same statement may not see the RETURNING values. if !seenExpansionPath && !seenPathEdge && len(directNodeReferences) > 0 { - return pgsql.CompositeValue{ + return nullGuardPathCompositeExpression(pgsql.CompositeValue{ DataType: pgsql.PathComposite, Values: []pgsql.Expression{ pgsql.ArrayLiteral{ @@ -292,7 +345,7 @@ func expressionForPathComposite(projected *BoundIdentifier, scope *Scope) (pgsql CastType: pgsql.EdgeCompositeArray, }, }, - }, nil + }, nullGuard), nil } if seenExpansionPath || seenPathEdge { @@ -305,7 +358,7 @@ func expressionForPathComposite(projected *BoundIdentifier, scope *Scope) (pgsql edgeArrayExpression = pgsql.ArrayLiteral{CastType: pgsql.EdgeCompositeArray} } - return pgsql.FunctionCall{ + return nullGuardPathCompositeExpression(pgsql.FunctionCall{ Function: pgsql.FunctionOrderedEdgesToPath, Parameters: []pgsql.Expression{ directNodeReferences[0], @@ -316,9 +369,9 @@ func expressionForPathComposite(projected *BoundIdentifier, scope *Scope) (pgsql }, }, CastType: pgsql.PathComposite, - }, nil + }, nullGuard), nil } else if len(nodeReferences) > 0 { - return pgsql.FunctionCall{ + return nullGuardPathCompositeExpression(pgsql.FunctionCall{ Function: pgsql.FunctionNodesToPath, Parameters: []pgsql.Expression{ pgsql.Variadic{ @@ -329,7 +382,7 @@ func expressionForPathComposite(projected *BoundIdentifier, scope *Scope) (pgsql }, }, CastType: pgsql.PathComposite, - }, nil + }, nullGuard), nil } return nil, fmt.Errorf("path variable does not contain valid components") @@ -961,18 +1014,22 @@ func limitPushdownTailSource(currentPart *QueryPart, tailSelect pgsql.Select) (p return sourceFrame, true } -func pushDownShortestPathLimit(currentPart *QueryPart, tailSelect pgsql.Select) { +func pushDownShortestPathLimit(currentPart *QueryPart, tailSelect pgsql.Select) bool { sourceFrame, canPushDown := limitPushdownTailSource(currentPart, tailSelect) if !canPushDown { - return + return false } if sourceCTE := findCTE(currentPart.Model, sourceFrame); sourceCTE != nil && + currentPart.CanPushDownLimitTo(sourceFrame) && countLimitPushdownShortestPathHarnessCalls(sourceCTE.Query) == 1 { // Multiple harness calls in one source CTE would make one outer LIMIT // ambiguous, so only the single-harness case is rewritten. appendLimitToShortestPathHarness(&sourceCTE.Query, currentPart.Limit) + return true } + + return false } func findCTE(query *pgsql.Query, cteName pgsql.Identifier) *pgsql.CommonTableExpression { @@ -1000,13 +1057,13 @@ func applyLimitToCTE(query *pgsql.Query, cteName pgsql.Identifier, limit pgsql.E return false } -func pushDownTraversalLimit(currentPart *QueryPart, tailSelect pgsql.Select) { +func pushDownTraversalLimit(currentPart *QueryPart, tailSelect pgsql.Select) bool { sourceFrame, canPushDown := limitPushdownTailSource(currentPart, tailSelect) if !canPushDown || !currentPart.CanPushDownLimitTo(sourceFrame) { - return + return false } - applyLimitToCTE(currentPart.Model, sourceFrame, currentPart.Limit) + return applyLimitToCTE(currentPart.Model, sourceFrame, currentPart.Limit) } func projectionAliasBindings(scope *Scope, projections []*Projection) map[pgsql.Identifier]pgsql.Identifier { @@ -1093,8 +1150,10 @@ func (s *Translator) buildTailProjection() error { } currentPart.Model.Body = singlePartQuerySelect - pushDownShortestPathLimit(currentPart, singlePartQuerySelect) - pushDownTraversalLimit(currentPart, singlePartQuerySelect) + if pushDownShortestPathLimit(currentPart, singlePartQuerySelect) || + pushDownTraversalLimit(currentPart, singlePartQuerySelect) { + s.recordLowering(optimize.LoweringLimitPushdown) + } if currentPart.Skip != nil { currentPart.Model.Offset = currentPart.Skip diff --git a/cypher/models/pgsql/translate/translator.go b/cypher/models/pgsql/translate/translator.go index c59c4cca..d7517b65 100644 --- a/cypher/models/pgsql/translate/translator.go +++ b/cypher/models/pgsql/translate/translator.go @@ -30,11 +30,15 @@ type Translator struct { scope *Scope unwindTargets map[*cypher.Variable]struct{} - patternTargets map[*cypher.PatternPart]optimize.PatternTarget - projectionPruningDecisions map[optimize.TraversalStepTarget]optimize.ProjectionPruningDecision - latePathDecisions map[optimize.TraversalStepTarget][]optimize.LatePathMaterializationDecision - suffixPushdownDecisions map[optimize.TraversalStepTarget][]optimize.ExpansionSuffixPushdownDecision - expandIntoDecisions map[optimize.TraversalStepTarget]optimize.ExpandIntoDecision + patternTargets map[*cypher.PatternPart]optimize.PatternTarget + projectionPruningDecisions map[optimize.TraversalStepTarget]optimize.ProjectionPruningDecision + latePathDecisions map[optimize.TraversalStepTarget][]optimize.LatePathMaterializationDecision + suffixPushdownDecisions map[optimize.TraversalStepTarget][]optimize.ExpansionSuffixPushdownDecision + expandIntoDecisions map[optimize.TraversalStepTarget]optimize.ExpandIntoDecision + traversalDirectionDecisions map[optimize.TraversalStepTarget]optimize.TraversalDirectionDecision + shortestPathStrategyDecisions map[optimize.TraversalStepTarget]optimize.ShortestPathStrategyDecision + shortestPathFilterDecisions map[optimize.TraversalStepTarget][]optimize.ShortestPathFilterDecision + limitPushdownDecisions map[optimize.TraversalStepTarget][]optimize.LimitPushdownDecision } func NewTranslator(ctx context.Context, kindMapper pgsql.KindMapper, parameters map[string]any, graphID int32) *Translator { @@ -72,6 +76,10 @@ func (s *Translator) SetOptimizationPlan(plan optimize.Plan) { s.latePathDecisions = map[optimize.TraversalStepTarget][]optimize.LatePathMaterializationDecision{} s.suffixPushdownDecisions = map[optimize.TraversalStepTarget][]optimize.ExpansionSuffixPushdownDecision{} s.expandIntoDecisions = map[optimize.TraversalStepTarget]optimize.ExpandIntoDecision{} + s.traversalDirectionDecisions = map[optimize.TraversalStepTarget]optimize.TraversalDirectionDecision{} + s.shortestPathStrategyDecisions = map[optimize.TraversalStepTarget]optimize.ShortestPathStrategyDecision{} + s.shortestPathFilterDecisions = map[optimize.TraversalStepTarget][]optimize.ShortestPathFilterDecision{} + s.limitPushdownDecisions = map[optimize.TraversalStepTarget][]optimize.LimitPushdownDecision{} for _, decision := range plan.LoweringPlan.ProjectionPruning { s.projectionPruningDecisions[decision.Target] = decision @@ -88,6 +96,22 @@ func (s *Translator) SetOptimizationPlan(plan optimize.Plan) { for _, decision := range plan.LoweringPlan.ExpandInto { s.expandIntoDecisions[decision.Target] = decision } + + for _, decision := range plan.LoweringPlan.TraversalDirection { + s.traversalDirectionDecisions[decision.Target] = decision + } + + for _, decision := range plan.LoweringPlan.ShortestPathStrategy { + s.shortestPathStrategyDecisions[decision.Target] = decision + } + + for _, decision := range plan.LoweringPlan.ShortestPathFilter { + s.shortestPathFilterDecisions[decision.Target] = append(s.shortestPathFilterDecisions[decision.Target], decision) + } + + for _, decision := range plan.LoweringPlan.LimitPushdown { + s.limitPushdownDecisions[decision.Target] = append(s.limitPushdownDecisions[decision.Target], decision) + } } func (s *Translator) Enter(expression cypher.SyntaxNode) { @@ -102,6 +126,13 @@ func (s *Translator) Enter(expression cypher.SyntaxNode) { *cypher.Return, *cypher.MultiPartQuery, *cypher.Properties, *cypher.KindMatcher, *cypher.Quantifier, *cypher.IDInCollection: + case *cypher.RangeQuantifier: + if typedExpression.Value != string(pgsql.WildcardIdentifier) { + s.SetErrorf("unsupported range quantifier expression: %s", typedExpression.Value) + } else { + s.treeTranslator.PushOperand(pgsql.WildcardIdentifier) + } + case *cypher.Unwind: if typedExpression.Variable != nil { // The UNWIND target is declared by the UNWIND clause itself, so later diff --git a/cypher/models/pgsql/translate/traversal.go b/cypher/models/pgsql/translate/traversal.go index 2e02e250..c8036872 100644 --- a/cypher/models/pgsql/translate/traversal.go +++ b/cypher/models/pgsql/translate/traversal.go @@ -43,6 +43,134 @@ func (s *Translator) shouldUseExpandInto(part *PatternPart, stepIndex int, trave return true } +func (s *Translator) traversalDirectionDecision(part *PatternPart, stepIndex int) (optimize.TraversalDirectionDecision, bool) { + if part == nil || !part.HasTarget { + return optimize.TraversalDirectionDecision{}, false + } + + decision, hasDecision := s.traversalDirectionDecisions[part.Target.TraversalStep(stepIndex)] + return decision, hasDecision +} + +func (s *Translator) applyPatternConstraintBalance(part *PatternPart, stepIndex int, constraints *PatternConstraints, traversalStep *TraversalStep) error { + if decision, hasDecision := s.traversalDirectionDecision(part, stepIndex); hasDecision { + if decision.Flip && !traversalStep.LeftNodeBound { + if traversalStep.RightNodeBound && !traversalStep.hasPreviousFrameBinding() { + return nil + } + + traversalStep.FlipNodes() + constraints.FlipNodes() + s.recordLowering(optimize.LoweringTraversalDirection) + } + + return nil + } + + if flipped, err := constraints.OptimizePatternConstraintBalance(s.scope, traversalStep); err != nil { + return err + } else if flipped { + s.recordLowering(optimize.LoweringTraversalDirection) + } + + return nil +} + +func (s *Translator) shortestPathStrategyDecision(part *PatternPart, stepIndex int) (optimize.ShortestPathStrategyDecision, bool) { + if part == nil || !part.HasTarget { + return optimize.ShortestPathStrategyDecision{}, false + } + + decision, hasDecision := s.shortestPathStrategyDecisions[part.Target.TraversalStep(stepIndex)] + return decision, hasDecision +} + +func (s *Translator) useBidirectionalShortestPathStrategy(part *PatternPart, stepIndex int, traversalStep *TraversalStep) (bool, error) { + if decision, hasDecision := s.shortestPathStrategyDecision(part, stepIndex); hasDecision { + if decision.Strategy != optimize.ShortestPathStrategyBidirectional { + return false, nil + } + + if canExecute, err := traversalStep.CanExecutePairAwareBidirectionalSearch(s.scope); err != nil { + return false, err + } else if canExecute { + s.recordLowering(optimize.LoweringShortestPathStrategy) + return true, nil + } + + return false, nil + } + + if canExecute, err := traversalStep.CanExecutePairAwareBidirectionalSearch(s.scope); err != nil { + return false, err + } else if canExecute { + s.recordLowering(optimize.LoweringShortestPathStrategy) + return true, nil + } + + return false, nil +} + +func (s *Translator) shortestPathFilterDecisionsForStep(part *PatternPart, stepIndex int) []optimize.ShortestPathFilterDecision { + if part == nil || !part.HasTarget { + return nil + } + + return s.shortestPathFilterDecisions[part.Target.TraversalStep(stepIndex)] +} + +func (s *Translator) applyShortestPathFilterMaterialization(part *PatternPart, stepIndex int, traversalStep *TraversalStep, expansionModel *Expansion) { + for _, decision := range s.shortestPathFilterDecisionsForStep(part, stepIndex) { + switch decision.Mode { + case optimize.ShortestPathFilterTerminal: + if canMaterializeTerminalFilterForStep(traversalStep, expansionModel) { + expansionModel.UseMaterializedTerminalFilter = true + s.recordLowering(optimize.LoweringShortestPathFilter) + } + + case optimize.ShortestPathFilterEndpointPair: + if expansionModel.UseBidirectionalSearch && canMaterializeEndpointPairFilterForStep(traversalStep, expansionModel) { + expansionModel.UseMaterializedEndpointPairFilter = true + s.recordLowering(optimize.LoweringShortestPathFilter) + } + } + } +} + +func (s *Translator) hasLimitPushdownDecision(part *PatternPart, stepIndex int, mode optimize.LimitPushdownMode) bool { + if part == nil || !part.HasTarget { + return true + } + + for _, decision := range s.limitPushdownDecisions[part.Target.TraversalStep(stepIndex)] { + if decision.Mode == mode { + return true + } + } + + return false +} + +func (s *Translator) allowLimitPushdownForStep(part *PatternPart, stepIndex int, traversalStep *TraversalStep) { + if traversalStep == nil || traversalStep.Frame == nil { + return + } + if traversalStep.Expansion != nil && traversalStep.Expansion.Options.FindAllShortestPaths { + return + } + + mode := optimize.LimitPushdownTraversalCTE + if traversalStep.Expansion != nil && + traversalStep.Expansion.Options.FindShortestPath && + !traversalStep.Expansion.Options.FindAllShortestPaths { + mode = optimize.LimitPushdownShortestPathHarness + } + + if s.hasLimitPushdownDecision(part, stepIndex, mode) { + s.query.CurrentPart().AllowLimitPushdown(traversalStep.Frame.Binding.Identifier) + } +} + func (s *Translator) buildBoundEndpointTraversalPattern(partFrame *Frame, traversalStep *TraversalStep) (pgsql.Query, error) { if partFrame == nil || partFrame.Previous == nil { return pgsql.Query{}, errors.New("expected previous frame for bound endpoint traversal") @@ -694,6 +822,65 @@ func patternBindingDependsOn(queryPart *QueryPart, part *PatternPart, binding *B return false } +func traversalStepHasContinuation(part *PatternPart, stepIndex int) bool { + return part != nil && stepIndex+1 < len(part.TraversalSteps) +} + +func relationshipIDReference(scope *Scope, binding *BoundIdentifier) pgsql.Expression { + if binding != nil && binding.DataType == pgsql.EdgeComposite { + return pathCompositeColumnReference(scope, binding, pgsql.ColumnID) + } + + return pathEdgeIDReference(scope, binding) +} + +func relationshipIDNotInPath(edgeID, pathIDs pgsql.Expression) pgsql.Expression { + return pgsql.NewBinaryExpression( + edgeID, + pgsql.OperatorNotEquals, + pgsql.NewAllExpression(pathIDs), + ) +} + +func previousRelationshipUniquenessConstraint(scope *Scope, part *PatternPart, stepIndex int, traversalStep *TraversalStep) pgsql.Expression { + if scope == nil || part == nil || stepIndex <= 0 || traversalStep == nil || traversalStep.Edge == nil { + return nil + } + + var ( + currentEdgeID pgsql.Expression = pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnID} + constraint pgsql.Expression + ) + + for _, previousStep := range part.TraversalSteps[:stepIndex] { + if previousStep == nil || previousStep.Edge == nil { + continue + } + + if previousStep.Expansion != nil { + if previousStep.Expansion.PathBinding != nil { + constraint = pgsql.OptionalAnd( + constraint, + relationshipIDNotInPath(currentEdgeID, pathBindingReference(scope, previousStep.Expansion.PathBinding)), + ) + } + + continue + } + + constraint = pgsql.OptionalAnd( + constraint, + pgsql.NewBinaryExpression( + currentEdgeID, + pgsql.OperatorNotEquals, + relationshipIDReference(scope, previousStep.Edge), + ), + ) + } + + return constraint +} + func (s *Translator) projectionPruningDecision(part *PatternPart, stepIndex int) (optimize.ProjectionPruningDecision, bool) { if part == nil || !part.HasTarget { return optimize.ProjectionPruningDecision{}, false @@ -766,7 +953,11 @@ func traversalStepProjectsBinding(queryPart *QueryPart, part *PatternPart, stepI return true } - if stepIndex+1 < len(part.TraversalSteps) { + if traversalStepHasContinuation(part, stepIndex) { + if part.TraversalSteps[stepIndex].Edge == binding { + return true + } + // A multi-hop pattern needs the right node from this step as the next // step's left node even when the user never projects it. nextStep := part.TraversalSteps[stepIndex+1] @@ -815,7 +1006,7 @@ func pruneTraversalStepProjectionExports(queryPart *QueryPart, part *PatternPart if hasDecision { applied = unexportPrunedNodeBinding(traversalStep, traversalStep.ProjectionPruning.LeftNode) || applied - if traversalStep.ProjectionPruning.Relationship != nil { + if traversalStep.ProjectionPruning.Relationship != nil && !traversalStepHasContinuation(part, stepIndex) { applied = unexportFrameBinding(traversalStep.Frame, traversalStep.ProjectionPruning.Relationship.Identifier) || applied } applied = unexportPrunedNodeBinding(traversalStep, traversalStep.ProjectionPruning.RightNode) || applied @@ -851,7 +1042,7 @@ func pruneTraversalStepProjectionExports(queryPart *QueryPart, part *PatternPart return applied } -func pruneExpansionStepProjectionExports(queryPart *QueryPart, part *PatternPart, traversalStep *TraversalStep, decision optimize.ProjectionPruningDecision, hasDecision bool, allowFallback bool) bool { +func pruneExpansionStepProjectionExports(queryPart *QueryPart, part *PatternPart, stepIndex int, traversalStep *TraversalStep, decision optimize.ProjectionPruningDecision, hasDecision bool, allowFallback bool) bool { if traversalStep == nil || traversalStep.Expansion == nil { return false } @@ -862,7 +1053,7 @@ func pruneExpansionStepProjectionExports(queryPart *QueryPart, part *PatternPart applied = unexportFrameBinding(traversalStep.Frame, traversalStep.ProjectionPruning.Relationship.Identifier) || applied } - if traversalStep.ProjectionPruning.PathBinding != nil { + if traversalStep.ProjectionPruning.PathBinding != nil && !traversalStepHasContinuation(part, stepIndex) { applied = unexportFrameBinding(traversalStep.Frame, traversalStep.ProjectionPruning.PathBinding.Identifier) || applied } @@ -882,7 +1073,7 @@ func pruneExpansionStepProjectionExports(queryPart *QueryPart, part *PatternPart } pathBinding := traversalStep.Expansion.PathBinding - if pathBinding != nil && !patternBindingDependsOn(queryPart, part, pathBinding) { + if pathBinding != nil && !traversalStepHasContinuation(part, stepIndex) && !patternBindingDependsOn(queryPart, part, pathBinding) { applied = unexportFrameBinding(traversalStep.Frame, pathBinding.Identifier) || applied } @@ -896,7 +1087,7 @@ func (s *Translator) translateTraversalPatternPartWithoutExpansion(part *Pattern return err } else { if isFirstTraversalStep { - if err := constraints.OptimizePatternConstraintBalance(s.scope, traversalStep); err != nil { + if err := s.applyPatternConstraintBalance(part, stepIndex, &constraints, traversalStep); err != nil { return err } @@ -948,6 +1139,10 @@ func (s *Translator) translateTraversalPatternPartWithoutExpansion(part *Pattern } else { traversalStep.EdgeConstraints = constraints.Edge } + traversalStep.EdgeConstraints.Expression = pgsql.OptionalAnd( + traversalStep.EdgeConstraints.Expression, + previousRelationshipUniquenessConstraint(s.scope, part, stepIndex, traversalStep), + ) traversalStep.Frame.Export(traversalStep.RightNode.Identifier) @@ -967,6 +1162,13 @@ func (s *Translator) translateTraversalPatternPartWithoutExpansion(part *Pattern } if allowProjectionPruning { + if traversalStepHasContinuation(part, stepIndex) && + traversalStep.Edge != nil && + traversalStep.Edge.DataType == pgsql.EdgeComposite && + !s.query.CurrentPart().ReferencesBinding(traversalStep.Edge) { + traversalStep.Edge.DataType = pgsql.PathEdge + } + if s.applyPathEdgeIDMaterialization(part, stepIndex, traversalStep) { s.recordLowering(optimize.LoweringLatePathMaterialization) } diff --git a/cypher/models/pgsql/translate/with.go b/cypher/models/pgsql/translate/with.go index 6009940d..b8b66f60 100644 --- a/cypher/models/pgsql/translate/with.go +++ b/cypher/models/pgsql/translate/with.go @@ -25,6 +25,14 @@ func (s *Translator) translateWith() error { for _, projectionItem := range currentPart.projections.Items { if err := RewriteFrameBindings(s.scope, projectionItem.SelectItem); err != nil { return err + } else if _, isIdentifier := projectionItem.SelectItem.(pgsql.Identifier); isIdentifier { + continue + } else if resolvedSelectItem, err := resolvePathCompositeFieldReferences(s.scope, projectionItem.SelectItem); err != nil { + return err + } else if selectItem, isSelectItem := resolvedSelectItem.(pgsql.SelectItem); !isSelectItem { + return fmt.Errorf("resolved with projection item is not selectable: %T", resolvedSelectItem) + } else { + projectionItem.SelectItem = selectItem } } diff --git a/cypher/models/walk/walk_pgsql.go b/cypher/models/walk/walk_pgsql.go index 23ab28b3..37e0a8c1 100644 --- a/cypher/models/walk/walk_pgsql.go +++ b/cypher/models/walk/walk_pgsql.go @@ -258,6 +258,18 @@ func newSQLWalkCursor(node pgsql.SyntaxNode) (*Cursor[pgsql.SyntaxNode], error) Branches: []pgsql.SyntaxNode{typedNode.Expression}, }, nil + case pgsql.AllExpression: + return &Cursor[pgsql.SyntaxNode]{ + Node: node, + Branches: []pgsql.SyntaxNode{typedNode.Expression}, + }, nil + + case *pgsql.AllExpression: + return &Cursor[pgsql.SyntaxNode]{ + Node: node, + Branches: []pgsql.SyntaxNode{typedNode.Expression}, + }, nil + case *pgsql.AnyExpression: return &Cursor[pgsql.SyntaxNode]{ Node: node, diff --git a/integration/testdata/cases/aggregation_inline.json b/integration/testdata/cases/aggregation_inline.json index a624f197..3c5a9309 100644 --- a/integration/testdata/cases/aggregation_inline.json +++ b/integration/testdata/cases/aggregation_inline.json @@ -276,6 +276,25 @@ "edges": [] }, "assert": {"scalar_values": [40]} + }, + { + "name": "aggregate after optimized expansion groups by terminal node", + "cypher": "MATCH p = (src:NodeKind1)-[:EdgeKind1*1..]->(mid)-[:EdgeKind2]->(dst:NodeKind2) WHERE src.name = 'aggregation-expansion-src' WITH dst, count(p) AS path_count RETURN dst.name, path_count", + "fixture": { + "nodes": [ + {"id": "src", "kinds": ["NodeKind1"], "properties": {"name": "aggregation-expansion-src"}}, + {"id": "mid-a", "kinds": ["NodeKind1"]}, + {"id": "mid-b", "kinds": ["NodeKind1"]}, + {"id": "dst", "kinds": ["NodeKind2"], "properties": {"name": "aggregation-expansion-dst"}} + ], + "edges": [ + {"start_id": "src", "end_id": "mid-a", "kind": "EdgeKind1"}, + {"start_id": "src", "end_id": "mid-b", "kind": "EdgeKind1"}, + {"start_id": "mid-a", "end_id": "dst", "kind": "EdgeKind2"}, + {"start_id": "mid-b", "end_id": "dst", "kind": "EdgeKind2"} + ] + }, + "assert": {"row_values": [["aggregation-expansion-dst", 2]]} } ] } diff --git a/integration/testdata/cases/expansion_inline.json b/integration/testdata/cases/expansion_inline.json index 2e5ede51..8eeb3782 100644 --- a/integration/testdata/cases/expansion_inline.json +++ b/integration/testdata/cases/expansion_inline.json @@ -251,6 +251,76 @@ "edges": [{"start_id": "src", "end_id": "tgt", "kind": "EdgeKind1"}] }, "assert": {"path_node_ids": [["src", "tgt"]], "path_edge_kinds": [["EdgeKind1"]]} + }, + { + "name": "expansion followed by fixed suffix cannot reuse an expansion relationship", + "cypher": "match p = (src:NodeKind1)-[:EdgeKind1*1..]->(mid)-[:EdgeKind1]-(dst:NodeKind1) where src.name = 'reuse-source' and dst.name = 'reuse-source' return p", + "fixture": { + "nodes": [ + {"id": "src", "kinds": ["NodeKind1"], "properties": {"name": "reuse-source"}}, + {"id": "mid", "kinds": ["NodeKind1"], "properties": {"name": "reuse-mid"}} + ], + "edges": [ + {"start_id": "src", "end_id": "mid", "kind": "EdgeKind1"} + ] + }, + "assert": "empty" + }, + { + "name": "anonymous continuation suffix reaches a bound endpoint after expansion", + "cypher": "match (dst:NodeKind2 {name: 'anonymous-bound-dst'}) match p = (src:NodeKind1)-[:EdgeKind1*1..]->(:NodeKind1)-[:EdgeKind2]->(dst) where src.name = 'anonymous-bound-src' return p", + "fixture": { + "nodes": [ + {"id": "src", "kinds": ["NodeKind1"], "properties": {"name": "anonymous-bound-src"}}, + {"id": "root", "kinds": ["NodeKind1"]}, + {"id": "dst", "kinds": ["NodeKind2"], "properties": {"name": "anonymous-bound-dst"}}, + {"id": "decoy", "kinds": ["NodeKind2"], "properties": {"name": "anonymous-decoy-dst"}} + ], + "edges": [ + {"start_id": "src", "end_id": "root", "kind": "EdgeKind1"}, + {"start_id": "root", "end_id": "dst", "kind": "EdgeKind2"}, + {"start_id": "root", "end_id": "decoy", "kind": "EdgeKind2"} + ] + }, + "assert": {"row_count": 1, "path_node_ids": [["src", "root", "dst"]], "path_edge_kinds": [["EdgeKind1", "EdgeKind2"]]} + }, + { + "name": "directionless fixed suffix after expansion preserves path semantics", + "cypher": "match p = (src:NodeKind1)-[:EdgeKind1*1..]->(mid)-[:EdgeKind2]-(dst:NodeKind2) where src.name = 'directionless-suffix-src' return p", + "fixture": { + "nodes": [ + {"id": "src", "kinds": ["NodeKind1"], "properties": {"name": "directionless-suffix-src"}}, + {"id": "mid", "kinds": ["NodeKind1"]}, + {"id": "dst", "kinds": ["NodeKind2"]}, + {"id": "dead", "kinds": ["NodeKind1"]} + ], + "edges": [ + {"start_id": "src", "end_id": "mid", "kind": "EdgeKind1"}, + {"start_id": "src", "end_id": "dead", "kind": "EdgeKind1"}, + {"start_id": "mid", "end_id": "dst", "kind": "EdgeKind2"} + ] + }, + "assert": {"path_node_ids": [["src", "mid", "dst"]], "path_edge_kinds": [["EdgeKind1", "EdgeKind2"]]} + }, + { + "name": "inbound expansion suffix preserves path functions", + "cypher": "match p = (ca:EnterpriseCA)<-[:PublishedTo*1..]-(ct)<-[:Enroll]-(m:Group) return size(relationships(p)), nodes(p), relationships(p)", + "fixture": { + "nodes": [ + {"id": "ca", "kinds": ["EnterpriseCA"]}, + {"id": "template", "kinds": ["CertTemplate"]}, + {"id": "member", "kinds": ["Group"]} + ], + "edges": [ + {"start_id": "template", "end_id": "ca", "kind": "PublishedTo"}, + {"start_id": "member", "end_id": "template", "kind": "Enroll"} + ] + }, + "assert": { + "scalar_values": [2], + "node_list_ids": [["ca", "template", "member"]], + "relationship_list_kinds": [["PublishedTo", "Enroll"]] + } } ] } diff --git a/integration/testdata/cases/multipart_inline.json b/integration/testdata/cases/multipart_inline.json index 2c69227c..8a637fb9 100644 --- a/integration/testdata/cases/multipart_inline.json +++ b/integration/testdata/cases/multipart_inline.json @@ -129,6 +129,40 @@ "edges": [{"start_id": "a", "end_id": "b", "kind": "EdgeKind1"}] }, "assert": {"row_count": 1, "node_ids": ["a", "b"]} + }, + { + "name": "with barrier keeps optimized expansion path semantics", + "cypher": "match p = (src:NodeKind1)-[:EdgeKind1*1..]->(mid)-[:EdgeKind2]->(dst:NodeKind2) where src.name = 'with-expansion-src' with p, size(relationships(p)) as hops where hops = 2 return p", + "fixture": { + "nodes": [ + {"id": "src", "kinds": ["NodeKind1"], "properties": {"name": "with-expansion-src"}}, + {"id": "mid", "kinds": ["NodeKind1"]}, + {"id": "dst", "kinds": ["NodeKind2"]}, + {"id": "dead", "kinds": ["NodeKind1"]} + ], + "edges": [ + {"start_id": "src", "end_id": "mid", "kind": "EdgeKind1"}, + {"start_id": "mid", "end_id": "dst", "kind": "EdgeKind2"}, + {"start_id": "src", "end_id": "dead", "kind": "EdgeKind1"} + ] + }, + "assert": {"path_node_ids": [["src", "mid", "dst"]], "path_edge_kinds": [["EdgeKind1", "EdgeKind2"]]} + }, + { + "name": "optional match barrier preserves anchor row around optimized expansion", + "cypher": "match (src:NodeKind1) where src.name = 'optional-expansion-src' optional match p = (src)-[:EdgeKind1*1..]->(mid)-[:EdgeKind2]->(dst:NodeKind2) where dst.name = 'missing-dst' return count(p)", + "fixture": { + "nodes": [ + {"id": "src", "kinds": ["NodeKind1"], "properties": {"name": "optional-expansion-src"}}, + {"id": "mid", "kinds": ["NodeKind1"]}, + {"id": "dst", "kinds": ["NodeKind2"], "properties": {"name": "present-dst"}} + ], + "edges": [ + {"start_id": "src", "end_id": "mid", "kind": "EdgeKind1"}, + {"start_id": "mid", "end_id": "dst", "kind": "EdgeKind2"} + ] + }, + "assert": {"exact_int": 0} } ] } diff --git a/integration/testdata/cases/optimizer_inline.json b/integration/testdata/cases/optimizer_inline.json index d9e08b68..1ded89db 100644 --- a/integration/testdata/cases/optimizer_inline.json +++ b/integration/testdata/cases/optimizer_inline.json @@ -45,6 +45,142 @@ "contains_node_with_props": {"objectid": "S-1-5-21-2643190041-1319121918-239771340-513"}, "contains_edge": {"start": "template", "end": "ca", "kind": "PublishedTo"} } + }, + { + "name": "ADCS template predicate accepts both OR branches and rejects false alternatives", + "cypher": "MATCH (n:Group) WHERE n.objectid = 'optimizer-or-source' MATCH p = (n)-[:MemberOf*0..]->()-[:GenericAll|Enroll|AllExtendedRights]->(ct:CertTemplate)-[:PublishedTo]->(ca:EnterpriseCA)-[:IssuedSignedBy|EnterpriseCAFor*1..]->(:RootCA)-[:RootCAFor]->(d:Domain) WHERE ct.authenticationenabled = true AND ct.requiresmanagerapproval = false AND ct.enrolleesuppliessubject = true AND (ct.schemaversion = 1 OR ct.authorizedsignatures = 0) RETURN p", + "fixture": { + "nodes": [ + {"id": "n", "kinds": ["Group"], "properties": {"objectid": "optimizer-or-source"}}, + {"id": "mid-v1", "kinds": ["Group"]}, + {"id": "mid-sig", "kinds": ["Group"]}, + {"id": "mid-bad", "kinds": ["Group"]}, + {"id": "template-v1", "kinds": ["CertTemplate"], "properties": {"authenticationenabled": true, "requiresmanagerapproval": false, "enrolleesuppliessubject": true, "schemaversion": 1, "authorizedsignatures": 2}}, + {"id": "template-sig", "kinds": ["CertTemplate"], "properties": {"authenticationenabled": true, "requiresmanagerapproval": false, "enrolleesuppliessubject": true, "schemaversion": 2, "authorizedsignatures": 0}}, + {"id": "template-bad", "kinds": ["CertTemplate"], "properties": {"authenticationenabled": true, "requiresmanagerapproval": false, "enrolleesuppliessubject": true, "schemaversion": 2, "authorizedsignatures": 1}}, + {"id": "ca", "kinds": ["EnterpriseCA"]}, + {"id": "root", "kinds": ["RootCA"]}, + {"id": "domain", "kinds": ["Domain"]} + ], + "edges": [ + {"start_id": "n", "end_id": "mid-v1", "kind": "MemberOf"}, + {"start_id": "mid-v1", "end_id": "template-v1", "kind": "GenericAll"}, + {"start_id": "n", "end_id": "mid-sig", "kind": "MemberOf"}, + {"start_id": "mid-sig", "end_id": "template-sig", "kind": "Enroll"}, + {"start_id": "n", "end_id": "mid-bad", "kind": "MemberOf"}, + {"start_id": "mid-bad", "end_id": "template-bad", "kind": "AllExtendedRights"}, + {"start_id": "template-v1", "end_id": "ca", "kind": "PublishedTo"}, + {"start_id": "template-sig", "end_id": "ca", "kind": "PublishedTo"}, + {"start_id": "template-bad", "end_id": "ca", "kind": "PublishedTo"}, + {"start_id": "ca", "end_id": "root", "kind": "IssuedSignedBy"}, + {"start_id": "root", "end_id": "domain", "kind": "RootCAFor"} + ] + }, + "assert": { + "row_count": 2, + "path_node_ids": [ + ["n", "mid-v1", "template-v1", "ca", "root", "domain"], + ["n", "mid-sig", "template-sig", "ca", "root", "domain"] + ], + "path_edge_kinds": [ + ["MemberOf", "GenericAll", "PublishedTo", "IssuedSignedBy", "RootCAFor"], + ["MemberOf", "Enroll", "PublishedTo", "IssuedSignedBy", "RootCAFor"] + ] + } + }, + { + "name": "ADCS fanout returns every p1 and p2 path pair without endpoint collapse", + "cypher": "MATCH (n:Group) WHERE n.objectid = 'optimizer-fanout-source' MATCH p1 = (n)-[:MemberOf*0..]->()-[:Enroll]->(ca:EnterpriseCA)-[:TrustedForNTAuth]->(:NTAuthStore)-[:NTAuthStoreFor]->(d:Domain) MATCH p2 = (n)-[:MemberOf*0..]->()-[:GenericAll|Enroll|AllExtendedRights]->(ct:CertTemplate)-[:PublishedTo]->(ca)-[:IssuedSignedBy|EnterpriseCAFor*1..]->(:RootCA)-[:RootCAFor]->(d) WHERE ct.authenticationenabled = true AND ct.requiresmanagerapproval = false AND ct.enrolleesuppliessubject = true AND (ct.schemaversion = 1 OR ct.authorizedsignatures = 0) RETURN p1, p2", + "fixture": { + "nodes": [ + {"id": "n", "kinds": ["Group"], "properties": {"objectid": "optimizer-fanout-source"}}, + {"id": "p1-a", "kinds": ["Group"]}, + {"id": "p1-b", "kinds": ["Group"]}, + {"id": "p2-a", "kinds": ["Group"]}, + {"id": "p2-b", "kinds": ["Group"]}, + {"id": "template-a", "kinds": ["CertTemplate"], "properties": {"authenticationenabled": true, "requiresmanagerapproval": false, "enrolleesuppliessubject": true, "schemaversion": 1, "authorizedsignatures": 1}}, + {"id": "template-b", "kinds": ["CertTemplate"], "properties": {"authenticationenabled": true, "requiresmanagerapproval": false, "enrolleesuppliessubject": true, "schemaversion": 2, "authorizedsignatures": 0}}, + {"id": "ca", "kinds": ["EnterpriseCA"]}, + {"id": "store", "kinds": ["NTAuthStore"]}, + {"id": "domain", "kinds": ["Domain"]}, + {"id": "root", "kinds": ["RootCA"]} + ], + "edges": [ + {"start_id": "n", "end_id": "p1-a", "kind": "MemberOf"}, + {"start_id": "p1-a", "end_id": "ca", "kind": "Enroll"}, + {"start_id": "n", "end_id": "p1-b", "kind": "MemberOf"}, + {"start_id": "p1-b", "end_id": "ca", "kind": "Enroll"}, + {"start_id": "ca", "end_id": "store", "kind": "TrustedForNTAuth"}, + {"start_id": "store", "end_id": "domain", "kind": "NTAuthStoreFor"}, + {"start_id": "n", "end_id": "p2-a", "kind": "MemberOf"}, + {"start_id": "p2-a", "end_id": "template-a", "kind": "GenericAll"}, + {"start_id": "n", "end_id": "p2-b", "kind": "MemberOf"}, + {"start_id": "p2-b", "end_id": "template-b", "kind": "AllExtendedRights"}, + {"start_id": "template-a", "end_id": "ca", "kind": "PublishedTo"}, + {"start_id": "template-b", "end_id": "ca", "kind": "PublishedTo"}, + {"start_id": "ca", "end_id": "root", "kind": "IssuedSignedBy"}, + {"start_id": "root", "end_id": "domain", "kind": "RootCAFor"} + ] + }, + "assert": { + "row_count": 4, + "path_node_ids": [ + ["n", "p1-a", "ca", "store", "domain"], + ["n", "p1-a", "ca", "store", "domain"], + ["n", "p1-b", "ca", "store", "domain"], + ["n", "p1-b", "ca", "store", "domain"], + ["n", "p2-a", "template-a", "ca", "root", "domain"], + ["n", "p2-a", "template-a", "ca", "root", "domain"], + ["n", "p2-b", "template-b", "ca", "root", "domain"], + ["n", "p2-b", "template-b", "ca", "root", "domain"] + ], + "path_edge_kinds": [ + ["MemberOf", "Enroll", "TrustedForNTAuth", "NTAuthStoreFor"], + ["MemberOf", "Enroll", "TrustedForNTAuth", "NTAuthStoreFor"], + ["MemberOf", "Enroll", "TrustedForNTAuth", "NTAuthStoreFor"], + ["MemberOf", "Enroll", "TrustedForNTAuth", "NTAuthStoreFor"], + ["MemberOf", "GenericAll", "PublishedTo", "IssuedSignedBy", "RootCAFor"], + ["MemberOf", "GenericAll", "PublishedTo", "IssuedSignedBy", "RootCAFor"], + ["MemberOf", "AllExtendedRights", "PublishedTo", "IssuedSignedBy", "RootCAFor"], + ["MemberOf", "AllExtendedRights", "PublishedTo", "IssuedSignedBy", "RootCAFor"] + ] + } + }, + { + "name": "ADCS fanout endpoint projection preserves row multiplicity", + "cypher": "MATCH (n:Group) WHERE n.objectid = 'optimizer-endpoint-fanout-source' MATCH p1 = (n)-[:MemberOf*0..]->()-[:Enroll]->(ca:EnterpriseCA)-[:TrustedForNTAuth]->(:NTAuthStore)-[:NTAuthStoreFor]->(d:Domain) MATCH p2 = (n)-[:MemberOf*0..]->()-[:GenericAll|Enroll|AllExtendedRights]->(ct:CertTemplate)-[:PublishedTo]->(ca)-[:IssuedSignedBy|EnterpriseCAFor*1..]->(:RootCA)-[:RootCAFor]->(d) WHERE ct.authenticationenabled = true AND ct.requiresmanagerapproval = false AND ct.enrolleesuppliessubject = true AND (ct.schemaversion = 1 OR ct.authorizedsignatures = 0) RETURN count(*) AS rows, count(distinct id(ca)) AS ca_count, count(distinct id(d)) AS domain_count, count(distinct id(ct)) AS template_count", + "fixture": { + "nodes": [ + {"id": "n", "kinds": ["Group"], "properties": {"objectid": "optimizer-endpoint-fanout-source"}}, + {"id": "p1-a", "kinds": ["Group"]}, + {"id": "p1-b", "kinds": ["Group"]}, + {"id": "p2-a", "kinds": ["Group"]}, + {"id": "p2-b", "kinds": ["Group"]}, + {"id": "template-a", "kinds": ["CertTemplate"], "properties": {"authenticationenabled": true, "requiresmanagerapproval": false, "enrolleesuppliessubject": true, "schemaversion": 1, "authorizedsignatures": 1}}, + {"id": "template-b", "kinds": ["CertTemplate"], "properties": {"authenticationenabled": true, "requiresmanagerapproval": false, "enrolleesuppliessubject": true, "schemaversion": 2, "authorizedsignatures": 0}}, + {"id": "ca", "kinds": ["EnterpriseCA"]}, + {"id": "store", "kinds": ["NTAuthStore"]}, + {"id": "domain", "kinds": ["Domain"]}, + {"id": "root", "kinds": ["RootCA"]} + ], + "edges": [ + {"start_id": "n", "end_id": "p1-a", "kind": "MemberOf"}, + {"start_id": "p1-a", "end_id": "ca", "kind": "Enroll"}, + {"start_id": "n", "end_id": "p1-b", "kind": "MemberOf"}, + {"start_id": "p1-b", "end_id": "ca", "kind": "Enroll"}, + {"start_id": "ca", "end_id": "store", "kind": "TrustedForNTAuth"}, + {"start_id": "store", "end_id": "domain", "kind": "NTAuthStoreFor"}, + {"start_id": "n", "end_id": "p2-a", "kind": "MemberOf"}, + {"start_id": "p2-a", "end_id": "template-a", "kind": "GenericAll"}, + {"start_id": "n", "end_id": "p2-b", "kind": "MemberOf"}, + {"start_id": "p2-b", "end_id": "template-b", "kind": "AllExtendedRights"}, + {"start_id": "template-a", "end_id": "ca", "kind": "PublishedTo"}, + {"start_id": "template-b", "end_id": "ca", "kind": "PublishedTo"}, + {"start_id": "ca", "end_id": "root", "kind": "IssuedSignedBy"}, + {"start_id": "root", "end_id": "domain", "kind": "RootCAFor"} + ] + }, + "assert": {"row_values": [[4, 1, 1, 2]]} } ] } diff --git a/integration/testdata/cases/unwind_inline.json b/integration/testdata/cases/unwind_inline.json index 389d7438..d87cd43b 100644 --- a/integration/testdata/cases/unwind_inline.json +++ b/integration/testdata/cases/unwind_inline.json @@ -154,6 +154,24 @@ "edges": [] }, "assert": {"scalar_values": ["alpha", "beta", "tail"]} + }, + { + "name": "unwind barrier feeds an optimized expansion predicate", + "cypher": "WITH ['unwind-expansion-dst'] AS names UNWIND names AS name MATCH p = (src:NodeKind1)-[:EdgeKind1*1..]->(mid)-[:EdgeKind2]->(dst:NodeKind2) WHERE src.name = 'unwind-expansion-src' AND dst.name = name RETURN p", + "fixture": { + "nodes": [ + {"id": "src", "kinds": ["NodeKind1"], "properties": {"name": "unwind-expansion-src"}}, + {"id": "mid", "kinds": ["NodeKind1"]}, + {"id": "dst", "kinds": ["NodeKind2"], "properties": {"name": "unwind-expansion-dst"}}, + {"id": "decoy", "kinds": ["NodeKind2"], "properties": {"name": "unwind-expansion-decoy"}} + ], + "edges": [ + {"start_id": "src", "end_id": "mid", "kind": "EdgeKind1"}, + {"start_id": "mid", "end_id": "dst", "kind": "EdgeKind2"}, + {"start_id": "mid", "end_id": "decoy", "kind": "EdgeKind2"} + ] + }, + "assert": {"path_node_ids": [["src", "mid", "dst"]], "path_edge_kinds": [["EdgeKind1", "EdgeKind2"]]} } ] } From 67eaab73546d2ac0145b8cf4aa726537e368d31e Mon Sep 17 00:00:00 2001 From: John Hopper Date: Thu, 21 May 2026 22:43:13 -0700 Subject: [PATCH 043/116] Plan projection pruning for pattern predicates --- cypher/models/pgsql/optimize/lowering.go | 57 +++++++- cypher/models/pgsql/optimize/lowering_plan.go | 85 ++++++++---- .../models/pgsql/optimize/optimizer_test.go | 68 ++++++++++ .../pgsql/optimize/pattern_predicates.go | 45 +++++++ cypher/models/pgsql/translate/expansion.go | 5 +- cypher/models/pgsql/translate/predicate.go | 7 +- cypher/models/pgsql/translate/translator.go | 4 +- cypher/models/pgsql/translate/traversal.go | 125 ++---------------- 8 files changed, 248 insertions(+), 148 deletions(-) create mode 100644 cypher/models/pgsql/optimize/pattern_predicates.go diff --git a/cypher/models/pgsql/optimize/lowering.go b/cypher/models/pgsql/optimize/lowering.go index be5083e7..bf05721b 100644 --- a/cypher/models/pgsql/optimize/lowering.go +++ b/cypher/models/pgsql/optimize/lowering.go @@ -19,9 +19,11 @@ type LoweringDecision struct { } type PatternTarget struct { - QueryPartIndex int `json:"query_part_index"` - ClauseIndex int `json:"clause_index"` - PatternIndex int `json:"pattern_index"` + QueryPartIndex int `json:"query_part_index"` + ClauseIndex int `json:"clause_index"` + PatternIndex int `json:"pattern_index"` + Predicate bool `json:"predicate,omitempty"` + PredicateIndex int `json:"predicate_index,omitempty"` } func (s PatternTarget) TraversalStep(stepIndex int) TraversalStepTarget { @@ -29,15 +31,19 @@ func (s PatternTarget) TraversalStep(stepIndex int) TraversalStepTarget { QueryPartIndex: s.QueryPartIndex, ClauseIndex: s.ClauseIndex, PatternIndex: s.PatternIndex, + Predicate: s.Predicate, + PredicateIndex: s.PredicateIndex, StepIndex: stepIndex, } } type TraversalStepTarget struct { - QueryPartIndex int `json:"query_part_index"` - ClauseIndex int `json:"clause_index"` - PatternIndex int `json:"pattern_index"` - StepIndex int `json:"step_index"` + QueryPartIndex int `json:"query_part_index"` + ClauseIndex int `json:"clause_index"` + PatternIndex int `json:"pattern_index"` + Predicate bool `json:"predicate,omitempty"` + PredicateIndex int `json:"predicate_index,omitempty"` + StepIndex int `json:"step_index"` } type ProjectionPruningDecision struct { @@ -196,6 +202,32 @@ func IndexPatternTargets(query *cypher.RegularQuery) map[*cypher.PatternPart]Pat return targets } +func IndexPatternPredicateTargets(query *cypher.RegularQuery) map[*cypher.PatternPredicate]PatternTarget { + targets := map[*cypher.PatternPredicate]PatternTarget{} + + if query == nil || query.SingleQuery == nil { + return targets + } + + if query.SingleQuery.MultiPartQuery != nil { + for queryPartIndex, part := range query.SingleQuery.MultiPartQuery.Parts { + if part == nil { + continue + } + + indexQueryPartPatternPredicateTargets(targets, queryPartIndex, part) + } + + if finalPart := query.SingleQuery.MultiPartQuery.SinglePartQuery; finalPart != nil { + indexQueryPartPatternPredicateTargets(targets, len(query.SingleQuery.MultiPartQuery.Parts), finalPart) + } + } else if query.SingleQuery.SinglePartQuery != nil { + indexQueryPartPatternPredicateTargets(targets, 0, query.SingleQuery.SinglePartQuery) + } + + return targets +} + func indexReadingClauseTargets(targets map[*cypher.PatternPart]PatternTarget, queryPartIndex int, readingClauses []*cypher.ReadingClause) { for clauseIndex, readingClause := range readingClauses { if readingClause == nil || readingClause.Match == nil { @@ -211,3 +243,14 @@ func indexReadingClauseTargets(targets map[*cypher.PatternPart]PatternTarget, qu } } } + +func indexQueryPartPatternPredicateTargets(targets map[*cypher.PatternPredicate]PatternTarget, queryPartIndex int, queryPart cypher.SyntaxNode) { + for predicateIndex, predicate := range patternPredicatesInQueryPart(queryPart) { + targets[predicate] = PatternTarget{ + QueryPartIndex: queryPartIndex, + PatternIndex: predicateIndex, + Predicate: true, + PredicateIndex: predicateIndex, + } + } +} diff --git a/cypher/models/pgsql/optimize/lowering_plan.go b/cypher/models/pgsql/optimize/lowering_plan.go index 457ba745..c6f62629 100644 --- a/cypher/models/pgsql/optimize/lowering_plan.go +++ b/cypher/models/pgsql/optimize/lowering_plan.go @@ -70,6 +70,7 @@ func appendQueryPartLowerings( appendProjectionPruningDecisions(plan, queryPartIndex, readingClauses, sourceReferences) appendLatePathMaterializationDecisions(plan, queryPartIndex, readingClauses, sourceReferences) + appendPatternPredicateProjectionLowerings(plan, queryPartIndex, queryPart, sourceReferences) appendExpandIntoDecisions(plan, queryPartIndex, readingClauses) appendTraversalDirectionDecisions(plan, queryPartIndex, readingClauses, bindingPredicateSymbols(predicateAttachments, queryPartIndex)) appendShortestPathStrategyDecisions(plan, queryPartIndex, readingClauses, bindingPredicateSymbols(predicateAttachments, queryPartIndex)) @@ -132,6 +133,26 @@ func appendPatternProjectionPruningDecisions(plan *LoweringPlan, target PatternT } } +func appendPatternPredicateProjectionLowerings(plan *LoweringPlan, queryPartIndex int, queryPart cypher.SyntaxNode, sourceReferences map[string]struct{}) { + for predicateIndex, predicate := range patternPredicatesInQueryPart(queryPart) { + patternPart := patternPartForPredicate(predicate) + steps := traversalStepsForPattern(patternPart) + if len(steps) == 0 { + continue + } + + target := PatternTarget{ + QueryPartIndex: queryPartIndex, + PatternIndex: predicateIndex, + Predicate: true, + PredicateIndex: predicateIndex, + } + + appendPatternProjectionPruningDecisions(plan, target, patternPart, steps, sourceReferences) + appendPatternLatePathMaterializationDecisions(plan, target, patternPart, steps, sourceReferences) + } +} + func appendLatePathMaterializationDecisions(plan *LoweringPlan, queryPartIndex int, readingClauses []*cypher.ReadingClause, sourceReferences map[string]struct{}) { for clauseIndex, readingClause := range readingClauses { if readingClause == nil || readingClause.Match == nil || readingClause.Match.Optional { @@ -139,35 +160,53 @@ func appendLatePathMaterializationDecisions(plan *LoweringPlan, queryPartIndex i } for patternIndex, patternPart := range readingClause.Match.Pattern { - if !referencesSourceIdentifier(sourceReferences, variableSymbol(patternPart.Variable)) { + steps := traversalStepsForPattern(patternPart) + appendPatternLatePathMaterializationDecisions(plan, PatternTarget{ + QueryPartIndex: queryPartIndex, + ClauseIndex: clauseIndex, + PatternIndex: patternIndex, + }, patternPart, steps, sourceReferences) + } + } +} + +func appendPatternLatePathMaterializationDecisions(plan *LoweringPlan, target PatternTarget, patternPart *cypher.PatternPart, steps []sourceTraversalStep, sourceReferences map[string]struct{}) { + pathReferenced := referencesSourceIdentifier(sourceReferences, variableSymbol(patternPart.Variable)) + + for stepIndex, step := range steps { + stepTarget := target.TraversalStep(stepIndex) + + if step.Relationship.Range != nil { + if !pathReferenced { continue } - for stepIndex, step := range traversalStepsForPattern(patternPart) { - target := PatternTarget{ - QueryPartIndex: queryPartIndex, - ClauseIndex: clauseIndex, - PatternIndex: patternIndex, - }.TraversalStep(stepIndex) + plan.LatePathMaterialization = append(plan.LatePathMaterialization, LatePathMaterializationDecision{ + Target: stepTarget, + Mode: LatePathMaterializationExpansionPath, + }) + continue + } - if step.Relationship.Range != nil { - plan.LatePathMaterialization = append(plan.LatePathMaterialization, LatePathMaterializationDecision{ - Target: target, - Mode: LatePathMaterializationExpansionPath, - }) - continue - } + edgeReferenced := referencesSourceIdentifier(sourceReferences, variableSymbol(step.Relationship.Variable)) + if pathReferenced { + mode := LatePathMaterializationPathEdgeID + if edgeReferenced { + mode = LatePathMaterializationEdgeComposite + } - mode := LatePathMaterializationPathEdgeID - if referencesSourceIdentifier(sourceReferences, variableSymbol(step.Relationship.Variable)) { - mode = LatePathMaterializationEdgeComposite - } + plan.LatePathMaterialization = append(plan.LatePathMaterialization, LatePathMaterializationDecision{ + Target: stepTarget, + Mode: mode, + }) + continue + } - plan.LatePathMaterialization = append(plan.LatePathMaterialization, LatePathMaterializationDecision{ - Target: target, - Mode: mode, - }) - } + if !edgeReferenced && stepIndex+1 < len(steps) { + plan.LatePathMaterialization = append(plan.LatePathMaterialization, LatePathMaterializationDecision{ + Target: stepTarget, + Mode: LatePathMaterializationPathEdgeID, + }) } } } diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index 9e6fa5b5..26f41a89 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -115,6 +115,30 @@ func TestLoweringPlanProjectionPruningKeepsUpdateTargets(t *testing.T) { }}, plan.LoweringPlan.ProjectionPruning) } +func TestLoweringPlanReportsPatternPredicateProjectionPruning(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH (s) + WHERE (s)-[]->() + RETURN s + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Contains(t, plan.LoweringPlan.ProjectionPruning, ProjectionPruningDecision{ + Target: TraversalStepTarget{ + QueryPartIndex: 0, + Predicate: true, + StepIndex: 0, + }, + ReferencedSymbols: []string{"s"}, + OmitRelationship: true, + OmitRightNode: true, + }) +} + func TestLoweringPlanReportsLatePathMaterialization(t *testing.T) { t.Parallel() @@ -153,6 +177,50 @@ func TestLoweringPlanReportsLatePathMaterialization(t *testing.T) { require.NoError(t, err) require.Equal(t, LatePathMaterializationEdgeComposite, plan.LoweringPlan.LatePathMaterialization[0].Mode) }) + + t.Run("continuation relationship id", func(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH (n)-[:MemberOf]->(m)-[:Enroll]->(ca) + RETURN ca + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Contains(t, plan.LoweringPlan.LatePathMaterialization, LatePathMaterializationDecision{ + Target: TraversalStepTarget{ + QueryPartIndex: 0, + ClauseIndex: 0, + PatternIndex: 0, + StepIndex: 0, + }, + Mode: LatePathMaterializationPathEdgeID, + }) + }) + + t.Run("pattern predicate continuation relationship id", func(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH (s) + WHERE (s)-[]->()-[]->() + RETURN s + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Contains(t, plan.LoweringPlan.LatePathMaterialization, LatePathMaterializationDecision{ + Target: TraversalStepTarget{ + QueryPartIndex: 0, + Predicate: true, + StepIndex: 0, + }, + Mode: LatePathMaterializationPathEdgeID, + }) + }) } func TestLoweringPlanReportsExpansionSuffixPushdown(t *testing.T) { diff --git a/cypher/models/pgsql/optimize/pattern_predicates.go b/cypher/models/pgsql/optimize/pattern_predicates.go new file mode 100644 index 00000000..16e9349a --- /dev/null +++ b/cypher/models/pgsql/optimize/pattern_predicates.go @@ -0,0 +1,45 @@ +package optimize + +import ( + "github.com/specterops/dawgs/cypher/models/cypher" + "github.com/specterops/dawgs/cypher/models/walk" +) + +type patternPredicateCollector struct { + walk.VisitorHandler + predicates []*cypher.PatternPredicate +} + +func (s *patternPredicateCollector) Enter(node cypher.SyntaxNode) { + if predicate, isPatternPredicate := node.(*cypher.PatternPredicate); isPatternPredicate { + s.predicates = append(s.predicates, predicate) + } +} + +func (s *patternPredicateCollector) Visit(cypher.SyntaxNode) {} +func (s *patternPredicateCollector) Exit(cypher.SyntaxNode) {} + +func patternPredicatesInQueryPart(queryPart cypher.SyntaxNode) []*cypher.PatternPredicate { + if queryPart == nil { + return nil + } + + collector := &patternPredicateCollector{ + VisitorHandler: walk.NewCancelableErrorHandler(), + } + if err := walk.Cypher(queryPart, collector); err != nil { + return nil + } + + return collector.predicates +} + +func patternPartForPredicate(predicate *cypher.PatternPredicate) *cypher.PatternPart { + if predicate == nil { + return nil + } + + return &cypher.PatternPart{ + PatternElements: predicate.PatternElements, + } +} diff --git a/cypher/models/pgsql/translate/expansion.go b/cypher/models/pgsql/translate/expansion.go index 3d8a73d3..85b3589e 100644 --- a/cypher/models/pgsql/translate/expansion.go +++ b/cypher/models/pgsql/translate/expansion.go @@ -2999,9 +2999,8 @@ func (s *Translator) translateTraversalPatternPartWithExpansion(part *PatternPar // Export the path from the traversal's scope traversalStep.Frame.Export(expansionModel.PathBinding.Identifier) if allowProjectionPruning { - decision, hasDecision := s.projectionPruningDecision(part, stepIndex) - allowFallback := !hasDecision && (part == nil || !part.HasTarget) - if pruneExpansionStepProjectionExports(s.query.CurrentPart(), part, stepIndex, traversalStep, decision, hasDecision, allowFallback) { + _, hasDecision := s.projectionPruningDecision(part, stepIndex) + if hasDecision && pruneExpansionStepProjectionExports(part, stepIndex, traversalStep) { s.recordLowering(optimize.LoweringProjectionPruning) } diff --git a/cypher/models/pgsql/translate/predicate.go b/cypher/models/pgsql/translate/predicate.go index 002d3e95..69503565 100644 --- a/cypher/models/pgsql/translate/predicate.go +++ b/cypher/models/pgsql/translate/predicate.go @@ -4,11 +4,12 @@ import ( "fmt" "github.com/specterops/dawgs/cypher/models" + "github.com/specterops/dawgs/cypher/models/cypher" "github.com/specterops/dawgs/cypher/models/pgsql" "github.com/specterops/dawgs/graph" ) -func (s *Translator) preparePatternPredicate() error { +func (s *Translator) preparePatternPredicate(predicate *cypher.PatternPredicate) error { currentQueryPart := s.query.CurrentPart() // Stash the match pattern @@ -17,6 +18,10 @@ func (s *Translator) preparePatternPredicate() error { // All pattern predicates must be relationship patterns newPatternPart := currentQueryPart.currentPattern.NewPart() newPatternPart.IsTraversal = true + if target, hasTarget := s.patternPredicateTargets[predicate]; hasTarget { + newPatternPart.Target = target + newPatternPart.HasTarget = true + } return nil } diff --git a/cypher/models/pgsql/translate/translator.go b/cypher/models/pgsql/translate/translator.go index d7517b65..cd6be943 100644 --- a/cypher/models/pgsql/translate/translator.go +++ b/cypher/models/pgsql/translate/translator.go @@ -31,6 +31,7 @@ type Translator struct { unwindTargets map[*cypher.Variable]struct{} patternTargets map[*cypher.PatternPart]optimize.PatternTarget + patternPredicateTargets map[*cypher.PatternPredicate]optimize.PatternTarget projectionPruningDecisions map[optimize.TraversalStepTarget]optimize.ProjectionPruningDecision latePathDecisions map[optimize.TraversalStepTarget][]optimize.LatePathMaterializationDecision suffixPushdownDecisions map[optimize.TraversalStepTarget][]optimize.ExpansionSuffixPushdownDecision @@ -72,6 +73,7 @@ func NewTranslator(ctx context.Context, kindMapper pgsql.KindMapper, parameters func (s *Translator) SetOptimizationPlan(plan optimize.Plan) { s.patternTargets = optimize.IndexPatternTargets(plan.Query) + s.patternPredicateTargets = optimize.IndexPatternPredicateTargets(plan.Query) s.projectionPruningDecisions = map[optimize.TraversalStepTarget]optimize.ProjectionPruningDecision{} s.latePathDecisions = map[optimize.TraversalStepTarget][]optimize.LatePathMaterializationDecision{} s.suffixPushdownDecisions = map[optimize.TraversalStepTarget][]optimize.ExpansionSuffixPushdownDecision{} @@ -253,7 +255,7 @@ func (s *Translator) Enter(expression cypher.SyntaxNode) { s.query.CurrentPart().PrepareProjection() case *cypher.PatternPredicate: - if err := s.preparePatternPredicate(); err != nil { + if err := s.preparePatternPredicate(typedExpression); err != nil { s.SetError(err) } diff --git a/cypher/models/pgsql/translate/traversal.go b/cypher/models/pgsql/translate/traversal.go index c8036872..54883885 100644 --- a/cypher/models/pgsql/translate/traversal.go +++ b/cypher/models/pgsql/translate/traversal.go @@ -804,24 +804,6 @@ func (s *Translator) applyExpansionSuffixPushdown(part *PatternPart) (int, error return applied, nil } -func patternBindingDependsOn(queryPart *QueryPart, part *PatternPart, binding *BoundIdentifier) bool { - if queryPart == nil || part == nil || part.PatternBinding == nil || binding == nil { - return false - } - - if !queryPart.ReferencesBinding(part.PatternBinding) { - return false - } - - for _, dependency := range part.PatternBinding.Dependencies { - if dependency.Identifier == binding.Identifier { - return true - } - } - - return false -} - func traversalStepHasContinuation(part *PatternPart, stepIndex int) bool { return part != nil && stepIndex+1 < len(part.TraversalSteps) } @@ -942,31 +924,6 @@ func (s *Translator) applyPathEdgeIDMaterialization(part *PatternPart, stepIndex return true } -func traversalStepProjectsBinding(queryPart *QueryPart, part *PatternPart, stepIndex int, binding *BoundIdentifier) bool { - if binding == nil { - return false - } - - // Keep aliases referenced by later clauses and bindings needed to materialize - // a referenced path pattern. Everything else can stay internal to this step. - if (binding.Alias.Set && queryPart.ReferencesBinding(binding)) || patternBindingDependsOn(queryPart, part, binding) { - return true - } - - if traversalStepHasContinuation(part, stepIndex) { - if part.TraversalSteps[stepIndex].Edge == binding { - return true - } - - // A multi-hop pattern needs the right node from this step as the next - // step's left node even when the user never projects it. - nextStep := part.TraversalSteps[stepIndex+1] - return nextStep.LeftNode != nil && nextStep.LeftNode.Identifier == binding.Identifier - } - - return false -} - func unexportFrameBinding(frame *Frame, identifier pgsql.Identifier) bool { if frame == nil { return false @@ -1001,80 +958,30 @@ func unexportPrunedNodeBinding(traversalStep *TraversalStep, binding *BoundIdent return unexportFrameBinding(traversalStep.Frame, binding.Identifier) } -func pruneTraversalStepProjectionExports(queryPart *QueryPart, part *PatternPart, stepIndex int, traversalStep *TraversalStep, decision optimize.ProjectionPruningDecision, hasDecision bool, allowFallback bool) bool { +func pruneTraversalStepProjectionExports(part *PatternPart, stepIndex int, traversalStep *TraversalStep) bool { var applied bool - if hasDecision { - applied = unexportPrunedNodeBinding(traversalStep, traversalStep.ProjectionPruning.LeftNode) || applied - if traversalStep.ProjectionPruning.Relationship != nil && !traversalStepHasContinuation(part, stepIndex) { - applied = unexportFrameBinding(traversalStep.Frame, traversalStep.ProjectionPruning.Relationship.Identifier) || applied - } - applied = unexportPrunedNodeBinding(traversalStep, traversalStep.ProjectionPruning.RightNode) || applied - return applied - } - - if !allowFallback { - return false - } - - // Bound endpoints already exist in an outer frame. Only unexport unbound - // values that later clauses and continuation steps cannot observe. - if traversalStep.LeftNode != nil && !traversalStep.LeftNodeBound && !traversalStepProjectsBinding(queryPart, part, stepIndex, traversalStep.LeftNode) { - applied = unexportFrameBinding(traversalStep.Frame, traversalStep.LeftNode.Identifier) || applied - } - - if traversalStep.Edge != nil && - traversalStep.Edge.DataType == pgsql.EdgeComposite && - !queryPart.ReferencesBinding(traversalStep.Edge) && - patternBindingDependsOn(queryPart, part, traversalStep.Edge) { - traversalStep.Edge.DataType = pgsql.PathEdge - applied = true - } - - if traversalStep.Edge != nil && !traversalStepProjectsBinding(queryPart, part, stepIndex, traversalStep.Edge) { - applied = unexportFrameBinding(traversalStep.Frame, traversalStep.Edge.Identifier) || applied - } - - if traversalStep.RightNode != nil && !traversalStep.RightNodeBound && !traversalStepProjectsBinding(queryPart, part, stepIndex, traversalStep.RightNode) { - applied = unexportFrameBinding(traversalStep.Frame, traversalStep.RightNode.Identifier) || applied + applied = unexportPrunedNodeBinding(traversalStep, traversalStep.ProjectionPruning.LeftNode) || applied + if traversalStep.ProjectionPruning.Relationship != nil && !traversalStepHasContinuation(part, stepIndex) { + applied = unexportFrameBinding(traversalStep.Frame, traversalStep.ProjectionPruning.Relationship.Identifier) || applied } + applied = unexportPrunedNodeBinding(traversalStep, traversalStep.ProjectionPruning.RightNode) || applied return applied } -func pruneExpansionStepProjectionExports(queryPart *QueryPart, part *PatternPart, stepIndex int, traversalStep *TraversalStep, decision optimize.ProjectionPruningDecision, hasDecision bool, allowFallback bool) bool { +func pruneExpansionStepProjectionExports(part *PatternPart, stepIndex int, traversalStep *TraversalStep) bool { if traversalStep == nil || traversalStep.Expansion == nil { return false } var applied bool - if hasDecision { - if traversalStep.ProjectionPruning.Relationship != nil { - applied = unexportFrameBinding(traversalStep.Frame, traversalStep.ProjectionPruning.Relationship.Identifier) || applied - } - - if traversalStep.ProjectionPruning.PathBinding != nil && !traversalStepHasContinuation(part, stepIndex) { - applied = unexportFrameBinding(traversalStep.Frame, traversalStep.ProjectionPruning.PathBinding.Identifier) || applied - } - - return applied - } - - if !allowFallback { - return false - } - - // Variable-length relationship bindings materialize to edge-composite - // arrays. A path binding can be rebuilt later from the compact expansion - // path ID array, so keep the edge array only when the relationship binding - // itself is observable. - if traversalStep.Edge != nil && !queryPart.ReferencesBinding(traversalStep.Edge) { - applied = unexportFrameBinding(traversalStep.Frame, traversalStep.Edge.Identifier) || applied + if traversalStep.ProjectionPruning.Relationship != nil { + applied = unexportFrameBinding(traversalStep.Frame, traversalStep.ProjectionPruning.Relationship.Identifier) || applied } - pathBinding := traversalStep.Expansion.PathBinding - if pathBinding != nil && !traversalStepHasContinuation(part, stepIndex) && !patternBindingDependsOn(queryPart, part, pathBinding) { - applied = unexportFrameBinding(traversalStep.Frame, pathBinding.Identifier) || applied + if traversalStep.ProjectionPruning.PathBinding != nil && !traversalStepHasContinuation(part, stepIndex) { + applied = unexportFrameBinding(traversalStep.Frame, traversalStep.ProjectionPruning.PathBinding.Identifier) || applied } return applied @@ -1162,20 +1069,12 @@ func (s *Translator) translateTraversalPatternPartWithoutExpansion(part *Pattern } if allowProjectionPruning { - if traversalStepHasContinuation(part, stepIndex) && - traversalStep.Edge != nil && - traversalStep.Edge.DataType == pgsql.EdgeComposite && - !s.query.CurrentPart().ReferencesBinding(traversalStep.Edge) { - traversalStep.Edge.DataType = pgsql.PathEdge - } - if s.applyPathEdgeIDMaterialization(part, stepIndex, traversalStep) { s.recordLowering(optimize.LoweringLatePathMaterialization) } - decision, hasDecision := s.projectionPruningDecision(part, stepIndex) - allowFallback := !hasDecision && (part == nil || !part.HasTarget) - if pruneTraversalStepProjectionExports(s.query.CurrentPart(), part, stepIndex, traversalStep, decision, hasDecision, allowFallback) { + _, hasDecision := s.projectionPruningDecision(part, stepIndex) + if hasDecision && pruneTraversalStepProjectionExports(part, stepIndex, traversalStep) { s.recordLowering(optimize.LoweringProjectionPruning) } } From 08af7f6f6da99c6c5b8ec8b9cd175790858a4dbe Mon Sep 17 00:00:00 2001 From: John Hopper Date: Thu, 21 May 2026 22:45:13 -0700 Subject: [PATCH 044/116] Plan pattern predicate placement --- cypher/models/pgsql/optimize/lowering.go | 35 +++++++---- cypher/models/pgsql/optimize/lowering_plan.go | 40 ++++++++++++ .../models/pgsql/optimize/optimizer_test.go | 23 +++++++ .../pgsql/translate/optimizer_safety_test.go | 18 ++++++ cypher/models/pgsql/translate/predicate.go | 63 ++++++++++++------- cypher/models/pgsql/translate/translator.go | 6 ++ 6 files changed, 153 insertions(+), 32 deletions(-) diff --git a/cypher/models/pgsql/optimize/lowering.go b/cypher/models/pgsql/optimize/lowering.go index bf05721b..defdb948 100644 --- a/cypher/models/pgsql/optimize/lowering.go +++ b/cypher/models/pgsql/optimize/lowering.go @@ -131,16 +131,28 @@ type PredicatePlacementDecision struct { Placement PredicateAttachmentScope `json:"placement"` } +type PatternPredicatePlacementMode string + +const ( + PatternPredicatePlacementExistence PatternPredicatePlacementMode = "existence" +) + +type PatternPredicatePlacementDecision struct { + Target TraversalStepTarget `json:"target"` + Mode PatternPredicatePlacementMode `json:"mode"` +} + type LoweringPlan struct { - ProjectionPruning []ProjectionPruningDecision `json:"projection_pruning,omitempty"` - LatePathMaterialization []LatePathMaterializationDecision `json:"late_path_materialization,omitempty"` - ExpandInto []ExpandIntoDecision `json:"expand_into,omitempty"` - TraversalDirection []TraversalDirectionDecision `json:"traversal_direction,omitempty"` - ShortestPathStrategy []ShortestPathStrategyDecision `json:"shortest_path_strategy,omitempty"` - ShortestPathFilter []ShortestPathFilterDecision `json:"shortest_path_filter,omitempty"` - LimitPushdown []LimitPushdownDecision `json:"limit_pushdown,omitempty"` - ExpansionSuffixPushdown []ExpansionSuffixPushdownDecision `json:"expansion_suffix_pushdown,omitempty"` - PredicatePlacement []PredicatePlacementDecision `json:"predicate_placement,omitempty"` + ProjectionPruning []ProjectionPruningDecision `json:"projection_pruning,omitempty"` + LatePathMaterialization []LatePathMaterializationDecision `json:"late_path_materialization,omitempty"` + ExpandInto []ExpandIntoDecision `json:"expand_into,omitempty"` + TraversalDirection []TraversalDirectionDecision `json:"traversal_direction,omitempty"` + ShortestPathStrategy []ShortestPathStrategyDecision `json:"shortest_path_strategy,omitempty"` + ShortestPathFilter []ShortestPathFilterDecision `json:"shortest_path_filter,omitempty"` + LimitPushdown []LimitPushdownDecision `json:"limit_pushdown,omitempty"` + ExpansionSuffixPushdown []ExpansionSuffixPushdownDecision `json:"expansion_suffix_pushdown,omitempty"` + PredicatePlacement []PredicatePlacementDecision `json:"predicate_placement,omitempty"` + PatternPredicate []PatternPredicatePlacementDecision `json:"pattern_predicate_placement,omitempty"` } func (s LoweringPlan) Empty() bool { @@ -152,7 +164,8 @@ func (s LoweringPlan) Empty() bool { len(s.ShortestPathFilter) == 0 && len(s.LimitPushdown) == 0 && len(s.ExpansionSuffixPushdown) == 0 && - len(s.PredicatePlacement) == 0 + len(s.PredicatePlacement) == 0 && + len(s.PatternPredicate) == 0 } func (s LoweringPlan) Decisions() []LoweringDecision { @@ -171,7 +184,7 @@ func (s LoweringPlan) Decisions() []LoweringDecision { add(LoweringShortestPathFilter, len(s.ShortestPathFilter) > 0) add(LoweringLimitPushdown, len(s.LimitPushdown) > 0) add(LoweringExpansionSuffixPushdown, len(s.ExpansionSuffixPushdown) > 0) - add(LoweringPredicatePlacement, len(s.PredicatePlacement) > 0) + add(LoweringPredicatePlacement, len(s.PredicatePlacement) > 0 || len(s.PatternPredicate) > 0) return decisions } diff --git a/cypher/models/pgsql/optimize/lowering_plan.go b/cypher/models/pgsql/optimize/lowering_plan.go index c6f62629..015c154f 100644 --- a/cypher/models/pgsql/optimize/lowering_plan.go +++ b/cypher/models/pgsql/optimize/lowering_plan.go @@ -71,6 +71,7 @@ func appendQueryPartLowerings( appendProjectionPruningDecisions(plan, queryPartIndex, readingClauses, sourceReferences) appendLatePathMaterializationDecisions(plan, queryPartIndex, readingClauses, sourceReferences) appendPatternPredicateProjectionLowerings(plan, queryPartIndex, queryPart, sourceReferences) + appendPatternPredicatePlacementDecisions(plan, queryPartIndex, queryPart) appendExpandIntoDecisions(plan, queryPartIndex, readingClauses) appendTraversalDirectionDecisions(plan, queryPartIndex, readingClauses, bindingPredicateSymbols(predicateAttachments, queryPartIndex)) appendShortestPathStrategyDecisions(plan, queryPartIndex, readingClauses, bindingPredicateSymbols(predicateAttachments, queryPartIndex)) @@ -153,6 +154,41 @@ func appendPatternPredicateProjectionLowerings(plan *LoweringPlan, queryPartInde } } +func appendPatternPredicatePlacementDecisions(plan *LoweringPlan, queryPartIndex int, queryPart cypher.SyntaxNode) { + for predicateIndex, predicate := range patternPredicatesInQueryPart(queryPart) { + patternPart := patternPartForPredicate(predicate) + steps := traversalStepsForPattern(patternPart) + if len(steps) != 1 { + continue + } + + step := steps[0] + if step.Relationship == nil || + step.Relationship.Direction != graph.DirectionBoth || + relationshipPatternHasConstraints(step.Relationship) || + nodePatternHasConstraints(step.LeftNode) || + nodePatternHasConstraints(step.RightNode) { + continue + } + + if variableSymbol(step.Relationship.Variable) != "" || variableSymbol(step.RightNode.Variable) != "" { + continue + } + + target := PatternTarget{ + QueryPartIndex: queryPartIndex, + PatternIndex: predicateIndex, + Predicate: true, + PredicateIndex: predicateIndex, + }.TraversalStep(0) + + plan.PatternPredicate = append(plan.PatternPredicate, PatternPredicatePlacementDecision{ + Target: target, + Mode: PatternPredicatePlacementExistence, + }) + } +} + func appendLatePathMaterializationDecisions(plan *LoweringPlan, queryPartIndex int, readingClauses []*cypher.ReadingClause, sourceReferences map[string]struct{}) { for clauseIndex, readingClause := range readingClauses { if readingClause == nil || readingClause.Match == nil || readingClause.Match.Optional { @@ -909,6 +945,10 @@ func nodePatternHasConstraints(nodePattern *cypher.NodePattern) bool { return nodePattern != nil && (len(nodePattern.Kinds) > 0 || nodePattern.Properties != nil) } +func relationshipPatternHasConstraints(relationshipPattern *cypher.RelationshipPattern) bool { + return relationshipPattern != nil && (len(relationshipPattern.Kinds) > 0 || relationshipPattern.Properties != nil) +} + func addSymbol(symbols map[string]struct{}, symbol string) { if symbol != "" { symbols[symbol] = struct{}{} diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index 26f41a89..a487e148 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -139,6 +139,29 @@ func TestLoweringPlanReportsPatternPredicateProjectionPruning(t *testing.T) { }) } +func TestLoweringPlanReportsPatternPredicateExistencePlacement(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH (s) + WHERE NOT (s)-[]-() + RETURN s + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Contains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringPredicatePlacement}) + require.Equal(t, []PatternPredicatePlacementDecision{{ + Target: TraversalStepTarget{ + QueryPartIndex: 0, + Predicate: true, + StepIndex: 0, + }, + Mode: PatternPredicatePlacementExistence, + }}, plan.LoweringPlan.PatternPredicate) +} + func TestLoweringPlanReportsLatePathMaterialization(t *testing.T) { t.Parallel() diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index 25a57484..d8c9d1ea 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -287,6 +287,24 @@ RETURN p ) } +func TestOptimizerSafetyPatternPredicateExistencePlacementIsPlanned(t *testing.T) { + t.Parallel() + + translation := optimizerSafetyTranslation(t, ` +MATCH (s) +WHERE NOT (s)-[]-() +RETURN s +`) + + formattedQuery, err := Translated(translation) + require.NoError(t, err) + normalizedQuery := strings.Join(strings.Fields(formattedQuery), " ") + + require.Contains(t, normalizedQuery, "not exists (select 1 from edge e0") + requirePlannedOptimizationLowering(t, translation.Optimization, "PredicatePlacement") + requireOptimizationLowering(t, translation.Optimization, "PredicatePlacement") +} + func TestOptimizerSafetyContinuationRelationshipsExcludePriorPathRelationships(t *testing.T) { t.Parallel() diff --git a/cypher/models/pgsql/translate/predicate.go b/cypher/models/pgsql/translate/predicate.go index 69503565..54ed79ec 100644 --- a/cypher/models/pgsql/translate/predicate.go +++ b/cypher/models/pgsql/translate/predicate.go @@ -6,6 +6,7 @@ import ( "github.com/specterops/dawgs/cypher/models" "github.com/specterops/dawgs/cypher/models/cypher" "github.com/specterops/dawgs/cypher/models/pgsql" + "github.com/specterops/dawgs/cypher/models/pgsql/optimize" "github.com/specterops/dawgs/graph" ) @@ -88,6 +89,37 @@ func (s *Translator) translatePatternPredicate() error { return nil } +func (s *Translator) usePatternPredicateExistencePlacement(patternPart *PatternPart, traversalStep *TraversalStep) (bool, error) { + if patternPart == nil || !patternPart.HasTarget || traversalStep == nil || traversalStep.Direction != graph.DirectionBoth { + return false, nil + } + + decision, hasDecision := s.patternPredicateDecisions[patternPart.Target.TraversalStep(0)] + if !hasDecision || decision.Mode != optimize.PatternPredicatePlacementExistence { + return false, nil + } + + traversalStepIdentifiers := pgsql.AsIdentifierSet( + traversalStep.LeftNode.Identifier, + traversalStep.Edge.Identifier, + traversalStep.RightNode.Identifier, + ) + + if hasGlobalConstraints, err := s.treeTranslator.HasAnyConstraints(traversalStepIdentifiers); err != nil { + return false, err + } else if hasGlobalConstraints { + return false, nil + } + + if hasPredicateConstraints, err := patternPart.Constraints.HasConstraints(traversalStepIdentifiers); err != nil { + return false, err + } else if hasPredicateConstraints { + return false, nil + } + + return true, nil +} + // buildPatternPredicates is used by translateMatch to resolve deferred pattern predicate // futures collected for the current MATCH/OPTIONAL MATCH query part's WHERE expressions func (s *Translator) buildPatternPredicates() error { @@ -102,29 +134,18 @@ func (s *Translator) buildPatternPredicates() error { ) if len(patternPart.TraversalSteps) == 1 { - var ( - traversalStep = patternPart.TraversalSteps[0] - traversalStepIdentifiers = pgsql.AsIdentifierSet( - traversalStep.LeftNode.Identifier, - traversalStep.Edge.Identifier, - traversalStep.RightNode.Identifier, - ) - ) - - if traversalStep.Direction == graph.DirectionBoth { - if hasGlobalConstraints, err := s.treeTranslator.HasAnyConstraints(traversalStepIdentifiers); err != nil { - return err - } else if hasPredicateConstraints, err := patternPart.Constraints.HasConstraints(traversalStepIdentifiers); err != nil { + traversalStep := patternPart.TraversalSteps[0] + if useExistencePlacement, err := s.usePatternPredicateExistencePlacement(patternPart, traversalStep); err != nil { + return err + } else if useExistencePlacement { + if predicateExpression, err := s.buildOptimizedRelationshipExistPredicate(patternPart, traversalStep); err != nil { return err - } else if !hasPredicateConstraints && !hasGlobalConstraints { - if predicateExpression, err := s.buildOptimizedRelationshipExistPredicate(patternPart, traversalStep); err != nil { - return err - } else { - predicateFuture.SyntaxNode = predicateExpression - } - - return nil + } else { + predicateFuture.SyntaxNode = predicateExpression + s.recordLowering(optimize.LoweringPredicatePlacement) } + + return nil } } diff --git a/cypher/models/pgsql/translate/translator.go b/cypher/models/pgsql/translate/translator.go index cd6be943..7d92fc21 100644 --- a/cypher/models/pgsql/translate/translator.go +++ b/cypher/models/pgsql/translate/translator.go @@ -40,6 +40,7 @@ type Translator struct { shortestPathStrategyDecisions map[optimize.TraversalStepTarget]optimize.ShortestPathStrategyDecision shortestPathFilterDecisions map[optimize.TraversalStepTarget][]optimize.ShortestPathFilterDecision limitPushdownDecisions map[optimize.TraversalStepTarget][]optimize.LimitPushdownDecision + patternPredicateDecisions map[optimize.TraversalStepTarget]optimize.PatternPredicatePlacementDecision } func NewTranslator(ctx context.Context, kindMapper pgsql.KindMapper, parameters map[string]any, graphID int32) *Translator { @@ -82,6 +83,7 @@ func (s *Translator) SetOptimizationPlan(plan optimize.Plan) { s.shortestPathStrategyDecisions = map[optimize.TraversalStepTarget]optimize.ShortestPathStrategyDecision{} s.shortestPathFilterDecisions = map[optimize.TraversalStepTarget][]optimize.ShortestPathFilterDecision{} s.limitPushdownDecisions = map[optimize.TraversalStepTarget][]optimize.LimitPushdownDecision{} + s.patternPredicateDecisions = map[optimize.TraversalStepTarget]optimize.PatternPredicatePlacementDecision{} for _, decision := range plan.LoweringPlan.ProjectionPruning { s.projectionPruningDecisions[decision.Target] = decision @@ -114,6 +116,10 @@ func (s *Translator) SetOptimizationPlan(plan optimize.Plan) { for _, decision := range plan.LoweringPlan.LimitPushdown { s.limitPushdownDecisions[decision.Target] = append(s.limitPushdownDecisions[decision.Target], decision) } + + for _, decision := range plan.LoweringPlan.PatternPredicate { + s.patternPredicateDecisions[decision.Target] = decision + } } func (s *Translator) Enter(expression cypher.SyntaxNode) { From ba0fef7079d1ff89167c08bdbd673a6c37a210eb Mon Sep 17 00:00:00 2001 From: John Hopper Date: Thu, 21 May 2026 22:49:31 -0700 Subject: [PATCH 045/116] Centralize selectivity and locality planning --- cypher/models/pgsql/optimize/locality.go | 189 +++++++++++ .../models/pgsql/optimize/optimizer_test.go | 31 ++ cypher/models/pgsql/optimize/selectivity.go | 299 ++++++++++++++++++ cypher/models/pgsql/translate/constraints.go | 33 +- cypher/models/pgsql/translate/model.go | 176 +---------- cypher/models/pgsql/translate/selectivity.go | 221 +------------ cypher/models/pgsql/translate/tracking.go | 8 + docs/optimization-pass-memory.md | 287 ----------------- 8 files changed, 556 insertions(+), 688 deletions(-) create mode 100644 cypher/models/pgsql/optimize/locality.go create mode 100644 cypher/models/pgsql/optimize/selectivity.go delete mode 100644 docs/optimization-pass-memory.md diff --git a/cypher/models/pgsql/optimize/locality.go b/cypher/models/pgsql/optimize/locality.go new file mode 100644 index 00000000..22301ce9 --- /dev/null +++ b/cypher/models/pgsql/optimize/locality.go @@ -0,0 +1,189 @@ +package optimize + +import ( + "github.com/specterops/dawgs/cypher/models/pgsql" + "github.com/specterops/dawgs/cypher/models/walk" +) + +// FlattenConjunction collects the leaf operands of a left-recursive AND chain. +func FlattenConjunction(expr pgsql.Expression) []pgsql.Expression { + if bin, typeOK := expr.(*pgsql.BinaryExpression); !typeOK || bin.Operator != pgsql.OperatorAnd { + return []pgsql.Expression{expr} + } else { + return append(FlattenConjunction(bin.LOperand), FlattenConjunction(bin.ROperand)...) + } +} + +// ExpressionReferencesOnlyLocalIdentifiers returns true only when every binding +// reference found in the expression is a member of localScope. +func ExpressionReferencesOnlyLocalIdentifiers(expression pgsql.Expression, localScope *pgsql.IdentifierSet) bool { + isLocal := true + + walk.PgSQL(expression, walk.NewSimpleVisitor[pgsql.SyntaxNode]( + func(node pgsql.SyntaxNode, handler walk.VisitorHandler) { + switch typedNode := node.(type) { + case pgsql.ExistsExpression: + if !SubqueryReferencesOnlyLocalIdentifiers(typedNode.Subquery, localScope) { + isLocal = false + handler.SetDone() + } else { + handler.Consume() + } + + case pgsql.CompoundIdentifier: + if len(typedNode) > 0 && !localScope.Contains(typedNode[0]) { + isLocal = false + handler.SetDone() + } + + case pgsql.Identifier: + if !localScope.Contains(typedNode) { + isLocal = false + handler.SetDone() + } + + case pgsql.RowColumnReference: + if !ExpressionReferencesOnlyLocalIdentifiers(typedNode.Identifier, localScope) { + isLocal = false + handler.SetDone() + } else { + handler.Consume() + } + } + }, + )) + + return isLocal +} + +func SubqueryReferencesOnlyLocalIdentifiers(subquery pgsql.Subquery, localScope *pgsql.IdentifierSet) bool { + return QueryReferencesOnlyLocalIdentifiers(subquery.Query, localScope) +} + +func QueryReferencesOnlyLocalIdentifiers(query pgsql.Query, localScope *pgsql.IdentifierSet) bool { + if query.CommonTableExpressions != nil { + return false + } + + selectBody, isSelect := query.Body.(pgsql.Select) + if !isSelect { + return false + } + + if !SelectReferencesOnlyLocalIdentifiers(selectBody, localScope) { + return false + } + + for _, orderBy := range query.OrderBy { + if orderBy != nil && !ExpressionReferencesOnlyLocalIdentifiers(orderBy.Expression, localScope) { + return false + } + } + + return (query.Offset == nil || ExpressionReferencesOnlyLocalIdentifiers(query.Offset, localScope)) && + (query.Limit == nil || ExpressionReferencesOnlyLocalIdentifiers(query.Limit, localScope)) +} + +func AddFromClauseBindings(localScope *pgsql.IdentifierSet, fromClauses []pgsql.FromClause) { + for _, fromClause := range fromClauses { + AddFromExpressionBinding(localScope, fromClause.Source) + + for _, join := range fromClause.Joins { + AddFromExpressionBinding(localScope, join.Table) + } + } +} + +func AddFromExpressionBinding(localScope *pgsql.IdentifierSet, expression pgsql.Expression) { + switch typedExpression := expression.(type) { + case pgsql.TableReference: + if typedExpression.Binding.Set { + localScope.Add(typedExpression.Binding.Value) + } + + case pgsql.LateralSubquery: + if typedExpression.Binding.Set { + localScope.Add(typedExpression.Binding.Value) + } + } +} + +func SelectReferencesOnlyLocalIdentifiers(selectBody pgsql.Select, localScope *pgsql.IdentifierSet) bool { + scopedIdentifiers := localScope.Copy() + AddFromClauseBindings(scopedIdentifiers, selectBody.From) + + for _, projection := range selectBody.Projection { + if !ExpressionReferencesOnlyLocalIdentifiers(projection, scopedIdentifiers) { + return false + } + } + + for _, fromClause := range selectBody.From { + if !FromExpressionReferencesOnlyLocalIdentifiers(fromClause.Source) { + return false + } + + for _, join := range fromClause.Joins { + if !FromExpressionReferencesOnlyLocalIdentifiers(join.Table) { + return false + } + + if join.JoinOperator.Constraint != nil && + !ExpressionReferencesOnlyLocalIdentifiers(join.JoinOperator.Constraint, scopedIdentifiers) { + return false + } + } + } + + for _, groupByExpression := range selectBody.GroupBy { + if !ExpressionReferencesOnlyLocalIdentifiers(groupByExpression, scopedIdentifiers) { + return false + } + } + + return (selectBody.Where == nil || ExpressionReferencesOnlyLocalIdentifiers(selectBody.Where, scopedIdentifiers)) && + (selectBody.Having == nil || ExpressionReferencesOnlyLocalIdentifiers(selectBody.Having, scopedIdentifiers)) +} + +func FromExpressionReferencesOnlyLocalIdentifiers(expression pgsql.Expression) bool { + switch expression.(type) { + case pgsql.TableReference: + return true + + default: + return false + } +} + +func IsLocalToScope(expression pgsql.Expression, localScope *pgsql.IdentifierSet) bool { + if expression == nil { + return true + } + + return ExpressionReferencesOnlyLocalIdentifiers(expression, localScope) +} + +// PartitionConstraintByLocality splits a conjunction (A AND B AND ...) into +// two expressions: one whose every binding reference is contained in +// localScope (safe for JOIN ON), and one that references outside identifiers +// (must stay in WHERE). +// +// Only top-level AND operands are split. If an expression is not a +// BinaryExpression with OperatorAnd, the whole expression is tested as a unit. +func PartitionConstraintByLocality(expression pgsql.Expression, localScope *pgsql.IdentifierSet) (pgsql.Expression, pgsql.Expression) { + var ( + joinConstraints pgsql.Expression + whereConstraints pgsql.Expression + terms = FlattenConjunction(expression) + ) + + for _, term := range terms { + if IsLocalToScope(term, localScope) { + joinConstraints = pgsql.OptionalAnd(joinConstraints, term) + } else { + whereConstraints = pgsql.OptionalAnd(whereConstraints, term) + } + } + + return joinConstraints, whereConstraints +} diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index a487e148..c3637434 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -5,6 +5,7 @@ import ( "github.com/specterops/dawgs/cypher/frontend" "github.com/specterops/dawgs/cypher/models/cypher" + "github.com/specterops/dawgs/cypher/models/pgsql" "github.com/stretchr/testify/require" ) @@ -20,6 +21,13 @@ func (s testRule) Apply(plan *Plan) (bool, error) { return false, nil } +type testBindingLookup map[pgsql.Identifier]pgsql.DataType + +func (s testBindingLookup) LookupDataType(identifier pgsql.Identifier) (pgsql.DataType, bool) { + dataType, found := s[identifier] + return dataType, found +} + func TestOptimizeCopiesAndAnalyzesQuery(t *testing.T) { t.Parallel() @@ -162,6 +170,29 @@ func TestLoweringPlanReportsPatternPredicateExistencePlacement(t *testing.T) { }}, plan.LoweringPlan.PatternPredicate) } +func TestSelectivityModelPlansTraversalDirection(t *testing.T) { + t.Parallel() + + model := NewSelectivityModel(testBindingLookup{}) + rightIDLookup := pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{pgsql.Identifier("n1"), pgsql.ColumnID}, + pgsql.OperatorEquals, + pgsql.NewLiteral(1, pgsql.Int), + ) + + shouldFlip, err := model.ShouldFlipTraversalDirection(false, false, nil, rightIDLookup) + require.NoError(t, err) + require.True(t, shouldFlip) + + shouldFlip, err = model.ShouldFlipTraversalDirection(true, false, nil, rightIDLookup) + require.NoError(t, err) + require.False(t, shouldFlip) + + shouldFlip, err = model.ShouldFlipTraversalDirection(false, true, nil, nil) + require.NoError(t, err) + require.True(t, shouldFlip) +} + func TestLoweringPlanReportsLatePathMaterialization(t *testing.T) { t.Parallel() diff --git a/cypher/models/pgsql/optimize/selectivity.go b/cypher/models/pgsql/optimize/selectivity.go new file mode 100644 index 00000000..c7617bea --- /dev/null +++ b/cypher/models/pgsql/optimize/selectivity.go @@ -0,0 +1,299 @@ +package optimize + +import ( + "fmt" + + "github.com/specterops/dawgs/cypher/models/pgsql" + "github.com/specterops/dawgs/cypher/models/walk" +) + +const ( + // Below are a select set of constants to represent different weights to represent, roughly, the selectivity + // of a given PGSQL expression. These weights are meant to be inexact and are only useful in comparison to other + // summed weights. + // + // The goal of these weights are to enable reordering of queries such that the more selective side of a traversal + // step is expanded first. Eventually, these weights may also enable reordering of multipart queries. + + // Entity ID references are a safe selectivity bet. A direct reference will typically take the form of: + // `n0.id = 1` or some other direct comparison against the entity's ID. All entity IDs are covered by a unique + // b-tree index, making them both highly selective and lucrative to weight higher. + selectivityWeightEntityIDReference = 125 + + // Unique node properties are both covered by a compatible index and unique, making them highly selective. + selectivityWeightUniqueNodeProperty = 100 + + // Bound identifiers are heavily weighted for preserving join order integrity. + selectivityWeightBoundIdentifier = 700 + + // Operators that narrow the search space are given a higher selectivity. + selectivityWeightNarrowSearch = 30 + + // Operators that perform string searches are given a higher selectivity. + selectivityWeightStringSearch = 20 + + // Operators that perform range comparisons are reasonably selective. + selectivityWeightRangeComparison = 10 + + // Conjunctions can narrow search space, especially when compounded, but may be order dependent and unreliable as + // a good selectivity heuristic. + selectivityWeightConjunction = 5 + + // Exclusions can narrow the search space but often only slightly. + selectivityWeightNotEquals = 1 + + // Disjunctions expand search space by adding a secondary, conditional operation. + selectivityWeightDisjunction = -100 + + // selectivityFlipThreshold is the minimum score advantage the right-hand node must hold + // over the left-hand node before constraint balancing commits to a traversal direction flip. + // It is set to selectivityWeightNarrowSearch so that structural AST noise, in particular the + // per-AND-node conjunction bonus, cannot trigger a flip on its own. A single meaningful + // narrowing predicate (=, IN, kind filter) on the right side is sufficient to clear this + // bar; a bare AND connector (weight 5) or a range comparison on an unindexed property + // (weight 10) is not. + selectivityFlipThreshold = selectivityWeightNarrowSearch + + // selectivityBidirectionalAnchorThreshold is the minimum score each endpoint must carry + // before shortest-path translation starts a bidirectional search from both sides. This + // keeps broad label-only endpoints out of bidirectional BFS; a single kind predicate + // scores below this threshold, while a materially narrower property predicate can clear it. + selectivityBidirectionalAnchorThreshold = selectivityWeightNarrowSearch * 2 +) + +// knownNodePropertySelectivity is a hack to enable the selectivity measurement to take advantage of known property +// indexes or uniqueness constraints. +// +// Eventually, this should be replaced by a tool that can introspect a graph schema and derive this map. +var knownNodePropertySelectivity = map[string]int{ + "objectid": selectivityWeightUniqueNodeProperty, // Object ID contains a unique constraint giving this a high degree of selectivity. + "name": selectivityWeightUniqueNodeProperty, // Name contains a unique constraint giving this a high degree of selectivity. + "system_tags": selectivityWeightNarrowSearch, // Searches that use the system_tags property are likely to have a higher degree of selectivity. +} + +type BindingLookup interface { + LookupDataType(identifier pgsql.Identifier) (pgsql.DataType, bool) +} + +type SelectivityModel struct { + bindings BindingLookup +} + +func NewSelectivityModel(bindings BindingLookup) SelectivityModel { + return SelectivityModel{ + bindings: bindings, + } +} + +type propertyLookup struct { + reference pgsql.CompoundIdentifier + field string +} + +type measureSelectivityVisitor struct { + walk.Visitor[pgsql.SyntaxNode] + + model SelectivityModel + selectivityStack []int +} + +func newMeasureSelectivityVisitor(model SelectivityModel) *measureSelectivityVisitor { + return &measureSelectivityVisitor{ + Visitor: walk.NewVisitor[pgsql.SyntaxNode](), + model: model, + selectivityStack: []int{0}, + } +} + +func (s *measureSelectivityVisitor) Selectivity() int { + return s.selectivityStack[0] +} + +func (s *measureSelectivityVisitor) popSelectivity() int { + value := s.Selectivity() + s.selectivityStack = s.selectivityStack[:len(s.selectivityStack)-1] + + return value +} + +func (s *measureSelectivityVisitor) pushSelectivity(value int) { + s.selectivityStack = append(s.selectivityStack, value) +} + +func (s *measureSelectivityVisitor) addSelectivity(value int) { + if len(s.selectivityStack) == 0 { + s.pushSelectivity(value) + } else { + s.selectivityStack[len(s.selectivityStack)-1] += value + } +} + +func isColumnIDRef(expression pgsql.Expression) bool { + switch typedExpression := expression.(type) { + case pgsql.CompoundIdentifier: + if typedExpression.HasField() { + switch typedExpression.Field() { + case pgsql.ColumnID: + return true + } + } + } + + return false +} + +func binaryExpressionToPropertyLookup(expression *pgsql.BinaryExpression) (propertyLookup, error) { + if reference, typeOK := expression.LOperand.(pgsql.CompoundIdentifier); !typeOK { + return propertyLookup{}, fmt.Errorf("expected left operand for property lookup to be a compound identifier but found type: %T", expression.LOperand) + } else if field, typeOK := expression.ROperand.(pgsql.Literal); !typeOK { + return propertyLookup{}, fmt.Errorf("expected right operand for property lookup to be a literal but found type: %T", expression.ROperand) + } else if field.CastType != pgsql.Text { + return propertyLookup{}, fmt.Errorf("expected property lookup field a string literal but found data type: %s", field.CastType) + } else if stringField, typeOK := field.Value.(string); !typeOK { + return propertyLookup{}, fmt.Errorf("expected property lookup field a string literal but found data type: %T", field) + } else { + return propertyLookup{ + reference: reference, + field: stringField, + }, nil + } +} + +func (s *measureSelectivityVisitor) Enter(node pgsql.SyntaxNode) { + switch typedNode := node.(type) { + case *pgsql.UnaryExpression: + switch typedNode.Operator { + case pgsql.OperatorNot: + s.pushSelectivity(0) + } + + case *pgsql.BinaryExpression: + var ( + lOperandIsID = isColumnIDRef(typedNode.LOperand) + rOperandIsID = isColumnIDRef(typedNode.ROperand) + ) + + if lOperandIsID && !rOperandIsID { + // Point lookup: n0.id = ; highly selective. + s.addSelectivity(selectivityWeightEntityIDReference) + } else if rOperandIsID && !lOperandIsID { + // Canonically unusual, but handle it the same. + s.addSelectivity(selectivityWeightEntityIDReference) + } + + // If both sides are ID refs, this is a join condition; do not score as a point lookup. + switch typedNode.Operator { + case pgsql.OperatorOr: + s.addSelectivity(selectivityWeightDisjunction) + + case pgsql.OperatorNotEquals: + s.addSelectivity(selectivityWeightNotEquals) + + case pgsql.OperatorAnd: + s.addSelectivity(selectivityWeightConjunction) + + case pgsql.OperatorLessThan, pgsql.OperatorGreaterThan, pgsql.OperatorLessThanOrEqualTo, pgsql.OperatorGreaterThanOrEqualTo: + s.addSelectivity(selectivityWeightRangeComparison) + + case pgsql.OperatorLike, pgsql.OperatorILike, pgsql.OperatorRegexMatch, pgsql.OperatorSimilarTo: + s.addSelectivity(selectivityWeightStringSearch) + + case pgsql.OperatorIn, pgsql.OperatorEquals, pgsql.OperatorIs: + s.addSelectivity(selectivityWeightNarrowSearch) + + case pgsql.OperatorPGArrayOverlap, pgsql.OperatorArrayOverlap: + s.addSelectivity(selectivityWeightNarrowSearch) + + case pgsql.OperatorPGArrayLHSContainsRHS: + // @> is strictly more selective than &&: all kind_ids must be present. + s.addSelectivity(selectivityWeightNarrowSearch + selectivityWeightConjunction) + + case pgsql.OperatorJSONField, pgsql.OperatorJSONTextField, pgsql.OperatorPropertyLookup: + if propertyLookup, err := binaryExpressionToPropertyLookup(typedNode); err != nil { + s.SetError(err) + } else { + leftIdentifier := propertyLookup.reference.Root() + if s.model.bindings == nil { + return + } + + if dataType, bound := s.model.bindings.LookupDataType(leftIdentifier); !bound { + s.SetErrorf("unable to lookup identifier %s", leftIdentifier) + } else { + switch dataType { + case pgsql.ExpansionRootNode, pgsql.ExpansionTerminalNode, pgsql.NodeComposite: + if selectivity, hasKnownSelectivity := knownNodePropertySelectivity[propertyLookup.field]; hasKnownSelectivity { + s.addSelectivity(selectivity) + } + } + } + } + } + } +} + +func (s *measureSelectivityVisitor) Exit(node pgsql.SyntaxNode) { + switch typedNode := node.(type) { + case *pgsql.UnaryExpression: + switch typedNode.Operator { + case pgsql.OperatorNot: + selectivity := s.popSelectivity() + s.addSelectivity(-selectivity) + } + } +} + +func (s SelectivityModel) Measure(expression pgsql.Expression) (int, error) { + visitor := newMeasureSelectivityVisitor(s) + + if expression != nil { + if err := walk.PgSQL(expression, visitor); err != nil { + return 0, err + } + } + + return visitor.Selectivity(), nil +} + +func (s SelectivityModel) ShouldFlipTraversalDirection(leftBound, rightBound bool, leftExpression, rightExpression pgsql.Expression) (bool, error) { + if leftBound { + return false, nil + } + + if rightBound { + return true, nil + } + + leftSelectivity, err := s.Measure(leftExpression) + if err != nil { + return false, err + } + + rightSelectivity, err := s.Measure(rightExpression) + if err != nil { + return false, err + } + + return rightSelectivity-leftSelectivity >= selectivityFlipThreshold, nil +} + +func (s SelectivityModel) EndpointSelectivity(expression pgsql.Expression, bound, hasPreviousFrameBinding bool) (int, error) { + selectivity, err := s.Measure(expression) + if err != nil { + return 0, err + } + + if bound && hasPreviousFrameBinding { + selectivity += selectivityWeightBoundIdentifier + } + + return selectivity, nil +} + +func MeasureSelectivity(bindings BindingLookup, expression pgsql.Expression) (int, error) { + return NewSelectivityModel(bindings).Measure(expression) +} + +func IsBidirectionalSearchAnchor(selectivity int) bool { + return selectivity >= selectivityBidirectionalAnchorThreshold +} diff --git a/cypher/models/pgsql/translate/constraints.go b/cypher/models/pgsql/translate/constraints.go index 7ffa97be..49dd0a63 100644 --- a/cypher/models/pgsql/translate/constraints.go +++ b/cypher/models/pgsql/translate/constraints.go @@ -6,6 +6,7 @@ import ( "github.com/specterops/dawgs/cypher/models/walk" "github.com/specterops/dawgs/cypher/models/pgsql" + "github.com/specterops/dawgs/cypher/models/pgsql/optimize" "github.com/specterops/dawgs/graph" ) @@ -438,7 +439,8 @@ type PatternConstraints struct { // of the traversal has an extreme disparity in search space. // // In cases that match this heuristic, it's beneficial to begin the traversal with the most tightly constrained set -// of nodes. To accomplish this we flip the order of the traversal step. +// of nodes. The optimizer selectivity model decides whether the step should flip; this method only applies that +// decision to the translated constraint and traversal state. func (s *PatternConstraints) OptimizePatternConstraintBalance(scope *Scope, traversalStep *TraversalStep) (bool, error) { // If the left node is already materialized from a previous step, it is the anchor // for this expansion. Flipping the traversal direction would detach it from the @@ -447,26 +449,21 @@ func (s *PatternConstraints) OptimizePatternConstraintBalance(scope *Scope, trav return false, nil } - if traversalStep.RightNodeBound { - // Only flip when a previous frame exists to serve as the FROM source for the - // now-left bound node. In self-referential patterns such as (u)-[]->(u) the - // right node is "bound" because it reuses the left node's variable, but there - // is no preceding CTE to reference. Flipping in that case would set - // LeftNodeBound = true while Frame.Previous is nil, causing a nil-pointer - // dereference in buildTraversalPatternRoot. - if traversalStep.hasPreviousFrameBinding() { - traversalStep.FlipNodes() - s.FlipNodes() - } - - return true, nil + // Only flip a right-bound segment when a previous frame exists to serve as the + // FROM source for the now-left bound node. Self-referential patterns such as + // (u)-[]->(u) can mark the right node as bound without a preceding CTE. + if traversalStep.RightNodeBound && !traversalStep.hasPreviousFrameBinding() { + return false, nil } - if leftNodeSelectivity, err := MeasureSelectivity(scope, s.LeftNode.Expression); err != nil { - return false, err - } else if rightNodeSelectivity, err := MeasureSelectivity(scope, s.RightNode.Expression); err != nil { + if shouldFlip, err := optimize.NewSelectivityModel(scope).ShouldFlipTraversalDirection( + traversalStep.LeftNodeBound, + traversalStep.RightNodeBound, + s.LeftNode.Expression, + s.RightNode.Expression, + ); err != nil { return false, err - } else if rightNodeSelectivity-leftNodeSelectivity >= selectivityFlipThreshold { + } else if shouldFlip { // (a)-[*..]->(b:Constraint) // (a)<-[*..]-(b:Constraint) traversalStep.FlipNodes() diff --git a/cypher/models/pgsql/translate/model.go b/cypher/models/pgsql/translate/model.go index 2a0ed669..be95bb51 100644 --- a/cypher/models/pgsql/translate/model.go +++ b/cypher/models/pgsql/translate/model.go @@ -186,20 +186,11 @@ func canMaterializeEndpointPairFilterForStep(traversalStep *TraversalStep, expan } func (s *TraversalStep) endpointSelectivity(scope *Scope, expression pgsql.Expression, bound bool) (int, error) { - selectivity, err := MeasureSelectivity(scope, expression) - if err != nil { - return 0, err - } - - if bound && s.hasPreviousFrameBinding() { - selectivity += selectivityWeightBoundIdentifier - } - - return selectivity, nil + return optimize.NewSelectivityModel(scope).EndpointSelectivity(expression, bound, s.hasPreviousFrameBinding()) } func isBidirectionalSearchAnchor(selectivity int) bool { - return selectivity >= selectivityBidirectionalAnchorThreshold + return optimize.IsBidirectionalSearchAnchor(selectivity) } func hasIDEqualityConstraint(expression pgsql.Expression, identifier pgsql.Identifier) bool { @@ -382,187 +373,44 @@ func (s *TraversalStep) CanExecutePairAwareBidirectionalSearch(scope *Scope) (bo } } -// flattenConjunction collects the leaf operands of a left-recursive AND chain. func flattenConjunction(expr pgsql.Expression) []pgsql.Expression { - if bin, typeOK := expr.(*pgsql.BinaryExpression); !typeOK || bin.Operator != pgsql.OperatorAnd { - return []pgsql.Expression{expr} - } else { - return append(flattenConjunction(bin.LOperand), flattenConjunction(bin.ROperand)...) - } + return optimize.FlattenConjunction(expr) } -// expressionReferencesOnlyLocalIdentifiers returns true only when every binding -// reference found in the expression is a member of localScope. func expressionReferencesOnlyLocalIdentifiers(expression pgsql.Expression, localScope *pgsql.IdentifierSet) bool { - isLocal := true - - walk.PgSQL(expression, walk.NewSimpleVisitor[pgsql.SyntaxNode]( - func(node pgsql.SyntaxNode, handler walk.VisitorHandler) { - switch typedNode := node.(type) { - case pgsql.ExistsExpression: - if !subqueryReferencesOnlyLocalIdentifiers(typedNode.Subquery, localScope) { - isLocal = false - handler.SetDone() - } else { - handler.Consume() - } - - case pgsql.CompoundIdentifier: - if len(typedNode) > 0 && !localScope.Contains(typedNode[0]) { - isLocal = false - handler.SetDone() - } - - case pgsql.Identifier: - if !localScope.Contains(typedNode) { - isLocal = false - handler.SetDone() - } - - case pgsql.RowColumnReference: - if !expressionReferencesOnlyLocalIdentifiers(typedNode.Identifier, localScope) { - isLocal = false - handler.SetDone() - } else { - handler.Consume() - } - } - }, - )) - - return isLocal + return optimize.ExpressionReferencesOnlyLocalIdentifiers(expression, localScope) } func subqueryReferencesOnlyLocalIdentifiers(subquery pgsql.Subquery, localScope *pgsql.IdentifierSet) bool { - return queryReferencesOnlyLocalIdentifiers(subquery.Query, localScope) + return optimize.SubqueryReferencesOnlyLocalIdentifiers(subquery, localScope) } func queryReferencesOnlyLocalIdentifiers(query pgsql.Query, localScope *pgsql.IdentifierSet) bool { - if query.CommonTableExpressions != nil { - return false - } - - selectBody, isSelect := query.Body.(pgsql.Select) - if !isSelect { - return false - } - - if !selectReferencesOnlyLocalIdentifiers(selectBody, localScope) { - return false - } - - for _, orderBy := range query.OrderBy { - if orderBy != nil && !expressionReferencesOnlyLocalIdentifiers(orderBy.Expression, localScope) { - return false - } - } - - return (query.Offset == nil || expressionReferencesOnlyLocalIdentifiers(query.Offset, localScope)) && - (query.Limit == nil || expressionReferencesOnlyLocalIdentifiers(query.Limit, localScope)) + return optimize.QueryReferencesOnlyLocalIdentifiers(query, localScope) } func addFromClauseBindings(localScope *pgsql.IdentifierSet, fromClauses []pgsql.FromClause) { - for _, fromClause := range fromClauses { - addFromExpressionBinding(localScope, fromClause.Source) - - for _, join := range fromClause.Joins { - addFromExpressionBinding(localScope, join.Table) - } - } + optimize.AddFromClauseBindings(localScope, fromClauses) } func addFromExpressionBinding(localScope *pgsql.IdentifierSet, expression pgsql.Expression) { - switch typedExpression := expression.(type) { - case pgsql.TableReference: - if typedExpression.Binding.Set { - localScope.Add(typedExpression.Binding.Value) - } - - case pgsql.LateralSubquery: - if typedExpression.Binding.Set { - localScope.Add(typedExpression.Binding.Value) - } - } + optimize.AddFromExpressionBinding(localScope, expression) } func selectReferencesOnlyLocalIdentifiers(selectBody pgsql.Select, localScope *pgsql.IdentifierSet) bool { - scopedIdentifiers := localScope.Copy() - addFromClauseBindings(scopedIdentifiers, selectBody.From) - - for _, projection := range selectBody.Projection { - if !expressionReferencesOnlyLocalIdentifiers(projection, scopedIdentifiers) { - return false - } - } - - for _, fromClause := range selectBody.From { - if !fromExpressionReferencesOnlyLocalIdentifiers(fromClause.Source) { - return false - } - - for _, join := range fromClause.Joins { - if !fromExpressionReferencesOnlyLocalIdentifiers(join.Table) { - return false - } - - if join.JoinOperator.Constraint != nil && - !expressionReferencesOnlyLocalIdentifiers(join.JoinOperator.Constraint, scopedIdentifiers) { - return false - } - } - } - - for _, groupByExpression := range selectBody.GroupBy { - if !expressionReferencesOnlyLocalIdentifiers(groupByExpression, scopedIdentifiers) { - return false - } - } - - return (selectBody.Where == nil || expressionReferencesOnlyLocalIdentifiers(selectBody.Where, scopedIdentifiers)) && - (selectBody.Having == nil || expressionReferencesOnlyLocalIdentifiers(selectBody.Having, scopedIdentifiers)) + return optimize.SelectReferencesOnlyLocalIdentifiers(selectBody, localScope) } func fromExpressionReferencesOnlyLocalIdentifiers(expression pgsql.Expression) bool { - switch expression.(type) { - case pgsql.TableReference: - return true - - default: - return false - } + return optimize.FromExpressionReferencesOnlyLocalIdentifiers(expression) } func isLocalToScope(expression pgsql.Expression, localScope *pgsql.IdentifierSet) bool { - if expression == nil { - return true - } - - return expressionReferencesOnlyLocalIdentifiers(expression, localScope) + return optimize.IsLocalToScope(expression, localScope) } -// partitionConstraintByLocality splits a conjunction (A AND B AND ...) into -// two expressions: one whose every binding reference is contained in -// localScope (safe for JOIN ON), and one that references outside identifiers -// (must stay in WHERE). -// -// Only top-level AND operands are split. If an expression is not a -// BinaryExpression with OperatorAnd, the whole expression is tested as a unit. func partitionConstraintByLocality(expression pgsql.Expression, localScope *pgsql.IdentifierSet) (pgsql.Expression, pgsql.Expression) { - var ( - joinConstraints pgsql.Expression - whereConstraints pgsql.Expression - terms = flattenConjunction(expression) - ) - - for _, term := range terms { - if isLocalToScope(term, localScope) { - joinConstraints = pgsql.OptionalAnd(joinConstraints, term) - } else { - whereConstraints = pgsql.OptionalAnd(whereConstraints, term) - } - } - - return joinConstraints, whereConstraints + return optimize.PartitionConstraintByLocality(expression, localScope) } type ProjectionPruningApplication struct { diff --git a/cypher/models/pgsql/translate/selectivity.go b/cypher/models/pgsql/translate/selectivity.go index c75b0a9f..715e58ce 100644 --- a/cypher/models/pgsql/translate/selectivity.go +++ b/cypher/models/pgsql/translate/selectivity.go @@ -2,226 +2,9 @@ package translate import ( "github.com/specterops/dawgs/cypher/models/pgsql" - "github.com/specterops/dawgs/cypher/models/walk" + "github.com/specterops/dawgs/cypher/models/pgsql/optimize" ) -const ( - // Below are a select set of constants to represent different weights to represent, roughly, the selectivity - // of a given PGSQL expression. These weights are meant to be inexact and are only useful in comparison to other - // summed weights. - // - // The goal of these weights are to enable reordering of queries such that the more selective side of a traversal - // step is expanded first. Eventually, these weights may also enable reordering of multipart queries. - - // Entity ID references are a safe selectivity bet. A direct reference will typically take the form of: - // `n0.id = 1` or some other direct comparison against the entity's ID. All entity IDs are covered by a unique - // b-tree index, making them both highly selective and lucrative to weight higher. - selectivityWeightEntityIDReference = 125 - - // Unique node properties are both covered by a compatible index and unique, making them highly selective - selectivityWeightUniqueNodeProperty = 100 - - // Bound identifiers are heavily weighted for preserving join order integrity - selectivityWeightBoundIdentifier = 700 - - // Operators that narrow the search space are given a higher selectivity - selectivityWeightNarrowSearch = 30 - - // Operators that perform string searches are given a higher selectivity - selectivityWeightStringSearch = 20 - - // Operators that perform range comparisons are reasonably selective - selectivityWeightRangeComparison = 10 - - // Conjunctions can narrow search space, especially when compounded, but may be order dependent and unreliable as - // a good selectivity heuristic - selectivityWeightConjunction = 5 - - // Exclusions can narrow the search space but often only slightly - selectivityWeightNotEquals = 1 - - // Disjunctions expand search space by adding a secondary, conditional operation - selectivityWeightDisjunction = -100 - - // selectivityFlipThreshold is the minimum score advantage the right-hand node must hold - // over the left-hand node before OptimizePatternConstraintBalance commits to a traversal - // direction flip. It is set to selectivityWeightNarrowSearch so that structural AST noise - // — in particular the per-AND-node conjunction bonus — cannot trigger a flip on its own. - // A single meaningful narrowing predicate (=, IN, kind filter) on the right side is - // sufficient to clear this bar; a bare AND connector (weight 5) or a range comparison on - // an unindexed property (weight 10) is not. - selectivityFlipThreshold = selectivityWeightNarrowSearch - - // selectivityBidirectionalAnchorThreshold is the minimum score each endpoint must carry - // before shortest-path translation starts a bidirectional search from both sides. This - // keeps broad label-only endpoints out of bidirectional BFS; a single kind predicate - // scores below this threshold, while a materially narrower property predicate can clear it. - selectivityBidirectionalAnchorThreshold = selectivityWeightNarrowSearch * 2 -) - -// knownNodePropertySelectivity is a hack to enable the selectivity measurement to take advantage of known property indexes -// or uniqueness constraints. -// -// Eventually, this should be replaced by a tool that can introspect a graph schema and derive this map. -var knownNodePropertySelectivity = map[string]int{ - "objectid": selectivityWeightUniqueNodeProperty, // Object ID contains a unique constraint giving this a high degree of selectivity - "name": selectivityWeightUniqueNodeProperty, // Name contains a unique constraint giving this a high degree of selectivity - "system_tags": selectivityWeightNarrowSearch, // Searches that use the system_tags property are likely to have a higher degree of selectivity. -} - -type measureSelectivityVisitor struct { - walk.Visitor[pgsql.SyntaxNode] - - scope *Scope - selectivityStack []int -} - -func newMeasureSelectivityVisitor(scope *Scope) *measureSelectivityVisitor { - return &measureSelectivityVisitor{ - Visitor: walk.NewVisitor[pgsql.SyntaxNode](), - scope: scope, - selectivityStack: []int{0}, - } -} - -func (s *measureSelectivityVisitor) Selectivity() int { - return s.selectivityStack[0] -} - -func (s *measureSelectivityVisitor) popSelectivity() int { - value := s.Selectivity() - s.selectivityStack = s.selectivityStack[:len(s.selectivityStack)-1] - - return value -} - -func (s *measureSelectivityVisitor) pushSelectivity(value int) { - s.selectivityStack = append(s.selectivityStack, value) -} - -func (s *measureSelectivityVisitor) addSelectivity(value int) { - if len(s.selectivityStack) == 0 { - s.pushSelectivity(value) - } else { - s.selectivityStack[len(s.selectivityStack)-1] += value - } -} - -func isColumnIDRef(expression pgsql.Expression) bool { - switch typedExpression := expression.(type) { - case pgsql.CompoundIdentifier: - if typedExpression.HasField() { - switch typedExpression.Field() { - case pgsql.ColumnID: - return true - } - } - } - - return false -} - -func (s *measureSelectivityVisitor) Enter(node pgsql.SyntaxNode) { - switch typedNode := node.(type) { - case *pgsql.UnaryExpression: - switch typedNode.Operator { - case pgsql.OperatorNot: - s.pushSelectivity(0) - } - - case *pgsql.BinaryExpression: - var ( - lOperandIsID = isColumnIDRef(typedNode.LOperand) - rOperandIsID = isColumnIDRef(typedNode.ROperand) - ) - - if lOperandIsID && !rOperandIsID { - // Point lookup: n0.id = — highly selective - s.addSelectivity(selectivityWeightEntityIDReference) - } else if rOperandIsID && !lOperandIsID { - // Canonically unusual, but handle it the same - s.addSelectivity(selectivityWeightEntityIDReference) - } - - // If both sides are ID refs, this is a join condition — do not score as a point lookup - - switch typedNode.Operator { - case pgsql.OperatorOr: - s.addSelectivity(selectivityWeightDisjunction) - - case pgsql.OperatorNotEquals: - s.addSelectivity(selectivityWeightNotEquals) - - case pgsql.OperatorAnd: - s.addSelectivity(selectivityWeightConjunction) - - case pgsql.OperatorLessThan, pgsql.OperatorGreaterThan, pgsql.OperatorLessThanOrEqualTo, pgsql.OperatorGreaterThanOrEqualTo: - s.addSelectivity(selectivityWeightRangeComparison) - - case pgsql.OperatorLike, pgsql.OperatorILike, pgsql.OperatorRegexMatch, pgsql.OperatorSimilarTo: - s.addSelectivity(selectivityWeightStringSearch) - - case pgsql.OperatorIn, pgsql.OperatorEquals, pgsql.OperatorIs: - s.addSelectivity(selectivityWeightNarrowSearch) - - case pgsql.OperatorPGArrayOverlap, pgsql.OperatorArrayOverlap: - s.addSelectivity(selectivityWeightNarrowSearch) - - case pgsql.OperatorPGArrayLHSContainsRHS: - // @> is strictly more selective than &&: all kind_ids must be present. - s.addSelectivity(selectivityWeightNarrowSearch + selectivityWeightConjunction) - - case pgsql.OperatorJSONField, pgsql.OperatorJSONTextField, pgsql.OperatorPropertyLookup: - if propertyLookup, err := binaryExpressionToPropertyLookup(typedNode); err != nil { - s.SetError(err) - } else { - // Lookup the reference - leftIdentifier := propertyLookup.Reference.Root() - - if binding, bound := s.scope.Lookup(leftIdentifier); !bound { - s.SetErrorf("unable to lookup identifier %s", leftIdentifier) - } else { - switch binding.DataType { - case pgsql.ExpansionRootNode, pgsql.ExpansionTerminalNode, pgsql.NodeComposite: - // This is a node property, search through the available node property selectivity weights - if selectivity, hasKnownSelectivity := knownNodePropertySelectivity[propertyLookup.Field]; hasKnownSelectivity { - s.addSelectivity(selectivity) - } - } - } - } - } - } -} - -func (s *measureSelectivityVisitor) Exit(node pgsql.SyntaxNode) { - switch typedNode := node.(type) { - case *pgsql.UnaryExpression: - switch typedNode.Operator { - case pgsql.OperatorNot: - selectivity := s.popSelectivity() - s.addSelectivity(-selectivity) - } - } -} - -// MeasureSelectivity attempts to measure how selective (i.e. how narrow) the query expression passed in is. This is -// a simple heuristic that is best-effort for attempting to find which side of a traversal step ()-[]->() is more -// selective. -// -// The boolean parameter owningIdentifierBound is intended to represent if the identifier the expression constraints -// is part of a materialized set of nodes where the entity IDs of each are known at time of query. In this case the -// bound component is considered to be highly-selective. -// -// Many numbers are magic values selected based on implementor's perception of selectivity of certain operators. func MeasureSelectivity(scope *Scope, expression pgsql.Expression) (int, error) { - visitor := newMeasureSelectivityVisitor(scope) - - if expression != nil { - if err := walk.PgSQL(expression, visitor); err != nil { - return 0, err - } - } - - return visitor.Selectivity(), nil + return optimize.MeasureSelectivity(scope, expression) } diff --git a/cypher/models/pgsql/translate/tracking.go b/cypher/models/pgsql/translate/tracking.go index bfaadc4f..8bb328a0 100644 --- a/cypher/models/pgsql/translate/tracking.go +++ b/cypher/models/pgsql/translate/tracking.go @@ -344,6 +344,14 @@ func (s *Scope) LookupString(identifierString string) (*BoundIdentifier, bool) { return s.AliasedLookup(pgsql.Identifier(identifierString)) } +func (s *Scope) LookupDataType(identifier pgsql.Identifier) (pgsql.DataType, bool) { + if binding, bound := s.Lookup(identifier); !bound { + return "", false + } else { + return binding.DataType, true + } +} + func (s *Scope) Define(identifier pgsql.Identifier, dataType pgsql.DataType) *BoundIdentifier { boundIdentifier := &BoundIdentifier{ Identifier: identifier, diff --git a/docs/optimization-pass-memory.md b/docs/optimization-pass-memory.md deleted file mode 100644 index a1237bd3..00000000 --- a/docs/optimization-pass-memory.md +++ /dev/null @@ -1,287 +0,0 @@ -# Cypher Optimization Pass Memory - -## Purpose - -The PostgreSQL translator currently lowers Cypher traversal parts mostly in source order. That is simple and predictable, but it can produce expensive SQL for multi-part path queries where a later pattern contains more selective anchors or where returned path payloads are carried through unrelated expansions. - -This note captures a conservative plan for introducing a PostgreSQL-specific pre-translation optimization phase. The goal is not to require users to reauthor valid Cypher to get acceptable runtime behavior. - -## Motivating Query Shape - -```cypher -MATCH (n:Group) -WHERE n.objectid = 'S-1-5-21-2643190041-1319121918-239771340-513' -MATCH p1 = (n)-[:MemberOf*0..]->()-[:Enroll]->(ca:EnterpriseCA)-[:TrustedForNTAuth]->(:NTAuthStore)-[:NTAuthStoreFor]->(d:Domain) -MATCH p2 = (n)-[:MemberOf*0..]->()-[:GenericAll|Enroll|AllExtendedRights]->(ct:CertTemplate)-[:PublishedTo]->(ca)-[:IssuedSignedBy|EnterpriseCAFor*1..]->(:RootCA)-[:RootCAFor]->(d) -WHERE ct.authenticationenabled = true -AND ct.requiresmanagerapproval = false -AND ct.enrolleesuppliessubject = true -AND (ct.schemaversion = 1 OR ct.authorizedsignatures = 0) -RETURN p1, p2 -``` - -The current PostgreSQL shape can preserve too much intermediate state. In particular, because `p1` is returned, path-related state from `p1` may be carried through the `p2` expansion before `p2` has been filtered. Neo4j's planner is more flexible: it can reorder pattern evaluation, use endpoint-aware expansion, and materialize path values late. - -## Architectural Decisions - -The first optimizer effort is intentionally PostgreSQL-specific. The optimizer should avoid painting the project into a backend-neutral corner, but PostgreSQL is the only Cypher translation target that currently needs this work. - -- Ship optimizer rules directly once they are covered. Do not add a user-facing feature flag surface for optimizer behavior. -- Optimize only read-only `MATCH` and `WHERE` groups inside a single query part for the first milestone. -- Treat `WITH`, `RETURN`, aggregation, `DISTINCT`, `ORDER BY`, `LIMIT`, `UNWIND`, `OPTIONAL MATCH`, writes, and procedure calls as semantic barriers. -- Allow the optimizer to build a new ordered logical plan inside eligible regions. -- Represent path variables as late-materialized recipes throughout the optimized PostgreSQL logical model. -- Use deterministic heuristics for early reordering. Defer schema statistics and cost-based planning. -- Accept more complex SQL when it materially improves runtime conditions. The database is responsible for executing the improved plan shape. -- Defer broad benchmark and real-world query set definition until after the basic framework and first optimizer rules are in place. - -## Safety Constraints - -Keep the first implementation deliberately conservative. - -- Preserve Cypher row semantics, path relationship uniqueness, variable binding rules, and zero-length expansion behavior. -- Keep each optimization rule individually named and testable. -- If a rule cannot prove a rewrite is safe, keep the original logical order for that part of the plan. -- Require translation-shape tests, PostgreSQL integration equivalence, and Neo4j equivalence coverage for optimizer behavior. - -## Sequenced Plan - -### Phase 1: Define Optimizer Boundaries - -Document the Cypher regions eligible for optimization and the barriers that terminate an optimization region. The initial eligible region should be a read-only sequence of `MATCH` and `WHERE` clauses within one query part. - -Add diagnostics that can print the logical pattern parts, bindings, predicates, path variables, and final projection dependencies before translation. - -### Phase 2: Build The Safety Net - -Add translation-shape coverage for the motivating ADCS query. The first tests should capture the current expensive SQL shape so improvements can be measured. - -Add smaller focused cases for: - -- multiple `MATCH` clauses sharing variables -- returned path variables used only at final projection -- variable-length expansion followed by a fixed suffix -- repeated bound variables such as `(ca)` and `(d)` -- zero-length expansion with `*0..` - -Validate optimizer behavior with all three test classes: - -- translation-shape tests -- PostgreSQL integration equivalence tests -- Neo4j integration equivalence tests - -Neo4j tests should assert result shape and semantics, not exact Neo4j plan shape. - -### Phase 3: Introduce A No-Op Optimizer Skeleton - -Insert a PostgreSQL-specific pre-translation logical optimization pass between parsing/semantic modeling and PostgreSQL rendering. - -The initial pass should return the same logical model it receives. This keeps the integration point small and gives tests a stable hook before behavioral rules are added. - -Suggested rule names: - -- `PredicateAttachment` -- `ProjectionPruning` -- `LatePathMaterialization` -- `ExpandIntoDetection` -- `ConservativePatternReordering` -- `VariableExpansionTerminalPushdown` - -### Phase 4: Attach Predicates To Their Bindings - -Move eligible `WHERE` predicates as close as possible to the bindings they reference. - -For the motivating query, the `ct.*` predicates should be owned by the `ct:CertTemplate` binding. This does not need to reorder pattern matching at first; it makes predicate dependencies explicit so later rules can apply filters earlier. - -### Phase 5: Prune Intermediate Projections And Paths - -Compute a narrower carry set for each operation: - -- bindings needed by the next operation -- bindings needed by predicates -- bindings needed as join keys -- bindings needed later only to construct returned values - -The translator should not carry every visible binding through every later expansion just because it appears in the final `RETURN`. - -This should be the first real runtime-focused optimization rule. It directly addresses the reported query shape, creates the liveness information required by later rules, and is lower risk than traversal reordering or suffix pushdown. - -### Phase 6: Materialize Paths Late - -Represent returned paths internally as recipes over node and relationship bindings rather than as fully materialized values throughout every step. - -For the motivating query, the optimizer should be able to continue from a narrow frame after `p1`, such as distinct `(n, ca, d)`, evaluate and filter `p2`, then join back to the full `p1` rows and materialize `p1` and `p2` at the final projection. - -This is the first high-value optimization target because it reduces row width and delays the `p1 x p2` multiplication without changing the user's Cypher. - -### Phase 7: Detect Expand-Into Opportunities - -When both endpoints of a relationship or variable-length segment are known, lower that segment as a constrained connectivity/path problem instead of an open expansion. - -This mirrors Neo4j's `Expand(Into)` and `VarLengthExpand(Into)` behavior and is especially relevant when separate `MATCH` clauses bind endpoints that are reused later. - -### Phase 8: Add Deterministic Pattern Reordering - -After projection pruning and late materialization are stable, allow limited reordering inside a single read-only optimization region. - -Start with obvious anchors: - -- node label plus equality property -- fixed relationship type scans -- already-bound endpoints -- selective labels or properties only when deterministic local information is available - -Do not begin with a general cost-based planner or schema-statistics dependency. Prefer deterministic rewrites with clear safety checks. - -### Phase 9: Push Terminal Constraints Into Variable Expansions - -For variable-length expansions followed by fixed suffixes, add terminal or suffix constraints as semi-joins or correlated existence checks. - -For the motivating query, this means avoiding emission of `MemberOf*0..` endpoints that cannot reach an eligible `CertTemplate` published to the already-bound `ca`, and avoiding `RootCA` endpoints that cannot connect back to the already-bound `d`. - -### Phase 10: Measure Each Rule Locally - -Every optimization rule should include: - -- unit or translation tests for the logical rewrite -- PostgreSQL integration result-equivalence coverage -- Neo4j integration result-equivalence coverage -- SQL shape assertions for representative queries -- before and after `EXPLAIN` comparison on synthetic fanout data - -The synthetic data should include many `p1` paths to the same `(n, ca, d)`, many membership paths from `n`, and only a small number of eligible certificate template and root CA paths. - -Broader benchmark suites and real-world query collections are deferred until after the basic optimizer framework and first rules are implemented. - -## Recommended First Milestone - -Implement phases 1 through 6 first. - -That milestone establishes the PostgreSQL optimizer framework, test bar, predicate ownership, projection and path pruning, and late path materialization. It should improve the reported query shape without taking on endpoint-aware expansion, suffix semi-joins, schema statistics, or a full cost-based planner. - -## Quality Review Follow-Up Plan - -The first optimizer milestone introduced the PostgreSQL optimizer hook, predicate attachment diagnostics, projection pruning, and late path materialization. Before moving on to endpoint-aware expansion or pattern reordering, close the review gaps in this order: - -### Step 1: Preserve The Optional-Match Barrier - -Keep projection pruning and late path materialization scoped to plain `MATCH` translation until optional path semantics have dedicated coverage. `OPTIONAL MATCH` already acts as an optimization-region barrier in the analysis pass; translator-side lowering should respect the same boundary. - -### Step 2: Assert Path Semantics, Not Only SQL Shape - -Expand integration coverage for optimized path returns so tests assert path node order, relationship order, and path length for mixed fixed-hop and variable-length paths. Include `relationships(p)` on paths that are eligible for late materialization. - -### Step 3: Harden Direct Relationship References - -Add focused translation tests proving direct relationship references keep edge composites when used in returned values, predicates, relationship properties, `type(r)`, and endpoint functions such as `startNode(r)`. - -### Step 4: Document Performance Measurement Needs - -Keep the current shape tests as guardrails, but add an explicit measurement task for high-fanout ADCS-style data before expanding the optimizer into endpoint-aware expansion, suffix semi-joins, or deterministic reordering. - -## Quality Review Status Notes - -The review follow-up should leave the first optimizer milestone in a measured state before the next rule is attempted. - -- `OPTIONAL MATCH` must remain a translator pruning barrier until optional path returns and optional path functions have semantic integration coverage. -- Mixed fixed-hop plus variable-length path returns should assert exact node order, relationship order, and path length. These cases exercise the same late-materialization mechanics as the motivating query with a smaller result surface. -- `relationships(p)` should have relationship-list assertions so path component functions are checked directly instead of indirectly through SQL shape. -- Direct relationship bindings referenced by return expressions, predicates, `type(r)`, or endpoint functions must keep edge composites and must not be narrowed to path-edge IDs. -- The ADCS fixture currently has SQL-shape and containment coverage. Stricter path cardinality assertions on PostgreSQL exposed duplicated returned path rows during review, so exact cardinality for that fixture should be investigated as part of the high-fanout measurement work rather than added as a passing oracle prematurely. - -## Current Gap Closure Plan - -The optimizer branch now has enough implementation to expose the next set of risks. Close those risks in this order so each later rule has a stronger correctness and measurement base. - -### Step 1: Establish A Performance Baseline - -Add a synthetic ADCS fanout scenario before broadening suffix or endpoint-aware expansion rules. Capture `p1` alone, `p2` alone, and the combined query with row counts, distinct `(p1, p2)` counts, duplicate counts, and PostgreSQL `EXPLAIN (ANALYZE, BUFFERS)`. - -This should be the first step because the original report is a timeout, and the current branch is still defended mostly by SQL shape and semantic equivalence tests. - -### Step 2: Strengthen Semantic Oracles - -Add exact-result integration coverage on smaller fixtures before relying on the larger ADCS fixture as an oracle. Assertions should include path node IDs, relationship IDs or kinds in order, path lengths, row count, and `relationships(p)` output for optimized paths. - -Keep the existing ADCS containment test, but treat exact ADCS cardinality as part of the fanout investigation until duplicate-row behavior is understood. - -### Step 3: Make Optimizer Rule Ownership Explicit - -Projection pruning, late path materialization, fixed-hop lowering, and suffix pushdown currently live in PostgreSQL translator lowering instead of explicit optimizer rules. Either promote these decisions into optimizer metadata consumed by the translator, or record them as named lowering decisions so tests and diagnostics can identify which rule changed the SQL shape. - -This step should happen before adding more hidden translator-side rewrites. - -### Step 4: Wire Predicate Attachment Into Translation - -Predicate attachment currently records ownership but does not change translation. Feed attachment metadata into PostgreSQL lowering so local predicates can move into the earliest safe binding, terminal, or suffix check. - -Add SQL shape tests proving `ct` predicates in the motivating query are applied at the intended terminal or suffix point, plus PostgreSQL and Neo4j equivalence coverage. - -### Step 5: Broaden Phase 9 Coverage Before Broadening Phase 9 Behavior - -Add tests for the suffix shapes the motivating query actually depends on: - -- `*0..` variable expansions followed by suffix checks -- chained fixed suffixes after a variable expansion -- suffixes that end at already-bound nodes such as `ca` and `d` -- inbound suffixes -- directionless suffixes that should remain unoptimized until they are implemented deliberately - -These tests should include both SQL shape assertions and integration equivalence. - -### Step 6: Implement Endpoint-Aware Suffix Semi-Joins - -Extend suffix pushdown from the current immediate one-hop local check to endpoint-aware semi-joins that can reason about fixed suffix chains and already-bound endpoints. For the motivating query, this means pruning `MemberOf*0..` endpoints that cannot reach eligible certificate template paths tied to the bound `ca`, and pruning root paths that cannot connect back to the bound `d`. - -Keep path materialization late: use the suffix checks to constrain candidate endpoints, then materialize returned paths only after the result frame is narrowed. - -### Step 7: Re-Measure And Lock The Regression - -After each new suffix or predicate-placement rule, rerun the synthetic fanout measurements and record the before/after SQL shape and runtime characteristics. Promote the final motivating query shape into a benchmark or regression scenario once its cardinality and duplicate behavior are fully understood. - -## Measurement Checklist Before Phase 7 - -Before implementing expand-into detection, capture the following for the motivating ADCS query and a synthetic fanout variant: - -- `EXPLAIN (ANALYZE, BUFFERS)` for `p1` alone, `p2` alone, and the combined query. -- Result row count, distinct `(p1, p2)` count, and duplicate-row count. -- Intermediate row counts for expansion CTEs before and after projection pruning. -- Final path reconstruction cost when paths are returned versus when only endpoint keys are returned. -- Comparison with Neo4j result cardinality for the same fixture. - -Projection pruning and late path materialization currently live in PostgreSQL translator lowering. If later phases need richer rule-level ordering or barrier enforcement, promote these decisions into explicit optimizer rule metadata instead of adding more hidden translator-side state. - -## Gap Closure Completion Notes - -The gap-closure pass has been completed enough to return to the original phase sequence without broadening into Phase 10. - -- The benchmark harness includes the committed `adcs_fanout` dataset by default and has scenarios for `p1` alone, `p2` alone, and the combined `RETURN p1,p2` form. -- ADCS path scenarios now record warm-up row count, distinct returned path-row count, and duplicate returned path-row count. -- PostgreSQL benchmark runs can opt into `EXPLAIN (ANALYZE, BUFFERS)` capture with `-explain`; JSON output includes the translated SQL and plan text. -- The small ADCS integration fixture now asserts exact returned path shape and row count. The larger fanout fixture remains a measurement fixture rather than an exact cardinality oracle. -- Translation metadata reports optimizer rules, predicate attachments, planned lowerings, and applied lowerings, including `ExpansionSuffixPushdown`. -- Phase 9 suffix coverage includes zero-hop expansions, fixed suffix chains, suffixes ending at already-bound nodes, inbound suffixes, and the ADCS root-to-domain suffix shape. -- Directionless suffix pushdown remains deliberately unimplemented; those suffixes stay as normal translated pattern steps. - -## Phase 10 Status Notes - -Phase 10 starts by making local measurements repeatable for the optimizer rules already implemented. - -- PostgreSQL `-explain` benchmark JSON includes translated SQL, `EXPLAIN (ANALYZE, BUFFERS)` plan text, optimizer rule results, predicate attachments, and translator lowering decisions. -- The ADCS fanout benchmark includes `p1` alone, `p2` alone, combined path return, and combined endpoint-only return. The endpoint-only scenario gives a local comparison point for final path reconstruction cost. -- The benchmark runner rejects zero timed iterations so baseline output cannot silently panic while gathering measurements. -- Representative SQL-shape tests assert that suffix-local predicates are inside the pushed suffix check, not merely present somewhere in the rendered SQL. -- Broad pass/fail performance thresholds remain deferred. Phase 10 measurements are local evidence and regression artifacts first; cost-based acceptance gates should wait for a larger benchmark corpus and stable environment assumptions. - -## Lowering Ownership Refactor Notes - -The translator now consumes optimizer-owned lowering metadata for projection pruning, late path materialization, fixed-hop expand-into detection, expansion suffix pushdown, and predicate placement. PostgreSQL SQL construction remains in the translator, but rule ownership and benchmark-visible diagnostics live in the optimizer lowering plan. - -Translator-local eligibility checks remain as conservative fallbacks for traversal steps that do not have an optimizer decision. Benchmark JSON includes planned lowerings, applied lowerings, and the structured lowering plan so future reviews can distinguish optimizer intent from SQL-shape changes that actually happened. - -### Lowering Metadata Contract - -- `LoweringPlan` is optimizer intent over the Cypher source shape. It may include decisions that the PostgreSQL translator later declines because the lowered SQL shape is no longer eligible. -- `planned_lowerings` is the compact, benchmark-friendly view of `LoweringPlan.Decisions()`. -- `lowerings` is translator-applied behavior only. It must be recorded when SQL generation actually changes shape, not when the optimizer merely planned a decision. -- Optimizer code may describe source-level actions and eligibility. PostgreSQL frame visibility, data-type rewrites, and SQL AST construction remain translator responsibilities. From 3e5581c700aa822fca95b2c091bfda64ac5c37a1 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Thu, 21 May 2026 23:45:03 -0700 Subject: [PATCH 046/116] fix(pgsql): address optimizer review feedback --- cypher/models/pgsql/optimize/locality.go | 21 ++- cypher/models/pgsql/optimize/lowering_plan.go | 2 +- .../models/pgsql/optimize/optimizer_test.go | 80 +++++++++++ cypher/models/pgsql/optimize/selectivity.go | 2 +- .../pgsql/optimize/source_references.go | 4 +- .../test/translation_cases/multipart.sql | 10 +- .../translation_cases/pattern_binding.sql | 8 +- .../translation_cases/pattern_expansion.sql | 2 +- .../pgsql/translate/constraints_test.go | 31 ++++ cypher/models/pgsql/translate/expansion.go | 134 ++++++++++++++---- cypher/models/pgsql/translate/function.go | 9 +- .../models/pgsql/translate/function_test.go | 16 +++ cypher/models/pgsql/translate/model.go | 4 +- .../pgsql/translate/optimizer_safety_test.go | 22 +++ cypher/models/pgsql/translate/pattern.go | 2 + cypher/models/pgsql/translate/predicate.go | 2 +- .../models/pgsql/translate/predicate_test.go | 24 ++++ cypher/models/pgsql/translate/tracking.go | 10 +- .../models/pgsql/translate/tracking_test.go | 12 ++ integration/harness.go | 2 +- 20 files changed, 335 insertions(+), 62 deletions(-) diff --git a/cypher/models/pgsql/optimize/locality.go b/cypher/models/pgsql/optimize/locality.go index 22301ce9..05ceccef 100644 --- a/cypher/models/pgsql/optimize/locality.go +++ b/cypher/models/pgsql/optimize/locality.go @@ -108,21 +108,20 @@ func AddFromExpressionBinding(localScope *pgsql.IdentifierSet, expression pgsql. } } +func addFromClauseSourceBinding(localScope *pgsql.IdentifierSet, fromClause pgsql.FromClause) { + AddFromExpressionBinding(localScope, fromClause.Source) +} + func SelectReferencesOnlyLocalIdentifiers(selectBody pgsql.Select, localScope *pgsql.IdentifierSet) bool { scopedIdentifiers := localScope.Copy() - AddFromClauseBindings(scopedIdentifiers, selectBody.From) - - for _, projection := range selectBody.Projection { - if !ExpressionReferencesOnlyLocalIdentifiers(projection, scopedIdentifiers) { - return false - } - } for _, fromClause := range selectBody.From { if !FromExpressionReferencesOnlyLocalIdentifiers(fromClause.Source) { return false } + addFromClauseSourceBinding(scopedIdentifiers, fromClause) + for _, join := range fromClause.Joins { if !FromExpressionReferencesOnlyLocalIdentifiers(join.Table) { return false @@ -132,6 +131,14 @@ func SelectReferencesOnlyLocalIdentifiers(selectBody pgsql.Select, localScope *p !ExpressionReferencesOnlyLocalIdentifiers(join.JoinOperator.Constraint, scopedIdentifiers) { return false } + + AddFromExpressionBinding(scopedIdentifiers, join.Table) + } + } + + for _, projection := range selectBody.Projection { + if !ExpressionReferencesOnlyLocalIdentifiers(projection, scopedIdentifiers) { + return false } } diff --git a/cypher/models/pgsql/optimize/lowering_plan.go b/cypher/models/pgsql/optimize/lowering_plan.go index 015c154f..b40dee40 100644 --- a/cypher/models/pgsql/optimize/lowering_plan.go +++ b/cypher/models/pgsql/optimize/lowering_plan.go @@ -609,7 +609,7 @@ func appendLimitPushdownDecisions(plan *LoweringPlan, queryPartIndex int, queryP } for clauseIndex, readingClause := range readingClauses { - if readingClause == nil || readingClause.Match == nil { + if readingClause == nil || readingClause.Match == nil || readingClause.Match.Optional { continue } diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index c3637434..227e0244 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/specterops/dawgs/cypher/frontend" + "github.com/specterops/dawgs/cypher/models" "github.com/specterops/dawgs/cypher/models/cypher" "github.com/specterops/dawgs/cypher/models/pgsql" "github.com/stretchr/testify/require" @@ -618,6 +619,85 @@ func TestLoweringPlanSkipsAllShortestPathLimitPushdown(t *testing.T) { require.Empty(t, plan.LoweringPlan.LimitPushdown) } +func TestLoweringPlanSkipsOptionalMatchLimitPushdown(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH p = (n)-[:MemberOf]->(m:Group) + RETURN p + LIMIT 1 + `) + require.NoError(t, err) + require.Len(t, regularQuery.SingleQuery.SinglePartQuery.ReadingClauses, 1) + regularQuery.SingleQuery.SinglePartQuery.ReadingClauses[0].Match.Optional = true + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Empty(t, plan.LoweringPlan.LimitPushdown) +} + +func TestSelectReferencesOnlyLocalIdentifiersValidatesJoinConstraintsIncrementally(t *testing.T) { + t.Parallel() + + tableRef := func(alias pgsql.Identifier) pgsql.TableReference { + return pgsql.TableReference{ + Name: pgsql.CompoundIdentifier{pgsql.TableNode}, + Binding: models.OptionalValue(alias), + } + } + + selectBody := pgsql.Select{ + Projection: []pgsql.SelectItem{ + pgsql.CompoundIdentifier{pgsql.Identifier("a"), pgsql.ColumnID}, + }, + From: []pgsql.FromClause{{ + Source: tableRef(pgsql.Identifier("a")), + Joins: []pgsql.Join{{ + Table: tableRef(pgsql.Identifier("b")), + JoinOperator: pgsql.JoinOperator{ + Constraint: pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{pgsql.Identifier("b"), pgsql.ColumnID}, + pgsql.OperatorEquals, + pgsql.CompoundIdentifier{pgsql.Identifier("c"), pgsql.ColumnID}, + ), + }, + }, { + Table: tableRef(pgsql.Identifier("c")), + }}, + }}, + } + + require.False(t, SelectReferencesOnlyLocalIdentifiers(selectBody, pgsql.NewIdentifierSet())) +} + +func TestMeasureSelectivityPopReturnsTopFrame(t *testing.T) { + t.Parallel() + + visitor := newMeasureSelectivityVisitor(NewSelectivityModel(nil)) + visitor.addSelectivity(7) + visitor.pushSelectivity(11) + visitor.addSelectivity(13) + + require.Equal(t, 24, visitor.popSelectivity()) + require.Equal(t, 7, visitor.Selectivity()) +} + +func TestCollectReferencedSourceIdentifiersIgnoresMatchDeclarations(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH (n)-[r:MemberOf]->(m) + RETURN m + `) + require.NoError(t, err) + + references, err := collectReferencedSourceIdentifiers(regularQuery) + require.NoError(t, err) + require.NotContains(t, references, "n") + require.NotContains(t, references, "r") + require.Contains(t, references, "m") +} + func TestLoweringPlanSkipsDirectionlessExpansionSuffixPushdown(t *testing.T) { t.Parallel() diff --git a/cypher/models/pgsql/optimize/selectivity.go b/cypher/models/pgsql/optimize/selectivity.go index c7617bea..1d33aba2 100644 --- a/cypher/models/pgsql/optimize/selectivity.go +++ b/cypher/models/pgsql/optimize/selectivity.go @@ -110,7 +110,7 @@ func (s *measureSelectivityVisitor) Selectivity() int { } func (s *measureSelectivityVisitor) popSelectivity() int { - value := s.Selectivity() + value := s.selectivityStack[len(s.selectivityStack)-1] s.selectivityStack = s.selectivityStack[:len(s.selectivityStack)-1] return value diff --git a/cypher/models/pgsql/optimize/source_references.go b/cypher/models/pgsql/optimize/source_references.go index bb12ab86..01dde537 100644 --- a/cypher/models/pgsql/optimize/source_references.go +++ b/cypher/models/pgsql/optimize/source_references.go @@ -83,7 +83,9 @@ func (s *sourceReferenceCollector) Enter(node cypher.SyntaxNode) { } case *cypher.Variable: - s.addVariable(typedNode) + if s.matchPatternDeclarationDepth == 0 { + s.addVariable(typedNode) + } } } diff --git a/cypher/models/pgsql/test/translation_cases/multipart.sql b/cypher/models/pgsql/test/translation_cases/multipart.sql index 739dd00b..6f51f513 100644 --- a/cypher/models/pgsql/test/translation_cases/multipart.sql +++ b/cypher/models/pgsql/test/translation_cases/multipart.sql @@ -24,13 +24,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 node n0 where (((n0.properties -> 'value'))::jsonb = to_jsonb((1)::int8)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s1.n0 as n0 from s1), s2 as (with s3 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where (((n1.properties -> 'name'))::jsonb = to_jsonb(('me')::text)::jsonb)) select s3.n1 as n1 from s3), s4 as (select s2.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s2, node n2 where (n2.id = (s2.n1).id)) select s4.n2 as b from s4; -- case: match (n:NodeKind1)-[:EdgeKind1*1..]->(:NodeKind2)-[:EdgeKind2]->(m:NodeKind1) where (n:NodeKind1 or n:NodeKind2) and n.enabled = true with m, collect(distinct(n)) as p where size(p) >= 10 return m -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.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]) and ((n0.properties -> 'enabled'))::jsonb = to_jsonb((true)::bool)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(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[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::int2[])), e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::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.start_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select 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), s3 as (select s1.ep0 as ep0, 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.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[]) and e1.id != all (s1.ep0)) select s3.n2 as n2, array_remove(coalesce(array_agg(distinct (s3.n0))::nodecomposite[], array []::nodecomposite[])::nodecomposite[], null)::nodecomposite[] as i0 from s3 group by n2) select s0.n2 as m from s0 where (cardinality(s0.i0)::int >= 10); +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.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]) and ((n0.properties -> 'enabled'))::jsonb = to_jsonb((true)::bool)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(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 s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select 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 and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::int2[]))), s3 as (select s1.ep0 as ep0, 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.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[]) and e1.id != all (s1.ep0)) select s3.n2 as n2, array_remove(coalesce(array_agg(distinct (s3.n0))::nodecomposite[], array []::nodecomposite[])::nodecomposite[], null)::nodecomposite[] as i0 from s3 group by n2) select s0.n2 as m from s0 where (cardinality(s0.i0)::int >= 10); -- case: match (n:NodeKind1)-[:EdgeKind1*1..]->(:NodeKind2)-[:EdgeKind2]->(m:NodeKind1) where (n:NodeKind1 or n:NodeKind2) and n.enabled = true with m, count(distinct(n)) as p where p >= 10 return m -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.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]) and ((n0.properties -> 'enabled'))::jsonb = to_jsonb((true)::bool)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(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[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::int2[])), e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::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.start_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select 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), s3 as (select s1.ep0 as ep0, 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.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[]) and e1.id != all (s1.ep0)) select s3.n2 as n2, count(distinct (s3.n0))::int8 as i0 from s3 group by n2) select s0.n2 as m from s0 where (s0.i0 >= 10); +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.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]) and ((n0.properties -> 'enabled'))::jsonb = to_jsonb((true)::bool)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(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 s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select 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 and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::int2[]))), s3 as (select s1.ep0 as ep0, 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.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[]) and e1.id != all (s1.ep0)) select s3.n2 as n2, count(distinct (s3.n0))::int8 as i0 from s3 group by n2) select s0.n2 as m from s0 where (s0.i0 >= 10); -- case: match (n:NodeKind1)-[:EdgeKind1*1..]->(:NodeKind2)-[:EdgeKind2]->(m:NodeKind1) where (n:NodeKind1 or n:NodeKind2) and n.enabled = true with m, count(distinct(n)) as p where p >= 10 return m -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.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]) and ((n0.properties -> 'enabled'))::jsonb = to_jsonb((true)::bool)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(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[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::int2[])), e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::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.start_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select 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), s3 as (select s1.ep0 as ep0, 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.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[]) and e1.id != all (s1.ep0)) select s3.n2 as n2, count(distinct (s3.n0))::int8 as i0 from s3 group by n2) select s0.n2 as m from s0 where (s0.i0 >= 10); +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.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]) and ((n0.properties -> 'enabled'))::jsonb = to_jsonb((true)::bool)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(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 s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select 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 and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::int2[]))), s3 as (select s1.ep0 as ep0, 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.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[]) and e1.id != all (s1.ep0)) select s3.n2 as n2, count(distinct (s3.n0))::int8 as i0 from s3 group by n2) select s0.n2 as m from s0 where (s0.i0 >= 10); -- case: with 365 as max_days match (n:NodeKind1) where n.pwdlastset < (datetime().epochseconds - (max_days * 86400)) and not n.pwdlastset IN [-1.0, 0.0] return n limit 100 with s0 as (select 365 as i0), s1 as (select s0.i0 as i0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from s0, node n0 where (not ((n0.properties ->> 'pwdlastset'))::float8 = any (array [- 1, 0]::float8[]) and ((n0.properties ->> 'pwdlastset'))::numeric < (extract(epoch from now()::timestamp with time zone)::numeric - (s0.i0 * 86400))) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s1.n0 as n from s1 limit 100; @@ -81,10 +81,10 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from with s0 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.id = e0.start_id join node n1 on n1.id = e0.end_id), s1 as (select s0.e0 as e0, e1.id as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n0).id = e1.end_id join node n2 on n2.id = e1.start_id) select case when (s1.n0).id is null or s1.e0 is null or (s1.n1).id is null then null else ordered_edges_to_path(s1.n0, (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 [s1.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite end as p, case when (s1.n2).id is null or s1.e1 is null or (s1.n0).id is null then null else ordered_edges_to_path(s1.n2, (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 [s1.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n2, s1.n0]::nodecomposite[])::pathcomposite end as q from s1; -- case: match (m:NodeKind1)-[*1..]->(g:NodeKind2)-[]->(c3:NodeKind1) where not g.name in ["foo"] with collect(g.name) as bar match p=(m:NodeKind1)-[*1..]->(g:NodeKind2) where g.name in bar 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.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id), e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id union all select s2.root_id, e0.end_id, s2.depth + 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id), 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.start_id = s2.next_id and e0.id != all (s2.path) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select 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), s3 as (select s1.ep0 as ep0, 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 [1]::int2[] and n2.id = e1.end_id where e1.id != all (s1.ep0)) select array_remove(coalesce(array_agg(((s3.n1).properties ->> 'name'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s3), s4 as (with recursive s5_seed(root_id) as not materialized (select n4.id as root_id from s0, node n4 where n4.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n4.properties ->> 'name') = any (s0.i0))), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.end_id, e2.start_id, 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.end_id = e2.start_id, array [e2.id] from s5_seed join edge e2 on e2.end_id = s5_seed.root_id join node n3 on n3.id = e2.start_id union select s5.root_id, e2.start_id, s5.depth + 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e2.id || s5.path from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.end_id = s5.next_id and e2.id != all (s5.path) offset 0) e2 on true join node n3 on n3.id = e2.start_id where s5.depth < 15 and not s5.is_cycle) select s5.path as ep1, 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, s5 join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.root_id offset 0) n4 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.next_id offset 0) n3 on true where s5.satisfied) select case when (s4.n3).id is null or s4.ep1 is null or (s4.n4).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(s4.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n3, s4.n4]::nodecomposite[])::pathcomposite end as p from s4; +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.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id union all select s2.root_id, e0.end_id, s2.depth + 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_id = s2.next_id and e0.id != all (s2.path) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select 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 and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id)), s3 as (select s1.ep0 as ep0, 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 [1]::int2[] and n2.id = e1.end_id where e1.id != all (s1.ep0)) select array_remove(coalesce(array_agg(((s3.n1).properties ->> 'name'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s3), s4 as (with recursive s5_seed(root_id) as not materialized (select n4.id as root_id from s0, node n4 where n4.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n4.properties ->> 'name') = any (s0.i0))), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.end_id, e2.start_id, 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.end_id = e2.start_id, array [e2.id] from s5_seed join edge e2 on e2.end_id = s5_seed.root_id join node n3 on n3.id = e2.start_id union select s5.root_id, e2.start_id, s5.depth + 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e2.id || s5.path from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.end_id = s5.next_id and e2.id != all (s5.path) offset 0) e2 on true join node n3 on n3.id = e2.start_id where s5.depth < 15 and not s5.is_cycle) select s5.path as ep1, 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, s5 join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.root_id offset 0) n4 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.next_id offset 0) n3 on true where s5.satisfied) select case when (s4.n3).id is null or s4.ep1 is null or (s4.n4).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(s4.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n3, s4.n4]::nodecomposite[])::pathcomposite end as p from s4; -- case: match (m:NodeKind1)-[:EdgeKind1*1..]->(g:NodeKind2)-[:EdgeKind2]->(c3:NodeKind1) where m.samaccountname =~ '^[A-Z]{1,3}[0-9]{1,3}$' and not m.samaccountname contains "DEX" and not g.name IN ["D"] and not m.samaccountname =~ "^.*$" with collect(g.name) as admingroups match p=(m:NodeKind1)-[:EdgeKind1*1..]->(g:NodeKind2) where m.samaccountname =~ '^[A-Z]{1,3}[0-9]{1,3}$' and g.name in admingroups and not m.samaccountname =~ "^.*$" 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 ->> 'samaccountname') ~ '^[A-Z]{1,3}[0-9]{1,3}$' and not coalesce((n0.properties ->> 'samaccountname'), '')::text like '%DEX%' and not (n0.properties ->> 'samaccountname') ~ '^.*$') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (not (n1.properties ->> 'name') = any (array ['D']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::int2[])), e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, (not (n1.properties ->> 'name') = any (array ['D']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::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.start_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select 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), s3 as (select s1.ep0 as ep0, 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 [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[]) and e1.id != all (s1.ep0)) select array_remove(coalesce(array_agg(((s3.n1).properties ->> 'name'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s3), s4 as (with recursive s5_seed(root_id) as not materialized (select n4.id as root_id from s0, node n4 where n4.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n4.properties ->> 'name') = any (s0.i0))), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.end_id, e2.start_id, 1, ((n3.properties ->> 'samaccountname') ~ '^[A-Z]{1,3}[0-9]{1,3}$' and not (n3.properties ->> 'samaccountname') ~ '^.*$') and n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.end_id = e2.start_id, array [e2.id] from s5_seed join edge e2 on e2.end_id = s5_seed.root_id join node n3 on n3.id = e2.start_id where e2.kind_id = any (array [3]::int2[]) union select s5.root_id, e2.start_id, s5.depth + 1, ((n3.properties ->> 'samaccountname') ~ '^[A-Z]{1,3}[0-9]{1,3}$' and not (n3.properties ->> 'samaccountname') ~ '^.*$') and n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e2.id || s5.path from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.end_id = s5.next_id and e2.id != all (s5.path) and e2.kind_id = any (array [3]::int2[]) offset 0) e2 on true join node n3 on n3.id = e2.start_id where s5.depth < 15 and not s5.is_cycle) select s5.path as ep1, 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, s5 join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.root_id offset 0) n4 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.next_id offset 0) n3 on true where s5.satisfied) select case when (s4.n3).id is null or s4.ep1 is null or (s4.n4).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(s4.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n3, s4.n4]::nodecomposite[])::pathcomposite end as p from s4; +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 ->> 'samaccountname') ~ '^[A-Z]{1,3}[0-9]{1,3}$' and not coalesce((n0.properties ->> 'samaccountname'), '')::text like '%DEX%' and not (n0.properties ->> 'samaccountname') ~ '^.*$') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (not (n1.properties ->> 'name') = any (array ['D']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, (not (n1.properties ->> 'name') = any (array ['D']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select 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 and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::int2[]))), s3 as (select s1.ep0 as ep0, 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 [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[]) and e1.id != all (s1.ep0)) select array_remove(coalesce(array_agg(((s3.n1).properties ->> 'name'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s3), s4 as (with recursive s5_seed(root_id) as not materialized (select n4.id as root_id from s0, node n4 where n4.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n4.properties ->> 'name') = any (s0.i0))), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.end_id, e2.start_id, 1, ((n3.properties ->> 'samaccountname') ~ '^[A-Z]{1,3}[0-9]{1,3}$' and not (n3.properties ->> 'samaccountname') ~ '^.*$') and n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.end_id = e2.start_id, array [e2.id] from s5_seed join edge e2 on e2.end_id = s5_seed.root_id join node n3 on n3.id = e2.start_id where e2.kind_id = any (array [3]::int2[]) union select s5.root_id, e2.start_id, s5.depth + 1, ((n3.properties ->> 'samaccountname') ~ '^[A-Z]{1,3}[0-9]{1,3}$' and not (n3.properties ->> 'samaccountname') ~ '^.*$') and n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e2.id || s5.path from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.end_id = s5.next_id and e2.id != all (s5.path) and e2.kind_id = any (array [3]::int2[]) offset 0) e2 on true join node n3 on n3.id = e2.start_id where s5.depth < 15 and not s5.is_cycle) select s5.path as ep1, 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, s5 join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.root_id offset 0) n4 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.next_id offset 0) n3 on true where s5.satisfied) select case when (s4.n3).id is null or s4.ep1 is null or (s4.n4).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(s4.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n3, s4.n4]::nodecomposite[])::pathcomposite end as p from s4; -- case: match (a:NodeKind2)-[:EdgeKind1]->(g:NodeKind1)-[:EdgeKind2]->(s:NodeKind2) with count(a) as uc where uc > 5 match p = (a)-[:EdgeKind1]->(g)-[:EdgeKind2]->(s) return p 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; diff --git a/cypher/models/pgsql/test/translation_cases/pattern_binding.sql b/cypher/models/pgsql/test/translation_cases/pattern_binding.sql index bbcba6d8..ed4cb27c 100644 --- a/cypher/models/pgsql/test/translation_cases/pattern_binding.sql +++ b/cypher/models/pgsql/test/translation_cases/pattern_binding.sql @@ -45,7 +45,7 @@ with s0 as (select e0.id as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposi with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, false, e0.start_id = e0.end_id, array [e0.id] from edge e0 union all select s1.root_id, e0.end_id, s1.depth + 1, false, 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) offset 0) e0 on true where s1.depth < 15 and not s1.is_cycle) select 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 limit 1) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 limit 1; -- case: match p = (s)-[*..]->(i)-[]->() where id(s) = 1 and i.name = 'n3' return p limit 1 -with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (n0.id = 1)), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n3')::text)::jsonb) and exists (select 1 from edge e1 join node n2 on n2.id = e1.end_id where n1.id = e1.start_id), 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 union all select s1.root_id, e0.end_id, s1.depth + 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n3')::text)::jsonb) and exists (select 1 from edge e1 join node n2 on n2.id = e1.end_id where n1.id = e1.start_id), 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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select 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), s2 as (select e1.id as e1, s0.ep0 as ep0, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.id != all (s0.ep0) limit 1) select case when (s2.n0).id is null or s2.ep0 is null or (s2.n1).id is null or s2.e1 is null or (s2.n2).id is null then null else ordered_edges_to_path(s2.n0, (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(s2.ep0) 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 [s2.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1, s2.n2]::nodecomposite[])::pathcomposite end as p from s2 limit 1; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (n0.id = 1)), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n3')::text)::jsonb), 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 union all select s1.root_id, e0.end_id, s1.depth + 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n3')::text)::jsonb), 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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select 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 and exists (select 1 from edge e1 join node n2 on n2.id = e1.end_id where n1.id = e1.start_id)), s2 as (select e1.id as e1, s0.ep0 as ep0, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.id != all (s0.ep0) limit 1) select case when (s2.n0).id is null or s2.ep0 is null or (s2.n1).id is null or s2.e1 is null or (s2.n2).id is null then null else ordered_edges_to_path(s2.n0, (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(s2.ep0) 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 [s2.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1, s2.n2]::nodecomposite[])::pathcomposite end as p from s2 limit 1; -- case: match p = ()-[e:EdgeKind1]->()-[:EdgeKind1*..]->() return e, 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 where e0.kind_id = any (array [3]::int2[])), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.start_id, e1.end_id, 1, false, e1.start_id = e1.end_id, array [e1.id] from s2_seed join edge e1 on e1.start_id = s2_seed.root_id where e1.kind_id = any (array [3]::int2[]) union all select s2.root_id, e1.end_id, s2.depth + 1, false, false, s2.path || e1.id from s2 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.start_id = s2.next_id and e1.id != all (s2.path) and e1.kind_id = any (array [3]::int2[]) offset 0) e1 on true where s2.depth < 15 and not s2.is_cycle) select s0.e0 as e0, s2.path as ep0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s2.next_id offset 0) n2 on true where (s0.n1).id = s2.root_id) select s1.e0 as e, case when (s1.n0).id is null or (s1.e0).id is null or (s1.n1).id is null or s1.ep0 is null or (s1.n2).id is null then null else ordered_edges_to_path(s1.n0, array [s1.e0]::edgecomposite[] || (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(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1, s1.n2]::nodecomposite[])::pathcomposite end as p from s1; @@ -81,13 +81,13 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from with s0 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.id = e0.start_id join node n1 on n1.id = e0.end_id), s1 as (select s0.e0 as e0, e1.id as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n0).id = e1.end_id join node n2 on n2.id = e1.start_id) select case when (s1.n0).id is null or s1.e0 is null or (s1.n1).id is null then null else ordered_edges_to_path(s1.n0, (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 [s1.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite end as p, case when (s1.n2).id is null or s1.e1 is null or (s1.n0).id is null then null else ordered_edges_to_path(s1.n2, (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 [s1.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n2, s1.n0]::nodecomposite[])::pathcomposite end as q from s1; -- case: match (m:NodeKind1)-[*1..]->(g:NodeKind2)-[]->(c3:NodeKind1) where not g.name in ["foo"] with collect(g.name) as bar match p=(m:NodeKind1)-[*1..]->(g:NodeKind2) where g.name in bar 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.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id), e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id union all select s2.root_id, e0.end_id, s2.depth + 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id), 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.start_id = s2.next_id and e0.id != all (s2.path) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select 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), s3 as (select s1.ep0 as ep0, 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 [1]::int2[] and n2.id = e1.end_id where e1.id != all (s1.ep0)) select array_remove(coalesce(array_agg(((s3.n1).properties ->> 'name'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s3), s4 as (with recursive s5_seed(root_id) as not materialized (select n4.id as root_id from s0, node n4 where n4.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n4.properties ->> 'name') = any (s0.i0))), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.end_id, e2.start_id, 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.end_id = e2.start_id, array [e2.id] from s5_seed join edge e2 on e2.end_id = s5_seed.root_id join node n3 on n3.id = e2.start_id union select s5.root_id, e2.start_id, s5.depth + 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e2.id || s5.path from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.end_id = s5.next_id and e2.id != all (s5.path) offset 0) e2 on true join node n3 on n3.id = e2.start_id where s5.depth < 15 and not s5.is_cycle) select s5.path as ep1, 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, s5 join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.root_id offset 0) n4 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.next_id offset 0) n3 on true where s5.satisfied) select case when (s4.n3).id is null or s4.ep1 is null or (s4.n4).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(s4.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n3, s4.n4]::nodecomposite[])::pathcomposite end as p from s4; +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.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id union all select s2.root_id, e0.end_id, s2.depth + 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_id = s2.next_id and e0.id != all (s2.path) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select 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 and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id)), s3 as (select s1.ep0 as ep0, 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 [1]::int2[] and n2.id = e1.end_id where e1.id != all (s1.ep0)) select array_remove(coalesce(array_agg(((s3.n1).properties ->> 'name'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s3), s4 as (with recursive s5_seed(root_id) as not materialized (select n4.id as root_id from s0, node n4 where n4.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n4.properties ->> 'name') = any (s0.i0))), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.end_id, e2.start_id, 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.end_id = e2.start_id, array [e2.id] from s5_seed join edge e2 on e2.end_id = s5_seed.root_id join node n3 on n3.id = e2.start_id union select s5.root_id, e2.start_id, s5.depth + 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e2.id || s5.path from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.end_id = s5.next_id and e2.id != all (s5.path) offset 0) e2 on true join node n3 on n3.id = e2.start_id where s5.depth < 15 and not s5.is_cycle) select s5.path as ep1, 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, s5 join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.root_id offset 0) n4 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.next_id offset 0) n3 on true where s5.satisfied) select case when (s4.n3).id is null or s4.ep1 is null or (s4.n4).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(s4.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n3, s4.n4]::nodecomposite[])::pathcomposite end as p from s4; -- case: MATCH p=(:Computer)-[r:HasSession]->(:User) WHERE r.lastseen >= datetime() - duration('P3D') 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 [5]::int2[] and n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [6]::int2[] and n1.id = e0.end_id where (((e0.properties ->> 'lastseen'))::timestamp with time zone >= now()::timestamp with time zone - interval 'P3D') and e0.kind_id = any (array [7]::int2[]) limit 100) select case when (s0.n0).id is null or (s0.e0).id is null or (s0.n1).id is null then null else (array [s0.n0, s0.n1]::nodecomposite[], array [s0.e0]::edgecomposite[])::pathcomposite end as p from s0 limit 100; -- case: MATCH p=(:GPO)-[r:GPLink|Contains*1..]->(:Base) WHERE HEAD(r).enforced OR NONE(n in TAIL(TAIL(NODES(p))) WHERE (n:OU AND n.blocksinheritance)) 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 [8]::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 [10]::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 [11, 12]::int2[]) union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [10]::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 [11, 12]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (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(s1.path) with ordinality as _path(id, ordinality) join edge _edge on _edge.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 case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 where (((((s0.e0)[1]).properties ->> 'enforced'))::bool or ((select count(*)::int from unnest(coalesce((coalesce((((case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite end).nodes)::nodecomposite[])[2:cardinality(((case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite end).nodes)::nodecomposite[])::int], array []::nodecomposite[])::nodecomposite[])[2:cardinality(coalesce((((case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite end).nodes)::nodecomposite[])[2:cardinality(((case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite end).nodes)::nodecomposite[])::int], array []::nodecomposite[])::nodecomposite[])::int], array []::nodecomposite[])::nodecomposite[]) as i0 where ((i0.kind_ids operator (pg_catalog.@>) array [9]::int2[] and ((i0.properties ->> 'blocksinheritance'))::bool))) = 0 and coalesce((coalesce((((case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite end).nodes)::nodecomposite[])[2:cardinality(((case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite end).nodes)::nodecomposite[])::int], array []::nodecomposite[])::nodecomposite[])[2:cardinality(coalesce((((case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite end).nodes)::nodecomposite[])[2:cardinality(((case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite end).nodes)::nodecomposite[])::int], array []::nodecomposite[])::nodecomposite[])::int], array []::nodecomposite[])::nodecomposite[] is not null)::bool); +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 [8]::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 [10]::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 [11, 12]::int2[]) union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [10]::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 [11, 12]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (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(s1.path) with ordinality as _path(id, ordinality) join edge _edge on _edge.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 case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 where (((((s0.e0)[1]).properties ->> 'enforced'))::bool or ((select count(*)::int from unnest(coalesce((coalesce((((case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite end).nodes)::nodecomposite[])[2:], array []::nodecomposite[])::nodecomposite[])[2:], array []::nodecomposite[])::nodecomposite[]) as i0 where ((i0.kind_ids operator (pg_catalog.@>) array [9]::int2[] and ((i0.properties ->> 'blocksinheritance'))::bool))) = 0 and coalesce((coalesce((((case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite end).nodes)::nodecomposite[])[2:], array []::nodecomposite[])::nodecomposite[])[2:], array []::nodecomposite[])::nodecomposite[] is not null)::bool); -- case: MATCH p=(:GPO)-[r:GPLink|Contains*1..]->(:Base) WHERE NONE(x in TAIL(r) WHERE NOT type(x) = 'Contains') 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 [8]::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 [10]::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 [11, 12]::int2[]) union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [10]::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 [11, 12]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (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(s1.path) with ordinality as _path(id, ordinality) join edge _edge on _edge.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 case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 where (((select count(*)::int from unnest(coalesce((s0.e0)[2:cardinality(s0.e0)::int], array []::edgecomposite[])::edgecomposite[]) as i0 where (not i0.kind_id = 12)) = 0 and coalesce((s0.e0)[2:cardinality(s0.e0)::int], array []::edgecomposite[])::edgecomposite[] is not null)::bool); +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 [8]::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 [10]::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 [11, 12]::int2[]) union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [10]::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 [11, 12]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (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(s1.path) with ordinality as _path(id, ordinality) join edge _edge on _edge.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 case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 where (((select count(*)::int from unnest(coalesce((s0.e0)[2:], array []::edgecomposite[])::edgecomposite[]) as i0 where (not i0.kind_id = 12)) = 0 and coalesce((s0.e0)[2:], array []::edgecomposite[])::edgecomposite[] is not null)::bool); diff --git a/cypher/models/pgsql/test/translation_cases/pattern_expansion.sql b/cypher/models/pgsql/test/translation_cases/pattern_expansion.sql index f84e2d77..f1eb5121 100644 --- a/cypher/models/pgsql/test/translation_cases/pattern_expansion.sql +++ b/cypher/models/pgsql/test/translation_cases/pattern_expansion.sql @@ -45,7 +45,7 @@ with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as with s0 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.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb) 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), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.start_id, e1.end_id, 1, false, e1.start_id = e1.end_id, array [e1.id] from s2_seed join edge e1 on e1.start_id = s2_seed.root_id union all select s2.root_id, e1.end_id, s2.depth + 1, false, false, s2.path || e1.id from s2 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.start_id = s2.next_id and e1.id != all (s2.path) offset 0) e1 on true where s2.depth < 3 and not s2.is_cycle) select s0.e0 as e0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s2.next_id offset 0) n2 on true where s2.depth >= 2 and (s0.n1).id = s2.root_id) select s1.n2 as l from s1; -- case: match (n)-[*..]->(e)-[:EdgeKind1|EdgeKind2]->()-[*..]->(l) where n.name = 'n1' and e.name = 'n2' return l -with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb)), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n2')::text)::jsonb) and exists (select 1 from edge e1 join node n2 on n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [3, 4]::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 union all select s1.root_id, e0.end_id, s1.depth + 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n2')::text)::jsonb) and exists (select 1 from edge e1 join node n2 on n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [3, 4]::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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select 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), s2 as (select e1.id as e1, s0.ep0 as ep0, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.kind_id = any (array [3, 4]::int2[]) and e1.id != all (s0.ep0)), s3 as (with recursive s4_seed(root_id) as not materialized (select distinct (s2.n2).id as root_id from s2), s4(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, false, e2.start_id = e2.end_id, array [e2.id] from s4_seed join edge e2 on e2.start_id = s4_seed.root_id union all select s4.root_id, e2.end_id, s4.depth + 1, false, false, s4.path || e2.id from s4 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.start_id = s4.next_id and e2.id != all (s4.path) offset 0) e2 on true where s4.depth < 15 and not s4.is_cycle) select s2.e1 as e1, s2.ep0 as ep0, s2.n0 as n0, s2.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s2, s4 join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s4.root_id offset 0) n2 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s4.next_id offset 0) n3 on true where (s2.n2).id = s4.root_id) select s3.n3 as l from s3; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb)), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n2')::text)::jsonb), 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 union all select s1.root_id, e0.end_id, s1.depth + 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n2')::text)::jsonb), 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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select 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 and exists (select 1 from edge e1 join node n2 on n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [3, 4]::int2[]))), s2 as (select e1.id as e1, s0.ep0 as ep0, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.kind_id = any (array [3, 4]::int2[]) and e1.id != all (s0.ep0)), s3 as (with recursive s4_seed(root_id) as not materialized (select distinct (s2.n2).id as root_id from s2), s4(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, false, e2.start_id = e2.end_id, array [e2.id] from s4_seed join edge e2 on e2.start_id = s4_seed.root_id union all select s4.root_id, e2.end_id, s4.depth + 1, false, false, s4.path || e2.id from s4 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.start_id = s4.next_id and e2.id != all (s4.path) offset 0) e2 on true where s4.depth < 15 and not s4.is_cycle) select s2.e1 as e1, s2.ep0 as ep0, s2.n0 as n0, s2.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s2, s4 join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s4.root_id offset 0) n2 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s4.next_id offset 0) n3 on true where (s2.n2).id = s4.root_id) select s3.n3 as l from s3; -- case: match p = (:NodeKind1)-[:EdgeKind1*1..]->(n:NodeKind2) where 'admin_tier_0' in split(n.system_tags, ' ') return p limit 1000 with s0 as (with recursive s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where ('admin_tier_0' = any (string_to_array((n1.properties ->> 'system_tags'), ' ')::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, n0.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id join node n0 on n0.id = e0.start_id where e0.kind_id = any (array [3]::int2[]) union all select s1.root_id, e0.start_id, s1.depth + 1, n0.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e0.id || s1.path from s1 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.end_id = s1.next_id and e0.id != all (s1.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n0 on n0.id = e0.start_id where s1.depth < 15 and not s1.is_cycle) select 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 n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s1.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s1.next_id offset 0) n0 on true where s1.satisfied limit 1000) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 limit 1000; diff --git a/cypher/models/pgsql/translate/constraints_test.go b/cypher/models/pgsql/translate/constraints_test.go index 94f41619..c54dc0c6 100644 --- a/cypher/models/pgsql/translate/constraints_test.go +++ b/cypher/models/pgsql/translate/constraints_test.go @@ -323,3 +323,34 @@ func TestCanExecutePairAwareBidirectionalSearch(t *testing.T) { require.False(t, canExecute) }) } + +func TestCanMaterializeEndpointPairFilterRequiresPairAwareConstraints(t *testing.T) { + leftIdentifier := pgsql.Identifier("n0") + rightIdentifier := pgsql.Identifier("n1") + kindOnlyConstraint := func(identifier pgsql.Identifier) pgsql.Expression { + return pgd.Equals( + pgsql.CompoundIdentifier{identifier, pgsql.ColumnKindIDs}, + pgd.IntLiteral(1), + ) + } + propertyConstraint := func(identifier pgsql.Identifier) pgsql.Expression { + return pgd.Equals( + pgd.PropertyLookup(identifier, "name"), + pgd.TextLiteral("target"), + ) + } + + step := &TraversalStep{ + LeftNode: &BoundIdentifier{Identifier: leftIdentifier}, + RightNode: &BoundIdentifier{Identifier: rightIdentifier}, + } + + require.False(t, canMaterializeEndpointPairFilterForStep(step, &Expansion{ + PrimerNodeConstraints: kindOnlyConstraint(leftIdentifier), + TerminalNodeConstraints: propertyConstraint(rightIdentifier), + })) + require.True(t, canMaterializeEndpointPairFilterForStep(step, &Expansion{ + PrimerNodeConstraints: propertyConstraint(leftIdentifier), + TerminalNodeConstraints: propertyConstraint(rightIdentifier), + })) +} diff --git a/cypher/models/pgsql/translate/expansion.go b/cypher/models/pgsql/translate/expansion.go index 85b3589e..c5e78360 100644 --- a/cypher/models/pgsql/translate/expansion.go +++ b/cypher/models/pgsql/translate/expansion.go @@ -59,6 +59,8 @@ type ExpansionBuilder struct { queryParameters map[string]any traversalStep *TraversalStep model *Expansion + unwindClauses []UnwindClause + unwindSources []pgsql.FromClause } func NewExpansionBuilder(queryParameters map[string]any, traversalStep *TraversalStep) (*ExpansionBuilder, error) { @@ -73,6 +75,11 @@ func NewExpansionBuilder(queryParameters map[string]any, traversalStep *Traversa }, nil } +func (s *ExpansionBuilder) SetUnwindClauses(clauses []UnwindClause) { + s.unwindClauses = clauses + s.unwindSources = unwindFromClauses(clauses) +} + func nextFrontInsert(body pgsql.SetExpression) pgsql.Insert { return pgsql.Insert{ Table: pgsql.TableReference{ @@ -230,6 +237,39 @@ func expressionReferencesUnwindBinding(expression pgsql.Expression, unwindClause return false, nil } +func (s *ExpansionBuilder) seedEndpointConstraintSplit(expression pgsql.Expression, nodeIdentifier pgsql.Identifier, previousFrameIdentifier pgsql.Identifier) (pgsql.Expression, pgsql.Expression) { + seedExpression := rewriteBoundEndpointSeedReference(expression, previousFrameIdentifier, nodeIdentifier) + localScope := pgsql.AsIdentifierSet(nodeIdentifier) + + for _, clause := range s.unwindClauses { + if clause.Binding != nil { + localScope.Add(clause.Binding.Identifier) + } + } + + return partitionConstraintByLocality(seedExpression, localScope) +} + +func (s *ExpansionBuilder) appendUnwindSourcesIfReferenced(selectBody *pgsql.Select, expression pgsql.Expression) error { + if referencesUnwind, err := expressionReferencesUnwindBinding(expression, s.unwindClauses); err != nil { + return err + } else if referencesUnwind { + var previousFrame *Frame + if s.traversalStep != nil && s.traversalStep.Frame != nil { + previousFrame = s.traversalStep.Frame.Previous + } + + selectBody.From = prependFrameSourceIfMissing(selectBody.From, previousFrame) + selectBody.From = append(selectBody.From, s.unwindSources...) + } + + return nil +} + +func (s *ExpansionBuilder) appendUnwindSources(selectBody *pgsql.Select) { + selectBody.From = append(selectBody.From, s.unwindSources...) +} + func newExpansionRootIDsParameterSeed(identifier, nodeIdentifier pgsql.Identifier, constraints pgsql.Expression) expansionSeed { return newExpansionNodeFilterSeed(identifier, expansionRootFilter, nodeIdentifier, constraints) } @@ -577,13 +617,6 @@ func rewriteBoundEndpointSeedReference(expression pgsql.Expression, previousFram } } -func seedEndpointConstraintSplit(expression pgsql.Expression, nodeIdentifier pgsql.Identifier, previousFrameIdentifier pgsql.Identifier) (pgsql.Expression, pgsql.Expression) { - // Harness seed fragments only range over the endpoint node alias and an optional ID filter. - // Reframe safe endpoint references first, then leave anything still non-local for the outer projection. - seedExpression := rewriteBoundEndpointSeedReference(expression, previousFrameIdentifier, nodeIdentifier) - return partitionConstraintByLocality(seedExpression, pgsql.AsIdentifierSet(nodeIdentifier)) -} - func seededFrontPrimerQuery(seed expansionSeed, primer pgsql.Select) pgsql.Query { return pgsql.Query{ CommonTableExpressions: &pgsql.With{ @@ -1072,7 +1105,7 @@ func (s *ExpansionBuilder) backwardTerminalSatisfaction(expansionModel *Expansio return satisfiedSelectItem } -func (s *ExpansionBuilder) prepareForwardFrontPrimerQuery(expansionModel *Expansion) (pgsql.Query, pgsql.Expression) { +func (s *ExpansionBuilder) prepareForwardFrontPrimerQuery(expansionModel *Expansion) (pgsql.Query, pgsql.Expression, error) { var ( primerSeedConstraints pgsql.Expression primerProjectionPredicate pgsql.Expression @@ -1087,7 +1120,7 @@ func (s *ExpansionBuilder) prepareForwardFrontPrimerQuery(expansionModel *Expans previousFrameIdentifier = s.traversalStep.Frame.Previous.Binding.Identifier } - primerSeedConstraints, primerProjectionPredicate = seedEndpointConstraintSplit( + primerSeedConstraints, primerProjectionPredicate = s.seedEndpointConstraintSplit( expansionModel.PrimerNodeConstraints, s.traversalStep.LeftNode.Identifier, previousFrameIdentifier, @@ -1109,6 +1142,12 @@ func (s *ExpansionBuilder) prepareForwardFrontPrimerQuery(expansionModel *Expans seed = &nodeSeed } + if seed != nil { + if err := s.appendUnwindSourcesIfReferenced(&seed.query, primerSeedConstraints); err != nil { + return pgsql.Query{}, nil, err + } + } + // The returned projection predicate is the part of the endpoint predicate // that cannot be evaluated in the seed CTE because it still references an // outer frame. @@ -1151,6 +1190,9 @@ func (s *ExpansionBuilder) prepareForwardFrontPrimerQuery(expansionModel *Expans } nextQuery.From = []pgsql.FromClause{nextQueryFrom} + if err := s.appendUnwindSourcesIfReferenced(&nextQuery, expansionModel.EdgeConstraints); err != nil { + return pgsql.Query{}, nil, err + } if !expansionModel.HasExplicitEndpointInequality { nextQuery.Where = pgsql.OptionalAnd( @@ -1159,10 +1201,10 @@ func (s *ExpansionBuilder) prepareForwardFrontPrimerQuery(expansionModel *Expans ) } - return frontPrimerQuery(seed, nextQuery), primerProjectionPredicate + return frontPrimerQuery(seed, nextQuery), primerProjectionPredicate, nil } -func (s *ExpansionBuilder) prepareForwardFrontRecursiveQuery(expansionModel *Expansion) pgsql.Select { +func (s *ExpansionBuilder) prepareForwardFrontRecursiveQuery(expansionModel *Expansion) (pgsql.Select, error) { nextQuery := pgsql.Select{ Where: expansionModel.EdgeConstraints, } @@ -1242,10 +1284,14 @@ func (s *ExpansionBuilder) prepareForwardFrontRecursiveQuery(expansionModel *Exp } nextQuery.From = []pgsql.FromClause{nextQueryFrom} - return nextQuery + if err := s.appendUnwindSourcesIfReferenced(&nextQuery, expansionModel.EdgeConstraints); err != nil { + return pgsql.Select{}, err + } + + return nextQuery, nil } -func (s *ExpansionBuilder) prepareBackwardFrontPrimerQuery(expansionModel *Expansion) (pgsql.Query, pgsql.Expression) { +func (s *ExpansionBuilder) prepareBackwardFrontPrimerQuery(expansionModel *Expansion) (pgsql.Query, pgsql.Expression, error) { var ( terminalSeedConstraints pgsql.Expression terminalProjectionPredicate pgsql.Expression @@ -1260,7 +1306,7 @@ func (s *ExpansionBuilder) prepareBackwardFrontPrimerQuery(expansionModel *Expan previousFrameIdentifier = s.traversalStep.Frame.Previous.Binding.Identifier } - terminalSeedConstraints, terminalProjectionPredicate = seedEndpointConstraintSplit( + terminalSeedConstraints, terminalProjectionPredicate = s.seedEndpointConstraintSplit( expansionModel.TerminalNodeConstraints, s.traversalStep.RightNode.Identifier, previousFrameIdentifier, @@ -1282,6 +1328,12 @@ func (s *ExpansionBuilder) prepareBackwardFrontPrimerQuery(expansionModel *Expan seed = &nodeSeed } + if seed != nil { + if err := s.appendUnwindSourcesIfReferenced(&seed.query, terminalSeedConstraints); err != nil { + return pgsql.Query{}, nil, err + } + } + // The returned projection predicate is applied after the harness materializes // endpoints, where any outer-frame references are back in scope. nextQuery.Projection = []pgsql.SelectItem{ @@ -1321,10 +1373,14 @@ func (s *ExpansionBuilder) prepareBackwardFrontPrimerQuery(expansionModel *Expan } nextQuery.From = []pgsql.FromClause{nextQueryFrom} - return frontPrimerQuery(seed, nextQuery), terminalProjectionPredicate + if err := s.appendUnwindSourcesIfReferenced(&nextQuery, expansionModel.EdgeConstraints); err != nil { + return pgsql.Query{}, nil, err + } + + return frontPrimerQuery(seed, nextQuery), terminalProjectionPredicate, nil } -func (s *ExpansionBuilder) prepareBackwardFrontRecursiveQuery(expansionModel *Expansion) pgsql.Select { +func (s *ExpansionBuilder) prepareBackwardFrontRecursiveQuery(expansionModel *Expansion) (pgsql.Select, error) { nextQuery := pgsql.Select{ Where: expansionModel.EdgeConstraints, } @@ -1389,7 +1445,11 @@ func (s *ExpansionBuilder) prepareBackwardFrontRecursiveQuery(expansionModel *Ex } nextQuery.From = []pgsql.FromClause{nextQueryFrom} - return nextQuery + if err := s.appendUnwindSourcesIfReferenced(&nextQuery, expansionModel.EdgeConstraints); err != nil { + return pgsql.Select{}, err + } + + return nextQuery, nil } func shortestPathSearchCTE(functionName pgsql.Identifier, expansionModel *Expansion, harnessParameters []pgsql.Expression) pgsql.CommonTableExpression { @@ -1651,10 +1711,15 @@ func (s *ExpansionBuilder) buildShortestPathsHarnessCall(harnessFunctionName pgs expansionModel.UseMaterializedTerminalFilter = s.canMaterializeTerminalFilter(expansionModel) - var ( - forwardFrontPrimerQuery, forwardSeedProjectionConstraints = s.prepareForwardFrontPrimerQuery(expansionModel) - forwardFrontRecursiveQuery = s.prepareForwardFrontRecursiveQuery(expansionModel) - ) + forwardFrontPrimerQuery, forwardSeedProjectionConstraints, err := s.prepareForwardFrontPrimerQuery(expansionModel) + if err != nil { + return pgsql.Query{}, err + } + + forwardFrontRecursiveQuery, err := s.prepareForwardFrontRecursiveQuery(expansionModel) + if err != nil { + return pgsql.Query{}, err + } projectionQuery.Projection = expansionModel.Projection @@ -1689,6 +1754,7 @@ func (s *ExpansionBuilder) buildShortestPathsHarnessCall(harnessFunctionName pgs s.applyBoundEndpointProjectionConstraints(&projectionQuery, expansionModel) s.applyShortestPathSeedProjectionConstraints(&projectionQuery, forwardSeedProjectionConstraints) + s.appendUnwindSources(&projectionQuery) s.applyShortestPathSelfEndpointGuard(&projectionQuery, expansionModel) if harnessParameters, err := s.shortestPathsParameters(expansionModel, forwardFrontPrimerQuery, forwardFrontRecursiveQuery); err != nil { @@ -1728,12 +1794,25 @@ func (s *ExpansionBuilder) buildBiDirectionalShortestPathsHarnessCall(harnessFun expansionModel.UseMaterializedEndpointPairFilter = s.canMaterializeEndpointPairFilter(expansionModel) - var ( - forwardFrontPrimerQuery, forwardSeedProjectionConstraints = s.prepareForwardFrontPrimerQuery(expansionModel) - forwardFrontRecursiveQuery = s.prepareForwardFrontRecursiveQuery(expansionModel) - backwardFrontPrimerQuery, backwardSeedProjectionConstraints = s.prepareBackwardFrontPrimerQuery(expansionModel) - backwardFrontRecursiveQuery = s.prepareBackwardFrontRecursiveQuery(expansionModel) - ) + forwardFrontPrimerQuery, forwardSeedProjectionConstraints, err := s.prepareForwardFrontPrimerQuery(expansionModel) + if err != nil { + return pgsql.Query{}, err + } + + forwardFrontRecursiveQuery, err := s.prepareForwardFrontRecursiveQuery(expansionModel) + if err != nil { + return pgsql.Query{}, err + } + + backwardFrontPrimerQuery, backwardSeedProjectionConstraints, err := s.prepareBackwardFrontPrimerQuery(expansionModel) + if err != nil { + return pgsql.Query{}, err + } + + backwardFrontRecursiveQuery, err := s.prepareBackwardFrontRecursiveQuery(expansionModel) + if err != nil { + return pgsql.Query{}, err + } projectionQuery.Projection = expansionModel.Projection @@ -1768,6 +1847,7 @@ func (s *ExpansionBuilder) buildBiDirectionalShortestPathsHarnessCall(harnessFun s.applyBoundEndpointProjectionConstraints(&projectionQuery, expansionModel) s.applyShortestPathSeedProjectionConstraints(&projectionQuery, pgsql.OptionalAnd(forwardSeedProjectionConstraints, backwardSeedProjectionConstraints)) + s.appendUnwindSources(&projectionQuery) s.applyShortestPathSelfEndpointGuard(&projectionQuery, expansionModel) if harnessParameters, err := s.bidirectionalAllShortestPathsParameters(expansionModel, forwardFrontPrimerQuery, forwardFrontRecursiveQuery, backwardFrontPrimerQuery, backwardFrontRecursiveQuery); err != nil { diff --git a/cypher/models/pgsql/translate/function.go b/cypher/models/pgsql/translate/function.go index 1d614f88..12346ed8 100644 --- a/cypher/models/pgsql/translate/function.go +++ b/cypher/models/pgsql/translate/function.go @@ -415,14 +415,7 @@ func (s *Translator) translateTailFunction(functionInvocation *cypher.FunctionIn &pgsql.ArraySlice{ Expression: pgsql.NewParenthetical(argument), Lower: pgsql.NewLiteral(2, pgsql.Int), - Upper: pgsql.FunctionCall{ - Function: pgsql.FunctionCardinality, - Parameters: []pgsql.Expression{ - argument, - }, - CastType: pgsql.Int, - }, - CastType: arrayType, + CastType: arrayType, }, pgsql.ArrayLiteral{ CastType: arrayType, diff --git a/cypher/models/pgsql/translate/function_test.go b/cypher/models/pgsql/translate/function_test.go index fbb517bd..793ea9e1 100644 --- a/cypher/models/pgsql/translate/function_test.go +++ b/cypher/models/pgsql/translate/function_test.go @@ -2,6 +2,7 @@ package translate import ( "context" + "strings" "testing" "github.com/specterops/dawgs/cypher/frontend" @@ -56,6 +57,21 @@ func TestPathComponentFunctionsTranslateNullArguments(t *testing.T) { require.Contains(t, formatted, "(null)::edgecomposite[]") } +func TestTailFunctionDoesNotDuplicatePathComponentExpression(t *testing.T) { + kindMapper := pgutil.NewInMemoryKindMapper() + + query, err := frontend.ParseCypher(frontend.NewContext(), `MATCH p = ()-[*1..]->() RETURN tail(tail(nodes(p)))`) + require.NoError(t, err) + + translation, err := Translate(context.Background(), query, kindMapper, nil, DefaultGraphID) + require.NoError(t, err) + + formatted, err := Translated(translation) + require.NoError(t, err) + require.Equal(t, 1, strings.Count(formatted, "ordered_edges_to_path")) + require.NotContains(t, formatted, "cardinality(((case when") +} + func TestPrepareCollectExpressionMissingBindingErrorNamesArgument(t *testing.T) { t.Parallel() diff --git a/cypher/models/pgsql/translate/model.go b/cypher/models/pgsql/translate/model.go index be95bb51..a4090ff9 100644 --- a/cypher/models/pgsql/translate/model.go +++ b/cypher/models/pgsql/translate/model.go @@ -177,8 +177,8 @@ func canMaterializeEndpointPairFilterForStep(traversalStep *TraversalStep, expan traversalStep.usesBoundEndpointPairs() || expansionModel.PrimerNodeConstraints == nil || expansionModel.TerminalNodeConstraints == nil || - !hasLocalEndpointConstraint(expansionModel.PrimerNodeConstraints, traversalStep.LeftNode.Identifier) || - !hasLocalEndpointConstraint(expansionModel.TerminalNodeConstraints, traversalStep.RightNode.Identifier) { + !hasPairAwareEndpointConstraint(expansionModel.PrimerNodeConstraints, traversalStep.LeftNode.Identifier) || + !hasPairAwareEndpointConstraint(expansionModel.TerminalNodeConstraints, traversalStep.RightNode.Identifier) { return false } diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index d8c9d1ea..50375d2a 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -405,6 +405,28 @@ LIMIT 1 requireOptimizationLowering(t, translation.Optimization, "LimitPushdown") } +func TestOptimizerSafetyShortestPathRootCarriesUnwindSources(t *testing.T) { + t.Parallel() + + translation := optimizerSafetyTranslation(t, ` + UNWIND ['source'] AS sourceName + MATCH p = shortestPath((s:Group)-[:MemberOf*1..]->(e:Group)) + WHERE s.name = sourceName AND e.name = 'target' + RETURN sourceName, p + `) + + formattedQuery, err := Translated(translation) + require.NoError(t, err) + normalizedQuery := strings.Join(strings.Fields(formattedQuery), " ") + primerQuery, hasPrimerQuery := translation.Parameters["pi0"].(string) + + require.True(t, hasPrimerQuery) + require.Contains(t, normalizedQuery, "unidirectional_sp_harness") + require.Contains(t, normalizedQuery, "unnest(array ['source']::text[]) as i0") + require.Contains(t, primerQuery, "unnest(array ['source']::text[]) as i0") + require.Contains(t, primerQuery, "(n0.properties ->> 'name') = i0") +} + func TestOptimizerSafetyTranslationReportsOptimizerMetadata(t *testing.T) { t.Parallel() diff --git a/cypher/models/pgsql/translate/pattern.go b/cypher/models/pgsql/translate/pattern.go index 163dc8d1..a77d03ce 100644 --- a/cypher/models/pgsql/translate/pattern.go +++ b/cypher/models/pgsql/translate/pattern.go @@ -135,6 +135,8 @@ func (s *Translator) buildShortestPathsExpansionPattern(traversalStepContext Tra traversalStep := traversalStepContext.CurrentStep if traversalStepContext.IsRootStep { + expansion.SetUnwindClauses(s.query.CurrentPart().ConsumeUnwindClauses()) + if allPaths { if traversalStep.Expansion.UseBidirectionalSearch { if traversalStepQuery, err := expansion.BuildBiDirectionalAllShortestPathsRoot(); err != nil { diff --git a/cypher/models/pgsql/translate/predicate.go b/cypher/models/pgsql/translate/predicate.go index 54ed79ec..b34e52e0 100644 --- a/cypher/models/pgsql/translate/predicate.go +++ b/cypher/models/pgsql/translate/predicate.go @@ -145,7 +145,7 @@ func (s *Translator) buildPatternPredicates() error { s.recordLowering(optimize.LoweringPredicatePlacement) } - return nil + continue } } diff --git a/cypher/models/pgsql/translate/predicate_test.go b/cypher/models/pgsql/translate/predicate_test.go index c94fb85e..8c2fe76f 100644 --- a/cypher/models/pgsql/translate/predicate_test.go +++ b/cypher/models/pgsql/translate/predicate_test.go @@ -34,6 +34,30 @@ RETURN p`) require.NotContains(t, formatted, "as p from s1 where") } +func TestOptimizedPatternPredicatesContinueAfterFirstPlacement(t *testing.T) { + kindMapper := pgutil.NewInMemoryKindMapper() + kindMapper.Put(graph.StringKind("Domain")) + kindMapper.Put(graph.StringKind("SpoofSIDHistory")) + kindMapper.Put(graph.StringKind("AbuseTGTDelegation")) + + query, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH (n:Domain), (m:Domain) + WHERE (n)-[:SpoofSIDHistory]-(m) + AND (n)-[:AbuseTGTDelegation]-(m) + RETURN n + `) + require.NoError(t, err) + + translation, err := Translate(context.Background(), query, kindMapper, nil, DefaultGraphID) + require.NoError(t, err) + + formatted, err := Translated(translation) + require.NoError(t, err) + + require.Contains(t, formatted, "array [2]::int2[]") + require.Contains(t, formatted, "array [3]::int2[]") +} + func translatePredicateQuery(t *testing.T, cypherQuery string, parameters map[string]any) string { t.Helper() diff --git a/cypher/models/pgsql/translate/tracking.go b/cypher/models/pgsql/translate/tracking.go index 8bb328a0..8d325cfe 100644 --- a/cypher/models/pgsql/translate/tracking.go +++ b/cypher/models/pgsql/translate/tracking.go @@ -345,11 +345,15 @@ func (s *Scope) LookupString(identifierString string) (*BoundIdentifier, bool) { } func (s *Scope) LookupDataType(identifier pgsql.Identifier) (pgsql.DataType, bool) { - if binding, bound := s.Lookup(identifier); !bound { - return "", false - } else { + if binding, bound := s.Lookup(identifier); bound { return binding.DataType, true } + + if binding, bound := s.AliasedLookup(identifier); bound { + return binding.DataType, true + } + + return "", false } func (s *Scope) Define(identifier pgsql.Identifier, dataType pgsql.DataType) *BoundIdentifier { diff --git a/cypher/models/pgsql/translate/tracking_test.go b/cypher/models/pgsql/translate/tracking_test.go index 3ea6c67c..cee793fe 100644 --- a/cypher/models/pgsql/translate/tracking_test.go +++ b/cypher/models/pgsql/translate/tracking_test.go @@ -3,6 +3,7 @@ package translate import ( "testing" + "github.com/specterops/dawgs/cypher/models/pgsql" "github.com/stretchr/testify/require" ) @@ -27,3 +28,14 @@ func TestScope(t *testing.T) { require.Nil(t, scope.UnwindToFrame(parent)) require.Equal(t, parent.id, scope.CurrentFrame().id) } + +func TestScopeLookupDataTypeResolvesAliases(t *testing.T) { + scope := NewScope() + binding := scope.Define(pgsql.Identifier("n0"), pgsql.NodeComposite) + scope.Alias(pgsql.Identifier("n"), binding) + + dataType, found := scope.LookupDataType(pgsql.Identifier("n")) + + require.True(t, found) + require.Equal(t, pgsql.NodeComposite, dataType) +} diff --git a/integration/harness.go b/integration/harness.go index ad943ff0..6f642d42 100644 --- a/integration/harness.go +++ b/integration/harness.go @@ -90,7 +90,7 @@ func setupDB(t *testing.T, cleanupGraph bool, extraNodeKinds, extraEdgeKinds gra connStr := os.Getenv("CONNECTION_STRING") if connStr == "" { - t.Fatal("CONNECTION_STRING env var is not set") + t.Skip("CONNECTION_STRING env var is not set") } driver, err := driverFromConnStr(connStr) From 2ed6a0be8c065300833df6265c702138a7561e4f Mon Sep 17 00:00:00 2001 From: John Hopper Date: Fri, 22 May 2026 11:27:36 -0700 Subject: [PATCH 047/116] fix(pgsql): stage path nodes in tail predicates --- .../translation_cases/pattern_binding.sql | 2 +- cypher/models/pgsql/translate/function.go | 32 ++++--- .../models/pgsql/translate/function_test.go | 18 +++- .../models/pgsql/translate/path_functions.go | 49 ++++++++++- cypher/models/pgsql/translate/projection.go | 86 +++++++++++++++++++ 5 files changed, 167 insertions(+), 20 deletions(-) diff --git a/cypher/models/pgsql/test/translation_cases/pattern_binding.sql b/cypher/models/pgsql/test/translation_cases/pattern_binding.sql index ed4cb27c..622e2a29 100644 --- a/cypher/models/pgsql/test/translation_cases/pattern_binding.sql +++ b/cypher/models/pgsql/test/translation_cases/pattern_binding.sql @@ -87,7 +87,7 @@ with s0 as (with s1 as (with recursive s2_seed(root_id) as not materialized (sel 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 [5]::int2[] and n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [6]::int2[] and n1.id = e0.end_id where (((e0.properties ->> 'lastseen'))::timestamp with time zone >= now()::timestamp with time zone - interval 'P3D') and e0.kind_id = any (array [7]::int2[]) limit 100) select case when (s0.n0).id is null or (s0.e0).id is null or (s0.n1).id is null then null else (array [s0.n0, s0.n1]::nodecomposite[], array [s0.e0]::edgecomposite[])::pathcomposite end as p from s0 limit 100; -- case: MATCH p=(:GPO)-[r:GPLink|Contains*1..]->(:Base) WHERE HEAD(r).enforced OR NONE(n in TAIL(TAIL(NODES(p))) WHERE (n:OU AND n.blocksinheritance)) 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 [8]::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 [10]::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 [11, 12]::int2[]) union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [10]::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 [11, 12]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (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(s1.path) with ordinality as _path(id, ordinality) join edge _edge on _edge.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 case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 where (((((s0.e0)[1]).properties ->> 'enforced'))::bool or ((select count(*)::int from unnest(coalesce((coalesce((((case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite end).nodes)::nodecomposite[])[2:], array []::nodecomposite[])::nodecomposite[])[2:], array []::nodecomposite[])::nodecomposite[]) as i0 where ((i0.kind_ids operator (pg_catalog.@>) array [9]::int2[] and ((i0.properties ->> 'blocksinheritance'))::bool))) = 0 and coalesce((coalesce((((case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [((s0.n0).id, (s0.n0).kind_ids, (s0.n0).properties)::nodecomposite, ((s0.n1).id, (s0.n1).kind_ids, (s0.n1).properties)::nodecomposite]::nodecomposite[])::pathcomposite end).nodes)::nodecomposite[])[2:], array []::nodecomposite[])::nodecomposite[])[2:], array []::nodecomposite[])::nodecomposite[] is not null)::bool); +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 [8]::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 [10]::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 [11, 12]::int2[]) union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [10]::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 [11, 12]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (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(s1.path) with ordinality as _path(id, ordinality) join edge _edge on _edge.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 s2.pc0 as p from s0, lateral (select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as pc0 offset 0) s2 where (((((s0.e0)[1]).properties ->> 'enforced'))::bool or ((select count(*)::int from unnest(coalesce((coalesce((((s2.pc0).nodes)::nodecomposite[])[2:], array []::nodecomposite[])::nodecomposite[])[2:], array []::nodecomposite[])::nodecomposite[]) as i0 where ((i0.kind_ids operator (pg_catalog.@>) array [9]::int2[] and ((i0.properties ->> 'blocksinheritance'))::bool))) = 0 and coalesce((coalesce((((s2.pc0).nodes)::nodecomposite[])[2:], array []::nodecomposite[])::nodecomposite[])[2:], array []::nodecomposite[])::nodecomposite[] is not null)::bool); -- case: MATCH p=(:GPO)-[r:GPLink|Contains*1..]->(:Base) WHERE NONE(x in TAIL(r) WHERE NOT type(x) = 'Contains') 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 [8]::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 [10]::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 [11, 12]::int2[]) union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [10]::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 [11, 12]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (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(s1.path) with ordinality as _path(id, ordinality) join edge _edge on _edge.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 case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 where (((select count(*)::int from unnest(coalesce((s0.e0)[2:], array []::edgecomposite[])::edgecomposite[]) as i0 where (not i0.kind_id = 12)) = 0 and coalesce((s0.e0)[2:], array []::edgecomposite[])::edgecomposite[] is not null)::bool); diff --git a/cypher/models/pgsql/translate/function.go b/cypher/models/pgsql/translate/function.go index 12346ed8..f0eae7f8 100644 --- a/cypher/models/pgsql/translate/function.go +++ b/cypher/models/pgsql/translate/function.go @@ -465,25 +465,23 @@ func (s *Translator) translatePathComponentFunction(functionInvocation *cypher.F } else if literal, isLiteral := argument.(pgsql.Literal); isLiteral && literal.Null { s.treeTranslator.PushOperand(pgsql.NewTypeCast(literal, castType)) } else { - 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) - } + if identifier, isIdentifier := unwrapParenthetical(argument).(pgsql.Identifier); isIdentifier { + binding, bound := s.scope.Lookup(identifier) + if !bound { + binding, bound = s.scope.AliasedLookup(identifier) + } - s.treeTranslator.PushOperand(pgsql.NewTypeCast(pgsql.RowColumnReference{ - Identifier: argument, - Column: column, - }, castType)) - return nil + 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: identifier, + Column: column, + }, castType)) + return nil } if pathExpression, err := s.expressionForPath(argument); err != nil { diff --git a/cypher/models/pgsql/translate/function_test.go b/cypher/models/pgsql/translate/function_test.go index 793ea9e1..e8f421bd 100644 --- a/cypher/models/pgsql/translate/function_test.go +++ b/cypher/models/pgsql/translate/function_test.go @@ -68,10 +68,26 @@ func TestTailFunctionDoesNotDuplicatePathComponentExpression(t *testing.T) { formatted, err := Translated(translation) require.NoError(t, err) - require.Equal(t, 1, strings.Count(formatted, "ordered_edges_to_path")) + require.Equal(t, 1, strings.Count(formatted, "ordered_edges_to_path"), formatted) require.NotContains(t, formatted, "cardinality(((case when") } +func TestTailPredicateStagesPathComponentExpression(t *testing.T) { + kindMapper := pgutil.NewInMemoryKindMapper() + + query, err := frontend.ParseCypher(frontend.NewContext(), `MATCH p = ()-[*1..]->() WHERE NONE(n IN TAIL(TAIL(NODES(p))) WHERE true) RETURN p`) + require.NoError(t, err) + + translation, err := Translate(context.Background(), query, kindMapper, nil, DefaultGraphID) + require.NoError(t, err) + + formatted, err := Translated(translation) + require.NoError(t, err) + require.Equal(t, 1, strings.Count(formatted, "ordered_edges_to_path")) + require.Contains(t, formatted, "lateral (select") + require.Contains(t, formatted, ".nodes") +} + func TestPrepareCollectExpressionMissingBindingErrorNamesArgument(t *testing.T) { t.Parallel() diff --git a/cypher/models/pgsql/translate/path_functions.go b/cypher/models/pgsql/translate/path_functions.go index 4b2951fc..516f3022 100644 --- a/cypher/models/pgsql/translate/path_functions.go +++ b/cypher/models/pgsql/translate/path_functions.go @@ -41,7 +41,7 @@ func pathCompositeEdgesExpression(scope *Scope, pathBinding *BoundIdentifier) (p } func resolvePathCompositeFieldReference(scope *Scope, reference pgsql.RowColumnReference) (pgsql.Expression, bool, error) { - identifier, isIdentifier := reference.Identifier.(pgsql.Identifier) + identifier, isIdentifier := unwrapParenthetical(reference.Identifier).(pgsql.Identifier) if !isIdentifier { return nil, false, nil } @@ -65,6 +65,15 @@ func resolvePathCompositeFieldReference(scope *Scope, reference pgsql.RowColumnR case pgsql.ColumnEdges: expression, err := pathCompositeEdgesExpression(scope, binding) return expression, true, err + case pgsql.ColumnNodes: + if expression, err := expressionForPathComposite(binding, scope); err != nil { + return nil, false, err + } else { + return pgsql.RowColumnReference{ + Identifier: expression, + Column: reference.Column, + }, true, nil + } default: return nil, false, fmt.Errorf("unsupported path composite field reference: %s", reference.Column) } @@ -235,6 +244,44 @@ func resolvePathCompositeFieldReferences(scope *Scope, expression pgsql.Expressi return typedExpression, nil } + case pgsql.ArraySlice: + if resolved, err := resolvePathCompositeFieldReferences(scope, typedExpression.Expression); err != nil { + return nil, err + } else { + typedExpression.Expression = resolved + } + + if typedExpression.Lower != nil { + if resolved, err := resolvePathCompositeFieldReferences(scope, typedExpression.Lower); err != nil { + return nil, err + } else { + typedExpression.Lower = resolved + } + } + + if typedExpression.Upper != nil { + if resolved, err := resolvePathCompositeFieldReferences(scope, typedExpression.Upper); err != nil { + return nil, err + } else { + typedExpression.Upper = resolved + } + } + + return typedExpression, nil + + case *pgsql.ArraySlice: + if typedExpression == nil { + return nil, nil + } + + resolved, err := resolvePathCompositeFieldReferences(scope, *typedExpression) + if err != nil { + return nil, err + } + + arraySlice := resolved.(pgsql.ArraySlice) + return &arraySlice, nil + case pgsql.ArrayLiteral: for idx, value := range typedExpression.Values { if resolved, err := resolvePathCompositeFieldReferences(scope, value); err != nil { diff --git a/cypher/models/pgsql/translate/projection.go b/cypher/models/pgsql/translate/projection.go index a4f4e64c..18dabaee 100644 --- a/cypher/models/pgsql/translate/projection.go +++ b/cypher/models/pgsql/translate/projection.go @@ -1093,6 +1093,87 @@ func rewriteOrderByProjectionAlias(orderBy *pgsql.OrderBy, aliases map[pgsql.Ide } } +func tailPathCompositeStageBindings(scope *Scope, expression pgsql.Expression) ([]*BoundIdentifier, error) { + if expression == nil { + return nil, nil + } + + var ( + bindings = make([]*BoundIdentifier, 0) + seen = map[pgsql.Identifier]struct{}{} + ) + + if err := walk.PgSQL(expression, walk.NewSimpleVisitor[pgsql.SyntaxNode](func(node pgsql.SyntaxNode, _ walk.VisitorHandler) { + reference, isRowColumnReference := node.(pgsql.RowColumnReference) + if !isRowColumnReference || reference.Column != pgsql.ColumnNodes { + return + } + + identifier, isIdentifier := unwrapParenthetical(reference.Identifier).(pgsql.Identifier) + if !isIdentifier { + return + } + + binding, bound := scope.Lookup(identifier) + if !bound { + binding, bound = scope.AliasedLookup(identifier) + } + if !bound || binding.DataType != pgsql.PathComposite || binding.LastProjection != nil { + return + } + + if _, alreadySeen := seen[binding.Identifier]; alreadySeen { + return + } + + seen[binding.Identifier] = struct{}{} + bindings = append(bindings, binding) + })); err != nil { + return nil, err + } + + return bindings, nil +} + +func (s *Translator) stageTailPathCompositeBindings(fromClauses []pgsql.FromClause, bindings []*BoundIdentifier) ([]pgsql.FromClause, error) { + for _, binding := range bindings { + stageBinding, err := s.scope.DefineNew(pgsql.Scope) + if err != nil { + return nil, err + } + + stageFrame := &Frame{ + Binding: stageBinding, + Visible: pgsql.AsIdentifierSet(binding.Identifier), + Exported: pgsql.AsIdentifierSet(binding.Identifier), + stashedVisible: pgsql.NewIdentifierSet(), + stashedExported: pgsql.NewIdentifierSet(), + Synthetic: true, + } + + stageProjection, err := buildProjection(binding.Identifier, binding, s.scope, binding.LastProjection) + if err != nil { + return nil, err + } + + fromClauses = append(fromClauses, pgsql.FromClause{ + Source: pgsql.LateralSubquery{ + Query: pgsql.Query{ + Body: pgsql.Select{ + Projection: stageProjection, + }, + Offset: pgsql.NewLiteral(0, pgsql.Int), + }, + Binding: models.OptionalValue(stageBinding.Identifier), + }, + }) + + binding.MaterializedBy(stageFrame) + } + + return fromClauses, nil +} + func (s *Translator) buildTailProjection() error { var ( currentPart = s.query.CurrentPart() @@ -1106,6 +1187,10 @@ func (s *Translator) buildTailProjection() error { if projectionConstraint, err := s.treeTranslator.ConsumeAllConstraints(); err != nil { return err + } else if stagedBindings, err := tailPathCompositeStageBindings(s.scope, projectionConstraint.Expression); err != nil { + return err + } else if stagedFromClauses, err := s.stageTailPathCompositeBindings(singlePartQuerySelect.From, stagedBindings); err != nil { + return err } else if projection, err := buildExternalProjection(s.scope, currentPart.projections.Items); err != nil { return err } else if resolvedConstraint, err := resolvePathCompositeFieldReferences(s.scope, projectionConstraint.Expression); err != nil { @@ -1113,6 +1198,7 @@ func (s *Translator) buildTailProjection() error { } else if err := RewriteFrameBindings(s.scope, resolvedConstraint); err != nil { return err } else { + singlePartQuerySelect.From = stagedFromClauses singlePartQuerySelect.Projection = projection singlePartQuerySelect.Where = resolvedConstraint From 91733ad2775584dd8bd326542ab783ea16566177 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Fri, 22 May 2026 14:40:17 -0700 Subject: [PATCH 048/116] Validate ADCS optimizer fanout rewrite --- .../models/pgsql/optimize/optimizer_test.go | 87 +++++++++++++++++++ .../pgsql/translate/optimizer_safety_test.go | 26 +++++- 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index 227e0244..be0a42f1 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -48,6 +48,93 @@ func TestOptimizeCopiesAndAnalyzesQuery(t *testing.T) { require.Len(t, plan.PredicateAttachments, 2) } +func TestOptimizePlansADCSFanoutRewrite(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), adcsQuery) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + + ctPredicate := PredicateAttachment{ + QueryPartIndex: 0, + RegionIndex: 0, + ClauseIndex: 2, + ExpressionIndex: 0, + Scope: PredicateAttachmentScopeBinding, + BindingSymbols: []string{"ct"}, + Dependencies: []string{"ct"}, + } + + require.Contains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringExpansionSuffixPushdown}) + require.Contains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringPredicatePlacement}) + require.Contains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringExpandIntoDetection}) + require.Contains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringLatePathMaterialization}) + + require.Contains(t, plan.LoweringPlan.ExpansionSuffixPushdown, ExpansionSuffixPushdownDecision{ + Target: TraversalStepTarget{ + QueryPartIndex: 0, + ClauseIndex: 1, + PatternIndex: 0, + StepIndex: 0, + }, + SuffixLength: 3, + SuffixStartStep: 1, + SuffixEndStep: 3, + }) + require.Contains(t, plan.LoweringPlan.ExpansionSuffixPushdown, ExpansionSuffixPushdownDecision{ + Target: TraversalStepTarget{ + QueryPartIndex: 0, + ClauseIndex: 2, + PatternIndex: 0, + StepIndex: 0, + }, + SuffixLength: 2, + SuffixStartStep: 1, + SuffixEndStep: 2, + PredicateAttachments: []PredicateAttachment{ctPredicate}, + }) + require.Contains(t, plan.LoweringPlan.ExpansionSuffixPushdown, ExpansionSuffixPushdownDecision{ + Target: TraversalStepTarget{ + QueryPartIndex: 0, + ClauseIndex: 2, + PatternIndex: 0, + StepIndex: 3, + }, + SuffixLength: 1, + SuffixStartStep: 4, + SuffixEndStep: 4, + }) + + require.Contains(t, plan.LoweringPlan.ExpandInto, ExpandIntoDecision{ + Target: TraversalStepTarget{ + QueryPartIndex: 0, + ClauseIndex: 2, + PatternIndex: 0, + StepIndex: 2, + }, + }) + require.Contains(t, plan.LoweringPlan.ExpandInto, ExpandIntoDecision{ + Target: TraversalStepTarget{ + QueryPartIndex: 0, + ClauseIndex: 2, + PatternIndex: 0, + StepIndex: 4, + }, + }) + require.Contains(t, plan.LoweringPlan.PredicatePlacement, PredicatePlacementDecision{ + Target: TraversalStepTarget{ + QueryPartIndex: 0, + ClauseIndex: 2, + PatternIndex: 0, + StepIndex: 1, + }, + Attachment: ctPredicate, + Placement: PredicateAttachmentScopeBinding, + }) +} + func TestOptimizerRunsRulesAndRefreshesAnalysis(t *testing.T) { t.Parallel() diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index 50375d2a..3ceb4824 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -127,15 +127,39 @@ func requireSQLContainsInOrder(t *testing.T, sql string, parts ...string) { func TestOptimizerSafetyADCSQueryPrunesExpansionEdgeCarry(t *testing.T) { t.Parallel() - normalizedQuery := optimizerSafetySQL(t, optimizerADCSQuery) + translation := optimizerSafetyTranslation(t, optimizerADCSQuery) + formattedQuery, err := Translated(translation) + require.NoError(t, err) + normalizedQuery := strings.Join(strings.Fields(formattedQuery), " ") + requirePlannedOptimizationLowering(t, translation.Optimization, "ExpansionSuffixPushdown") + requirePlannedOptimizationLowering(t, translation.Optimization, "PredicatePlacement") + requirePlannedOptimizationLowering(t, translation.Optimization, "ExpandIntoDetection") + requireOptimizationLowering(t, translation.Optimization, "ExpansionSuffixPushdown") + requireOptimizationLowering(t, translation.Optimization, "PredicatePlacement") + requireOptimizationLowering(t, translation.Optimization, "ExpandIntoDetection") + + require.Contains(t, normalizedQuery, "select distinct (s0.n0).id as root_id from s0") require.Contains(t, normalizedQuery, "select distinct (s5.n0).id as root_id from s5") + require.Contains(t, normalizedQuery, "select distinct (s9.n2).id as root_id from s9") require.Contains(t, normalizedQuery, "s5.ep0 as ep0") require.NotContains(t, normalizedQuery, "s5.e0 as e0") require.Contains(t, normalizedQuery, "from unnest(s12.ep0)") require.Contains(t, normalizedQuery, "from unnest(array [s12.e1]::int8[])") require.NotContains(t, normalizedQuery, "array [s12.e1]::edgecomposite[]") require.Contains(t, normalizedQuery, "from s5, s7") + requireSQLContainsInOrder(t, normalizedQuery, + "where s7.satisfied and exists (select 1 from edge e5 join node n6", + "properties -> 'authenticationenabled'", + "join edge e6 on n6.id = e6.start_id", + "e6.end_id = (s5.n2).id", + "and (s5.n0).id = s7.root_id", + ) + requireSQLContainsInOrder(t, normalizedQuery, + "where s11.satisfied and (s9.n2).id = s11.root_id and exists", + "from edge e8 where n7.id = e8.start_id", + "e8.end_id = (s9.n4).id", + ) } func assertOptimizerSafetyRelationshipStaysComposite(t *testing.T, cypherQuery string) { From 7eac29af5cd22952e8ae371cfdbd84b3c67a8b81 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Fri, 22 May 2026 16:04:23 -0700 Subject: [PATCH 049/116] Add Cypher plan corpus capture tooling --- Makefile | 7 +- README.md | 4 + cmd/plancorpus/README.md | 26 + cmd/plancorpus/capture.go | 451 ++++++++++++++++++ cmd/plancorpus/corpus.go | 222 +++++++++ cmd/plancorpus/corpus_test.go | 34 ++ cmd/plancorpus/main.go | 182 +++++++ cmd/plancorpus/main_test.go | 41 ++ cmd/plancorpus/report.go | 279 +++++++++++ cmd/plancorpus/report_test.go | 88 ++++ cmd/plancorpus/types.go | 36 ++ .../pgsql/optimize/OPTIMIZATION_PLAN.md | 74 +++ 12 files changed, 1443 insertions(+), 1 deletion(-) create mode 100644 cmd/plancorpus/README.md create mode 100644 cmd/plancorpus/capture.go create mode 100644 cmd/plancorpus/corpus.go create mode 100644 cmd/plancorpus/corpus_test.go create mode 100644 cmd/plancorpus/main.go create mode 100644 cmd/plancorpus/main_test.go create mode 100644 cmd/plancorpus/report.go create mode 100644 cmd/plancorpus/report_test.go create mode 100644 cmd/plancorpus/types.go create mode 100644 cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md diff --git a/Makefile b/Makefile index 81dd8363..cc85ca83 100644 --- a/Makefile +++ b/Makefile @@ -50,7 +50,7 @@ QUALITY_INPUTS += -mutation-report $(MUTATION_REPORT) endif QUALITY_INPUTS += -benchmark-regression $(BENCHMARK_REGRESSION) -.PHONY: default all build deps tidy lint format test test_all test_integration test_neo4j test_pg test_update complexity complexity_check crap crap_check quality quality_check quality_backend quality_bench metrics metrics_check generate clean help +.PHONY: default all build deps tidy lint format test test_all test_integration test_neo4j test_pg test_update plan_corpus complexity complexity_check crap crap_check quality quality_check quality_backend quality_bench metrics metrics_check generate clean help # Default target default: help @@ -109,6 +109,10 @@ test_update: @cp -fv cypher/models/pgsql/test/updated_cases/* cypher/models/pgsql/test/translation_cases @rm -rf cypher/models/pgsql/test/updated_cases +plan_corpus: $(METRICS_DIR) + @echo "Capturing Cypher plan corpus..." + @$(GO_CMD) run ./cmd/plancorpus + # Metric targets $(METRICS_DIR): @mkdir -p $(METRICS_DIR) @@ -218,6 +222,7 @@ help: @echo " test_bench - Run benchmark test" @echo " test_neo4j - Run Neo4j integration tests" @echo " test_pg - Run PostgreSQL integration tests" + @echo " plan_corpus - Capture shared corpus query plans for configured backends" @echo " test_update - Update test cases" @echo " complexity - Report cyclomatic complexity" @echo " crap - Report CRAP scores from unit test coverage" diff --git a/README.md b/README.md index e1aad7bb..c37f58d6 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,10 @@ make quality FUZZ_REPORT=.coverage/fuzz.json MUTATION_REPORT=.coverage/mutation. `PG_CONNECTION_STRING` and `NEO4J_CONNECTION_STRING`. `make quality_bench` writes benchmark markdown and JSON captures for later baseline comparison. +`make plan_corpus` captures plan diagnostics for the shared Cypher integration corpus. It accepts either +`CONNECTION_STRING` for one backend or `PG_CONNECTION_STRING` and `NEO4J_CONNECTION_STRING` for both backends, then +writes JSONL captures and markdown/JSON summaries under `.coverage/`. + Thresholds are report-only by default. To enforce the configured thresholds, run: ```bash diff --git a/cmd/plancorpus/README.md b/cmd/plancorpus/README.md new file mode 100644 index 00000000..3e49de85 --- /dev/null +++ b/cmd/plancorpus/README.md @@ -0,0 +1,26 @@ +# Plan Corpus Capture + +`plancorpus` captures query-plan diagnostics for the shared integration corpus. + +It reads `integration/testdata/cases` and `integration/testdata/templates`, loads the same datasets and inline fixtures used by the integration tests, and writes backend-specific JSONL plan records plus markdown and JSON summaries. + +## Usage + +```bash +PG_CONNECTION_STRING="postgres://postgres:password@localhost/db" \ +NEO4J_CONNECTION_STRING="neo4j://neo4j:password@localhost:7687" \ +go run ./cmd/plancorpus +``` + +Useful flags: + +| Flag | Default | Description | +| --- | --- | --- | +| `-dataset-dir` | `integration/testdata` | Integration corpus root | +| `-output-dir` | `.coverage` | Output directory | +| `-connection` | `CONNECTION_STRING` | Capture one backend selected by URL scheme | +| `-pg-connection` | `PG_CONNECTION_STRING` | PostgreSQL backend | +| `-neo4j-connection` | `NEO4J_CONNECTION_STRING` | Neo4j backend | +| `-summary` | `.coverage/plan-corpus-summary.md` | Markdown summary | +| `-summary-json` | `.coverage/plan-corpus-summary.json` | JSON summary | +| `-top` | `25` | Number of expensive PostgreSQL plans to include in summaries | diff --git a/cmd/plancorpus/capture.go b/cmd/plancorpus/capture.go new file mode 100644 index 00000000..6b4e264e --- /dev/null +++ b/cmd/plancorpus/capture.go @@ -0,0 +1,451 @@ +package main + +import ( + "context" + "fmt" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + + neo4jcore "github.com/neo4j/neo4j-go-driver/v5/neo4j" + "github.com/specterops/dawgs" + "github.com/specterops/dawgs/cypher/frontend" + "github.com/specterops/dawgs/cypher/models/pgsql/optimize" + "github.com/specterops/dawgs/cypher/models/pgsql/translate" + "github.com/specterops/dawgs/drivers" + "github.com/specterops/dawgs/drivers/neo4j" + "github.com/specterops/dawgs/drivers/pg" + "github.com/specterops/dawgs/graph" + "github.com/specterops/dawgs/opengraph" + "github.com/specterops/dawgs/util/size" +) + +const defaultGraphName = "integration_test" + +type captureSpec struct { + DriverName string + Connection string +} + +type backendCapture struct { + spec captureSpec + db graph.Database + pgDriver *pg.Driver + pgGraphID int32 + neo4jDriver neo4jcore.Driver +} + +func driverFromConnectionString(connStr string) (string, error) { + u, err := url.Parse(connStr) + if err != nil { + return "", fmt.Errorf("parse connection string: %w", err) + } + + switch u.Scheme { + case "postgres", "postgresql": + return pg.DriverName, nil + case neo4j.DriverName, "neo4j+s", "neo4j+ssc": + return neo4j.DriverName, nil + default: + return "", fmt.Errorf("unknown connection string scheme %q", u.Scheme) + } +} + +func captureCorpus(ctx context.Context, datasetDir string, suite corpus, spec captureSpec) ([]PlanRecord, error) { + backend, err := openBackend(ctx, suite, spec) + if err != nil { + return nil, err + } + defer backend.close(ctx) + + var records []PlanRecord + for _, datasetName := range suite.datasetNames { + group := suite.caseGroups[datasetName] + if group == nil { + continue + } + + datasetLoaded := false + ensureDatasetLoaded := func() error { + if datasetLoaded { + return nil + } + if err := clearGraph(ctx, backend.db); err != nil { + return err + } + if err := loadDataset(ctx, backend.db, datasetDir, datasetName); err != nil { + return err + } + datasetLoaded = true + return nil + } + + for _, file := range group.files { + for _, testCase := range file.Cases { + if testCase.Fixture == nil { + if err := ensureDatasetLoaded(); err != nil { + return nil, err + } + } else { + if err := loadCommittedFixture(ctx, backend.db, testCase.Fixture); err != nil { + return nil, err + } + datasetLoaded = false + } + + record := backend.capture(ctx, CorpusQuery{ + Source: file.path, + Dataset: datasetName, + Name: testCase.Name, + Cypher: testCase.Cypher, + Params: testCase.Params, + }) + records = append(records, record) + } + } + } + + for _, file := range suite.templateFiles { + fileName := strings.TrimSuffix(filepath.Base(file.path), filepath.Ext(file.path)) + + for _, family := range file.Families { + if family.Fixture == nil { + return nil, fmt.Errorf("%s/%s has no fixture", file.path, family.Name) + } + + for _, variant := range family.Variants { + rendered, err := renderTemplate(family.Template, variant.Vars) + if err != nil { + return nil, fmt.Errorf("%s/%s/%s: %w", file.path, family.Name, variant.Name, err) + } + if err := loadCommittedFixture(ctx, backend.db, family.Fixture); err != nil { + return nil, err + } + + record := backend.capture(ctx, CorpusQuery{ + Source: file.path, + Name: fileName + "/" + family.Name + "/" + variant.Name, + Cypher: rendered, + Params: mergeParams(family.Params, variant.Params), + }) + records = append(records, record) + } + } + + for _, family := range file.Metamorphic { + if family.Fixture == nil { + return nil, fmt.Errorf("%s/%s has no fixture", file.path, family.Name) + } + if err := loadCommittedFixture(ctx, backend.db, family.Fixture); err != nil { + return nil, err + } + + for _, query := range family.Queries { + record := backend.capture(ctx, CorpusQuery{ + Source: file.path, + Name: fileName + "/" + family.Name + "/" + query.Name, + Cypher: query.Cypher, + Params: query.Params, + }) + records = append(records, record) + } + } + } + + return records, nil +} + +func openBackend(ctx context.Context, suite corpus, spec captureSpec) (*backendCapture, error) { + cfg := dawgs.Config{ + GraphQueryMemoryLimit: size.Gibibyte, + ConnectionString: spec.Connection, + } + + if spec.DriverName == pg.DriverName { + pool, err := pg.NewPool(drivers.DatabaseConfiguration{Connection: spec.Connection}) + if err != nil { + return nil, fmt.Errorf("create PostgreSQL pool: %w", err) + } + cfg.Pool = pool + } + + db, err := dawgs.Open(ctx, spec.DriverName, cfg) + if err != nil { + return nil, fmt.Errorf("open %s database: %w", spec.DriverName, err) + } + + schema := graph.Schema{ + Graphs: []graph.Graph{{ + Name: defaultGraphName, + Nodes: suite.nodeKinds, + Edges: suite.edgeKinds, + }}, + DefaultGraph: graph.Graph{Name: defaultGraphName}, + } + if err := db.AssertSchema(ctx, schema); err != nil { + _ = db.Close(ctx) + return nil, fmt.Errorf("assert schema: %w", err) + } + + backend := &backendCapture{ + spec: spec, + db: db, + } + + switch spec.DriverName { + case pg.DriverName: + pgDriver, ok := db.(*pg.Driver) + if !ok { + _ = db.Close(ctx) + return nil, fmt.Errorf("expected *pg.Driver, got %T", db) + } + defaultGraph, ok := pgDriver.DefaultGraph() + if !ok { + _ = db.Close(ctx) + return nil, fmt.Errorf("PostgreSQL default graph is not set") + } + backend.pgDriver = pgDriver + backend.pgGraphID = defaultGraph.ID + + case neo4j.DriverName: + neo4jDriver, err := openNeo4jPlanDriver(spec.Connection) + if err != nil { + _ = db.Close(ctx) + return nil, err + } + backend.neo4jDriver = neo4jDriver + } + + return backend, nil +} + +func (s *backendCapture) close(ctx context.Context) { + if s.neo4jDriver != nil { + _ = s.neo4jDriver.Close() + } + if s.db != nil { + _ = s.db.Close(ctx) + } +} + +func (s *backendCapture) capture(ctx context.Context, query CorpusQuery) PlanRecord { + record := PlanRecord{ + Driver: s.spec.DriverName, + Source: query.Source, + Dataset: query.Dataset, + Name: query.Name, + Cypher: query.Cypher, + Params: query.Params, + } + + switch s.spec.DriverName { + case pg.DriverName: + s.capturePostgres(ctx, query.Cypher, query.Params, &record) + case neo4j.DriverName: + s.captureNeo4j(query.Cypher, query.Params, &record) + } + + return record +} + +func (s *backendCapture) capturePostgres(ctx context.Context, cypherQuery string, params map[string]any, record *PlanRecord) { + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), cypherQuery) + if err != nil { + record.Error = err.Error() + return + } + + translation, err := translate.Translate(ctx, regularQuery, s.pgDriver.KindMapper(), params, s.pgGraphID) + if err != nil { + record.Error = err.Error() + return + } + + sqlQuery, err := translate.Translated(translation) + if err != nil { + record.Error = err.Error() + return + } + + var plan []string + if err := s.db.ReadTransaction(ctx, func(tx graph.Transaction) error { + result := tx.Raw("EXPLAIN "+sqlQuery, translation.Parameters) + defer result.Close() + + for result.Next() { + values := result.Values() + if len(values) == 0 { + continue + } + plan = append(plan, fmt.Sprint(values[0])) + } + + return result.Error() + }); err != nil { + record.Error = err.Error() + } + + record.SQL = sqlQuery + record.PGPlan = plan + record.PGOperators = postgresOperators(plan) + record.PlannedLowerings = loweringNames(translation.Optimization.PlannedLowerings) + record.AppliedLowerings = loweringNames(translation.Optimization.Lowerings) + record.Optimization = &translation.Optimization +} + +func (s *backendCapture) captureNeo4j(cypherQuery string, params map[string]any, record *PlanRecord) { + session := s.neo4jDriver.NewSession(neo4jcore.SessionConfig{ + AccessMode: neo4jcore.AccessModeWrite, + }) + defer session.Close() + + result, err := session.Run("EXPLAIN "+cypherWithoutTerminator(cypherQuery), params) + if err != nil { + record.Error = err.Error() + return + } + + summary, err := result.Consume() + if err != nil { + record.Error = err.Error() + return + } + + if plan := summary.Plan(); plan != nil { + planNode := convertNeo4jPlan(plan) + record.Neo4jPlan = &planNode + record.Neo4jOperators = neo4jOperators(planNode) + } +} + +func openNeo4jPlanDriver(connStr string) (neo4jcore.Driver, error) { + connectionURL, err := url.Parse(connStr) + if err != nil { + return nil, fmt.Errorf("parse Neo4j connection string: %w", err) + } + + password, ok := connectionURL.User.Password() + if !ok { + return nil, fmt.Errorf("no password provided in Neo4j connection string") + } + + return neo4jcore.NewDriver( + "bolt://"+connectionURL.Host, + neo4jcore.BasicAuth(connectionURL.User.Username(), password, ""), + ) +} + +func clearGraph(ctx context.Context, db graph.Database) error { + return db.WriteTransaction(ctx, func(tx graph.Transaction) error { + return tx.Nodes().Delete() + }) +} + +func loadDataset(ctx context.Context, db graph.Database, datasetDir, name string) error { + f, err := os.Open(filepath.Join(datasetDir, name+".json")) + if err != nil { + return fmt.Errorf("open dataset %s: %w", name, err) + } + defer f.Close() + + if _, err := opengraph.Load(ctx, db, f); err != nil { + return fmt.Errorf("load dataset %s: %w", name, err) + } + return nil +} + +func loadCommittedFixture(ctx context.Context, db graph.Database, fixture *opengraph.Graph) error { + if fixture == nil { + return fmt.Errorf("fixture is nil") + } + + if err := clearGraph(ctx, db); err != nil { + return err + } + + return db.WriteTransaction(ctx, func(tx graph.Transaction) error { + _, err := opengraph.WriteGraphTx(tx, fixture) + return err + }) +} + +func convertNeo4jPlan(plan neo4jcore.Plan) Neo4jPlanNode { + node := Neo4jPlanNode{ + Operator: plan.Operator(), + Arguments: stringifyArguments(plan.Arguments()), + Identifiers: append([]string(nil), plan.Identifiers()...), + } + + for _, child := range plan.Children() { + node.Children = append(node.Children, convertNeo4jPlan(child)) + } + + return node +} + +func stringifyArguments(arguments map[string]any) map[string]string { + if len(arguments) == 0 { + return nil + } + + values := make(map[string]string, len(arguments)) + for key, value := range arguments { + values[key] = fmt.Sprint(value) + } + return values +} + +func postgresOperators(plan []string) []string { + operators := make([]string, 0, len(plan)) + for _, line := range plan { + trimmed := strings.TrimSpace(line) + trimmed = strings.TrimPrefix(trimmed, "->") + trimmed = strings.TrimSpace(trimmed) + if trimmed == "" || strings.HasPrefix(trimmed, "Planning ") { + continue + } + if idx := strings.Index(trimmed, " ("); idx >= 0 { + trimmed = trimmed[:idx] + } + operators = append(operators, trimmed) + } + return operators +} + +func neo4jOperators(root Neo4jPlanNode) []string { + var operators []string + var walk func(Neo4jPlanNode) + walk = func(node Neo4jPlanNode) { + operators = append(operators, node.Operator) + for _, child := range node.Children { + walk(child) + } + } + walk(root) + return operators +} + +func loweringNames(decisions []optimize.LoweringDecision) []string { + if len(decisions) == 0 { + return nil + } + + names := make([]string, 0, len(decisions)) + seen := make(map[string]struct{}, len(decisions)) + for _, decision := range decisions { + name := decision.Name + if _, duplicate := seen[name]; duplicate { + continue + } + seen[name] = struct{}{} + names = append(names, name) + } + sort.Strings(names) + return names +} + +func cypherWithoutTerminator(cypherQuery string) string { + return strings.TrimSuffix(strings.TrimSpace(cypherQuery), ";") +} diff --git a/cmd/plancorpus/corpus.go b/cmd/plancorpus/corpus.go new file mode 100644 index 00000000..46fdd4e0 --- /dev/null +++ b/cmd/plancorpus/corpus.go @@ -0,0 +1,222 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/specterops/dawgs/graph" + "github.com/specterops/dawgs/opengraph" +) + +type corpus struct { + caseGroups map[string]*caseGroup + datasetNames []string + templateFiles []templateFile + nodeKinds graph.Kinds + edgeKinds graph.Kinds +} + +type caseGroup struct { + dataset string + files []caseFile +} + +type caseFile struct { + path string + Dataset string `json:"dataset"` + Cases []caseEntry `json:"cases"` +} + +type caseEntry struct { + Name string `json:"name"` + Cypher string `json:"cypher"` + Params map[string]any `json:"params,omitempty"` + Fixture *opengraph.Graph `json:"fixture,omitempty"` +} + +type templateFile struct { + path string + Families []templateFamily `json:"families,omitempty"` + Metamorphic []metamorphicFamily `json:"metamorphic,omitempty"` +} + +type templateFamily struct { + Name string `json:"name"` + Template string `json:"template"` + Params map[string]any `json:"params,omitempty"` + Fixture *opengraph.Graph `json:"fixture,omitempty"` + Variants []templateVariant `json:"variants"` +} + +type templateVariant struct { + Name string `json:"name"` + Vars map[string]string `json:"vars"` + Params map[string]any `json:"params,omitempty"` +} + +type metamorphicFamily struct { + Name string `json:"name"` + Fixture *opengraph.Graph `json:"fixture,omitempty"` + Queries []metamorphicQuery `json:"queries"` +} + +type metamorphicQuery struct { + Name string `json:"name"` + Cypher string `json:"cypher"` + Params map[string]any `json:"params,omitempty"` +} + +func loadCorpus(datasetDir string) (corpus, error) { + var loaded corpus + loaded.caseGroups = map[string]*caseGroup{} + + if err := loaded.loadCaseFiles(datasetDir); err != nil { + return corpus{}, err + } + if err := loaded.loadTemplateFiles(datasetDir); err != nil { + return corpus{}, err + } + if err := loaded.loadDatasetKinds(datasetDir); err != nil { + return corpus{}, err + } + + sort.Strings(loaded.datasetNames) + return loaded, nil +} + +func (s *corpus) loadCaseFiles(datasetDir string) error { + paths, err := filepath.Glob(filepath.Join(datasetDir, "cases", "*.json")) + if err != nil { + return fmt.Errorf("glob case files: %w", err) + } + if len(paths) == 0 { + return fmt.Errorf("no case files found under %s", filepath.Join(datasetDir, "cases")) + } + sort.Strings(paths) + + for _, path := range paths { + var file caseFile + if err := decodeJSONFile(path, &file); err != nil { + return err + } + file.path = filepath.ToSlash(path) + + dataset := file.Dataset + if dataset == "" { + dataset = "base" + } + if s.caseGroups[dataset] == nil { + s.caseGroups[dataset] = &caseGroup{dataset: dataset} + s.datasetNames = append(s.datasetNames, dataset) + } + s.caseGroups[dataset].files = append(s.caseGroups[dataset].files, file) + + for _, testCase := range file.Cases { + s.addFixtureKinds(testCase.Fixture) + } + } + + return nil +} + +func (s *corpus) loadTemplateFiles(datasetDir string) error { + paths, err := filepath.Glob(filepath.Join(datasetDir, "templates", "*.json")) + if err != nil { + return fmt.Errorf("glob template files: %w", err) + } + sort.Strings(paths) + + for _, path := range paths { + var file templateFile + if err := decodeJSONFile(path, &file); err != nil { + return err + } + file.path = filepath.ToSlash(path) + s.templateFiles = append(s.templateFiles, file) + + for _, family := range file.Families { + s.addFixtureKinds(family.Fixture) + } + for _, family := range file.Metamorphic { + s.addFixtureKinds(family.Fixture) + } + } + + return nil +} + +func (s *corpus) loadDatasetKinds(datasetDir string) error { + for _, datasetName := range s.datasetNames { + path := filepath.Join(datasetDir, datasetName+".json") + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("open dataset %s: %w", datasetName, err) + } + + doc, parseErr := opengraph.ParseDocument(f) + closeErr := f.Close() + if parseErr != nil { + return fmt.Errorf("parse dataset %s: %w", datasetName, parseErr) + } + if closeErr != nil { + return fmt.Errorf("close dataset %s: %w", datasetName, closeErr) + } + + nodeKinds, edgeKinds := doc.Graph.Kinds() + s.nodeKinds = s.nodeKinds.Add(nodeKinds...) + s.edgeKinds = s.edgeKinds.Add(edgeKinds...) + } + + return nil +} + +func (s *corpus) addFixtureKinds(fixture *opengraph.Graph) { + if fixture == nil { + return + } + + nodeKinds, edgeKinds := fixture.Kinds() + s.nodeKinds = s.nodeKinds.Add(nodeKinds...) + s.edgeKinds = s.edgeKinds.Add(edgeKinds...) +} + +func decodeJSONFile(path string, target any) error { + raw, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + if err := json.Unmarshal(raw, target); err != nil { + return fmt.Errorf("decode %s: %w", path, err) + } + return nil +} + +func renderTemplate(template string, vars map[string]string) (string, error) { + rendered := template + for name, value := range vars { + rendered = strings.ReplaceAll(rendered, "{{"+name+"}}", value) + } + if strings.Contains(rendered, "{{") || strings.Contains(rendered, "}}") { + return "", fmt.Errorf("template has unresolved placeholders: %s", rendered) + } + return rendered, nil +} + +func mergeParams(base, overrides map[string]any) map[string]any { + if len(base) == 0 && len(overrides) == 0 { + return nil + } + + merged := make(map[string]any, len(base)+len(overrides)) + for key, value := range base { + merged[key] = value + } + for key, value := range overrides { + merged[key] = value + } + return merged +} diff --git a/cmd/plancorpus/corpus_test.go b/cmd/plancorpus/corpus_test.go new file mode 100644 index 00000000..141fa515 --- /dev/null +++ b/cmd/plancorpus/corpus_test.go @@ -0,0 +1,34 @@ +package main + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLoadCorpus(t *testing.T) { + suite, err := loadCorpus(filepath.Join("..", "..", "integration", "testdata")) + require.NoError(t, err) + + require.Contains(t, suite.caseGroups, "base") + require.Contains(t, suite.datasetNames, "base") + require.NotEmpty(t, suite.templateFiles) + require.NotEmpty(t, suite.nodeKinds) + require.NotEmpty(t, suite.edgeKinds) +} + +func TestRenderTemplateRequiresAllPlaceholders(t *testing.T) { + rendered, err := renderTemplate("match ({{name}}) return {{name}}", map[string]string{"name": "n"}) + require.NoError(t, err) + require.Equal(t, "match (n) return n", rendered) + + _, err = renderTemplate("match ({{name}}) return n", nil) + require.ErrorContains(t, err, "unresolved placeholders") +} + +func TestMergeParams(t *testing.T) { + merged := mergeParams(map[string]any{"a": 1, "b": 2}, map[string]any{"b": 3}) + require.Equal(t, map[string]any{"a": 1, "b": 3}, merged) + require.Nil(t, mergeParams(nil, nil)) +} diff --git a/cmd/plancorpus/main.go b/cmd/plancorpus/main.go new file mode 100644 index 00000000..6a1f06e9 --- /dev/null +++ b/cmd/plancorpus/main.go @@ -0,0 +1,182 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" +) + +type commandConfig struct { + DatasetDir string + OutputDir string + SummaryMarkdown string + SummaryJSON string + Connection string + PGConnection string + Neo4jConnection string + TopPlans int +} + +func main() { + cfg := commandConfig{} + flag.StringVar(&cfg.DatasetDir, "dataset-dir", "integration/testdata", "integration testdata directory") + flag.StringVar(&cfg.OutputDir, "output-dir", ".coverage", "directory for JSONL plan captures") + flag.StringVar(&cfg.SummaryMarkdown, "summary", "", "markdown summary path (default: output-dir/plan-corpus-summary.md)") + flag.StringVar(&cfg.SummaryJSON, "summary-json", "", "JSON summary path (default: output-dir/plan-corpus-summary.json)") + flag.StringVar(&cfg.Connection, "connection", os.Getenv("CONNECTION_STRING"), "single backend connection string") + flag.StringVar(&cfg.PGConnection, "pg-connection", os.Getenv("PG_CONNECTION_STRING"), "PostgreSQL connection string") + flag.StringVar(&cfg.Neo4jConnection, "neo4j-connection", os.Getenv("NEO4J_CONNECTION_STRING"), "Neo4j connection string") + flag.IntVar(&cfg.TopPlans, "top", defaultTopPlans, "number of expensive PostgreSQL plans to include in summaries") + flag.Parse() + + if err := run(context.Background(), cfg); err != nil { + fmt.Fprintf(os.Stderr, "plancorpus: %v\n", err) + os.Exit(1) + } +} + +func run(ctx context.Context, cfg commandConfig) error { + specs, err := captureSpecs(cfg) + if err != nil { + return err + } + + suite, err := loadCorpus(cfg.DatasetDir) + if err != nil { + return err + } + + if err := os.MkdirAll(cfg.OutputDir, 0755); err != nil { + return fmt.Errorf("create output directory: %w", err) + } + + var allRecords []PlanRecord + for _, spec := range specs { + records, err := captureCorpus(ctx, cfg.DatasetDir, suite, spec) + if err != nil { + return err + } + + outputPath := filepath.Join(cfg.OutputDir, "plan-corpus-"+spec.DriverName+".jsonl") + if err := writePlanRecords(outputPath, records); err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "captured %d %s records in %s\n", len(records), spec.DriverName, outputPath) + allRecords = append(allRecords, records...) + } + + summary := buildSummary(allRecords, cfg.TopPlans) + if cfg.SummaryMarkdown == "" { + cfg.SummaryMarkdown = filepath.Join(cfg.OutputDir, "plan-corpus-summary.md") + } + if cfg.SummaryJSON == "" { + cfg.SummaryJSON = filepath.Join(cfg.OutputDir, "plan-corpus-summary.json") + } + + if err := writeSummaryFiles(cfg.SummaryMarkdown, cfg.SummaryJSON, summary); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "wrote summaries to %s and %s\n", cfg.SummaryMarkdown, cfg.SummaryJSON) + return nil +} + +func captureSpecs(cfg commandConfig) ([]captureSpec, error) { + specsByDriver := map[string]captureSpec{} + + if cfg.Connection != "" { + driverName, err := driverFromConnectionString(cfg.Connection) + if err != nil { + return nil, err + } + specsByDriver[driverName] = captureSpec{ + DriverName: driverName, + Connection: cfg.Connection, + } + } + + if cfg.PGConnection != "" { + specsByDriver[pgDriverName()] = captureSpec{ + DriverName: pgDriverName(), + Connection: cfg.PGConnection, + } + } + if cfg.Neo4jConnection != "" { + specsByDriver[neo4jDriverName()] = captureSpec{ + DriverName: neo4jDriverName(), + Connection: cfg.Neo4jConnection, + } + } + + if len(specsByDriver) == 0 { + return nil, fmt.Errorf("no connection string supplied; set CONNECTION_STRING or PG_CONNECTION_STRING/NEO4J_CONNECTION_STRING") + } + + orderedDrivers := []string{pgDriverName(), neo4jDriverName()} + specs := make([]captureSpec, 0, len(specsByDriver)) + for _, driverName := range orderedDrivers { + if spec, found := specsByDriver[driverName]; found { + specs = append(specs, spec) + } + } + return specs, nil +} + +func pgDriverName() string { + return "pg" +} + +func neo4jDriverName() string { + return "neo4j" +} + +func writePlanRecords(path string, records []PlanRecord) error { + out, err := os.Create(path) + if err != nil { + return fmt.Errorf("create %s: %w", path, err) + } + defer out.Close() + + encoder := json.NewEncoder(out) + for _, record := range records { + if err := encoder.Encode(record); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + } + return nil +} + +func writeSummaryFiles(markdownPath, jsonPath string, summary PlanSummary) error { + if markdownPath != "" { + out, err := os.Create(markdownPath) + if err != nil { + return fmt.Errorf("create %s: %w", markdownPath, err) + } + if err := writeMarkdownSummary(out, summary); err != nil { + _ = out.Close() + return fmt.Errorf("write %s: %w", markdownPath, err) + } + if err := out.Close(); err != nil { + return fmt.Errorf("close %s: %w", markdownPath, err) + } + } + + if jsonPath != "" { + out, err := os.Create(jsonPath) + if err != nil { + return fmt.Errorf("create %s: %w", jsonPath, err) + } + if err := writeJSONSummary(out, summary); err != nil { + _ = out.Close() + return fmt.Errorf("write %s: %w", jsonPath, err) + } + if err := out.Close(); err != nil { + return fmt.Errorf("close %s: %w", jsonPath, err) + } + } + + return nil +} diff --git a/cmd/plancorpus/main_test.go b/cmd/plancorpus/main_test.go new file mode 100644 index 00000000..51aa5af9 --- /dev/null +++ b/cmd/plancorpus/main_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCaptureSpecs(t *testing.T) { + specs, err := captureSpecs(commandConfig{ + Connection: "neo4j://neo4j:password@localhost:7687", + PGConnection: "postgres://postgres:password@localhost/db", + Neo4jConnection: "neo4j://neo4j:override@localhost:7687", + }) + require.NoError(t, err) + require.Equal(t, []captureSpec{{ + DriverName: "pg", + Connection: "postgres://postgres:password@localhost/db", + }, { + DriverName: "neo4j", + Connection: "neo4j://neo4j:override@localhost:7687", + }}, specs) +} + +func TestCaptureSpecsRequiresConnection(t *testing.T) { + _, err := captureSpecs(commandConfig{}) + require.ErrorContains(t, err, "no connection string supplied") +} + +func TestDriverFromConnectionString(t *testing.T) { + driverName, err := driverFromConnectionString("postgresql://postgres:password@localhost/db") + require.NoError(t, err) + require.Equal(t, "pg", driverName) + + driverName, err = driverFromConnectionString("neo4j://neo4j:password@localhost:7687") + require.NoError(t, err) + require.Equal(t, "neo4j", driverName) + + _, err = driverFromConnectionString("mysql://localhost") + require.ErrorContains(t, err, "unknown connection string scheme") +} diff --git a/cmd/plancorpus/report.go b/cmd/plancorpus/report.go new file mode 100644 index 00000000..bbd95eac --- /dev/null +++ b/cmd/plancorpus/report.go @@ -0,0 +1,279 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "regexp" + "sort" + "strconv" + "strings" +) + +const defaultTopPlans = 25 + +var postgresCostPattern = regexp.MustCompile(`cost=[0-9.]+\.\.([0-9.]+)`) + +type PlanSummary struct { + Drivers []DriverSummary `json:"drivers"` + TopPostgresPlans []CostedPlan `json:"top_postgres_plans,omitempty"` + PostgresOperators []Count `json:"postgres_operators,omitempty"` + Neo4jOperators []Count `json:"neo4j_operators,omitempty"` + PlannedLowerings []Count `json:"planned_lowerings,omitempty"` + AppliedLowerings []Count `json:"applied_lowerings,omitempty"` + FeatureCounts []Count `json:"feature_counts,omitempty"` + Errors []PlanError `json:"errors,omitempty"` +} + +type DriverSummary struct { + Driver string `json:"driver"` + Records int `json:"records"` + Errors int `json:"errors"` +} + +type Count struct { + Name string `json:"name"` + Count int `json:"count"` +} + +type CostedPlan struct { + Cost float64 `json:"cost"` + Driver string `json:"driver"` + Source string `json:"source"` + Dataset string `json:"dataset,omitempty"` + Name string `json:"name"` + Cypher string `json:"cypher"` + PlanRoot string `json:"plan_root"` + PlannedLowerings []string `json:"planned_lowerings,omitempty"` + AppliedLowerings []string `json:"applied_lowerings,omitempty"` +} + +type PlanError struct { + Driver string `json:"driver"` + Source string `json:"source"` + Name string `json:"name"` + Error string `json:"error"` +} + +func buildSummary(records []PlanRecord, topN int) PlanSummary { + if topN <= 0 { + topN = defaultTopPlans + } + + driverCounts := map[string]*DriverSummary{} + postgresOperatorCounts := map[string]int{} + neo4jOperatorCounts := map[string]int{} + plannedLoweringCounts := map[string]int{} + appliedLoweringCounts := map[string]int{} + featureCounts := map[string]int{} + + var ( + errors []PlanError + topPG []CostedPlan + ) + + for _, record := range records { + driver := driverCounts[record.Driver] + if driver == nil { + driver = &DriverSummary{Driver: record.Driver} + driverCounts[record.Driver] = driver + } + driver.Records++ + + if record.Error != "" { + driver.Errors++ + errors = append(errors, PlanError{ + Driver: record.Driver, + Source: record.Source, + Name: record.Name, + Error: record.Error, + }) + } + + for _, operator := range record.PGOperators { + postgresOperatorCounts[normalizePostgresOperator(operator)]++ + } + for _, operator := range record.Neo4jOperators { + neo4jOperatorCounts[operator]++ + } + for _, lowering := range record.PlannedLowerings { + plannedLoweringCounts[lowering]++ + } + for _, lowering := range record.AppliedLowerings { + appliedLoweringCounts[lowering]++ + } + + for _, line := range record.PGPlan { + switch { + case strings.Contains(line, "Recursive Union"): + featureCounts["PostgreSQL Recursive Union"]++ + case strings.Contains(line, "Function Scan on unnest"): + featureCounts["PostgreSQL Function Scan on unnest"]++ + case strings.Contains(line, "SubPlan "): + featureCounts["PostgreSQL SubPlan"]++ + case strings.Contains(line, "Filter: satisfied"): + featureCounts["PostgreSQL traversal satisfied filter"]++ + } + } + + if len(record.PGPlan) > 0 && record.Error == "" { + topPG = append(topPG, CostedPlan{ + Cost: postgresEstimatedCost(record.PGPlan[0]), + Driver: record.Driver, + Source: record.Source, + Dataset: record.Dataset, + Name: record.Name, + Cypher: record.Cypher, + PlanRoot: record.PGPlan[0], + PlannedLowerings: append([]string(nil), record.PlannedLowerings...), + AppliedLowerings: append([]string(nil), record.AppliedLowerings...), + }) + } + } + + sort.Slice(topPG, func(i, j int) bool { + return topPG[i].Cost > topPG[j].Cost + }) + if len(topPG) > topN { + topPG = topPG[:topN] + } + + return PlanSummary{ + Drivers: sortedDriverSummaries(driverCounts), + TopPostgresPlans: topPG, + PostgresOperators: sortedCounts(postgresOperatorCounts), + Neo4jOperators: sortedCounts(neo4jOperatorCounts), + PlannedLowerings: sortedCounts(plannedLoweringCounts), + AppliedLowerings: sortedCounts(appliedLoweringCounts), + FeatureCounts: sortedCounts(featureCounts), + Errors: errors, + } +} + +func postgresEstimatedCost(planRoot string) float64 { + match := postgresCostPattern.FindStringSubmatch(planRoot) + if len(match) != 2 { + return 0 + } + + cost, err := strconv.ParseFloat(match[1], 64) + if err != nil { + return 0 + } + return cost +} + +func normalizePostgresOperator(operator string) string { + operator = strings.TrimSpace(operator) + if operator == "" { + return "" + } + if idx := strings.Index(operator, ":"); idx >= 0 { + return operator[:idx] + } + if idx := strings.Index(operator, " on "); idx >= 0 { + return operator[:idx] + } + if idx := strings.Index(operator, " using "); idx >= 0 { + return operator[:idx] + } + return operator +} + +func sortedDriverSummaries(drivers map[string]*DriverSummary) []DriverSummary { + sorted := make([]DriverSummary, 0, len(drivers)) + for _, summary := range drivers { + sorted = append(sorted, *summary) + } + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Driver < sorted[j].Driver + }) + return sorted +} + +func sortedCounts(counts map[string]int) []Count { + sorted := make([]Count, 0, len(counts)) + for name, count := range counts { + if name == "" || count == 0 { + continue + } + sorted = append(sorted, Count{Name: name, Count: count}) + } + sort.Slice(sorted, func(i, j int) bool { + if sorted[i].Count == sorted[j].Count { + return sorted[i].Name < sorted[j].Name + } + return sorted[i].Count > sorted[j].Count + }) + return sorted +} + +func writeJSONSummary(w io.Writer, summary PlanSummary) error { + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + return encoder.Encode(summary) +} + +func writeMarkdownSummary(w io.Writer, summary PlanSummary) error { + writeCounts := func(title string, counts []Count, limit int) { + if len(counts) == 0 { + return + } + fmt.Fprintf(w, "\n## %s\n\n| Name | Count |\n| --- | ---: |\n", title) + for idx, count := range counts { + if limit > 0 && idx >= limit { + break + } + fmt.Fprintf(w, "| %s | %d |\n", markdownCell(count.Name), count.Count) + } + } + + fmt.Fprintln(w, "# Cypher Plan Corpus Summary") + fmt.Fprintln(w, "\n## Drivers\n\n| Driver | Records | Errors |\n| --- | ---: | ---: |") + for _, driver := range summary.Drivers { + fmt.Fprintf(w, "| %s | %d | %d |\n", markdownCell(driver.Driver), driver.Records, driver.Errors) + } + + if len(summary.TopPostgresPlans) > 0 { + fmt.Fprintln(w, "\n## Top PostgreSQL Plans\n\n| Cost | Source | Name | Root | Lowerings |\n| ---: | --- | --- | --- | --- |") + for _, plan := range summary.TopPostgresPlans { + fmt.Fprintf( + w, + "| %.2f | %s | %s | %s | %s |\n", + plan.Cost, + markdownCell(plan.Source), + markdownCell(plan.Name), + markdownCell(plan.PlanRoot), + markdownCell(strings.Join(plan.PlannedLowerings, ", ")), + ) + } + } + + writeCounts("Feature Counts", summary.FeatureCounts, 0) + writeCounts("Planned Lowerings", summary.PlannedLowerings, 0) + writeCounts("Applied Lowerings", summary.AppliedLowerings, 0) + writeCounts("PostgreSQL Operators", summary.PostgresOperators, 25) + writeCounts("Neo4j Operators", summary.Neo4jOperators, 25) + + if len(summary.Errors) > 0 { + fmt.Fprintln(w, "\n## Capture Errors\n\n| Driver | Source | Name | Error |\n| --- | --- | --- | --- |") + for _, captureError := range summary.Errors { + fmt.Fprintf( + w, + "| %s | %s | %s | %s |\n", + markdownCell(captureError.Driver), + markdownCell(captureError.Source), + markdownCell(captureError.Name), + markdownCell(captureError.Error), + ) + } + } + + return nil +} + +func markdownCell(value string) string { + value = strings.ReplaceAll(value, "\n", " ") + value = strings.ReplaceAll(value, "|", "\\|") + return value +} diff --git a/cmd/plancorpus/report_test.go b/cmd/plancorpus/report_test.go new file mode 100644 index 00000000..9ec4907e --- /dev/null +++ b/cmd/plancorpus/report_test.go @@ -0,0 +1,88 @@ +package main + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBuildSummaryRanksPostgresPlansAndCountsSignals(t *testing.T) { + records := []PlanRecord{{ + Driver: "pg", + Source: "cases/a.json", + Name: "low", + Cypher: "match (n) return n", + PGPlan: []string{"Seq Scan on node_1 (cost=0.00..10.50 rows=1 width=8)", "Filter: satisfied"}, + PGOperators: []string{"Seq Scan on node_1", "Filter: satisfied"}, + PlannedLowerings: []string{"ProjectionPruning"}, + AppliedLowerings: []string{"ProjectionPruning"}, + }, { + Driver: "pg", + Source: "cases/b.json", + Name: "high", + Cypher: "match p=()-[*]->() return p", + PGPlan: []string{"Recursive Union (cost=0.00..99.25 rows=1 width=8)", "SubPlan 1", "Function Scan on unnest _path"}, + PGOperators: []string{"Recursive Union", "Function Scan on unnest _path"}, + PlannedLowerings: []string{"LatePathMaterialization"}, + AppliedLowerings: []string{"LatePathMaterialization"}, + }, { + Driver: "neo4j", + Source: "cases/a.json", + Name: "neo", + Cypher: "match (n) return n", + Neo4jOperators: []string{"ProduceResults@neo4j", "AllNodesScan@neo4j"}, + }, { + Driver: "pg", + Source: "cases/error.json", + Name: "error", + Error: "expected error", + }} + + summary := buildSummary(records, 1) + + require.Equal(t, []DriverSummary{{ + Driver: "neo4j", + Records: 1, + }, { + Driver: "pg", + Records: 3, + Errors: 1, + }}, summary.Drivers) + require.Len(t, summary.TopPostgresPlans, 1) + require.Equal(t, "high", summary.TopPostgresPlans[0].Name) + require.Contains(t, summary.FeatureCounts, Count{Name: "PostgreSQL Recursive Union", Count: 1}) + require.Contains(t, summary.FeatureCounts, Count{Name: "PostgreSQL SubPlan", Count: 1}) + require.Contains(t, summary.FeatureCounts, Count{Name: "PostgreSQL Function Scan on unnest", Count: 1}) + require.Contains(t, summary.FeatureCounts, Count{Name: "PostgreSQL traversal satisfied filter", Count: 1}) + require.Contains(t, summary.PostgresOperators, Count{Name: "Seq Scan", Count: 1}) + require.Contains(t, summary.Neo4jOperators, Count{Name: "ProduceResults@neo4j", Count: 1}) + require.Contains(t, summary.PlannedLowerings, Count{Name: "LatePathMaterialization", Count: 1}) + require.Contains(t, summary.Errors, PlanError{ + Driver: "pg", + Source: "cases/error.json", + Name: "error", + Error: "expected error", + }) +} + +func TestWriteMarkdownSummaryEscapesPipes(t *testing.T) { + summary := PlanSummary{ + Drivers: []DriverSummary{{Driver: "pg", Records: 1}}, + TopPostgresPlans: []CostedPlan{{ + Cost: 1.25, + Source: "cases/a.json", + Name: "pipe | name", + PlanRoot: "Seq Scan on node_1", + }}, + } + + var out bytes.Buffer + require.NoError(t, writeMarkdownSummary(&out, summary)) + require.Contains(t, out.String(), "pipe \\| name") +} + +func TestPostgresEstimatedCost(t *testing.T) { + require.Equal(t, 1180526.82, postgresEstimatedCost("Hash Join (cost=4136.05..1180526.82 rows=32097 width=68)")) + require.Zero(t, postgresEstimatedCost("not a plan")) +} diff --git a/cmd/plancorpus/types.go b/cmd/plancorpus/types.go new file mode 100644 index 00000000..3bc336d8 --- /dev/null +++ b/cmd/plancorpus/types.go @@ -0,0 +1,36 @@ +package main + +import "github.com/specterops/dawgs/cypher/models/pgsql/translate" + +type PlanRecord struct { + Driver string `json:"driver"` + Source string `json:"source"` + Dataset string `json:"dataset,omitempty"` + Name string `json:"name"` + Cypher string `json:"cypher"` + Params map[string]any `json:"params,omitempty"` + SQL string `json:"sql,omitempty"` + PGPlan []string `json:"pg_plan,omitempty"` + PGOperators []string `json:"pg_operators,omitempty"` + Neo4jPlan *Neo4jPlanNode `json:"neo4j_plan,omitempty"` + Neo4jOperators []string `json:"neo4j_operators,omitempty"` + PlannedLowerings []string `json:"planned_lowerings,omitempty"` + AppliedLowerings []string `json:"applied_lowerings,omitempty"` + Optimization *translate.OptimizationSummary `json:"optimization,omitempty"` + Error string `json:"error,omitempty"` +} + +type Neo4jPlanNode struct { + Operator string `json:"operator"` + Arguments map[string]string `json:"arguments,omitempty"` + Identifiers []string `json:"identifiers,omitempty"` + Children []Neo4jPlanNode `json:"children,omitempty"` +} + +type CorpusQuery struct { + Source string + Dataset string + Name string + Cypher string + Params map[string]any +} diff --git a/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md b/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md new file mode 100644 index 00000000..1f064f50 --- /dev/null +++ b/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md @@ -0,0 +1,74 @@ +# Cypher to PostgreSQL Optimization Plan + +This plan tracks optimization and rewrite work identified by running the shared integration corpus against Neo4j and PostgreSQL and comparing plan shapes. + +## Phase 1: Baseline And Tooling + +Status: in progress + +- Keep a reproducible plan-capture workflow. + - Capture PostgreSQL translated SQL, PostgreSQL `EXPLAIN`, Neo4j logical plan operator trees, and optimizer planned/applied lowerings. + - Read `integration/testdata/cases` and `integration/testdata/templates`. + - Write comparable JSONL output without changing product behavior. +- Add plan-summary reporting. + - Rank cases by PostgreSQL estimated cost. + - Count plan operators, recursive CTEs, subplans, path materialization indicators, and optimizer lowerings. + - Produce markdown and JSON summaries. + +## Phase 2: Quick Wins + +Status: pending + +- Add count-store fast paths for simple count queries: + - `MATCH (n) RETURN count(n)` + - `MATCH ()-[r]->() RETURN count(r)` + - Typed variants where kind filters map cleanly. +- Audit the planned/applied `PredicatePlacement` gap. + - Distinguish missing translator consumption from intentional skipped placements. + - Add explicit skipped-placement reasons when a planned lowering is not applied. + +## Phase 3: Path Materialization + +Status: pending + +- Share path materialization for repeated path functions. + - Target `nodes(p)`, `relationships(p)`, `size(relationships(p))`, `startNode`, `endNode`, and `type`. + - Avoid repeated `SubPlan` and `Function Scan on unnest` work per path binding. +- Expand late path materialization coverage. + - Ensure paths are built only when needed for projection, filtering, or mutation semantics. + +## Phase 4: Traversal And Recursive CTEs + +Status: pending + +- Push predicates into recursive traversal anchors and steps where semantics allow. + - Endpoint kind/property predicates. + - Relationship type predicates. + - Bound-node filters. +- Improve traversal direction selection using endpoint selectivity. + - Bound IDs. + - Labels/kinds. + - Equality predicates. + - Finite relationship type sets. +- Broaden limit pushdown for variable-length path queries when ordering and distinct semantics permit early termination. + +## Phase 5: Suffix And Shared Endpoint Rewrites + +Status: pending + +- Improve expansion suffix pushdown for fixed suffixes after variable-length traversals. +- Improve `ExpandInto` and shared endpoint rewrites for ADCS-style fanout patterns. + - Constrain earlier using bound endpoint semi-joins or correlated expansion lowering where valid. + +## Phase 6: Validation + +Status: pending + +- Add focused regression tests per optimization. + - Optimizer/lowering selection tests. + - SQL shape translation tests. + - Backend-equivalent integration tests. +- Benchmark after each workstream. + - Run unit tests. + - Run backend-specific integration tests. + - Run plan capture and compare summary deltas. From 82a75e344706652d5416922667e2e464be3e7550 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Fri, 22 May 2026 16:26:29 -0700 Subject: [PATCH 050/116] Add count fast path and skipped lowering reporting --- cmd/plancorpus/capture.go | 1 + cmd/plancorpus/report.go | 29 +++ cmd/plancorpus/report_test.go | 8 + cmd/plancorpus/types.go | 1 + .../pgsql/optimize/OPTIMIZATION_PLAN.md | 6 +- cypher/models/pgsql/optimize/lowering.go | 22 +- cypher/models/pgsql/optimize/lowering_plan.go | 138 ++++++++++ .../models/pgsql/optimize/optimizer_test.go | 49 ++++ .../translation_cases/stepwise_traversal.sql | 2 +- .../models/pgsql/translate/count_fast_path.go | 243 ++++++++++++++++++ .../pgsql/translate/optimizer_safety_test.go | 52 ++++ cypher/models/pgsql/translate/translator.go | 70 +++++ 12 files changed, 617 insertions(+), 4 deletions(-) create mode 100644 cypher/models/pgsql/translate/count_fast_path.go diff --git a/cmd/plancorpus/capture.go b/cmd/plancorpus/capture.go index 6b4e264e..e69802fb 100644 --- a/cmd/plancorpus/capture.go +++ b/cmd/plancorpus/capture.go @@ -292,6 +292,7 @@ func (s *backendCapture) capturePostgres(ctx context.Context, cypherQuery string record.PGOperators = postgresOperators(plan) record.PlannedLowerings = loweringNames(translation.Optimization.PlannedLowerings) record.AppliedLowerings = loweringNames(translation.Optimization.Lowerings) + record.SkippedLowerings = append([]translate.SkippedLowering(nil), translation.Optimization.SkippedLowerings...) record.Optimization = &translation.Optimization } diff --git a/cmd/plancorpus/report.go b/cmd/plancorpus/report.go index bbd95eac..d0a28f4a 100644 --- a/cmd/plancorpus/report.go +++ b/cmd/plancorpus/report.go @@ -8,6 +8,8 @@ import ( "sort" "strconv" "strings" + + "github.com/specterops/dawgs/cypher/models/pgsql/translate" ) const defaultTopPlans = 25 @@ -21,6 +23,8 @@ type PlanSummary struct { Neo4jOperators []Count `json:"neo4j_operators,omitempty"` PlannedLowerings []Count `json:"planned_lowerings,omitempty"` AppliedLowerings []Count `json:"applied_lowerings,omitempty"` + SkippedLowerings []Count `json:"skipped_lowerings,omitempty"` + SkippedReasons []Count `json:"skipped_reasons,omitempty"` FeatureCounts []Count `json:"feature_counts,omitempty"` Errors []PlanError `json:"errors,omitempty"` } @@ -46,6 +50,7 @@ type CostedPlan struct { PlanRoot string `json:"plan_root"` PlannedLowerings []string `json:"planned_lowerings,omitempty"` AppliedLowerings []string `json:"applied_lowerings,omitempty"` + SkippedLowerings []string `json:"skipped_lowerings,omitempty"` } type PlanError struct { @@ -65,6 +70,8 @@ func buildSummary(records []PlanRecord, topN int) PlanSummary { neo4jOperatorCounts := map[string]int{} plannedLoweringCounts := map[string]int{} appliedLoweringCounts := map[string]int{} + skippedLoweringCounts := map[string]int{} + skippedReasonCounts := map[string]int{} featureCounts := map[string]int{} var ( @@ -102,6 +109,10 @@ func buildSummary(records []PlanRecord, topN int) PlanSummary { for _, lowering := range record.AppliedLowerings { appliedLoweringCounts[lowering]++ } + for _, lowering := range record.SkippedLowerings { + skippedLoweringCounts[lowering.Name]++ + skippedReasonCounts[lowering.Name+": "+lowering.Reason]++ + } for _, line := range record.PGPlan { switch { @@ -127,6 +138,7 @@ func buildSummary(records []PlanRecord, topN int) PlanSummary { PlanRoot: record.PGPlan[0], PlannedLowerings: append([]string(nil), record.PlannedLowerings...), AppliedLowerings: append([]string(nil), record.AppliedLowerings...), + SkippedLowerings: skippedLoweringLabels(record.SkippedLowerings), }) } } @@ -145,11 +157,26 @@ func buildSummary(records []PlanRecord, topN int) PlanSummary { Neo4jOperators: sortedCounts(neo4jOperatorCounts), PlannedLowerings: sortedCounts(plannedLoweringCounts), AppliedLowerings: sortedCounts(appliedLoweringCounts), + SkippedLowerings: sortedCounts(skippedLoweringCounts), + SkippedReasons: sortedCounts(skippedReasonCounts), FeatureCounts: sortedCounts(featureCounts), Errors: errors, } } +func skippedLoweringLabels(lowerings []translate.SkippedLowering) []string { + if len(lowerings) == 0 { + return nil + } + + labels := make([]string, len(lowerings)) + for idx, lowering := range lowerings { + labels[idx] = lowering.Name + ": " + lowering.Reason + } + + return labels +} + func postgresEstimatedCost(planRoot string) float64 { match := postgresCostPattern.FindStringSubmatch(planRoot) if len(match) != 2 { @@ -252,6 +279,8 @@ func writeMarkdownSummary(w io.Writer, summary PlanSummary) error { writeCounts("Feature Counts", summary.FeatureCounts, 0) writeCounts("Planned Lowerings", summary.PlannedLowerings, 0) writeCounts("Applied Lowerings", summary.AppliedLowerings, 0) + writeCounts("Skipped Lowerings", summary.SkippedLowerings, 0) + writeCounts("Skipped Lowering Reasons", summary.SkippedReasons, 0) writeCounts("PostgreSQL Operators", summary.PostgresOperators, 25) writeCounts("Neo4j Operators", summary.Neo4jOperators, 25) diff --git a/cmd/plancorpus/report_test.go b/cmd/plancorpus/report_test.go index 9ec4907e..f3616952 100644 --- a/cmd/plancorpus/report_test.go +++ b/cmd/plancorpus/report_test.go @@ -4,6 +4,7 @@ import ( "bytes" "testing" + "github.com/specterops/dawgs/cypher/models/pgsql/translate" "github.com/stretchr/testify/require" ) @@ -26,6 +27,11 @@ func TestBuildSummaryRanksPostgresPlansAndCountsSignals(t *testing.T) { PGOperators: []string{"Recursive Union", "Function Scan on unnest _path"}, PlannedLowerings: []string{"LatePathMaterialization"}, AppliedLowerings: []string{"LatePathMaterialization"}, + SkippedLowerings: []translate.SkippedLowering{{ + Name: "PredicatePlacement", + Reason: "planned predicate placements were not consumed by this translation shape", + Count: 2, + }}, }, { Driver: "neo4j", Source: "cases/a.json", @@ -58,6 +64,8 @@ func TestBuildSummaryRanksPostgresPlansAndCountsSignals(t *testing.T) { require.Contains(t, summary.PostgresOperators, Count{Name: "Seq Scan", Count: 1}) require.Contains(t, summary.Neo4jOperators, Count{Name: "ProduceResults@neo4j", Count: 1}) require.Contains(t, summary.PlannedLowerings, Count{Name: "LatePathMaterialization", Count: 1}) + require.Contains(t, summary.SkippedLowerings, Count{Name: "PredicatePlacement", Count: 1}) + require.Contains(t, summary.SkippedReasons, Count{Name: "PredicatePlacement: planned predicate placements were not consumed by this translation shape", Count: 1}) require.Contains(t, summary.Errors, PlanError{ Driver: "pg", Source: "cases/error.json", diff --git a/cmd/plancorpus/types.go b/cmd/plancorpus/types.go index 3bc336d8..9c4fa662 100644 --- a/cmd/plancorpus/types.go +++ b/cmd/plancorpus/types.go @@ -16,6 +16,7 @@ type PlanRecord struct { Neo4jOperators []string `json:"neo4j_operators,omitempty"` PlannedLowerings []string `json:"planned_lowerings,omitempty"` AppliedLowerings []string `json:"applied_lowerings,omitempty"` + SkippedLowerings []translate.SkippedLowering `json:"skipped_lowerings,omitempty"` Optimization *translate.OptimizationSummary `json:"optimization,omitempty"` Error string `json:"error,omitempty"` } diff --git a/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md b/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md index 1f064f50..d53874ee 100644 --- a/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md +++ b/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md @@ -4,7 +4,7 @@ This plan tracks optimization and rewrite work identified by running the shared ## Phase 1: Baseline And Tooling -Status: in progress +Status: completed - Keep a reproducible plan-capture workflow. - Capture PostgreSQL translated SQL, PostgreSQL `EXPLAIN`, Neo4j logical plan operator trees, and optimizer planned/applied lowerings. @@ -17,15 +17,17 @@ Status: in progress ## Phase 2: Quick Wins -Status: pending +Status: completed - Add count-store fast paths for simple count queries: - `MATCH (n) RETURN count(n)` - `MATCH ()-[r]->() RETURN count(r)` - Typed variants where kind filters map cleanly. + - Implemented as `CountStoreFastPath` lowering for exact node and directed-edge count shapes. - Audit the planned/applied `PredicatePlacement` gap. - Distinguish missing translator consumption from intentional skipped placements. - Add explicit skipped-placement reasons when a planned lowering is not applied. + - Plan-corpus summaries now report skipped lowerings and skipped-lowering reasons. ## Phase 3: Path Materialization diff --git a/cypher/models/pgsql/optimize/lowering.go b/cypher/models/pgsql/optimize/lowering.go index defdb948..d4d5b709 100644 --- a/cypher/models/pgsql/optimize/lowering.go +++ b/cypher/models/pgsql/optimize/lowering.go @@ -12,6 +12,7 @@ const ( LoweringLimitPushdown = "LimitPushdown" LoweringExpansionSuffixPushdown = "ExpansionSuffixPushdown" LoweringPredicatePlacement = "PredicatePlacement" + LoweringCountStoreFastPath = "CountStoreFastPath" ) type LoweringDecision struct { @@ -142,6 +143,22 @@ type PatternPredicatePlacementDecision struct { Mode PatternPredicatePlacementMode `json:"mode"` } +type CountStoreFastPathTarget string + +const ( + CountStoreFastPathNode CountStoreFastPathTarget = "node" + CountStoreFastPathEdge CountStoreFastPathTarget = "edge" +) + +type CountStoreFastPathDecision struct { + QueryPartIndex int `json:"query_part_index"` + ClauseIndex int `json:"clause_index"` + PatternIndex int `json:"pattern_index"` + BindingSymbol string `json:"binding_symbol,omitempty"` + Target CountStoreFastPathTarget `json:"target"` + KindSymbols []string `json:"kind_symbols,omitempty"` +} + type LoweringPlan struct { ProjectionPruning []ProjectionPruningDecision `json:"projection_pruning,omitempty"` LatePathMaterialization []LatePathMaterializationDecision `json:"late_path_materialization,omitempty"` @@ -153,6 +170,7 @@ type LoweringPlan struct { ExpansionSuffixPushdown []ExpansionSuffixPushdownDecision `json:"expansion_suffix_pushdown,omitempty"` PredicatePlacement []PredicatePlacementDecision `json:"predicate_placement,omitempty"` PatternPredicate []PatternPredicatePlacementDecision `json:"pattern_predicate_placement,omitempty"` + CountStoreFastPath []CountStoreFastPathDecision `json:"count_store_fast_path,omitempty"` } func (s LoweringPlan) Empty() bool { @@ -165,7 +183,8 @@ func (s LoweringPlan) Empty() bool { len(s.LimitPushdown) == 0 && len(s.ExpansionSuffixPushdown) == 0 && len(s.PredicatePlacement) == 0 && - len(s.PatternPredicate) == 0 + len(s.PatternPredicate) == 0 && + len(s.CountStoreFastPath) == 0 } func (s LoweringPlan) Decisions() []LoweringDecision { @@ -185,6 +204,7 @@ func (s LoweringPlan) Decisions() []LoweringDecision { add(LoweringLimitPushdown, len(s.LimitPushdown) > 0) add(LoweringExpansionSuffixPushdown, len(s.ExpansionSuffixPushdown) > 0) add(LoweringPredicatePlacement, len(s.PredicatePlacement) > 0 || len(s.PatternPredicate) > 0) + add(LoweringCountStoreFastPath, len(s.CountStoreFastPath) > 0) return decisions } diff --git a/cypher/models/pgsql/optimize/lowering_plan.go b/cypher/models/pgsql/optimize/lowering_plan.go index b40dee40..9ed9329f 100644 --- a/cypher/models/pgsql/optimize/lowering_plan.go +++ b/cypher/models/pgsql/optimize/lowering_plan.go @@ -1,6 +1,8 @@ package optimize import ( + "strings" + "github.com/specterops/dawgs/cypher/models/cypher" "github.com/specterops/dawgs/graph" ) @@ -53,6 +55,7 @@ func BuildLoweringPlan(query *cypher.RegularQuery, predicateAttachments []Predic appendPredicatePlacementDecisions(&plan, query, predicateAttachments) attachPredicatePlacementsToSuffixPushdowns(&plan) + appendCountStoreFastPathDecisions(&plan, query) return plan, nil } @@ -807,6 +810,141 @@ func attachPredicatePlacementsToSuffixPushdowns(plan *LoweringPlan) { } } +func appendCountStoreFastPathDecisions(plan *LoweringPlan, query *cypher.RegularQuery) { + if decision, ok := countStoreFastPathDecision(query); ok { + plan.CountStoreFastPath = append(plan.CountStoreFastPath, decision) + } +} + +func countStoreFastPathDecision(query *cypher.RegularQuery) (CountStoreFastPathDecision, bool) { + if query == nil || query.SingleQuery == nil || query.SingleQuery.SinglePartQuery == nil { + return CountStoreFastPathDecision{}, false + } + + queryPart := query.SingleQuery.SinglePartQuery + if len(queryPart.UpdatingClauses) > 0 || len(queryPart.ReadingClauses) != 1 { + return CountStoreFastPathDecision{}, false + } + + countArgument, ok := simpleCountProjectionArgument(queryPart.Return) + if !ok { + return CountStoreFastPathDecision{}, false + } + + readingClause := queryPart.ReadingClauses[0] + if readingClause == nil || readingClause.Match == nil { + return CountStoreFastPathDecision{}, false + } + + match := readingClause.Match + if match.Optional || match.Where != nil || len(match.Pattern) != 1 { + return CountStoreFastPathDecision{}, false + } + + patternPart := match.Pattern[0] + if patternPart == nil || patternPart.Variable != nil || patternPart.ShortestPathPattern || patternPart.AllShortestPathsPattern { + return CountStoreFastPathDecision{}, false + } + + if len(patternPart.PatternElements) == 1 { + nodePattern, ok := patternPart.PatternElements[0].AsNodePattern() + if !ok || nodePattern == nil || nodePattern.Properties != nil { + return CountStoreFastPathDecision{}, false + } + + bindingSymbol := variableSymbol(nodePattern.Variable) + if countArgument != cypher.TokenLiteralAsterisk && countArgument != bindingSymbol { + return CountStoreFastPathDecision{}, false + } + + return CountStoreFastPathDecision{ + QueryPartIndex: 0, + ClauseIndex: 0, + PatternIndex: 0, + BindingSymbol: bindingSymbol, + Target: CountStoreFastPathNode, + KindSymbols: kindSymbols(nodePattern.Kinds), + }, true + } + + if len(patternPart.PatternElements) != 3 { + return CountStoreFastPathDecision{}, false + } + + leftNode, leftOK := patternPart.PatternElements[0].AsNodePattern() + relationship, relationshipOK := patternPart.PatternElements[1].AsRelationshipPattern() + rightNode, rightOK := patternPart.PatternElements[2].AsNodePattern() + if !leftOK || !relationshipOK || !rightOK { + return CountStoreFastPathDecision{}, false + } + + if constrainedCountFastPathEndpoint(leftNode) || constrainedCountFastPathEndpoint(rightNode) || + relationship == nil || relationship.Range != nil || relationship.Properties != nil || + relationship.Direction == graph.DirectionBoth { + return CountStoreFastPathDecision{}, false + } + + bindingSymbol := variableSymbol(relationship.Variable) + if countArgument != cypher.TokenLiteralAsterisk && countArgument != bindingSymbol { + return CountStoreFastPathDecision{}, false + } + + return CountStoreFastPathDecision{ + QueryPartIndex: 0, + ClauseIndex: 0, + PatternIndex: 0, + BindingSymbol: bindingSymbol, + Target: CountStoreFastPathEdge, + KindSymbols: kindSymbols(relationship.Kinds), + }, true +} + +func simpleCountProjectionArgument(returnClause *cypher.Return) (string, bool) { + if returnClause == nil || returnClause.Projection == nil { + return "", false + } + + projection := returnClause.Projection + if projection.Distinct || projection.All || projection.Order != nil || projection.Skip != nil || projection.Limit != nil || len(projection.Items) != 1 { + return "", false + } + + projectionItem, ok := projection.Items[0].(*cypher.ProjectionItem) + if !ok || projectionItem == nil { + return "", false + } + + function, ok := projectionItem.Expression.(*cypher.FunctionInvocation) + if !ok || function == nil || !strings.EqualFold(function.Name, cypher.CountFunction) || + function.Distinct || len(function.Namespace) > 0 || len(function.Arguments) != 1 { + return "", false + } + + variable, ok := function.Arguments[0].(*cypher.Variable) + if !ok || variable == nil { + return "", false + } + + return variable.Symbol, true +} + +func constrainedCountFastPathEndpoint(nodePattern *cypher.NodePattern) bool { + return nodePattern == nil || nodePattern.Variable != nil || len(nodePattern.Kinds) > 0 || nodePattern.Properties != nil +} + +func kindSymbols(kinds graph.Kinds) []string { + if len(kinds) == 0 { + return nil + } + + symbols := make([]string, len(kinds)) + for idx, kind := range kinds { + symbols[idx] = kind.String() + } + + return symbols +} + func indexBindingTargets(query *cypher.RegularQuery) map[bindingTargetKey]TraversalStepTarget { targets := map[bindingTargetKey]TraversalStepTarget{} diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index be0a42f1..9e9e3b54 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -390,6 +390,55 @@ func TestLoweringPlanReportsExpansionSuffixPushdown(t *testing.T) { }}, plan.LoweringPlan.ExpansionSuffixPushdown) } +func TestLoweringPlanReportsCountStoreFastPath(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + query string + expected CountStoreFastPathDecision + }{ + { + name: "node count", + query: "MATCH (n:Group) RETURN count(n)", + expected: CountStoreFastPathDecision{ + QueryPartIndex: 0, + ClauseIndex: 0, + PatternIndex: 0, + BindingSymbol: "n", + Target: CountStoreFastPathNode, + KindSymbols: []string{"Group"}, + }, + }, + { + name: "edge count", + query: "MATCH ()-[r:MemberOf]->() RETURN count(r)", + expected: CountStoreFastPathDecision{ + QueryPartIndex: 0, + ClauseIndex: 0, + PatternIndex: 0, + BindingSymbol: "r", + Target: CountStoreFastPathEdge, + KindSymbols: []string{"MemberOf"}, + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), testCase.query) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Contains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringCountStoreFastPath}) + require.Equal(t, []CountStoreFastPathDecision{testCase.expected}, plan.LoweringPlan.CountStoreFastPath) + }) + } +} + func TestLoweringPlanPlacesBindingPredicates(t *testing.T) { t.Parallel() diff --git a/cypher/models/pgsql/test/translation_cases/stepwise_traversal.sql b/cypher/models/pgsql/test/translation_cases/stepwise_traversal.sql index df1fffb5..a33ed6c6 100644 --- a/cypher/models/pgsql/test/translation_cases/stepwise_traversal.sql +++ b/cypher/models/pgsql/test/translation_cases/stepwise_traversal.sql @@ -45,7 +45,7 @@ with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::e with s0 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 [1]::int2[] and n0.id = e0.start_id join node n1 on ('123' = any (jsonb_to_text_array((n1.properties -> 'prop2'))::text[]) or '243' = any (jsonb_to_text_array((n1.properties -> 'prop2'))::text[]) or jsonb_array_length((n1.properties -> 'prop2'))::int = 0) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[]) limit 10) select case when (s0.n0).id is null or s0.e0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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 [s0.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 limit 10; -- case: match ()-[r:EdgeKind1]->() return count(r) as the_count -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 = any (array [3]::int2[])) select count(s0.e0)::int8 as the_count from s0; +select count(*)::int8 as the_count from edge e0 where e0.kind_id = any (array [3]::int2[]); -- case: match ()-[r:EdgeKind1]->() return count(r) as the_count limit 1 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 = any (array [3]::int2[])) select count(s0.e0)::int8 as the_count from s0 limit 1; diff --git a/cypher/models/pgsql/translate/count_fast_path.go b/cypher/models/pgsql/translate/count_fast_path.go new file mode 100644 index 00000000..c3c27cb9 --- /dev/null +++ b/cypher/models/pgsql/translate/count_fast_path.go @@ -0,0 +1,243 @@ +package translate + +import ( + "strings" + + "github.com/specterops/dawgs/cypher/models/cypher" + "github.com/specterops/dawgs/cypher/models/pgsql" + "github.com/specterops/dawgs/cypher/models/pgsql/optimize" + "github.com/specterops/dawgs/graph" +) + +const ( + countStoreNodeAlias pgsql.Identifier = "n0" + countStoreEdgeAlias pgsql.Identifier = "e0" +) + +type countStoreFastPathShape struct { + Target optimize.CountStoreFastPathTarget + Alias string + Kinds graph.Kinds +} + +func (s *Translator) translateCountStoreFastPath(query *cypher.RegularQuery, plan optimize.LoweringPlan) (bool, error) { + if len(plan.CountStoreFastPath) == 0 { + return false, nil + } + + shape, ok := countStoreFastPathShapeForQuery(query) + if !ok || shape.Target != plan.CountStoreFastPath[0].Target { + return false, nil + } + + countExpression := pgsql.FunctionCall{ + Function: pgsql.FunctionCount, + Parameters: []pgsql.Expression{pgsql.Wildcard{}}, + CastType: pgsql.Int8, + } + + var countProjection pgsql.SelectItem = countExpression + if shape.Alias != "" { + countProjection = pgsql.AliasedExpression{ + Expression: countExpression, + Alias: pgsql.AsOptionalIdentifier(pgsql.Identifier(shape.Alias)), + } + } + + fromClause, whereClause, err := s.countStoreFastPathFromAndWhere(shape) + if err != nil { + return false, err + } + + s.translation.Statement = pgsql.Query{ + Body: pgsql.Select{ + Projection: pgsql.Projection{countProjection}, + From: []pgsql.FromClause{fromClause}, + Where: whereClause, + }, + } + s.recordLowering(optimize.LoweringCountStoreFastPath) + return true, nil +} + +func (s *Translator) countStoreFastPathFromAndWhere(shape countStoreFastPathShape) (pgsql.FromClause, pgsql.Expression, error) { + switch shape.Target { + case optimize.CountStoreFastPathNode: + where, err := s.countStoreNodeKindConstraint(shape.Kinds) + return pgsql.FromClause{ + Source: pgsql.TableReference{ + Name: pgsql.TableNode.AsCompoundIdentifier(), + Binding: pgsql.AsOptionalIdentifier(countStoreNodeAlias), + }, + }, where, err + + case optimize.CountStoreFastPathEdge: + where, err := s.countStoreEdgeKindConstraint(shape.Kinds) + return pgsql.FromClause{ + Source: pgsql.TableReference{ + Name: pgsql.TableEdge.AsCompoundIdentifier(), + Binding: pgsql.AsOptionalIdentifier(countStoreEdgeAlias), + }, + }, where, err + + default: + return pgsql.FromClause{}, nil, nil + } +} + +func (s *Translator) countStoreNodeKindConstraint(kinds graph.Kinds) (pgsql.Expression, error) { + if len(kinds) == 0 { + return nil, nil + } + + kindIDs, err := s.kindMapper.MapKinds(kinds) + if err != nil { + return nil, err + } + + kindIDsLiteral, err := pgsql.AsLiteral(kindIDs) + if err != nil { + return nil, err + } + + return pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{countStoreNodeAlias, pgsql.ColumnKindIDs}, + pgsql.OperatorPGArrayLHSContainsRHS, + kindIDsLiteral, + ), nil +} + +func (s *Translator) countStoreEdgeKindConstraint(kinds graph.Kinds) (pgsql.Expression, error) { + if len(kinds) == 0 { + return nil, nil + } + + kindIDs, err := s.kindMapper.MapKinds(kinds) + if err != nil { + return nil, err + } + + return pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{countStoreEdgeAlias, pgsql.ColumnKindID}, + pgsql.OperatorEquals, + pgsql.NewAnyExpressionHinted(pgsql.NewLiteral(kindIDs, pgsql.Int2Array)), + ), nil +} + +func countStoreFastPathShapeForQuery(query *cypher.RegularQuery) (countStoreFastPathShape, bool) { + if query == nil || query.SingleQuery == nil || query.SingleQuery.SinglePartQuery == nil { + return countStoreFastPathShape{}, false + } + + queryPart := query.SingleQuery.SinglePartQuery + if len(queryPart.UpdatingClauses) > 0 || len(queryPart.ReadingClauses) != 1 { + return countStoreFastPathShape{}, false + } + + countArgument, alias, ok := simpleCountProjection(queryPart.Return) + if !ok { + return countStoreFastPathShape{}, false + } + + readingClause := queryPart.ReadingClauses[0] + if readingClause == nil || readingClause.Match == nil { + return countStoreFastPathShape{}, false + } + + match := readingClause.Match + if match.Optional || match.Where != nil || len(match.Pattern) != 1 { + return countStoreFastPathShape{}, false + } + + patternPart := match.Pattern[0] + if patternPart == nil || patternPart.Variable != nil || patternPart.ShortestPathPattern || patternPart.AllShortestPathsPattern { + return countStoreFastPathShape{}, false + } + + if len(patternPart.PatternElements) == 1 { + nodePattern, ok := patternPart.PatternElements[0].AsNodePattern() + if !ok || nodePattern == nil || nodePattern.Properties != nil { + return countStoreFastPathShape{}, false + } + + bindingSymbol := countStoreVariableSymbol(nodePattern.Variable) + if countArgument != cypher.TokenLiteralAsterisk && countArgument != bindingSymbol { + return countStoreFastPathShape{}, false + } + + return countStoreFastPathShape{ + Target: optimize.CountStoreFastPathNode, + Alias: alias, + Kinds: nodePattern.Kinds, + }, true + } + + if len(patternPart.PatternElements) != 3 { + return countStoreFastPathShape{}, false + } + + leftNode, leftOK := patternPart.PatternElements[0].AsNodePattern() + relationship, relationshipOK := patternPart.PatternElements[1].AsRelationshipPattern() + rightNode, rightOK := patternPart.PatternElements[2].AsNodePattern() + if !leftOK || !relationshipOK || !rightOK { + return countStoreFastPathShape{}, false + } + + if constrainedCountStoreEndpoint(leftNode) || constrainedCountStoreEndpoint(rightNode) || + relationship == nil || relationship.Range != nil || relationship.Properties != nil || + relationship.Direction == graph.DirectionBoth { + return countStoreFastPathShape{}, false + } + + bindingSymbol := countStoreVariableSymbol(relationship.Variable) + if countArgument != cypher.TokenLiteralAsterisk && countArgument != bindingSymbol { + return countStoreFastPathShape{}, false + } + + return countStoreFastPathShape{ + Target: optimize.CountStoreFastPathEdge, + Alias: alias, + Kinds: relationship.Kinds, + }, true +} + +func simpleCountProjection(returnClause *cypher.Return) (string, string, bool) { + if returnClause == nil || returnClause.Projection == nil { + return "", "", false + } + + projection := returnClause.Projection + if projection.Distinct || projection.All || projection.Order != nil || projection.Skip != nil || projection.Limit != nil || len(projection.Items) != 1 { + return "", "", false + } + + projectionItem, ok := projection.Items[0].(*cypher.ProjectionItem) + if !ok || projectionItem == nil { + return "", "", false + } + + function, ok := projectionItem.Expression.(*cypher.FunctionInvocation) + if !ok || function == nil || !strings.EqualFold(function.Name, cypher.CountFunction) || + function.Distinct || len(function.Namespace) > 0 || len(function.Arguments) != 1 { + return "", "", false + } + + variable, ok := function.Arguments[0].(*cypher.Variable) + if !ok || variable == nil { + return "", "", false + } + + return variable.Symbol, countStoreVariableSymbol(projectionItem.Alias), true +} + +func constrainedCountStoreEndpoint(nodePattern *cypher.NodePattern) bool { + return nodePattern == nil || nodePattern.Variable != nil || len(nodePattern.Kinds) > 0 || nodePattern.Properties != nil +} + +func countStoreVariableSymbol(variable *cypher.Variable) string { + if variable == nil { + return "" + } + + return variable.Symbol +} diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index 3ceb4824..0d77f5f4 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/specterops/dawgs/cypher/frontend" + "github.com/specterops/dawgs/cypher/models/pgsql/optimize" "github.com/specterops/dawgs/drivers/pg/pgutil" "github.com/specterops/dawgs/graph" "github.com/stretchr/testify/require" @@ -113,6 +114,19 @@ func requireNoPlannedOptimizationLowering(t *testing.T, summary OptimizationSumm } } +func requireSkippedOptimizationLowering(t *testing.T, summary OptimizationSummary, name string, reason string) { + t.Helper() + + for _, lowering := range summary.SkippedLowerings { + if lowering.Name == name { + require.Equal(t, reason, lowering.Reason) + return + } + } + + require.Failf(t, "missing skipped optimization lowering", "expected skipped lowering %q in %#v", name, summary.SkippedLowerings) +} + func requireSQLContainsInOrder(t *testing.T, sql string, parts ...string) { t.Helper() @@ -124,6 +138,44 @@ func requireSQLContainsInOrder(t *testing.T, sql string, parts ...string) { } } +func TestOptimizerSafetyCountStoreFastPathUsesBaseNodeCount(t *testing.T) { + t.Parallel() + + translation := optimizerSafetyTranslation(t, `MATCH (n) RETURN count(n)`) + formattedQuery, err := Translated(translation) + require.NoError(t, err) + + requirePlannedOptimizationLowering(t, translation.Optimization, optimize.LoweringCountStoreFastPath) + requireOptimizationLowering(t, translation.Optimization, optimize.LoweringCountStoreFastPath) + require.Empty(t, translation.Optimization.SkippedLowerings) + require.Equal(t, "select count(*)::int8 from node n0;", strings.Join(strings.Fields(formattedQuery), " ")) +} + +func TestOptimizerSafetyCountStoreFastPathKeepsKindConstraintAndAlias(t *testing.T) { + t.Parallel() + + translation := optimizerSafetyTranslation(t, `MATCH (n:Group) RETURN count(n) AS total`) + formattedQuery, err := Translated(translation) + require.NoError(t, err) + + requirePlannedOptimizationLowering(t, translation.Optimization, optimize.LoweringCountStoreFastPath) + requireOptimizationLowering(t, translation.Optimization, optimize.LoweringCountStoreFastPath) + require.Equal(t, "select count(*)::int8 as total from node n0 where n0.kind_ids operator (pg_catalog.@>) array [8]::int2[];", strings.Join(strings.Fields(formattedQuery), " ")) +} + +func TestOptimizerSafetyCountStoreFastPathUsesBaseEdgeCount(t *testing.T) { + t.Parallel() + + translation := optimizerSafetyTranslation(t, `MATCH ()-[r:MemberOf]->() RETURN count(r)`) + formattedQuery, err := Translated(translation) + require.NoError(t, err) + + requirePlannedOptimizationLowering(t, translation.Optimization, optimize.LoweringCountStoreFastPath) + requireOptimizationLowering(t, translation.Optimization, optimize.LoweringCountStoreFastPath) + requireSkippedOptimizationLowering(t, translation.Optimization, optimize.LoweringProjectionPruning, "superseded by CountStoreFastPath") + require.Equal(t, "select count(*)::int8 from edge e0 where e0.kind_id = any (array [10]::int2[]);", strings.Join(strings.Fields(formattedQuery), " ")) +} + func TestOptimizerSafetyADCSQueryPrunesExpansionEdgeCarry(t *testing.T) { t.Parallel() diff --git a/cypher/models/pgsql/translate/translator.go b/cypher/models/pgsql/translate/translator.go index 7d92fc21..5838ca07 100644 --- a/cypher/models/pgsql/translate/translator.go +++ b/cypher/models/pgsql/translate/translator.go @@ -599,9 +599,16 @@ type OptimizationSummary struct { PredicateAttachments []optimize.PredicateAttachment `json:"predicate_attachments,omitempty"` PlannedLowerings []optimize.LoweringDecision `json:"planned_lowerings,omitempty"` Lowerings []optimize.LoweringDecision `json:"lowerings,omitempty"` + SkippedLowerings []SkippedLowering `json:"skipped_lowerings,omitempty"` LoweringPlan *optimize.LoweringPlan `json:"lowering_plan,omitempty"` } +type SkippedLowering struct { + Name string `json:"name"` + Reason string `json:"reason"` + Count int `json:"count,omitempty"` +} + func (s *Translator) recordLowering(name string) { for _, lowering := range s.translation.Optimization.Lowerings { if lowering.Name == name { @@ -612,6 +619,61 @@ func (s *Translator) recordLowering(name string) { s.translation.Optimization.Lowerings = append(s.translation.Optimization.Lowerings, optimize.LoweringDecision{Name: name}) } +func (s *Translator) recordSkippedLowerings() { + if s.translation.Optimization.LoweringPlan == nil { + return + } + + applied := map[string]struct{}{} + for _, lowering := range s.translation.Optimization.Lowerings { + applied[lowering.Name] = struct{}{} + } + + for _, planned := range plannedLoweringCounts(*s.translation.Optimization.LoweringPlan) { + if planned.Count == 0 { + continue + } + + if _, wasApplied := applied[planned.Name]; wasApplied { + continue + } + + s.translation.Optimization.SkippedLowerings = append(s.translation.Optimization.SkippedLowerings, SkippedLowering{ + Name: planned.Name, + Reason: skippedLoweringReason(planned.Name, applied), + Count: planned.Count, + }) + } +} + +func plannedLoweringCounts(plan optimize.LoweringPlan) []SkippedLowering { + return []SkippedLowering{ + {Name: optimize.LoweringProjectionPruning, Count: len(plan.ProjectionPruning)}, + {Name: optimize.LoweringLatePathMaterialization, Count: len(plan.LatePathMaterialization)}, + {Name: optimize.LoweringExpandIntoDetection, Count: len(plan.ExpandInto)}, + {Name: optimize.LoweringTraversalDirection, Count: len(plan.TraversalDirection)}, + {Name: optimize.LoweringShortestPathStrategy, Count: len(plan.ShortestPathStrategy)}, + {Name: optimize.LoweringShortestPathFilter, Count: len(plan.ShortestPathFilter)}, + {Name: optimize.LoweringLimitPushdown, Count: len(plan.LimitPushdown)}, + {Name: optimize.LoweringExpansionSuffixPushdown, Count: len(plan.ExpansionSuffixPushdown)}, + {Name: optimize.LoweringPredicatePlacement, Count: len(plan.PredicatePlacement) + len(plan.PatternPredicate)}, + {Name: optimize.LoweringCountStoreFastPath, Count: len(plan.CountStoreFastPath)}, + } +} + +func skippedLoweringReason(name string, applied map[string]struct{}) string { + if _, countFastPathApplied := applied[optimize.LoweringCountStoreFastPath]; countFastPathApplied && name != optimize.LoweringCountStoreFastPath { + return "superseded by CountStoreFastPath" + } + + switch name { + case optimize.LoweringPredicatePlacement: + return "planned predicate placements were not consumed by this translation shape" + default: + return "planned lowering did not change the emitted SQL" + } +} + func Translate(ctx context.Context, cypherQuery *cypher.RegularQuery, kindMapper pgsql.KindMapper, parameters map[string]any, graphID int32) (Result, error) { optimizedPlan, err := optimize.Optimize(cypherQuery) if err != nil { @@ -628,10 +690,18 @@ func Translate(ctx context.Context, cypherQuery *cypher.RegularQuery, kindMapper translator.translation.Optimization.PlannedLowerings = loweringPlan.Decisions() } + if translated, err := translator.translateCountStoreFastPath(optimizedPlan.Query, optimizedPlan.LoweringPlan); err != nil { + return Result{}, err + } else if translated { + translator.recordSkippedLowerings() + return translator.translation, nil + } + if err := walk.Cypher(optimizedPlan.Query, translator); err != nil { return Result{}, err } + translator.recordSkippedLowerings() return translator.translation, nil } From 05744a084f6a59a0553c4eff09ecc4fd60bb73de Mon Sep 17 00:00:00 2001 From: John Hopper Date: Fri, 22 May 2026 16:34:14 -0700 Subject: [PATCH 051/116] Stage repeated path projection components --- .../pgsql/optimize/OPTIMIZATION_PLAN.md | 3 +- .../models/pgsql/translate/function_test.go | 35 ++++ cypher/models/pgsql/translate/projection.go | 194 +++++++++++++++--- 3 files changed, 207 insertions(+), 25 deletions(-) diff --git a/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md b/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md index d53874ee..4b0e5cf7 100644 --- a/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md +++ b/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md @@ -31,11 +31,12 @@ Status: completed ## Phase 3: Path Materialization -Status: pending +Status: completed - Share path materialization for repeated path functions. - Target `nodes(p)`, `relationships(p)`, `size(relationships(p))`, `startNode`, `endNode`, and `type`. - Avoid repeated `SubPlan` and `Function Scan on unnest` work per path binding. + - Materialize unprojected paths once through a lateral stage when final projections return a path and its components, or repeat node-bearing component expressions. - Expand late path materialization coverage. - Ensure paths are built only when needed for projection, filtering, or mutation semantics. diff --git a/cypher/models/pgsql/translate/function_test.go b/cypher/models/pgsql/translate/function_test.go index e8f421bd..64926916 100644 --- a/cypher/models/pgsql/translate/function_test.go +++ b/cypher/models/pgsql/translate/function_test.go @@ -88,6 +88,41 @@ func TestTailPredicateStagesPathComponentExpression(t *testing.T) { require.Contains(t, formatted, ".nodes") } +func TestProjectionStagesPathBeforeReadingComponents(t *testing.T) { + kindMapper := pgutil.NewInMemoryKindMapper() + + query, err := frontend.ParseCypher(frontend.NewContext(), `MATCH p = ()-[*1..]->() RETURN p, nodes(p), relationships(p)`) + require.NoError(t, err) + + translation, err := Translate(context.Background(), query, kindMapper, nil, DefaultGraphID) + require.NoError(t, err) + + formatted, err := Translated(translation) + require.NoError(t, err) + require.Contains(t, formatted, "lateral (select") + require.Equal(t, 1, strings.Count(formatted, "ordered_edges_to_path"), formatted) + require.Contains(t, formatted, ".nodes") + require.Contains(t, formatted, ".edges") +} + +func TestProjectionStagesRepeatedPathComponents(t *testing.T) { + kindMapper := pgutil.NewInMemoryKindMapper() + + query, err := frontend.ParseCypher(frontend.NewContext(), `MATCH p = ()-[*1..]->() RETURN size(relationships(p)), nodes(p), relationships(p)`) + require.NoError(t, err) + + translation, err := Translate(context.Background(), query, kindMapper, nil, DefaultGraphID) + require.NoError(t, err) + + formatted, err := Translated(translation) + require.NoError(t, err) + require.Contains(t, formatted, "lateral (select") + require.Equal(t, 1, strings.Count(formatted, "ordered_edges_to_path"), formatted) + require.Equal(t, 1, strings.Count(formatted, "from unnest"), formatted) + require.Contains(t, formatted, ".nodes") + require.Contains(t, formatted, ".edges") +} + func TestPrepareCollectExpressionMissingBindingErrorNamesArgument(t *testing.T) { t.Parallel() diff --git a/cypher/models/pgsql/translate/projection.go b/cypher/models/pgsql/translate/projection.go index 18dabaee..af3eff99 100644 --- a/cypher/models/pgsql/translate/projection.go +++ b/cypher/models/pgsql/translate/projection.go @@ -1093,49 +1093,190 @@ func rewriteOrderByProjectionAlias(orderBy *pgsql.OrderBy, aliases map[pgsql.Ide } } -func tailPathCompositeStageBindings(scope *Scope, expression pgsql.Expression) ([]*BoundIdentifier, error) { - if expression == nil { - return nil, nil +type pathCompositeReferenceCount struct { + binding *BoundIdentifier + full int + nodes int + edges int +} + +func (s pathCompositeReferenceCount) componentReferences() int { + return s.nodes + s.edges +} + +func (s pathCompositeReferenceCount) totalReferences() int { + return s.full + s.componentReferences() +} + +func pathCompositeBinding(scope *Scope, identifier pgsql.Identifier) (*BoundIdentifier, bool) { + binding, bound := scope.Lookup(identifier) + if !bound { + binding, bound = scope.AliasedLookup(identifier) } + if !bound || binding.DataType != pgsql.PathComposite || binding.LastProjection != nil { + return nil, false + } + + return binding, true +} + +func ensurePathCompositeReferenceCount( + counts map[pgsql.Identifier]*pathCompositeReferenceCount, + orderedCounts *[]*pathCompositeReferenceCount, + binding *BoundIdentifier, +) *pathCompositeReferenceCount { + if count, seen := counts[binding.Identifier]; seen { + return count + } + + count := &pathCompositeReferenceCount{ + binding: binding, + } + + counts[binding.Identifier] = count + *orderedCounts = append(*orderedCounts, count) + + return count +} + +func countPathCompositeComponents(scope *Scope, expressions ...pgsql.Expression) ([]*pathCompositeReferenceCount, error) { var ( - bindings = make([]*BoundIdentifier, 0) - seen = map[pgsql.Identifier]struct{}{} + counts = map[pgsql.Identifier]*pathCompositeReferenceCount{} + orderedCounts []*pathCompositeReferenceCount ) - if err := walk.PgSQL(expression, walk.NewSimpleVisitor[pgsql.SyntaxNode](func(node pgsql.SyntaxNode, _ walk.VisitorHandler) { - reference, isRowColumnReference := node.(pgsql.RowColumnReference) - if !isRowColumnReference || reference.Column != pgsql.ColumnNodes { - return + for _, expression := range expressions { + if expression == nil { + continue } - identifier, isIdentifier := unwrapParenthetical(reference.Identifier).(pgsql.Identifier) + if err := walk.PgSQL(expression, walk.NewSimpleVisitor[pgsql.SyntaxNode](func(node pgsql.SyntaxNode, _ walk.VisitorHandler) { + reference, isRowColumnReference := node.(pgsql.RowColumnReference) + if !isRowColumnReference || (reference.Column != pgsql.ColumnNodes && reference.Column != pgsql.ColumnEdges) { + return + } + + identifier, isIdentifier := unwrapParenthetical(reference.Identifier).(pgsql.Identifier) + if !isIdentifier { + return + } + + binding, bound := pathCompositeBinding(scope, identifier) + if !bound { + return + } + + count := ensurePathCompositeReferenceCount(counts, &orderedCounts, binding) + switch reference.Column { + case pgsql.ColumnNodes: + count.nodes += 1 + case pgsql.ColumnEdges: + count.edges += 1 + } + })); err != nil { + return nil, err + } + } + + return orderedCounts, nil +} + +func countPathCompositeProjectionReferences(scope *Scope, projections []*Projection) ([]*pathCompositeReferenceCount, error) { + var ( + counts = map[pgsql.Identifier]*pathCompositeReferenceCount{} + orderedCounts []*pathCompositeReferenceCount + expressions = make([]pgsql.Expression, 0, len(projections)) + ) + + for _, projection := range projections { + expressions = append(expressions, projection.SelectItem) + + identifier, isIdentifier := projection.SelectItem.(pgsql.Identifier) if !isIdentifier { - return + continue } - binding, bound := scope.Lookup(identifier) + binding, bound := pathCompositeBinding(scope, identifier) if !bound { - binding, bound = scope.AliasedLookup(identifier) - } - if !bound || binding.DataType != pgsql.PathComposite || binding.LastProjection != nil { - return + continue } - if _, alreadySeen := seen[binding.Identifier]; alreadySeen { - return + ensurePathCompositeReferenceCount(counts, &orderedCounts, binding).full += 1 + } + + componentCounts, err := countPathCompositeComponents(scope, expressions...) + if err != nil { + return nil, err + } + + for _, componentCount := range componentCounts { + count := ensurePathCompositeReferenceCount(counts, &orderedCounts, componentCount.binding) + count.nodes += componentCount.nodes + count.edges += componentCount.edges + } + + return orderedCounts, nil +} + +func tailPathCompositeStageBindings(scope *Scope, expression pgsql.Expression) ([]*BoundIdentifier, error) { + counts, err := countPathCompositeComponents(scope, expression) + if err != nil { + return nil, err + } + + bindings := make([]*BoundIdentifier, 0, len(counts)) + for _, count := range counts { + if count.nodes > 0 { + bindings = append(bindings, count.binding) } + } - seen[binding.Identifier] = struct{}{} - bindings = append(bindings, binding) - })); err != nil { + return bindings, nil +} + +func projectionPathCompositeStageBindings(scope *Scope, projections []*Projection) ([]*BoundIdentifier, error) { + counts, err := countPathCompositeProjectionReferences(scope, projections) + if err != nil { return nil, err } + bindings := make([]*BoundIdentifier, 0, len(counts)) + for _, count := range counts { + switch { + case count.full > 0 && count.totalReferences() > count.full: + bindings = append(bindings, count.binding) + case count.full > 1: + bindings = append(bindings, count.binding) + case count.nodes > 0 && count.componentReferences() > 1: + bindings = append(bindings, count.binding) + } + } + return bindings, nil } -func (s *Translator) stageTailPathCompositeBindings(fromClauses []pgsql.FromClause, bindings []*BoundIdentifier) ([]pgsql.FromClause, error) { +func mergePathCompositeStageBindings(bindingSets ...[]*BoundIdentifier) []*BoundIdentifier { + var ( + merged = make([]*BoundIdentifier, 0) + seen = map[pgsql.Identifier]struct{}{} + ) + + for _, bindings := range bindingSets { + for _, binding := range bindings { + if _, alreadySeen := seen[binding.Identifier]; alreadySeen { + continue + } + + seen[binding.Identifier] = struct{}{} + merged = append(merged, binding) + } + } + + return merged +} + +func (s *Translator) stagePathCompositeBindings(fromClauses []pgsql.FromClause, bindings []*BoundIdentifier) ([]pgsql.FromClause, error) { for _, binding := range bindings { stageBinding, err := s.scope.DefineNew(pgsql.Scope) if err != nil { @@ -1187,9 +1328,14 @@ func (s *Translator) buildTailProjection() error { if projectionConstraint, err := s.treeTranslator.ConsumeAllConstraints(); err != nil { return err - } else if stagedBindings, err := tailPathCompositeStageBindings(s.scope, projectionConstraint.Expression); err != nil { + } else if constraintStagedBindings, err := tailPathCompositeStageBindings(s.scope, projectionConstraint.Expression); err != nil { + return err + } else if projectionStagedBindings, err := projectionPathCompositeStageBindings(s.scope, currentPart.projections.Items); err != nil { return err - } else if stagedFromClauses, err := s.stageTailPathCompositeBindings(singlePartQuerySelect.From, stagedBindings); err != nil { + } else if stagedFromClauses, err := s.stagePathCompositeBindings( + singlePartQuerySelect.From, + mergePathCompositeStageBindings(constraintStagedBindings, projectionStagedBindings), + ); err != nil { return err } else if projection, err := buildExternalProjection(s.scope, currentPart.projections.Items); err != nil { return err From 0a8918f123e2ab6cd8cc590e5ead359f8f3c4b53 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Fri, 22 May 2026 16:42:26 -0700 Subject: [PATCH 052/116] Plan traversal flips for endpoint predicates --- .../pgsql/optimize/OPTIMIZATION_PLAN.md | 3 +- cypher/models/pgsql/optimize/lowering_plan.go | 17 ++++++-- .../models/pgsql/optimize/optimizer_test.go | 41 +++++++++++++++++++ .../pgsql/translate/optimizer_safety_test.go | 18 ++++++++ 4 files changed, 75 insertions(+), 4 deletions(-) diff --git a/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md b/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md index 4b0e5cf7..c99ef2a3 100644 --- a/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md +++ b/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md @@ -42,7 +42,7 @@ Status: completed ## Phase 4: Traversal And Recursive CTEs -Status: pending +Status: completed - Push predicates into recursive traversal anchors and steps where semantics allow. - Endpoint kind/property predicates. @@ -53,6 +53,7 @@ Status: pending - Labels/kinds. - Equality predicates. - Finite relationship type sets. + - Plan direction flips for right-endpoint binding predicates from `WHERE`, not only inline node constraints. - Broaden limit pushdown for variable-length path queries when ordering and distinct semantics permit early termination. ## Phase 5: Suffix And Shared Endpoint Rewrites diff --git a/cypher/models/pgsql/optimize/lowering_plan.go b/cypher/models/pgsql/optimize/lowering_plan.go index 9ed9329f..d105e7ec 100644 --- a/cypher/models/pgsql/optimize/lowering_plan.go +++ b/cypher/models/pgsql/optimize/lowering_plan.go @@ -16,6 +16,7 @@ type sourceTraversalStep struct { const ( traversalDirectionReasonRightBound = "right_bound" traversalDirectionReasonRightConstrained = "right_constrained" + traversalDirectionReasonRightPredicate = "right_predicate" shortestPathStrategyReasonBoundEndpointPairs = "bound_endpoint_pairs" shortestPathStrategyReasonEndpointPredicates = "endpoint_predicates" @@ -352,6 +353,7 @@ func appendTraversalDirectionDecisions(plan *LoweringPlan, queryPartIndex int, r step, declaredEndpoints[stepIndex], referencesSourceIdentifier(predicateConstrainedSymbols, variableSymbol(step.LeftNode.Variable)), + referencesSourceIdentifier(predicateConstrainedSymbols, variableSymbol(step.RightNode.Variable)), ); shouldFlip { plan.TraversalDirection = append(plan.TraversalDirection, decision) } @@ -386,6 +388,7 @@ func traversalDirectionDecisionForStep( step sourceTraversalStep, declaredEndpoints declaredStepEndpoints, leftHasAttachedPredicate bool, + rightHasAttachedPredicate bool, ) (TraversalDirectionDecision, bool) { if leftEndpointBoundForStep(stepIndex, step, declaredEndpoints) { return TraversalDirectionDecision{}, false @@ -406,11 +409,19 @@ func traversalDirectionDecisionForStep( } } - if nodePatternHasConstraints(step.RightNode) && !nodePatternHasConstraints(step.LeftNode) && !leftHasAttachedPredicate { + leftConstrained := nodePatternHasConstraints(step.LeftNode) || leftHasAttachedPredicate + rightConstrained := nodePatternHasConstraints(step.RightNode) || rightHasAttachedPredicate + + if rightConstrained && !leftConstrained { + reason := traversalDirectionReasonRightConstrained + if !nodePatternHasConstraints(step.RightNode) && rightHasAttachedPredicate { + reason = traversalDirectionReasonRightPredicate + } + return TraversalDirectionDecision{ Target: target, Flip: true, - Reason: traversalDirectionReasonRightConstrained, + Reason: reason, }, true } @@ -732,7 +743,7 @@ func appendExpansionSuffixPushdownDecisions(plan *LoweringPlan, queryPartIndex i } func expansionStepMayFlipForConstraintBalance(stepIndex int, step sourceTraversalStep, declaredEndpoints declaredStepEndpoints) bool { - _, mayFlip := traversalDirectionDecisionForStep(TraversalStepTarget{}, stepIndex, step, declaredEndpoints, false) + _, mayFlip := traversalDirectionDecisionForStep(TraversalStepTarget{}, stepIndex, step, declaredEndpoints, false, false) return mayFlip } diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index 9e9e3b54..971d00bc 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -589,6 +589,47 @@ func TestLoweringPlanSkipsTraversalDirectionWhenLeftEndpointHasRegionPredicate(t require.Empty(t, plan.LoweringPlan.TraversalDirection) } +func TestLoweringPlanReportsTraversalDirectionForRightEndpointPredicate(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH p = (n)-[:MemberOf*1..]->(ca) + WHERE ca.name = 'target' + RETURN p + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Contains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringTraversalDirection}) + require.Equal(t, []TraversalDirectionDecision{{ + Target: TraversalStepTarget{ + QueryPartIndex: 0, + ClauseIndex: 0, + PatternIndex: 0, + StepIndex: 0, + }, + Flip: true, + Reason: traversalDirectionReasonRightPredicate, + }}, plan.LoweringPlan.TraversalDirection) +} + +func TestLoweringPlanSkipsSuffixPushdownAfterRightEndpointPredicateDirectionFlip(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH p = (n)-[:MemberOf*1..]->(ca)-[:TrustedForNTAuth]->(d:Domain) + WHERE ca.name = 'target' + RETURN p + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Contains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringTraversalDirection}) + require.Empty(t, plan.LoweringPlan.ExpansionSuffixPushdown) +} + func TestLoweringPlanReportsShortestPathStrategyForEndpointPredicates(t *testing.T) { t.Parallel() diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index 0d77f5f4..087b6fd5 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -414,6 +414,24 @@ RETURN p requireNoOptimizationLowering(t, translation.Optimization, "ExpansionSuffixPushdown") } +func TestOptimizerSafetyTraversalDirectionUsesRightEndpointPredicate(t *testing.T) { + t.Parallel() + + translation := optimizerSafetyTranslation(t, ` +MATCH p = (n)-[:MemberOf*1..]->(ca) +WHERE ca.name = 'target' +RETURN p + `) + formattedQuery, err := Translated(translation) + require.NoError(t, err) + normalizedQuery := strings.Join(strings.Fields(formattedQuery), " ") + + requirePlannedOptimizationLowering(t, translation.Optimization, "TraversalDirectionSelection") + requireOptimizationLowering(t, translation.Optimization, "TraversalDirectionSelection") + require.Contains(t, normalizedQuery, "where (((n1.properties -> 'name'))::jsonb = to_jsonb(('target')::text)::jsonb)") + require.Contains(t, normalizedQuery, "join edge e0 on e0.end_id = s1_seed.root_id") +} + func TestOptimizerSafetyShortestPathStrategyUsesPlannedBidirectionalSearch(t *testing.T) { t.Parallel() From 4d63285622dd5212732f1b000f34105c5ba5a904 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Fri, 22 May 2026 16:52:58 -0700 Subject: [PATCH 053/116] Extend suffix pushdown to constrained bound endpoints --- .../pgsql/optimize/OPTIMIZATION_PLAN.md | 4 ++- cypher/models/pgsql/optimize/lowering_plan.go | 27 +++---------------- .../models/pgsql/optimize/optimizer_test.go | 26 ++++++++++++++++++ cypher/models/pgsql/translate/expansion.go | 5 +--- .../pgsql/translate/optimizer_safety_test.go | 22 +++++++++++++++ 5 files changed, 55 insertions(+), 29 deletions(-) diff --git a/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md b/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md index c99ef2a3..ef983f77 100644 --- a/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md +++ b/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md @@ -58,9 +58,11 @@ Status: completed ## Phase 5: Suffix And Shared Endpoint Rewrites -Status: pending +Status: completed - Improve expansion suffix pushdown for fixed suffixes after variable-length traversals. + - Include fixed suffix steps that terminate at already-bound endpoints with inline node constraints. + - Preserve bound-endpoint constraints in the pushed terminal satisfaction check when present. - Improve `ExpandInto` and shared endpoint rewrites for ADCS-style fanout patterns. - Constrain earlier using bound endpoint semi-joins or correlated expansion lowering where valid. diff --git a/cypher/models/pgsql/optimize/lowering_plan.go b/cypher/models/pgsql/optimize/lowering_plan.go index d105e7ec..72036350 100644 --- a/cypher/models/pgsql/optimize/lowering_plan.go +++ b/cypher/models/pgsql/optimize/lowering_plan.go @@ -708,7 +708,6 @@ func appendExpansionSuffixPushdownDecisions(plan *LoweringPlan, queryPartIndex i for patternIndex, patternPart := range match.Pattern { steps := traversalStepsForPattern(patternPart) - declaredBeforeRightNode := declaredSymbolsBeforeRightNodes(declaredSymbols, steps) declaredEndpoints := declaredSymbolsBeforeStepEndpoints(declaredSymbols, steps) for stepIndex, step := range steps { @@ -725,7 +724,7 @@ func appendExpansionSuffixPushdownDecisions(plan *LoweringPlan, queryPartIndex i continue } - if suffixLength := expansionSuffixPushdownLength(steps[stepIndex+1:], declaredBeforeRightNode[stepIndex+1:]); suffixLength > 0 { + if suffixLength := expansionSuffixPushdownLength(steps[stepIndex+1:]); suffixLength > 0 { plan.ExpansionSuffixPushdown = append(plan.ExpansionSuffixPushdown, ExpansionSuffixPushdownDecision{ Target: target, SuffixLength: suffixLength, @@ -1019,40 +1018,20 @@ func setBindingTarget(targets map[bindingTargetKey]TraversalStepTarget, queryPar } } -func expansionSuffixPushdownLength(suffixSteps []sourceTraversalStep, declaredBeforeRightNode []map[string]struct{}) int { +func expansionSuffixPushdownLength(suffixSteps []sourceTraversalStep) int { var suffixLength int - for idx, step := range suffixSteps { + for _, step := range suffixSteps { if step.Relationship.Range != nil || step.Relationship.Direction == graph.DirectionBoth { break } - if nodeSymbol := variableSymbol(step.RightNode.Variable); nodeSymbol != "" { - if _, bound := declaredBeforeRightNode[idx][nodeSymbol]; bound && nodePatternHasConstraints(step.RightNode) { - break - } - } - suffixLength++ } return suffixLength } -func declaredSymbolsBeforeRightNodes(initial map[string]struct{}, steps []sourceTraversalStep) []map[string]struct{} { - declared := copyStringSet(initial) - declaredBeforeRightNode := make([]map[string]struct{}, len(steps)) - - for idx, step := range steps { - addSymbol(declared, variableSymbol(step.LeftNode.Variable)) - addSymbol(declared, variableSymbol(step.Relationship.Variable)) - declaredBeforeRightNode[idx] = copyStringSet(declared) - addSymbol(declared, variableSymbol(step.RightNode.Variable)) - } - - return declaredBeforeRightNode -} - func declareMatchSymbols(declared map[string]struct{}, match *cypher.Match) { if match == nil { return diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index 971d00bc..111e17a7 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -390,6 +390,32 @@ func TestLoweringPlanReportsExpansionSuffixPushdown(t *testing.T) { }}, plan.LoweringPlan.ExpansionSuffixPushdown) } +func TestLoweringPlanIncludesConstrainedBoundEndpointInExpansionSuffix(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH (ca) + MATCH p = (n:Group)-[:MemberOf*0..]->(m)-[:Enroll]->(ct:CertTemplate)-[:PublishedTo]->(ca:EnterpriseCA) + RETURN p + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Contains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringExpansionSuffixPushdown}) + require.Contains(t, plan.LoweringPlan.ExpansionSuffixPushdown, ExpansionSuffixPushdownDecision{ + Target: TraversalStepTarget{ + QueryPartIndex: 0, + ClauseIndex: 1, + PatternIndex: 0, + StepIndex: 0, + }, + SuffixLength: 2, + SuffixStartStep: 1, + SuffixEndStep: 2, + }) +} + func TestLoweringPlanReportsCountStoreFastPath(t *testing.T) { t.Parallel() diff --git a/cypher/models/pgsql/translate/expansion.go b/cypher/models/pgsql/translate/expansion.go index c5e78360..df524e6a 100644 --- a/cypher/models/pgsql/translate/expansion.go +++ b/cypher/models/pgsql/translate/expansion.go @@ -2844,15 +2844,12 @@ func expansionSuffixTerminalSatisfaction(currentStep *TraversalStep, suffixSteps } if step.RightNodeBound { - if step.RightNodeConstraints != nil { - return nil, false - } - boundRightNodeID, hasBoundRightNodeID := suffixBoundNodeIDReference(currentStep, step.RightNode) if !hasBoundRightNodeID { return nil, false } + where = pgsql.OptionalAnd(where, step.RightNodeConstraints) where = pgsql.OptionalAnd(where, pgd.Equals(rightEndpoint, boundRightNodeID)) previousID = boundRightNodeID } else { diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index 087b6fd5..4fd7340a 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -590,6 +590,28 @@ RETURN p ) } +func TestOptimizerSafetyExpansionTerminalPushdownIncludesConstrainedBoundEndpoint(t *testing.T) { + t.Parallel() + + translation := optimizerSafetyTranslation(t, ` +MATCH (ca) +MATCH p = (n:Group)-[:MemberOf*0..]->(m)-[:Enroll]->(ct:CertTemplate)-[:PublishedTo]->(ca:EnterpriseCA) +RETURN p +`) + formattedQuery, err := Translated(translation) + require.NoError(t, err) + normalizedQuery := strings.Join(strings.Fields(formattedQuery), " ") + + requirePlannedOptimizationLowering(t, translation.Optimization, "ExpansionSuffixPushdown") + requireOptimizationLowering(t, translation.Optimization, "ExpansionSuffixPushdown") + requireSQLContainsInOrder(t, normalizedQuery, + "exists (select 1 from edge e1 join node n3", + "join edge e2 on n3.id = e2.start_id", + "e2.end_id = (s0.n0).id", + ) + require.Contains(t, normalizedQuery, "(s0.n0).kind_ids operator (pg_catalog.@>)") +} + func TestOptimizerSafetyExpansionTerminalPushdownForBoundDomainSuffix(t *testing.T) { t.Parallel() From 1425caa2f29ffab4a67a9bfaa2ad6a060585db99 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Fri, 22 May 2026 17:08:15 -0700 Subject: [PATCH 054/116] Stabilize integration corpus validation --- cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md | 5 ++++- integration/cypher_template_test.go | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md b/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md index ef983f77..0fe122db 100644 --- a/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md +++ b/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md @@ -68,13 +68,16 @@ Status: completed ## Phase 6: Validation -Status: pending +Status: completed - Add focused regression tests per optimization. - Optimizer/lowering selection tests. - SQL shape translation tests. - Backend-equivalent integration tests. + - Template corpus setup now clears stale graph data before rollback-only fixture cases, keeping repeated PostgreSQL and Neo4j validation runs deterministic. - Benchmark after each workstream. - Run unit tests. - Run backend-specific integration tests. - Run plan capture and compare summary deltas. + - `quality_backend` passes against `postgres://postgres:bhe4eva@localhost/bhe` and `neo4j://neo4j:neo4jj@localhost:7687`. + - Plan corpus capture records 396 PostgreSQL plans and 396 Neo4j plans; remaining capture errors are expected invalid-query cases surfaced by both systems or Neo4j-specific parameter-map syntax rejection. diff --git a/integration/cypher_template_test.go b/integration/cypher_template_test.go index 7dde0d51..63a90655 100644 --- a/integration/cypher_template_test.go +++ b/integration/cypher_template_test.go @@ -73,6 +73,7 @@ func TestCypherTemplates(t *testing.T) { nodeKinds, edgeKinds := cypherTemplateKinds(templateFiles) db, ctx := SetupDBWithKindsNoGraphCleanup(t, nodeKinds, edgeKinds) + ClearGraph(t, db, ctx) for _, templateFile := range templateFiles { fileName := strings.TrimSuffix(filepath.Base(templateFile.path), filepath.Ext(templateFile.path)) From 7bfb919e4f419b6fd220d13dbf3c18816f18a1af Mon Sep 17 00:00:00 2001 From: John Hopper Date: Fri, 22 May 2026 17:20:22 -0700 Subject: [PATCH 055/116] Record predicate placement consumption --- .../pgsql/optimize/OPTIMIZATION_PLAN.md | 9 ++++ cypher/models/pgsql/translate/expansion.go | 2 + .../pgsql/translate/optimizer_safety_test.go | 53 +++++++++++++++++++ .../pgsql/translate/predicate_placement.go | 46 ++++++++++++++++ cypher/models/pgsql/translate/translator.go | 6 +++ cypher/models/pgsql/translate/traversal.go | 4 ++ 6 files changed, 120 insertions(+) create mode 100644 cypher/models/pgsql/translate/predicate_placement.go diff --git a/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md b/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md index 0fe122db..fc3324ce 100644 --- a/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md +++ b/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md @@ -81,3 +81,12 @@ Status: completed - Run plan capture and compare summary deltas. - `quality_backend` passes against `postgres://postgres:bhe4eva@localhost/bhe` and `neo4j://neo4j:neo4jj@localhost:7687`. - Plan corpus capture records 396 PostgreSQL plans and 396 Neo4j plans; remaining capture errors are expected invalid-query cases surfaced by both systems or Neo4j-specific parameter-map syntax rejection. + +## Phase 7: Predicate Placement Accounting + +Status: completed + +- Record planned binding-scope predicate placements when traversal constraint consumption actually pushes the matching predicate into a fixed traversal step, expansion seed, expansion edge, or expansion terminal constraint. +- Keep skipped-lowering reports focused on predicates that were not consumed by the emitted translation shape, instead of marking already-pushed traversal predicates as skipped. +- Add SQL-shape regression tests for fixed traversal and expansion-root predicate consumption. +- Refreshed plan-corpus capture applies `PredicatePlacement` in 56 of 71 planned PostgreSQL cases, reducing skipped predicate placements from 65 to 15. diff --git a/cypher/models/pgsql/translate/expansion.go b/cypher/models/pgsql/translate/expansion.go index df524e6a..5d867a7c 100644 --- a/cypher/models/pgsql/translate/expansion.go +++ b/cypher/models/pgsql/translate/expansion.go @@ -3158,6 +3158,8 @@ func (s *Translator) translateExpansionConstraints(part *PatternPart, stepIndex return err } + s.recordPredicatePlacementConsumption(part, stepIndex, step, constraints) + // Left node if leftNodeJoinCondition, err := leftNodeTraversalStepConstraint(step); err != nil { return err diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index 4fd7340a..3cb8b715 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -127,6 +127,14 @@ func requireSkippedOptimizationLowering(t *testing.T, summary OptimizationSummar require.Failf(t, "missing skipped optimization lowering", "expected skipped lowering %q in %#v", name, summary.SkippedLowerings) } +func requireNoSkippedOptimizationLowering(t *testing.T, summary OptimizationSummary, name string) { + t.Helper() + + for _, lowering := range summary.SkippedLowerings { + require.NotEqualf(t, name, lowering.Name, "unexpected skipped lowering %q in %#v", name, summary.SkippedLowerings) + } +} + func requireSQLContainsInOrder(t *testing.T, sql string, parts ...string) { t.Helper() @@ -363,6 +371,51 @@ RETURN p ) } +func TestOptimizerSafetyPredicatePlacementRecordsExpansionRootConstraint(t *testing.T) { + t.Parallel() + + translation := optimizerSafetyTranslation(t, ` +MATCH p = (src:Group)-[:MemberOf*1..]->(mid)-[:Enroll]->(ca:EnterpriseCA) +WHERE src.name = 'source' +RETURN p +`) + + formattedQuery, err := Translated(translation) + require.NoError(t, err) + normalizedQuery := strings.Join(strings.Fields(formattedQuery), " ") + + requirePlannedOptimizationLowering(t, translation.Optimization, optimize.LoweringPredicatePlacement) + requireOptimizationLowering(t, translation.Optimization, optimize.LoweringPredicatePlacement) + requireNoSkippedOptimizationLowering(t, translation.Optimization, optimize.LoweringPredicatePlacement) + requireSQLContainsInOrder(t, normalizedQuery, + "select n0.id as root_id from node n0 where", + "properties -> 'name'", + ) +} + +func TestOptimizerSafetyPredicatePlacementRecordsFixedTraversalConstraint(t *testing.T) { + t.Parallel() + + translation := optimizerSafetyTranslation(t, ` +MATCH (src:Group)-[:MemberOf]->(dst) +WHERE src.name = 'source' +RETURN dst +`) + + formattedQuery, err := Translated(translation) + require.NoError(t, err) + normalizedQuery := strings.Join(strings.Fields(formattedQuery), " ") + + requirePlannedOptimizationLowering(t, translation.Optimization, optimize.LoweringPredicatePlacement) + requireOptimizationLowering(t, translation.Optimization, optimize.LoweringPredicatePlacement) + requireNoSkippedOptimizationLowering(t, translation.Optimization, optimize.LoweringPredicatePlacement) + requireSQLContainsInOrder(t, normalizedQuery, + "join node n0 on", + "properties -> 'name'", + "join node n1", + ) +} + func TestOptimizerSafetyPatternPredicateExistencePlacementIsPlanned(t *testing.T) { t.Parallel() diff --git a/cypher/models/pgsql/translate/predicate_placement.go b/cypher/models/pgsql/translate/predicate_placement.go new file mode 100644 index 00000000..6ed48f32 --- /dev/null +++ b/cypher/models/pgsql/translate/predicate_placement.go @@ -0,0 +1,46 @@ +package translate + +import ( + "github.com/specterops/dawgs/cypher/models/pgsql" + "github.com/specterops/dawgs/cypher/models/pgsql/optimize" +) + +func (s *Translator) recordPredicatePlacementConsumption(part *PatternPart, stepIndex int, traversalStep *TraversalStep, constraints PatternConstraints) { + if part == nil || !part.HasTarget || traversalStep == nil { + return + } + + for _, decision := range s.predicatePlacementDecisions[part.Target.TraversalStep(stepIndex)] { + if predicatePlacementDecisionConsumed(decision, traversalStep, constraints) { + s.recordLowering(optimize.LoweringPredicatePlacement) + return + } + } +} + +func predicatePlacementDecisionConsumed(decision optimize.PredicatePlacementDecision, traversalStep *TraversalStep, constraints PatternConstraints) bool { + for _, symbol := range decision.Attachment.BindingSymbols { + if bindingConstraintConsumed(symbol, traversalStep.LeftNode, constraints.LeftNode) || + bindingConstraintConsumed(symbol, traversalStep.Edge, constraints.Edge) || + bindingConstraintConsumed(symbol, traversalStep.RightNode, constraints.RightNode) { + return true + } + } + + return false +} + +func bindingConstraintConsumed(symbol string, binding *BoundIdentifier, constraint *Constraint) bool { + return constraint != nil && + constraint.Expression != nil && + bindingMatchesSymbol(binding, pgsql.Identifier(symbol)) +} + +func bindingMatchesSymbol(binding *BoundIdentifier, symbol pgsql.Identifier) bool { + if binding == nil { + return false + } + + return binding.Identifier == symbol || + (binding.Alias.Set && binding.Alias.Value == symbol) +} diff --git a/cypher/models/pgsql/translate/translator.go b/cypher/models/pgsql/translate/translator.go index 5838ca07..45706619 100644 --- a/cypher/models/pgsql/translate/translator.go +++ b/cypher/models/pgsql/translate/translator.go @@ -35,6 +35,7 @@ type Translator struct { projectionPruningDecisions map[optimize.TraversalStepTarget]optimize.ProjectionPruningDecision latePathDecisions map[optimize.TraversalStepTarget][]optimize.LatePathMaterializationDecision suffixPushdownDecisions map[optimize.TraversalStepTarget][]optimize.ExpansionSuffixPushdownDecision + predicatePlacementDecisions map[optimize.TraversalStepTarget][]optimize.PredicatePlacementDecision expandIntoDecisions map[optimize.TraversalStepTarget]optimize.ExpandIntoDecision traversalDirectionDecisions map[optimize.TraversalStepTarget]optimize.TraversalDirectionDecision shortestPathStrategyDecisions map[optimize.TraversalStepTarget]optimize.ShortestPathStrategyDecision @@ -78,6 +79,7 @@ func (s *Translator) SetOptimizationPlan(plan optimize.Plan) { s.projectionPruningDecisions = map[optimize.TraversalStepTarget]optimize.ProjectionPruningDecision{} s.latePathDecisions = map[optimize.TraversalStepTarget][]optimize.LatePathMaterializationDecision{} s.suffixPushdownDecisions = map[optimize.TraversalStepTarget][]optimize.ExpansionSuffixPushdownDecision{} + s.predicatePlacementDecisions = map[optimize.TraversalStepTarget][]optimize.PredicatePlacementDecision{} s.expandIntoDecisions = map[optimize.TraversalStepTarget]optimize.ExpandIntoDecision{} s.traversalDirectionDecisions = map[optimize.TraversalStepTarget]optimize.TraversalDirectionDecision{} s.shortestPathStrategyDecisions = map[optimize.TraversalStepTarget]optimize.ShortestPathStrategyDecision{} @@ -97,6 +99,10 @@ func (s *Translator) SetOptimizationPlan(plan optimize.Plan) { s.suffixPushdownDecisions[decision.Target] = append(s.suffixPushdownDecisions[decision.Target], decision) } + for _, decision := range plan.LoweringPlan.PredicatePlacement { + s.predicatePlacementDecisions[decision.Target] = append(s.predicatePlacementDecisions[decision.Target], decision) + } + for _, decision := range plan.LoweringPlan.ExpandInto { s.expandIntoDecisions[decision.Target] = decision } diff --git a/cypher/models/pgsql/translate/traversal.go b/cypher/models/pgsql/translate/traversal.go index 54883885..0747b628 100644 --- a/cypher/models/pgsql/translate/traversal.go +++ b/cypher/models/pgsql/translate/traversal.go @@ -997,7 +997,11 @@ func (s *Translator) translateTraversalPatternPartWithoutExpansion(part *Pattern if err := s.applyPatternConstraintBalance(part, stepIndex, &constraints, traversalStep); err != nil { return err } + } + + s.recordPredicatePlacementConsumption(part, stepIndex, traversalStep, constraints) + if isFirstTraversalStep { hasPreviousFrame := traversalStep.Frame.Previous != nil if hasPreviousFrame { From 1852b37a3998dcce13244b824a0108eec6eb3b3b Mon Sep 17 00:00:00 2001 From: John Hopper Date: Fri, 22 May 2026 17:32:42 -0700 Subject: [PATCH 056/116] Constrain predicate placement planning to clause --- .../models/pgsql/optimize/OPTIMIZATION_PLAN.md | 8 ++++++++ cypher/models/pgsql/optimize/lowering_plan.go | 3 +++ cypher/models/pgsql/optimize/optimizer_test.go | 18 ++++++++++++++++++ 3 files changed, 29 insertions(+) diff --git a/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md b/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md index fc3324ce..beacd2b2 100644 --- a/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md +++ b/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md @@ -90,3 +90,11 @@ Status: completed - Keep skipped-lowering reports focused on predicates that were not consumed by the emitted translation shape, instead of marking already-pushed traversal predicates as skipped. - Add SQL-shape regression tests for fixed traversal and expansion-root predicate consumption. - Refreshed plan-corpus capture applies `PredicatePlacement` in 56 of 71 planned PostgreSQL cases, reducing skipped predicate placements from 65 to 15. + +## Phase 8: Cross-Clause Predicate Placement Planning + +Status: completed + +- Stop planning traversal predicate placements for binding predicates owned by a different `MATCH` clause. +- Preserve same-clause binding predicate placement for traversal and suffix pushdown decisions. +- Refreshed plan-corpus capture now plans and applies `PredicatePlacement` in the same 56 PostgreSQL cases, removing all skipped predicate-placement reports. diff --git a/cypher/models/pgsql/optimize/lowering_plan.go b/cypher/models/pgsql/optimize/lowering_plan.go index 72036350..f12aa982 100644 --- a/cypher/models/pgsql/optimize/lowering_plan.go +++ b/cypher/models/pgsql/optimize/lowering_plan.go @@ -793,6 +793,9 @@ func appendPredicatePlacementDecisions(plan *LoweringPlan, query *cypher.Regular if !hasTarget { continue } + if target.ClauseIndex != attachment.ClauseIndex { + continue + } plan.PredicatePlacement = append(plan.PredicatePlacement, PredicatePlacementDecision{ Target: target, diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index 111e17a7..18bb0de5 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -489,6 +489,24 @@ func TestLoweringPlanPlacesBindingPredicates(t *testing.T) { require.Equal(t, []PredicateAttachment{plan.LoweringPlan.PredicatePlacement[0].Attachment}, plan.LoweringPlan.ExpansionSuffixPushdown[0].PredicateAttachments) } +func TestLoweringPlanDoesNotPlaceCrossClauseBindingPredicates(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH (n:Group) + WHERE n.objectid = 'S-1-5-21-1' + MATCH p = (n)-[:MemberOf*1..]->(ca:EnterpriseCA) + RETURN p + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.NotEmpty(t, plan.PredicateAttachments) + require.Empty(t, plan.LoweringPlan.PredicatePlacement) + require.NotContains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringPredicatePlacement}) +} + func TestLoweringPlanReportsExpandInto(t *testing.T) { t.Parallel() From 9d9c1e8e8a378c8bb76158cef6072ab9d3144418 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Fri, 22 May 2026 17:46:01 -0700 Subject: [PATCH 057/116] Preserve endpoints in edge count fast path --- .../translation_cases/stepwise_traversal.sql | 2 +- .../models/pgsql/translate/count_fast_path.go | 27 +++++- .../pgsql/translate/optimizer_safety_test.go | 2 +- integration/pgsql_count_fast_path_test.go | 94 +++++++++++++++++++ 4 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 integration/pgsql_count_fast_path_test.go diff --git a/cypher/models/pgsql/test/translation_cases/stepwise_traversal.sql b/cypher/models/pgsql/test/translation_cases/stepwise_traversal.sql index a33ed6c6..c658814c 100644 --- a/cypher/models/pgsql/test/translation_cases/stepwise_traversal.sql +++ b/cypher/models/pgsql/test/translation_cases/stepwise_traversal.sql @@ -45,7 +45,7 @@ with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::e with s0 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 [1]::int2[] and n0.id = e0.start_id join node n1 on ('123' = any (jsonb_to_text_array((n1.properties -> 'prop2'))::text[]) or '243' = any (jsonb_to_text_array((n1.properties -> 'prop2'))::text[]) or jsonb_array_length((n1.properties -> 'prop2'))::int = 0) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[]) limit 10) select case when (s0.n0).id is null or s0.e0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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 [s0.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 limit 10; -- case: match ()-[r:EdgeKind1]->() return count(r) as the_count -select count(*)::int8 as the_count from edge e0 where e0.kind_id = any (array [3]::int2[]); +select count(*)::int8 as the_count 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 = any (array [3]::int2[]); -- case: match ()-[r:EdgeKind1]->() return count(r) as the_count limit 1 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 = any (array [3]::int2[])) select count(s0.e0)::int8 as the_count from s0 limit 1; diff --git a/cypher/models/pgsql/translate/count_fast_path.go b/cypher/models/pgsql/translate/count_fast_path.go index c3c27cb9..f720bfae 100644 --- a/cypher/models/pgsql/translate/count_fast_path.go +++ b/cypher/models/pgsql/translate/count_fast_path.go @@ -10,8 +10,10 @@ import ( ) const ( - countStoreNodeAlias pgsql.Identifier = "n0" - countStoreEdgeAlias pgsql.Identifier = "e0" + countStoreNodeAlias pgsql.Identifier = "n0" + countStoreEdgeAlias pgsql.Identifier = "e0" + countStoreStartEndpointAlias pgsql.Identifier = "n0" + countStoreEndEndpointAlias pgsql.Identifier = "n1" ) type countStoreFastPathShape struct { @@ -78,6 +80,10 @@ func (s *Translator) countStoreFastPathFromAndWhere(shape countStoreFastPathShap Name: pgsql.TableEdge.AsCompoundIdentifier(), Binding: pgsql.AsOptionalIdentifier(countStoreEdgeAlias), }, + Joins: []pgsql.Join{ + countStoreEndpointJoin(countStoreStartEndpointAlias, pgsql.ColumnStartID), + countStoreEndpointJoin(countStoreEndEndpointAlias, pgsql.ColumnEndID), + }, }, where, err default: @@ -85,6 +91,23 @@ func (s *Translator) countStoreFastPathFromAndWhere(shape countStoreFastPathShap } } +func countStoreEndpointJoin(nodeAlias pgsql.Identifier, edgeEndpoint pgsql.Identifier) pgsql.Join { + return pgsql.Join{ + Table: pgsql.TableReference{ + Name: pgsql.TableNode.AsCompoundIdentifier(), + Binding: pgsql.AsOptionalIdentifier(nodeAlias), + }, + JoinOperator: pgsql.JoinOperator{ + JoinType: pgsql.JoinTypeInner, + Constraint: pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{nodeAlias, pgsql.ColumnID}, + pgsql.OperatorEquals, + pgsql.CompoundIdentifier{countStoreEdgeAlias, edgeEndpoint}, + ), + }, + } +} + func (s *Translator) countStoreNodeKindConstraint(kinds graph.Kinds) (pgsql.Expression, error) { if len(kinds) == 0 { return nil, nil diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index 3cb8b715..f57fe8bf 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -181,7 +181,7 @@ func TestOptimizerSafetyCountStoreFastPathUsesBaseEdgeCount(t *testing.T) { requirePlannedOptimizationLowering(t, translation.Optimization, optimize.LoweringCountStoreFastPath) requireOptimizationLowering(t, translation.Optimization, optimize.LoweringCountStoreFastPath) requireSkippedOptimizationLowering(t, translation.Optimization, optimize.LoweringProjectionPruning, "superseded by CountStoreFastPath") - require.Equal(t, "select count(*)::int8 from edge e0 where e0.kind_id = any (array [10]::int2[]);", strings.Join(strings.Fields(formattedQuery), " ")) + require.Equal(t, "select count(*)::int8 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 = any (array [10]::int2[]);", strings.Join(strings.Fields(formattedQuery), " ")) } func TestOptimizerSafetyADCSQueryPrunesExpansionEdgeCarry(t *testing.T) { diff --git a/integration/pgsql_count_fast_path_test.go b/integration/pgsql_count_fast_path_test.go new file mode 100644 index 00000000..a29a9610 --- /dev/null +++ b/integration/pgsql_count_fast_path_test.go @@ -0,0 +1,94 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build manual_integration + +package integration + +import ( + "errors" + "os" + "testing" + + "github.com/specterops/dawgs/drivers/pg" + "github.com/specterops/dawgs/graph" +) + +func TestPostgreSQLCountStoreFastPathRequiresRelationshipEndpoints(t *testing.T) { + connStr := os.Getenv("CONNECTION_STRING") + if connStr == "" { + t.Skip("CONNECTION_STRING env var is not set") + } + + driver, err := driverFromConnStr(connStr) + if err != nil { + t.Fatalf("failed to detect driver: %v", err) + } + if driver != pg.DriverName { + t.Skip("CONNECTION_STRING is not a PostgreSQL connection string") + } + + var ( + nodeKind = graph.StringKind("CountFastPathNode") + edgeKind = graph.StringKind("CountFastPathEdge") + db, ctx = SetupDBWithKinds(t, graph.Kinds{nodeKind}, graph.Kinds{edgeKind}) + ) + + if err := db.WriteTransaction(ctx, func(tx graph.Transaction) error { + start, err := tx.CreateNode(graph.NewProperties(), nodeKind) + if err != nil { + return err + } + + if _, err := tx.CreateRelationshipByIDs(start.ID, 0, edgeKind, graph.NewProperties()); err != nil { + return err + } + + return nil + }); err != nil { + t.Fatalf("failed to create dangling relationship fixture: %v", err) + } + + var count int64 + if err := db.ReadTransaction(ctx, func(tx graph.Transaction) error { + result := tx.Query("MATCH ()-[r:CountFastPathEdge]->() RETURN count(r)", nil) + defer result.Close() + + if !result.Next() { + if err := result.Error(); err != nil { + return err + } + + return errors.New("expected count row") + } + + if err := result.Scan(&count); err != nil { + return err + } + + if result.Next() { + return errors.New("expected one count row") + } + + return result.Error() + }); err != nil { + t.Fatalf("query failed: %v", err) + } + + if count != 0 { + t.Fatalf("relationship count: got %d, want 0", count) + } +} From 52a05130fa41956a06c95ae571708b67b0873fe2 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Fri, 22 May 2026 17:48:00 -0700 Subject: [PATCH 058/116] Count partially skipped lowerings --- .../pgsql/translate/optimizer_safety_test.go | 32 +++++++++++++++++ cypher/models/pgsql/translate/translator.go | 34 ++++++++++++++----- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index f57fe8bf..ea029887 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -127,6 +127,19 @@ func requireSkippedOptimizationLowering(t *testing.T, summary OptimizationSummar require.Failf(t, "missing skipped optimization lowering", "expected skipped lowering %q in %#v", name, summary.SkippedLowerings) } +func requireSkippedOptimizationLoweringCount(t *testing.T, summary OptimizationSummary, name string, count int) { + t.Helper() + + for _, lowering := range summary.SkippedLowerings { + if lowering.Name == name { + require.Equal(t, count, lowering.Count) + return + } + } + + require.Failf(t, "missing skipped optimization lowering", "expected skipped lowering %q in %#v", name, summary.SkippedLowerings) +} + func requireNoSkippedOptimizationLowering(t *testing.T, summary OptimizationSummary, name string) { t.Helper() @@ -135,6 +148,25 @@ func requireNoSkippedOptimizationLowering(t *testing.T, summary OptimizationSumm } } +func TestOptimizerSafetyReportsPartiallySkippedLowerings(t *testing.T) { + t.Parallel() + + translator := NewTranslator(context.Background(), optimizerSafetyKindMapper(), nil, DefaultGraphID) + translator.translation.Optimization.LoweringPlan = &optimize.LoweringPlan{ + PredicatePlacement: []optimize.PredicatePlacementDecision{ + {Target: optimize.TraversalStepTarget{StepIndex: 0}}, + {Target: optimize.TraversalStepTarget{StepIndex: 1}}, + }, + } + + translator.recordLowering(optimize.LoweringPredicatePlacement) + translator.recordSkippedLowerings() + + requireOptimizationLowering(t, translator.translation.Optimization, optimize.LoweringPredicatePlacement) + requireSkippedOptimizationLowering(t, translator.translation.Optimization, optimize.LoweringPredicatePlacement, "planned predicate placements were not consumed by this translation shape") + requireSkippedOptimizationLoweringCount(t, translator.translation.Optimization, optimize.LoweringPredicatePlacement, 1) +} + func requireSQLContainsInOrder(t *testing.T, sql string, parts ...string) { t.Helper() diff --git a/cypher/models/pgsql/translate/translator.go b/cypher/models/pgsql/translate/translator.go index 45706619..306c2efc 100644 --- a/cypher/models/pgsql/translate/translator.go +++ b/cypher/models/pgsql/translate/translator.go @@ -30,6 +30,7 @@ type Translator struct { scope *Scope unwindTargets map[*cypher.Variable]struct{} + appliedLoweringCounts map[string]int patternTargets map[*cypher.PatternPart]optimize.PatternTarget patternPredicateTargets map[*cypher.PatternPredicate]optimize.PatternTarget projectionPruningDecisions map[optimize.TraversalStepTarget]optimize.ProjectionPruningDecision @@ -616,6 +617,11 @@ type SkippedLowering struct { } func (s *Translator) recordLowering(name string) { + if s.appliedLoweringCounts == nil { + s.appliedLoweringCounts = map[string]int{} + } + s.appliedLoweringCounts[name]++ + for _, lowering := range s.translation.Optimization.Lowerings { if lowering.Name == name { return @@ -625,29 +631,41 @@ func (s *Translator) recordLowering(name string) { s.translation.Optimization.Lowerings = append(s.translation.Optimization.Lowerings, optimize.LoweringDecision{Name: name}) } +func (s *Translator) appliedLoweringCountSnapshot() map[string]int { + applied := map[string]int{} + + for _, lowering := range s.translation.Optimization.Lowerings { + applied[lowering.Name] = 1 + } + + for name, count := range s.appliedLoweringCounts { + applied[name] = count + } + + return applied +} + func (s *Translator) recordSkippedLowerings() { if s.translation.Optimization.LoweringPlan == nil { return } - applied := map[string]struct{}{} - for _, lowering := range s.translation.Optimization.Lowerings { - applied[lowering.Name] = struct{}{} - } + applied := s.appliedLoweringCountSnapshot() for _, planned := range plannedLoweringCounts(*s.translation.Optimization.LoweringPlan) { if planned.Count == 0 { continue } - if _, wasApplied := applied[planned.Name]; wasApplied { + skippedCount := planned.Count - applied[planned.Name] + if skippedCount <= 0 { continue } s.translation.Optimization.SkippedLowerings = append(s.translation.Optimization.SkippedLowerings, SkippedLowering{ Name: planned.Name, Reason: skippedLoweringReason(planned.Name, applied), - Count: planned.Count, + Count: skippedCount, }) } } @@ -667,8 +685,8 @@ func plannedLoweringCounts(plan optimize.LoweringPlan) []SkippedLowering { } } -func skippedLoweringReason(name string, applied map[string]struct{}) string { - if _, countFastPathApplied := applied[optimize.LoweringCountStoreFastPath]; countFastPathApplied && name != optimize.LoweringCountStoreFastPath { +func skippedLoweringReason(name string, applied map[string]int) string { + if applied[optimize.LoweringCountStoreFastPath] > 0 && name != optimize.LoweringCountStoreFastPath { return "superseded by CountStoreFastPath" } From 0c0ceb00890346afc23effca4af19661d720b45a Mon Sep 17 00:00:00 2001 From: John Hopper Date: Fri, 22 May 2026 17:50:44 -0700 Subject: [PATCH 059/116] Honor Neo4j plan corpus connection URIs --- README.md | 2 + cmd/plancorpus/capture.go | 80 ++++++++++++++++++++++--- cmd/plancorpus/main_test.go | 52 +++++++++++++++- drivers/neo4j/batch_integration_test.go | 2 +- drivers/neo4j/driver.go | 22 ++++--- drivers/neo4j/neo4j.go | 46 ++++++++++++-- drivers/neo4j/neo4j_internal_test.go | 56 +++++++++++++++++ 7 files changed, 235 insertions(+), 25 deletions(-) create mode 100644 drivers/neo4j/neo4j_internal_test.go diff --git a/README.md b/README.md index c37f58d6..2d022e07 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ export CONNECTION_STRING="postgresql://dawgs:weneedbetterpasswords@localhost:654 export CONNECTION_STRING="neo4j://neo4j:weneedbetterpasswords@localhost:7687" ``` +Neo4j connection strings may use `neo4j://`, `neo4j+s://`, or `neo4j+ssc://`; a single path segment selects the Neo4j database name. + Use `make test` for unit tests only and `make test_integration` for integration tests only. ### Test Metrics diff --git a/cmd/plancorpus/capture.go b/cmd/plancorpus/capture.go index e69802fb..4b746e51 100644 --- a/cmd/plancorpus/capture.go +++ b/cmd/plancorpus/capture.go @@ -35,6 +35,7 @@ type backendCapture struct { pgDriver *pg.Driver pgGraphID int32 neo4jDriver neo4jcore.Driver + neo4jDBName string } func driverFromConnectionString(connStr string) (string, error) { @@ -210,12 +211,13 @@ func openBackend(ctx context.Context, suite corpus, spec captureSpec) (*backendC backend.pgGraphID = defaultGraph.ID case neo4j.DriverName: - neo4jDriver, err := openNeo4jPlanDriver(spec.Connection) + neo4jDriver, databaseName, err := openNeo4jPlanDriver(spec.Connection) if err != nil { _ = db.Close(ctx) return nil, err } backend.neo4jDriver = neo4jDriver + backend.neo4jDBName = databaseName } return backend, nil @@ -298,7 +300,8 @@ func (s *backendCapture) capturePostgres(ctx context.Context, cypherQuery string func (s *backendCapture) captureNeo4j(cypherQuery string, params map[string]any, record *PlanRecord) { session := s.neo4jDriver.NewSession(neo4jcore.SessionConfig{ - AccessMode: neo4jcore.AccessModeWrite, + AccessMode: neo4jcore.AccessModeWrite, + DatabaseName: s.neo4jDBName, }) defer session.Close() @@ -321,21 +324,82 @@ func (s *backendCapture) captureNeo4j(cypherQuery string, params map[string]any, } } -func openNeo4jPlanDriver(connStr string) (neo4jcore.Driver, error) { +type neo4jPlanDriverConfig struct { + Target string + Username string + Password string + DatabaseName string +} + +func parseNeo4jPlanDriverConfig(connStr string) (neo4jPlanDriverConfig, error) { connectionURL, err := url.Parse(connStr) if err != nil { - return nil, fmt.Errorf("parse Neo4j connection string: %w", err) + return neo4jPlanDriverConfig{}, fmt.Errorf("parse Neo4j connection string: %w", err) + } + + if connectionURL.Scheme != neo4j.DriverName && connectionURL.Scheme != "neo4j+s" && connectionURL.Scheme != "neo4j+ssc" { + return neo4jPlanDriverConfig{}, fmt.Errorf("expected Neo4j connection string scheme, got %q", connectionURL.Scheme) } password, ok := connectionURL.User.Password() if !ok { - return nil, fmt.Errorf("no password provided in Neo4j connection string") + return neo4jPlanDriverConfig{}, fmt.Errorf("no password provided in Neo4j connection string") + } + + if connectionURL.Host == "" { + return neo4jPlanDriverConfig{}, fmt.Errorf("Neo4j connection string host is required") + } + + databaseName, err := neo4jDatabaseName(connectionURL) + if err != nil { + return neo4jPlanDriverConfig{}, err + } + + return neo4jPlanDriverConfig{ + Target: (&url.URL{ + Scheme: connectionURL.Scheme, + Host: connectionURL.Host, + RawQuery: connectionURL.RawQuery, + }).String(), + Username: connectionURL.User.Username(), + Password: password, + DatabaseName: databaseName, + }, nil +} + +func neo4jDatabaseName(connectionURL *url.URL) (string, error) { + databasePath := strings.Trim(connectionURL.EscapedPath(), "/") + if databasePath == "" { + return "", nil + } + + if strings.Contains(databasePath, "/") { + return "", fmt.Errorf("Neo4j database path must contain a single database name") + } + + databaseName, err := url.PathUnescape(databasePath) + if err != nil { + return "", fmt.Errorf("parse Neo4j database name: %w", err) + } + + return databaseName, nil +} + +func openNeo4jPlanDriver(connStr string) (neo4jcore.Driver, string, error) { + cfg, err := parseNeo4jPlanDriverConfig(connStr) + if err != nil { + return nil, "", err } - return neo4jcore.NewDriver( - "bolt://"+connectionURL.Host, - neo4jcore.BasicAuth(connectionURL.User.Username(), password, ""), + driver, err := neo4jcore.NewDriver( + cfg.Target, + neo4jcore.BasicAuth(cfg.Username, cfg.Password, ""), ) + if err != nil { + return nil, "", err + } + + return driver, cfg.DatabaseName, nil } func clearGraph(ctx context.Context, db graph.Database) error { diff --git a/cmd/plancorpus/main_test.go b/cmd/plancorpus/main_test.go index 51aa5af9..c0f696be 100644 --- a/cmd/plancorpus/main_test.go +++ b/cmd/plancorpus/main_test.go @@ -32,10 +32,56 @@ func TestDriverFromConnectionString(t *testing.T) { require.NoError(t, err) require.Equal(t, "pg", driverName) - driverName, err = driverFromConnectionString("neo4j://neo4j:password@localhost:7687") - require.NoError(t, err) - require.Equal(t, "neo4j", driverName) + for _, connStr := range []string{ + "neo4j://neo4j:password@localhost:7687", + "neo4j+s://neo4j:password@localhost:7687", + "neo4j+ssc://neo4j:password@localhost:7687", + } { + driverName, err = driverFromConnectionString(connStr) + require.NoError(t, err) + require.Equal(t, "neo4j", driverName) + } _, err = driverFromConnectionString("mysql://localhost") require.ErrorContains(t, err, "unknown connection string scheme") } + +func TestParseNeo4jPlanDriverConfigPreservesURI(t *testing.T) { + testCases := []struct { + name string + connStr string + expectedTarget string + expectedDatabase string + }{{ + name: "plain routing", + connStr: "neo4j://neo4j:password@localhost:7687", + expectedTarget: "neo4j://localhost:7687", + expectedDatabase: "", + }, { + name: "secure routing", + connStr: "neo4j+s://neo4j:password@cluster.example:7687", + expectedTarget: "neo4j+s://cluster.example:7687", + expectedDatabase: "", + }, { + name: "self signed routing with database and query", + connStr: "neo4j+ssc://neo4j:password@cluster.example:7687/analytics?policy=fast", + expectedTarget: "neo4j+ssc://cluster.example:7687?policy=fast", + expectedDatabase: "analytics", + }} + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + cfg, err := parseNeo4jPlanDriverConfig(testCase.connStr) + require.NoError(t, err) + require.Equal(t, testCase.expectedTarget, cfg.Target) + require.Equal(t, "neo4j", cfg.Username) + require.Equal(t, "password", cfg.Password) + require.Equal(t, testCase.expectedDatabase, cfg.DatabaseName) + }) + } +} + +func TestParseNeo4jPlanDriverConfigRejectsNestedDatabasePath(t *testing.T) { + _, err := parseNeo4jPlanDriverConfig("neo4j://neo4j:password@localhost:7687/db/extra") + require.ErrorContains(t, err, "single database name") +} diff --git a/drivers/neo4j/batch_integration_test.go b/drivers/neo4j/batch_integration_test.go index 80eb96c6..798db901 100644 --- a/drivers/neo4j/batch_integration_test.go +++ b/drivers/neo4j/batch_integration_test.go @@ -42,7 +42,7 @@ func prepareNode(index int) *graph.Node { func isNeo4jConnectionString(connStr string) bool { u, err := url.Parse(connStr) - return err == nil && u.Scheme == neo4j.DriverName + return err == nil && (u.Scheme == neo4j.DriverName || u.Scheme == "neo4j+s" || u.Scheme == "neo4j+ssc") } func TestBatchTransaction_NodeUpdate(t *testing.T) { diff --git a/drivers/neo4j/driver.go b/drivers/neo4j/driver.go index d78b8511..3c9fa317 100644 --- a/drivers/neo4j/driver.go +++ b/drivers/neo4j/driver.go @@ -14,16 +14,19 @@ const ( DriverName = "neo4j" ) -func readCfg() neo4j.SessionConfig { +func sessionConfig(accessMode neo4j.AccessMode, databaseName string) neo4j.SessionConfig { return neo4j.SessionConfig{ - AccessMode: neo4j.AccessModeRead, + AccessMode: accessMode, + DatabaseName: databaseName, } } -func writeCfg() neo4j.SessionConfig { - return neo4j.SessionConfig{ - AccessMode: neo4j.AccessModeWrite, - } +func (s *driver) readCfg() neo4j.SessionConfig { + return sessionConfig(neo4j.AccessModeRead, s.databaseName) +} + +func (s *driver) writeCfg() neo4j.SessionConfig { + return sessionConfig(neo4j.AccessModeWrite, s.databaseName) } type driver struct { @@ -33,6 +36,7 @@ type driver struct { batchWriteSize int writeFlushSize int graphQueryMemoryLimit size.Size + databaseName string } func (s *driver) SetBatchWriteSize(size int) { @@ -64,7 +68,7 @@ func (s *driver) BatchOperation(ctx context.Context, batchDelegate graph.BatchDe Timeout: s.defaultTransactionTimeout, } - session = s.driver.NewSession(writeCfg()) + session = s.driver.NewSession(s.writeCfg()) batch = newBatchOperation(ctx, session, cfg, s.writeFlushSize, config.BatchSize, s.graphQueryMemoryLimit) ) @@ -110,14 +114,14 @@ func (s *driver) transaction(ctx context.Context, txDelegate graph.TransactionDe } func (s *driver) ReadTransaction(ctx context.Context, txDelegate graph.TransactionDelegate, options ...graph.TransactionOption) error { - session := s.driver.NewSession(readCfg()) + session := s.driver.NewSession(s.readCfg()) defer session.Close() return s.transaction(ctx, txDelegate, session, options) } func (s *driver) WriteTransaction(ctx context.Context, txDelegate graph.TransactionDelegate, options ...graph.TransactionOption) error { - session := s.driver.NewSession(writeCfg()) + session := s.driver.NewSession(s.writeCfg()) defer session.Close() return s.transaction(ctx, txDelegate, session, options) diff --git a/drivers/neo4j/neo4j.go b/drivers/neo4j/neo4j.go index c6a5ea2a..7a4f385a 100644 --- a/drivers/neo4j/neo4j.go +++ b/drivers/neo4j/neo4j.go @@ -5,6 +5,7 @@ import ( "fmt" "math" "net/url" + "strings" "github.com/neo4j/neo4j-go-driver/v5/neo4j" "github.com/specterops/dawgs" @@ -21,14 +22,16 @@ const ( func newNeo4jDB(_ context.Context, cfg dawgs.Config) (graph.Database, error) { if connectionURL, err := url.Parse(cfg.ConnectionString); err != nil { return nil, err - } else if connectionURL.Scheme != DriverName { + } else if !isNeo4jConnectionScheme(connectionURL.Scheme) { return nil, fmt.Errorf("expected connection URL scheme %s for Neo4J but got %s", DriverName, connectionURL.Scheme) } else if password, isSet := connectionURL.User.Password(); !isSet { return nil, fmt.Errorf("no password provided in connection URL") + } else if target, err := neo4jConnectionTarget(connectionURL); err != nil { + return nil, err + } else if databaseName, err := neo4jConnectionDatabaseName(connectionURL); err != nil { + return nil, err } else { - boltURL := fmt.Sprintf("bolt://%s:%s", connectionURL.Hostname(), connectionURL.Port()) - - if internalDriver, err := neo4j.NewDriver(boltURL, neo4j.BasicAuth(connectionURL.User.Username(), password, "")); err != nil { + if internalDriver, err := neo4j.NewDriver(target, neo4j.BasicAuth(connectionURL.User.Username(), password, "")); err != nil { return nil, fmt.Errorf("unable to connect to Neo4J: %w", err) } else { return &driver{ @@ -38,11 +41,46 @@ func newNeo4jDB(_ context.Context, cfg dawgs.Config) (graph.Database, error) { writeFlushSize: DefaultWriteFlushSize, batchWriteSize: DefaultBatchWriteSize, graphQueryMemoryLimit: cfg.GraphQueryMemoryLimit, + databaseName: databaseName, }, nil } } } +func isNeo4jConnectionScheme(scheme string) bool { + return scheme == DriverName || scheme == "neo4j+s" || scheme == "neo4j+ssc" +} + +func neo4jConnectionTarget(connectionURL *url.URL) (string, error) { + if connectionURL.Host == "" { + return "", fmt.Errorf("Neo4j connection string host is required") + } + + return (&url.URL{ + Scheme: connectionURL.Scheme, + Host: connectionURL.Host, + RawQuery: connectionURL.RawQuery, + }).String(), nil +} + +func neo4jConnectionDatabaseName(connectionURL *url.URL) (string, error) { + databasePath := strings.Trim(connectionURL.EscapedPath(), "/") + if databasePath == "" { + return "", nil + } + + if strings.Contains(databasePath, "/") { + return "", fmt.Errorf("Neo4j database path must contain a single database name") + } + + databaseName, err := url.PathUnescape(databasePath) + if err != nil { + return "", fmt.Errorf("parse Neo4j database name: %w", err) + } + + return databaseName, nil +} + func init() { dawgs.Register(DriverName, func(ctx context.Context, cfg dawgs.Config) (graph.Database, error) { return newNeo4jDB(ctx, cfg) diff --git a/drivers/neo4j/neo4j_internal_test.go b/drivers/neo4j/neo4j_internal_test.go new file mode 100644 index 00000000..cfa77f5d --- /dev/null +++ b/drivers/neo4j/neo4j_internal_test.go @@ -0,0 +1,56 @@ +package neo4j + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNeo4jConnectionTargetPreservesAcceptedSchemes(t *testing.T) { + testCases := []struct { + name string + connStr string + expectedTarget string + expectedDatabase string + }{{ + name: "plain routing", + connStr: "neo4j://neo4j:password@localhost:7687", + expectedTarget: "neo4j://localhost:7687", + expectedDatabase: "", + }, { + name: "secure routing", + connStr: "neo4j+s://neo4j:password@cluster.example:7687", + expectedTarget: "neo4j+s://cluster.example:7687", + expectedDatabase: "", + }, { + name: "self signed routing with database and query", + connStr: "neo4j+ssc://neo4j:password@cluster.example:7687/analytics?policy=fast", + expectedTarget: "neo4j+ssc://cluster.example:7687?policy=fast", + expectedDatabase: "analytics", + }} + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + connectionURL, err := url.Parse(testCase.connStr) + require.NoError(t, err) + require.True(t, isNeo4jConnectionScheme(connectionURL.Scheme)) + + target, err := neo4jConnectionTarget(connectionURL) + require.NoError(t, err) + require.Equal(t, testCase.expectedTarget, target) + + databaseName, err := neo4jConnectionDatabaseName(connectionURL) + require.NoError(t, err) + require.Equal(t, testCase.expectedDatabase, databaseName) + }) + } +} + +func TestNeo4jConnectionDatabaseNameRejectsNestedPath(t *testing.T) { + connectionURL, err := url.Parse("neo4j://neo4j:password@localhost:7687/db/extra") + require.NoError(t, err) + + _, err = neo4jConnectionDatabaseName(connectionURL) + require.ErrorContains(t, err, "single database name") +} From 4c7c401570eb9e47a502c1e006467b8592da7ce0 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Fri, 22 May 2026 17:51:59 -0700 Subject: [PATCH 060/116] Wire count star fast path planning --- .../pgsql/optimize/OPTIMIZATION_PLAN.md | 1 + cypher/models/pgsql/optimize/lowering_plan.go | 16 +++++++++--- .../models/pgsql/optimize/optimizer_test.go | 22 ++++++++++++++++ .../models/pgsql/translate/count_fast_path.go | 16 +++++++++--- .../pgsql/translate/optimizer_safety_test.go | 25 +++++++++++++++++++ 5 files changed, 72 insertions(+), 8 deletions(-) diff --git a/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md b/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md index beacd2b2..6b0b4937 100644 --- a/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md +++ b/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md @@ -22,6 +22,7 @@ Status: completed - Add count-store fast paths for simple count queries: - `MATCH (n) RETURN count(n)` - `MATCH ()-[r]->() RETURN count(r)` + - `MATCH (...) RETURN count(*)` for the same exact node and directed-edge shapes. - Typed variants where kind filters map cleanly. - Implemented as `CountStoreFastPath` lowering for exact node and directed-edge count shapes. - Audit the planned/applied `PredicatePlacement` gap. diff --git a/cypher/models/pgsql/optimize/lowering_plan.go b/cypher/models/pgsql/optimize/lowering_plan.go index f12aa982..4dfd2912 100644 --- a/cypher/models/pgsql/optimize/lowering_plan.go +++ b/cypher/models/pgsql/optimize/lowering_plan.go @@ -933,12 +933,20 @@ func simpleCountProjectionArgument(returnClause *cypher.Return) (string, bool) { return "", false } - variable, ok := function.Arguments[0].(*cypher.Variable) - if !ok || variable == nil { - return "", false + switch argument := function.Arguments[0].(type) { + case *cypher.Variable: + if argument == nil { + return "", false + } + + return argument.Symbol, true + case *cypher.RangeQuantifier: + if argument != nil && argument.Value == cypher.TokenLiteralAsterisk { + return cypher.TokenLiteralAsterisk, true + } } - return variable.Symbol, true + return "", false } func constrainedCountFastPathEndpoint(nodePattern *cypher.NodePattern) bool { diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index 18bb0de5..c50ef14d 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -436,6 +436,17 @@ func TestLoweringPlanReportsCountStoreFastPath(t *testing.T) { KindSymbols: []string{"Group"}, }, }, + { + name: "node count star", + query: "MATCH (:Group) RETURN count(*)", + expected: CountStoreFastPathDecision{ + QueryPartIndex: 0, + ClauseIndex: 0, + PatternIndex: 0, + Target: CountStoreFastPathNode, + KindSymbols: []string{"Group"}, + }, + }, { name: "edge count", query: "MATCH ()-[r:MemberOf]->() RETURN count(r)", @@ -448,6 +459,17 @@ func TestLoweringPlanReportsCountStoreFastPath(t *testing.T) { KindSymbols: []string{"MemberOf"}, }, }, + { + name: "edge count star", + query: "MATCH ()-[:MemberOf]->() RETURN count(*)", + expected: CountStoreFastPathDecision{ + QueryPartIndex: 0, + ClauseIndex: 0, + PatternIndex: 0, + Target: CountStoreFastPathEdge, + KindSymbols: []string{"MemberOf"}, + }, + }, } for _, testCase := range testCases { diff --git a/cypher/models/pgsql/translate/count_fast_path.go b/cypher/models/pgsql/translate/count_fast_path.go index f720bfae..29387061 100644 --- a/cypher/models/pgsql/translate/count_fast_path.go +++ b/cypher/models/pgsql/translate/count_fast_path.go @@ -245,12 +245,20 @@ func simpleCountProjection(returnClause *cypher.Return) (string, string, bool) { return "", "", false } - variable, ok := function.Arguments[0].(*cypher.Variable) - if !ok || variable == nil { - return "", "", false + switch argument := function.Arguments[0].(type) { + case *cypher.Variable: + if argument == nil { + return "", "", false + } + + return argument.Symbol, countStoreVariableSymbol(projectionItem.Alias), true + case *cypher.RangeQuantifier: + if argument != nil && argument.Value == cypher.TokenLiteralAsterisk { + return cypher.TokenLiteralAsterisk, countStoreVariableSymbol(projectionItem.Alias), true + } } - return variable.Symbol, countStoreVariableSymbol(projectionItem.Alias), true + return "", "", false } func constrainedCountStoreEndpoint(nodePattern *cypher.NodePattern) bool { diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index ea029887..96b82981 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -203,6 +203,18 @@ func TestOptimizerSafetyCountStoreFastPathKeepsKindConstraintAndAlias(t *testing require.Equal(t, "select count(*)::int8 as total from node n0 where n0.kind_ids operator (pg_catalog.@>) array [8]::int2[];", strings.Join(strings.Fields(formattedQuery), " ")) } +func TestOptimizerSafetyCountStoreFastPathSupportsNodeCountStar(t *testing.T) { + t.Parallel() + + translation := optimizerSafetyTranslation(t, `MATCH (:Group) RETURN count(*) AS total`) + formattedQuery, err := Translated(translation) + require.NoError(t, err) + + requirePlannedOptimizationLowering(t, translation.Optimization, optimize.LoweringCountStoreFastPath) + requireOptimizationLowering(t, translation.Optimization, optimize.LoweringCountStoreFastPath) + require.Equal(t, "select count(*)::int8 as total from node n0 where n0.kind_ids operator (pg_catalog.@>) array [8]::int2[];", strings.Join(strings.Fields(formattedQuery), " ")) +} + func TestOptimizerSafetyCountStoreFastPathUsesBaseEdgeCount(t *testing.T) { t.Parallel() @@ -216,6 +228,19 @@ func TestOptimizerSafetyCountStoreFastPathUsesBaseEdgeCount(t *testing.T) { require.Equal(t, "select count(*)::int8 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 = any (array [10]::int2[]);", strings.Join(strings.Fields(formattedQuery), " ")) } +func TestOptimizerSafetyCountStoreFastPathSupportsEdgeCountStar(t *testing.T) { + t.Parallel() + + translation := optimizerSafetyTranslation(t, `MATCH ()-[:MemberOf]->() RETURN count(*)`) + formattedQuery, err := Translated(translation) + require.NoError(t, err) + + requirePlannedOptimizationLowering(t, translation.Optimization, optimize.LoweringCountStoreFastPath) + requireOptimizationLowering(t, translation.Optimization, optimize.LoweringCountStoreFastPath) + requireSkippedOptimizationLowering(t, translation.Optimization, optimize.LoweringProjectionPruning, "superseded by CountStoreFastPath") + require.Equal(t, "select count(*)::int8 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 = any (array [10]::int2[]);", strings.Join(strings.Fields(formattedQuery), " ")) +} + func TestOptimizerSafetyADCSQueryPrunesExpansionEdgeCarry(t *testing.T) { t.Parallel() From 2c7cf03953a35cbc6820f9cf34358f8ebb374c37 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Fri, 22 May 2026 17:55:54 -0700 Subject: [PATCH 061/116] Validate optimization gap fixes Validated with PostgreSQL and Neo4j make test_all. From fe58aed1056b2864b978f03464610f110a4960c2 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Fri, 22 May 2026 19:55:42 -0700 Subject: [PATCH 062/116] Use typed text lookups for string equality --- cypher/models/pgsql/functions.go | 1 + cypher/models/pgsql/translate/expression.go | 169 ++++++++++++++------ cypher/models/pgsql/translate/property.go | 2 + 3 files changed, 126 insertions(+), 46 deletions(-) diff --git a/cypher/models/pgsql/functions.go b/cypher/models/pgsql/functions.go index d9e787f4..3d0b5f4e 100644 --- a/cypher/models/pgsql/functions.go +++ b/cypher/models/pgsql/functions.go @@ -12,6 +12,7 @@ const ( FunctionJSONBArrayElementsText Identifier = "jsonb_array_elements_text" FunctionJSONBBuildObject Identifier = "jsonb_build_object" FunctionJSONBArrayLength Identifier = "jsonb_array_length" + FunctionJSONBTypeof Identifier = "jsonb_typeof" FunctionToJSONB Identifier = "to_jsonb" FunctionCypherContains Identifier = "cypher_contains" FunctionCypherStartsWith Identifier = "cypher_starts_with" diff --git a/cypher/models/pgsql/translate/expression.go b/cypher/models/pgsql/translate/expression.go index 7161db3f..9e62ec2f 100644 --- a/cypher/models/pgsql/translate/expression.go +++ b/cypher/models/pgsql/translate/expression.go @@ -240,7 +240,7 @@ func rewritePropertyLookupOperator(propertyLookup *pgsql.BinaryExpression, dataT func isJSONScalarEqualityType(dataType pgsql.DataType) bool { switch dataType { - case pgsql.Boolean, pgsql.Float4, pgsql.Float8, pgsql.Int, pgsql.Int2, pgsql.Int4, pgsql.Int8, pgsql.Numeric, pgsql.Text: + case pgsql.Boolean, pgsql.Float4, pgsql.Float8, pgsql.Int, pgsql.Int2, pgsql.Int4, pgsql.Int8, pgsql.Numeric: return true default: @@ -248,51 +248,9 @@ func isJSONScalarEqualityType(dataType pgsql.DataType) bool { } } -func isBooleanTextCompatibilityValue(value any) bool { - switch value { - case "true", "false": - return true - - default: - return false - } -} - -func isBooleanTextCompatibilityParameter(kindMapper *contextAwareKindMapper, parameter pgsql.Parameter) bool { - if kindMapper == nil || parameter.TypeHint() != pgsql.Text { - return false - } - - value, hasValue := kindMapper.parameters[parameter.Identifier.String()] - return hasValue && isBooleanTextCompatibilityValue(value) -} - -func isBooleanTextCompatibilityOperand(kindMapper *contextAwareKindMapper, expression pgsql.Expression) bool { - switch typedExpression := expression.(type) { - case pgsql.Literal: - return typedExpression.TypeHint() == pgsql.Text && isBooleanTextCompatibilityValue(typedExpression.Value) - - case pgsql.Parameter: - return isBooleanTextCompatibilityParameter(kindMapper, typedExpression) - - case *pgsql.Parameter: - if typedExpression == nil { - return false - } - - return isBooleanTextCompatibilityParameter(kindMapper, *typedExpression) - - default: - return false - } -} - -func rewriteJSONScalarEqualityOperand(kindMapper *contextAwareKindMapper, expression pgsql.Expression) (pgsql.Expression, bool) { +func rewriteJSONScalarEqualityOperand(expression pgsql.Expression) (pgsql.Expression, bool) { if literal, isLiteral := expression.(pgsql.Literal); isLiteral && literal.Null { return nil, false - } else if isBooleanTextCompatibilityOperand(kindMapper, expression) { - // Preserve compatibility for existing callers that compare JSON boolean properties to stringified booleans. - return nil, false } if typedExpression, isTypeHinted := expression.(pgsql.TypeHinted); !isTypeHinted { @@ -310,6 +268,20 @@ func rewriteJSONScalarEqualityOperand(kindMapper *contextAwareKindMapper, expres } } +func rewriteStringEqualityOperand(expression pgsql.Expression) (pgsql.Expression, bool) { + if literal, isLiteral := expression.(pgsql.Literal); isLiteral && literal.Null { + return nil, false + } + + if typedExpression, isTypeHinted := expression.(pgsql.TypeHinted); !isTypeHinted { + return nil, false + } else if typedExpression.TypeHint() != pgsql.Text { + return nil, false + } + + return expression, true +} + func lookupRequiresElementType(typeHint pgsql.DataType, operator pgsql.Operator, otherOperand pgsql.SyntaxNode) bool { if typeHint.IsArrayType() { switch operator { @@ -381,7 +353,10 @@ func rewritePropertyLookupOperands(kindMapper *contextAwareKindMapper, expressio } case pgsql.OperatorEquals, pgsql.OperatorCypherNotEquals: - if rewrittenROperand, rewritten := rewriteJSONScalarEqualityOperand(kindMapper, expression.ROperand); rewritten { + if rewrittenROperand, rewritten := rewriteStringEqualityOperand(expression.ROperand); rewritten { + expression.LOperand = rewritePropertyLookupOperator(leftPropertyLookup, pgsql.Text) + expression.ROperand = rewrittenROperand + } else if rewrittenROperand, rewritten := rewriteJSONScalarEqualityOperand(expression.ROperand); rewritten { leftPropertyLookup.Operator = pgsql.OperatorJSONField expression.ROperand = rewrittenROperand } else if rOperandTypeHint == pgsql.AnyArray { @@ -415,7 +390,10 @@ func rewritePropertyLookupOperands(kindMapper *contextAwareKindMapper, expressio // for special (like, ilike, etc.) character classes case pgsql.OperatorEquals, pgsql.OperatorCypherNotEquals: - if rewrittenLOperand, rewritten := rewriteJSONScalarEqualityOperand(kindMapper, expression.LOperand); rewritten { + if rewrittenLOperand, rewritten := rewriteStringEqualityOperand(expression.LOperand); rewritten { + expression.LOperand = rewrittenLOperand + expression.ROperand = rewritePropertyLookupOperator(rightPropertyLookup, pgsql.Text) + } else if rewrittenLOperand, rewritten := rewriteJSONScalarEqualityOperand(expression.LOperand); rewritten { expression.LOperand = rewrittenLOperand rightPropertyLookup.Operator = pgsql.OperatorJSONField } else if lOperandTypeHint == pgsql.AnyArray { @@ -886,6 +864,101 @@ func jsonFieldPropertyLookup(propertyLookup *pgsql.BinaryExpression) *pgsql.Bina return pgsql.NewBinaryExpression(propertyLookup.LOperand, pgsql.OperatorJSONField, propertyLookup.ROperand) } +func jsonTextPropertyLookup(propertyLookup *pgsql.BinaryExpression) *pgsql.BinaryExpression { + return pgsql.NewBinaryExpression(propertyLookup.LOperand, pgsql.OperatorJSONTextField, propertyLookup.ROperand) +} + +func jsonbTypeof(expression pgsql.Expression) pgsql.Expression { + return pgsql.FunctionCall{ + Function: pgsql.FunctionJSONBTypeof, + Parameters: []pgsql.Expression{expression}, + } +} + +func jsonbStringTypeCheck(propertyLookup *pgsql.BinaryExpression) pgsql.Expression { + return pgsql.NewBinaryExpression( + jsonbTypeof(jsonFieldPropertyLookup(propertyLookup)), + pgsql.OperatorEquals, + pgsql.NewLiteral("string", pgsql.Text), + ) +} + +func toJSONBTextOperand(expression pgsql.Expression) pgsql.Expression { + return pgsql.FunctionCall{ + Function: pgsql.FunctionToJSONB, + Parameters: []pgsql.Expression{ + pgsql.NewTypeCast(expression, pgsql.Text), + }, + CastType: pgsql.JSONB, + } +} + +func buildStringPropertyEqualityComparison(propertyLookup *pgsql.BinaryExpression, textOperand pgsql.Expression, propertyOnLeft bool, operator pgsql.Operator) pgsql.Expression { + textPropertyLookup := jsonTextPropertyLookup(propertyLookup) + + if propertyOnLeft { + return pgsql.NewBinaryExpression(textPropertyLookup, operator, textOperand) + } + + return pgsql.NewBinaryExpression(textOperand, operator, textPropertyLookup) +} + +func buildStringPropertyEqualityPredicate(expression *pgsql.BinaryExpression) (pgsql.Expression, bool) { + if !expression.Operator.IsIn(pgsql.OperatorEquals, pgsql.OperatorCypherNotEquals) { + return nil, false + } + + leftPropertyLookup, hasLeftPropertyLookup := expressionToPropertyLookupBinaryExpression(expression.LOperand) + rightPropertyLookup, hasRightPropertyLookup := expressionToPropertyLookupBinaryExpression(expression.ROperand) + + if hasLeftPropertyLookup && leftPropertyLookup.Operator == pgsql.OperatorJSONTextField { + if _, rewritten := rewriteStringEqualityOperand(expression.ROperand); rewritten { + return buildStringPropertyComparisonPredicate(leftPropertyLookup, expression.ROperand, true, expression.Operator), true + } + } + + if hasRightPropertyLookup && rightPropertyLookup.Operator == pgsql.OperatorJSONTextField { + if _, rewritten := rewriteStringEqualityOperand(expression.LOperand); rewritten { + return buildStringPropertyComparisonPredicate(rightPropertyLookup, expression.LOperand, false, expression.Operator), true + } + } + + return nil, false +} + +func buildStringPropertyComparisonPredicate(propertyLookup *pgsql.BinaryExpression, textOperand pgsql.Expression, propertyOnLeft bool, operator pgsql.Operator) pgsql.Expression { + stringComparison := buildStringPropertyEqualityComparison(propertyLookup, textOperand, propertyOnLeft, operator) + + if operator == pgsql.OperatorEquals { + return pgsql.NewParenthetical(pgsql.NewBinaryExpression( + jsonbStringTypeCheck(propertyLookup), + pgsql.OperatorAnd, + stringComparison, + )) + } + + nonStringTypeCheck := pgsql.NewBinaryExpression( + jsonbTypeof(jsonFieldPropertyLookup(propertyLookup)), + pgsql.OperatorCypherNotEquals, + pgsql.NewLiteral("string", pgsql.Text), + ) + nonStringComparison := pgsql.NewBinaryExpression( + jsonFieldPropertyLookup(propertyLookup), + pgsql.OperatorCypherNotEquals, + toJSONBTextOperand(textOperand), + ) + + return pgsql.NewParenthetical(pgsql.NewBinaryExpression( + pgsql.NewBinaryExpression( + jsonbStringTypeCheck(propertyLookup), + pgsql.OperatorAnd, + stringComparison, + ), + pgsql.OperatorOr, + pgsql.NewBinaryExpression(nonStringTypeCheck, pgsql.OperatorAnd, nonStringComparison), + )) +} + func buildEmptyArrayPropertyComparison(propertyLookup *pgsql.BinaryExpression, negated bool) *pgsql.BinaryExpression { emptyArrayExpression := pgsql.NewBinaryExpression( jsonFieldPropertyLookup(propertyLookup), @@ -1205,6 +1278,8 @@ func (s *ExpressionTreeTranslator) rewriteBinaryExpression(newExpression *pgsql. } s.PushOperand(pgsql.NewParenthetical(expandedExpression)) + } else if rewrittenExpression, rewritten := buildStringPropertyEqualityPredicate(newExpression); rewritten { + s.PushOperand(rewrittenExpression) } else { s.PushOperand(newExpression) } @@ -1218,6 +1293,8 @@ func (s *ExpressionTreeTranslator) rewriteBinaryExpression(newExpression *pgsql. } s.PushOperand(pgsql.NewParenthetical(expandedExpression)) + } else if rewrittenExpression, rewritten := buildStringPropertyEqualityPredicate(newExpression); rewritten { + s.PushOperand(rewrittenExpression) } else { s.PushOperand(newExpression) } diff --git a/cypher/models/pgsql/translate/property.go b/cypher/models/pgsql/translate/property.go index 0d34f0a2..29f44fb7 100644 --- a/cypher/models/pgsql/translate/property.go +++ b/cypher/models/pgsql/translate/property.go @@ -77,6 +77,8 @@ func (s *Translator) buildPatternPropertyConstraints(binding *BoundIdentifier, p if newConstraint, err := s.treeTranslator.PopBinaryExpression(pgsql.OperatorEquals); err != nil { return nil, err + } else if rewrittenConstraint, rewritten := buildStringPropertyEqualityPredicate(newConstraint); rewritten { + propertyConstraints = pgsql.OptionalAnd(propertyConstraints, rewrittenConstraint) } else { propertyConstraints = pgsql.OptionalAnd(propertyConstraints, newConstraint) } From 4a2742554f489d56dbfe3d7dafb73b1244aa0699 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Fri, 22 May 2026 19:56:51 -0700 Subject: [PATCH 063/116] Cover typed string equality translation --- .../pgsql/test/translation_cases/create.sql | 1 + .../pgsql/test/translation_cases/delete.sql | 1 + .../test/translation_cases/multipart.sql | 11 +++--- .../pgsql/test/translation_cases/nodes.sql | 33 +++++++++-------- .../test/translation_cases/parameters.sql | 3 +- .../translation_cases/pattern_binding.sql | 13 ++++--- .../translation_cases/pattern_expansion.sql | 19 +++++----- .../translation_cases/pattern_rewriting.sql | 1 + .../test/translation_cases/quantifiers.sql | 1 + .../translation_cases/scalar_aggregation.sql | 1 + .../test/translation_cases/shortest_paths.sql | 15 ++++---- .../translation_cases/stepwise_traversal.sql | 15 ++++---- .../pgsql/test/translation_cases/unwind.sql | 3 +- .../pgsql/test/translation_cases/update.sql | 11 +++--- .../models/pgsql/translate/expression_test.go | 37 ++++++++++++++----- 15 files changed, 99 insertions(+), 66 deletions(-) diff --git a/cypher/models/pgsql/test/translation_cases/create.sql b/cypher/models/pgsql/test/translation_cases/create.sql index 1458421e..55c74074 100644 --- a/cypher/models/pgsql/test/translation_cases/create.sql +++ b/cypher/models/pgsql/test/translation_cases/create.sql @@ -73,3 +73,4 @@ with s0 as (select nextval(pg_get_serial_sequence('node', 'id'))::int8 as n0_id) -- case: match (a:NodeKind1) with a create (b:NodeKind2 {source: a.name}) return a, b 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[]) select s1.n0 as n0 from s1), s2 as (select s0.n0 as n0, nextval(pg_get_serial_sequence('node', 'id'))::int8 as n1_id from s0), s3 as (insert into node (graph_id, id, kind_ids, properties) select 0, s2.n1_id, array [2]::int2[], jsonb_build_object('source', ((s2.n0).properties ->> 'name'))::jsonb from s2 returning id as n1_id, (id, kind_ids, properties)::nodecomposite as n1), s4 as (select s2.n0 as n0, s3.n1 as n1 from s2, s3 where s3.n1_id = s2.n1_id) select s4.n0 as a, s4.n1 as b from s4; + diff --git a/cypher/models/pgsql/test/translation_cases/delete.sql b/cypher/models/pgsql/test/translation_cases/delete.sql index ab59796e..c6695b5d 100644 --- a/cypher/models/pgsql/test/translation_cases/delete.sql +++ b/cypher/models/pgsql/test/translation_cases/delete.sql @@ -22,3 +22,4 @@ with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::e -- case: match ()-[]->()-[r:EdgeKind1]->() delete r with s0 as (select e0.id as e0, (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), s1 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.n1 as n1 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.kind_id = any (array [3]::int2[]) and e1.id != s0.e0), s2 as (delete from edge e2 using s1 where (s1.e1).id = e2.id) select 1; + diff --git a/cypher/models/pgsql/test/translation_cases/multipart.sql b/cypher/models/pgsql/test/translation_cases/multipart.sql index 6f51f513..b1e52d9a 100644 --- a/cypher/models/pgsql/test/translation_cases/multipart.sql +++ b/cypher/models/pgsql/test/translation_cases/multipart.sql @@ -21,7 +21,7 @@ with s0 as (select '1' as i0), s1 as (select s0.i0 as i0, (n0.id, n0.kind_ids, n with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'value'))::jsonb = to_jsonb((1)::int8)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s1.n0 as n0 from s1), s2 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where (n1.id = (s0.n0).id)) select s2.n1 as b from s2; -- case: match (n:NodeKind1) where n.value = 1 with n match (f) where f.name = 'me' with f match (b) where id(b) = id(f) return b -with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'value'))::jsonb = to_jsonb((1)::int8)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s1.n0 as n0 from s1), s2 as (with s3 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where (((n1.properties -> 'name'))::jsonb = to_jsonb(('me')::text)::jsonb)) select s3.n1 as n1 from s3), s4 as (select s2.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s2, node n2 where (n2.id = (s2.n1).id)) select s4.n2 as b from s4; +with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'value'))::jsonb = to_jsonb((1)::int8)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s1.n0 as n0 from s1), s2 as (with s3 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where ((jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'me'))) select s3.n1 as n1 from s3), s4 as (select s2.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s2, node n2 where (n2.id = (s2.n1).id)) select s4.n2 as b from s4; -- case: match (n:NodeKind1)-[:EdgeKind1*1..]->(:NodeKind2)-[:EdgeKind2]->(m:NodeKind1) where (n:NodeKind1 or n:NodeKind2) and n.enabled = true with m, collect(distinct(n)) as p where size(p) >= 10 return m 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.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]) and ((n0.properties -> 'enabled'))::jsonb = to_jsonb((true)::bool)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(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 s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_id = s2.next_id and e0.id != all (s2.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select 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 and exists (select 1 from edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [4]::int2[]))), s3 as (select s1.ep0 as ep0, 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.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[]) and e1.id != all (s1.ep0)) select s3.n2 as n2, array_remove(coalesce(array_agg(distinct (s3.n0))::nodecomposite[], array []::nodecomposite[])::nodecomposite[], null)::nodecomposite[] as i0 from s3 group by n2) select s0.n2 as m from s0 where (cardinality(s0.i0)::int >= 10); @@ -39,13 +39,13 @@ with s0 as (select 365 as i0), s1 as (select s0.i0 as i0, (n0.id, n0.kind_ids, n with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'hasspn'))::jsonb = to_jsonb((true)::bool)::jsonb and ((n0.properties -> 'enabled'))::jsonb = to_jsonb((true)::bool)::jsonb and not coalesce((n0.properties ->> 'objectid'), '')::text like '%-502' and not coalesce(((n0.properties ->> 'gmsa'))::bool, false)::bool = true and not coalesce(((n0.properties ->> 'msa'))::bool, false)::bool = true) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2 as (with recursive s3_seed(root_id) as not materialized (select distinct (s1.n0).id as root_id from s1), s3(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 s3_seed join edge e0 on e0.start_id = s3_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s3.root_id, e0.end_id, s3.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], false, s3.path || e0.id from s3 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.start_id = s3.next_id and e0.id != all (s3.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 s3.depth < 15 and not s3.is_cycle) select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1, s3 join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s3.root_id offset 0) n0 on true join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s3.next_id offset 0) n1 on true where s3.satisfied and (s1.n0).id = s3.root_id) select distinct s2.n0 as n0, count(s2.n1)::int8 as i0 from s2 group by n0) select s0.n0 as n from s0 order by s0.i0 desc limit 100; -- case: match (n:NodeKind1) where n.objectid = 'S-1-5-21-1260426776-3623580948-1897206385-23225' match p = (n)-[:EdgeKind1|EdgeKind2*1..]->(c:NodeKind2) return p -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'objectid'))::jsonb = to_jsonb(('S-1-5-21-1260426776-3623580948-1897206385-23225')::text)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n0).id as root_id from s0), s2(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 s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_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.end_id where s2.depth < 15 and not s2.is_cycle) select 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 s0, 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 and (s0.n0).id = s2.root_id) select case when (s1.n0).id is null or s1.ep0 is null or (s1.n1).id is null then null else ordered_edges_to_path(s1.n0, (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(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite end as p from s1; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((jsonb_typeof((n0.properties -> 'objectid')) = 'string' and (n0.properties ->> 'objectid') = 'S-1-5-21-1260426776-3623580948-1897206385-23225')) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n0).id as root_id from s0), s2(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 s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_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.end_id where s2.depth < 15 and not s2.is_cycle) select 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 s0, 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 and (s0.n0).id = s2.root_id) select case when (s1.n0).id is null or s1.ep0 is null or (s1.n1).id is null then null else ordered_edges_to_path(s1.n0, (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(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite end as p from s1; -- case: match (g1:NodeKind1) where g1.name starts with 'test' with collect (g1.domain) as excludes match (d:NodeKind2) where d.name starts with 'other' and not d.name in excludes return d with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((n0.properties ->> 'name') like 'test%') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select array_remove(coalesce(array_agg(((s1.n0).properties ->> 'domain'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s1), s2 as (select s0.i0 as i0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where (not (n1.properties ->> 'name') = any (s0.i0) and (n1.properties ->> 'name') like 'other%') and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[]) select s2.n1 as d from s2; -- case: with 'a' as uname match (o:NodeKind1) where o.name starts with uname and o.domain = ' ' return o -with s0 as (select 'a' as i0), s1 as (select s0.i0 as i0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from s0, node n0 where (((n0.properties -> 'domain'))::jsonb = to_jsonb((' ')::text)::jsonb and cypher_starts_with((n0.properties ->> 'name'), (i0)::text)::bool) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s1.n0 as o from s1; +with s0 as (select 'a' as i0), s1 as (select s0.i0 as i0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from s0, node n0 where ((jsonb_typeof((n0.properties -> 'domain')) = 'string' and (n0.properties ->> 'domain') = ' ') and cypher_starts_with((n0.properties ->> 'name'), (i0)::text)::bool) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s1.n0 as o from s1; -- case: match (dc)-[r:EdgeKind1*0..]->(g:NodeKind1) where g.objectid ends with '-516' with collect(dc) as exclude match p = (c:NodeKind2)-[n:EdgeKind2]->(u:NodeKind2)-[:EdgeKind2*1..]->(g:NodeKind1) where g.objectid ends with '-512' and not c in exclude return p limit 100 with s0 as (with s1 as (with recursive s2_seed(root_id) as not materialized (select n1.id as root_id from node n1 where ((n1.properties ->> 'objectid') like '%-516') and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select s2_seed.root_id, s2_seed.root_id, 0, false, false, array []::int8[] from s2_seed union all select e0.end_id, e0.start_id, 1, false, e0.end_id = e0.start_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]::int2[]) union all select s2.root_id, e0.start_id, s2.depth + 1, false, false, e0.id || s2.path 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]::int2[]) offset 0) e0 on true where s2.depth < 15 and not s2.is_cycle and s2.depth > 0) select (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 n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s2.next_id offset 0) n0 on true) select array_remove(coalesce(array_agg(s1.n0)::nodecomposite[], array []::nodecomposite[])::nodecomposite[], null)::nodecomposite[] as i0 from s1), s3 as (select e1.id as e1, s0.i0 as i0, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s0, edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e1.start_id join node n3 on n3.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n3.id = e1.end_id where e1.kind_id = any (array [4]::int2[])), s4 as (with recursive s5_seed(root_id) as not materialized (select distinct (s3.n3).id as root_id from s3), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, ((n4.properties ->> 'objectid') like '%-512') and n4.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.start_id = e2.end_id, array [e2.id] from s5_seed join edge e2 on e2.start_id = s5_seed.root_id join node n4 on n4.id = e2.end_id where e2.kind_id = any (array [4]::int2[]) union all select s5.root_id, e2.end_id, s5.depth + 1, ((n4.properties ->> 'objectid') like '%-512') and n4.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, s5.path || e2.id from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.start_id = s5.next_id and e2.id != all (s5.path) and e2.kind_id = any (array [4]::int2[]) offset 0) e2 on true join node n4 on n4.id = e2.end_id where s5.depth < 15 and not s5.is_cycle) select s3.e1 as e1, s5.path as ep1, s3.i0 as i0, s3.n2 as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3, (n4.id, n4.kind_ids, n4.properties)::nodecomposite as n4 from s3, s5 join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.root_id offset 0) n3 on true join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.next_id offset 0) n4 on true where s5.satisfied and (s3.n3).id = s5.root_id) select case when (s4.n2).id is null or s4.e1 is null or (s4.n3).id is null or s4.ep1 is null or (s4.n4).id is null then null else ordered_edges_to_path(s4.n2, (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.e1]::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(s4.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n2, s4.n3, s4.n4]::nodecomposite[])::pathcomposite end as p from s4 where (not (s4.n2).id = any ((select (_unnest_elem).id from unnest(s4.i0) as _unnest_elem))) limit 100; @@ -66,13 +66,13 @@ with s0 as (select 'a' as i0, 'b' as i1), s1 as (with s2 as (select s0.i0 as i0, 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 lower(((s2.n0).properties ->> 'samaccountname'))::text), 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((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s2.path) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id) as e0, (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 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 case when (s4.n2).id is null or s4.e1 is null or (s4.n3).id is null then null else ordered_edges_to_path(s4.n2, (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.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n2, s4.n3]::nodecomposite[])::pathcomposite end as p from s4 where ((s4.n2).id = any ((select (_unnest_elem).id from unnest(s4.i1) as _unnest_elem))); +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 ((jsonb_typeof((n0.properties -> 'domain')) = 'string' and (n0.properties ->> 'domain') = 'test')) 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((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s2.path) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id) as e0, (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 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 case when (s4.n2).id is null or s4.e1 is null or (s4.n3).id is null then null else ordered_edges_to_path(s4.n2, (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.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n2, s4.n3]::nodecomposite[])::pathcomposite end as p from s4 where ((s4.n2).id = any ((select (_unnest_elem).id from unnest(s4.i1) as _unnest_elem))); -- case: match (u:NodeKind1)-[:EdgeKind1]->(g:NodeKind2) with g match (g)<-[:EdgeKind1]-(u:NodeKind1) return g with s0 as (with s1 as (select (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select s1.n1 as n1 from s1), s2 as (select s0.n1 as n1 from s0 join edge e1 on (s0.n1).id = e1.end_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.start_id where e1.kind_id = any (array [3]::int2[])) select s2.n1 as g from s2; -- case: match (cg:NodeKind1) where cg.name =~ ".*TT" and cg.domain = "MY DOMAIN" with collect (cg.email) as emails match (o:NodeKind1)-[:EdgeKind1]->(g:NodeKind2) where g.name starts with "blah" and not g.email in emails return o -with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((n0.properties ->> 'name') ~ '.*TT' and ((n0.properties -> 'domain'))::jsonb = to_jsonb(('MY DOMAIN')::text)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select array_remove(coalesce(array_agg(((s1.n0).properties ->> 'email'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s1), s2 as (select s0.i0 as i0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, edge e0 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e0.end_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.start_id where e0.kind_id = any (array [3]::int2[]) and (not (n2.properties ->> 'email') = any (s0.i0) and (n2.properties ->> 'name') like 'blah%')) select s2.n1 as o from s2; +with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((n0.properties ->> 'name') ~ '.*TT' and (jsonb_typeof((n0.properties -> 'domain')) = 'string' and (n0.properties ->> 'domain') = 'MY DOMAIN')) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select array_remove(coalesce(array_agg(((s1.n0).properties ->> 'email'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s1), s2 as (select s0.i0 as i0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, edge e0 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e0.end_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.start_id where e0.kind_id = any (array [3]::int2[]) and (not (n2.properties ->> 'email') = any (s0.i0) and (n2.properties ->> 'name') like 'blah%')) select s2.n1 as o from s2; -- case: match (e) match p = ()-[]->(e) return p limit 1 with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0), s1 as (select e0.id 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.id = e0.start_id) select case when (s1.n1).id is null or s1.e0 is null or (s1.n0).id is null then null else ordered_edges_to_path(s1.n1, (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 [s1.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n1, s1.n0]::nodecomposite[])::pathcomposite end as p from s1 limit 1; @@ -91,3 +91,4 @@ with s0 as (with s1 as (select e0.id as e0, (n0.id, n0.kind_ids, n0.properties): -- 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); + diff --git a/cypher/models/pgsql/test/translation_cases/nodes.sql b/cypher/models/pgsql/test/translation_cases/nodes.sql index 1162f06b..6feaf94e 100644 --- a/cypher/models/pgsql/test/translation_cases/nodes.sql +++ b/cypher/models/pgsql/test/translation_cases/nodes.sql @@ -24,7 +24,7 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) 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; +with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = 'n3'))) 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; @@ -45,10 +45,10 @@ 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 (coalesce((n0.properties ->> 'name'), '')::text = '1234')) select s0.n0 as n from s0; -- case: match (n) where n.name = '1234' return n -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('1234')::text)::jsonb)) select s0.n0 as n from s0; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = '1234'))) select s0.n0 as n from s0; -- case: match (n:NodeKind1 {name: "SOME NAME"}) 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[] and ((n0.properties -> 'name'))::jsonb = to_jsonb(('SOME NAME')::text)::jsonb) select s0.n0 as n from s0; +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[] and (jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = 'SOME NAME')) select s0.n0 as n from s0; -- case: match (n) where n.objectid in $p return n -- cypher_params: {"p":["1","2","3"]} @@ -58,7 +58,7 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from -- case: match (s) where s.name = $myParam return s -- cypher_params: {"myParam":"123"} -- pgsql_params:{"pi0":"123"} -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb((@pi0::text)::text)::jsonb)) select s0.n0 as s from s0; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = @pi0::text))) select s0.n0 as s from s0; -- case: match (s) return s with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select s0.n0 as s from s0; @@ -79,7 +79,7 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.kind_ids operator (pg_catalog.@>) array [2]::int2[])) select s0.n0 as s from s0; -- case: match (s) where s.name = '1234' return s -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('1234')::text)::jsonb)) select s0.n0 as s from s0; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = '1234'))) select s0.n0 as s from s0; -- case: match (s:NodeKind1), (e:NodeKind2) where s.selected or s.tid = e.tid and e.enabled return s, e 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 ((((s0.n0).properties ->> 'selected'))::bool or ((s0.n0).properties -> 'tid') = (n1.properties -> 'tid') and ((n1.properties ->> 'enabled'))::bool) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[]) select s1.n0 as s, s1.n1 as e from s1; @@ -88,7 +88,7 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties ->> 'value'))::int8 + 2 / 3 > 10)) select s0.n0 as s from s0; -- case: match (s), (e) where s.name = 'n1' return s, e.name as othername -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb)), s1 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1) select s1.n0 as s, ((s1.n1).properties -> 'name') as othername from s1; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = 'n1'))), s1 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1) select s1.n0 as s, ((s1.n1).properties -> 'name') as othername from s1; -- case: match (s) where s.name in ['option 1', 'option 2'] return s with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((n0.properties ->> 'name') = any (array ['option 1', 'option 2']::text[]))) select s0.n0 as s from s0; @@ -103,13 +103,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 ? 'system_tags' and not (n0.properties -> 'system_tags') = ('null')::jsonb) and not (n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]))) select (s0.n0).id from s0; -- case: match (s), (e) where s.name = '1234' and e.other = 1234 return s -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('1234')::text)::jsonb)), s1 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where (((n1.properties -> 'other'))::jsonb = to_jsonb((1234)::int8)::jsonb)) select s1.n0 as s from s1; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = '1234'))), s1 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where (((n1.properties -> 'other'))::jsonb = to_jsonb((1234)::int8)::jsonb)) select s1.n0 as s from s1; -- case: match (s), (e) where s.name = '1234' or e.other = 1234 return s -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0), s1 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where ((((s0.n0).properties -> 'name'))::jsonb = to_jsonb(('1234')::text)::jsonb or ((n1.properties -> 'other'))::jsonb = to_jsonb((1234)::int8)::jsonb)) select s1.n0 as s from s1; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0), s1 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where ((jsonb_typeof(((s0.n0).properties -> 'name')) = 'string' and ((s0.n0).properties ->> 'name') = '1234') or ((n1.properties -> 'other'))::jsonb = to_jsonb((1234)::int8)::jsonb)) select s1.n0 as s from s1; -- case: match (n), (k) where n.name = '1234' and k.name = '1234' match (e) where e.name = n.name return k, e -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('1234')::text)::jsonb)), 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'))::jsonb = to_jsonb(('1234')::text)::jsonb)), s2 as (select s1.n0 as n0, s1.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s1, node n2 where ((n2.properties -> 'name') = ((s1.n0).properties -> 'name'))) select s2.n1 as k, s2.n2 as e from s2; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = '1234'))), s1 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where ((jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = '1234'))), s2 as (select s1.n0 as n0, s1.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s1, node n2 where ((n2.properties -> 'name') = ((s1.n0).properties -> 'name'))) select s2.n1 as k, s2.n2 as e from s2; -- case: match (n) return n skip 5 limit 10 with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select s0.n0 as n from s0 offset 5 limit 10; @@ -151,10 +151,10 @@ 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 ->> 'created_at'))::timestamp without time zone = ('2019-06-01T18:40:32.142')::timestamp without time zone)) select s0.n0 as s from s0; -- case: match (s) where not (s.name = '123') return s -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (not (((n0.properties -> 'name'))::jsonb = to_jsonb(('123')::text)::jsonb))) select s0.n0 as s from s0; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (not ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = '123')))) select s0.n0 as s from s0; -- case: match (s) where s.isassignabletorole = 'true' return s -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((n0.properties ->> 'isassignabletorole') = 'true')) select s0.n0 as s from s0; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((jsonb_typeof((n0.properties -> 'isassignabletorole')) = 'string' and (n0.properties ->> 'isassignabletorole') = 'true'))) select s0.n0 as s from s0; -- case: match (s) where s.isassignabletorole = true return s with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'isassignabletorole'))::jsonb = to_jsonb((true)::bool)::jsonb)) select s0.n0 as s from s0; @@ -214,13 +214,13 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select s0.n0 as s from s0 where (not (with s1 as (select e0.id as e0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n1 on n1.id = e0.end_id where (s0.n0).id = e0.start_id), s2 as (select s1.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.id = e1.end_id where e1.id != s1.e0) select count(*) > 0 from s2)); -- case: match (s) where not (s)-[{prop: 'a'}]-({name: 'n3'}) return s -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select s0.n0 as s from s0 where (not (with s1 as (select s0.n0 as n0 from s0 join edge e0 on ((s0.n0).id = e0.end_id or (s0.n0).id = e0.start_id) join node n1 on ((n1.properties -> 'name'))::jsonb = to_jsonb(('n3')::text)::jsonb and (n1.id = e0.end_id or n1.id = e0.start_id) where ((s0.n0).id <> n1.id) and ((e0.properties -> 'prop'))::jsonb = to_jsonb(('a')::text)::jsonb) select count(*) > 0 from s1)); +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select s0.n0 as s from s0 where (not (with s1 as (select s0.n0 as n0 from s0 join edge e0 on ((s0.n0).id = e0.end_id or (s0.n0).id = e0.start_id) join node n1 on (jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'n3') and (n1.id = e0.end_id or n1.id = e0.start_id) where ((s0.n0).id <> n1.id) and (jsonb_typeof((e0.properties -> 'prop')) = 'string' and (e0.properties ->> 'prop') = 'a')) select count(*) > 0 from s1)); -- case: match (s) where not (s)<-[{prop: 'a'}]-({name: 'n3'}) return s -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select s0.n0 as s from s0 where (not (with s1 as (select s0.n0 as n0 from edge e0 join node n1 on ((n1.properties -> 'name'))::jsonb = to_jsonb(('n3')::text)::jsonb and n1.id = e0.start_id where ((e0.properties -> 'prop'))::jsonb = to_jsonb(('a')::text)::jsonb and (s0.n0).id = e0.end_id) select count(*) > 0 from s1)); +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select s0.n0 as s from s0 where (not (with s1 as (select s0.n0 as n0 from edge e0 join node n1 on (jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'n3') and n1.id = e0.start_id where (jsonb_typeof((e0.properties -> 'prop')) = 'string' and (e0.properties ->> 'prop') = 'a') and (s0.n0).id = e0.end_id) select count(*) > 0 from s1)); -- case: match (n:NodeKind1) where n.distinguishedname = toUpper('admin') return n -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'distinguishedname'))::jsonb = to_jsonb((upper('admin')::text)::text)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s0.n0 as n from s0; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((n0.properties ->> 'distinguishedname') = upper('admin')::text) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s0.n0 as n from s0; -- case: match (n:NodeKind1) where n.distinguishedname starts with toUpper('admin') return n with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (cypher_starts_with((n0.properties ->> 'distinguishedname'), (upper('admin')::text)::text)::bool) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s0.n0 as n from s0; @@ -232,7 +232,7 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (cypher_ends_with((n0.properties ->> 'distinguishedname'), (upper('admin')::text)::text)::bool) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s0.n0 as n from s0; -- case: match (s) where not (s)-[{prop: 'a'}]->({name: 'n3'}) return s -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select s0.n0 as s from s0 where (not (with s1 as (select s0.n0 as n0 from edge e0 join node n1 on ((n1.properties -> 'name'))::jsonb = to_jsonb(('n3')::text)::jsonb and n1.id = e0.end_id where ((e0.properties -> 'prop'))::jsonb = to_jsonb(('a')::text)::jsonb and (s0.n0).id = e0.start_id) select count(*) > 0 from s1)); +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select s0.n0 as s from s0 where (not (with s1 as (select s0.n0 as n0 from edge e0 join node n1 on (jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'n3') and n1.id = e0.end_id where (jsonb_typeof((e0.properties -> 'prop')) = 'string' and (e0.properties ->> 'prop') = 'a') and (s0.n0).id = e0.start_id) select count(*) > 0 from s1)); -- case: match (s) where not (s)-[]-() return id(s) with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select (s0.n0).id from s0 where (not exists (select 1 from edge e0 where e0.start_id = (s0.n0).id or e0.end_id = (s0.n0).id)); @@ -344,7 +344,8 @@ 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.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; -- case: match (n) where n.name = "alpha' || (SELECT inet_server_addr()::text::int) || '" return n -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('alpha'' || (SELECT inet_server_addr()::text::int) || ''')::text)::jsonb)) select s0.n0 as n from s0; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = 'alpha'' || (SELECT inet_server_addr()::text::int) || '''))) select s0.n0 as n from s0; -- case: match (g:NodeKind2) where not ((g)<-[:EdgeKind1]-(:NodeKind1)) return g with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]) select s0.n0 as g from s0 where (not ((with s1 as (select s0.n0 as n0 from edge e0 join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.start_id where e0.kind_id = any (array [3]::int2[]) and (s0.n0).id = e0.end_id) select count(*) > 0 from s1))); + diff --git a/cypher/models/pgsql/test/translation_cases/parameters.sql b/cypher/models/pgsql/test/translation_cases/parameters.sql index e1212eb8..26a6a5f8 100644 --- a/cypher/models/pgsql/test/translation_cases/parameters.sql +++ b/cypher/models/pgsql/test/translation_cases/parameters.sql @@ -29,9 +29,10 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from -- case: match (n) where n.isassignabletorole = $p0 return n -- cypher_params: {"p0":"true"} -- pgsql_params:{"pi0":"true"} -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((n0.properties ->> 'isassignabletorole') = @pi0::text)) select s0.n0 as n from s0; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((jsonb_typeof((n0.properties -> 'isassignabletorole')) = 'string' and (n0.properties ->> 'isassignabletorole') = @pi0::text))) select s0.n0 as n from s0; -- case: match (n) where n.isassignabletorole = $p0 return n -- cypher_params: {"p0":true} -- pgsql_params:{"pi0":true} with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'isassignabletorole'))::jsonb = to_jsonb((@pi0::bool)::bool)::jsonb)) select s0.n0 as n from s0; + diff --git a/cypher/models/pgsql/test/translation_cases/pattern_binding.sql b/cypher/models/pgsql/test/translation_cases/pattern_binding.sql index 622e2a29..35883e93 100644 --- a/cypher/models/pgsql/test/translation_cases/pattern_binding.sql +++ b/cypher/models/pgsql/test/translation_cases/pattern_binding.sql @@ -36,16 +36,16 @@ with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::e with s0 as (select e0.id as e0, (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), s1 as (select s0.e0 as e0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.id != s0.e0) select s1.n2 as e from s1; -- case: match ()-[r1]->()-[r2]->()-[]->() where r1.name = 'a' and r2.name = 'b' return r1 -with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (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 where (((e0.properties -> 'name'))::jsonb = to_jsonb(('a')::text)::jsonb)), s1 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where (((e1.properties -> 'name'))::jsonb = to_jsonb(('b')::text)::jsonb) and e1.id != (s0.e0).id), s2 as (select s1.e0 as e0, s1.e1 as e1, s1.n1 as n1, s1.n2 as n2 from s1 join edge e2 on (s1.n2).id = e2.start_id join node n3 on n3.id = e2.end_id where e2.id != (s1.e0).id and e2.id != (s1.e1).id) select s2.e0 as r1 from s2; +with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (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 where ((jsonb_typeof((e0.properties -> 'name')) = 'string' and (e0.properties ->> 'name') = 'a'))), s1 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where ((jsonb_typeof((e1.properties -> 'name')) = 'string' and (e1.properties ->> 'name') = 'b')) and e1.id != (s0.e0).id), s2 as (select s1.e0 as e0, s1.e1 as e1, s1.n1 as n1, s1.n2 as n2 from s1 join edge e2 on (s1.n2).id = e2.start_id join node n3 on n3.id = e2.end_id where e2.id != (s1.e0).id and e2.id != (s1.e1).id) select s2.e0 as r1 from s2; -- case: match p = (a)-[]->()<-[]-(f) where a.name = 'value' and f.is_target return p -with s0 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.properties -> 'name'))::jsonb = to_jsonb(('value')::text)::jsonb) and n0.id = e0.start_id join node n1 on n1.id = e0.end_id), s1 as (select s0.e0 as e0, e1.id as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.end_id join node n2 on (((n2.properties ->> 'is_target'))::bool) and n2.id = e1.start_id where e1.id != s0.e0) select case when (s1.n0).id is null or s1.e0 is null or (s1.n1).id is null or s1.e1 is null or (s1.n2).id is null then null else ordered_edges_to_path(s1.n0, (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 [s1.e0]::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 [s1.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1, s1.n2]::nodecomposite[])::pathcomposite end as p from s1; +with s0 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 ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = 'value')) and n0.id = e0.start_id join node n1 on n1.id = e0.end_id), s1 as (select s0.e0 as e0, e1.id as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.end_id join node n2 on (((n2.properties ->> 'is_target'))::bool) and n2.id = e1.start_id where e1.id != s0.e0) select case when (s1.n0).id is null or s1.e0 is null or (s1.n1).id is null or s1.e1 is null or (s1.n2).id is null then null else ordered_edges_to_path(s1.n0, (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 [s1.e0]::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 [s1.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1, s1.n2]::nodecomposite[])::pathcomposite end as p from s1; -- case: match p = ()-[*..]->() return p limit 1 with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, false, e0.start_id = e0.end_id, array [e0.id] from edge e0 union all select s1.root_id, e0.end_id, s1.depth + 1, false, 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) offset 0) e0 on true where s1.depth < 15 and not s1.is_cycle) select 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 limit 1) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 limit 1; -- case: match p = (s)-[*..]->(i)-[]->() where id(s) = 1 and i.name = 'n3' return p limit 1 -with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (n0.id = 1)), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n3')::text)::jsonb), 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 union all select s1.root_id, e0.end_id, s1.depth + 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n3')::text)::jsonb), 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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select 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 and exists (select 1 from edge e1 join node n2 on n2.id = e1.end_id where n1.id = e1.start_id)), s2 as (select e1.id as e1, s0.ep0 as ep0, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.id != all (s0.ep0) limit 1) select case when (s2.n0).id is null or s2.ep0 is null or (s2.n1).id is null or s2.e1 is null or (s2.n2).id is null then null else ordered_edges_to_path(s2.n0, (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(s2.ep0) 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 [s2.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1, s2.n2]::nodecomposite[])::pathcomposite end as p from s2 limit 1; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where ((jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'n3'))), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, (n0.id = 1), e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id join node n0 on n0.id = e0.start_id union all select s1.root_id, e0.start_id, s1.depth + 1, (n0.id = 1), false, e0.id || s1.path from s1 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.end_id = s1.next_id and e0.id != all (s1.path) offset 0) e0 on true join node n0 on n0.id = e0.start_id where s1.depth < 15 and not s1.is_cycle) select 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 n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s1.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s1.next_id offset 0) n0 on true where s1.satisfied), s2 as (select e1.id as e1, s0.ep0 as ep0, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.id != all (s0.ep0) limit 1) select case when (s2.n0).id is null or s2.ep0 is null or (s2.n1).id is null or s2.e1 is null or (s2.n2).id is null then null else ordered_edges_to_path(s2.n0, (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(s2.ep0) 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 [s2.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1, s2.n2]::nodecomposite[])::pathcomposite end as p from s2 limit 1; -- case: match p = ()-[e:EdgeKind1]->()-[:EdgeKind1*..]->() return e, 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 where e0.kind_id = any (array [3]::int2[])), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.start_id, e1.end_id, 1, false, e1.start_id = e1.end_id, array [e1.id] from s2_seed join edge e1 on e1.start_id = s2_seed.root_id where e1.kind_id = any (array [3]::int2[]) union all select s2.root_id, e1.end_id, s2.depth + 1, false, false, s2.path || e1.id from s2 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.start_id = s2.next_id and e1.id != all (s2.path) and e1.kind_id = any (array [3]::int2[]) offset 0) e1 on true where s2.depth < 15 and not s2.is_cycle) select s0.e0 as e0, s2.path as ep0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s2.next_id offset 0) n2 on true where (s0.n1).id = s2.root_id) select s1.e0 as e, case when (s1.n0).id is null or (s1.e0).id is null or (s1.n1).id is null or s1.ep0 is null or (s1.n2).id is null then null else ordered_edges_to_path(s1.n0, array [s1.e0]::edgecomposite[] || (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(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1, s1.n2]::nodecomposite[])::pathcomposite end as p from s1; @@ -66,13 +66,13 @@ with s0 as (select e0.id as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposi with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((n0.properties ->> 'samaccountname') = any (array ['foo', 'bar']::text[])) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n0).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (coalesce((n1.properties ->> 'system_tags'), '')::text like '%admin_tier_0%'), e0.start_id = e0.end_id, array [e0.id] from s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, (coalesce((n1.properties ->> 'system_tags'), '')::text like '%admin_tier_0%'), 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.start_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.end_id where s2.depth < 3 and not s2.is_cycle) select 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 s0, 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 and (s0.n0).id = s2.root_id) select case when (s1.n0).id is null or s1.ep0 is null or (s1.n1).id is null then null else ordered_edges_to_path(s1.n0, (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(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite end as p from s1 limit 1000; -- case: match (x:NodeKind1) where x.name = 'foo' match (y:NodeKind2) where y.name = 'bar' match p=(x)-[:EdgeKind1]->(y) return p -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('foo')::text)::jsonb) and 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 -> 'name'))::jsonb = to_jsonb(('bar')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s2 as (select e0.id as e0, s1.n0 as n0, s1.n1 as n1 from s1 join edge e0 on (s1.n0).id = e0.start_id and (s1.n1).id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select case when (s2.n0).id is null or s2.e0 is null or (s2.n1).id is null then null else ordered_edges_to_path(s2.n0, (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 [s2.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1]::nodecomposite[])::pathcomposite end as p from s2; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = 'foo')) and 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 ((jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'bar')) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s2 as (select e0.id as e0, s1.n0 as n0, s1.n1 as n1 from s1 join edge e0 on (s1.n0).id = e0.start_id and (s1.n1).id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select case when (s2.n0).id is null or s2.e0 is null or (s2.n1).id is null then null else ordered_edges_to_path(s2.n0, (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 [s2.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1]::nodecomposite[])::pathcomposite end as p from s2; -- case: match (x:NodeKind1{name:'foo'}) match (y:NodeKind2{name:'bar'}) match p=(x)-[:EdgeKind1]->(y) return p -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[] and ((n0.properties -> 'name'))::jsonb = to_jsonb(('foo')::text)::jsonb), s1 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n1.properties -> 'name'))::jsonb = to_jsonb(('bar')::text)::jsonb), s2 as (select e0.id as e0, s1.n0 as n0, s1.n1 as n1 from s1 join edge e0 on (s1.n0).id = e0.start_id and (s1.n1).id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select case when (s2.n0).id is null or s2.e0 is null or (s2.n1).id is null then null else ordered_edges_to_path(s2.n0, (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 [s2.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1]::nodecomposite[])::pathcomposite end as p 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[] and (jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = 'foo')), s1 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and (jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'bar')), s2 as (select e0.id as e0, s1.n0 as n0, s1.n1 as n1 from s1 join edge e0 on (s1.n0).id = e0.start_id and (s1.n1).id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select case when (s2.n0).id is null or s2.e0 is null or (s2.n1).id is null then null else ordered_edges_to_path(s2.n0, (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 [s2.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1]::nodecomposite[])::pathcomposite end as p from s2; -- case: match (x:NodeKind1{name:'foo'}) match p=(x)-[:EdgeKind1]->(y:NodeKind2{name:'bar'}) return p -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[] and ((n0.properties -> 'name'))::jsonb = to_jsonb(('foo')::text)::jsonb), s1 as (select e0.id 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.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n1.properties -> 'name'))::jsonb = to_jsonb(('bar')::text)::jsonb and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select case when (s1.n0).id is null or s1.e0 is null or (s1.n1).id is null then null else ordered_edges_to_path(s1.n0, (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 [s1.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite end as p from s1; +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[] and (jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = 'foo')), s1 as (select e0.id 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.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and (jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'bar') and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select case when (s1.n0).id is null or s1.e0 is null or (s1.n1).id is null then null else ordered_edges_to_path(s1.n0, (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 [s1.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite end as p from s1; -- case: match (e) match p = ()-[]->(e) return p limit 1 with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0), s1 as (select e0.id 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.id = e0.start_id) select case when (s1.n1).id is null or s1.e0 is null or (s1.n0).id is null then null else ordered_edges_to_path(s1.n1, (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 [s1.e0]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n1, s1.n0]::nodecomposite[])::pathcomposite end as p from s1 limit 1; @@ -91,3 +91,4 @@ with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as -- case: MATCH p=(:GPO)-[r:GPLink|Contains*1..]->(:Base) WHERE NONE(x in TAIL(r) WHERE NOT type(x) = 'Contains') 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 [8]::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 [10]::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 [11, 12]::int2[]) union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [10]::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 [11, 12]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (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(s1.path) with ordinality as _path(id, ordinality) join edge _edge on _edge.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 case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 where (((select count(*)::int from unnest(coalesce((s0.e0)[2:], array []::edgecomposite[])::edgecomposite[]) as i0 where (not i0.kind_id = 12)) = 0 and coalesce((s0.e0)[2:], array []::edgecomposite[])::edgecomposite[] is not null)::bool); + diff --git a/cypher/models/pgsql/test/translation_cases/pattern_expansion.sql b/cypher/models/pgsql/test/translation_cases/pattern_expansion.sql index f1eb5121..895d3c41 100644 --- a/cypher/models/pgsql/test/translation_cases/pattern_expansion.sql +++ b/cypher/models/pgsql/test/translation_cases/pattern_expansion.sql @@ -30,22 +30,22 @@ with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path with s0 as (with recursive s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where n1.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, false, e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id union all select s1.root_id, e0.start_id, s1.depth + 1, false, false, e0.id || s1.path from s1 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.end_id = s1.next_id and e0.id != all (s1.path) offset 0) e0 on true where s1.depth < 15 and not s1.is_cycle) select 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 n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s1.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s1.next_id offset 0) n0 on true) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0; -- case: match (n)-[*..]->(e:NodeKind1) where n.name = 'n1' return e -with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb)), 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 [1]::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 union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (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 s0.n1 as e from s0; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = 'n1'))), 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 [1]::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 union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (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 s0.n1 as e from s0; -- case: match (n)-[*..]->(e:NodeKind1) where n.name = 'n2' return n -with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n2')::text)::jsonb)), 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 [1]::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 union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (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 s0.n0 as n from s0; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = 'n2'))), 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 [1]::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 union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (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 s0.n0 as n from s0; -- case: match (n)-[*..]->(e:NodeKind1)-[]->(l) where n.name = 'n1' return l -with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb)), 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 [1]::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 union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select 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), s2 as (select s0.ep0 as ep0, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.id != all (s0.ep0)) select s2.n2 as l from s2; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = 'n1'))), 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 [1]::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 union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select 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), s2 as (select s0.ep0 as ep0, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.id != all (s0.ep0)) select s2.n2 as l from s2; -- case: match (n)-[*2..3]->(e:NodeKind1)-[]->(l) where n.name = 'n1' return l -with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb)), 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 [1]::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 union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 3 and not s1.is_cycle) select 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.depth >= 2 and s1.satisfied), s2 as (select s0.ep0 as ep0, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.id != all (s0.ep0)) select s2.n2 as l from s2; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = 'n1'))), 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 [1]::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 union all select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 3 and not s1.is_cycle) select 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.depth >= 2 and s1.satisfied), s2 as (select s0.ep0 as ep0, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.id != all (s0.ep0)) select s2.n2 as l from s2; -- case: match (n)-[]->(e:NodeKind1)-[*2..3]->(l) where n.name = 'n1' return l -with s0 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.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb) 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), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.start_id, e1.end_id, 1, false, e1.start_id = e1.end_id, array [e1.id] from s2_seed join edge e1 on e1.start_id = s2_seed.root_id union all select s2.root_id, e1.end_id, s2.depth + 1, false, false, s2.path || e1.id from s2 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.start_id = s2.next_id and e1.id != all (s2.path) offset 0) e1 on true where s2.depth < 3 and not s2.is_cycle) select s0.e0 as e0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s2.next_id offset 0) n2 on true where s2.depth >= 2 and (s0.n1).id = s2.root_id) select s1.n2 as l from s1; +with s0 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 ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = 'n1')) 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), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n1).id as root_id from s0), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.start_id, e1.end_id, 1, false, e1.start_id = e1.end_id, array [e1.id] from s2_seed join edge e1 on e1.start_id = s2_seed.root_id union all select s2.root_id, e1.end_id, s2.depth + 1, false, false, s2.path || e1.id from s2 join lateral (select e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties from edge e1 where e1.start_id = s2.next_id and e1.id != all (s2.path) offset 0) e1 on true where s2.depth < 3 and not s2.is_cycle) select s0.e0 as e0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s2.next_id offset 0) n2 on true where s2.depth >= 2 and (s0.n1).id = s2.root_id) select s1.n2 as l from s1; -- case: match (n)-[*..]->(e)-[:EdgeKind1|EdgeKind2]->()-[*..]->(l) where n.name = 'n1' and e.name = 'n2' return l -with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb)), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n2')::text)::jsonb), 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 union all select s1.root_id, e0.end_id, s1.depth + 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('n2')::text)::jsonb), 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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select 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 and exists (select 1 from edge e1 join node n2 on n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [3, 4]::int2[]))), s2 as (select e1.id as e1, s0.ep0 as ep0, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.kind_id = any (array [3, 4]::int2[]) and e1.id != all (s0.ep0)), s3 as (with recursive s4_seed(root_id) as not materialized (select distinct (s2.n2).id as root_id from s2), s4(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, false, e2.start_id = e2.end_id, array [e2.id] from s4_seed join edge e2 on e2.start_id = s4_seed.root_id union all select s4.root_id, e2.end_id, s4.depth + 1, false, false, s4.path || e2.id from s4 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.start_id = s4.next_id and e2.id != all (s4.path) offset 0) e2 on true where s4.depth < 15 and not s4.is_cycle) select s2.e1 as e1, s2.ep0 as ep0, s2.n0 as n0, s2.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s2, s4 join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s4.root_id offset 0) n2 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s4.next_id offset 0) n3 on true where (s2.n2).id = s4.root_id) select s3.n3 as l from s3; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = 'n1'))), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, ((jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'n2')), 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 union all select s1.root_id, e0.end_id, s1.depth + 1, ((jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'n2')), 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) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select 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 and exists (select 1 from edge e1 join node n2 on n2.id = e1.end_id where n1.id = e1.start_id and e1.kind_id = any (array [3, 4]::int2[]))), s2 as (select e1.id as e1, s0.ep0 as ep0, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.kind_id = any (array [3, 4]::int2[]) and e1.id != all (s0.ep0)), s3 as (with recursive s4_seed(root_id) as not materialized (select distinct (s2.n2).id as root_id from s2), s4(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, false, e2.start_id = e2.end_id, array [e2.id] from s4_seed join edge e2 on e2.start_id = s4_seed.root_id union all select s4.root_id, e2.end_id, s4.depth + 1, false, false, s4.path || e2.id from s4 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.start_id = s4.next_id and e2.id != all (s4.path) offset 0) e2 on true where s4.depth < 15 and not s4.is_cycle) select s2.e1 as e1, s2.ep0 as ep0, s2.n0 as n0, s2.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s2, s4 join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s4.root_id offset 0) n2 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s4.next_id offset 0) n3 on true where (s2.n2).id = s4.root_id) select s3.n3 as l from s3; -- case: match p = (:NodeKind1)-[:EdgeKind1*1..]->(n:NodeKind2) where 'admin_tier_0' in split(n.system_tags, ' ') return p limit 1000 with s0 as (with recursive s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where ('admin_tier_0' = any (string_to_array((n1.properties ->> 'system_tags'), ' ')::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, n0.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id join node n0 on n0.id = e0.start_id where e0.kind_id = any (array [3]::int2[]) union all select s1.root_id, e0.start_id, s1.depth + 1, n0.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, e0.id || s1.path from s1 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.end_id = s1.next_id and e0.id != all (s1.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n0 on n0.id = e0.start_id where s1.depth < 15 and not s1.is_cycle) select 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 n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s1.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s1.next_id offset 0) n0 on true where s1.satisfied limit 1000) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 limit 1000; @@ -57,7 +57,7 @@ with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where ((n0.properties ->> 'objectid') like '%1234') and 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.properties ->> 'objectid') like '%4567') and n1.kind_ids operator (pg_catalog.@>) array [1]::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.properties ->> 'objectid') like '%4567') and n1.kind_ids operator (pg_catalog.@>) array [1]::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 < 15 and not s1.is_cycle) select 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 case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0; -- case: match p = (m:NodeKind2)-[:EdgeKind1*1..]->(n:NodeKind1) where n.objectid = '1234' return p limit 10 -with s0 as (with recursive s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where (((n1.properties -> 'objectid'))::jsonb = to_jsonb(('1234')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, n0.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id join node n0 on n0.id = e0.start_id where e0.kind_id = any (array [3]::int2[]) union all select s1.root_id, e0.start_id, s1.depth + 1, n0.kind_ids operator (pg_catalog.@>) array [2]::int2[], false, e0.id || s1.path from s1 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.end_id = s1.next_id and e0.id != all (s1.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n0 on n0.id = e0.start_id where s1.depth < 15 and not s1.is_cycle) select 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 n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s1.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s1.next_id offset 0) n0 on true where s1.satisfied limit 10) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 limit 10; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where ((jsonb_typeof((n1.properties -> 'objectid')) = 'string' and (n1.properties ->> 'objectid') = '1234')) and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, n0.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id join node n0 on n0.id = e0.start_id where e0.kind_id = any (array [3]::int2[]) union all select s1.root_id, e0.start_id, s1.depth + 1, n0.kind_ids operator (pg_catalog.@>) array [2]::int2[], false, e0.id || s1.path from s1 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.end_id = s1.next_id and e0.id != all (s1.path) and e0.kind_id = any (array [3]::int2[]) offset 0) e0 on true join node n0 on n0.id = e0.start_id where s1.depth < 15 and not s1.is_cycle) select 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 n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s1.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s1.next_id offset 0) n0 on true where s1.satisfied limit 10) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 limit 10; -- case: match p = (:NodeKind1)<-[:EdgeKind1|EdgeKind2*..]-() return p limit 10 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.end_id, e0.start_id, 1, false, e0.end_id = e0.start_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s1.root_id, e0.start_id, s1.depth + 1, false, 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.end_id = s1.next_id and e0.id != all (s1.path) and e0.kind_id = any (array [3, 4]::int2[]) offset 0) e0 on true where s1.depth < 15 and not s1.is_cycle) select 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 limit 10) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 limit 10; @@ -78,7 +78,8 @@ with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as with s0 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 [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.kind_id = any (array [3, 4]::int2[])), s1 as (select s0.e0 as e0, e1.id as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.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 != s0.e0), s2 as (with recursive s3_seed(root_id) as not materialized (select distinct (s1.n2).id as root_id from s1), s3(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.start_id = e2.end_id, array [e2.id] from s3_seed join edge e2 on e2.start_id = s3_seed.root_id join node n3 on n3.id = e2.end_id where e2.kind_id = any (array [3]::int2[]) union all select s3.root_id, e2.end_id, s3.depth + 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, s3.path || e2.id from s3 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.start_id = s3.next_id and e2.id != all (s3.path) and e2.kind_id = any (array [3]::int2[]) offset 0) e2 on true join node n3 on n3.id = e2.end_id where s3.depth < 15 and not s3.is_cycle) select s1.e0 as e0, s1.e1 as e1, s3.path as ep0, s1.n0 as n0, s1.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s1, s3 join lateral (select n2.id, n2.kind_ids, n2.properties from node n2 where n2.id = s3.root_id offset 0) n2 on true join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s3.next_id offset 0) n3 on true where s3.satisfied and (s1.n2).id = s3.root_id and (((s1.n0).properties -> 'objectid') = (n3.properties -> 'objectid')) limit 100) select case when (s2.n0).id is null or s2.e0 is null or (s2.n1).id is null or s2.e1 is null or (s2.n2).id is null or s2.ep0 is null or (s2.n3).id is null then null else ordered_edges_to_path(s2.n0, (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 [s2.e0]::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 [s2.e1]::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(s2.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1, s2.n2, s2.n3]::nodecomposite[])::pathcomposite end as p from s2 limit 100; -- case: match (a:NodeKind1)-[:EdgeKind1*0..]->(b:NodeKind1) where a.name = 'solo' and b.name = 'solo' return a.name, b.name -with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('solo')::text)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select s1_seed.root_id, s1_seed.root_id, 0, (((n1.properties -> 'name'))::jsonb = to_jsonb(('solo')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, array []::int8[] from s1_seed join node n1 on n1.id = s1_seed.root_id union all select e0.start_id, e0.end_id, 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('solo')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [1]::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]::int2[]) union all select s1.root_id, e0.end_id, s1.depth + 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('solo')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [1]::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]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle and s1.depth > 0) select (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 ((s0.n0).properties -> 'name'), ((s0.n1).properties -> 'name') from s0; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = 'solo')) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select s1_seed.root_id, s1_seed.root_id, 0, ((jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'solo')) and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, array []::int8[] from s1_seed join node n1 on n1.id = s1_seed.root_id union all select e0.start_id, e0.end_id, 1, ((jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'solo')) and n1.kind_ids operator (pg_catalog.@>) array [1]::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]::int2[]) union all select s1.root_id, e0.end_id, s1.depth + 1, ((jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'solo')) and n1.kind_ids operator (pg_catalog.@>) array [1]::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]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle and s1.depth > 0) select (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 ((s0.n0).properties -> 'name'), ((s0.n1).properties -> 'name') from s0; -- case: match (a:NodeKind1)-[:EdgeKind1*0..]->(b:NodeKind1) where a.name = 'zero-source' and b.name = 'zero-target' return count(b) -with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('zero-source')::text)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select s1_seed.root_id, s1_seed.root_id, 0, (((n1.properties -> 'name'))::jsonb = to_jsonb(('zero-target')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, array []::int8[] from s1_seed join node n1 on n1.id = s1_seed.root_id union all select e0.start_id, e0.end_id, 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('zero-target')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [1]::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]::int2[]) union all select s1.root_id, e0.end_id, s1.depth + 1, (((n1.properties -> 'name'))::jsonb = to_jsonb(('zero-target')::text)::jsonb) and n1.kind_ids operator (pg_catalog.@>) array [1]::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]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle and s1.depth > 0) select (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 count(s0.n1)::int8 from s0; +with s0 as (with recursive s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = 'zero-source')) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select s1_seed.root_id, s1_seed.root_id, 0, ((jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'zero-target')) and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, array []::int8[] from s1_seed join node n1 on n1.id = s1_seed.root_id union all select e0.start_id, e0.end_id, 1, ((jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'zero-target')) and n1.kind_ids operator (pg_catalog.@>) array [1]::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]::int2[]) union all select s1.root_id, e0.end_id, s1.depth + 1, ((jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'zero-target')) and n1.kind_ids operator (pg_catalog.@>) array [1]::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]::int2[]) offset 0) e0 on true join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle and s1.depth > 0) select (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 count(s0.n1)::int8 from s0; + diff --git a/cypher/models/pgsql/test/translation_cases/pattern_rewriting.sql b/cypher/models/pgsql/test/translation_cases/pattern_rewriting.sql index 21dcea9d..01ee8044 100644 --- a/cypher/models/pgsql/test/translation_cases/pattern_rewriting.sql +++ b/cypher/models/pgsql/test/translation_cases/pattern_rewriting.sql @@ -16,3 +16,4 @@ -- case: match (s:NodeKind1) return s 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 s from s0; + diff --git a/cypher/models/pgsql/test/translation_cases/quantifiers.sql b/cypher/models/pgsql/test/translation_cases/quantifiers.sql index 01b88d38..c4d2f249 100644 --- a/cypher/models/pgsql/test/translation_cases/quantifiers.sql +++ b/cypher/models/pgsql/test/translation_cases/quantifiers.sql @@ -46,3 +46,4 @@ with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposit -- case: MATCH (m:NodeKind1) WHERE ANY(name in m.serviceprincipalnames WHERE name CONTAINS "PHANTOM") WITH m MATCH (n:NodeKind1)-[:EdgeKind1]->(g:NodeKind2) WHERE g.objectid ENDS WITH '-525' WITH m, COLLECT(n) AS matchingNs WHERE NONE(t IN matchingNs WHERE t.objectid = m.objectid) RETURN m with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((select count(*)::int from unnest(jsonb_to_text_array((n0.properties -> 'serviceprincipalnames'))) as i0 where (i0 like '%PHANTOM%')) >= 1)::bool) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s1.n0 as n0 from s1), s2 as (with s3 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, edge e0 join node n2 on ((n2.properties ->> 'objectid') like '%-525') and n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e0.end_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.start_id where e0.kind_id = any (array [3]::int2[])) select s3.n0 as n0, array_remove(coalesce(array_agg(s3.n1)::nodecomposite[], array []::nodecomposite[])::nodecomposite[], null)::nodecomposite[] as i1 from s3 group by n0) select s2.n0 as m from s2 where (((select count(*)::int from unnest(s2.i1) as i2 where ((i2.properties -> 'objectid') = ((s2.n0).properties -> 'objectid'))) = 0 and s2.i1 is not null)::bool); + diff --git a/cypher/models/pgsql/test/translation_cases/scalar_aggregation.sql b/cypher/models/pgsql/test/translation_cases/scalar_aggregation.sql index d4668bff..21458c72 100644 --- a/cypher/models/pgsql/test/translation_cases/scalar_aggregation.sql +++ b/cypher/models/pgsql/test/translation_cases/scalar_aggregation.sql @@ -109,3 +109,4 @@ with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposit -- case: MATCH (n) WITH sum(n.age) / count(n) AS avg_age RETURN avg_age with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select sum((((s1.n0).properties ->> 'age'))::float8)::numeric / count(s1.n0)::int8 as i0 from s1) select s0.i0 as avg_age 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 b6693945..91bbce1d 100644 --- a/cypher/models/pgsql/test/translation_cases/shortest_paths.sql +++ b/cypher/models/pgsql/test/translation_cases/shortest_paths.sql @@ -19,11 +19,11 @@ with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_asp_harness(@pi0::text, @pi1::text, 15)) select 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where case when s1.root_id != s1.next_id then true else shortest_path_self_endpoint_error(s1.root_id, s1.next_id) end) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0; -- case: match p = allShortestPaths((s:NodeKind1)-[*..]->({name: "123"})) return p --- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where ((n1.properties -\u003e 'name'))::jsonb = to_jsonb(('123')::text)::jsonb) select e0.end_id, e0.start_id, 1, exists (select 1 from traversal_terminal_filter where traversal_terminal_filter.id = e0.start_id), e0.start_id = e0.end_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id where case when (select count(*)::int8 from traversal_terminal_filter where traversal_terminal_filter.id = e0.end_id) = 0 then true else shortest_path_self_endpoint_error(e0.end_id, e0.end_id) end;","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.start_id, s1.depth + 1, exists (select 1 from traversal_terminal_filter where traversal_terminal_filter.id = e0.start_id), false, e0.id || s1.path from forward_front s1 join edge e0 on e0.end_id = s1.next_id where e0.id != all (s1.path);"} +-- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where (jsonb_typeof((n1.properties -\u003e 'name')) = 'string' and (n1.properties -\u003e\u003e 'name') = '123')) select e0.end_id, e0.start_id, 1, exists (select 1 from traversal_terminal_filter where traversal_terminal_filter.id = e0.start_id), e0.start_id = e0.end_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id where case when (select count(*)::int8 from traversal_terminal_filter where traversal_terminal_filter.id = e0.end_id) = 0 then true else shortest_path_self_endpoint_error(e0.end_id, e0.end_id) end;","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.start_id, s1.depth + 1, exists (select 1 from traversal_terminal_filter where traversal_terminal_filter.id = e0.start_id), false, e0.id || s1.path from forward_front s1 join edge e0 on e0.end_id = s1.next_id where e0.id != all (s1.path);"} with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_asp_harness(@pi0::text, @pi1::text, 15, ('')::text, ('insert into traversal_terminal_filter (id) select distinct n0.id from node n0 where n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id is not null;')::text)) select 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 node n1 on n1.id = s1.root_id join node n0 on n0.id = s1.next_id where case when s1.root_id != s1.next_id then true else shortest_path_self_endpoint_error(s1.root_id, s1.next_id) end) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0; -- case: match p = allShortestPaths((s:NodeKind1)-[*..]->(e)) where e.name = '123' return p --- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where (((n1.properties -\u003e 'name'))::jsonb = to_jsonb(('123')::text)::jsonb)) select e0.end_id, e0.start_id, 1, exists (select 1 from traversal_terminal_filter where traversal_terminal_filter.id = e0.start_id), e0.start_id = e0.end_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id where case when (select count(*)::int8 from traversal_terminal_filter where traversal_terminal_filter.id = e0.end_id) = 0 then true else shortest_path_self_endpoint_error(e0.end_id, e0.end_id) end;","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.start_id, s1.depth + 1, exists (select 1 from traversal_terminal_filter where traversal_terminal_filter.id = e0.start_id), false, e0.id || s1.path from forward_front s1 join edge e0 on e0.end_id = s1.next_id where e0.id != all (s1.path);"} +-- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where ((jsonb_typeof((n1.properties -\u003e 'name')) = 'string' and (n1.properties -\u003e\u003e 'name') = '123'))) select e0.end_id, e0.start_id, 1, exists (select 1 from traversal_terminal_filter where traversal_terminal_filter.id = e0.start_id), e0.start_id = e0.end_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id where case when (select count(*)::int8 from traversal_terminal_filter where traversal_terminal_filter.id = e0.end_id) = 0 then true else shortest_path_self_endpoint_error(e0.end_id, e0.end_id) end;","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.start_id, s1.depth + 1, exists (select 1 from traversal_terminal_filter where traversal_terminal_filter.id = e0.start_id), false, e0.id || s1.path from forward_front s1 join edge e0 on e0.end_id = s1.next_id where e0.id != all (s1.path);"} with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_asp_harness(@pi0::text, @pi1::text, 15, ('')::text, ('insert into traversal_terminal_filter (id) select distinct n0.id from node n0 where n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id is not null;')::text)) select 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 node n1 on n1.id = s1.root_id join node n0 on n0.id = s1.next_id where case when s1.root_id != s1.next_id then true else shortest_path_self_endpoint_error(s1.root_id, s1.next_id) end) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0; -- case: match p=shortestPath((n:NodeKind1)-[:EdgeKind1*1..]->(m)) where 'admin_tier_0' in split(m.system_tags, ' ') and n.objectid ends with '-513' and n<>m return p limit 1000 @@ -55,8 +55,8 @@ with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (sele with s0 as (with s1(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.id = 2) and (n1.id = 1) and n0.id is not null and n1.id is not null;')::text)) select 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 node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where case when s1.root_id != s1.next_id then true else shortest_path_self_endpoint_error(s1.root_id, s1.next_id) end) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0; -- case: match p = allShortestPaths((m:NodeKind1)<-[:EdgeKind1*..]-(n)) where coalesce(m.system_tags, '') contains 'admin_tier_0' and n.name = '123' and n <> m return p --- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where (((n1.properties -\u003e 'name'))::jsonb = to_jsonb(('123')::text)::jsonb)) 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 s1_seed join edge e0 on e0.start_id = s1_seed.root_id where e0.kind_id = any (array [3]::int2[]);","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.end_id, s1.depth + 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = s1.root_id and traversal_pair_filter.terminal_id = e0.end_id), false, s1.path || e0.id from forward_front s1 join edge e0 on e0.start_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path);","pi2":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (coalesce((n0.properties -\u003e\u003e 'system_tags'), '')::text like '%admin_tier_0%') and n0.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 s1_seed join edge e0 on e0.end_id = s1_seed.root_id where e0.kind_id = any (array [3]::int2[]);","pi3":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.start_id, s1.depth + 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = e0.start_id and traversal_pair_filter.terminal_id = s1.root_id), false, e0.id || s1.path from backward_front s1 join edge e0 on e0.end_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path);"} -with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from bidirectional_asp_harness(@pi0::text, @pi1::text, @pi2::text, @pi3::text, 15, ('')::text, ('')::text, ('insert into traversal_pair_filter (root_id, terminal_id) select distinct n1.id, n0.id from node n1, node n0 where (((n1.properties -> ''name''))::jsonb = to_jsonb((''123'')::text)::jsonb) and (coalesce((n0.properties ->> ''system_tags''), '''')::text like ''%admin_tier_0%'') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id is not null and n0.id is not null;')::text)) select 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 node n1 on n1.id = s1.root_id join node n0 on n0.id = s1.next_id) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 where ((s0.n1).id <> (s0.n0).id); +-- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where ((jsonb_typeof((n1.properties -\u003e 'name')) = 'string' and (n1.properties -\u003e\u003e 'name') = '123'))) 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 s1_seed join edge e0 on e0.start_id = s1_seed.root_id where e0.kind_id = any (array [3]::int2[]);","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.end_id, s1.depth + 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = s1.root_id and traversal_pair_filter.terminal_id = e0.end_id), false, s1.path || e0.id from forward_front s1 join edge e0 on e0.start_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path);","pi2":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n0.id as root_id from node n0 where (coalesce((n0.properties -\u003e\u003e 'system_tags'), '')::text like '%admin_tier_0%') and n0.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 s1_seed join edge e0 on e0.end_id = s1_seed.root_id where e0.kind_id = any (array [3]::int2[]);","pi3":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.start_id, s1.depth + 1, exists (select 1 from traversal_pair_filter where traversal_pair_filter.root_id = e0.start_id and traversal_pair_filter.terminal_id = s1.root_id), false, e0.id || s1.path from backward_front s1 join edge e0 on e0.end_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path);"} +with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from bidirectional_asp_harness(@pi0::text, @pi1::text, @pi2::text, @pi3::text, 15, ('')::text, ('')::text, ('insert into traversal_pair_filter (root_id, terminal_id) select distinct n1.id, n0.id from node n1, node n0 where ((jsonb_typeof((n1.properties -> ''name'')) = ''string'' and (n1.properties ->> ''name'') = ''123'')) and (coalesce((n0.properties ->> ''system_tags''), '''')::text like ''%admin_tier_0%'') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id is not null and n0.id is not null;')::text)) select 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 node n1 on n1.id = s1.root_id join node n0 on n0.id = s1.next_id) select case when (s0.n0).id is null or s0.ep0 is null or (s0.n1).id is null then null else ordered_edges_to_path(s0.n0, (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(s0.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s0.n0, s0.n1]::nodecomposite[])::pathcomposite end as p from s0 where ((s0.n1).id <> (s0.n0).id); -- case: match p=shortestPath((a)-[:EdgeKind1*]->(b:NodeKind1)) where a <> b return p -- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) with s1_seed(root_id) as not materialized (select n1.id as root_id from node n1 where n1.kind_ids operator (pg_catalog.@\u003e) array [1]::int2[]) select e0.end_id, e0.start_id, 1, exists (select 1 from edge where end_id = e0.end_id), e0.start_id = e0.end_id, array [e0.id] from s1_seed join edge e0 on e0.end_id = s1_seed.root_id where e0.kind_id = any (array [3]::int2[]);","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.start_id, s1.depth + 1, exists (select 1 from edge where end_id = e0.end_id), false, e0.id || s1.path from forward_front s1 join edge e0 on e0.end_id = s1.next_id where e0.kind_id = any (array [3]::int2[]) and e0.id != all (s1.path) and not exists (select 1 from visited where visited.root_id = s1.root_id and visited.id = e0.start_id);"} @@ -88,8 +88,9 @@ 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 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 case when (s2.n0).id is null or s2.ep0 is null or (s2.n1).id is null then null else ordered_edges_to_path(s2.n0, (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(s2.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1]::nodecomposite[])::pathcomposite end as p from s2 where (((select count(*)::int from unnest(((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(s2.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id))::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 ((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(s2.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id))::edgecomposite[] is not null)::bool); +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 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 case when (s2.n0).id is null or s2.ep0 is null or (s2.n1).id is null then null else ordered_edges_to_path(s2.n0, (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(s2.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s2.n0, s2.n1]::nodecomposite[])::pathcomposite end as p from s2 where (((select count(*)::int from unnest(((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(s2.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id))::edgecomposite[]) as i0 where ((jsonb_typeof(((start_node(i0)::nodecomposite).properties -> 'name')) = 'string' and ((start_node(i0)::nodecomposite).properties ->> 'name') = 'DF-WIN10-DEV01.DUMPSTER.FIRE') and i0.kind_id = 7)) = 0 and ((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(s2.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id))::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 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 case when (s1.n0).id is null or s1.ep0 is null or (s1.n1).id is null then null else ordered_edges_to_path(s1.n0, (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(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite end 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); +-- 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 ((jsonb_typeof((n0.properties -\u003e 'name')) = 'string' and (n0.properties -\u003e\u003e 'name') = 'path-filter-src')) 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 ((jsonb_typeof((n1.properties -\u003e 'name')) = 'string' and (n1.properties -\u003e\u003e 'name') = 'path-filter-dst')) 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 ((jsonb_typeof((n0.properties -> ''name'')) = ''string'' and (n0.properties ->> ''name'') = ''path-filter-src'')) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and ((jsonb_typeof((n1.properties -> ''name'')) = ''string'' and (n1.properties ->> ''name'') = ''path-filter-dst'')) and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id is not null and n1.id is not null;')::text)) select 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 case when (s1.n0).id is null or s1.ep0 is null or (s1.n1).id is null then null else ordered_edges_to_path(s1.n0, (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(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite end as pc0 from s1) select s0.pc0 as p from s0 where (((select count(*)::int from unnest(((s0.pc0).edges)::edgecomposite[]) as i0 where ((jsonb_typeof(((start_node(i0)::nodecomposite).properties -> 'name')) = 'string' and ((start_node(i0)::nodecomposite).properties ->> 'name') = 'blocked-session-host') and i0.kind_id = 7)) = 0 and ((s0.pc0).edges)::edgecomposite[] is not null)::bool); + diff --git a/cypher/models/pgsql/test/translation_cases/stepwise_traversal.sql b/cypher/models/pgsql/test/translation_cases/stepwise_traversal.sql index c658814c..f48a2bcf 100644 --- a/cypher/models/pgsql/test/translation_cases/stepwise_traversal.sql +++ b/cypher/models/pgsql/test/translation_cases/stepwise_traversal.sql @@ -51,7 +51,7 @@ select count(*)::int8 as the_count from edge e0 join node n0 on n0.id = e0.start 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 = any (array [3]::int2[])) select count(s0.e0)::int8 as the_count from s0 limit 1; -- case: match ()-[r:EdgeKind1]->({name: "123"}) return count(r) as the_count -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 n1 on ((n1.properties -> 'name'))::jsonb = to_jsonb(('123')::text)::jsonb and n1.id = e0.end_id join node n0 on n0.id = e0.start_id where e0.kind_id = any (array [3]::int2[])) select count(s0.e0)::int8 as the_count from s0; +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 n1 on (jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = '123') and n1.id = e0.end_id join node n0 on n0.id = e0.start_id where e0.kind_id = any (array [3]::int2[])) select count(s0.e0)::int8 as the_count from s0; -- case: match (s)-[r]->(e) where id(e) = $a and not (id(s) = $b) and (r:EdgeKind1 or r:EdgeKind2) and not (s.objectid ends with $c or e.objectid ends with $d) return distinct id(s), id(r), id(e) -- cypher_params: {"a":1,"b":2,"c":"123","d":"456"} @@ -59,7 +59,7 @@ with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::e 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 = e0.end_id join node n0 on (not (n0.id = @pi1::float8)) and n0.id = e0.start_id where ((e0.kind_id = any (array [3]::int2[]) or e0.kind_id = any (array [4]::int2[]))) and (not (cypher_ends_with((n0.properties ->> 'objectid'), (@pi2::text)::text)::bool or cypher_ends_with((n1.properties ->> 'objectid'), (@pi3::text)::text)::bool) and n1.id = @pi0::float8)) select distinct (s0.n0).id, (s0.e0).id, (s0.n1).id from s0; -- case: match (s)-[r]->(e) where s.name = '123' and e:NodeKind1 and not r.property return s, r, e -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.properties -> 'name'))::jsonb = to_jsonb(('123')::text)::jsonb) 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 (not ((e0.properties ->> 'property'))::bool)) select s0.n0 as s, s0.e0 as r, s0.n1 as e 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 n0 on ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = '123')) 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 (not ((e0.properties ->> 'property'))::bool)) select s0.n0 as s, s0.e0 as r, s0.n1 as e from s0; -- case: match ()-[r]->() where r.value = 42 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.properties -> 'value'))::jsonb = to_jsonb((42)::int8)::jsonb)) select s0.e0 as r from s0; @@ -68,16 +68,16 @@ with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::e 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.properties ->> 'bool_prop'))::bool)) select s0.e0 as r from s0; -- case: match (n)-[r]->() where n.name = '123' return n, 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 from edge e0 join node n0 on (((n0.properties -> 'name'))::jsonb = to_jsonb(('123')::text)::jsonb) and n0.id = e0.start_id join node n1 on n1.id = e0.end_id) select s0.n0 as n, s0.e0 as r 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 from edge e0 join node n0 on ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = '123')) and n0.id = e0.start_id join node n1 on n1.id = e0.end_id) select s0.n0 as n, s0.e0 as r from s0; -- case: match (n:NodeKind1)-[r]->() where n.name = '123' or n.name = '321' or n.name = '222' or n.name = '333' return n, 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 from edge e0 join node n0 on (((n0.properties -> 'name'))::jsonb = to_jsonb(('123')::text)::jsonb or ((n0.properties -> 'name'))::jsonb = to_jsonb(('321')::text)::jsonb or ((n0.properties -> 'name'))::jsonb = to_jsonb(('222')::text)::jsonb or ((n0.properties -> 'name'))::jsonb = to_jsonb(('333')::text)::jsonb) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id = e0.start_id join node n1 on n1.id = e0.end_id) select s0.n0 as n, s0.e0 as r 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 from edge e0 join node n0 on ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = '123') or (jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = '321') or (jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = '222') or (jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = '333')) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id = e0.start_id join node n1 on n1.id = e0.end_id) select s0.n0 as n, s0.e0 as r from s0; -- case: match (s)-[r]->(e) where s.name = '123' and e.name = '321' return s, r, e -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.properties -> 'name'))::jsonb = to_jsonb(('123')::text)::jsonb) and n0.id = e0.start_id join node n1 on (((n1.properties -> 'name'))::jsonb = to_jsonb(('321')::text)::jsonb) and n1.id = e0.end_id) select s0.n0 as s, s0.e0 as r, s0.n1 as e 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 n0 on ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = '123')) and n0.id = e0.start_id join node n1 on ((jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = '321')) and n1.id = e0.end_id) select s0.n0 as s, s0.e0 as r, s0.n1 as e from s0; -- case: match (f), (s)-[r]->(e) where not f.bool_field and s.name = '123' and e.name = '321' return f, s, r, e -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (not ((n0.properties ->> 'bool_field'))::bool)), 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, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, edge e0 join node n1 on (((n1.properties -> 'name'))::jsonb = to_jsonb(('123')::text)::jsonb) and n1.id = e0.start_id join node n2 on (((n2.properties -> 'name'))::jsonb = to_jsonb(('321')::text)::jsonb) and n2.id = e0.end_id) select s1.n0 as f, s1.n1 as s, s1.e0 as r, s1.n2 as e from s1; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (not ((n0.properties ->> 'bool_field'))::bool)), 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, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, edge e0 join node n1 on ((jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = '123')) and n1.id = e0.start_id join node n2 on ((jsonb_typeof((n2.properties -> 'name')) = 'string' and (n2.properties ->> 'name') = '321')) and n2.id = e0.end_id) select s1.n0 as f, s1.n1 as s, s1.e0 as r, s1.n2 as e from s1; -- case: match ()-[e0]->(n)<-[e1]-() return e0, n, e1 with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (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), s1 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.n1 as n1 from s0 join edge e1 on (s0.n1).id = e1.end_id join node n2 on n2.id = e1.start_id where e1.id != (s0.e0).id) select s1.e0 as e0, s1.n1 as n, s1.e1 as e1 from s1; @@ -98,7 +98,7 @@ with s0 as (select e0.id as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposi with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[])) select ((s0.n0).properties -> 'name'), ((s0.n1).properties -> 'name') from s0; -- case: match (s)-[r:EdgeKind1]->() where (s)-[r {prop: 'a'}]->() return s -with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.id = e0.end_id where ((e0.properties -> 'prop'))::jsonb = to_jsonb(('a')::text)::jsonb and e0.kind_id = any (array [3]::int2[])) select s0.n0 as s from s0 where ((with s1 as (select s0.e0 as e0, s0.n0 as n0 from edge e0 join node n2 on n2.id = (s0.e0).end_id where (s0.n0).id = (s0.e0).start_id) select count(*) > 0 from s1)); +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 (jsonb_typeof((e0.properties -> 'prop')) = 'string' and (e0.properties ->> 'prop') = 'a') and e0.kind_id = any (array [3]::int2[])) select s0.n0 as s from s0 where ((with s1 as (select s0.e0 as e0, s0.n0 as n0 from edge e0 join node n2 on n2.id = (s0.e0).end_id where (s0.n0).id = (s0.e0).start_id) select count(*) > 0 from s1)); -- case: match (s)-[r:EdgeKind1]->(e) where not (s.system_tags contains 'admin_tier_0') and id(e) = 1 return id(s), labels(s), id(r), type(r) with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n1 on (n1.id = 1) and n1.id = e0.end_id join node n0 on (not (coalesce((n0.properties ->> 'system_tags'), '')::text like '%admin\_tier\_0%')) and n0.id = e0.start_id where e0.kind_id = any (array [3]::int2[])) select (s0.n0).id, (array(select _kind.name from generate_subscripts((s0.n0).kind_ids, 1) as _kind_idx, kind _kind where _kind.id = ((s0.n0).kind_ids)[_kind_idx] order by _kind_idx))::text[], (s0.e0).id, kind_name((s0.e0).kind_id)::text from s0; @@ -117,3 +117,4 @@ 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 8c2c8160..4c00ab6e 100644 --- a/cypher/models/pgsql/test/translation_cases/unwind.sql +++ b/cypher/models/pgsql/test/translation_cases/unwind.sql @@ -48,7 +48,7 @@ with s0 as (select array [1, 2, 3]::int8[] as i0) select i1 as x from s0, unnest 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; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((jsonb_typeof((n0.properties -> 'environmentid')) = 'string' and (n0.properties ->> 'environmentid') = '1234'))) 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; -- case: MATCH (n) UNWIND labels(n) AS label RETURN label, count(n) AS count with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select i0 as label, 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; @@ -67,3 +67,4 @@ with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposit -- case: MATCH (n) WITH collect(n.name) + ['tail'] AS names UNWIND names AS name RETURN name 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(((s1.n0).properties ->> 'name'))::anyarray, array []::text[])::anyarray, null)::anyarray || array ['tail']::text[] as i0 from s1) select i1 as name from s0, unnest(i0) as i1; + diff --git a/cypher/models/pgsql/test/translation_cases/update.sql b/cypher/models/pgsql/test/translation_cases/update.sql index 8e54475d..7663e030 100644 --- a/cypher/models/pgsql/test/translation_cases/update.sql +++ b/cypher/models/pgsql/test/translation_cases/update.sql @@ -36,10 +36,10 @@ 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), s1 as (update node n1 set kind_ids = n1.kind_ids - array [1]::int2[], properties = n1.properties - array ['prop']::text[] from s0 where (s0.n0).id = n1.id returning (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n0) select s1.n0 as n from s1; -- case: match (n) where n.name = '1234' set n.is_target = true -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('1234')::text)::jsonb)), s1 as (update node n1 set properties = n1.properties || jsonb_build_object('is_target', true)::jsonb from s0 where (s0.n0).id = n1.id returning (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n0) select 1; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = '1234'))), s1 as (update node n1 set properties = n1.properties || jsonb_build_object('is_target', true)::jsonb from s0 where (s0.n0).id = n1.id returning (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n0) select 1; -- case: match (n) where n.name = '1234' match (e) where e.tag = n.tag_id set e.is_target = true -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('1234')::text)::jsonb)), s1 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where ((n1.properties -> 'tag') = ((s0.n0).properties -> 'tag_id'))), s2 as (update node n2 set properties = n2.properties || jsonb_build_object('is_target', true)::jsonb from s1 where (s1.n1).id = n2.id returning s1.n0 as n0, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n1) select 1; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = '1234'))), s1 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where ((n1.properties -> 'tag') = ((s0.n0).properties -> 'tag_id'))), s2 as (update node n2 set properties = n2.properties || jsonb_build_object('is_target', true)::jsonb from s1 where (s1.n1).id = n2.id returning s1.n0 as n0, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n1) select 1; -- case: match (n1), (n3) set n1.target = true set n3.target = true return n1, n3 with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0), s1 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1), s2 as (update node n2 set properties = n2.properties || jsonb_build_object('target', true)::jsonb from s1 where (s1.n0).id = n2.id returning (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n0, s1.n1 as n1), s3 as (update node n3 set properties = n3.properties || jsonb_build_object('target', true)::jsonb from s2 where (s2.n1).id = n3.id returning s2.n0 as n0, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n1) select s3.n0 as n1, s3.n1 as n3 from s3; @@ -60,13 +60,14 @@ 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), s1 as (update node n1 set properties = n1.properties - array ['prop']::text[] || jsonb_build_object('name', 'n' || (s0.n0).id)::jsonb from s0 where (s0.n0).id = n1.id returning (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n0) select 1; -- case: match (n) where n.name = 'n3' set n.name = 'RENAMED' return n -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n3')::text)::jsonb)), s1 as (update node n1 set properties = n1.properties || jsonb_build_object('name', 'RENAMED')::jsonb from s0 where (s0.n0).id = n1.id returning (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n0) select s1.n0 as n from s1; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = 'n3'))), s1 as (update node n1 set properties = n1.properties || jsonb_build_object('name', 'RENAMED')::jsonb from s0 where (s0.n0).id = n1.id returning (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n0) select s1.n0 as n from s1; -- case: match (n), (e) where n.name = 'n1' and e.name = 'n4' set n.name = e.name set e.name = 'RENAMED' -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb)), 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'))::jsonb = to_jsonb(('n4')::text)::jsonb)), s2 as (update node n2 set properties = n2.properties || jsonb_build_object('name', ((s1.n1).properties -> 'name'))::jsonb from s1 where (s1.n0).id = n2.id returning (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n0, s1.n1 as n1), s3 as (update node n3 set properties = n3.properties || jsonb_build_object('name', 'RENAMED')::jsonb from s2 where (s2.n1).id = n3.id returning s2.n0 as n0, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n1) select 1; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = 'n1'))), s1 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where ((jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'n4'))), s2 as (update node n2 set properties = n2.properties || jsonb_build_object('name', ((s1.n1).properties -> 'name'))::jsonb from s1 where (s1.n0).id = n2.id returning (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n0, s1.n1 as n1), s3 as (update node n3 set properties = n3.properties || jsonb_build_object('name', 'RENAMED')::jsonb from s2 where (s2.n1).id = n3.id returning s2.n0 as n0, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n1) select 1; -- case: match (n)-[r:EdgeKind1]->() where n:NodeKind1 set r.visited = true return 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 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.id = e0.end_id where e0.kind_id = any (array [3]::int2[])), s1 as (update edge e1 set properties = e1.properties || jsonb_build_object('visited', true)::jsonb from s0 where (s0.e0).id = e1.id returning (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e0, s0.n0 as n0) select s1.e0 as r from s1; -- case: match (n)-[]->()-[r]->() where n.name = 'n1' set r.visited = true return r.name -with s0 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.properties -> 'name'))::jsonb = to_jsonb(('n1')::text)::jsonb) and n0.id = e0.start_id join node n1 on n1.id = e0.end_id), s1 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.n0 as n0, s0.n1 as n1 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.id != s0.e0), s2 as (update edge e2 set properties = e2.properties || jsonb_build_object('visited', true)::jsonb from s1 where (s1.e1).id = e2.id returning s1.e0 as e0, (e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties)::edgecomposite as e1, s1.n0 as n0, s1.n1 as n1) select ((s2.e1).properties -> 'name') from s2; +with s0 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 ((jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = 'n1')) and n0.id = e0.start_id join node n1 on n1.id = e0.end_id), s1 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.n0 as n0, s0.n1 as n1 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.id != s0.e0), s2 as (update edge e2 set properties = e2.properties || jsonb_build_object('visited', true)::jsonb from s1 where (s1.e1).id = e2.id returning s1.e0 as e0, (e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties)::edgecomposite as e1, s1.n0 as n0, s1.n1 as n1) select ((s2.e1).properties -> 'name') from s2; + diff --git a/cypher/models/pgsql/translate/expression_test.go b/cypher/models/pgsql/translate/expression_test.go index c95edb1c..4127490c 100644 --- a/cypher/models/pgsql/translate/expression_test.go +++ b/cypher/models/pgsql/translate/expression_test.go @@ -304,13 +304,13 @@ func TestPropertyLookupEqualityScalarRewrites(t *testing.T) { mustAsLiteral(property), ) } - renderEquality := func(t *testing.T, lOperand, rOperand pgsql.Expression) string { + renderComparison := func(t *testing.T, lOperand pgsql.Expression, operator pgsql.Operator, rOperand pgsql.Expression) string { t.Helper() treeTranslator := translate.NewExpressionTreeTranslator(nil) treeTranslator.PushOperand(lOperand) treeTranslator.PushOperand(rOperand) - require.NoError(t, treeTranslator.CompleteBinaryExpression(translate.NewScope(), pgsql.OperatorEquals)) + require.NoError(t, treeTranslator.CompleteBinaryExpression(translate.NewScope(), operator)) formatted, err := format.Expression(treeTranslator.PeekOperand(), format.NewOutputBuilder()) require.NoError(t, err) @@ -321,43 +321,62 @@ func TestPropertyLookupEqualityScalarRewrites(t *testing.T) { testCases := []struct { Name string LOperand pgsql.Expression + Operator pgsql.Operator ROperand pgsql.Expression Expected string }{{ - Name: "boolean string literal keeps text property lookup", + Name: "string literal uses typed text property lookup", LOperand: propertyLookup("isassignabletorole"), + Operator: pgsql.OperatorEquals, ROperand: mustAsLiteral("true"), - Expected: "(n.properties ->> 'isassignabletorole') = 'true'", + Expected: "(jsonb_typeof((n.properties -> 'isassignabletorole')) = 'string' and (n.properties ->> 'isassignabletorole') = 'true')", }, { - Name: "boolean string literal keeps text property lookup when reversed", + Name: "string literal uses typed text property lookup when reversed", LOperand: mustAsLiteral("true"), + Operator: pgsql.OperatorEquals, ROperand: propertyLookup("isassignabletorole"), - Expected: "'true' = (n.properties ->> 'isassignabletorole')", + Expected: "(jsonb_typeof((n.properties -> 'isassignabletorole')) = 'string' and 'true' = (n.properties ->> 'isassignabletorole'))", }, { - Name: "non-boolean string literal keeps jsonb scalar equality", + Name: "numeric-looking string literal remains string typed", LOperand: propertyLookup("rank"), + Operator: pgsql.OperatorEquals, ROperand: mustAsLiteral("1"), - Expected: "((n.properties -> 'rank'))::jsonb = to_jsonb(('1')::text)::jsonb", + Expected: "(jsonb_typeof((n.properties -> 'rank')) = 'string' and (n.properties ->> 'rank') = '1')", + }, { + Name: "text parameter uses typed text property lookup", + LOperand: propertyLookup("objectid"), + Operator: pgsql.OperatorEquals, + ROperand: pgsql.Parameter{Identifier: "pi0", CastType: pgsql.Text}, + Expected: "(jsonb_typeof((n.properties -> 'objectid')) = 'string' and (n.properties ->> 'objectid') = @pi0::text)", + }, { + Name: "string inequality keeps non-string JSONB branch", + LOperand: propertyLookup("rank"), + Operator: pgsql.OperatorCypherNotEquals, + ROperand: mustAsLiteral("1"), + Expected: "(jsonb_typeof((n.properties -> 'rank')) = 'string' and (n.properties ->> 'rank') <> '1' or jsonb_typeof((n.properties -> 'rank')) <> 'string' and (n.properties -> 'rank') <> to_jsonb(('1')::text)::jsonb)", }, { Name: "boolean literal keeps jsonb scalar equality", LOperand: propertyLookup("isassignabletorole"), + Operator: pgsql.OperatorEquals, ROperand: mustAsLiteral(true), Expected: "((n.properties -> 'isassignabletorole'))::jsonb = to_jsonb((true)::bool)::jsonb", }, { Name: "numeric literal keeps jsonb scalar equality", LOperand: propertyLookup("count"), + Operator: pgsql.OperatorEquals, ROperand: mustAsLiteral(1), Expected: "((n.properties -> 'count'))::jsonb = to_jsonb((1)::int8)::jsonb", }, { Name: "property to property equality keeps jsonb operands", LOperand: propertyLookup("left"), + Operator: pgsql.OperatorEquals, ROperand: propertyLookup("right"), Expected: "(n.properties -> 'left') = (n.properties -> 'right')", }} for _, testCase := range testCases { t.Run(testCase.Name, func(t *testing.T) { - require.Equal(t, testCase.Expected, renderEquality(t, testCase.LOperand, testCase.ROperand)) + require.Equal(t, testCase.Expected, renderComparison(t, testCase.LOperand, testCase.Operator, testCase.ROperand)) }) } } From ce73e36ac6e3e54cf2ed293f43ce45869b9674fe Mon Sep 17 00:00:00 2001 From: John Hopper Date: Fri, 22 May 2026 19:57:37 -0700 Subject: [PATCH 064/116] Add PG string equality plan coverage --- integration/pgsql_property_equality_test.go | 128 ++++++++++++++++++-- 1 file changed, 117 insertions(+), 11 deletions(-) diff --git a/integration/pgsql_property_equality_test.go b/integration/pgsql_property_equality_test.go index 51dcddb0..5db0293e 100644 --- a/integration/pgsql_property_equality_test.go +++ b/integration/pgsql_property_equality_test.go @@ -19,9 +19,14 @@ package integration import ( + "context" "os" + "strings" "testing" + "time" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" "github.com/specterops/dawgs/drivers/pg" "github.com/specterops/dawgs/graph" "github.com/specterops/dawgs/query" @@ -62,13 +67,14 @@ func TestPostgreSQLPropertyTextEqualityCompatibility(t *testing.T) { } var ( - userKind = graph.StringKind("User") - groupKind = graph.StringKind("Group") - memberOf = graph.StringKind("MemberOf") - db, ctx = SetupDBWithKinds(t, graph.Kinds{userKind, groupKind}, graph.Kinds{memberOf}) - boolTrue *graph.Relationship - boolFalse *graph.Relationship - stringTrue *graph.Relationship + userKind = graph.StringKind("User") + groupKind = graph.StringKind("Group") + memberOf = graph.StringKind("MemberOf") + db, ctx = SetupDBWithKinds(t, graph.Kinds{userKind, groupKind}, graph.Kinds{memberOf}) + boolTrue *graph.Relationship + boolFalse *graph.Relationship + stringTrue *graph.Relationship + stringFalse *graph.Relationship ) if err := db.WriteTransaction(ctx, func(tx graph.Transaction) error { @@ -103,6 +109,14 @@ func TestPostgreSQLPropertyTextEqualityCompatibility(t *testing.T) { return err } + stringFalseGroup, err := tx.CreateNode(graph.AsProperties(map[string]any{ + "isassignabletorole": "false", + "rank": "2", + }), groupKind) + if err != nil { + return err + } + if boolTrue, err = tx.CreateRelationshipByIDs(user.ID, boolTrueGroup.ID, memberOf, graph.NewProperties()); err != nil { return err } @@ -112,6 +126,9 @@ func TestPostgreSQLPropertyTextEqualityCompatibility(t *testing.T) { if stringTrue, err = tx.CreateRelationshipByIDs(user.ID, stringTrueGroup.ID, memberOf, graph.NewProperties()); err != nil { return err } + if stringFalse, err = tx.CreateRelationshipByIDs(user.ID, stringFalseGroup.ID, memberOf, graph.NewProperties()); err != nil { + return err + } return nil }); err != nil { @@ -124,20 +141,20 @@ func TestPostgreSQLPropertyTextEqualityCompatibility(t *testing.T) { value any expected []graph.ID }{{ - name: "text true matches JSON boolean and string true", + name: "text true only matches JSON string true", property: "isassignabletorole", value: "true", - expected: []graph.ID{boolTrue.ID, stringTrue.ID}, + expected: []graph.ID{stringTrue.ID}, }, { name: "boolean true remains strict", property: "isassignabletorole", value: true, expected: []graph.ID{boolTrue.ID}, }, { - name: "text false matches JSON boolean false text", + name: "text false only matches JSON string false", property: "isassignabletorole", value: "false", - expected: []graph.ID{boolFalse.ID}, + expected: []graph.ID{stringFalse.ID}, }, { name: "boolean false remains strict", property: "isassignabletorole", @@ -181,3 +198,92 @@ func TestPostgreSQLPropertyTextEqualityCompatibility(t *testing.T) { }) } } + +func TestPostgreSQLLiveObjectIDEqualityPlanUsesTextExpressionIndex(t *testing.T) { + connStr := os.Getenv("CONNECTION_STRING") + if connStr == "" { + t.Skip("CONNECTION_STRING env var is not set") + } + + driver, err := driverFromConnStr(connStr) + if err != nil { + t.Fatalf("failed to detect driver: %v", err) + } + if driver != pg.DriverName { + t.Skipf("CONNECTION_STRING is not a PostgreSQL connection string") + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + poolCfg, err := pgxpool.ParseConfig(connStr) + if err != nil { + t.Fatalf("failed to parse PG connection string: %v", err) + } + pool, err := pgxpool.NewWithConfig(ctx, poolCfg) + if err != nil { + t.Fatalf("failed to connect to PostgreSQL: %v", err) + } + defer pool.Close() + + var hasObjectIDIndex bool + if err := pool.QueryRow(ctx, ` + select exists ( + select 1 + from pg_indexes + where tablename like 'node\_%' escape '\' + and indexdef like '%properties ->> ''objectid''%' + ) + `).Scan(&hasObjectIDIndex); err != nil { + t.Fatalf("failed to inspect node indexes: %v", err) + } + if !hasObjectIDIndex { + t.Skip("connected PostgreSQL database has no node objectid text expression index") + } + + var objectID string + if err := pool.QueryRow(ctx, ` + select n.properties ->> 'objectid' + from node n + join kind k on k.name = 'Group' + where n.kind_ids operator (pg_catalog.@>) array[k.id]::int2[] + and jsonb_typeof(n.properties -> 'objectid') = 'string' + limit 1 + `).Scan(&objectID); err != nil { + if err == pgx.ErrNoRows { + t.Skip("connected PostgreSQL database has no Group node with a string objectid") + } + + t.Fatalf("failed to find live objectid sample: %v", err) + } + + rows, err := pool.Query(ctx, ` + explain (analyze, buffers, timing off, summary off) + select n.id + from node n + where jsonb_typeof(n.properties -> 'objectid') = 'string' + and n.properties ->> 'objectid' = $1 + limit 1 + `, objectID) + if err != nil { + t.Fatalf("failed to explain objectid lookup: %v", err) + } + defer rows.Close() + + var planLines []string + for rows.Next() { + var line string + if err := rows.Scan(&line); err != nil { + t.Fatalf("failed to scan plan line: %v", err) + } + planLines = append(planLines, line) + } + if err := rows.Err(); err != nil { + t.Fatalf("failed while reading plan: %v", err) + } + + plan := strings.Join(planLines, "\n") + if !strings.Contains(plan, "Index") || !strings.Contains(plan, "objectid") { + t.Fatalf("expected objectid text expression index plan, got:\n%s", plan) + } +} From 4d674de92251e3155d869443f8d312ddfa46366a Mon Sep 17 00:00:00 2001 From: John Hopper Date: Fri, 22 May 2026 19:57:59 -0700 Subject: [PATCH 065/116] Add edge kind count index --- drivers/pg/query/sql/schema_up.sql | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/drivers/pg/query/sql/schema_up.sql b/drivers/pg/query/sql/schema_up.sql index 27912cbe..d3500afd 100644 --- a/drivers/pg/query/sql/schema_up.sql +++ b/drivers/pg/query/sql/schema_up.sql @@ -184,12 +184,14 @@ drop index if exists edge_kind_index; drop index if exists edge_start_kind_index; drop index if exists edge_end_kind_index; --- Covering indexes for traversal joins. The INCLUDE columns allow index-only scans for the common case where --- the join needs (id, start_id, end_id, kind_id) without fetching from the heap. The standalone start_id, --- end_id, and kind_id indexes are intentionally omitted: the composite indexes satisfy left-prefix lookups --- on start_id or end_id alone, and kind_id is never queried in isolation during traversal. +-- Covering indexes for traversal joins and relationship counts. The INCLUDE columns allow index-only scans for +-- the common case where the join needs (id, start_id, end_id, kind_id) without fetching from the heap. The standalone +-- start_id and end_id indexes are intentionally omitted: the composite indexes satisfy left-prefix lookups on start_id +-- or end_id alone. Relationship count fast paths query kind_id without an endpoint anchor, so keep a kind_id-first +-- covering index for those shapes. create index if not exists edge_start_id_kind_id_id_end_id_index on edge using btree (start_id, kind_id) include (id, end_id); create index if not exists edge_end_id_kind_id_id_start_id_index on edge using btree (end_id, kind_id) include (id, start_id); +create index if not exists edge_kind_id_id_start_id_end_id_index on edge using btree (kind_id) include (id, start_id, end_id); -- Path composite type do From 90c83848e4bd89e9aa50b065b1e9b39939f8cdcb Mon Sep 17 00:00:00 2001 From: John Hopper Date: Fri, 22 May 2026 19:58:32 -0700 Subject: [PATCH 066/116] Cover count fast path SQL shapes --- .../pgsql/translate/optimizer_safety_test.go | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index 96b82981..23885ee2 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -228,6 +228,38 @@ func TestOptimizerSafetyCountStoreFastPathUsesBaseEdgeCount(t *testing.T) { require.Equal(t, "select count(*)::int8 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 = any (array [10]::int2[]);", strings.Join(strings.Fields(formattedQuery), " ")) } +func TestOptimizerSafetyCountStoreFastPathUsesSparseEdgeKindCount(t *testing.T) { + t.Parallel() + + translation := optimizerSafetyTranslation(t, `MATCH ()-[r:Enroll]->() RETURN count(r)`) + formattedQuery, err := Translated(translation) + require.NoError(t, err) + normalizedQuery := strings.Join(strings.Fields(formattedQuery), " ") + + requirePlannedOptimizationLowering(t, translation.Optimization, optimize.LoweringCountStoreFastPath) + requireOptimizationLowering(t, translation.Optimization, optimize.LoweringCountStoreFastPath) + requireSkippedOptimizationLowering(t, translation.Optimization, optimize.LoweringProjectionPruning, "superseded by CountStoreFastPath") + require.NotContains(t, normalizedQuery, "with recursive") + require.NotContains(t, normalizedQuery, "ordered_edges_to_path") + require.Equal(t, "select count(*)::int8 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 = any (array [4]::int2[]);", normalizedQuery) +} + +func TestOptimizerSafetyCountStoreFastPathUsesUntypedEdgeCount(t *testing.T) { + t.Parallel() + + translation := optimizerSafetyTranslation(t, `MATCH ()-[r]->() RETURN count(r)`) + formattedQuery, err := Translated(translation) + require.NoError(t, err) + normalizedQuery := strings.Join(strings.Fields(formattedQuery), " ") + + requirePlannedOptimizationLowering(t, translation.Optimization, optimize.LoweringCountStoreFastPath) + requireOptimizationLowering(t, translation.Optimization, optimize.LoweringCountStoreFastPath) + requireSkippedOptimizationLowering(t, translation.Optimization, optimize.LoweringProjectionPruning, "superseded by CountStoreFastPath") + require.NotContains(t, normalizedQuery, "with recursive") + require.NotContains(t, normalizedQuery, "ordered_edges_to_path") + require.Equal(t, "select count(*)::int8 from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.id = e0.end_id;", normalizedQuery) +} + func TestOptimizerSafetyCountStoreFastPathSupportsEdgeCountStar(t *testing.T) { t.Parallel() From 03168c0bafe4a2eb4eee7f31e485046c3c93d2f7 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Fri, 22 May 2026 19:59:11 -0700 Subject: [PATCH 067/116] Document optimizer index assumptions --- README.md | 5 +++++ cypher/Cypher Syntax Support.md | 11 ++++++----- cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md | 14 ++++++++++++++ 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 2d022e07..2f898937 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,11 @@ for later baseline comparison. `CONNECTION_STRING` for one backend or `PG_CONNECTION_STRING` and `NEO4J_CONNECTION_STRING` for both backends, then writes JSONL captures and markdown/JSON summaries under `.coverage/`. +PostgreSQL translates exact string property equality with a JSON string type guard and `properties ->>` extraction, so +indexes created on expressions such as `properties ->> 'objectid'` and `properties ->> 'name'` can be used for selective +anchors without matching JSON booleans or numbers. Simple relationship count fast paths depend on the schema's +`kind_id`-first edge index for efficient typed counts. + Thresholds are report-only by default. To enforce the configured thresholds, run: ```bash diff --git a/cypher/Cypher Syntax Support.md b/cypher/Cypher Syntax Support.md index e65618da..b4032ff8 100644 --- a/cypher/Cypher Syntax Support.md +++ b/cypher/Cypher Syntax Support.md @@ -428,15 +428,16 @@ This indicates that there is a node with a value for `n.name` that is not parsab In the future, CySQL translation will cover most of the strict typing requirements automatically for users. -Property equality against the string literal or string parameter `'true'` or `'false'` is translated through PostgreSQL -JSON text extraction for backwards compatibility. This means a JSON boolean property value of `true` compares equal to -the string literal `'true'`. Other string equality operands use strict JSON scalar equality; use boolean or numeric -literals, such as `n.enabled = true` or `n.count = 1`, when typed JSON scalar equality is required. +Property equality against a string literal or text parameter is translated through PostgreSQL JSON text extraction with +a JSON string type guard. This keeps strings distinct from JSON booleans and numbers while allowing PostgreSQL +expression indexes such as `properties ->> 'objectid'` or `properties ->> 'name'` to accelerate exact string anchors. +Boolean and numeric literals continue to use strict JSON scalar equality; use boolean or numeric literals, such as +`n.enabled = true` or `n.count = 1`, when typed JSON scalar equality is required. ### Index Utilization Indexing in CySQL does not require a label specifier to be utilized. If the node property `name` is indexed in CySQL, -both: +exact string equality is emitted in a form compatible with PostgreSQL text expression indexes. Both: ``` match (n:User) where n.name = '1234' return n diff --git a/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md b/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md index 6b0b4937..9bac6735 100644 --- a/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md +++ b/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md @@ -99,3 +99,17 @@ Status: completed - Stop planning traversal predicate placements for binding predicates owned by a different `MATCH` clause. - Preserve same-clause binding predicate placement for traversal and suffix pushdown decisions. - Refreshed plan-corpus capture now plans and applies `PredicatePlacement` in the same 56 PostgreSQL cases, removing all skipped predicate-placement reports. + +## Phase 9: Live Dataset Assumption Checks + +Status: completed + +- Re-vet optimizer assumptions against a large live PostgreSQL graph with `EXPLAIN ANALYZE`. +- Exact string property anchors now lower to `jsonb_typeof(properties -> key) = 'string'` plus `properties ->> key = value`, + allowing existing `->>` expression indexes on selective fields such as `objectid` and `name` to be used without + matching JSON booleans or numbers. +- Relationship count fast paths remain endpoint-preserving for correctness, but the PostgreSQL schema now includes a + `kind_id`-first covering edge index so typed relationship counts have a direct access path instead of relying on + endpoint-oriented traversal indexes. +- Added PG-scoped manual integration coverage for strict string equality and a read-only live-plan check that asserts + indexed `objectid` lookups use a PostgreSQL index when the connected database exposes the expected expression index. From 3188ab25e58365ca9eac09856f9fa6a428f370bb Mon Sep 17 00:00:00 2001 From: John Hopper Date: Fri, 22 May 2026 20:02:11 -0700 Subject: [PATCH 068/116] Align optimizer safety string expectations --- cypher/models/pgsql/translate/optimizer_safety_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index 23885ee2..f2c6eaba 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -570,7 +570,8 @@ RETURN p requirePlannedOptimizationLowering(t, translation.Optimization, "TraversalDirectionSelection") requireOptimizationLowering(t, translation.Optimization, "TraversalDirectionSelection") - require.Contains(t, normalizedQuery, "where (((n1.properties -> 'name'))::jsonb = to_jsonb(('target')::text)::jsonb)") + require.Contains(t, normalizedQuery, "jsonb_typeof((n1.properties -> 'name')) = 'string'") + require.Contains(t, normalizedQuery, "(n1.properties ->> 'name') = 'target'") require.Contains(t, normalizedQuery, "join edge e0 on e0.end_id = s1_seed.root_id") } @@ -659,7 +660,7 @@ func TestOptimizerSafetyShortestPathRootCarriesUnwindSources(t *testing.T) { require.True(t, hasPrimerQuery) require.Contains(t, normalizedQuery, "unidirectional_sp_harness") require.Contains(t, normalizedQuery, "unnest(array ['source']::text[]) as i0") - require.Contains(t, primerQuery, "unnest(array ['source']::text[]) as i0") + require.Contains(t, primerQuery, "jsonb_typeof((n1.properties -> 'name')) = 'string'") require.Contains(t, primerQuery, "(n0.properties ->> 'name') = i0") } From 5bd5adc539f08a3c4fba71c10103f3ca4ba07f97 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Fri, 22 May 2026 20:11:29 -0700 Subject: [PATCH 069/116] Record optimizer validation status PostgreSQL make test_all passed with the provided endpoint. Neo4j make test_all was attempted with the provided endpoint but failed before exercising integration behavior because localhost:7687 refused connections. From bb986fadec4ac3a1eae31b8b80410bab0b922588 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Fri, 22 May 2026 21:10:05 -0700 Subject: [PATCH 070/116] More cases --- .../testdata/cases/optimizer_inline.json | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/integration/testdata/cases/optimizer_inline.json b/integration/testdata/cases/optimizer_inline.json index 1ded89db..a23110fb 100644 --- a/integration/testdata/cases/optimizer_inline.json +++ b/integration/testdata/cases/optimizer_inline.json @@ -181,6 +181,165 @@ ] }, "assert": {"row_values": [[4, 1, 1, 2]]} + }, + { + "name": "common search domain admins reverse membership source label disjunction", + "cypher": "MATCH p = (t:Group)<-[:MemberOf*1..]-(a) WHERE (a:User OR a:Computer) AND t.objectid ENDS WITH '-512' RETURN p LIMIT 1000", + "fixture": { + "nodes": [ + {"id": "admins", "kinds": ["Group"], "properties": {"objectid": "S-1-5-21-1-512"}}, + {"id": "user", "kinds": ["User"]}, + {"id": "computer", "kinds": ["Computer"]}, + {"id": "mid", "kinds": ["Group"]}, + {"id": "other", "kinds": ["Group"], "properties": {"objectid": "S-1-5-21-1-513"}}, + {"id": "ignored", "kinds": ["Base"]} + ], + "edges": [ + {"start_id": "user", "end_id": "admins", "kind": "MemberOf"}, + {"start_id": "computer", "end_id": "mid", "kind": "MemberOf"}, + {"start_id": "mid", "end_id": "admins", "kind": "MemberOf"}, + {"start_id": "ignored", "end_id": "admins", "kind": "MemberOf"}, + {"start_id": "user", "end_id": "other", "kind": "MemberOf"} + ] + }, + "assert": { + "row_count": 2, + "path_node_ids": [["admins", "user"], ["admins", "mid", "computer"]], + "path_edge_kinds": [["MemberOf"], ["MemberOf", "MemberOf"]] + } + }, + { + "name": "common search dangerous domain users privileges exclude memberof relationships", + "cypher": "MATCH p=(s:Group)-[r:MemberOf|GenericAll|GenericWrite]->(t:Base) WHERE s.objectid ENDS WITH '-513' AND NOT r:MemberOf RETURN p LIMIT 1000", + "fixture": { + "nodes": [ + {"id": "domain-users", "kinds": ["Group"], "properties": {"objectid": "S-1-5-21-1-513"}}, + {"id": "member-target", "kinds": ["Base"]}, + {"id": "generic-target", "kinds": ["Base"]}, + {"id": "other-group", "kinds": ["Group"], "properties": {"objectid": "S-1-5-21-1-512"}}, + {"id": "other-target", "kinds": ["Base"]} + ], + "edges": [ + {"start_id": "domain-users", "end_id": "member-target", "kind": "MemberOf"}, + {"start_id": "domain-users", "end_id": "generic-target", "kind": "GenericAll"}, + {"start_id": "other-group", "end_id": "other-target", "kind": "GenericWrite"} + ] + }, + "assert": { + "row_count": 1, + "path_node_ids": [["domain-users", "generic-target"]], + "path_edge_kinds": [["GenericAll"]] + } + }, + { + "name": "common search domain admins logons excludes domain controllers", + "cypher": "MATCH (s)-[:MemberOf*0..]->(g:Group) WHERE g.objectid ENDS WITH '-516' WITH COLLECT(s) AS exclude MATCH p = (c:Computer)-[:HasSession]->(:User)-[:MemberOf*1..]->(g:Group) WHERE g.objectid ENDS WITH '-512' AND NOT c IN exclude RETURN p LIMIT 1000", + "fixture": { + "nodes": [ + {"id": "dc", "kinds": ["Computer"]}, + {"id": "dc-group", "kinds": ["Group"], "properties": {"objectid": "S-1-5-21-1-516"}}, + {"id": "workstation", "kinds": ["Computer"]}, + {"id": "admin-user", "kinds": ["User"]}, + {"id": "domain-admins", "kinds": ["Group"], "properties": {"objectid": "S-1-5-21-1-512"}} + ], + "edges": [ + {"start_id": "dc", "end_id": "dc-group", "kind": "MemberOf"}, + {"start_id": "workstation", "end_id": "admin-user", "kind": "HasSession"}, + {"start_id": "dc", "end_id": "admin-user", "kind": "HasSession"}, + {"start_id": "admin-user", "end_id": "domain-admins", "kind": "MemberOf"} + ] + }, + "assert": { + "row_count": 1, + "path_node_ids": [["workstation", "admin-user", "domain-admins"]], + "path_edge_kinds": [["HasSession", "MemberOf"]] + } + }, + { + "name": "common search kerberoastable users ordered by reachable admin privilege count", + "cypher": "MATCH (u:User) WHERE u.hasspn = true AND u.enabled = true AND NOT u.objectid ENDS WITH '-502' AND NOT COALESCE(u.gmsa, false) = true AND NOT COALESCE(u.msa, false) = true MATCH (u)-[:MemberOf|AdminTo*1..]->(c:Computer) WITH DISTINCT u, COUNT(c) AS adminCount RETURN u ORDER BY adminCount DESC LIMIT 100", + "fixture": { + "nodes": [ + {"id": "roastable", "kinds": ["User"], "properties": {"objectid": "S-1-5-21-1-1100", "hasspn": true, "enabled": true, "gmsa": false, "msa": false}}, + {"id": "disabled", "kinds": ["User"], "properties": {"objectid": "S-1-5-21-1-1101", "hasspn": true, "enabled": false}}, + {"id": "krbtgt", "kinds": ["User"], "properties": {"objectid": "S-1-5-21-1-502", "hasspn": true, "enabled": true}}, + {"id": "ops-group", "kinds": ["Group"]}, + {"id": "computer-a", "kinds": ["Computer"]}, + {"id": "computer-b", "kinds": ["Computer"]}, + {"id": "computer-c", "kinds": ["Computer"]} + ], + "edges": [ + {"start_id": "roastable", "end_id": "computer-a", "kind": "AdminTo"}, + {"start_id": "roastable", "end_id": "ops-group", "kind": "MemberOf"}, + {"start_id": "ops-group", "end_id": "computer-b", "kind": "AdminTo"}, + {"start_id": "disabled", "end_id": "computer-c", "kind": "AdminTo"}, + {"start_id": "krbtgt", "end_id": "computer-a", "kind": "AdminTo"} + ] + }, + "assert": {"node_ids": ["roastable"]} + }, + { + "name": "common search shortest path from domain users to tier zero target", + "cypher": "MATCH p=shortestPath((s:Group)-[:MemberOf|GenericAll|AdminTo*1..]->(t:Tag_Tier_Zero)) WHERE s.objectid ENDS WITH '-513' AND s<>t RETURN p LIMIT 1000", + "fixture": { + "nodes": [ + {"id": "domain-users", "kinds": ["Group"], "properties": {"objectid": "S-1-5-21-1-513"}}, + {"id": "bridge", "kinds": ["Group"]}, + {"id": "tier-zero", "kinds": ["Base", "Tag_Tier_Zero"]}, + {"id": "other", "kinds": ["Base"]} + ], + "edges": [ + {"start_id": "domain-users", "end_id": "bridge", "kind": "MemberOf"}, + {"start_id": "bridge", "end_id": "tier-zero", "kind": "GenericAll"}, + {"start_id": "domain-users", "end_id": "other", "kind": "AdminTo"} + ] + }, + "assert": { + "row_count": 1, + "path_node_ids": [["domain-users", "bridge", "tier-zero"]], + "path_edge_kinds": [["MemberOf", "GenericAll"]] + } + }, + { + "name": "common search cross forest trusts require connected abuse edge", + "cypher": "MATCH p=(n:Domain)-[:CrossForestTrust|SpoofSIDHistory|AbuseTGTDelegation]-(m:Domain) WHERE (n)-[:SpoofSIDHistory|AbuseTGTDelegation]-(m) RETURN p LIMIT 1000", + "fixture": { + "nodes": [ + {"id": "domain-a", "kinds": ["Domain"]}, + {"id": "domain-b", "kinds": ["Domain"]}, + {"id": "domain-c", "kinds": ["Domain"]} + ], + "edges": [ + {"start_id": "domain-a", "end_id": "domain-b", "kind": "CrossForestTrust"}, + {"start_id": "domain-a", "end_id": "domain-b", "kind": "SpoofSIDHistory"}, + {"start_id": "domain-a", "end_id": "domain-c", "kind": "CrossForestTrust"} + ] + }, + "assert": "non_empty" + }, + { + "name": "common search azure high privileged role bounded membership expansion", + "cypher": "MATCH p=(t:AZRole)<-[:AZHasRole|AZMemberOf*1..2]-(a:AZBase) WHERE t.name =~ '(?i)Global Administrator.*' RETURN p LIMIT 1000", + "fixture": { + "nodes": [ + {"id": "role", "kinds": ["AZRole"], "properties": {"name": "Global Administrator"}}, + {"id": "direct-user", "kinds": ["AZUser", "AZBase"]}, + {"id": "delegated-user", "kinds": ["AZUser", "AZBase"]}, + {"id": "delegated-group", "kinds": ["AZGroup", "AZBase"]}, + {"id": "other-role", "kinds": ["AZRole"], "properties": {"name": "Reader"}} + ], + "edges": [ + {"start_id": "direct-user", "end_id": "role", "kind": "AZHasRole"}, + {"start_id": "delegated-user", "end_id": "delegated-group", "kind": "AZMemberOf"}, + {"start_id": "delegated-group", "end_id": "role", "kind": "AZHasRole"}, + {"start_id": "direct-user", "end_id": "other-role", "kind": "AZHasRole"} + ] + }, + "assert": { + "row_count": 2, + "path_node_ids": [["role", "direct-user"], ["role", "delegated-group", "delegated-user"]], + "path_edge_kinds": [["AZHasRole"], ["AZHasRole", "AZMemberOf"]] + } } ] } From 765d2ea4aaa57f389851fce18eeeaab24204e1b5 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Fri, 22 May 2026 21:14:05 -0700 Subject: [PATCH 071/116] Optimize typed pattern predicates --- cypher/models/pgsql/optimize/lowering_plan.go | 8 +-- .../models/pgsql/optimize/optimizer_test.go | 23 ++++++++ cypher/models/pgsql/translate/predicate.go | 52 ++++++++++++------- .../models/pgsql/translate/predicate_test.go | 7 ++- 4 files changed, 65 insertions(+), 25 deletions(-) diff --git a/cypher/models/pgsql/optimize/lowering_plan.go b/cypher/models/pgsql/optimize/lowering_plan.go index 4dfd2912..6fe3ecb4 100644 --- a/cypher/models/pgsql/optimize/lowering_plan.go +++ b/cypher/models/pgsql/optimize/lowering_plan.go @@ -169,13 +169,13 @@ func appendPatternPredicatePlacementDecisions(plan *LoweringPlan, queryPartIndex step := steps[0] if step.Relationship == nil || step.Relationship.Direction != graph.DirectionBoth || - relationshipPatternHasConstraints(step.Relationship) || + relationshipPatternHasProperties(step.Relationship) || nodePatternHasConstraints(step.LeftNode) || nodePatternHasConstraints(step.RightNode) { continue } - if variableSymbol(step.Relationship.Variable) != "" || variableSymbol(step.RightNode.Variable) != "" { + if variableSymbol(step.Relationship.Variable) != "" { continue } @@ -1084,8 +1084,8 @@ func nodePatternHasConstraints(nodePattern *cypher.NodePattern) bool { return nodePattern != nil && (len(nodePattern.Kinds) > 0 || nodePattern.Properties != nil) } -func relationshipPatternHasConstraints(relationshipPattern *cypher.RelationshipPattern) bool { - return relationshipPattern != nil && (len(relationshipPattern.Kinds) > 0 || relationshipPattern.Properties != nil) +func relationshipPatternHasProperties(relationshipPattern *cypher.RelationshipPattern) bool { + return relationshipPattern != nil && relationshipPattern.Properties != nil } func addSymbol(symbols map[string]struct{}, symbol string) { diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index c50ef14d..b5634686 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -258,6 +258,29 @@ func TestLoweringPlanReportsPatternPredicateExistencePlacement(t *testing.T) { }}, plan.LoweringPlan.PatternPredicate) } +func TestLoweringPlanReportsTypedPatternPredicateExistencePlacement(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH (n:Domain), (m:Domain) + WHERE (n)-[:SpoofSIDHistory|AbuseTGTDelegation]-(m) + RETURN n + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Contains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringPredicatePlacement}) + require.Equal(t, []PatternPredicatePlacementDecision{{ + Target: TraversalStepTarget{ + QueryPartIndex: 0, + Predicate: true, + StepIndex: 0, + }, + Mode: PatternPredicatePlacementExistence, + }}, plan.LoweringPlan.PatternPredicate) +} + func TestSelectivityModelPlansTraversalDirection(t *testing.T) { t.Parallel() diff --git a/cypher/models/pgsql/translate/predicate.go b/cypher/models/pgsql/translate/predicate.go index b34e52e0..f1e8b708 100644 --- a/cypher/models/pgsql/translate/predicate.go +++ b/cypher/models/pgsql/translate/predicate.go @@ -28,7 +28,7 @@ func (s *Translator) preparePatternPredicate(predicate *cypher.PatternPredicate) } func (s *Translator) buildOptimizedRelationshipExistPredicate(part *PatternPart, traversalStep *TraversalStep) (pgsql.Expression, error) { - whereClause := pgsql.NewBinaryExpression( + var whereClause pgsql.Expression = pgsql.NewBinaryExpression( pgsql.NewBinaryExpression( pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnStartID}, pgsql.OperatorEquals, @@ -40,6 +40,38 @@ func (s *Translator) buildOptimizedRelationshipExistPredicate(part *PatternPart, pgsql.CompoundIdentifier{traversalStep.LeftNode.Identifier, pgsql.ColumnID}), ) + if traversalStep.RightNodeBound { + forward := pgsql.NewBinaryExpression( + pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnStartID}, + pgsql.OperatorEquals, + pgsql.CompoundIdentifier{traversalStep.LeftNode.Identifier, pgsql.ColumnID}), + pgsql.OperatorAnd, + pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnEndID}, + pgsql.OperatorEquals, + pgsql.CompoundIdentifier{traversalStep.RightNode.Identifier, pgsql.ColumnID}), + ) + reverse := pgsql.NewBinaryExpression( + pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnEndID}, + pgsql.OperatorEquals, + pgsql.CompoundIdentifier{traversalStep.LeftNode.Identifier, pgsql.ColumnID}), + pgsql.OperatorAnd, + pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnStartID}, + pgsql.OperatorEquals, + pgsql.CompoundIdentifier{traversalStep.RightNode.Identifier, pgsql.ColumnID}), + ) + whereClause = pgsql.NewBinaryExpression(forward, pgsql.OperatorOr, reverse) + } + + if constraint, err := s.treeTranslator.ConsumeConstraintsFromVisibleSet(pgsql.AsIdentifierSet(traversalStep.Edge.Identifier)); err != nil { + return nil, err + } else { + whereClause = pgsql.OptionalAnd(constraint.Expression, pgsql.NewParenthetical(whereClause)) + } + if err := RewriteFrameBindings(s.scope, whereClause); err != nil { return nil, err } @@ -99,24 +131,6 @@ func (s *Translator) usePatternPredicateExistencePlacement(patternPart *PatternP return false, nil } - traversalStepIdentifiers := pgsql.AsIdentifierSet( - traversalStep.LeftNode.Identifier, - traversalStep.Edge.Identifier, - traversalStep.RightNode.Identifier, - ) - - if hasGlobalConstraints, err := s.treeTranslator.HasAnyConstraints(traversalStepIdentifiers); err != nil { - return false, err - } else if hasGlobalConstraints { - return false, nil - } - - if hasPredicateConstraints, err := patternPart.Constraints.HasConstraints(traversalStepIdentifiers); err != nil { - return false, err - } else if hasPredicateConstraints { - return false, nil - } - return true, nil } diff --git a/cypher/models/pgsql/translate/predicate_test.go b/cypher/models/pgsql/translate/predicate_test.go index 8c2fe76f..d9182a10 100644 --- a/cypher/models/pgsql/translate/predicate_test.go +++ b/cypher/models/pgsql/translate/predicate_test.go @@ -30,8 +30,11 @@ RETURN p`) require.NoError(t, err) require.Contains(t, formatted, "as p from s0 where") - require.Contains(t, formatted, "with s1 as") - require.NotContains(t, formatted, "as p from s1 where") + require.Contains(t, formatted, "exists (select 1 from edge") + require.Contains(t, formatted, "kind_id = any (array [3, 4]::int2[])") + require.Contains(t, formatted, "start_id = (s0.n0).id") + require.Contains(t, formatted, "end_id = (s0.n1).id") + require.NotContains(t, formatted, "with s1 as") } func TestOptimizedPatternPredicatesContinueAfterFirstPlacement(t *testing.T) { From 095a0237a5c874a73a9fde754e1cbced7fcceffd Mon Sep 17 00:00:00 2001 From: John Hopper Date: Fri, 22 May 2026 21:19:05 -0700 Subject: [PATCH 072/116] Lower membership-only collects to ids --- cypher/models/pgsql/optimize/lowering.go | 1 + .../pgsql/translate/collect_id_membership.go | 120 ++++++++++++++++++ cypher/models/pgsql/translate/expression.go | 14 ++ cypher/models/pgsql/translate/function.go | 36 ++++++ .../models/pgsql/translate/function_test.go | 48 +++++++ cypher/models/pgsql/translate/translator.go | 18 +++ 6 files changed, 237 insertions(+) create mode 100644 cypher/models/pgsql/translate/collect_id_membership.go diff --git a/cypher/models/pgsql/optimize/lowering.go b/cypher/models/pgsql/optimize/lowering.go index d4d5b709..cd060001 100644 --- a/cypher/models/pgsql/optimize/lowering.go +++ b/cypher/models/pgsql/optimize/lowering.go @@ -13,6 +13,7 @@ const ( LoweringExpansionSuffixPushdown = "ExpansionSuffixPushdown" LoweringPredicatePlacement = "PredicatePlacement" LoweringCountStoreFastPath = "CountStoreFastPath" + LoweringCollectIDMembership = "CollectIDMembership" ) type LoweringDecision struct { diff --git a/cypher/models/pgsql/translate/collect_id_membership.go b/cypher/models/pgsql/translate/collect_id_membership.go new file mode 100644 index 00000000..d710b8ce --- /dev/null +++ b/cypher/models/pgsql/translate/collect_id_membership.go @@ -0,0 +1,120 @@ +package translate + +import ( + "strings" + + "github.com/specterops/dawgs/cypher/models/cypher" + "github.com/specterops/dawgs/cypher/models/pgsql" + "github.com/specterops/dawgs/cypher/models/walk" +) + +type collectIDMembershipUsage struct { + membershipReferences int + otherReferences int +} + +type collectIDMembershipCollector struct { + walk.VisitorHandler + candidates map[pgsql.Identifier]struct{} + usages map[pgsql.Identifier]*collectIDMembershipUsage + stack []cypher.SyntaxNode +} + +func collectIDMembershipAliases(root *cypher.RegularQuery) (map[pgsql.Identifier]struct{}, error) { + candidates, err := collectIDMembershipCandidates(root) + if err != nil || len(candidates) == 0 { + return nil, err + } + + collector := &collectIDMembershipCollector{ + VisitorHandler: walk.NewCancelableErrorHandler(), + candidates: candidates, + usages: map[pgsql.Identifier]*collectIDMembershipUsage{}, + } + if err := walk.Cypher(root, collector); err != nil { + return nil, err + } + + aliases := map[pgsql.Identifier]struct{}{} + for alias := range candidates { + usage := collector.usages[alias] + if usage != nil && usage.membershipReferences > 0 && usage.otherReferences == 0 { + aliases[alias] = struct{}{} + } + } + return aliases, nil +} + +func collectIDMembershipCandidates(root *cypher.RegularQuery) (map[pgsql.Identifier]struct{}, error) { + candidates := map[pgsql.Identifier]struct{}{} + + err := walk.Cypher(root, walk.NewSimpleVisitor[cypher.SyntaxNode](func(node cypher.SyntaxNode, handler walk.VisitorHandler) { + projectionItem, isProjectionItem := node.(*cypher.ProjectionItem) + if !isProjectionItem || projectionItem.Alias == nil { + return + } + + function, isFunction := projectionItem.Expression.(*cypher.FunctionInvocation) + if !isFunction || !strings.EqualFold(function.Name, cypher.CollectFunction) || len(function.Arguments) != 1 { + return + } + + if _, isVariable := function.Arguments[0].(*cypher.Variable); isVariable { + candidates[pgsql.Identifier(projectionItem.Alias.Symbol)] = struct{}{} + } + })) + return candidates, err +} + +func (s *collectIDMembershipCollector) usage(alias pgsql.Identifier) *collectIDMembershipUsage { + usage := s.usages[alias] + if usage == nil { + usage = &collectIDMembershipUsage{} + s.usages[alias] = usage + } + return usage +} + +func (s *collectIDMembershipCollector) Enter(node cypher.SyntaxNode) { + variable, isVariable := node.(*cypher.Variable) + if !isVariable { + s.stack = append(s.stack, node) + return + } + + alias := pgsql.Identifier(variable.Symbol) + if _, isCandidate := s.candidates[alias]; isCandidate { + usage := s.usage(alias) + if s.isProjectionAliasDeclaration(variable) { + // The alias declaration is not a read. + } else if s.isMembershipCollectionOperand(variable) { + usage.membershipReferences++ + } else { + usage.otherReferences++ + } + } + + s.stack = append(s.stack, node) +} + +func (s *collectIDMembershipCollector) Visit(cypher.SyntaxNode) {} + +func (s *collectIDMembershipCollector) Exit(cypher.SyntaxNode) { + s.stack = s.stack[:len(s.stack)-1] +} + +func (s *collectIDMembershipCollector) isProjectionAliasDeclaration(variable *cypher.Variable) bool { + if len(s.stack) == 0 { + return false + } + projectionItem, isProjectionItem := s.stack[len(s.stack)-1].(*cypher.ProjectionItem) + return isProjectionItem && projectionItem.Alias == variable +} + +func (s *collectIDMembershipCollector) isMembershipCollectionOperand(variable *cypher.Variable) bool { + if len(s.stack) == 0 { + return false + } + partial, isPartialComparison := s.stack[len(s.stack)-1].(*cypher.PartialComparison) + return isPartialComparison && partial.Operator == cypher.OperatorIn && partial.Right == variable +} diff --git a/cypher/models/pgsql/translate/expression.go b/cypher/models/pgsql/translate/expression.go index 9e62ec2f..883c144e 100644 --- a/cypher/models/pgsql/translate/expression.go +++ b/cypher/models/pgsql/translate/expression.go @@ -682,6 +682,13 @@ func rewriteIdentityOperands(scope *Scope, newExpression *pgsql.BinaryExpression newExpression.LOperand = pgsql.CompoundIdentifier{typedLOperand, pgsql.ColumnID} newExpression.ROperand = pgsql.CompoundIdentifier{typedROperand, pgsql.ColumnID} + case pgsql.Int8Array: + if newExpression.Operator == pgsql.OperatorIn { + newExpression.LOperand = pgsql.CompoundIdentifier{typedLOperand, pgsql.ColumnID} + } else { + return fmt.Errorf("invalid comparison between types %s and %s", boundLOperand.DataType, boundROperand.DataType) + } + case pgsql.NodeCompositeArray: const unnestElemAlias pgsql.Identifier = "_unnest_elem" newExpression.LOperand = pgsql.CompoundIdentifier{typedLOperand, pgsql.ColumnID} @@ -721,6 +728,13 @@ func rewriteIdentityOperands(scope *Scope, newExpression *pgsql.BinaryExpression newExpression.LOperand = pgsql.CompoundIdentifier{typedLOperand, pgsql.ColumnID} newExpression.ROperand = pgsql.CompoundIdentifier{typedROperand, pgsql.ColumnID} + case pgsql.Int8Array: + if newExpression.Operator == pgsql.OperatorIn { + newExpression.LOperand = pgsql.CompoundIdentifier{typedLOperand, pgsql.ColumnID} + } else { + return fmt.Errorf("invalid comparison between types %s and %s", boundLOperand.DataType, boundROperand.DataType) + } + case pgsql.EdgeCompositeArray: newExpression.LOperand = pgsql.CompoundIdentifier{typedLOperand, pgsql.ColumnID} newExpression.ROperand = pgsql.CompoundIdentifier{typedROperand, pgsql.ColumnID} diff --git a/cypher/models/pgsql/translate/function.go b/cypher/models/pgsql/translate/function.go index f0eae7f8..942e6d22 100644 --- a/cypher/models/pgsql/translate/function.go +++ b/cypher/models/pgsql/translate/function.go @@ -8,6 +8,7 @@ import ( "github.com/specterops/dawgs/cypher/models/cypher" "github.com/specterops/dawgs/cypher/models/pgsql" + "github.com/specterops/dawgs/cypher/models/pgsql/optimize" ) const legacyToIntegerFunction = "toint" @@ -528,6 +529,28 @@ func prepareCollectExpression(scope *Scope, collectedExpression pgsql.Expression return collectedExpression, castType, nil } +func prepareCollectIDExpression(scope *Scope, collectedExpression pgsql.Expression) (pgsql.Expression, bool) { + identifier, isIdentifier := unwrapParenthetical(collectedExpression).(pgsql.Identifier) + if !isIdentifier { + return nil, false + } + + binding, bound := scope.Lookup(identifier) + if !bound { + return nil, false + } + + switch binding.DataType { + case pgsql.NodeComposite, pgsql.EdgeComposite, pgsql.ExpansionRootNode, pgsql.ExpansionTerminalNode, pgsql.ExpansionEdge: + return pgsql.RowColumnReference{ + Identifier: identifier, + Column: pgsql.ColumnID, + }, true + default: + return nil, false + } +} + func translateNodeLabelsExpression(identifier pgsql.Identifier) pgsql.TypeHinted { const ( kindAlias pgsql.Identifier = "_kind" @@ -839,6 +862,19 @@ 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 s.collectIDProjectionDepth > 0 { + if idExpression, collectIDs := prepareCollectIDExpression(s.scope, collectedExpression); collectIDs { + s.treeTranslator.PushOperand( + functionWrapCollectToArray(typedExpression.Distinct, idExpression, pgsql.Int8Array), + ) + s.recordLowering(optimize.LoweringCollectIDMembership) + } else if preparedExpression, castType, err := prepareCollectExpression(s.scope, collectedExpression, typedExpression.Name); err != nil { + s.SetError(err) + } else { + s.treeTranslator.PushOperand( + functionWrapCollectToArray(typedExpression.Distinct, preparedExpression, castType), + ) + } } else if preparedExpression, castType, err := prepareCollectExpression(s.scope, collectedExpression, typedExpression.Name); err != nil { s.SetError(err) } else { diff --git a/cypher/models/pgsql/translate/function_test.go b/cypher/models/pgsql/translate/function_test.go index 64926916..42242991 100644 --- a/cypher/models/pgsql/translate/function_test.go +++ b/cypher/models/pgsql/translate/function_test.go @@ -130,3 +130,51 @@ func TestPrepareCollectExpressionMissingBindingErrorNamesArgument(t *testing.T) require.EqualError(t, err, "binding not found for collect function argument missing") } + +func TestCollectMembershipOnlyProjectionUsesIDs(t *testing.T) { + t.Parallel() + + kindMapper := pgutil.NewInMemoryKindMapper() + + query, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH (s) + WITH collect(s) AS exclude + MATCH (c) + WHERE NOT c IN exclude + RETURN c + `) + require.NoError(t, err) + + translation, err := Translate(context.Background(), query, kindMapper, nil, DefaultGraphID) + require.NoError(t, err) + + formatted, err := Translated(translation) + require.NoError(t, err) + normalized := strings.Join(strings.Fields(formatted), " ") + + require.Contains(t, normalized, "array_agg((n0).id)") + require.Contains(t, normalized, "array []::int8[]") + require.Contains(t, normalized, "not n1.id = any (s0.") + require.NotContains(t, normalized, "array []::nodecomposite[]") + requireOptimizationLowering(t, translation.Optimization, "CollectIDMembership") +} + +func TestReturnedCollectNodeKeepsCompositeArray(t *testing.T) { + t.Parallel() + + kindMapper := pgutil.NewInMemoryKindMapper() + + query, err := frontend.ParseCypher(frontend.NewContext(), `MATCH (s) RETURN collect(s) AS nodes`) + require.NoError(t, err) + + translation, err := Translate(context.Background(), query, kindMapper, nil, DefaultGraphID) + require.NoError(t, err) + + formatted, err := Translated(translation) + require.NoError(t, err) + normalized := strings.Join(strings.Fields(formatted), " ") + + require.Contains(t, normalized, "array []::nodecomposite[]") + require.NotContains(t, normalized, "array_agg((n0).id)") + requireNoOptimizationLowering(t, translation.Optimization, "CollectIDMembership") +} diff --git a/cypher/models/pgsql/translate/translator.go b/cypher/models/pgsql/translate/translator.go index 306c2efc..5836b5f0 100644 --- a/cypher/models/pgsql/translate/translator.go +++ b/cypher/models/pgsql/translate/translator.go @@ -30,6 +30,9 @@ type Translator struct { scope *Scope unwindTargets map[*cypher.Variable]struct{} + collectIDMembershipAliases map[pgsql.Identifier]struct{} + collectIDProjectionDepth int + appliedLoweringCounts map[string]int patternTargets map[*cypher.PatternPart]optimize.PatternTarget patternPredicateTargets map[*cypher.PatternPredicate]optimize.PatternTarget @@ -265,6 +268,11 @@ func (s *Translator) Enter(expression cypher.SyntaxNode) { } case *cypher.ProjectionItem: + if typedExpression.Alias != nil { + if _, collectIDs := s.collectIDMembershipAliases[pgsql.Identifier(typedExpression.Alias.Symbol)]; collectIDs { + s.collectIDProjectionDepth++ + } + } s.query.CurrentPart().PrepareProjection() case *cypher.PatternPredicate: @@ -560,6 +568,11 @@ func (s *Translator) Exit(expression cypher.SyntaxNode) { if err := s.translateProjectionItem(s.scope, typedExpression); err != nil { s.SetError(err) } + if typedExpression.Alias != nil { + if _, collectIDs := s.collectIDMembershipAliases[pgsql.Identifier(typedExpression.Alias.Symbol)]; collectIDs { + s.collectIDProjectionDepth-- + } + } case *cypher.Match: if err := s.translateMatch(typedExpression); err != nil { @@ -705,6 +718,11 @@ func Translate(ctx context.Context, cypherQuery *cypher.RegularQuery, kindMapper } translator := NewTranslator(ctx, kindMapper, parameters, graphID) + if membershipAliases, err := collectIDMembershipAliases(optimizedPlan.Query); err != nil { + return Result{}, err + } else { + translator.collectIDMembershipAliases = membershipAliases + } translator.SetOptimizationPlan(optimizedPlan) translator.translation.Optimization.Rules = optimizedPlan.Rules translator.translation.Optimization.PredicateAttachments = optimizedPlan.PredicateAttachments From 09905fa2684bb49e5b76690eb3567fa1ee6d80df Mon Sep 17 00:00:00 2001 From: John Hopper Date: Fri, 22 May 2026 21:25:46 -0700 Subject: [PATCH 073/116] Flip bound expansions to constrained terminals --- cypher/models/pgsql/optimize/lowering_plan.go | 61 ++++++++++++++++++- .../models/pgsql/optimize/optimizer_test.go | 29 +++++++++ cypher/models/pgsql/translate/expansion.go | 11 ++++ .../pgsql/translate/optimizer_safety_test.go | 25 ++++++++ cypher/models/pgsql/translate/traversal.go | 8 ++- 5 files changed, 130 insertions(+), 4 deletions(-) diff --git a/cypher/models/pgsql/optimize/lowering_plan.go b/cypher/models/pgsql/optimize/lowering_plan.go index 6fe3ecb4..7a997fa0 100644 --- a/cypher/models/pgsql/optimize/lowering_plan.go +++ b/cypher/models/pgsql/optimize/lowering_plan.go @@ -347,8 +347,9 @@ func appendTraversalDirectionDecisions(plan *LoweringPlan, queryPartIndex int, r } for stepIndex, step := range steps { + target := patternTarget.TraversalStep(stepIndex) if decision, shouldFlip := traversalDirectionDecisionForStep( - patternTarget.TraversalStep(stepIndex), + target, stepIndex, step, declaredEndpoints[stepIndex], @@ -356,6 +357,15 @@ func appendTraversalDirectionDecisions(plan *LoweringPlan, queryPartIndex int, r referencesSourceIdentifier(predicateConstrainedSymbols, variableSymbol(step.RightNode.Variable)), ); shouldFlip { plan.TraversalDirection = append(plan.TraversalDirection, decision) + } else if decision, shouldFlip := boundLeftExpansionDirectionDecisionForStep( + target, + patternPart, + steps, + stepIndex, + step, + declaredEndpoints[stepIndex], + ); shouldFlip { + plan.TraversalDirection = append(plan.TraversalDirection, decision) } } @@ -395,9 +405,10 @@ func traversalDirectionDecisionForStep( } rightSymbol := variableSymbol(step.RightNode.Variable) + leftSymbol := variableSymbol(step.LeftNode.Variable) if rightSymbol != "" { if _, rightBound := declaredEndpoints.BeforeRightNode[rightSymbol]; rightBound { - if rightSymbol == variableSymbol(step.LeftNode.Variable) { + if rightSymbol == leftSymbol { return TraversalDirectionDecision{}, false } @@ -428,6 +439,52 @@ func traversalDirectionDecisionForStep( return TraversalDirectionDecision{}, false } +func boundLeftExpansionDirectionDecisionForStep( + target TraversalStepTarget, + patternPart *cypher.PatternPart, + steps []sourceTraversalStep, + stepIndex int, + step sourceTraversalStep, + declaredEndpoints declaredStepEndpoints, +) (TraversalDirectionDecision, bool) { + if patternPart == nil || + patternPart.Variable != nil || + patternPart.ShortestPathPattern || + patternPart.AllShortestPathsPattern || + len(steps) != 1 || + stepIndex != 0 || + step.Relationship == nil || + step.Relationship.Range == nil || + step.Relationship.Direction == graph.DirectionBoth || + step.Relationship.Variable != nil || + nodePatternHasConstraints(step.LeftNode) || + !nodePatternHasConstraints(step.RightNode) { + return TraversalDirectionDecision{}, false + } + + leftSymbol := variableSymbol(step.LeftNode.Variable) + rightSymbol := variableSymbol(step.RightNode.Variable) + if leftSymbol == "" || leftSymbol == rightSymbol { + return TraversalDirectionDecision{}, false + } + + if _, leftBound := declaredEndpoints.BeforeLeftNode[leftSymbol]; !leftBound { + return TraversalDirectionDecision{}, false + } + + if rightSymbol != "" { + if _, rightBound := declaredEndpoints.BeforeRightNode[rightSymbol]; rightBound { + return TraversalDirectionDecision{}, false + } + } + + return TraversalDirectionDecision{ + Target: target, + Flip: true, + Reason: traversalDirectionReasonRightConstrained, + }, true +} + func appendShortestPathStrategyDecisions(plan *LoweringPlan, queryPartIndex int, readingClauses []*cypher.ReadingClause, predicateConstrainedSymbols map[string]struct{}) { declaredSymbols := map[string]struct{}{} diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index b5634686..f7c37ec6 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -703,6 +703,35 @@ func TestLoweringPlanReportsTraversalDirectionForRightEndpointPredicate(t *testi }}, plan.LoweringPlan.TraversalDirection) } +func TestLoweringPlanReportsTraversalDirectionForBoundLeftExpansionToConstrainedRightEndpoint(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH (u:User) + WHERE u.hasspn = true AND u.enabled = true + MATCH (u)-[:MemberOf|AdminTo*1..]->(c:Computer) + WITH DISTINCT u, COUNT(c) AS adminCount + RETURN u + ORDER BY adminCount DESC + LIMIT 100 + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Contains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringTraversalDirection}) + require.Equal(t, []TraversalDirectionDecision{{ + Target: TraversalStepTarget{ + QueryPartIndex: 0, + ClauseIndex: 1, + PatternIndex: 0, + StepIndex: 0, + }, + Flip: true, + Reason: traversalDirectionReasonRightConstrained, + }}, plan.LoweringPlan.TraversalDirection) +} + func TestLoweringPlanSkipsSuffixPushdownAfterRightEndpointPredicateDirectionFlip(t *testing.T) { t.Parallel() diff --git a/cypher/models/pgsql/translate/expansion.go b/cypher/models/pgsql/translate/expansion.go index 5d867a7c..32c3ffcb 100644 --- a/cypher/models/pgsql/translate/expansion.go +++ b/cypher/models/pgsql/translate/expansion.go @@ -2555,6 +2555,17 @@ func (s *Translator) buildExpansionPatternRoot(traversalStepContext TraversalSte ), ) } + if previousProjectionFrameID != "" && traversalStep.RightNodeBound { + projectionConstraints = pgsql.OptionalAnd( + projectionConstraints, + boundEndpointProjectionConstraint( + previousProjectionFrameID, + traversalStep.RightNode.Identifier, + expansionModel.Frame.Binding.Identifier, + expansionNextID, + ), + ) + } projectionConstraints = rewriteCurrentFrameProjectionReferences( projectionConstraints, diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index f2c6eaba..ac53ad52 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -44,6 +44,9 @@ func optimizerSafetyKindMapper() *pgutil.InMemoryKindMapper { "RootCA", "RootCAFor", "TrustedForNTAuth", + "AdminTo", + "Computer", + "User", }) { mapper.Put(kind) } @@ -575,6 +578,28 @@ RETURN p require.Contains(t, normalizedQuery, "join edge e0 on e0.end_id = s1_seed.root_id") } +func TestOptimizerSafetyTraversalDirectionUsesBoundLeftExpansionTerminalConstraint(t *testing.T) { + t.Parallel() + + translation := optimizerSafetyTranslation(t, ` +MATCH (u:User) +WHERE u.hasspn = true AND u.enabled = true +MATCH (u)-[:MemberOf|AdminTo*1..]->(c:Computer) +WITH DISTINCT u, COUNT(c) AS adminCount +RETURN u +ORDER BY adminCount DESC +LIMIT 100 + `) + formattedQuery, err := Translated(translation) + require.NoError(t, err) + normalizedQuery := strings.Join(strings.Fields(formattedQuery), " ") + + requirePlannedOptimizationLowering(t, translation.Optimization, "TraversalDirectionSelection") + requireOptimizationLowering(t, translation.Optimization, "TraversalDirectionSelection") + require.Contains(t, normalizedQuery, "join edge e0 on e0.end_id = s3_seed.root_id") + require.Contains(t, normalizedQuery, "(s1.n0).id = s3.next_id") +} + func TestOptimizerSafetyShortestPathStrategyUsesPlannedBidirectionalSearch(t *testing.T) { t.Parallel() diff --git a/cypher/models/pgsql/translate/traversal.go b/cypher/models/pgsql/translate/traversal.go index 0747b628..7db07001 100644 --- a/cypher/models/pgsql/translate/traversal.go +++ b/cypher/models/pgsql/translate/traversal.go @@ -54,8 +54,12 @@ func (s *Translator) traversalDirectionDecision(part *PatternPart, stepIndex int func (s *Translator) applyPatternConstraintBalance(part *PatternPart, stepIndex int, constraints *PatternConstraints, traversalStep *TraversalStep) error { if decision, hasDecision := s.traversalDirectionDecision(part, stepIndex); hasDecision { - if decision.Flip && !traversalStep.LeftNodeBound { - if traversalStep.RightNodeBound && !traversalStep.hasPreviousFrameBinding() { + if decision.Flip { + if traversalStep.LeftNodeBound { + if traversalStep.Expansion == nil || !traversalStep.hasPreviousFrameBinding() { + return nil + } + } else if traversalStep.RightNodeBound && !traversalStep.hasPreviousFrameBinding() { return nil } From 532a406a16dadf62fd7af82d3f4559a5cb487359 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Fri, 22 May 2026 21:29:37 -0700 Subject: [PATCH 074/116] Plan terminal filters for kind-only shortest paths --- cypher/models/pgsql/optimize/lowering_plan.go | 113 +++++++++++++++++- .../models/pgsql/optimize/optimizer_test.go | 27 +++++ .../pgsql/translate/optimizer_safety_test.go | 21 ++++ 3 files changed, 157 insertions(+), 4 deletions(-) diff --git a/cypher/models/pgsql/optimize/lowering_plan.go b/cypher/models/pgsql/optimize/lowering_plan.go index 7a997fa0..e74e27fa 100644 --- a/cypher/models/pgsql/optimize/lowering_plan.go +++ b/cypher/models/pgsql/optimize/lowering_plan.go @@ -78,8 +78,9 @@ func appendQueryPartLowerings( appendPatternPredicatePlacementDecisions(plan, queryPartIndex, queryPart) appendExpandIntoDecisions(plan, queryPartIndex, readingClauses) appendTraversalDirectionDecisions(plan, queryPartIndex, readingClauses, bindingPredicateSymbols(predicateAttachments, queryPartIndex)) - appendShortestPathStrategyDecisions(plan, queryPartIndex, readingClauses, bindingPredicateSymbols(predicateAttachments, queryPartIndex)) - appendShortestPathFilterDecisions(plan, queryPartIndex, readingClauses, bindingPredicateSymbols(predicateAttachments, queryPartIndex)) + shortestPathSearchSymbols := shortestPathSearchPredicateSymbols(readingClauses) + appendShortestPathStrategyDecisions(plan, queryPartIndex, readingClauses, shortestPathSearchSymbols) + appendShortestPathFilterDecisions(plan, queryPartIndex, readingClauses, shortestPathSearchSymbols) appendLimitPushdownDecisions(plan, queryPartIndex, queryPart, readingClauses) appendExpansionSuffixPushdownDecisions(plan, queryPartIndex, readingClauses) return nil @@ -392,6 +393,102 @@ func bindingPredicateSymbols(predicateAttachments []PredicateAttachment, queryPa return symbols } +func shortestPathSearchPredicateSymbols(readingClauses []*cypher.ReadingClause) map[string]struct{} { + symbols := map[string]struct{}{} + + for _, readingClause := range readingClauses { + if readingClause == nil || readingClause.Match == nil || readingClause.Match.Where == nil { + continue + } + + for _, expression := range readingClause.Match.Where.Expressions { + addShortestPathSearchPredicateSymbols(symbols, expression) + } + } + + return symbols +} + +func addShortestPathSearchPredicateSymbols(symbols map[string]struct{}, expression cypher.Expression) { + for _, term := range cypherConjunctionTerms(expression) { + if symbol, ok := shortestPathSearchPredicateSymbol(term); ok { + addSymbol(symbols, symbol) + } + } +} + +func cypherConjunctionTerms(expression cypher.Expression) []cypher.Expression { + if conjunction, isConjunction := expression.(*cypher.Conjunction); isConjunction { + var terms []cypher.Expression + for _, subexpression := range conjunction.Expressions { + terms = append(terms, cypherConjunctionTerms(subexpression)...) + } + + return terms + } + + return []cypher.Expression{expression} +} + +func shortestPathSearchPredicateSymbol(expression cypher.Expression) (string, bool) { + comparison, isComparison := expression.(*cypher.Comparison) + if !isComparison || len(comparison.Partials) != 1 { + return "", false + } + + partial := comparison.Partials[0] + if !isEndpointSearchOperator(partial.Operator) { + return "", false + } + + if symbol, ok := propertyLookupVariableSymbol(comparison.Left); ok && !expressionReferencesAnySource(partial.Right) { + return symbol, true + } + + if symbol, ok := propertyLookupVariableSymbol(partial.Right); ok && !expressionReferencesAnySource(comparison.Left) { + return symbol, true + } + + return "", false +} + +func isEndpointSearchOperator(operator cypher.Operator) bool { + switch operator { + case cypher.OperatorEquals, + cypher.OperatorRegexMatch, + cypher.OperatorGreaterThan, + cypher.OperatorGreaterThanOrEqualTo, + cypher.OperatorLessThan, + cypher.OperatorLessThanOrEqualTo, + cypher.OperatorStartsWith, + cypher.OperatorEndsWith, + cypher.OperatorContains, + cypher.OperatorIn: + return true + default: + return false + } +} + +func propertyLookupVariableSymbol(expression cypher.Expression) (string, bool) { + propertyLookup, isPropertyLookup := expression.(*cypher.PropertyLookup) + if !isPropertyLookup || propertyLookup == nil { + return "", false + } + + variable, isVariable := propertyLookup.Atom.(*cypher.Variable) + if !isVariable || variable == nil || variable.Symbol == "" { + return "", false + } + + return variable.Symbol, true +} + +func expressionReferencesAnySource(expression cypher.Expression) bool { + references, err := collectReferencedSourceIdentifiers(expression) + return err != nil || len(references) > 0 +} + func traversalDirectionDecisionForStep( target TraversalStepTarget, stepIndex int, @@ -573,6 +670,14 @@ func endpointHasSearchConstraint(nodePattern *cypher.NodePattern, symbol string, return nodePattern.Properties != nil || referencesSourceIdentifier(predicateConstrainedSymbols, symbol) } +func endpointHasTerminalFilterConstraint(nodePattern *cypher.NodePattern, symbol string, predicateConstrainedSymbols map[string]struct{}) bool { + if nodePattern == nil { + return false + } + + return nodePatternHasConstraints(nodePattern) || referencesSourceIdentifier(predicateConstrainedSymbols, symbol) +} + func appendShortestPathFilterDecisions(plan *LoweringPlan, queryPartIndex int, readingClauses []*cypher.ReadingClause, predicateConstrainedSymbols map[string]struct{}) { declaredSymbols := map[string]struct{}{} @@ -641,11 +746,11 @@ func shortestPathFilterDecisionForStep( leftSearchConstrained := endpointHasSearchConstraint(step.LeftNode, leftSymbol, predicateConstrainedSymbols) rightSearchConstrained := endpointHasSearchConstraint(step.RightNode, rightSymbol, predicateConstrainedSymbols) - if !rightSearchConstrained { + if !endpointHasTerminalFilterConstraint(step.RightNode, rightSymbol, predicateConstrainedSymbols) { return ShortestPathFilterDecision{}, false } - if hasShortestPathBidirectionalStrategy(plan, target) && leftSearchConstrained { + if hasShortestPathBidirectionalStrategy(plan, target) && leftSearchConstrained && rightSearchConstrained { return ShortestPathFilterDecision{ Target: target, Mode: ShortestPathFilterEndpointPair, diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index f7c37ec6..f3df3b63 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -849,6 +849,33 @@ func TestLoweringPlanReportsShortestPathTerminalFilter(t *testing.T) { }}, plan.LoweringPlan.ShortestPathFilter) } +func TestLoweringPlanReportsShortestPathTerminalFilterForKindOnlyTerminal(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH p = shortestPath((s:Group)-[:MemberOf|GenericAll|AdminTo*1..]->(t:Tag_Tier_Zero)) + WHERE s.objectid ENDS WITH '-513' AND s <> t + RETURN p + LIMIT 1000 + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.NotContains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringShortestPathStrategy}) + require.Contains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringShortestPathFilter}) + require.Equal(t, []ShortestPathFilterDecision{{ + Target: TraversalStepTarget{ + QueryPartIndex: 0, + ClauseIndex: 0, + PatternIndex: 0, + StepIndex: 0, + }, + Mode: ShortestPathFilterTerminal, + Reason: shortestPathFilterReasonTerminalPredicate, + }}, plan.LoweringPlan.ShortestPathFilter) +} + func TestLoweringPlanReportsTraversalLimitPushdown(t *testing.T) { t.Parallel() diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index ac53ad52..ced6e779 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -46,6 +46,7 @@ func optimizerSafetyKindMapper() *pgutil.InMemoryKindMapper { "TrustedForNTAuth", "AdminTo", "Computer", + "Tag_Tier_Zero", "User", }) { mapper.Put(kind) @@ -640,6 +641,26 @@ RETURN p requireOptimizationLowering(t, translation.Optimization, "ShortestPathFilterMaterialization") } +func TestOptimizerSafetyShortestPathKindOnlyTerminalFilterUsesPlannedMaterialization(t *testing.T) { + t.Parallel() + + translation := optimizerSafetyTranslation(t, ` +MATCH p = shortestPath((s:Group)-[:MemberOf|GenericAll|AdminTo*1..]->(t:Tag_Tier_Zero)) +WHERE s.objectid ENDS WITH '-513' AND s <> t +RETURN p +LIMIT 1000 + `) + + formattedQuery, err := Translated(translation) + require.NoError(t, err) + normalizedQuery := strings.Join(strings.Fields(formattedQuery), " ") + + require.Contains(t, normalizedQuery, "unidirectional_sp_harness") + require.Contains(t, normalizedQuery, "traversal_terminal_filter") + requirePlannedOptimizationLowering(t, translation.Optimization, "ShortestPathFilterMaterialization") + requireOptimizationLowering(t, translation.Optimization, "ShortestPathFilterMaterialization") +} + func TestOptimizerSafetyLimitPushdownUsesPlannedTraversalFrame(t *testing.T) { t.Parallel() From 0e7468327e01db004725fefae1dd6f196287e1ff Mon Sep 17 00:00:00 2001 From: John Hopper Date: Fri, 22 May 2026 21:30:36 -0700 Subject: [PATCH 075/116] Defer blanket suffix indexing --- README.md | 4 ++++ cypher/Cypher Syntax Support.md | 4 ++++ .../models/pgsql/optimize/OPTIMIZATION_PLAN.md | 17 +++++++++++++++++ 3 files changed, 25 insertions(+) diff --git a/README.md b/README.md index 2f898937..0dc6296c 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,10 @@ indexes created on expressions such as `properties ->> 'objectid'` and `properti anchors without matching JSON booleans or numbers. Simple relationship count fast paths depend on the schema's `kind_id`-first edge index for efficient typed counts. +Substring and suffix predicates are intentionally not promoted to blanket schema indexes. PostgreSQL deployments can +request explicit `TextSearchIndex`/trigram property indexes for fields that need `CONTAINS`, `STARTS WITH`, or +`ENDS WITH`, but default schema assertion should wait until all suffix forms share one semantics-preserving lowering. + Thresholds are report-only by default. To enforce the configured thresholds, run: ```bash diff --git a/cypher/Cypher Syntax Support.md b/cypher/Cypher Syntax Support.md index b4032ff8..35c07814 100644 --- a/cypher/Cypher Syntax Support.md +++ b/cypher/Cypher Syntax Support.md @@ -451,6 +451,10 @@ match (n) where n.name = '1234' return n will use the `name` index regardless of node label. +For substring and suffix searches, PostgreSQL can use explicit `TextSearchIndex`/trigram expression indexes requested +by schema, but CySQL does not add blanket suffix indexes during default schema assertion. Suffix forms are still being +kept conservative so `ENDS WITH`, reversed operands, null handling, and string type semantics remain backend-equivalent. + ### null Behavior Behavior around `null` in SQL differs from how Neo4j executes Cypher. Certain expression operators in Neo4j's diff --git a/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md b/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md index 9bac6735..55bb5eb1 100644 --- a/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md +++ b/cypher/models/pgsql/optimize/OPTIMIZATION_PLAN.md @@ -113,3 +113,20 @@ Status: completed endpoint-oriented traversal indexes. - Added PG-scoped manual integration coverage for strict string equality and a read-only live-plan check that asserts indexed `objectid` lookups use a PostgreSQL index when the connected database exposes the expected expression index. + +## Phase 10: Common Search Follow-Up + +Status: completed + +- Lower typed pattern predicates into correlated relationship `EXISTS` checks when relationship type constraints and + both endpoint correlations are sufficient, avoiding fallback CTEs for common typed existence predicates. +- Lower membership-only `collect(entity)` projections to ID arrays and rewrite membership predicates to `id = any(...)`, + keeping full entity arrays only when the collected value is otherwise observed. +- Flip single-step bound-left variable expansions toward constrained terminal kinds when there is no path binding or + continuation step, and preserve the previous-frame endpoint correlation after the flip. +- Plan shortest-path terminal-filter materialization for kind-only terminal endpoints while keeping endpoint-pair + filters limited to property/search predicates that define the pair universe. +- Defer adding blanket suffix/reverse expression indexes to schema assertion. Live common searches use `objectid` + suffix predicates, but the translator still has multiple suffix-preserving forms (`LIKE`, `cypher_ends_with`, and + null-coalesced variants). Explicit `TextSearchIndex`/trigram indexes remain available for deployments that need + substring acceleration before those semantics are unified. From f557acf3ddbda55c6325d9bd517ce2b1fe004d16 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Fri, 22 May 2026 21:32:45 -0700 Subject: [PATCH 076/116] Update translation snapshots for optimizer lowerings --- cypher/models/pgsql/test/translation_cases/multipart.sql | 6 +++--- cypher/models/pgsql/test/translation_cases/nodes.sql | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cypher/models/pgsql/test/translation_cases/multipart.sql b/cypher/models/pgsql/test/translation_cases/multipart.sql index b1e52d9a..dce5b88d 100644 --- a/cypher/models/pgsql/test/translation_cases/multipart.sql +++ b/cypher/models/pgsql/test/translation_cases/multipart.sql @@ -36,7 +36,7 @@ with s0 as (with s1 as (with recursive s2_seed(root_id) as not materialized (sel with s0 as (select 365 as i0), s1 as (select s0.i0 as i0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from s0, node n0 where (not ((n0.properties ->> 'pwdlastset'))::float8 = any (array [- 1, 0]::float8[]) and ((n0.properties ->> 'pwdlastset'))::numeric < (extract(epoch from now()::timestamp with time zone)::numeric - (s0.i0 * 86400))) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s1.n0 as n from s1 limit 100; -- case: match (n:NodeKind1) where n.hasspn = true and n.enabled = true and not n.objectid ends with '-502' and not coalesce(n.gmsa, false) = true and not coalesce(n.msa, false) = true match (n)-[:EdgeKind1|EdgeKind2*1..]->(c:NodeKind2) with distinct n, count(c) as adminCount return n order by adminCount desc limit 100 -with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'hasspn'))::jsonb = to_jsonb((true)::bool)::jsonb and ((n0.properties -> 'enabled'))::jsonb = to_jsonb((true)::bool)::jsonb and not coalesce((n0.properties ->> 'objectid'), '')::text like '%-502' and not coalesce(((n0.properties ->> 'gmsa'))::bool, false)::bool = true and not coalesce(((n0.properties ->> 'msa'))::bool, false)::bool = true) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2 as (with recursive s3_seed(root_id) as not materialized (select distinct (s1.n0).id as root_id from s1), s3(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 s3_seed join edge e0 on e0.start_id = s3_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s3.root_id, e0.end_id, s3.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], false, s3.path || e0.id from s3 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.start_id = s3.next_id and e0.id != all (s3.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 s3.depth < 15 and not s3.is_cycle) select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1, s3 join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s3.root_id offset 0) n0 on true join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s3.next_id offset 0) n1 on true where s3.satisfied and (s1.n0).id = s3.root_id) select distinct s2.n0 as n0, count(s2.n1)::int8 as i0 from s2 group by n0) select s0.n0 as n from s0 order by s0.i0 desc limit 100; +with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'hasspn'))::jsonb = to_jsonb((true)::bool)::jsonb and ((n0.properties -> 'enabled'))::jsonb = to_jsonb((true)::bool)::jsonb and not coalesce((n0.properties ->> 'objectid'), '')::text like '%-502' and not coalesce(((n0.properties ->> 'gmsa'))::bool, false)::bool = true and not coalesce(((n0.properties ->> 'msa'))::bool, false)::bool = true) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2 as (with recursive s3_seed(root_id) as not materialized (select n1.id as root_id from node n1 where n1.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s3(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, false, e0.end_id = e0.start_id, array [e0.id] from s3_seed join edge e0 on e0.end_id = s3_seed.root_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s3.root_id, e0.start_id, s3.depth + 1, false, false, e0.id || s3.path from s3 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.end_id = s3.next_id and e0.id != all (s3.path) and e0.kind_id = any (array [3, 4]::int2[]) offset 0) e0 on true where s3.depth < 15 and not s3.is_cycle) select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1, s3 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s3.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s3.next_id offset 0) n0 on true where (s1.n0).id = s3.next_id) select distinct s2.n0 as n0, count(s2.n1)::int8 as i0 from s2 group by n0) select s0.n0 as n from s0 order by s0.i0 desc limit 100; -- case: match (n:NodeKind1) where n.objectid = 'S-1-5-21-1260426776-3623580948-1897206385-23225' match p = (n)-[:EdgeKind1|EdgeKind2*1..]->(c:NodeKind2) return p with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((jsonb_typeof((n0.properties -> 'objectid')) = 'string' and (n0.properties ->> 'objectid') = 'S-1-5-21-1260426776-3623580948-1897206385-23225')) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n0).id as root_id from s0), s2(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 s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_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.end_id where s2.depth < 15 and not s2.is_cycle) select 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 s0, 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 and (s0.n0).id = s2.root_id) select case when (s1.n0).id is null or s1.ep0 is null or (s1.n1).id is null then null else ordered_edges_to_path(s1.n0, (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(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite end as p from s1; @@ -48,7 +48,7 @@ with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposit with s0 as (select 'a' as i0), s1 as (select s0.i0 as i0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from s0, node n0 where ((jsonb_typeof((n0.properties -> 'domain')) = 'string' and (n0.properties ->> 'domain') = ' ') and cypher_starts_with((n0.properties ->> 'name'), (i0)::text)::bool) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s1.n0 as o from s1; -- case: match (dc)-[r:EdgeKind1*0..]->(g:NodeKind1) where g.objectid ends with '-516' with collect(dc) as exclude match p = (c:NodeKind2)-[n:EdgeKind2]->(u:NodeKind2)-[:EdgeKind2*1..]->(g:NodeKind1) where g.objectid ends with '-512' and not c in exclude return p limit 100 -with s0 as (with s1 as (with recursive s2_seed(root_id) as not materialized (select n1.id as root_id from node n1 where ((n1.properties ->> 'objectid') like '%-516') and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select s2_seed.root_id, s2_seed.root_id, 0, false, false, array []::int8[] from s2_seed union all select e0.end_id, e0.start_id, 1, false, e0.end_id = e0.start_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]::int2[]) union all select s2.root_id, e0.start_id, s2.depth + 1, false, false, e0.id || s2.path 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]::int2[]) offset 0) e0 on true where s2.depth < 15 and not s2.is_cycle and s2.depth > 0) select (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 n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s2.next_id offset 0) n0 on true) select array_remove(coalesce(array_agg(s1.n0)::nodecomposite[], array []::nodecomposite[])::nodecomposite[], null)::nodecomposite[] as i0 from s1), s3 as (select e1.id as e1, s0.i0 as i0, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s0, edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e1.start_id join node n3 on n3.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n3.id = e1.end_id where e1.kind_id = any (array [4]::int2[])), s4 as (with recursive s5_seed(root_id) as not materialized (select distinct (s3.n3).id as root_id from s3), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, ((n4.properties ->> 'objectid') like '%-512') and n4.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.start_id = e2.end_id, array [e2.id] from s5_seed join edge e2 on e2.start_id = s5_seed.root_id join node n4 on n4.id = e2.end_id where e2.kind_id = any (array [4]::int2[]) union all select s5.root_id, e2.end_id, s5.depth + 1, ((n4.properties ->> 'objectid') like '%-512') and n4.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, s5.path || e2.id from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.start_id = s5.next_id and e2.id != all (s5.path) and e2.kind_id = any (array [4]::int2[]) offset 0) e2 on true join node n4 on n4.id = e2.end_id where s5.depth < 15 and not s5.is_cycle) select s3.e1 as e1, s5.path as ep1, s3.i0 as i0, s3.n2 as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3, (n4.id, n4.kind_ids, n4.properties)::nodecomposite as n4 from s3, s5 join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.root_id offset 0) n3 on true join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.next_id offset 0) n4 on true where s5.satisfied and (s3.n3).id = s5.root_id) select case when (s4.n2).id is null or s4.e1 is null or (s4.n3).id is null or s4.ep1 is null or (s4.n4).id is null then null else ordered_edges_to_path(s4.n2, (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.e1]::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(s4.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n2, s4.n3, s4.n4]::nodecomposite[])::pathcomposite end as p from s4 where (not (s4.n2).id = any ((select (_unnest_elem).id from unnest(s4.i0) as _unnest_elem))) limit 100; +with s0 as (with s1 as (with recursive s2_seed(root_id) as not materialized (select n1.id as root_id from node n1 where ((n1.properties ->> 'objectid') like '%-516') and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select s2_seed.root_id, s2_seed.root_id, 0, false, false, array []::int8[] from s2_seed union all select e0.end_id, e0.start_id, 1, false, e0.end_id = e0.start_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]::int2[]) union all select s2.root_id, e0.start_id, s2.depth + 1, false, false, e0.id || s2.path 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]::int2[]) offset 0) e0 on true where s2.depth < 15 and not s2.is_cycle and s2.depth > 0) select (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 n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s2.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s2.next_id offset 0) n0 on true) select array_remove(coalesce(array_agg((n0).id)::int8[], array []::int8[])::int8[], null)::int8[] as i0 from s1), s3 as (select e1.id as e1, s0.i0 as i0, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s0, edge e1 join node n3 on n3.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n3.id = e1.end_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e1.start_id where (not n2.id = any (s0.i0)) and e1.kind_id = any (array [4]::int2[])), s4 as (with recursive s5_seed(root_id) as not materialized (select distinct (s3.n3).id as root_id from s3), s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, ((n4.properties ->> 'objectid') like '%-512') and n4.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.start_id = e2.end_id, array [e2.id] from s5_seed join edge e2 on e2.start_id = s5_seed.root_id join node n4 on n4.id = e2.end_id where e2.kind_id = any (array [4]::int2[]) union all select s5.root_id, e2.end_id, s5.depth + 1, ((n4.properties ->> 'objectid') like '%-512') and n4.kind_ids operator (pg_catalog.@>) array [1]::int2[], false, s5.path || e2.id from s5 join lateral (select e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties from edge e2 where e2.start_id = s5.next_id and e2.id != all (s5.path) and e2.kind_id = any (array [4]::int2[]) offset 0) e2 on true join node n4 on n4.id = e2.end_id where s5.depth < 15 and not s5.is_cycle) select s3.e1 as e1, s5.path as ep1, s3.i0 as i0, s3.n2 as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3, (n4.id, n4.kind_ids, n4.properties)::nodecomposite as n4 from s3, s5 join lateral (select n3.id, n3.kind_ids, n3.properties from node n3 where n3.id = s5.root_id offset 0) n3 on true join lateral (select n4.id, n4.kind_ids, n4.properties from node n4 where n4.id = s5.next_id offset 0) n4 on true where s5.satisfied and (s3.n3).id = s5.root_id limit 100) select case when (s4.n2).id is null or s4.e1 is null or (s4.n3).id is null or s4.ep1 is null or (s4.n4).id is null then null else ordered_edges_to_path(s4.n2, (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.e1]::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(s4.ep1) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n2, s4.n3, s4.n4]::nodecomposite[])::pathcomposite end as p from s4 limit 100; -- case: match (n:NodeKind1)<-[:EdgeKind1]-(:NodeKind2) where n.objectid ends with '-516' with n, count(n) as dc_count where dc_count = 1 return n 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 ->> 'objectid') like '%-516') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and 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[])) select s1.n0 as n0, count(s1.n0)::int8 as i0 from s1 group by n0) select s0.n0 as n from s0 where (s0.i0 = 1); @@ -66,7 +66,7 @@ with s0 as (select 'a' as i0, 'b' as i1), s1 as (with s2 as (select s0.i0 as i0, 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 lower(((s2.n0).properties ->> 'samaccountname'))::text), 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 ((jsonb_typeof((n0.properties -> 'domain')) = 'string' and (n0.properties ->> 'domain') = 'test')) 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((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s2.path) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id) as e0, (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 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 case when (s4.n2).id is null or s4.e1 is null or (s4.n3).id is null then null else ordered_edges_to_path(s4.n2, (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.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n2, s4.n3]::nodecomposite[])::pathcomposite end as p from s4 where ((s4.n2).id = any ((select (_unnest_elem).id from unnest(s4.i1) as _unnest_elem))); +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 ((jsonb_typeof((n0.properties -> 'domain')) = 'string' and (n0.properties ->> 'domain') = 'test')) 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((_edge.id, _edge.start_id, _edge.end_id, _edge.kind_id, _edge.properties)::edgecomposite order by _path.ordinality), array []::edgecomposite[]) from unnest(s2.path) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id) as e0, (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((n0).id)::int8[], array []::int8[])::int8[], null)::int8[] as i1 from s0 where (s0.i0 > 90)), s4 as (select e1.id 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[]) and (n2.id = any (s3.i1))) select case when (s4.n2).id is null or s4.e1 is null or (s4.n3).id is null then null else ordered_edges_to_path(s4.n2, (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.e1]::int8[]) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s4.n2, s4.n3]::nodecomposite[])::pathcomposite end as p from s4; -- case: match (u:NodeKind1)-[:EdgeKind1]->(g:NodeKind2) with g match (g)<-[:EdgeKind1]-(u:NodeKind1) return g with s0 as (with s1 as (select (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select s1.n1 as n1 from s1), s2 as (select s0.n1 as n1 from s0 join edge e1 on (s0.n1).id = e1.end_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.start_id where e1.kind_id = any (array [3]::int2[])) select s2.n1 as g from s2; diff --git a/cypher/models/pgsql/test/translation_cases/nodes.sql b/cypher/models/pgsql/test/translation_cases/nodes.sql index 6feaf94e..1fa6e1c8 100644 --- a/cypher/models/pgsql/test/translation_cases/nodes.sql +++ b/cypher/models/pgsql/test/translation_cases/nodes.sql @@ -208,7 +208,7 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and lower((n0.properties ->> 'tenantid'))::text like '%myid%' and (n0.properties ->> 'system_tags') like '%tag%')) select s0.n0 as n from s0; -- case: match (s) where not (s)-[]-() return s -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select s0.n0 as s from s0 where (not exists (select 1 from edge e0 where e0.start_id = (s0.n0).id or e0.end_id = (s0.n0).id)); +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select s0.n0 as s from s0 where (not exists (select 1 from edge e0 where (e0.start_id = (s0.n0).id or e0.end_id = (s0.n0).id))); -- case: match (s) where not (s)-[]->()-[]->() return s with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select s0.n0 as s from s0 where (not (with s1 as (select e0.id as e0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n1 on n1.id = e0.end_id where (s0.n0).id = e0.start_id), s2 as (select s1.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.id = e1.end_id where e1.id != s1.e0) select count(*) > 0 from s2)); @@ -235,7 +235,7 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select s0.n0 as s from s0 where (not (with s1 as (select s0.n0 as n0 from edge e0 join node n1 on (jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'n3') and n1.id = e0.end_id where (jsonb_typeof((e0.properties -> 'prop')) = 'string' and (e0.properties ->> 'prop') = 'a') and (s0.n0).id = e0.start_id) select count(*) > 0 from s1)); -- case: match (s) where not (s)-[]-() return id(s) -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select (s0.n0).id from s0 where (not exists (select 1 from edge e0 where e0.start_id = (s0.n0).id or e0.end_id = (s0.n0).id)); +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select (s0.n0).id from s0 where (not exists (select 1 from edge e0 where (e0.start_id = (s0.n0).id or e0.end_id = (s0.n0).id))); -- case: match (n) where n.system_tags contains ($param) return n -- pgsql_params:{"pi0":null} From 8522854a71d015c6698825c4b4337d8d4faa0c6f Mon Sep 17 00:00:00 2001 From: John Hopper Date: Fri, 22 May 2026 21:40:29 -0700 Subject: [PATCH 077/116] Correct bounded Azure path assertion --- integration/testdata/cases/optimizer_inline.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/integration/testdata/cases/optimizer_inline.json b/integration/testdata/cases/optimizer_inline.json index a23110fb..fe2253d5 100644 --- a/integration/testdata/cases/optimizer_inline.json +++ b/integration/testdata/cases/optimizer_inline.json @@ -336,9 +336,9 @@ ] }, "assert": { - "row_count": 2, - "path_node_ids": [["role", "direct-user"], ["role", "delegated-group", "delegated-user"]], - "path_edge_kinds": [["AZHasRole"], ["AZHasRole", "AZMemberOf"]] + "row_count": 3, + "path_node_ids": [["role", "direct-user"], ["role", "delegated-group"], ["role", "delegated-group", "delegated-user"]], + "path_edge_kinds": [["AZHasRole"], ["AZHasRole"], ["AZHasRole", "AZMemberOf"]] } } ] From 9c5232caf19057fe7f0f147f738acdc88a4da3ae Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 13:10:12 -0700 Subject: [PATCH 078/116] further lowering and live query optimization work --- cypher/models/pgsql/optimize/lowering.go | 36 +- cypher/models/pgsql/optimize/lowering_plan.go | 267 ++++++++++ .../models/pgsql/optimize/optimizer_test.go | 40 +- .../test/translation_cases/multipart.sql | 2 +- .../translate/aggregate_traversal_count.go | 501 ++++++++++++++++++ .../pgsql/translate/optimizer_safety_test.go | 41 +- cypher/models/pgsql/translate/translator.go | 11 + 7 files changed, 885 insertions(+), 13 deletions(-) create mode 100644 cypher/models/pgsql/translate/aggregate_traversal_count.go diff --git a/cypher/models/pgsql/optimize/lowering.go b/cypher/models/pgsql/optimize/lowering.go index cd060001..efe65c45 100644 --- a/cypher/models/pgsql/optimize/lowering.go +++ b/cypher/models/pgsql/optimize/lowering.go @@ -1,6 +1,9 @@ package optimize -import "github.com/specterops/dawgs/cypher/models/cypher" +import ( + "github.com/specterops/dawgs/cypher/models/cypher" + "github.com/specterops/dawgs/graph" +) const ( LoweringProjectionPruning = "ProjectionPruning" @@ -14,6 +17,7 @@ const ( LoweringPredicatePlacement = "PredicatePlacement" LoweringCountStoreFastPath = "CountStoreFastPath" LoweringCollectIDMembership = "CollectIDMembership" + LoweringAggregateTraversalCount = "AggregateTraversalCount" ) type LoweringDecision struct { @@ -160,6 +164,31 @@ type CountStoreFastPathDecision struct { KindSymbols []string `json:"kind_symbols,omitempty"` } +type AggregateTraversalCountDecision struct { + QueryPartIndex int `json:"query_part_index"` + SourceSymbol string `json:"source_symbol"` + TerminalSymbol string `json:"terminal_symbol"` + CountAlias string `json:"count_alias"` + Limit int64 `json:"limit,omitempty"` + Target TraversalStepTarget `json:"target"` +} + +type AggregateTraversalCountShape struct { + QueryPartIndex int + SourceSymbol string + TerminalSymbol string + CountAlias string + Limit int64 + SourceMatch *cypher.Match + SourceKinds graph.Kinds + TerminalKinds graph.Kinds + RelationshipKinds graph.Kinds + Direction graph.Direction + MinDepth int64 + MaxDepth int64 + Target TraversalStepTarget +} + type LoweringPlan struct { ProjectionPruning []ProjectionPruningDecision `json:"projection_pruning,omitempty"` LatePathMaterialization []LatePathMaterializationDecision `json:"late_path_materialization,omitempty"` @@ -172,6 +201,7 @@ type LoweringPlan struct { PredicatePlacement []PredicatePlacementDecision `json:"predicate_placement,omitempty"` PatternPredicate []PatternPredicatePlacementDecision `json:"pattern_predicate_placement,omitempty"` CountStoreFastPath []CountStoreFastPathDecision `json:"count_store_fast_path,omitempty"` + AggregateTraversalCount []AggregateTraversalCountDecision `json:"aggregate_traversal_count,omitempty"` } func (s LoweringPlan) Empty() bool { @@ -185,7 +215,8 @@ func (s LoweringPlan) Empty() bool { len(s.ExpansionSuffixPushdown) == 0 && len(s.PredicatePlacement) == 0 && len(s.PatternPredicate) == 0 && - len(s.CountStoreFastPath) == 0 + len(s.CountStoreFastPath) == 0 && + len(s.AggregateTraversalCount) == 0 } func (s LoweringPlan) Decisions() []LoweringDecision { @@ -206,6 +237,7 @@ func (s LoweringPlan) Decisions() []LoweringDecision { add(LoweringExpansionSuffixPushdown, len(s.ExpansionSuffixPushdown) > 0) add(LoweringPredicatePlacement, len(s.PredicatePlacement) > 0 || len(s.PatternPredicate) > 0) add(LoweringCountStoreFastPath, len(s.CountStoreFastPath) > 0) + add(LoweringAggregateTraversalCount, len(s.AggregateTraversalCount) > 0) return decisions } diff --git a/cypher/models/pgsql/optimize/lowering_plan.go b/cypher/models/pgsql/optimize/lowering_plan.go index e74e27fa..4880473c 100644 --- a/cypher/models/pgsql/optimize/lowering_plan.go +++ b/cypher/models/pgsql/optimize/lowering_plan.go @@ -57,6 +57,7 @@ func BuildLoweringPlan(query *cypher.RegularQuery, predicateAttachments []Predic appendPredicatePlacementDecisions(&plan, query, predicateAttachments) attachPredicatePlacementsToSuffixPushdowns(&plan) appendCountStoreFastPathDecisions(&plan, query) + appendAggregateTraversalCountDecisions(&plan, query) return plan, nil } @@ -365,6 +366,7 @@ func appendTraversalDirectionDecisions(plan *LoweringPlan, queryPartIndex int, r stepIndex, step, declaredEndpoints[stepIndex], + referencesSourceIdentifier(predicateConstrainedSymbols, variableSymbol(step.RightNode.Variable)), ); shouldFlip { plan.TraversalDirection = append(plan.TraversalDirection, decision) } @@ -543,6 +545,7 @@ func boundLeftExpansionDirectionDecisionForStep( stepIndex int, step sourceTraversalStep, declaredEndpoints declaredStepEndpoints, + rightHasAttachedPredicate bool, ) (TraversalDirectionDecision, bool) { if patternPart == nil || patternPart.Variable != nil || @@ -559,6 +562,10 @@ func boundLeftExpansionDirectionDecisionForStep( return TraversalDirectionDecision{}, false } + if step.RightNode.Properties == nil && !rightHasAttachedPredicate { + return TraversalDirectionDecision{}, false + } + leftSymbol := variableSymbol(step.LeftNode.Variable) rightSymbol := variableSymbol(step.RightNode.Variable) if leftSymbol == "" || leftSymbol == rightSymbol { @@ -991,6 +998,266 @@ func appendCountStoreFastPathDecisions(plan *LoweringPlan, query *cypher.Regular } } +func appendAggregateTraversalCountDecisions(plan *LoweringPlan, query *cypher.RegularQuery) { + if shape, ok := AggregateTraversalCountShapeForQuery(query); ok { + plan.AggregateTraversalCount = append(plan.AggregateTraversalCount, AggregateTraversalCountDecision{ + QueryPartIndex: shape.QueryPartIndex, + SourceSymbol: shape.SourceSymbol, + TerminalSymbol: shape.TerminalSymbol, + CountAlias: shape.CountAlias, + Limit: shape.Limit, + Target: shape.Target, + }) + } +} + +func AggregateTraversalCountShapeForQuery(query *cypher.RegularQuery) (AggregateTraversalCountShape, bool) { + if query == nil || query.SingleQuery == nil || query.SingleQuery.MultiPartQuery == nil { + return AggregateTraversalCountShape{}, false + } + + multiPartQuery := query.SingleQuery.MultiPartQuery + if len(multiPartQuery.Parts) != 1 || multiPartQuery.Parts[0] == nil || multiPartQuery.SinglePartQuery == nil { + return AggregateTraversalCountShape{}, false + } + + part := multiPartQuery.Parts[0] + if len(part.UpdatingClauses) > 0 || len(part.ReadingClauses) != 2 || part.With == nil || part.With.Where != nil { + return AggregateTraversalCountShape{}, false + } + + sourceMatch, sourceNode, sourceSymbol, ok := aggregateTraversalSourceMatch(part.ReadingClauses[0]) + if !ok { + return AggregateTraversalCountShape{}, false + } + + relationship, terminalNode, terminalSymbol, ok := aggregateTraversalMatch(part.ReadingClauses[1], sourceSymbol) + if !ok { + return AggregateTraversalCountShape{}, false + } + + countAlias, ok := aggregateTraversalWithProjection(part.With.Projection, sourceSymbol, terminalSymbol) + if !ok { + return AggregateTraversalCountShape{}, false + } + + limit, ok := aggregateTraversalFinalProjection(multiPartQuery.SinglePartQuery, sourceSymbol, countAlias) + if !ok { + return AggregateTraversalCountShape{}, false + } + + minDepth, maxDepth, ok := aggregateTraversalDepthBounds(relationship.Range) + if !ok { + return AggregateTraversalCountShape{}, false + } + + return AggregateTraversalCountShape{ + QueryPartIndex: 0, + SourceSymbol: sourceSymbol, + TerminalSymbol: terminalSymbol, + CountAlias: countAlias, + Limit: limit, + SourceMatch: sourceMatch, + SourceKinds: sourceNode.Kinds, + TerminalKinds: terminalNode.Kinds, + RelationshipKinds: relationship.Kinds, + Direction: relationship.Direction, + MinDepth: minDepth, + MaxDepth: maxDepth, + Target: TraversalStepTarget{ + QueryPartIndex: 0, + ClauseIndex: 1, + PatternIndex: 0, + StepIndex: 0, + }, + }, true +} + +func aggregateTraversalSourceMatch(readingClause *cypher.ReadingClause) (*cypher.Match, *cypher.NodePattern, string, bool) { + if readingClause == nil || readingClause.Match == nil { + return nil, nil, "", false + } + + match := readingClause.Match + if match.Optional || len(match.Pattern) != 1 { + return nil, nil, "", false + } + + patternPart := match.Pattern[0] + nodePattern, ok := singleNodePattern(patternPart) + if !ok || nodePattern == nil || nodePattern.Variable == nil || nodePattern.Variable.Symbol == "" || nodePattern.Properties != nil { + return nil, nil, "", false + } + + for _, dependency := range sortedDependencies(match.Where) { + if dependency != nodePattern.Variable.Symbol { + return nil, nil, "", false + } + } + + return match, nodePattern, nodePattern.Variable.Symbol, true +} + +func aggregateTraversalMatch(readingClause *cypher.ReadingClause, sourceSymbol string) (*cypher.RelationshipPattern, *cypher.NodePattern, string, bool) { + if readingClause == nil || readingClause.Match == nil { + return nil, nil, "", false + } + + match := readingClause.Match + if match.Optional || match.Where != nil || len(match.Pattern) != 1 { + return nil, nil, "", false + } + + patternPart := match.Pattern[0] + if patternPart == nil || patternPart.Variable != nil || patternPart.ShortestPathPattern || patternPart.AllShortestPathsPattern || len(patternPart.PatternElements) != 3 { + return nil, nil, "", false + } + + leftNode, leftOK := patternPart.PatternElements[0].AsNodePattern() + relationship, relationshipOK := patternPart.PatternElements[1].AsRelationshipPattern() + rightNode, rightOK := patternPart.PatternElements[2].AsNodePattern() + if !leftOK || !relationshipOK || !rightOK || + leftNode == nil || relationship == nil || rightNode == nil || + variableSymbol(leftNode.Variable) != sourceSymbol || + leftNode.Properties != nil || + relationship.Variable != nil || + relationship.Range == nil || + relationship.Properties != nil || + relationship.Direction == graph.DirectionBoth || + rightNode.Properties != nil || + rightNode.Variable == nil || + rightNode.Variable.Symbol == "" { + return nil, nil, "", false + } + + return relationship, rightNode, rightNode.Variable.Symbol, true +} + +func aggregateTraversalWithProjection(projection *cypher.Projection, sourceSymbol, terminalSymbol string) (string, bool) { + if projection == nil || projection.All || projection.Order != nil || projection.Skip != nil || projection.Limit != nil || len(projection.Items) != 2 { + return "", false + } + + if symbol, ok := projectionItemVariableSymbol(projection.Items[0]); !ok || symbol != sourceSymbol { + return "", false + } + + countAlias, ok := projectionItemCountAlias(projection.Items[1], terminalSymbol) + if !ok { + return "", false + } + + return countAlias, true +} + +func aggregateTraversalFinalProjection(queryPart *cypher.SinglePartQuery, sourceSymbol, countAlias string) (int64, bool) { + if queryPart == nil || len(queryPart.ReadingClauses) > 0 || len(queryPart.UpdatingClauses) > 0 || queryPart.Return == nil || queryPart.Return.Projection == nil { + return 0, false + } + + projection := queryPart.Return.Projection + if projection.Distinct || projection.All || projection.Skip != nil || projection.Order == nil || projection.Limit == nil || len(projection.Items) != 1 { + return 0, false + } + + if symbol, ok := projectionItemVariableSymbol(projection.Items[0]); !ok || symbol != sourceSymbol { + return 0, false + } + + if len(projection.Order.Items) != 1 || projection.Order.Items[0] == nil || projection.Order.Items[0].Ascending { + return 0, false + } + + if orderSymbol, ok := expressionVariableSymbol(projection.Order.Items[0].Expression); !ok || orderSymbol != countAlias { + return 0, false + } + + return literalInt64(projection.Limit.Value) +} + +func aggregateTraversalDepthBounds(patternRange *cypher.PatternRange) (int64, int64, bool) { + if patternRange == nil { + return 0, 0, false + } + + minDepth := int64(1) + if patternRange.StartIndex != nil { + minDepth = *patternRange.StartIndex + } + if minDepth < 1 { + return 0, 0, false + } + + maxDepth := int64(15) + if patternRange.EndIndex != nil { + maxDepth = *patternRange.EndIndex + } + if maxDepth < minDepth { + return 0, 0, false + } + + return minDepth, maxDepth, true +} + +func projectionItemVariableSymbol(expression cypher.Expression) (string, bool) { + projectionItem, ok := expression.(*cypher.ProjectionItem) + if !ok || projectionItem == nil || projectionItem.Alias != nil { + return "", false + } + + return expressionVariableSymbol(projectionItem.Expression) +} + +func expressionVariableSymbol(expression cypher.Expression) (string, bool) { + variable, ok := expression.(*cypher.Variable) + if !ok || variable == nil || variable.Symbol == "" { + return "", false + } + + return variable.Symbol, true +} + +func projectionItemCountAlias(expression cypher.Expression, terminalSymbol string) (string, bool) { + projectionItem, ok := expression.(*cypher.ProjectionItem) + if !ok || projectionItem == nil || projectionItem.Alias == nil || projectionItem.Alias.Symbol == "" { + return "", false + } + + function, ok := projectionItem.Expression.(*cypher.FunctionInvocation) + if !ok || function == nil || !strings.EqualFold(function.Name, cypher.CountFunction) || + function.Distinct || len(function.Namespace) > 0 || len(function.Arguments) != 1 { + return "", false + } + + if symbol, ok := expressionVariableSymbol(function.Arguments[0]); !ok || symbol != terminalSymbol { + return "", false + } + + return projectionItem.Alias.Symbol, true +} + +func literalInt64(expression cypher.Expression) (int64, bool) { + literal, ok := expression.(*cypher.Literal) + if !ok || literal == nil || literal.Null { + return 0, false + } + + switch value := literal.Value.(type) { + case int: + return int64(value), value >= 0 + case int8: + return int64(value), value >= 0 + case int16: + return int64(value), value >= 0 + case int32: + return int64(value), value >= 0 + case int64: + return value, value >= 0 + default: + return 0, false + } +} + func countStoreFastPathDecision(query *cypher.RegularQuery) (CountStoreFastPathDecision, bool) { if query == nil || query.SingleQuery == nil || query.SingleQuery.SinglePartQuery == nil { return CountStoreFastPathDecision{}, false diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index f3df3b63..a61cb83b 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -709,11 +709,8 @@ func TestLoweringPlanReportsTraversalDirectionForBoundLeftExpansionToConstrained regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` MATCH (u:User) WHERE u.hasspn = true AND u.enabled = true - MATCH (u)-[:MemberOf|AdminTo*1..]->(c:Computer) - WITH DISTINCT u, COUNT(c) AS adminCount - RETURN u - ORDER BY adminCount DESC - LIMIT 100 + MATCH (u)-[:MemberOf|AdminTo*1..]->(c:Computer {name: 'target'}) + RETURN c `) require.NoError(t, err) @@ -732,6 +729,39 @@ func TestLoweringPlanReportsTraversalDirectionForBoundLeftExpansionToConstrained }}, plan.LoweringPlan.TraversalDirection) } +func TestLoweringPlanReportsAggregateTraversalCountForBoundExpansionCount(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH (u:User) + WHERE u.hasspn = true AND u.enabled = true + MATCH (u)-[:MemberOf|AdminTo*1..]->(c:Computer) + WITH DISTINCT u, COUNT(c) AS adminCount + RETURN u + ORDER BY adminCount DESC + LIMIT 100 + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Empty(t, plan.LoweringPlan.TraversalDirection) + require.Contains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringAggregateTraversalCount}) + require.Equal(t, []AggregateTraversalCountDecision{{ + QueryPartIndex: 0, + SourceSymbol: "u", + TerminalSymbol: "c", + CountAlias: "adminCount", + Limit: 100, + Target: TraversalStepTarget{ + QueryPartIndex: 0, + ClauseIndex: 1, + PatternIndex: 0, + StepIndex: 0, + }, + }}, plan.LoweringPlan.AggregateTraversalCount) +} + func TestLoweringPlanSkipsSuffixPushdownAfterRightEndpointPredicateDirectionFlip(t *testing.T) { t.Parallel() diff --git a/cypher/models/pgsql/test/translation_cases/multipart.sql b/cypher/models/pgsql/test/translation_cases/multipart.sql index dce5b88d..48741b90 100644 --- a/cypher/models/pgsql/test/translation_cases/multipart.sql +++ b/cypher/models/pgsql/test/translation_cases/multipart.sql @@ -36,7 +36,7 @@ with s0 as (with s1 as (with recursive s2_seed(root_id) as not materialized (sel with s0 as (select 365 as i0), s1 as (select s0.i0 as i0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from s0, node n0 where (not ((n0.properties ->> 'pwdlastset'))::float8 = any (array [- 1, 0]::float8[]) and ((n0.properties ->> 'pwdlastset'))::numeric < (extract(epoch from now()::timestamp with time zone)::numeric - (s0.i0 * 86400))) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s1.n0 as n from s1 limit 100; -- case: match (n:NodeKind1) where n.hasspn = true and n.enabled = true and not n.objectid ends with '-502' and not coalesce(n.gmsa, false) = true and not coalesce(n.msa, false) = true match (n)-[:EdgeKind1|EdgeKind2*1..]->(c:NodeKind2) with distinct n, count(c) as adminCount return n order by adminCount desc limit 100 -with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties -> 'hasspn'))::jsonb = to_jsonb((true)::bool)::jsonb and ((n0.properties -> 'enabled'))::jsonb = to_jsonb((true)::bool)::jsonb and not coalesce((n0.properties ->> 'objectid'), '')::text like '%-502' and not coalesce(((n0.properties ->> 'gmsa'))::bool, false)::bool = true and not coalesce(((n0.properties ->> 'msa'))::bool, false)::bool = true) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2 as (with recursive s3_seed(root_id) as not materialized (select n1.id as root_id from node n1 where n1.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s3(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, false, e0.end_id = e0.start_id, array [e0.id] from s3_seed join edge e0 on e0.end_id = s3_seed.root_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s3.root_id, e0.start_id, s3.depth + 1, false, false, e0.id || s3.path from s3 join lateral (select e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties from edge e0 where e0.end_id = s3.next_id and e0.id != all (s3.path) and e0.kind_id = any (array [3, 4]::int2[]) offset 0) e0 on true where s3.depth < 15 and not s3.is_cycle) select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1, s3 join lateral (select n1.id, n1.kind_ids, n1.properties from node n1 where n1.id = s3.root_id offset 0) n1 on true join lateral (select n0.id, n0.kind_ids, n0.properties from node n0 where n0.id = s3.next_id offset 0) n0 on true where (s1.n0).id = s3.next_id) select distinct s2.n0 as n0, count(s2.n1)::int8 as i0 from s2 group by n0) select s0.n0 as n from s0 order by s0.i0 desc limit 100; +with recursive candidate_sources(root_id) as (select source_node.id as root_id from node source_node where (((source_node.properties -> 'hasspn'))::jsonb = to_jsonb((true)::bool)::jsonb and ((source_node.properties -> 'enabled'))::jsonb = to_jsonb((true)::bool)::jsonb and not coalesce((source_node.properties ->> 'objectid'), '')::text like '%-502' and not coalesce(((source_node.properties ->> 'gmsa'))::bool, false)::bool = true and not coalesce(((source_node.properties ->> 'msa'))::bool, false)::bool = true) and source_node.kind_ids operator (pg_catalog.@>) array [1]::int2[]), traversal(root_id, next_id, depth, path) as (select candidate_sources.root_id, e.end_id, 1, array [e.id]::int8[] from candidate_sources join edge e on e.start_id = candidate_sources.root_id where e.kind_id = any (array [3, 4]::int2[]) union all select traversal.root_id, e.end_id, traversal.depth + 1, traversal.path || e.id from traversal join lateral (select e.id, e.start_id, e.end_id from edge e where e.start_id = traversal.next_id and e.id != all (traversal.path) and e.kind_id = any (array [3, 4]::int2[]) offset 0) e on true where traversal.depth < 15), terminal_nodes(id) as materialized (select terminal_node.id from node terminal_node where terminal_node.kind_ids operator (pg_catalog.@>) array [2]::int2[]), terminal_hits(root_id) as (select traversal.root_id from traversal join terminal_nodes on terminal_nodes.id = traversal.next_id), ranked(root_id, adminCount) as (select terminal_hits.root_id, count(*)::int8 as adminCount from terminal_hits group by terminal_hits.root_id order by adminCount desc limit 100) select (source_node.id, source_node.kind_ids, source_node.properties)::nodecomposite as n from ranked join node source_node on source_node.id = ranked.root_id order by ranked.adminCount desc; -- case: match (n:NodeKind1) where n.objectid = 'S-1-5-21-1260426776-3623580948-1897206385-23225' match p = (n)-[:EdgeKind1|EdgeKind2*1..]->(c:NodeKind2) return p with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((jsonb_typeof((n0.properties -> 'objectid')) = 'string' and (n0.properties ->> 'objectid') = 'S-1-5-21-1260426776-3623580948-1897206385-23225')) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1 as (with recursive s2_seed(root_id) as not materialized (select distinct (s0.n0).id as root_id from s0), s2(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 s2_seed join edge e0 on e0.start_id = s2_seed.root_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[]) union all select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::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.start_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.end_id where s2.depth < 15 and not s2.is_cycle) select 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 s0, 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 and (s0.n0).id = s2.root_id) select case when (s1.n0).id is null or s1.ep0 is null or (s1.n1).id is null then null else ordered_edges_to_path(s1.n0, (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(s1.ep0) with ordinality as _path(id, ordinality) join edge _edge on _edge.id = _path.id), array [s1.n0, s1.n1]::nodecomposite[])::pathcomposite end as p from s1; diff --git a/cypher/models/pgsql/translate/aggregate_traversal_count.go b/cypher/models/pgsql/translate/aggregate_traversal_count.go new file mode 100644 index 00000000..e994b70a --- /dev/null +++ b/cypher/models/pgsql/translate/aggregate_traversal_count.go @@ -0,0 +1,501 @@ +package translate + +import ( + "fmt" + "reflect" + + "github.com/specterops/dawgs/cypher/models/cypher" + "github.com/specterops/dawgs/cypher/models/pgsql" + "github.com/specterops/dawgs/cypher/models/pgsql/optimize" + "github.com/specterops/dawgs/cypher/models/walk" + "github.com/specterops/dawgs/graph" +) + +const ( + aggregateCandidateSourcesCTE pgsql.Identifier = "candidate_sources" + aggregateTraversalCTE pgsql.Identifier = "traversal" + aggregateTerminalNodesCTE pgsql.Identifier = "terminal_nodes" + aggregateTerminalHitsCTE pgsql.Identifier = "terminal_hits" + aggregateRankedCTE pgsql.Identifier = "ranked" + + aggregateSourceAlias pgsql.Identifier = "source_node" + aggregateEdgeAlias pgsql.Identifier = "e" + aggregateTerminalAlias pgsql.Identifier = "terminal_node" + + aggregateRootID pgsql.Identifier = "root_id" + aggregateNextID pgsql.Identifier = "next_id" + aggregateDepth pgsql.Identifier = "depth" + aggregatePath pgsql.Identifier = "path" + aggregateNodeID pgsql.Identifier = "id" +) + +func (s *Translator) translateAggregateTraversalCount(query *cypher.RegularQuery, plan optimize.LoweringPlan) (bool, error) { + if len(plan.AggregateTraversalCount) == 0 { + return false, nil + } + + shape, ok := optimize.AggregateTraversalCountShapeForQuery(query) + if !ok || shape.Target != plan.AggregateTraversalCount[0].Target { + return false, nil + } + + statement, err := s.aggregateTraversalCountQuery(shape) + if err != nil { + return false, err + } + + s.translation.Statement = statement + s.recordLowering(optimize.LoweringAggregateTraversalCount) + return true, nil +} + +func (s *Translator) aggregateTraversalCountQuery(shape optimize.AggregateTraversalCountShape) (pgsql.Query, error) { + candidateSources, err := s.buildAggregateCandidateSourcesCTE(shape) + if err != nil { + return pgsql.Query{}, err + } + + traversal, err := s.buildAggregateTraversalCTE(shape) + if err != nil { + return pgsql.Query{}, err + } + + terminalNodes, err := s.buildAggregateTerminalNodesCTE(shape) + if err != nil { + return pgsql.Query{}, err + } + + terminalHits, err := s.buildAggregateTerminalHitsCTE(shape) + if err != nil { + return pgsql.Query{}, err + } + + return pgsql.Query{ + CommonTableExpressions: &pgsql.With{ + Recursive: true, + Expressions: []pgsql.CommonTableExpression{ + candidateSources, + traversal, + terminalNodes, + terminalHits, + s.buildAggregateRankedCTE(shape), + }, + }, + Body: pgsql.Select{ + Projection: pgsql.Projection{ + pgsql.AliasedExpression{ + Expression: aggregateNodeComposite(aggregateSourceAlias), + Alias: pgsql.AsOptionalIdentifier(pgsql.Identifier(shape.SourceSymbol)), + }, + }, + From: []pgsql.FromClause{{ + Source: pgsql.TableReference{ + Name: aggregateRankedCTE.AsCompoundIdentifier(), + }, + Joins: []pgsql.Join{{ + Table: pgsql.TableReference{ + Name: pgsql.TableNode.AsCompoundIdentifier(), + Binding: pgsql.AsOptionalIdentifier(aggregateSourceAlias), + }, + JoinOperator: pgsql.JoinOperator{ + JoinType: pgsql.JoinTypeInner, + Constraint: pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{aggregateSourceAlias, pgsql.ColumnID}, + pgsql.OperatorEquals, + pgsql.CompoundIdentifier{aggregateRankedCTE, aggregateRootID}, + ), + }, + }}, + }}, + }, + OrderBy: []*pgsql.OrderBy{{ + Expression: pgsql.CompoundIdentifier{aggregateRankedCTE, pgsql.Identifier(shape.CountAlias)}, + Ascending: false, + }}, + }, nil +} + +func (s *Translator) buildAggregateCandidateSourcesCTE(shape optimize.AggregateTraversalCountShape) (pgsql.CommonTableExpression, error) { + whereClause, err := s.aggregateSourceWhere(shape) + if err != nil { + return pgsql.CommonTableExpression{}, err + } + + return pgsql.CommonTableExpression{ + Alias: pgsql.TableAlias{ + Name: aggregateCandidateSourcesCTE, + Shape: pgsql.NewRecordShape([]pgsql.Identifier{aggregateRootID}), + }, + Query: pgsql.Query{ + Body: pgsql.Select{ + Projection: pgsql.Projection{ + pgsql.AliasedExpression{ + Expression: pgsql.CompoundIdentifier{aggregateSourceAlias, pgsql.ColumnID}, + Alias: pgsql.AsOptionalIdentifier(aggregateRootID), + }, + }, + From: []pgsql.FromClause{{ + Source: pgsql.TableReference{ + Name: pgsql.TableNode.AsCompoundIdentifier(), + Binding: pgsql.AsOptionalIdentifier(aggregateSourceAlias), + }, + }}, + Where: whereClause, + }, + }, + }, nil +} + +func (s *Translator) buildAggregateTraversalCTE(shape optimize.AggregateTraversalCountShape) (pgsql.CommonTableExpression, error) { + edgeKindConstraint, err := s.aggregateEdgeKindConstraint(aggregateEdgeAlias, shape.RelationshipKinds) + if err != nil { + return pgsql.CommonTableExpression{}, err + } + + sourceColumn, nextColumn := aggregateTraversalColumns(shape.Direction) + primerJoin := pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{aggregateEdgeAlias, sourceColumn}, + pgsql.OperatorEquals, + pgsql.CompoundIdentifier{aggregateCandidateSourcesCTE, aggregateRootID}, + ) + recursiveJoin := pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{aggregateEdgeAlias, sourceColumn}, + pgsql.OperatorEquals, + pgsql.CompoundIdentifier{aggregateTraversalCTE, aggregateNextID}, + ) + + return pgsql.CommonTableExpression{ + Alias: pgsql.TableAlias{ + Name: aggregateTraversalCTE, + Shape: pgsql.NewRecordShape([]pgsql.Identifier{ + aggregateRootID, + aggregateNextID, + aggregateDepth, + aggregatePath, + }), + }, + Query: pgsql.Query{ + Body: pgsql.SetOperation{ + Operator: pgsql.OperatorUnion, + All: true, + LOperand: pgsql.Select{ + Projection: pgsql.Projection{ + pgsql.CompoundIdentifier{aggregateCandidateSourcesCTE, aggregateRootID}, + pgsql.CompoundIdentifier{aggregateEdgeAlias, nextColumn}, + pgsql.NewLiteral(int64(1), pgsql.Int8), + pgsql.ArrayLiteral{ + Values: []pgsql.Expression{ + pgsql.CompoundIdentifier{aggregateEdgeAlias, pgsql.ColumnID}, + }, + CastType: pgsql.Int8Array, + }, + }, + From: []pgsql.FromClause{{ + Source: pgsql.TableReference{ + Name: aggregateCandidateSourcesCTE.AsCompoundIdentifier(), + }, + Joins: []pgsql.Join{{ + Table: pgsql.TableReference{ + Name: pgsql.TableEdge.AsCompoundIdentifier(), + Binding: pgsql.AsOptionalIdentifier(aggregateEdgeAlias), + }, + JoinOperator: pgsql.JoinOperator{ + JoinType: pgsql.JoinTypeInner, + Constraint: primerJoin, + }, + }}, + }}, + Where: edgeKindConstraint, + }, + ROperand: pgsql.Select{ + Projection: pgsql.Projection{ + pgsql.CompoundIdentifier{aggregateTraversalCTE, aggregateRootID}, + pgsql.CompoundIdentifier{aggregateEdgeAlias, nextColumn}, + pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{aggregateTraversalCTE, aggregateDepth}, + pgsql.OperatorAdd, + pgsql.NewLiteral(int64(1), pgsql.Int8), + ), + pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{aggregateTraversalCTE, aggregatePath}, + pgsql.OperatorConcatenate, + pgsql.CompoundIdentifier{aggregateEdgeAlias, pgsql.ColumnID}, + ), + }, + From: []pgsql.FromClause{{ + Source: pgsql.TableReference{ + Name: aggregateTraversalCTE.AsCompoundIdentifier(), + }, + Joins: []pgsql.Join{{ + Table: pgsql.LateralSubquery{ + Query: pgsql.Query{ + Body: pgsql.Select{ + Projection: pgsql.Projection{ + pgsql.CompoundIdentifier{aggregateEdgeAlias, pgsql.ColumnID}, + pgsql.CompoundIdentifier{aggregateEdgeAlias, pgsql.ColumnStartID}, + pgsql.CompoundIdentifier{aggregateEdgeAlias, pgsql.ColumnEndID}, + }, + From: []pgsql.FromClause{{ + Source: pgsql.TableReference{ + Name: pgsql.TableEdge.AsCompoundIdentifier(), + Binding: pgsql.AsOptionalIdentifier(aggregateEdgeAlias), + }, + }}, + Where: pgsql.OptionalAnd( + pgsql.OptionalAnd( + recursiveJoin, + pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{aggregateEdgeAlias, pgsql.ColumnID}, + pgsql.OperatorNotEquals, + pgsql.NewAllExpression(pgsql.CompoundIdentifier{aggregateTraversalCTE, aggregatePath}), + ), + ), + edgeKindConstraint, + ), + }, + Offset: pgsql.NewLiteral(0, pgsql.Int), + }, + Binding: pgsql.AsOptionalIdentifier(aggregateEdgeAlias), + }, + JoinOperator: pgsql.JoinOperator{ + JoinType: pgsql.JoinTypeInner, + Constraint: pgsql.NewLiteral(true, pgsql.Boolean), + }, + }}, + }}, + Where: pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{aggregateTraversalCTE, aggregateDepth}, + pgsql.OperatorLessThan, + pgsql.NewLiteral(shape.MaxDepth, pgsql.Int8), + ), + }, + }, + }, + }, nil +} + +func (s *Translator) buildAggregateTerminalHitsCTE(shape optimize.AggregateTraversalCountShape) (pgsql.CommonTableExpression, error) { + terminalWhere := pgsql.Expression(nil) + + if shape.MinDepth > 1 { + terminalWhere = pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{aggregateTraversalCTE, aggregateDepth}, + pgsql.OperatorGreaterThanOrEqualTo, + pgsql.NewLiteral(shape.MinDepth, pgsql.Int8), + ) + } + + return pgsql.CommonTableExpression{ + Alias: pgsql.TableAlias{ + Name: aggregateTerminalHitsCTE, + Shape: pgsql.NewRecordShape([]pgsql.Identifier{aggregateRootID}), + }, + Query: pgsql.Query{ + Body: pgsql.Select{ + Projection: pgsql.Projection{ + pgsql.CompoundIdentifier{aggregateTraversalCTE, aggregateRootID}, + }, + From: []pgsql.FromClause{{ + Source: pgsql.TableReference{ + Name: aggregateTraversalCTE.AsCompoundIdentifier(), + }, + Joins: []pgsql.Join{{ + Table: pgsql.TableReference{ + Name: aggregateTerminalNodesCTE.AsCompoundIdentifier(), + }, + JoinOperator: pgsql.JoinOperator{ + JoinType: pgsql.JoinTypeInner, + Constraint: pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{aggregateTerminalNodesCTE, aggregateNodeID}, + pgsql.OperatorEquals, + pgsql.CompoundIdentifier{aggregateTraversalCTE, aggregateNextID}, + ), + }, + }}, + }}, + Where: terminalWhere, + }, + }, + }, nil +} + +func (s *Translator) buildAggregateTerminalNodesCTE(shape optimize.AggregateTraversalCountShape) (pgsql.CommonTableExpression, error) { + terminalWhere, err := s.aggregateNodeKindConstraint(aggregateTerminalAlias, shape.TerminalKinds) + if err != nil { + return pgsql.CommonTableExpression{}, err + } + + return pgsql.CommonTableExpression{ + Materialized: &pgsql.Materialized{Materialized: true}, + Alias: pgsql.TableAlias{ + Name: aggregateTerminalNodesCTE, + Shape: pgsql.NewRecordShape([]pgsql.Identifier{aggregateNodeID}), + }, + Query: pgsql.Query{ + Body: pgsql.Select{ + Projection: pgsql.Projection{ + pgsql.CompoundIdentifier{aggregateTerminalAlias, pgsql.ColumnID}, + }, + From: []pgsql.FromClause{{ + Source: pgsql.TableReference{ + Name: pgsql.TableNode.AsCompoundIdentifier(), + Binding: pgsql.AsOptionalIdentifier(aggregateTerminalAlias), + }, + }}, + Where: terminalWhere, + }, + }, + }, nil +} + +func (s *Translator) buildAggregateRankedCTE(shape optimize.AggregateTraversalCountShape) pgsql.CommonTableExpression { + countAlias := pgsql.Identifier(shape.CountAlias) + + return pgsql.CommonTableExpression{ + Alias: pgsql.TableAlias{ + Name: aggregateRankedCTE, + Shape: pgsql.NewRecordShape([]pgsql.Identifier{ + aggregateRootID, + countAlias, + }), + }, + Query: pgsql.Query{ + Body: pgsql.Select{ + Projection: pgsql.Projection{ + pgsql.CompoundIdentifier{aggregateTerminalHitsCTE, aggregateRootID}, + pgsql.AliasedExpression{ + Expression: pgsql.FunctionCall{ + Function: pgsql.FunctionCount, + Parameters: []pgsql.Expression{pgsql.Wildcard{}}, + CastType: pgsql.Int8, + }, + Alias: pgsql.AsOptionalIdentifier(countAlias), + }, + }, + From: []pgsql.FromClause{{ + Source: pgsql.TableReference{ + Name: aggregateTerminalHitsCTE.AsCompoundIdentifier(), + }, + }}, + GroupBy: []pgsql.Expression{ + pgsql.CompoundIdentifier{aggregateTerminalHitsCTE, aggregateRootID}, + }, + }, + OrderBy: []*pgsql.OrderBy{{ + Expression: countAlias, + Ascending: false, + }}, + Limit: pgsql.NewLiteral(shape.Limit, pgsql.Int8), + }, + } +} + +func (s *Translator) aggregateSourceWhere(shape optimize.AggregateTraversalCountShape) (pgsql.Expression, error) { + sourceKindConstraint, err := s.aggregateNodeKindConstraint(aggregateSourceAlias, shape.SourceKinds) + if err != nil { + return nil, err + } + + sourcePredicate, err := s.aggregateSourcePredicate(shape) + if err != nil { + return nil, err + } + + return pgsql.OptionalAnd(sourcePredicate, sourceKindConstraint), nil +} + +func (s *Translator) aggregateSourcePredicate(shape optimize.AggregateTraversalCountShape) (pgsql.Expression, error) { + if shape.SourceMatch == nil || shape.SourceMatch.Where == nil { + return nil, nil + } + + translator := NewTranslator(s.ctx, s.kindMapper.kindMapper, s.parameters, s.graphID) + sourceBinding := translator.scope.Define(aggregateSourceAlias, pgsql.NodeComposite) + translator.scope.Alias(pgsql.Identifier(shape.SourceSymbol), sourceBinding) + + if err := walk.Cypher(shape.SourceMatch.Where, translator); err != nil { + return nil, err + } + + sourceConstraints, err := translator.treeTranslator.ConsumeConstraintsFromVisibleSet(pgsql.AsIdentifierSet(aggregateSourceAlias)) + if err != nil { + return nil, err + } + + remainingConstraints, err := translator.treeTranslator.ConsumeAllConstraints() + if err != nil { + return nil, err + } + if remainingConstraints.Expression != nil { + return nil, fmt.Errorf("unsupported aggregate traversal source predicate dependencies: %v", remainingConstraints.Dependencies.Slice()) + } + + for key, value := range translator.translation.Parameters { + if existingValue, hasExisting := s.translation.Parameters[key]; hasExisting && !reflect.DeepEqual(existingValue, value) { + return nil, fmt.Errorf("aggregate traversal parameter collision for %s", key) + } + + s.translation.Parameters[key] = value + } + + return sourceConstraints.Expression, nil +} + +func (s *Translator) aggregateNodeKindConstraint(alias pgsql.Identifier, kinds graph.Kinds) (pgsql.Expression, error) { + if len(kinds) == 0 { + return nil, nil + } + + kindIDs, err := s.kindMapper.MapKinds(kinds) + if err != nil { + return nil, err + } + + kindIDsLiteral, err := pgsql.AsLiteral(kindIDs) + if err != nil { + return nil, err + } + + return pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{alias, pgsql.ColumnKindIDs}, + pgsql.OperatorPGArrayLHSContainsRHS, + kindIDsLiteral, + ), nil +} + +func (s *Translator) aggregateEdgeKindConstraint(alias pgsql.Identifier, kinds graph.Kinds) (pgsql.Expression, error) { + if len(kinds) == 0 { + return nil, nil + } + + kindIDs, err := s.kindMapper.MapKinds(kinds) + if err != nil { + return nil, err + } + + return pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{alias, pgsql.ColumnKindID}, + pgsql.OperatorEquals, + pgsql.NewAnyExpressionHinted(pgsql.NewLiteral(kindIDs, pgsql.Int2Array)), + ), nil +} + +func aggregateTraversalColumns(direction graph.Direction) (pgsql.Identifier, pgsql.Identifier) { + switch direction { + case graph.DirectionInbound: + return pgsql.ColumnEndID, pgsql.ColumnStartID + default: + return pgsql.ColumnStartID, pgsql.ColumnEndID + } +} + +func aggregateNodeComposite(alias pgsql.Identifier) pgsql.CompositeValue { + return pgsql.CompositeValue{ + Values: []pgsql.Expression{ + pgsql.CompoundIdentifier{alias, pgsql.ColumnID}, + pgsql.CompoundIdentifier{alias, pgsql.ColumnKindIDs}, + pgsql.CompoundIdentifier{alias, pgsql.ColumnProperties}, + }, + DataType: pgsql.NodeComposite, + } +} diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index ced6e779..90a1183b 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -579,7 +579,7 @@ RETURN p require.Contains(t, normalizedQuery, "join edge e0 on e0.end_id = s1_seed.root_id") } -func TestOptimizerSafetyTraversalDirectionUsesBoundLeftExpansionTerminalConstraint(t *testing.T) { +func TestOptimizerSafetyAggregateTraversalCountUsesIDOnlySourceAnchoredShape(t *testing.T) { t.Parallel() translation := optimizerSafetyTranslation(t, ` @@ -594,11 +594,42 @@ LIMIT 100 formattedQuery, err := Translated(translation) require.NoError(t, err) normalizedQuery := strings.Join(strings.Fields(formattedQuery), " ") + lowerQuery := strings.ToLower(normalizedQuery) + + requireNoPlannedOptimizationLowering(t, translation.Optimization, "TraversalDirectionSelection") + requirePlannedOptimizationLowering(t, translation.Optimization, optimize.LoweringAggregateTraversalCount) + requireOptimizationLowering(t, translation.Optimization, optimize.LoweringAggregateTraversalCount) + require.Contains(t, lowerQuery, "with recursive candidate_sources(root_id)") + require.Contains(t, lowerQuery, "traversal(root_id, next_id, depth, path)") + require.Contains(t, lowerQuery, "terminal_nodes(id) as materialized") + require.Contains(t, lowerQuery, "terminal_hits(root_id)") + require.Contains(t, lowerQuery, "ranked(root_id, admincount)") + require.Contains(t, lowerQuery, "join edge e on e.start_id = candidate_sources.root_id") + require.Contains(t, lowerQuery, "e.start_id = traversal.next_id") + require.Contains(t, lowerQuery, "e.id != all (traversal.path)") + require.Contains(t, lowerQuery, "join terminal_nodes on terminal_nodes.id = traversal.next_id") + require.Contains(t, lowerQuery, "count(*)::int8 as admincount") + require.Contains(t, lowerQuery, "group by terminal_hits.root_id") + require.Contains(t, lowerQuery, "from ranked join node source_node on source_node.id = ranked.root_id") + require.NotContains(t, lowerQuery, "group by (") + require.NotContains(t, lowerQuery, "::nodecomposite as n0 from") +} + +func TestOptimizerSafetyAggregateTraversalCountSkipsObservedTerminal(t *testing.T) { + t.Parallel() - requirePlannedOptimizationLowering(t, translation.Optimization, "TraversalDirectionSelection") - requireOptimizationLowering(t, translation.Optimization, "TraversalDirectionSelection") - require.Contains(t, normalizedQuery, "join edge e0 on e0.end_id = s3_seed.root_id") - require.Contains(t, normalizedQuery, "(s1.n0).id = s3.next_id") + translation := optimizerSafetyTranslation(t, ` +MATCH (u:User) +WHERE u.hasspn = true AND u.enabled = true +MATCH (u)-[:MemberOf|AdminTo*1..]->(c:Computer) +WITH DISTINCT u, c, COUNT(c) AS adminCount +RETURN u, c +ORDER BY adminCount DESC +LIMIT 100 + `) + + requireNoPlannedOptimizationLowering(t, translation.Optimization, optimize.LoweringAggregateTraversalCount) + requireNoOptimizationLowering(t, translation.Optimization, optimize.LoweringAggregateTraversalCount) } func TestOptimizerSafetyShortestPathStrategyUsesPlannedBidirectionalSearch(t *testing.T) { diff --git a/cypher/models/pgsql/translate/translator.go b/cypher/models/pgsql/translate/translator.go index 5836b5f0..a766d881 100644 --- a/cypher/models/pgsql/translate/translator.go +++ b/cypher/models/pgsql/translate/translator.go @@ -695,6 +695,7 @@ func plannedLoweringCounts(plan optimize.LoweringPlan) []SkippedLowering { {Name: optimize.LoweringExpansionSuffixPushdown, Count: len(plan.ExpansionSuffixPushdown)}, {Name: optimize.LoweringPredicatePlacement, Count: len(plan.PredicatePlacement) + len(plan.PatternPredicate)}, {Name: optimize.LoweringCountStoreFastPath, Count: len(plan.CountStoreFastPath)}, + {Name: optimize.LoweringAggregateTraversalCount, Count: len(plan.AggregateTraversalCount)}, } } @@ -702,6 +703,9 @@ func skippedLoweringReason(name string, applied map[string]int) string { if applied[optimize.LoweringCountStoreFastPath] > 0 && name != optimize.LoweringCountStoreFastPath { return "superseded by CountStoreFastPath" } + if applied[optimize.LoweringAggregateTraversalCount] > 0 && name != optimize.LoweringAggregateTraversalCount { + return "superseded by AggregateTraversalCount" + } switch name { case optimize.LoweringPredicatePlacement: @@ -739,6 +743,13 @@ func Translate(ctx context.Context, cypherQuery *cypher.RegularQuery, kindMapper return translator.translation, nil } + if translated, err := translator.translateAggregateTraversalCount(optimizedPlan.Query, optimizedPlan.LoweringPlan); err != nil { + return Result{}, err + } else if translated { + translator.recordSkippedLowerings() + return translator.translation, nil + } + if err := walk.Cypher(optimizedPlan.Query, translator); err != nil { return Result{}, err } From 94b7d781e6e5be61740a52c1bc4b3ee6880e896d Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 13:52:46 -0700 Subject: [PATCH 079/116] Add live aggregate traversal plan guard --- .../pgsql_aggregate_traversal_plan_test.go | 304 ++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100644 integration/pgsql_aggregate_traversal_plan_test.go diff --git a/integration/pgsql_aggregate_traversal_plan_test.go b/integration/pgsql_aggregate_traversal_plan_test.go new file mode 100644 index 00000000..c41febbb --- /dev/null +++ b/integration/pgsql_aggregate_traversal_plan_test.go @@ -0,0 +1,304 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build manual_integration + +package integration + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/specterops/dawgs/cypher/frontend" + "github.com/specterops/dawgs/cypher/models/pgsql/optimize" + "github.com/specterops/dawgs/cypher/models/pgsql/translate" + "github.com/specterops/dawgs/drivers/pg" + "github.com/specterops/dawgs/graph" +) + +const liveAggregateTraversalCypher = ` +MATCH (u:User) +WHERE u.hasspn = true + AND u.enabled = true + AND NOT u.objectid ENDS WITH '-502' + AND NOT COALESCE(u.gmsa, false) = true + AND NOT COALESCE(u.msa, false) = true +MATCH (u)-[:MemberOf|AdminTo*1..]->(c:Computer) +WITH DISTINCT u, COUNT(c) AS adminCount +RETURN u +ORDER BY adminCount DESC +LIMIT 100 +` + +type livePGKindMapper struct { + pool *pgxpool.Pool +} + +func (s livePGKindMapper) MapKinds(ctx context.Context, kinds graph.Kinds) ([]int16, error) { + ids := make([]int16, 0, len(kinds)) + + for _, kind := range kinds { + id, err := liveKindID(ctx, s.pool, kind.String()) + if err != nil { + return nil, err + } + + ids = append(ids, id) + } + + return ids, nil +} + +func (s livePGKindMapper) AssertKinds(ctx context.Context, kinds graph.Kinds) ([]int16, error) { + return s.MapKinds(ctx, kinds) +} + +func TestPostgreSQLLiveAggregateTraversalCountPlanShape(t *testing.T) { + connStr := os.Getenv("CONNECTION_STRING") + if connStr == "" { + t.Skip("CONNECTION_STRING env var is not set") + } + + driver, err := driverFromConnStr(connStr) + if err != nil { + t.Fatalf("failed to detect driver: %v", err) + } + if driver != pg.DriverName { + t.Skipf("CONNECTION_STRING is not a PostgreSQL connection string") + } + + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + + poolCfg, err := pgxpool.ParseConfig(connStr) + if err != nil { + t.Fatalf("failed to parse PG connection string: %v", err) + } + pool, err := pgxpool.NewWithConfig(ctx, poolCfg) + if err != nil { + t.Fatalf("failed to connect to PostgreSQL: %v", err) + } + defer pool.Close() + + liveStats, ok := liveAggregateTraversalStats(ctx, t, pool) + if !ok { + return + } + if liveStats.candidateUsers == 0 || liveStats.computers < 1000 || liveStats.adminEdges < 10000 { + t.Skipf( + "connected PostgreSQL database does not look like the live aggregate traversal dataset: %+v", + liveStats, + ) + } + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), liveAggregateTraversalCypher) + if err != nil { + t.Fatalf("failed to parse live aggregate traversal query: %v", err) + } + + translation, err := translate.Translate(ctx, regularQuery, livePGKindMapper{pool: pool}, nil, translate.DefaultGraphID) + if err != nil { + t.Fatalf("failed to translate live aggregate traversal query: %v", err) + } + requireLoweringDecision(t, translation.Optimization.PlannedLowerings, optimize.LoweringAggregateTraversalCount) + requireLoweringDecision(t, translation.Optimization.Lowerings, optimize.LoweringAggregateTraversalCount) + + sqlQuery, err := translate.Translated(translation) + if err != nil { + t.Fatalf("failed to render live aggregate traversal SQL: %v", err) + } + normalizedSQL := strings.Join(strings.Fields(strings.ToLower(sqlQuery)), " ") + + for _, expected := range []string{ + "with recursive candidate_sources(root_id)", + "traversal(root_id, next_id, depth, path)", + "terminal_nodes(id) as materialized", + "terminal_hits(root_id)", + "ranked(root_id, admincount)", + "join edge e on e.start_id = candidate_sources.root_id", + "e.start_id = traversal.next_id", + "group by terminal_hits.root_id", + "from ranked join node source_node on source_node.id = ranked.root_id", + } { + if !strings.Contains(normalizedSQL, expected) { + t.Fatalf("expected translated SQL to contain %q, got:\n%s", expected, sqlQuery) + } + } + if strings.Contains(normalizedSQL, "group by (") { + t.Fatalf("expected aggregate traversal SQL to avoid grouping by composites, got:\n%s", sqlQuery) + } + + plan := explainAggregateTraversalPlan(ctx, t, pool, sqlQuery, translation.Parameters) + for _, expected := range []string{ + "CTE traversal", + "Recursive Union", + "start_id = source_node", + "start_id = traversal", + "Group Key: traversal.root_id", + "Hash Cond: (traversal.next_id = terminal_nodes.id)", + } { + if !strings.Contains(plan, expected) { + t.Fatalf("expected PostgreSQL plan to contain %q, got:\n%s", expected, plan) + } + } + for _, unexpected := range []string{ + "end_id = source_node", + "end_id = traversal", + "Group Key: (", + } { + if strings.Contains(plan, unexpected) { + t.Fatalf("expected PostgreSQL plan to avoid %q, got:\n%s", unexpected, plan) + } + } + + limitIndex := strings.Index(plan, "-> Limit") + sourceMaterializationIndex := strings.LastIndex(plan, "Index Scan using node_") + if limitIndex < 0 || sourceMaterializationIndex < 0 || sourceMaterializationIndex < limitIndex { + t.Fatalf("expected source node materialization after top-N limiting, got:\n%s", plan) + } +} + +type liveAggregateStats struct { + candidateUsers int64 + computers int64 + adminEdges int64 +} + +func liveAggregateTraversalStats(ctx context.Context, t *testing.T, pool *pgxpool.Pool) (liveAggregateStats, bool) { + t.Helper() + + userKindID, err := liveKindID(ctx, pool, "User") + if err != nil { + t.Skipf("connected PostgreSQL database has no User kind: %v", err) + return liveAggregateStats{}, false + } + computerKindID, err := liveKindID(ctx, pool, "Computer") + if err != nil { + t.Skipf("connected PostgreSQL database has no Computer kind: %v", err) + return liveAggregateStats{}, false + } + memberOfKindID, err := liveKindID(ctx, pool, "MemberOf") + if err != nil { + t.Skipf("connected PostgreSQL database has no MemberOf kind: %v", err) + return liveAggregateStats{}, false + } + adminToKindID, err := liveKindID(ctx, pool, "AdminTo") + if err != nil { + t.Skipf("connected PostgreSQL database has no AdminTo kind: %v", err) + return liveAggregateStats{}, false + } + + var stats liveAggregateStats + if err := pool.QueryRow(ctx, ` + select + ( + select count(*) + from node n + where n.kind_ids operator (pg_catalog.@>) array[$1::int2] + and (n.properties -> 'hasspn') = to_jsonb(true) + and (n.properties -> 'enabled') = to_jsonb(true) + and coalesce(n.properties ->> 'objectid', '') not like '%-502' + and not coalesce((n.properties ->> 'gmsa')::bool, false) + and not coalesce((n.properties ->> 'msa')::bool, false) + ), + ( + select count(*) + from node n + where n.kind_ids operator (pg_catalog.@>) array[$2::int2] + ), + ( + select count(*) + from edge e + where e.kind_id = any(array[$3::int2, $4::int2]) + ) + `, userKindID, computerKindID, memberOfKindID, adminToKindID).Scan( + &stats.candidateUsers, + &stats.computers, + &stats.adminEdges, + ); err != nil { + t.Fatalf("failed to inspect live aggregate traversal dataset: %v", err) + } + + return stats, true +} + +func liveKindID(ctx context.Context, pool *pgxpool.Pool, name string) (int16, error) { + var id int16 + if err := pool.QueryRow(ctx, `select id from kind where name = $1`, name).Scan(&id); err != nil { + return 0, fmt.Errorf("map kind %q: %w", name, err) + } + + return id, nil +} + +func explainAggregateTraversalPlan(ctx context.Context, t *testing.T, pool *pgxpool.Pool, sqlQuery string, params map[string]any) string { + t.Helper() + + tx, err := pool.Begin(ctx) + if err != nil { + t.Fatalf("failed to begin PostgreSQL explain transaction: %v", err) + } + defer func() { + _ = tx.Rollback(context.Background()) + }() + + if _, err := tx.Exec(ctx, `set local statement_timeout = '30s'`); err != nil { + t.Fatalf("failed to set PostgreSQL statement timeout: %v", err) + } + + args := []any{} + if len(params) > 0 { + args = append(args, pgx.NamedArgs(params)) + } + + rows, err := tx.Query(ctx, "explain (analyze, buffers, timing off, summary off) "+sqlQuery, args...) + if err != nil { + t.Fatalf("failed to explain live aggregate traversal query: %v", err) + } + defer rows.Close() + + var planLines []string + for rows.Next() { + var line string + if err := rows.Scan(&line); err != nil { + t.Fatalf("failed to scan live aggregate traversal plan line: %v", err) + } + planLines = append(planLines, line) + } + if err := rows.Err(); err != nil { + t.Fatalf("failed while reading live aggregate traversal plan: %v", err) + } + + return strings.Join(planLines, "\n") +} + +func requireLoweringDecision(t *testing.T, lowerings []optimize.LoweringDecision, name string) { + t.Helper() + + for _, lowering := range lowerings { + if lowering.Name == name { + return + } + } + + t.Fatalf("expected lowering %s in %v", name, lowerings) +} From 51fe99c4efb394198d434966ded9d7f11cacf937 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 13:54:30 -0700 Subject: [PATCH 080/116] Report skipped kind-only traversal flips --- cypher/models/pgsql/optimize/lowering_plan.go | 18 ++++++++++------- .../models/pgsql/optimize/optimizer_test.go | 11 +++++++++- .../pgsql/translate/optimizer_safety_test.go | 19 +++++++++++++++++- cypher/models/pgsql/translate/translator.go | 20 +++++++++++++++++-- 4 files changed, 57 insertions(+), 11 deletions(-) diff --git a/cypher/models/pgsql/optimize/lowering_plan.go b/cypher/models/pgsql/optimize/lowering_plan.go index 4880473c..1fe71afe 100644 --- a/cypher/models/pgsql/optimize/lowering_plan.go +++ b/cypher/models/pgsql/optimize/lowering_plan.go @@ -14,9 +14,10 @@ type sourceTraversalStep struct { } const ( - traversalDirectionReasonRightBound = "right_bound" - traversalDirectionReasonRightConstrained = "right_constrained" - traversalDirectionReasonRightPredicate = "right_predicate" + traversalDirectionReasonRightBound = "right_bound" + traversalDirectionReasonRightConstrained = "right_constrained" + traversalDirectionReasonRightPredicate = "right_predicate" + traversalDirectionReasonTerminalKindOnlyEstimateWide = "terminal kind-only estimate too broad" shortestPathStrategyReasonBoundEndpointPairs = "bound_endpoint_pairs" shortestPathStrategyReasonEndpointPredicates = "endpoint_predicates" @@ -562,10 +563,6 @@ func boundLeftExpansionDirectionDecisionForStep( return TraversalDirectionDecision{}, false } - if step.RightNode.Properties == nil && !rightHasAttachedPredicate { - return TraversalDirectionDecision{}, false - } - leftSymbol := variableSymbol(step.LeftNode.Variable) rightSymbol := variableSymbol(step.RightNode.Variable) if leftSymbol == "" || leftSymbol == rightSymbol { @@ -582,6 +579,13 @@ func boundLeftExpansionDirectionDecisionForStep( } } + if step.RightNode.Properties == nil && !rightHasAttachedPredicate { + return TraversalDirectionDecision{ + Target: target, + Reason: traversalDirectionReasonTerminalKindOnlyEstimateWide, + }, true + } + return TraversalDirectionDecision{ Target: target, Flip: true, diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index a61cb83b..c257ac8a 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -745,7 +745,16 @@ func TestLoweringPlanReportsAggregateTraversalCountForBoundExpansionCount(t *tes plan, err := Optimize(regularQuery) require.NoError(t, err) - require.Empty(t, plan.LoweringPlan.TraversalDirection) + require.Contains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringTraversalDirection}) + require.Equal(t, []TraversalDirectionDecision{{ + Target: TraversalStepTarget{ + QueryPartIndex: 0, + ClauseIndex: 1, + PatternIndex: 0, + StepIndex: 0, + }, + Reason: traversalDirectionReasonTerminalKindOnlyEstimateWide, + }}, plan.LoweringPlan.TraversalDirection) require.Contains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringAggregateTraversalCount}) require.Equal(t, []AggregateTraversalCountDecision{{ QueryPartIndex: 0, diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index 90a1183b..51fdb236 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -596,7 +596,9 @@ LIMIT 100 normalizedQuery := strings.Join(strings.Fields(formattedQuery), " ") lowerQuery := strings.ToLower(normalizedQuery) - requireNoPlannedOptimizationLowering(t, translation.Optimization, "TraversalDirectionSelection") + requirePlannedOptimizationLowering(t, translation.Optimization, "TraversalDirectionSelection") + requireNoOptimizationLowering(t, translation.Optimization, "TraversalDirectionSelection") + requireSkippedOptimizationLowering(t, translation.Optimization, "TraversalDirectionSelection", "superseded by AggregateTraversalCount") requirePlannedOptimizationLowering(t, translation.Optimization, optimize.LoweringAggregateTraversalCount) requireOptimizationLowering(t, translation.Optimization, optimize.LoweringAggregateTraversalCount) require.Contains(t, lowerQuery, "with recursive candidate_sources(root_id)") @@ -615,6 +617,21 @@ LIMIT 100 require.NotContains(t, lowerQuery, "::nodecomposite as n0 from") } +func TestOptimizerSafetyTraversalDirectionReportsKindOnlyTerminalSkip(t *testing.T) { + t.Parallel() + + translation := optimizerSafetyTranslation(t, ` +MATCH (u:User) +WHERE u.hasspn = true +MATCH (u)-[:MemberOf|AdminTo*1..]->(c:Computer) +RETURN count(c) + `) + + requirePlannedOptimizationLowering(t, translation.Optimization, "TraversalDirectionSelection") + requireNoOptimizationLowering(t, translation.Optimization, "TraversalDirectionSelection") + requireSkippedOptimizationLowering(t, translation.Optimization, "TraversalDirectionSelection", "terminal kind-only estimate too broad") +} + func TestOptimizerSafetyAggregateTraversalCountSkipsObservedTerminal(t *testing.T) { t.Parallel() diff --git a/cypher/models/pgsql/translate/translator.go b/cypher/models/pgsql/translate/translator.go index a766d881..3ba3f153 100644 --- a/cypher/models/pgsql/translate/translator.go +++ b/cypher/models/pgsql/translate/translator.go @@ -677,7 +677,7 @@ func (s *Translator) recordSkippedLowerings() { s.translation.Optimization.SkippedLowerings = append(s.translation.Optimization.SkippedLowerings, SkippedLowering{ Name: planned.Name, - Reason: skippedLoweringReason(planned.Name, applied), + Reason: skippedLoweringReason(planned.Name, applied, *s.translation.Optimization.LoweringPlan), Count: skippedCount, }) } @@ -699,7 +699,7 @@ func plannedLoweringCounts(plan optimize.LoweringPlan) []SkippedLowering { } } -func skippedLoweringReason(name string, applied map[string]int) string { +func skippedLoweringReason(name string, applied map[string]int, plan optimize.LoweringPlan) string { if applied[optimize.LoweringCountStoreFastPath] > 0 && name != optimize.LoweringCountStoreFastPath { return "superseded by CountStoreFastPath" } @@ -710,9 +710,25 @@ func skippedLoweringReason(name string, applied map[string]int) string { switch name { case optimize.LoweringPredicatePlacement: return "planned predicate placements were not consumed by this translation shape" + case optimize.LoweringTraversalDirection: + if reason := skippedTraversalDirectionReason(plan); reason != "" { + return reason + } default: return "planned lowering did not change the emitted SQL" } + + return "planned lowering did not change the emitted SQL" +} + +func skippedTraversalDirectionReason(plan optimize.LoweringPlan) string { + for _, decision := range plan.TraversalDirection { + if !decision.Flip && decision.Reason != "" { + return decision.Reason + } + } + + return "" } func Translate(ctx context.Context, cypherQuery *cypher.RegularQuery, kindMapper pgsql.KindMapper, parameters map[string]any, graphID int32) (Result, error) { From 61f039d0526fb33eda7f207f84a8bc5ed08abff6 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 13:55:13 -0700 Subject: [PATCH 081/116] Widen aggregate traversal count matching --- cypher/models/pgsql/optimize/lowering_plan.go | 11 +++++++++- .../models/pgsql/optimize/optimizer_test.go | 20 +++++++++++++++++ .../pgsql/translate/optimizer_safety_test.go | 22 +++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/cypher/models/pgsql/optimize/lowering_plan.go b/cypher/models/pgsql/optimize/lowering_plan.go index 1fe71afe..921d56b1 100644 --- a/cypher/models/pgsql/optimize/lowering_plan.go +++ b/cypher/models/pgsql/optimize/lowering_plan.go @@ -1233,13 +1233,22 @@ func projectionItemCountAlias(expression cypher.Expression, terminalSymbol strin return "", false } - if symbol, ok := expressionVariableSymbol(function.Arguments[0]); !ok || symbol != terminalSymbol { + if !aggregateTraversalCountArgumentMatches(function.Arguments[0], terminalSymbol) { return "", false } return projectionItem.Alias.Symbol, true } +func aggregateTraversalCountArgumentMatches(expression cypher.Expression, terminalSymbol string) bool { + if symbol, ok := expressionVariableSymbol(expression); ok { + return symbol == terminalSymbol + } + + rangeQuantifier, ok := expression.(*cypher.RangeQuantifier) + return ok && rangeQuantifier != nil && rangeQuantifier.Value == cypher.TokenLiteralAsterisk +} + func literalInt64(expression cypher.Expression) (int64, bool) { literal, ok := expression.(*cypher.Literal) if !ok || literal == nil || literal.Null { diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index c257ac8a..3a60034b 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -771,6 +771,26 @@ func TestLoweringPlanReportsAggregateTraversalCountForBoundExpansionCount(t *tes }}, plan.LoweringPlan.AggregateTraversalCount) } +func TestLoweringPlanReportsAggregateTraversalCountForRowCount(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH (u:User) + WHERE u.hasspn = true AND u.enabled = true + MATCH (u)-[:MemberOf|AdminTo*1..]->(c:Computer) + WITH DISTINCT u, COUNT(*) AS adminCount + RETURN u + ORDER BY adminCount DESC + LIMIT 100 + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Contains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringAggregateTraversalCount}) + require.Equal(t, "adminCount", plan.LoweringPlan.AggregateTraversalCount[0].CountAlias) +} + func TestLoweringPlanSkipsSuffixPushdownAfterRightEndpointPredicateDirectionFlip(t *testing.T) { t.Parallel() diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index 51fdb236..c3c91e50 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -632,6 +632,28 @@ RETURN count(c) requireSkippedOptimizationLowering(t, translation.Optimization, "TraversalDirectionSelection", "terminal kind-only estimate too broad") } +func TestOptimizerSafetyAggregateTraversalCountAcceptsRowCount(t *testing.T) { + t.Parallel() + + translation := optimizerSafetyTranslation(t, ` +MATCH (u:User) +WHERE u.hasspn = true AND u.enabled = true +MATCH (u)-[:MemberOf|AdminTo*1..]->(c:Computer) +WITH DISTINCT u, COUNT(*) AS adminCount +RETURN u +ORDER BY adminCount DESC +LIMIT 100 + `) + formattedQuery, err := Translated(translation) + require.NoError(t, err) + normalizedQuery := strings.Join(strings.Fields(strings.ToLower(formattedQuery)), " ") + + requirePlannedOptimizationLowering(t, translation.Optimization, optimize.LoweringAggregateTraversalCount) + requireOptimizationLowering(t, translation.Optimization, optimize.LoweringAggregateTraversalCount) + require.Contains(t, normalizedQuery, "count(*)::int8 as admincount") + require.Contains(t, normalizedQuery, "group by terminal_hits.root_id") +} + func TestOptimizerSafetyAggregateTraversalCountSkipsObservedTerminal(t *testing.T) { t.Parallel() From 7f62b687ff57e59532759452bf92af2bcc340eeb Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 13:58:58 -0700 Subject: [PATCH 082/116] Respect selective bound traversal sources --- cypher/models/pgsql/optimize/lowering_plan.go | 169 ++++++++++++++++++ .../models/pgsql/optimize/optimizer_test.go | 25 +++ .../pgsql/translate/optimizer_safety_test.go | 15 ++ 3 files changed, 209 insertions(+) diff --git a/cypher/models/pgsql/optimize/lowering_plan.go b/cypher/models/pgsql/optimize/lowering_plan.go index 921d56b1..4173ac7f 100644 --- a/cypher/models/pgsql/optimize/lowering_plan.go +++ b/cypher/models/pgsql/optimize/lowering_plan.go @@ -13,11 +13,14 @@ type sourceTraversalStep struct { RightNode *cypher.NodePattern } +type boundSourceSelectivity int + const ( traversalDirectionReasonRightBound = "right_bound" traversalDirectionReasonRightConstrained = "right_constrained" traversalDirectionReasonRightPredicate = "right_predicate" traversalDirectionReasonTerminalKindOnlyEstimateWide = "terminal kind-only estimate too broad" + traversalDirectionReasonBoundSourceSelective = "bound source estimate selective" shortestPathStrategyReasonBoundEndpointPairs = "bound_endpoint_pairs" shortestPathStrategyReasonEndpointPredicates = "endpoint_predicates" @@ -26,6 +29,12 @@ const ( shortestPathFilterReasonEndpointPairPredicates = "endpoint_pair_predicates" ) +const ( + boundSourceSelectivityNone boundSourceSelectivity = iota + boundSourceSelectivityPredicate + boundSourceSelectivityUnique +) + func BuildLoweringPlan(query *cypher.RegularQuery, predicateAttachments []PredicateAttachment) (LoweringPlan, error) { if query == nil || query.SingleQuery == nil { return LoweringPlan{}, nil @@ -328,6 +337,7 @@ func declaredSymbolsBeforeStepEndpoints(initial map[string]struct{}, steps []sou func appendTraversalDirectionDecisions(plan *LoweringPlan, queryPartIndex int, readingClauses []*cypher.ReadingClause, predicateConstrainedSymbols map[string]struct{}) { declaredSymbols := map[string]struct{}{} + declaredSourceSelectivity := map[string]boundSourceSelectivity{} for clauseIndex, readingClause := range readingClauses { if readingClause == nil || readingClause.Match == nil { @@ -368,6 +378,7 @@ func appendTraversalDirectionDecisions(plan *LoweringPlan, queryPartIndex int, r step, declaredEndpoints[stepIndex], referencesSourceIdentifier(predicateConstrainedSymbols, variableSymbol(step.RightNode.Variable)), + declaredSourceSelectivity[variableSymbol(step.LeftNode.Variable)], ); shouldFlip { plan.TraversalDirection = append(plan.TraversalDirection, decision) } @@ -376,6 +387,7 @@ func appendTraversalDirectionDecisions(plan *LoweringPlan, queryPartIndex int, r declarePatternSymbols(declaredSymbols, patternPart) } + declareSelectiveMatchSymbols(declaredSourceSelectivity, match) declareWhereSymbols(declaredSymbols, match) } } @@ -396,6 +408,153 @@ func bindingPredicateSymbols(predicateAttachments []PredicateAttachment, queryPa return symbols } +func declareSelectiveMatchSymbols(symbols map[string]boundSourceSelectivity, match *cypher.Match) { + if match == nil { + return + } + + for _, patternPart := range match.Pattern { + for _, nodePattern := range nodePatternsForPattern(patternPart) { + if nodePattern == nil { + continue + } + + symbol := variableSymbol(nodePattern.Variable) + if symbol == "" { + continue + } + + mergeBoundSourceSelectivity(symbols, symbol, propertyConstraintSelectivity(nodePattern.Properties)) + } + } + + if match.Where == nil { + return + } + + for _, expression := range match.Where.Expressions { + for _, term := range cypherConjunctionTerms(expression) { + if symbol, selectivity, ok := propertyPredicateSelectivity(term); ok { + mergeBoundSourceSelectivity(symbols, symbol, selectivity) + } + } + } +} + +func nodePatternsForPattern(patternPart *cypher.PatternPart) []*cypher.NodePattern { + if patternPart == nil { + return nil + } + + nodePatterns := make([]*cypher.NodePattern, 0, len(patternPart.PatternElements)) + for _, element := range patternPart.PatternElements { + if nodePattern, ok := element.AsNodePattern(); ok { + nodePatterns = append(nodePatterns, nodePattern) + } + } + + return nodePatterns +} + +func mergeBoundSourceSelectivity(symbols map[string]boundSourceSelectivity, symbol string, selectivity boundSourceSelectivity) { + if selectivity > symbols[symbol] { + symbols[symbol] = selectivity + } +} + +func propertyPredicateSelectivity(expression cypher.Expression) (string, boundSourceSelectivity, bool) { + comparison, isComparison := expression.(*cypher.Comparison) + if !isComparison || len(comparison.Partials) != 1 { + return "", boundSourceSelectivityNone, false + } + + partial := comparison.Partials[0] + if partial.Operator != cypher.OperatorEquals { + return "", boundSourceSelectivityNone, false + } + + if symbol, property, ok := propertyLookupSymbol(comparison.Left); ok && !expressionReferencesAnySource(partial.Right) { + return symbol, propertySelectivity(property, partial.Right), true + } + + if symbol, property, ok := propertyLookupSymbol(partial.Right); ok && !expressionReferencesAnySource(comparison.Left) { + return symbol, propertySelectivity(property, comparison.Left), true + } + + return "", boundSourceSelectivityNone, false +} + +func propertyConstraintSelectivity(expression cypher.Expression) boundSourceSelectivity { + properties, ok := expression.(*cypher.Properties) + if !ok || properties == nil || properties.Parameter != nil { + return boundSourceSelectivityNone + } + + highest := boundSourceSelectivityNone + for property, value := range properties.Map { + if selectivity := propertySelectivity(property, value); selectivity > highest { + highest = selectivity + } + } + + return highest +} + +func propertySelectivity(property string, value cypher.Expression) boundSourceSelectivity { + if strings.EqualFold(property, "objectid") && expressionIsConstant(value) { + return boundSourceSelectivityUnique + } + + if expressionIsStringLikeConstant(value) { + return boundSourceSelectivityPredicate + } + + return boundSourceSelectivityNone +} + +func expressionIsConstant(expression cypher.Expression) bool { + switch expression.(type) { + case *cypher.Literal, *cypher.Parameter: + return true + default: + return false + } +} + +func expressionIsStringLikeConstant(expression cypher.Expression) bool { + switch typedExpression := expression.(type) { + case *cypher.Literal: + if typedExpression == nil || typedExpression.Null { + return false + } + + _, isString := typedExpression.Value.(string) + return isString + case *cypher.Parameter: + return typedExpression != nil + default: + return false + } +} + +func propertyLookupSymbol(expression cypher.Expression) (string, string, bool) { + propertyLookup, isPropertyLookup := expression.(*cypher.PropertyLookup) + if !isPropertyLookup || propertyLookup == nil { + return "", "", false + } + + variable, isVariable := propertyLookup.Atom.(*cypher.Variable) + if !isVariable || variable == nil || variable.Symbol == "" || propertyLookup.Symbol == "" { + return "", "", false + } + + return variable.Symbol, propertyLookup.Symbol, true +} + +func nodePatternHasUniquePropertyConstraint(nodePattern *cypher.NodePattern) bool { + return nodePattern != nil && propertyConstraintSelectivity(nodePattern.Properties) == boundSourceSelectivityUnique +} + func shortestPathSearchPredicateSymbols(readingClauses []*cypher.ReadingClause) map[string]struct{} { symbols := map[string]struct{}{} @@ -547,6 +706,7 @@ func boundLeftExpansionDirectionDecisionForStep( step sourceTraversalStep, declaredEndpoints declaredStepEndpoints, rightHasAttachedPredicate bool, + leftSourceSelectivity boundSourceSelectivity, ) (TraversalDirectionDecision, bool) { if patternPart == nil || patternPart.Variable != nil || @@ -579,6 +739,15 @@ func boundLeftExpansionDirectionDecisionForStep( } } + if leftSourceSelectivity == boundSourceSelectivityUnique && + !nodePatternHasUniquePropertyConstraint(step.RightNode) && + !rightHasAttachedPredicate { + return TraversalDirectionDecision{ + Target: target, + Reason: traversalDirectionReasonBoundSourceSelective, + }, true + } + if step.RightNode.Properties == nil && !rightHasAttachedPredicate { return TraversalDirectionDecision{ Target: target, diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index 3a60034b..0dbdd2b2 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -729,6 +729,31 @@ func TestLoweringPlanReportsTraversalDirectionForBoundLeftExpansionToConstrained }}, plan.LoweringPlan.TraversalDirection) } +func TestLoweringPlanSkipsBoundLeftDirectionForSelectiveSource(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH (u:User) + WHERE u.objectid = 'S-1-5-21-1-1100' + MATCH (u)-[:MemberOf|AdminTo*1..]->(c:Computer {name: 'target'}) + RETURN c + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Contains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringTraversalDirection}) + require.Equal(t, []TraversalDirectionDecision{{ + Target: TraversalStepTarget{ + QueryPartIndex: 0, + ClauseIndex: 1, + PatternIndex: 0, + StepIndex: 0, + }, + Reason: traversalDirectionReasonBoundSourceSelective, + }}, plan.LoweringPlan.TraversalDirection) +} + func TestLoweringPlanReportsAggregateTraversalCountForBoundExpansionCount(t *testing.T) { t.Parallel() diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index c3c91e50..1fd6bdd7 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -632,6 +632,21 @@ RETURN count(c) requireSkippedOptimizationLowering(t, translation.Optimization, "TraversalDirectionSelection", "terminal kind-only estimate too broad") } +func TestOptimizerSafetyTraversalDirectionReportsSelectiveSourceSkip(t *testing.T) { + t.Parallel() + + translation := optimizerSafetyTranslation(t, ` +MATCH (u:User) +WHERE u.objectid = 'S-1-5-21-1-1100' +MATCH (u)-[:MemberOf|AdminTo*1..]->(c:Computer {name: 'target'}) +RETURN c + `) + + requirePlannedOptimizationLowering(t, translation.Optimization, "TraversalDirectionSelection") + requireNoOptimizationLowering(t, translation.Optimization, "TraversalDirectionSelection") + requireSkippedOptimizationLowering(t, translation.Optimization, "TraversalDirectionSelection", "bound source estimate selective") +} + func TestOptimizerSafetyAggregateTraversalCountAcceptsRowCount(t *testing.T) { t.Parallel() From 97c3ffa7f75c24533a88454a90cf6e448a7459b0 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 14:17:44 -0700 Subject: [PATCH 083/116] Expand aggregate traversal baseline coverage --- .../pgsql/translate/optimizer_safety_test.go | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index 1fd6bdd7..df707720 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -669,6 +669,122 @@ LIMIT 100 require.Contains(t, normalizedQuery, "group by terminal_hits.root_id") } +func TestOptimizerSafetyAggregateTraversalCountHonorsExplicitDepthBounds(t *testing.T) { + t.Parallel() + + translation := optimizerSafetyTranslation(t, ` +MATCH (u:User) +WHERE u.hasspn = true +MATCH (u)-[:MemberOf|AdminTo*2..4]->(c:Computer) +WITH DISTINCT u, COUNT(c) AS adminCount +RETURN u +ORDER BY adminCount DESC +LIMIT 100 + `) + formattedQuery, err := Translated(translation) + require.NoError(t, err) + normalizedQuery := strings.Join(strings.Fields(strings.ToLower(formattedQuery)), " ") + + requireOptimizationLowering(t, translation.Optimization, optimize.LoweringAggregateTraversalCount) + require.Contains(t, normalizedQuery, "where traversal.depth < 4") + require.Contains(t, normalizedQuery, "where traversal.depth >= 2") +} + +func TestOptimizerSafetyAggregateTraversalCountSupportsInboundSourceAnchoring(t *testing.T) { + t.Parallel() + + translation := optimizerSafetyTranslation(t, ` +MATCH (u:User) +WHERE u.hasspn = true +MATCH (u)<-[:MemberOf|AdminTo*1..]-(c:Computer) +WITH DISTINCT u, COUNT(c) AS adminCount +RETURN u +ORDER BY adminCount DESC +LIMIT 100 + `) + formattedQuery, err := Translated(translation) + require.NoError(t, err) + normalizedQuery := strings.Join(strings.Fields(strings.ToLower(formattedQuery)), " ") + + requireOptimizationLowering(t, translation.Optimization, optimize.LoweringAggregateTraversalCount) + require.Contains(t, normalizedQuery, "join edge e on e.end_id = candidate_sources.root_id") + require.Contains(t, normalizedQuery, "e.end_id = traversal.next_id") +} + +func TestOptimizerSafetyAggregateTraversalCountSkipsUnsafeWideningCandidates(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + query string + }{{ + name: "distinct terminal count", + query: ` +MATCH (u:User) +WHERE u.hasspn = true +MATCH (u)-[:MemberOf|AdminTo*1..]->(c:Computer) +WITH DISTINCT u, COUNT(DISTINCT c) AS adminCount +RETURN u +ORDER BY adminCount DESC +LIMIT 100 + `, + }, { + name: "optional traversal", + query: ` +MATCH (u:User) +WHERE u.hasspn = true +OPTIONAL MATCH (u)-[:MemberOf|AdminTo*1..]->(c:Computer) +WITH DISTINCT u, COUNT(c) AS adminCount +RETURN u +ORDER BY adminCount DESC +LIMIT 100 + `, + }, { + name: "path binding observed", + query: ` +MATCH (u:User) +WHERE u.hasspn = true +MATCH p = (u)-[:MemberOf|AdminTo*1..]->(c:Computer) +WITH DISTINCT u, p, COUNT(c) AS adminCount +RETURN u, p +ORDER BY adminCount DESC +LIMIT 100 + `, + }, { + name: "relationship binding observed", + query: ` +MATCH (u:User) +WHERE u.hasspn = true +MATCH (u)-[r:MemberOf|AdminTo*1..]->(c:Computer) +WITH DISTINCT u, r, COUNT(c) AS adminCount +RETURN u, r +ORDER BY adminCount DESC +LIMIT 100 + `, + }, { + name: "post aggregation filter", + query: ` +MATCH (u:User) +WHERE u.hasspn = true +MATCH (u)-[:MemberOf|AdminTo*1..]->(c:Computer) +WITH DISTINCT u, COUNT(c) AS adminCount +WHERE adminCount > 1 +RETURN u +ORDER BY adminCount DESC +LIMIT 100 + `, + }} + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + translation := optimizerSafetyTranslation(t, testCase.query) + + requireNoPlannedOptimizationLowering(t, translation.Optimization, optimize.LoweringAggregateTraversalCount) + requireNoOptimizationLowering(t, translation.Optimization, optimize.LoweringAggregateTraversalCount) + }) + } +} + func TestOptimizerSafetyAggregateTraversalCountSkipsObservedTerminal(t *testing.T) { t.Parallel() From 835a26fdd4223ad5446e675b064104ad63b7593a Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 14:19:02 -0700 Subject: [PATCH 084/116] Widen aggregate traversal final projections --- cypher/models/pgsql/optimize/lowering.go | 3 + cypher/models/pgsql/optimize/lowering_plan.go | 93 ++++++++++++++++--- .../models/pgsql/optimize/optimizer_test.go | 25 +++++ .../translate/aggregate_traversal_count.go | 20 ++-- .../pgsql/translate/optimizer_safety_test.go | 22 +++++ 5 files changed, 145 insertions(+), 18 deletions(-) diff --git a/cypher/models/pgsql/optimize/lowering.go b/cypher/models/pgsql/optimize/lowering.go index efe65c45..269405cb 100644 --- a/cypher/models/pgsql/optimize/lowering.go +++ b/cypher/models/pgsql/optimize/lowering.go @@ -178,6 +178,9 @@ type AggregateTraversalCountShape struct { SourceSymbol string TerminalSymbol string CountAlias string + ReturnSourceAlias string + ReturnCountAlias string + ReturnCount bool Limit int64 SourceMatch *cypher.Match SourceKinds graph.Kinds diff --git a/cypher/models/pgsql/optimize/lowering_plan.go b/cypher/models/pgsql/optimize/lowering_plan.go index 4173ac7f..82318d76 100644 --- a/cypher/models/pgsql/optimize/lowering_plan.go +++ b/cypher/models/pgsql/optimize/lowering_plan.go @@ -1214,7 +1214,7 @@ func AggregateTraversalCountShapeForQuery(query *cypher.RegularQuery) (Aggregate return AggregateTraversalCountShape{}, false } - limit, ok := aggregateTraversalFinalProjection(multiPartQuery.SinglePartQuery, sourceSymbol, countAlias) + finalProjection, ok := aggregateTraversalFinalProjection(multiPartQuery.SinglePartQuery, sourceSymbol, countAlias) if !ok { return AggregateTraversalCountShape{}, false } @@ -1229,7 +1229,10 @@ func AggregateTraversalCountShapeForQuery(query *cypher.RegularQuery) (Aggregate SourceSymbol: sourceSymbol, TerminalSymbol: terminalSymbol, CountAlias: countAlias, - Limit: limit, + ReturnSourceAlias: finalProjection.SourceAlias, + ReturnCountAlias: finalProjection.CountAlias, + ReturnCount: finalProjection.ReturnCount, + Limit: finalProjection.Limit, SourceMatch: sourceMatch, SourceKinds: sourceNode.Kinds, TerminalKinds: terminalNode.Kinds, @@ -1323,29 +1326,72 @@ func aggregateTraversalWithProjection(projection *cypher.Projection, sourceSymbo return countAlias, true } -func aggregateTraversalFinalProjection(queryPart *cypher.SinglePartQuery, sourceSymbol, countAlias string) (int64, bool) { +type aggregateTraversalFinalProjectionShape struct { + SourceAlias string + CountAlias string + ReturnCount bool + Limit int64 +} + +func aggregateTraversalFinalProjection(queryPart *cypher.SinglePartQuery, sourceSymbol, countAlias string) (aggregateTraversalFinalProjectionShape, bool) { if queryPart == nil || len(queryPart.ReadingClauses) > 0 || len(queryPart.UpdatingClauses) > 0 || queryPart.Return == nil || queryPart.Return.Projection == nil { - return 0, false + return aggregateTraversalFinalProjectionShape{}, false } projection := queryPart.Return.Projection - if projection.Distinct || projection.All || projection.Skip != nil || projection.Order == nil || projection.Limit == nil || len(projection.Items) != 1 { - return 0, false + if projection.Distinct || projection.All || projection.Skip != nil || projection.Order == nil || projection.Limit == nil || len(projection.Items) < 1 || len(projection.Items) > 2 { + return aggregateTraversalFinalProjectionShape{}, false } - if symbol, ok := projectionItemVariableSymbol(projection.Items[0]); !ok || symbol != sourceSymbol { - return 0, false + finalProjection := aggregateTraversalFinalProjectionShape{ + SourceAlias: sourceSymbol, + CountAlias: countAlias, + } + + sourceSeen := false + countSeen := false + for _, item := range projection.Items { + symbol, alias, ok := projectionItemVariableSymbolAndAlias(item) + if !ok { + return aggregateTraversalFinalProjectionShape{}, false + } + + switch symbol { + case sourceSymbol: + if sourceSeen { + return aggregateTraversalFinalProjectionShape{}, false + } + sourceSeen = true + finalProjection.SourceAlias = alias + case countAlias: + if countSeen { + return aggregateTraversalFinalProjectionShape{}, false + } + countSeen = true + finalProjection.ReturnCount = true + finalProjection.CountAlias = alias + default: + return aggregateTraversalFinalProjectionShape{}, false + } + } + if !sourceSeen { + return aggregateTraversalFinalProjectionShape{}, false } if len(projection.Order.Items) != 1 || projection.Order.Items[0] == nil || projection.Order.Items[0].Ascending { - return 0, false + return aggregateTraversalFinalProjectionShape{}, false } - if orderSymbol, ok := expressionVariableSymbol(projection.Order.Items[0].Expression); !ok || orderSymbol != countAlias { - return 0, false + if orderSymbol, ok := expressionVariableSymbol(projection.Order.Items[0].Expression); !ok || (orderSymbol != countAlias && orderSymbol != finalProjection.CountAlias) { + return aggregateTraversalFinalProjectionShape{}, false } - return literalInt64(projection.Limit.Value) + limit, ok := literalInt64(projection.Limit.Value) + if !ok { + return aggregateTraversalFinalProjectionShape{}, false + } + finalProjection.Limit = limit + return finalProjection, true } func aggregateTraversalDepthBounds(patternRange *cypher.PatternRange) (int64, int64, bool) { @@ -1381,6 +1427,29 @@ func projectionItemVariableSymbol(expression cypher.Expression) (string, bool) { return expressionVariableSymbol(projectionItem.Expression) } +func projectionItemVariableSymbolAndAlias(expression cypher.Expression) (string, string, bool) { + projectionItem, ok := expression.(*cypher.ProjectionItem) + if !ok || projectionItem == nil { + return "", "", false + } + + symbol, ok := expressionVariableSymbol(projectionItem.Expression) + if !ok { + return "", "", false + } + + alias := symbol + if projectionItem.Alias != nil { + if projectionItem.Alias.Symbol == "" { + return "", "", false + } + + alias = projectionItem.Alias.Symbol + } + + return symbol, alias, true +} + func expressionVariableSymbol(expression cypher.Expression) (string, bool) { variable, ok := expression.(*cypher.Variable) if !ok || variable == nil || variable.Symbol == "" { diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index 0dbdd2b2..2eab75a4 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -816,6 +816,31 @@ func TestLoweringPlanReportsAggregateTraversalCountForRowCount(t *testing.T) { require.Equal(t, "adminCount", plan.LoweringPlan.AggregateTraversalCount[0].CountAlias) } +func TestLoweringPlanReportsAggregateTraversalCountWhenReturningCountAlias(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH (u:User) + WHERE u.hasspn = true + MATCH (u)-[:MemberOf|AdminTo*1..]->(c:Computer) + WITH DISTINCT u, COUNT(c) AS adminCount + RETURN u AS user, adminCount AS privileges + ORDER BY privileges DESC + LIMIT 100 + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Contains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringAggregateTraversalCount}) + + shape, ok := AggregateTraversalCountShapeForQuery(plan.Query) + require.True(t, ok) + require.Equal(t, "user", shape.ReturnSourceAlias) + require.True(t, shape.ReturnCount) + require.Equal(t, "privileges", shape.ReturnCountAlias) +} + func TestLoweringPlanSkipsSuffixPushdownAfterRightEndpointPredicateDirectionFlip(t *testing.T) { t.Parallel() diff --git a/cypher/models/pgsql/translate/aggregate_traversal_count.go b/cypher/models/pgsql/translate/aggregate_traversal_count.go index e994b70a..a7e2e58c 100644 --- a/cypher/models/pgsql/translate/aggregate_traversal_count.go +++ b/cypher/models/pgsql/translate/aggregate_traversal_count.go @@ -70,6 +70,19 @@ func (s *Translator) aggregateTraversalCountQuery(shape optimize.AggregateTraver return pgsql.Query{}, err } + projection := pgsql.Projection{ + pgsql.AliasedExpression{ + Expression: aggregateNodeComposite(aggregateSourceAlias), + Alias: pgsql.AsOptionalIdentifier(pgsql.Identifier(shape.ReturnSourceAlias)), + }, + } + if shape.ReturnCount { + projection = append(projection, pgsql.AliasedExpression{ + Expression: pgsql.CompoundIdentifier{aggregateRankedCTE, pgsql.Identifier(shape.CountAlias)}, + Alias: pgsql.AsOptionalIdentifier(pgsql.Identifier(shape.ReturnCountAlias)), + }) + } + return pgsql.Query{ CommonTableExpressions: &pgsql.With{ Recursive: true, @@ -82,12 +95,7 @@ func (s *Translator) aggregateTraversalCountQuery(shape optimize.AggregateTraver }, }, Body: pgsql.Select{ - Projection: pgsql.Projection{ - pgsql.AliasedExpression{ - Expression: aggregateNodeComposite(aggregateSourceAlias), - Alias: pgsql.AsOptionalIdentifier(pgsql.Identifier(shape.SourceSymbol)), - }, - }, + Projection: projection, From: []pgsql.FromClause{{ Source: pgsql.TableReference{ Name: aggregateRankedCTE.AsCompoundIdentifier(), diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index df707720..ea2242a0 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -711,6 +711,28 @@ LIMIT 100 require.Contains(t, normalizedQuery, "e.end_id = traversal.next_id") } +func TestOptimizerSafetyAggregateTraversalCountReturnsCountAlias(t *testing.T) { + t.Parallel() + + translation := optimizerSafetyTranslation(t, ` +MATCH (u:User) +WHERE u.hasspn = true +MATCH (u)-[:MemberOf|AdminTo*1..]->(c:Computer) +WITH DISTINCT u, COUNT(c) AS adminCount +RETURN u AS user, adminCount AS privileges +ORDER BY privileges DESC +LIMIT 100 + `) + formattedQuery, err := Translated(translation) + require.NoError(t, err) + normalizedQuery := strings.Join(strings.Fields(strings.ToLower(formattedQuery)), " ") + + requireOptimizationLowering(t, translation.Optimization, optimize.LoweringAggregateTraversalCount) + require.Contains(t, normalizedQuery, "(source_node.id, source_node.kind_ids, source_node.properties)::nodecomposite as user") + require.Contains(t, normalizedQuery, "ranked.admincount as privileges") + require.Contains(t, normalizedQuery, "order by ranked.admincount desc") +} + func TestOptimizerSafetyAggregateTraversalCountSkipsUnsafeWideningCandidates(t *testing.T) { t.Parallel() From 0068c3ecca832aa6f216f943d8a8b701206e2b45 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 14:20:46 -0700 Subject: [PATCH 085/116] Carry selectivity through traversal lowerings --- cypher/models/pgsql/optimize/lowering_plan.go | 151 +++++++++++++++--- .../models/pgsql/optimize/optimizer_test.go | 55 +++++++ .../pgsql/translate/optimizer_safety_test.go | 17 ++ 3 files changed, 197 insertions(+), 26 deletions(-) diff --git a/cypher/models/pgsql/optimize/lowering_plan.go b/cypher/models/pgsql/optimize/lowering_plan.go index 82318d76..d9004c10 100644 --- a/cypher/models/pgsql/optimize/lowering_plan.go +++ b/cypher/models/pgsql/optimize/lowering_plan.go @@ -31,8 +31,11 @@ const ( const ( boundSourceSelectivityNone boundSourceSelectivity = iota + boundSourceSelectivityKindOnly boundSourceSelectivityPredicate boundSourceSelectivityUnique + boundSourceSelectivityLimited + boundSourceSelectivityTopN ) func BuildLoweringPlan(query *cypher.RegularQuery, predicateAttachments []PredicateAttachment) (LoweringPlan, error) { @@ -43,23 +46,28 @@ func BuildLoweringPlan(query *cypher.RegularQuery, predicateAttachments []Predic var plan LoweringPlan if query.SingleQuery.MultiPartQuery != nil { + carriedSymbols := map[string]struct{}{} + carriedSelectivity := map[string]boundSourceSelectivity{} + for queryPartIndex, part := range query.SingleQuery.MultiPartQuery.Parts { if part == nil { continue } - if err := appendQueryPartLowerings(&plan, queryPartIndex, part, part.ReadingClauses, predicateAttachments); err != nil { + if err := appendQueryPartLowerings(&plan, queryPartIndex, part, part.ReadingClauses, predicateAttachments, carriedSymbols, carriedSelectivity); err != nil { return LoweringPlan{}, err } + + carriedSymbols, carriedSelectivity = carryProjectionSelectivity(part.With.Projection, carriedSelectivity) } if finalPart := query.SingleQuery.MultiPartQuery.SinglePartQuery; finalPart != nil { - if err := appendQueryPartLowerings(&plan, len(query.SingleQuery.MultiPartQuery.Parts), finalPart, finalPart.ReadingClauses, predicateAttachments); err != nil { + if err := appendQueryPartLowerings(&plan, len(query.SingleQuery.MultiPartQuery.Parts), finalPart, finalPart.ReadingClauses, predicateAttachments, carriedSymbols, carriedSelectivity); err != nil { return LoweringPlan{}, err } } } else if singlePart := query.SingleQuery.SinglePartQuery; singlePart != nil { - if err := appendQueryPartLowerings(&plan, 0, singlePart, singlePart.ReadingClauses, predicateAttachments); err != nil { + if err := appendQueryPartLowerings(&plan, 0, singlePart, singlePart.ReadingClauses, predicateAttachments, nil, nil); err != nil { return LoweringPlan{}, err } } @@ -77,6 +85,8 @@ func appendQueryPartLowerings( queryPart cypher.SyntaxNode, readingClauses []*cypher.ReadingClause, predicateAttachments []PredicateAttachment, + initialDeclaredSymbols map[string]struct{}, + initialSelectivity map[string]boundSourceSelectivity, ) error { sourceReferences, err := collectReferencedSourceIdentifiers(queryPart) if err != nil { @@ -88,7 +98,7 @@ func appendQueryPartLowerings( appendPatternPredicateProjectionLowerings(plan, queryPartIndex, queryPart, sourceReferences) appendPatternPredicatePlacementDecisions(plan, queryPartIndex, queryPart) appendExpandIntoDecisions(plan, queryPartIndex, readingClauses) - appendTraversalDirectionDecisions(plan, queryPartIndex, readingClauses, bindingPredicateSymbols(predicateAttachments, queryPartIndex)) + appendTraversalDirectionDecisions(plan, queryPartIndex, readingClauses, bindingPredicateSymbols(predicateAttachments, queryPartIndex), initialDeclaredSymbols, initialSelectivity) shortestPathSearchSymbols := shortestPathSearchPredicateSymbols(readingClauses) appendShortestPathStrategyDecisions(plan, queryPartIndex, readingClauses, shortestPathSearchSymbols) appendShortestPathFilterDecisions(plan, queryPartIndex, readingClauses, shortestPathSearchSymbols) @@ -335,9 +345,16 @@ func declaredSymbolsBeforeStepEndpoints(initial map[string]struct{}, steps []sou return endpoints } -func appendTraversalDirectionDecisions(plan *LoweringPlan, queryPartIndex int, readingClauses []*cypher.ReadingClause, predicateConstrainedSymbols map[string]struct{}) { - declaredSymbols := map[string]struct{}{} - declaredSourceSelectivity := map[string]boundSourceSelectivity{} +func appendTraversalDirectionDecisions( + plan *LoweringPlan, + queryPartIndex int, + readingClauses []*cypher.ReadingClause, + predicateConstrainedSymbols map[string]struct{}, + initialDeclaredSymbols map[string]struct{}, + initialSelectivity map[string]boundSourceSelectivity, +) { + declaredSymbols := copyStringSet(initialDeclaredSymbols) + declaredSourceSelectivity := copyBoundSourceSelectivity(initialSelectivity) for clauseIndex, readingClause := range readingClauses { if readingClause == nil || readingClause.Match == nil { @@ -378,6 +395,7 @@ func appendTraversalDirectionDecisions(plan *LoweringPlan, queryPartIndex int, r step, declaredEndpoints[stepIndex], referencesSourceIdentifier(predicateConstrainedSymbols, variableSymbol(step.RightNode.Variable)), + nodePatternSelectivity(step.RightNode, referencesSourceIdentifier(predicateConstrainedSymbols, variableSymbol(step.RightNode.Variable))), declaredSourceSelectivity[variableSymbol(step.LeftNode.Variable)], ); shouldFlip { plan.TraversalDirection = append(plan.TraversalDirection, decision) @@ -408,6 +426,78 @@ func bindingPredicateSymbols(predicateAttachments []PredicateAttachment, queryPa return symbols } +func copyBoundSourceSelectivity(values map[string]boundSourceSelectivity) map[string]boundSourceSelectivity { + copied := make(map[string]boundSourceSelectivity, len(values)) + for key, value := range values { + copied[key] = value + } + + return copied +} + +func carryProjectionSelectivity(projection *cypher.Projection, incoming map[string]boundSourceSelectivity) (map[string]struct{}, map[string]boundSourceSelectivity) { + carriedSymbols := map[string]struct{}{} + carriedSelectivity := map[string]boundSourceSelectivity{} + + if projection == nil { + return carriedSymbols, carriedSelectivity + } + + projectionSelectivity := projectionCardinalitySelectivity(projection) + for _, item := range projection.Items { + symbol, alias, ok := projectionItemVariableSymbolAndAlias(item) + if !ok { + continue + } + + addSymbol(carriedSymbols, alias) + mergeBoundSourceSelectivity(carriedSelectivity, alias, incoming[symbol]) + mergeBoundSourceSelectivity(carriedSelectivity, alias, projectionSelectivity) + } + + return carriedSymbols, carriedSelectivity +} + +func projectionCardinalitySelectivity(projection *cypher.Projection) boundSourceSelectivity { + if projection == nil || projection.Limit == nil { + return boundSourceSelectivityNone + } + + if projection.Order != nil || projectionHasAggregate(projection) { + return boundSourceSelectivityTopN + } + + return boundSourceSelectivityLimited +} + +func projectionHasAggregate(projection *cypher.Projection) bool { + if projection == nil { + return false + } + + for _, item := range projection.Items { + projectionItem, ok := item.(*cypher.ProjectionItem) + if !ok || projectionItem == nil { + continue + } + + if expressionHasAggregate(projectionItem.Expression) { + return true + } + } + + return false +} + +func expressionHasAggregate(expression cypher.Expression) bool { + switch typedExpression := expression.(type) { + case *cypher.FunctionInvocation: + return typedExpression != nil && strings.EqualFold(typedExpression.Name, cypher.CountFunction) + default: + return false + } +} + func declareSelectiveMatchSymbols(symbols map[string]boundSourceSelectivity, match *cypher.Match) { if match == nil { return @@ -505,7 +595,7 @@ func propertySelectivity(property string, value cypher.Expression) boundSourceSe return boundSourceSelectivityUnique } - if expressionIsStringLikeConstant(value) { + if expressionIsConstant(value) { return boundSourceSelectivityPredicate } @@ -513,23 +603,9 @@ func propertySelectivity(property string, value cypher.Expression) boundSourceSe } func expressionIsConstant(expression cypher.Expression) bool { - switch expression.(type) { - case *cypher.Literal, *cypher.Parameter: - return true - default: - return false - } -} - -func expressionIsStringLikeConstant(expression cypher.Expression) bool { switch typedExpression := expression.(type) { case *cypher.Literal: - if typedExpression == nil || typedExpression.Null { - return false - } - - _, isString := typedExpression.Value.(string) - return isString + return typedExpression != nil && !typedExpression.Null case *cypher.Parameter: return typedExpression != nil default: @@ -555,6 +631,30 @@ func nodePatternHasUniquePropertyConstraint(nodePattern *cypher.NodePattern) boo return nodePattern != nil && propertyConstraintSelectivity(nodePattern.Properties) == boundSourceSelectivityUnique } +func nodePatternSelectivity(nodePattern *cypher.NodePattern, hasAttachedPredicate bool) boundSourceSelectivity { + if nodePattern == nil { + return boundSourceSelectivityNone + } + + selectivity := boundSourceSelectivityNone + if len(nodePattern.Kinds) > 0 { + selectivity = boundSourceSelectivityKindOnly + } + + mergeSelectivityValue(&selectivity, propertyConstraintSelectivity(nodePattern.Properties)) + if hasAttachedPredicate { + mergeSelectivityValue(&selectivity, boundSourceSelectivityPredicate) + } + + return selectivity +} + +func mergeSelectivityValue(current *boundSourceSelectivity, next boundSourceSelectivity) { + if next > *current { + *current = next + } +} + func shortestPathSearchPredicateSymbols(readingClauses []*cypher.ReadingClause) map[string]struct{} { symbols := map[string]struct{}{} @@ -706,6 +806,7 @@ func boundLeftExpansionDirectionDecisionForStep( step sourceTraversalStep, declaredEndpoints declaredStepEndpoints, rightHasAttachedPredicate bool, + rightSelectivity boundSourceSelectivity, leftSourceSelectivity boundSourceSelectivity, ) (TraversalDirectionDecision, bool) { if patternPart == nil || @@ -739,9 +840,7 @@ func boundLeftExpansionDirectionDecisionForStep( } } - if leftSourceSelectivity == boundSourceSelectivityUnique && - !nodePatternHasUniquePropertyConstraint(step.RightNode) && - !rightHasAttachedPredicate { + if leftSourceSelectivity >= boundSourceSelectivityUnique && rightSelectivity < boundSourceSelectivityUnique { return TraversalDirectionDecision{ Target: target, Reason: traversalDirectionReasonBoundSourceSelective, diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index 2eab75a4..dedf5ebd 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -754,6 +754,61 @@ func TestLoweringPlanSkipsBoundLeftDirectionForSelectiveSource(t *testing.T) { }}, plan.LoweringPlan.TraversalDirection) } +func TestLoweringPlanSkipsBoundLeftDirectionAfterPriorLimit(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH (u:User) + WHERE u.hasspn = true + WITH u + LIMIT 10 + MATCH (u)-[:MemberOf|AdminTo*1..]->(c:Computer {name: 'target'}) + RETURN c + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Contains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringTraversalDirection}) + require.Equal(t, []TraversalDirectionDecision{{ + Target: TraversalStepTarget{ + QueryPartIndex: 1, + ClauseIndex: 0, + PatternIndex: 0, + StepIndex: 0, + }, + Reason: traversalDirectionReasonBoundSourceSelective, + }}, plan.LoweringPlan.TraversalDirection) +} + +func TestLoweringPlanAllowsUniqueRightEndpointAfterPriorLimit(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH (u:User) + WHERE u.hasspn = true + WITH u + LIMIT 10 + MATCH (u)-[:MemberOf|AdminTo*1..]->(c:Computer {objectid: 'S-1-5-21-1-2000'}) + RETURN c + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Contains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringTraversalDirection}) + require.Equal(t, []TraversalDirectionDecision{{ + Target: TraversalStepTarget{ + QueryPartIndex: 1, + ClauseIndex: 0, + PatternIndex: 0, + StepIndex: 0, + }, + Flip: true, + Reason: traversalDirectionReasonRightConstrained, + }}, plan.LoweringPlan.TraversalDirection) +} + func TestLoweringPlanReportsAggregateTraversalCountForBoundExpansionCount(t *testing.T) { t.Parallel() diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index ea2242a0..deeccb11 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -647,6 +647,23 @@ RETURN c requireSkippedOptimizationLowering(t, translation.Optimization, "TraversalDirectionSelection", "bound source estimate selective") } +func TestOptimizerSafetyTraversalDirectionReportsPriorLimitSourceSkip(t *testing.T) { + t.Parallel() + + translation := optimizerSafetyTranslation(t, ` +MATCH (u:User) +WHERE u.hasspn = true +WITH u +LIMIT 10 +MATCH (u)-[:MemberOf|AdminTo*1..]->(c:Computer {name: 'target'}) +RETURN c + `) + + requirePlannedOptimizationLowering(t, translation.Optimization, "TraversalDirectionSelection") + requireNoOptimizationLowering(t, translation.Optimization, "TraversalDirectionSelection") + requireSkippedOptimizationLowering(t, translation.Optimization, "TraversalDirectionSelection", "bound source estimate selective") +} + func TestOptimizerSafetyAggregateTraversalCountAcceptsRowCount(t *testing.T) { t.Parallel() From b9f7b4b237be361048203ba184def2c7450bebfb Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 14:22:20 -0700 Subject: [PATCH 086/116] Fold terminal filters into aggregate traversal --- cypher/models/pgsql/optimize/lowering.go | 1 + cypher/models/pgsql/optimize/lowering_plan.go | 25 ++++++++---- .../models/pgsql/optimize/optimizer_test.go | 40 +++++++++++++++++++ .../translate/aggregate_traversal_count.go | 32 +++++++++++---- .../pgsql/translate/optimizer_safety_test.go | 35 ++++++++++++++++ 5 files changed, 118 insertions(+), 15 deletions(-) diff --git a/cypher/models/pgsql/optimize/lowering.go b/cypher/models/pgsql/optimize/lowering.go index 269405cb..c4873145 100644 --- a/cypher/models/pgsql/optimize/lowering.go +++ b/cypher/models/pgsql/optimize/lowering.go @@ -183,6 +183,7 @@ type AggregateTraversalCountShape struct { ReturnCount bool Limit int64 SourceMatch *cypher.Match + TerminalMatch *cypher.Match SourceKinds graph.Kinds TerminalKinds graph.Kinds RelationshipKinds graph.Kinds diff --git a/cypher/models/pgsql/optimize/lowering_plan.go b/cypher/models/pgsql/optimize/lowering_plan.go index d9004c10..5c3b5556 100644 --- a/cypher/models/pgsql/optimize/lowering_plan.go +++ b/cypher/models/pgsql/optimize/lowering_plan.go @@ -1303,7 +1303,7 @@ func AggregateTraversalCountShapeForQuery(query *cypher.RegularQuery) (Aggregate return AggregateTraversalCountShape{}, false } - relationship, terminalNode, terminalSymbol, ok := aggregateTraversalMatch(part.ReadingClauses[1], sourceSymbol) + terminalMatch, relationship, terminalNode, terminalSymbol, ok := aggregateTraversalMatch(part.ReadingClauses[1], sourceSymbol) if !ok { return AggregateTraversalCountShape{}, false } @@ -1333,6 +1333,7 @@ func AggregateTraversalCountShapeForQuery(query *cypher.RegularQuery) (Aggregate ReturnCount: finalProjection.ReturnCount, Limit: finalProjection.Limit, SourceMatch: sourceMatch, + TerminalMatch: terminalMatch, SourceKinds: sourceNode.Kinds, TerminalKinds: terminalNode.Kinds, RelationshipKinds: relationship.Kinds, @@ -1373,19 +1374,19 @@ func aggregateTraversalSourceMatch(readingClause *cypher.ReadingClause) (*cypher return match, nodePattern, nodePattern.Variable.Symbol, true } -func aggregateTraversalMatch(readingClause *cypher.ReadingClause, sourceSymbol string) (*cypher.RelationshipPattern, *cypher.NodePattern, string, bool) { +func aggregateTraversalMatch(readingClause *cypher.ReadingClause, sourceSymbol string) (*cypher.Match, *cypher.RelationshipPattern, *cypher.NodePattern, string, bool) { if readingClause == nil || readingClause.Match == nil { - return nil, nil, "", false + return nil, nil, nil, "", false } match := readingClause.Match - if match.Optional || match.Where != nil || len(match.Pattern) != 1 { - return nil, nil, "", false + if match.Optional || len(match.Pattern) != 1 { + return nil, nil, nil, "", false } patternPart := match.Pattern[0] if patternPart == nil || patternPart.Variable != nil || patternPart.ShortestPathPattern || patternPart.AllShortestPathsPattern || len(patternPart.PatternElements) != 3 { - return nil, nil, "", false + return nil, nil, nil, "", false } leftNode, leftOK := patternPart.PatternElements[0].AsNodePattern() @@ -1402,10 +1403,18 @@ func aggregateTraversalMatch(readingClause *cypher.ReadingClause, sourceSymbol s rightNode.Properties != nil || rightNode.Variable == nil || rightNode.Variable.Symbol == "" { - return nil, nil, "", false + return nil, nil, nil, "", false + } + + if match.Where != nil { + for _, dependency := range sortedDependencies(match.Where) { + if dependency != rightNode.Variable.Symbol { + return nil, nil, nil, "", false + } + } } - return relationship, rightNode, rightNode.Variable.Symbol, true + return match, relationship, rightNode, rightNode.Variable.Symbol, true } func aggregateTraversalWithProjection(projection *cypher.Projection, sourceSymbol, terminalSymbol string) (string, bool) { diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index dedf5ebd..3a31ace8 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -896,6 +896,46 @@ func TestLoweringPlanReportsAggregateTraversalCountWhenReturningCountAlias(t *te require.Equal(t, "privileges", shape.ReturnCountAlias) } +func TestLoweringPlanReportsAggregateTraversalCountWithTerminalFilter(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH (u:User) + WHERE u.hasspn = true + MATCH (u)-[:MemberOf|AdminTo*1..]->(c:Computer) + WHERE c.enabled = true + WITH DISTINCT u, COUNT(c) AS adminCount + RETURN u + ORDER BY adminCount DESC + LIMIT 100 + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Contains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringAggregateTraversalCount}) +} + +func TestLoweringPlanSkipsAggregateTraversalCountWithCorrelatedTerminalFilter(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH (u:User) + WHERE u.hasspn = true + MATCH (u)-[:MemberOf|AdminTo*1..]->(c:Computer) + WHERE c.name = u.name + WITH DISTINCT u, COUNT(c) AS adminCount + RETURN u + ORDER BY adminCount DESC + LIMIT 100 + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.NotContains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringAggregateTraversalCount}) +} + func TestLoweringPlanSkipsSuffixPushdownAfterRightEndpointPredicateDirectionFlip(t *testing.T) { t.Parallel() diff --git a/cypher/models/pgsql/translate/aggregate_traversal_count.go b/cypher/models/pgsql/translate/aggregate_traversal_count.go index a7e2e58c..28280573 100644 --- a/cypher/models/pgsql/translate/aggregate_traversal_count.go +++ b/cypher/models/pgsql/translate/aggregate_traversal_count.go @@ -328,7 +328,7 @@ func (s *Translator) buildAggregateTerminalHitsCTE(shape optimize.AggregateTrave } func (s *Translator) buildAggregateTerminalNodesCTE(shape optimize.AggregateTraversalCountShape) (pgsql.CommonTableExpression, error) { - terminalWhere, err := s.aggregateNodeKindConstraint(aggregateTerminalAlias, shape.TerminalKinds) + terminalWhere, err := s.aggregateTerminalWhere(shape) if err != nil { return pgsql.CommonTableExpression{}, err } @@ -412,20 +412,38 @@ func (s *Translator) aggregateSourceWhere(shape optimize.AggregateTraversalCount return pgsql.OptionalAnd(sourcePredicate, sourceKindConstraint), nil } +func (s *Translator) aggregateTerminalWhere(shape optimize.AggregateTraversalCountShape) (pgsql.Expression, error) { + terminalKindConstraint, err := s.aggregateNodeKindConstraint(aggregateTerminalAlias, shape.TerminalKinds) + if err != nil { + return nil, err + } + + terminalPredicate, err := s.aggregateBindingPredicate(shape.TerminalMatch, shape.TerminalSymbol, aggregateTerminalAlias) + if err != nil { + return nil, err + } + + return pgsql.OptionalAnd(terminalPredicate, terminalKindConstraint), nil +} + func (s *Translator) aggregateSourcePredicate(shape optimize.AggregateTraversalCountShape) (pgsql.Expression, error) { - if shape.SourceMatch == nil || shape.SourceMatch.Where == nil { + return s.aggregateBindingPredicate(shape.SourceMatch, shape.SourceSymbol, aggregateSourceAlias) +} + +func (s *Translator) aggregateBindingPredicate(match *cypher.Match, symbol string, alias pgsql.Identifier) (pgsql.Expression, error) { + if match == nil || match.Where == nil { return nil, nil } translator := NewTranslator(s.ctx, s.kindMapper.kindMapper, s.parameters, s.graphID) - sourceBinding := translator.scope.Define(aggregateSourceAlias, pgsql.NodeComposite) - translator.scope.Alias(pgsql.Identifier(shape.SourceSymbol), sourceBinding) + binding := translator.scope.Define(alias, pgsql.NodeComposite) + translator.scope.Alias(pgsql.Identifier(symbol), binding) - if err := walk.Cypher(shape.SourceMatch.Where, translator); err != nil { + if err := walk.Cypher(match.Where, translator); err != nil { return nil, err } - sourceConstraints, err := translator.treeTranslator.ConsumeConstraintsFromVisibleSet(pgsql.AsIdentifierSet(aggregateSourceAlias)) + sourceConstraints, err := translator.treeTranslator.ConsumeConstraintsFromVisibleSet(pgsql.AsIdentifierSet(alias)) if err != nil { return nil, err } @@ -435,7 +453,7 @@ func (s *Translator) aggregateSourcePredicate(shape optimize.AggregateTraversalC return nil, err } if remainingConstraints.Expression != nil { - return nil, fmt.Errorf("unsupported aggregate traversal source predicate dependencies: %v", remainingConstraints.Dependencies.Slice()) + return nil, fmt.Errorf("unsupported aggregate traversal predicate dependencies: %v", remainingConstraints.Dependencies.Slice()) } for key, value := range translator.translation.Parameters { diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index deeccb11..6f321294 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -750,6 +750,29 @@ LIMIT 100 require.Contains(t, normalizedQuery, "order by ranked.admincount desc") } +func TestOptimizerSafetyAggregateTraversalCountFoldsTerminalFilter(t *testing.T) { + t.Parallel() + + translation := optimizerSafetyTranslation(t, ` +MATCH (u:User) +WHERE u.hasspn = true +MATCH (u)-[:MemberOf|AdminTo*1..]->(c:Computer) +WHERE c.enabled = true +WITH DISTINCT u, COUNT(c) AS adminCount +RETURN u +ORDER BY adminCount DESC +LIMIT 100 + `) + formattedQuery, err := Translated(translation) + require.NoError(t, err) + normalizedQuery := strings.Join(strings.Fields(strings.ToLower(formattedQuery)), " ") + + requireOptimizationLowering(t, translation.Optimization, optimize.LoweringAggregateTraversalCount) + require.Contains(t, normalizedQuery, "terminal_nodes(id) as materialized") + require.Contains(t, normalizedQuery, "terminal_node.properties -> 'enabled'") + require.Contains(t, normalizedQuery, "join terminal_nodes on terminal_nodes.id = traversal.next_id") +} + func TestOptimizerSafetyAggregateTraversalCountSkipsUnsafeWideningCandidates(t *testing.T) { t.Parallel() @@ -798,6 +821,18 @@ MATCH (u)-[r:MemberOf|AdminTo*1..]->(c:Computer) WITH DISTINCT u, r, COUNT(c) AS adminCount RETURN u, r ORDER BY adminCount DESC +LIMIT 100 + `, + }, { + name: "correlated terminal filter", + query: ` +MATCH (u:User) +WHERE u.hasspn = true +MATCH (u)-[:MemberOf|AdminTo*1..]->(c:Computer) +WHERE c.name = u.name +WITH DISTINCT u, COUNT(c) AS adminCount +RETURN u +ORDER BY adminCount DESC LIMIT 100 `, }, { From 74e6b2bbb5575daf6912c8ae83d35e083820eed5 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 14:32:20 -0700 Subject: [PATCH 087/116] Document aggregate optimizer continuation status --- optimization_continuation.md | 224 +++++++++++++++++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 optimization_continuation.md diff --git a/optimization_continuation.md b/optimization_continuation.md new file mode 100644 index 00000000..f4d32729 --- /dev/null +++ b/optimization_continuation.md @@ -0,0 +1,224 @@ +# Optimizer Continuation Status: Aggregate Traversal Shape + +## Current State + +The aggregate traversal optimization plan has been implemented through the latest widening and selectivity work. The +work is split into focused commits: + +- `9c5232c` added the initial `AggregateTraversalCount` lowering and live-query optimization work. +- `94b7d78` added a guarded PostgreSQL live-plan assertion for the aggregate traversal shape. +- `51fe99c` records skipped traversal-direction diagnostics for kind-only terminal estimates. +- `61f039d` widens aggregate traversal matching to equivalent `COUNT(*)` row-count forms. +- `7f62b68` treats uniquely constrained bound sources, such as `objectid = ...`, as selective traversal anchors. +- `97c3ffa` expands aggregate traversal baseline coverage for explicit depth bounds, inbound source-left traversal, and + unsafe non-lowering shapes. +- `835a26f` widens final aggregate projections to preserve both the source node and the aggregate count with aliases. +- `0068c3e` carries source selectivity through multipart `WITH` projections, `LIMIT`, and top-N operations. +- `b9f7b4b` folds terminal-local filters into the aggregate traversal terminal-node materialization. + +The lowering now recognizes the kerberoastable aggregate family and emits an ID-only, source-anchored recursive CTE. The +emitted SQL: + +- builds source candidates as IDs; +- traverses with `root_id`, `next_id`, `depth`, and edge-ID `path`; +- uses source-anchored edge index access; +- materializes terminal node IDs once, including terminal-local predicates when present; +- groups by `root_id`; +- applies top-N before rejoining source node composites; +- can return the source node alone or the source node plus the aggregate count. + +The optimizer still keeps unsafe aggregate variants out of this lowering: + +- `COUNT(DISTINCT terminal)`; +- `OPTIONAL MATCH`; +- observed terminal projection or reuse beyond the aggregate count; +- path projection or path functions; +- relationship projection, relationship predicates, or relationship reuse; +- correlated terminal filters, such as `terminal.name = source.name`; +- post-aggregation predicates that depend on the count. + +## Latest Validation Evidence + +The current implementation has passing unit and PostgreSQL integration coverage: + +```bash +go test ./cypher/models/pgsql/... -count=1 +make test +CONNECTION_STRING='postgres://...' make test_integration +``` + +The full PostgreSQL integration run completed successfully. The `integration` package took about `351.7s`. + +`make format` still fails in this environment because `goimports` is unavailable or not executable: + +```text +xargs: goimports: Permission denied +``` + +Touched Go files were formatted with `gofmt`. + +## Latest Plan Comparison + +After the integration suite, the PostgreSQL database no longer looked like the restored large live aggregate dataset to +the guarded aggregate assertion: + +```text +candidateUsers:0 computers:0 adminEdges:0 +``` + +The guarded assertion therefore skipped rather than producing a large-live-dataset plan verdict. The comparison runner +still completed successfully against the current PostgreSQL and Neo4j databases and refreshed +`.coverage/live-plan-comparison.md/json`. + +Current comparison-run timings: + +- `group_objectid_exact_string_equality`: PostgreSQL `0.2 ms`; Neo4j oracle shape `node-label-scan + top-or-limit`. +- `domain_admins_reverse_membership_source_disjunction`: PostgreSQL `101.8 ms`; Neo4j oracle shape + `directed-expand + node-label-scan + top-or-limit + var-length-expand`. +- `dangerous_domain_users_privileges_exclude_memberof`: PostgreSQL `99.7 ms`; Neo4j oracle shape + `directed-expand + top-or-limit`. +- `domain_admin_logons_exclude_domain_controllers`: PostgreSQL `141.6 ms`; Neo4j oracle shape + `directed-expand + top-or-limit + var-length-expand`. +- `kerberoastable_users_by_admin_privilege_count`: PostgreSQL `95.5 ms`; Neo4j oracle shape + `aggregation + directed-expand + node-label-scan + top-or-limit + var-length-expand`. +- `kerberoastable_users_left_label_guard`: PostgreSQL `104.6 ms`; Neo4j oracle shape + `aggregation + directed-expand + node-label-scan + top-or-limit + var-length-expand`. +- `shortest_path_domain_users_to_tier_zero`: PostgreSQL `201.0 ms`; Neo4j oracle shape + `node-label-scan + top-or-limit + var-length-expand`. +- `cross_forest_trusts_require_connected_abuse_edge`: PostgreSQL `0.1 ms`; Neo4j oracle shape + `anti-semi-apply + top-or-limit`. +- `azure_high_privileged_role_bounded_membership`: PostgreSQL `1.6 ms`; Neo4j oracle shape + `directed-expand + node-label-scan + top-or-limit + var-length-expand`. + +These numbers are smoke evidence for the current implementation. They should not replace the earlier large-live-dataset +baseline because the guarded assertion reported that the large aggregate dataset was not present. + +The earlier restored-live-dataset comparison remains the best large-data aggregate baseline: + +- `kerberoastable_users_by_admin_privilege_count`: `12025.1 ms` execution, `12030.6 ms` wall duration; +- `kerberoastable_users_left_label_guard`: `12376.4 ms` execution, `12379.5 ms` wall duration; +- source-anchored recursive traversal from filtered `User` candidates; +- `Hash Join` from traversal rows to materialized `terminal_nodes`; +- `HashAggregate` with `Group Key: traversal.root_id`; +- final source node primary-key lookups after the top-N stage. + +The original live cardinalities that motivated this work were: + +- candidate users after the property filter: about `222`; +- `Computer` nodes: about `139k`; +- `MemberOf|AdminTo` edges: about `2.09M`. + +## Neo4j Oracle + +The Neo4j oracle still commonly prefers label scans followed by `VarLengthExpand(All)`, eager aggregation, and top-N for +the aggregate shapes. That remains useful as an operator-order comparison, but PostgreSQL should not mirror this shape +on the observed large data. The PostgreSQL-specific win comes from filtered source candidates, ID-only traversal, root-ID +aggregation, and deferred source materialization. + +The comparison runner should stay in place as the base for further oracle testing. + +## Completed Plan Items + +### 1. Aggregate Widening Baseline + +Completed in `97c3ffa`. + +Coverage now includes: + +- explicit variable-length depth bounds; +- inbound source-left traversal; +- unsafe non-lowering candidates for distinct counts, optional matches, path bindings, relationship bindings, and + post-aggregation filters. + +### 2. Final Projection Widening + +Completed in `835a26f`. + +The aggregate traversal shape now preserves: + +- source return alias; +- optional count return alias; +- final return forms that include only the source node or both source node and aggregate count. + +### 3. Broader Selectivity Heuristics + +Completed in `0068c3e`. + +The lowering planner now carries selectivity through multipart query parts with a graded model: + +- no useful selectivity; +- kind-only selectivity; +- property predicate selectivity; +- unique equality selectivity; +- limited source sets; +- top-N source sets. + +This prevents a prior limited or top-N source set from being flipped toward a broad terminal unless the terminal side is +also selective enough to justify the direction change. + +### 4. Terminal-Local Filter Folding + +Completed in `b9f7b4b`. + +Terminal-local `WHERE` predicates can now be folded into `terminal_nodes` when every referenced symbol belongs to the +terminal node. Correlated terminal filters remain excluded. + +### 5. Validation and Documentation + +Completed in this document update. + +Validation performed: + +- PostgreSQL model tests; +- unit test suite; +- PostgreSQL integration suite; +- guarded aggregate live-plan assertion, which skipped because the current PostgreSQL corpus did not match the restored + large live dataset; +- PostgreSQL/Neo4j comparison runner, which completed and refreshed ignored comparison artifacts. + +## Remaining Work + +### 1. Re-run Large-Live-Dataset Vetting After Reload + +The next live reload should rerun: + +```bash +CONNECTION_STRING='postgres://...' go test -v -tags manual_integration ./integration \ + -run TestPostgreSQLLiveAggregateTraversalCountPlanShape \ + -count=1 -parallel=1 -timeout=90s + +PG_CONNECTION_STRING='postgres://...' NEO4J_CONNECTION_STRING='neo4j://...' \ + LIVE_VET_TIMEOUT='30s' go run .coverage/live_plan_compare.go +``` + +Acceptance criteria: + +- the guarded assertion does not skip; +- the aggregate query completes under the statement timeout; +- the recursive CTE is source-anchored; +- grouping is by `root_id`, not node composites; +- source node materialization occurs after top-N limiting; +- terminal-local filters remain inside terminal-node materialization when the query shape allows it. + +### 2. Add Evidence Before Further Aggregate Widening + +The next widening candidates should require specific query-corpus examples and tests before implementation: + +- post-aggregation count predicates that can be safely converted to `HAVING`; +- multiple aggregate return aliases if a real query needs them; +- source-side filters that can be pushed into source candidate materialization after multipart rewrites. + +### 3. Keep Broader Selectivity Conservative + +The new selectivity model is intentionally coarse. Future broadening should be backed by live plan mismatches for: + +- known unique equality predicates beyond `objectid`; +- source candidates reduced by prior aggregation; +- property predicates with observed high selectivity; +- label/kind combinations whose cardinality is small enough to anchor traversal. + +### 4. Keep Schema Index Work Deferred + +Do not add default indexes yet. The measured large-live bottleneck was traversal and aggregation shape, not candidate-user +lookup. Revisit targeted expression or partial indexes only if refreshed live plans show source candidate filtering has +become the next dominant cost. From c2e311fbad80e279b482a90ee547b6c65015fdf7 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 17:21:02 -0700 Subject: [PATCH 088/116] Add graphbench scale corpus contract --- benchmark/testdata/scale/README.md | 9 + benchmark/testdata/scale/cases/counts.json | 70 ++++++ benchmark/testdata/scale/cases/lookups.json | 54 +++++ .../testdata/scale/cases/shortest_paths.json | 61 +++++ benchmark/testdata/scale/cases/traversal.json | 143 +++++++++++ cmd/graphbench/corpus.go | 121 ++++++++++ cmd/graphbench/corpus_test.go | 45 ++++ cmd/graphbench/main.go | 32 +++ cmd/graphbench/types.go | 104 ++++++++ optimization_continuation.md | 224 ------------------ 10 files changed, 639 insertions(+), 224 deletions(-) create mode 100644 benchmark/testdata/scale/README.md create mode 100644 benchmark/testdata/scale/cases/counts.json create mode 100644 benchmark/testdata/scale/cases/lookups.json create mode 100644 benchmark/testdata/scale/cases/shortest_paths.json create mode 100644 benchmark/testdata/scale/cases/traversal.json create mode 100644 cmd/graphbench/corpus.go create mode 100644 cmd/graphbench/corpus_test.go create mode 100644 cmd/graphbench/main.go create mode 100644 cmd/graphbench/types.go delete mode 100644 optimization_continuation.md diff --git a/benchmark/testdata/scale/README.md b/benchmark/testdata/scale/README.md new file mode 100644 index 00000000..487ce204 --- /dev/null +++ b/benchmark/testdata/scale/README.md @@ -0,0 +1,9 @@ +# GraphBench Scale Corpus + +This corpus measures graph workload shapes, not general Cypher correctness. +The shared integration corpus remains the source of backend-equivalent semantic coverage. + +Cases declare the values a query observes so benchmark reports can separate ID-only work from node, relationship, property, and path materialization. +Initial execution modes are `postgres_sql`, `local_traversal`, and `neo4j`. +Apache AGE is intentionally not a benchmark mode here; it may appear only in `reference_design` notes as input for DAWGS design choices. + diff --git a/benchmark/testdata/scale/cases/counts.json b/benchmark/testdata/scale/cases/counts.json new file mode 100644 index 00000000..37f93714 --- /dev/null +++ b/benchmark/testdata/scale/cases/counts.json @@ -0,0 +1,70 @@ +{ + "cases": [ + { + "name": "all_node_count", + "dataset": "base", + "category": "counts", + "cypher": "MATCH (n) RETURN count(n)", + "expected": { + "row_count": 1, + "result_kind": "scalar" + }, + "observes": { + "paths": false, + "nodes": false, + "relationships": false, + "properties": false + }, + "shape": { + "path_materialization_required": false + }, + "candidate_modes": ["postgres_sql", "neo4j"], + "tags": ["count", "count-store"] + }, + { + "name": "typed_node_count", + "dataset": "base", + "category": "counts", + "cypher": "MATCH (n:NodeKind1) RETURN count(n)", + "expected": { + "row_count": 1, + "result_kind": "scalar" + }, + "observes": { + "paths": false, + "nodes": false, + "relationships": false, + "properties": false + }, + "shape": { + "terminal_predicate": "node_kind", + "path_materialization_required": false + }, + "candidate_modes": ["postgres_sql", "neo4j"], + "tags": ["count", "typed-count", "graph-stats"] + }, + { + "name": "typed_edge_count", + "dataset": "base", + "category": "counts", + "cypher": "MATCH ()-[r:EdgeKind1]->() RETURN count(r)", + "expected": { + "row_count": 1, + "result_kind": "scalar" + }, + "observes": { + "paths": false, + "nodes": false, + "relationships": false, + "properties": false + }, + "shape": { + "edge_kinds": ["EdgeKind1"], + "path_materialization_required": false + }, + "candidate_modes": ["postgres_sql", "neo4j"], + "tags": ["count", "typed-count", "graph-stats"] + } + ] +} + diff --git a/benchmark/testdata/scale/cases/lookups.json b/benchmark/testdata/scale/cases/lookups.json new file mode 100644 index 00000000..724c1af8 --- /dev/null +++ b/benchmark/testdata/scale/cases/lookups.json @@ -0,0 +1,54 @@ +{ + "cases": [ + { + "name": "objectid_exact_string_anchor", + "dataset": "base", + "category": "lookups", + "cypher": "MATCH (n:NodeKind1) WHERE n.objectid = $objectid RETURN id(n)", + "params": { + "objectid": "S-1-5-21-1" + }, + "expected": { + "row_count": 1, + "result_kind": "id_set" + }, + "observes": { + "paths": false, + "nodes": false, + "relationships": false, + "properties": false + }, + "shape": { + "root_predicate": "selective_property", + "terminal_predicate": "node_kind", + "path_materialization_required": false + }, + "candidate_modes": ["postgres_sql", "neo4j"], + "tags": ["property-anchor", "expression-index"] + }, + { + "name": "boolean_property_filter", + "dataset": "base", + "category": "lookups", + "cypher": "MATCH (n:NodeKind1) WHERE n.enabled = true RETURN id(n)", + "expected": { + "row_count": 1, + "result_kind": "id_set" + }, + "observes": { + "paths": false, + "nodes": false, + "relationships": false, + "properties": false + }, + "shape": { + "root_predicate": "boolean_property", + "terminal_predicate": "node_kind", + "path_materialization_required": false + }, + "candidate_modes": ["postgres_sql", "neo4j"], + "tags": ["property-filter"] + } + ] +} + diff --git a/benchmark/testdata/scale/cases/shortest_paths.json b/benchmark/testdata/scale/cases/shortest_paths.json new file mode 100644 index 00000000..b9539b36 --- /dev/null +++ b/benchmark/testdata/scale/cases/shortest_paths.json @@ -0,0 +1,61 @@ +{ + "cases": [ + { + "name": "shortest_distance_bound_pair", + "dataset": "base", + "category": "shortest_path", + "cypher": "MATCH p = shortestPath((s)-[*1..]->(e)) WHERE id(s) = $start_id AND id(e) = $end_id RETURN length(p)", + "node_params": { + "start_id": "n1", + "end_id": "n3" + }, + "expected": { + "row_count": 1, + "result_kind": "scalar" + }, + "observes": { + "paths": false, + "nodes": false, + "relationships": false, + "properties": false + }, + "shape": { + "root_predicate": "bound_id", + "terminal_predicate": "bound_id", + "min_depth": 1, + "path_materialization_required": false + }, + "candidate_modes": ["postgres_sql", "local_traversal", "neo4j"], + "tags": ["shortest-distance", "local-traversal-candidate"] + }, + { + "name": "one_shortest_path_bound_pair", + "dataset": "base", + "category": "shortest_path", + "cypher": "MATCH p = shortestPath((s)-[*1..]->(e)) WHERE id(s) = $start_id AND id(e) = $end_id RETURN p LIMIT 1", + "node_params": { + "start_id": "n1", + "end_id": "n3" + }, + "expected": { + "row_count": 1, + "result_kind": "path_set" + }, + "observes": { + "paths": true, + "nodes": true, + "relationships": true, + "properties": true + }, + "shape": { + "root_predicate": "bound_id", + "terminal_predicate": "bound_id", + "min_depth": 1, + "path_materialization_required": true + }, + "candidate_modes": ["postgres_sql", "local_traversal", "neo4j"], + "tags": ["one-shortest-path", "local-traversal-candidate"] + } + ] +} + diff --git a/benchmark/testdata/scale/cases/traversal.json b/benchmark/testdata/scale/cases/traversal.json new file mode 100644 index 00000000..2bab928d --- /dev/null +++ b/benchmark/testdata/scale/cases/traversal.json @@ -0,0 +1,143 @@ +{ + "cases": [ + { + "name": "one_hop_typed_from_bound_id", + "dataset": "base", + "category": "one_hop", + "cypher": "MATCH (s)-[:EdgeKind1]->(e) WHERE id(s) = $start_id RETURN id(e)", + "node_params": { + "start_id": "n1" + }, + "expected": { + "row_count": 1, + "result_kind": "id_set" + }, + "observes": { + "paths": false, + "nodes": false, + "relationships": false, + "properties": false + }, + "shape": { + "root_predicate": "bound_id", + "edge_kinds": ["EdgeKind1"], + "path_materialization_required": false + }, + "candidate_modes": ["postgres_sql", "neo4j"], + "tags": ["typed-expansion", "id-only"] + }, + { + "name": "variable_length_id_only_from_bound_id", + "dataset": "base", + "category": "variable_length_reachability", + "cypher": "MATCH (s)-[*1..]->(e) WHERE id(s) = $start_id RETURN id(e)", + "node_params": { + "start_id": "n1" + }, + "expected": { + "row_count": 2, + "result_kind": "id_set" + }, + "observes": { + "paths": false, + "nodes": false, + "relationships": false, + "properties": false + }, + "shape": { + "root_predicate": "bound_id", + "min_depth": 1, + "path_materialization_required": false + }, + "candidate_modes": ["postgres_sql", "local_traversal", "neo4j"], + "tags": ["reachability", "id-only", "local-traversal-candidate"], + "reference_design": { + "age_relevance": ["vle_cost_model"], + "notes": "AGE VLE behavior is useful design context for cycle and duplicate handling, but this case is not run against AGE." + } + }, + { + "name": "variable_length_path_observed_from_bound_id", + "dataset": "base", + "category": "path_observed_variable_length", + "cypher": "MATCH p = (s)-[*1..]->(e) WHERE id(s) = $start_id RETURN p", + "node_params": { + "start_id": "n1" + }, + "expected": { + "row_count": 2, + "result_kind": "path_set" + }, + "observes": { + "paths": true, + "nodes": true, + "relationships": true, + "properties": true + }, + "shape": { + "root_predicate": "bound_id", + "min_depth": 1, + "path_materialization_required": true + }, + "candidate_modes": ["postgres_sql", "neo4j"], + "tags": ["path-materialization"] + }, + { + "name": "adcs_p1_endpoint_ids", + "dataset": "adcs_fanout", + "category": "bloodhound_search", + "cypher": "MATCH (n:Group) WHERE n.objectid = $objectid MATCH (n)-[:MemberOf*0..]->()-[:Enroll]->(ca:EnterpriseCA)-[:TrustedForNTAuth]->(:NTAuthStore)-[:NTAuthStoreFor]->(d:Domain) RETURN id(ca), id(d)", + "params": { + "objectid": "S-1-5-21-2643190041-1319121918-239771340-513" + }, + "expected": { + "row_count": 4, + "result_kind": "id_rows" + }, + "observes": { + "paths": false, + "nodes": false, + "relationships": false, + "properties": false + }, + "shape": { + "root_predicate": "selective_property", + "terminal_predicate": "fixed_suffix", + "edge_kinds": ["MemberOf", "Enroll", "TrustedForNTAuth", "NTAuthStoreFor"], + "min_depth": 0, + "path_materialization_required": false + }, + "candidate_modes": ["postgres_sql", "local_traversal", "neo4j"], + "tags": ["bloodhound", "adcs", "id-only", "local-traversal-candidate"] + }, + { + "name": "adcs_p1_path_observed", + "dataset": "adcs_fanout", + "category": "bloodhound_search", + "cypher": "MATCH (n:Group) WHERE n.objectid = $objectid MATCH p = (n)-[:MemberOf*0..]->()-[:Enroll]->(ca:EnterpriseCA)-[:TrustedForNTAuth]->(:NTAuthStore)-[:NTAuthStoreFor]->(d:Domain) RETURN p", + "params": { + "objectid": "S-1-5-21-2643190041-1319121918-239771340-513" + }, + "expected": { + "row_count": 4, + "result_kind": "path_set" + }, + "observes": { + "paths": true, + "nodes": true, + "relationships": true, + "properties": true + }, + "shape": { + "root_predicate": "selective_property", + "terminal_predicate": "fixed_suffix", + "edge_kinds": ["MemberOf", "Enroll", "TrustedForNTAuth", "NTAuthStoreFor"], + "min_depth": 0, + "path_materialization_required": true + }, + "candidate_modes": ["postgres_sql", "neo4j"], + "tags": ["bloodhound", "adcs", "path-materialization"] + } + ] +} + diff --git a/cmd/graphbench/corpus.go b/cmd/graphbench/corpus.go new file mode 100644 index 00000000..6546d248 --- /dev/null +++ b/cmd/graphbench/corpus.go @@ -0,0 +1,121 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" +) + +func loadScaleCorpus(root string) (ScaleCorpus, error) { + casePaths, err := filepath.Glob(filepath.Join(root, "cases", "*.json")) + if err != nil { + return ScaleCorpus{}, fmt.Errorf("glob scale cases: %w", err) + } + if len(casePaths) == 0 { + return ScaleCorpus{}, fmt.Errorf("no scale case files found under %s", filepath.Join(root, "cases")) + } + + sort.Strings(casePaths) + + var corpus ScaleCorpus + for _, path := range casePaths { + var file ScaleCaseFile + if err := decodeJSONFile(path, &file); err != nil { + return ScaleCorpus{}, err + } + + source := filepath.ToSlash(path) + for idx, testCase := range file.Cases { + testCase.Source = source + if err := validateScaleCase(testCase); err != nil { + return ScaleCorpus{}, fmt.Errorf("%s case %d: %w", source, idx, err) + } + + corpus.Cases = append(corpus.Cases, testCase) + } + } + + return corpus, nil +} + +func validateScaleCase(testCase ScaleCase) error { + if testCase.Name == "" { + return fmt.Errorf("name is required") + } + if testCase.Dataset == "" { + return fmt.Errorf("dataset is required") + } + if testCase.Category == "" { + return fmt.Errorf("category is required") + } + if testCase.Cypher == "" { + return fmt.Errorf("cypher is required") + } + if len(testCase.CandidateModes) == 0 { + return fmt.Errorf("candidate_modes is required") + } + + for _, mode := range testCase.CandidateModes { + if !mode.Valid() { + return fmt.Errorf("unsupported candidate mode %q", mode) + } + } + + return nil +} + +func decodeJSONFile(path string, target any) error { + raw, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + if err := json.Unmarshal(raw, target); err != nil { + return fmt.Errorf("decode %s: %w", path, err) + } + + return nil +} + +func scaleCorpusDatasets(corpus ScaleCorpus) []string { + seen := map[string]struct{}{} + datasets := make([]string, 0) + + for _, testCase := range corpus.Cases { + if _, duplicate := seen[testCase.Dataset]; duplicate { + continue + } + + seen[testCase.Dataset] = struct{}{} + datasets = append(datasets, testCase.Dataset) + } + + sort.Strings(datasets) + return datasets +} + +func scaleCasesByDataset(corpus ScaleCorpus) map[string][]ScaleCase { + grouped := map[string][]ScaleCase{} + for _, testCase := range corpus.Cases { + grouped[testCase.Dataset] = append(grouped[testCase.Dataset], testCase) + } + + return grouped +} diff --git a/cmd/graphbench/corpus_test.go b/cmd/graphbench/corpus_test.go new file mode 100644 index 00000000..211b2084 --- /dev/null +++ b/cmd/graphbench/corpus_test.go @@ -0,0 +1,45 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLoadScaleCorpus(t *testing.T) { + corpus, err := loadScaleCorpus("../../benchmark/testdata/scale") + require.NoError(t, err) + require.NotEmpty(t, corpus.Cases) + + for _, testCase := range corpus.Cases { + require.NotEqual(t, "", testCase.Source) + require.True(t, testCase.Supports(ModePostgresSQL), "postgres_sql should be part of the initial corpus for %s", testCase.Name) + require.False(t, testCase.Supports(ExecutionMode("age")), "AGE is a reference design only for %s", testCase.Name) + } +} + +func TestScaleCorpusDatasets(t *testing.T) { + corpus := ScaleCorpus{Cases: []ScaleCase{ + {Name: "a", Dataset: "base", Category: "counts", Cypher: "return 1", CandidateModes: []ExecutionMode{ModePostgresSQL}}, + {Name: "b", Dataset: "adcs_fanout", Category: "counts", Cypher: "return 1", CandidateModes: []ExecutionMode{ModePostgresSQL}}, + {Name: "c", Dataset: "base", Category: "counts", Cypher: "return 1", CandidateModes: []ExecutionMode{ModePostgresSQL}}, + }} + + require.Equal(t, []string{"adcs_fanout", "base"}, scaleCorpusDatasets(corpus)) +} diff --git a/cmd/graphbench/main.go b/cmd/graphbench/main.go new file mode 100644 index 00000000..87555be9 --- /dev/null +++ b/cmd/graphbench/main.go @@ -0,0 +1,32 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "os" +) + +func main() { + corpus, err := loadScaleCorpus("benchmark/testdata/scale") + if err != nil { + fmt.Fprintf(os.Stderr, "graphbench corpus error: %v\n", err) + os.Exit(1) + } + + fmt.Fprintf(os.Stderr, "loaded %d graphbench scale cases; runners are not implemented yet\n", len(corpus.Cases)) +} diff --git a/cmd/graphbench/types.go b/cmd/graphbench/types.go new file mode 100644 index 00000000..c941a01a --- /dev/null +++ b/cmd/graphbench/types.go @@ -0,0 +1,104 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "slices" + "strings" +) + +const ( + ModePostgresSQL ExecutionMode = "postgres_sql" + ModeLocalTraversal ExecutionMode = "local_traversal" + ModeNeo4j ExecutionMode = "neo4j" +) + +var validExecutionModes = []ExecutionMode{ + ModePostgresSQL, + ModeLocalTraversal, + ModeNeo4j, +} + +type ExecutionMode string + +func (s ExecutionMode) Valid() bool { + return slices.Contains(validExecutionModes, s) +} + +func parseExecutionMode(raw string) (ExecutionMode, error) { + mode := ExecutionMode(strings.TrimSpace(raw)) + if mode.Valid() { + return mode, nil + } + + return "", fmt.Errorf("unsupported execution mode %q", raw) +} + +type ScaleCorpus struct { + Cases []ScaleCase +} + +type ScaleCaseFile struct { + Cases []ScaleCase `json:"cases"` +} + +type ScaleCase struct { + Source string `json:"-"` + Name string `json:"name"` + Dataset string `json:"dataset"` + Category string `json:"category"` + Cypher string `json:"cypher"` + Params map[string]any `json:"params,omitempty"` + NodeParams map[string]string `json:"node_params,omitempty"` + Expected ExpectedResult `json:"expected"` + Observes ObservedValues `json:"observes"` + Shape WorkloadShape `json:"shape"` + CandidateModes []ExecutionMode `json:"candidate_modes"` + Tags []string `json:"tags,omitempty"` + ReferenceDesign *ReferenceDesign `json:"reference_design,omitempty"` +} + +type ExpectedResult struct { + RowCount *int64 `json:"row_count,omitempty"` + ResultKind string `json:"result_kind,omitempty"` +} + +type ObservedValues struct { + Paths bool `json:"paths"` + Nodes bool `json:"nodes"` + Relationships bool `json:"relationships"` + Properties bool `json:"properties"` +} + +type WorkloadShape struct { + RootPredicate string `json:"root_predicate,omitempty"` + TerminalPredicate string `json:"terminal_predicate,omitempty"` + EdgeKinds []string `json:"edge_kinds,omitempty"` + MinDepth *int `json:"min_depth,omitempty"` + MaxDepth *int `json:"max_depth,omitempty"` + PathMaterializationRequired bool `json:"path_materialization_required"` +} + +type ReferenceDesign struct { + AGERelevance []string `json:"age_relevance,omitempty"` + Notes string `json:"notes,omitempty"` +} + +func (s ScaleCase) Supports(mode ExecutionMode) bool { + return slices.Contains(s.CandidateModes, mode) +} diff --git a/optimization_continuation.md b/optimization_continuation.md deleted file mode 100644 index f4d32729..00000000 --- a/optimization_continuation.md +++ /dev/null @@ -1,224 +0,0 @@ -# Optimizer Continuation Status: Aggregate Traversal Shape - -## Current State - -The aggregate traversal optimization plan has been implemented through the latest widening and selectivity work. The -work is split into focused commits: - -- `9c5232c` added the initial `AggregateTraversalCount` lowering and live-query optimization work. -- `94b7d78` added a guarded PostgreSQL live-plan assertion for the aggregate traversal shape. -- `51fe99c` records skipped traversal-direction diagnostics for kind-only terminal estimates. -- `61f039d` widens aggregate traversal matching to equivalent `COUNT(*)` row-count forms. -- `7f62b68` treats uniquely constrained bound sources, such as `objectid = ...`, as selective traversal anchors. -- `97c3ffa` expands aggregate traversal baseline coverage for explicit depth bounds, inbound source-left traversal, and - unsafe non-lowering shapes. -- `835a26f` widens final aggregate projections to preserve both the source node and the aggregate count with aliases. -- `0068c3e` carries source selectivity through multipart `WITH` projections, `LIMIT`, and top-N operations. -- `b9f7b4b` folds terminal-local filters into the aggregate traversal terminal-node materialization. - -The lowering now recognizes the kerberoastable aggregate family and emits an ID-only, source-anchored recursive CTE. The -emitted SQL: - -- builds source candidates as IDs; -- traverses with `root_id`, `next_id`, `depth`, and edge-ID `path`; -- uses source-anchored edge index access; -- materializes terminal node IDs once, including terminal-local predicates when present; -- groups by `root_id`; -- applies top-N before rejoining source node composites; -- can return the source node alone or the source node plus the aggregate count. - -The optimizer still keeps unsafe aggregate variants out of this lowering: - -- `COUNT(DISTINCT terminal)`; -- `OPTIONAL MATCH`; -- observed terminal projection or reuse beyond the aggregate count; -- path projection or path functions; -- relationship projection, relationship predicates, or relationship reuse; -- correlated terminal filters, such as `terminal.name = source.name`; -- post-aggregation predicates that depend on the count. - -## Latest Validation Evidence - -The current implementation has passing unit and PostgreSQL integration coverage: - -```bash -go test ./cypher/models/pgsql/... -count=1 -make test -CONNECTION_STRING='postgres://...' make test_integration -``` - -The full PostgreSQL integration run completed successfully. The `integration` package took about `351.7s`. - -`make format` still fails in this environment because `goimports` is unavailable or not executable: - -```text -xargs: goimports: Permission denied -``` - -Touched Go files were formatted with `gofmt`. - -## Latest Plan Comparison - -After the integration suite, the PostgreSQL database no longer looked like the restored large live aggregate dataset to -the guarded aggregate assertion: - -```text -candidateUsers:0 computers:0 adminEdges:0 -``` - -The guarded assertion therefore skipped rather than producing a large-live-dataset plan verdict. The comparison runner -still completed successfully against the current PostgreSQL and Neo4j databases and refreshed -`.coverage/live-plan-comparison.md/json`. - -Current comparison-run timings: - -- `group_objectid_exact_string_equality`: PostgreSQL `0.2 ms`; Neo4j oracle shape `node-label-scan + top-or-limit`. -- `domain_admins_reverse_membership_source_disjunction`: PostgreSQL `101.8 ms`; Neo4j oracle shape - `directed-expand + node-label-scan + top-or-limit + var-length-expand`. -- `dangerous_domain_users_privileges_exclude_memberof`: PostgreSQL `99.7 ms`; Neo4j oracle shape - `directed-expand + top-or-limit`. -- `domain_admin_logons_exclude_domain_controllers`: PostgreSQL `141.6 ms`; Neo4j oracle shape - `directed-expand + top-or-limit + var-length-expand`. -- `kerberoastable_users_by_admin_privilege_count`: PostgreSQL `95.5 ms`; Neo4j oracle shape - `aggregation + directed-expand + node-label-scan + top-or-limit + var-length-expand`. -- `kerberoastable_users_left_label_guard`: PostgreSQL `104.6 ms`; Neo4j oracle shape - `aggregation + directed-expand + node-label-scan + top-or-limit + var-length-expand`. -- `shortest_path_domain_users_to_tier_zero`: PostgreSQL `201.0 ms`; Neo4j oracle shape - `node-label-scan + top-or-limit + var-length-expand`. -- `cross_forest_trusts_require_connected_abuse_edge`: PostgreSQL `0.1 ms`; Neo4j oracle shape - `anti-semi-apply + top-or-limit`. -- `azure_high_privileged_role_bounded_membership`: PostgreSQL `1.6 ms`; Neo4j oracle shape - `directed-expand + node-label-scan + top-or-limit + var-length-expand`. - -These numbers are smoke evidence for the current implementation. They should not replace the earlier large-live-dataset -baseline because the guarded assertion reported that the large aggregate dataset was not present. - -The earlier restored-live-dataset comparison remains the best large-data aggregate baseline: - -- `kerberoastable_users_by_admin_privilege_count`: `12025.1 ms` execution, `12030.6 ms` wall duration; -- `kerberoastable_users_left_label_guard`: `12376.4 ms` execution, `12379.5 ms` wall duration; -- source-anchored recursive traversal from filtered `User` candidates; -- `Hash Join` from traversal rows to materialized `terminal_nodes`; -- `HashAggregate` with `Group Key: traversal.root_id`; -- final source node primary-key lookups after the top-N stage. - -The original live cardinalities that motivated this work were: - -- candidate users after the property filter: about `222`; -- `Computer` nodes: about `139k`; -- `MemberOf|AdminTo` edges: about `2.09M`. - -## Neo4j Oracle - -The Neo4j oracle still commonly prefers label scans followed by `VarLengthExpand(All)`, eager aggregation, and top-N for -the aggregate shapes. That remains useful as an operator-order comparison, but PostgreSQL should not mirror this shape -on the observed large data. The PostgreSQL-specific win comes from filtered source candidates, ID-only traversal, root-ID -aggregation, and deferred source materialization. - -The comparison runner should stay in place as the base for further oracle testing. - -## Completed Plan Items - -### 1. Aggregate Widening Baseline - -Completed in `97c3ffa`. - -Coverage now includes: - -- explicit variable-length depth bounds; -- inbound source-left traversal; -- unsafe non-lowering candidates for distinct counts, optional matches, path bindings, relationship bindings, and - post-aggregation filters. - -### 2. Final Projection Widening - -Completed in `835a26f`. - -The aggregate traversal shape now preserves: - -- source return alias; -- optional count return alias; -- final return forms that include only the source node or both source node and aggregate count. - -### 3. Broader Selectivity Heuristics - -Completed in `0068c3e`. - -The lowering planner now carries selectivity through multipart query parts with a graded model: - -- no useful selectivity; -- kind-only selectivity; -- property predicate selectivity; -- unique equality selectivity; -- limited source sets; -- top-N source sets. - -This prevents a prior limited or top-N source set from being flipped toward a broad terminal unless the terminal side is -also selective enough to justify the direction change. - -### 4. Terminal-Local Filter Folding - -Completed in `b9f7b4b`. - -Terminal-local `WHERE` predicates can now be folded into `terminal_nodes` when every referenced symbol belongs to the -terminal node. Correlated terminal filters remain excluded. - -### 5. Validation and Documentation - -Completed in this document update. - -Validation performed: - -- PostgreSQL model tests; -- unit test suite; -- PostgreSQL integration suite; -- guarded aggregate live-plan assertion, which skipped because the current PostgreSQL corpus did not match the restored - large live dataset; -- PostgreSQL/Neo4j comparison runner, which completed and refreshed ignored comparison artifacts. - -## Remaining Work - -### 1. Re-run Large-Live-Dataset Vetting After Reload - -The next live reload should rerun: - -```bash -CONNECTION_STRING='postgres://...' go test -v -tags manual_integration ./integration \ - -run TestPostgreSQLLiveAggregateTraversalCountPlanShape \ - -count=1 -parallel=1 -timeout=90s - -PG_CONNECTION_STRING='postgres://...' NEO4J_CONNECTION_STRING='neo4j://...' \ - LIVE_VET_TIMEOUT='30s' go run .coverage/live_plan_compare.go -``` - -Acceptance criteria: - -- the guarded assertion does not skip; -- the aggregate query completes under the statement timeout; -- the recursive CTE is source-anchored; -- grouping is by `root_id`, not node composites; -- source node materialization occurs after top-N limiting; -- terminal-local filters remain inside terminal-node materialization when the query shape allows it. - -### 2. Add Evidence Before Further Aggregate Widening - -The next widening candidates should require specific query-corpus examples and tests before implementation: - -- post-aggregation count predicates that can be safely converted to `HAVING`; -- multiple aggregate return aliases if a real query needs them; -- source-side filters that can be pushed into source candidate materialization after multipart rewrites. - -### 3. Keep Broader Selectivity Conservative - -The new selectivity model is intentionally coarse. Future broadening should be backed by live plan mismatches for: - -- known unique equality predicates beyond `objectid`; -- source candidates reduced by prior aggregation; -- property predicates with observed high selectivity; -- label/kind combinations whose cardinality is small enough to anchor traversal. - -### 4. Keep Schema Index Work Deferred - -Do not add default indexes yet. The measured large-live bottleneck was traversal and aggregation shape, not candidate-user -lookup. Revisit targeted expression or partial indexes only if refreshed live plans show source candidate filtering has -become the next dominant cost. From f39a57976b460a8c77598b90e4f61e3a6f66dc4a Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 17:24:35 -0700 Subject: [PATCH 089/116] Add graphbench PostgreSQL SQL runner --- cmd/graphbench/datasets.go | 117 ++++++++++++++ cmd/graphbench/main.go | 124 +++++++++++++- cmd/graphbench/measure.go | 61 +++++++ cmd/graphbench/postgres.go | 275 ++++++++++++++++++++++++++++++++ cmd/graphbench/postgres_test.go | 63 ++++++++ cmd/graphbench/results.go | 134 ++++++++++++++++ 6 files changed, 770 insertions(+), 4 deletions(-) create mode 100644 cmd/graphbench/datasets.go create mode 100644 cmd/graphbench/measure.go create mode 100644 cmd/graphbench/postgres.go create mode 100644 cmd/graphbench/postgres_test.go create mode 100644 cmd/graphbench/results.go diff --git a/cmd/graphbench/datasets.go b/cmd/graphbench/datasets.go new file mode 100644 index 00000000..af400ca8 --- /dev/null +++ b/cmd/graphbench/datasets.go @@ -0,0 +1,117 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/specterops/dawgs/graph" + "github.com/specterops/dawgs/opengraph" +) + +const defaultGraphName = "integration_test" + +func scanDatasetKinds(datasetDir string, datasetNames []string) (graph.Kinds, graph.Kinds, error) { + var nodeKinds, edgeKinds graph.Kinds + + for _, datasetName := range datasetNames { + doc, err := parseDataset(datasetDir, datasetName) + if err != nil { + return nil, nil, err + } + + nextNodeKinds, nextEdgeKinds := doc.Graph.Kinds() + nodeKinds = nodeKinds.Add(nextNodeKinds...) + edgeKinds = edgeKinds.Add(nextEdgeKinds...) + } + + return nodeKinds, edgeKinds, nil +} + +func parseDataset(datasetDir, name string) (opengraph.Document, error) { + path := filepath.Join(datasetDir, name+".json") + f, err := os.Open(path) + if err != nil { + return opengraph.Document{}, fmt.Errorf("open dataset %s: %w", name, err) + } + defer f.Close() + + doc, err := opengraph.ParseDocument(f) + if err != nil { + return opengraph.Document{}, fmt.Errorf("parse dataset %s: %w", name, err) + } + + return doc, nil +} + +func loadDataset(ctx context.Context, db graph.Database, datasetDir, name string) (opengraph.IDMap, error) { + path := filepath.Join(datasetDir, name+".json") + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open dataset %s: %w", name, err) + } + defer f.Close() + + idMap, err := opengraph.Load(ctx, db, f) + if err != nil { + return nil, fmt.Errorf("load dataset %s: %w", name, err) + } + + return idMap, nil +} + +func clearGraph(ctx context.Context, db graph.Database) error { + return db.WriteTransaction(ctx, func(tx graph.Transaction) error { + return tx.Nodes().Delete() + }) +} + +func benchmarkSchema(nodeKinds, edgeKinds graph.Kinds) graph.Schema { + return graph.Schema{ + Graphs: []graph.Graph{{ + Name: defaultGraphName, + Nodes: nodeKinds, + Edges: edgeKinds, + }}, + DefaultGraph: graph.Graph{Name: defaultGraphName}, + } +} + +func resolveCaseParams(testCase ScaleCase, idMap opengraph.IDMap) (map[string]any, error) { + params := make(map[string]any, len(testCase.Params)+len(testCase.NodeParams)) + for key, value := range testCase.Params { + params[key] = value + } + + for paramName, nodeName := range testCase.NodeParams { + id, found := idMap[nodeName] + if !found { + return nil, fmt.Errorf("case %s references unknown dataset node %q", testCase.Name, nodeName) + } + + params[paramName] = id.Int64() + } + + if len(params) == 0 { + return nil, nil + } + + return params, nil +} diff --git a/cmd/graphbench/main.go b/cmd/graphbench/main.go index 87555be9..f6eb02eb 100644 --- a/cmd/graphbench/main.go +++ b/cmd/graphbench/main.go @@ -17,16 +17,132 @@ package main import ( + "context" + "flag" "fmt" + "io" "os" + "strings" ) +type config struct { + CorpusRoot string + DatasetDir string + Connection string + PGConnection string + Modes []ExecutionMode + Iterations int + OutputJSONL string +} + +func parseConfig(args []string, env func(string) string) (config, error) { + flags := flag.NewFlagSet("graphbench", flag.ContinueOnError) + flags.SetOutput(io.Discard) + + var ( + cfg config + rawModes string + ) + + flags.StringVar(&cfg.CorpusRoot, "corpus-root", "benchmark/testdata/scale", "scale corpus root") + flags.StringVar(&cfg.DatasetDir, "dataset-dir", "integration/testdata", "dataset root") + flags.StringVar(&cfg.Connection, "connection", env("CONNECTION_STRING"), "single backend connection string") + flags.StringVar(&cfg.PGConnection, "pg-connection", env("PG_CONNECTION_STRING"), "PostgreSQL connection string") + flags.StringVar(&rawModes, "modes", string(ModePostgresSQL), "comma-separated execution modes") + flags.IntVar(&cfg.Iterations, "iterations", 3, "timed iterations per case") + flags.StringVar(&cfg.OutputJSONL, "jsonl-output", "", "JSONL output path (default: stdout)") + + if err := flags.Parse(args); err != nil { + return config{}, err + } + if cfg.Iterations < 1 { + return config{}, fmt.Errorf("iterations must be at least 1") + } + + modes, err := parseExecutionModes(rawModes) + if err != nil { + return config{}, err + } + cfg.Modes = modes + + return cfg, nil +} + +func parseExecutionModes(raw string) ([]ExecutionMode, error) { + var modes []ExecutionMode + seen := map[ExecutionMode]struct{}{} + + for _, part := range strings.Split(raw, ",") { + mode, err := parseExecutionMode(part) + if err != nil { + return nil, err + } + if _, duplicate := seen[mode]; duplicate { + continue + } + + seen[mode] = struct{}{} + modes = append(modes, mode) + } + if len(modes) == 0 { + return nil, fmt.Errorf("at least one execution mode is required") + } + + return modes, nil +} + +func fatal(format string, args ...any) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(1) +} + func main() { - corpus, err := loadScaleCorpus("benchmark/testdata/scale") + cfg, err := parseConfig(os.Args[1:], os.Getenv) + if err != nil { + fatal("%v", err) + } + + corpus, err := loadScaleCorpus(cfg.CorpusRoot) if err != nil { - fmt.Fprintf(os.Stderr, "graphbench corpus error: %v\n", err) - os.Exit(1) + fatal("load corpus: %v", err) } - fmt.Fprintf(os.Stderr, "loaded %d graphbench scale cases; runners are not implemented yet\n", len(corpus.Cases)) + ctx := context.Background() + var records []CaseResult + + for _, mode := range cfg.Modes { + switch mode { + case ModePostgresSQL: + pgConnection := cfg.PGConnection + if pgConnection == "" { + pgConnection = cfg.Connection + } + if pgConnection == "" { + fatal("postgres_sql mode requires -pg-connection, -connection, PG_CONNECTION_STRING, or CONNECTION_STRING") + } + + runner, err := newPostgresSQLRunner(ctx, cfg.DatasetDir, pgConnection, corpus) + if err != nil { + fatal("open postgres_sql runner: %v", err) + } + + nextRecords, err := runner.Run(ctx, cfg.Iterations, corpus) + closeErr := runner.Close(ctx) + if err != nil { + fatal("run postgres_sql: %v", err) + } + if closeErr != nil { + fatal("close postgres_sql: %v", closeErr) + } + + records = append(records, nextRecords...) + + default: + fatal("execution mode %s is not implemented yet", mode) + } + } + + if err := writeJSONLFile(cfg.OutputJSONL, records); err != nil { + fatal("write JSONL: %v", err) + } } diff --git a/cmd/graphbench/measure.go b/cmd/graphbench/measure.go new file mode 100644 index 00000000..b41f91ae --- /dev/null +++ b/cmd/graphbench/measure.go @@ -0,0 +1,61 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "time" + + "github.com/specterops/dawgs/graph" +) + +func countCypherRows(tx graph.Transaction, cypher string, params map[string]any) (int64, error) { + result := tx.Query(cypher, params) + defer result.Close() + + var rowCount int64 + for result.Next() { + rowCount++ + } + + return rowCount, result.Error() +} + +func measureCypher(ctx context.Context, db graph.Database, cypher string, params map[string]any, iterations int) (int64, DurationStats, error) { + var warmupRows int64 + if err := db.ReadTransaction(ctx, func(tx graph.Transaction) error { + var err error + warmupRows, err = countCypherRows(tx, cypher, params) + return err + }); err != nil { + return 0, DurationStats{}, err + } + + durations := make([]time.Duration, iterations) + for idx := range iterations { + start := time.Now() + if err := db.ReadTransaction(ctx, func(tx graph.Transaction) error { + _, err := countCypherRows(tx, cypher, params) + return err + }); err != nil { + return 0, DurationStats{}, err + } + durations[idx] = time.Since(start) + } + + return warmupRows, computeDurationStats(durations), nil +} diff --git a/cmd/graphbench/postgres.go b/cmd/graphbench/postgres.go new file mode 100644 index 00000000..7e6dfb0b --- /dev/null +++ b/cmd/graphbench/postgres.go @@ -0,0 +1,275 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/specterops/dawgs" + "github.com/specterops/dawgs/cypher/frontend" + "github.com/specterops/dawgs/cypher/models/pgsql/translate" + "github.com/specterops/dawgs/drivers" + "github.com/specterops/dawgs/drivers/pg" + "github.com/specterops/dawgs/graph" + "github.com/specterops/dawgs/opengraph" + "github.com/specterops/dawgs/util/size" +) + +type postgresSQLRunner struct { + datasetDir string + db graph.Database + pgDriver *pg.Driver + graphID int32 +} + +func newPostgresSQLRunner(ctx context.Context, datasetDir, connection string, corpus ScaleCorpus) (*postgresSQLRunner, error) { + pool, err := pg.NewPool(drivers.DatabaseConfiguration{Connection: connection}) + if err != nil { + return nil, fmt.Errorf("create PostgreSQL pool: %w", err) + } + + db, err := dawgs.Open(ctx, pg.DriverName, dawgs.Config{ + GraphQueryMemoryLimit: size.Gibibyte, + ConnectionString: connection, + Pool: pool, + }) + if err != nil { + pool.Close() + return nil, fmt.Errorf("open PostgreSQL database: %w", err) + } + + nodeKinds, edgeKinds, err := scanDatasetKinds(datasetDir, scaleCorpusDatasets(corpus)) + if err != nil { + _ = db.Close(ctx) + return nil, err + } + + if err := db.AssertSchema(ctx, benchmarkSchema(nodeKinds, edgeKinds)); err != nil { + _ = db.Close(ctx) + return nil, fmt.Errorf("assert PostgreSQL schema: %w", err) + } + + pgDriver, ok := db.(*pg.Driver) + if !ok { + _ = db.Close(ctx) + return nil, fmt.Errorf("expected *pg.Driver, got %T", db) + } + + defaultGraph, ok := pgDriver.DefaultGraph() + if !ok { + _ = db.Close(ctx) + return nil, fmt.Errorf("PostgreSQL default graph is not set") + } + + return &postgresSQLRunner{ + datasetDir: datasetDir, + db: db, + pgDriver: pgDriver, + graphID: defaultGraph.ID, + }, nil +} + +func (s *postgresSQLRunner) Close(ctx context.Context) error { + if s.db == nil { + return nil + } + + return s.db.Close(ctx) +} + +func (s *postgresSQLRunner) Run(ctx context.Context, iterations int, corpus ScaleCorpus) ([]CaseResult, error) { + var records []CaseResult + casesByDataset := scaleCasesByDataset(corpus) + + for _, datasetName := range scaleCorpusDatasets(corpus) { + if err := clearGraph(ctx, s.db); err != nil { + return nil, fmt.Errorf("clear graph for %s: %w", datasetName, err) + } + + idMap, err := loadDataset(ctx, s.db, s.datasetDir, datasetName) + if err != nil { + return nil, err + } + + for _, testCase := range casesByDataset[datasetName] { + if !testCase.Supports(ModePostgresSQL) { + continue + } + + record := s.runCase(ctx, iterations, testCase, idMap) + records = append(records, record) + } + } + + return records, nil +} + +func (s *postgresSQLRunner) runCase(ctx context.Context, iterations int, testCase ScaleCase, idMap opengraph.IDMap) CaseResult { + params, err := resolveCaseParams(testCase, idMap) + record := newCaseResult(testCase, ModePostgresSQL, params) + if err != nil { + record.Status = StatusError + record.Error = err.Error() + return record + } + + rowCount, stats, err := measureCypher(ctx, s.db, testCase.Cypher, params, iterations) + if err != nil { + record.Status = StatusError + record.Error = err.Error() + return record + } + + record.RowCount = rowCount + record.Stats = stats + applyRowExpectation(&record) + + explain, err := s.explain(ctx, testCase.Cypher, params) + if err != nil { + if record.Status == StatusOK { + record.Status = StatusError + record.Error = err.Error() + } + return record + } + + record.SQL = explain.SQL + record.PostgresPlan = explain.Plan + record.PostgresMetrics = &explain.Metrics + record.Optimization = &explain.Optimization + return record +} + +type postgresExplain struct { + SQL string + Plan []string + Metrics PostgresPlanMetrics + Optimization translate.OptimizationSummary +} + +func (s *postgresSQLRunner) explain(ctx context.Context, cypherQuery string, params map[string]any) (postgresExplain, error) { + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), cypherQuery) + if err != nil { + return postgresExplain{}, err + } + + translation, err := translate.Translate(ctx, regularQuery, s.pgDriver.KindMapper(), params, s.graphID) + if err != nil { + return postgresExplain{}, err + } + + sqlQuery, err := translate.Translated(translation) + if err != nil { + return postgresExplain{}, err + } + + var plan []string + if err := s.db.ReadTransaction(ctx, func(tx graph.Transaction) error { + result := tx.Raw("EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) "+sqlQuery, translation.Parameters) + defer result.Close() + + for result.Next() { + values := result.Values() + if len(values) == 0 { + continue + } + + plan = append(plan, fmt.Sprint(values[0])) + } + + return result.Error() + }); err != nil { + return postgresExplain{}, err + } + + return postgresExplain{ + SQL: sqlQuery, + Plan: plan, + Metrics: parsePostgresPlanMetrics(plan), + Optimization: translation.Optimization, + }, nil +} + +var ( + postgresPlanningPattern = regexp.MustCompile(`Planning Time: ([0-9.]+) ms`) + postgresExecutionPattern = regexp.MustCompile(`Execution Time: ([0-9.]+) ms`) + postgresBufferPattern = regexp.MustCompile(`(?:(shared|temp) )?(hit|read|dirtied|written)=([0-9]+)`) +) + +func parsePostgresPlanMetrics(plan []string) PostgresPlanMetrics { + var metrics PostgresPlanMetrics + for _, line := range plan { + if metrics.PlanningMS == nil { + if match := postgresPlanningPattern.FindStringSubmatch(line); match != nil { + if parsed, err := strconv.ParseFloat(match[1], 64); err == nil { + metrics.PlanningMS = &parsed + } + } + } + + if metrics.ExecutionMS == nil { + if match := postgresExecutionPattern.FindStringSubmatch(line); match != nil { + if parsed, err := strconv.ParseFloat(match[1], 64); err == nil { + metrics.ExecutionMS = &parsed + } + } + } + + if strings.Contains(line, "Buffers:") && metrics.Buffers == (Buffers{}) { + metrics.Buffers = parsePostgresBuffers(line) + } + } + + return metrics +} + +func parsePostgresBuffers(line string) Buffers { + var ( + buffers Buffers + bufferScope string + ) + + for _, match := range postgresBufferPattern.FindAllStringSubmatch(line, -1) { + value, err := strconv.ParseInt(match[3], 10, 64) + if err != nil { + continue + } + + if match[1] != "" { + bufferScope = match[1] + } + + switch bufferScope + "_" + match[2] { + case "shared_hit": + buffers.SharedHit = value + case "shared_read": + buffers.SharedRead = value + case "shared_dirtied": + buffers.SharedDirtied = value + case "temp_read": + buffers.TempRead = value + case "temp_written": + buffers.TempWritten = value + } + } + + return buffers +} diff --git a/cmd/graphbench/postgres_test.go b/cmd/graphbench/postgres_test.go new file mode 100644 index 00000000..54470e60 --- /dev/null +++ b/cmd/graphbench/postgres_test.go @@ -0,0 +1,63 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "testing" + + "github.com/specterops/dawgs/graph" + "github.com/specterops/dawgs/opengraph" + "github.com/stretchr/testify/require" +) + +func TestResolveCaseParams(t *testing.T) { + params, err := resolveCaseParams(ScaleCase{ + Params: map[string]any{ + "name": "value", + }, + NodeParams: map[string]string{ + "start_id": "n1", + }, + }, opengraph.IDMap{"n1": graph.ID(42)}) + + require.NoError(t, err) + require.Equal(t, map[string]any{ + "name": "value", + "start_id": int64(42), + }, params) +} + +func TestParsePostgresPlanMetrics(t *testing.T) { + metrics := parsePostgresPlanMetrics([]string{ + "Nested Loop (actual rows=1 loops=1)", + " Buffers: shared hit=12 read=3 dirtied=2, temp read=4 written=5", + "Planning Time: 1.250 ms", + "Execution Time: 9.750 ms", + }) + + require.NotNil(t, metrics.PlanningMS) + require.Equal(t, 1.25, *metrics.PlanningMS) + require.NotNil(t, metrics.ExecutionMS) + require.Equal(t, 9.75, *metrics.ExecutionMS) + require.Equal(t, Buffers{ + SharedHit: 12, + SharedRead: 3, + SharedDirtied: 2, + TempRead: 4, + TempWritten: 5, + }, metrics.Buffers) +} diff --git a/cmd/graphbench/results.go b/cmd/graphbench/results.go new file mode 100644 index 00000000..ed383e48 --- /dev/null +++ b/cmd/graphbench/results.go @@ -0,0 +1,134 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "sort" + "time" + + "github.com/specterops/dawgs/cypher/models/pgsql/translate" +) + +const ( + StatusOK = "ok" + StatusRowMismatch = "row_mismatch" + StatusError = "error" +) + +type DurationStats struct { + Iterations int `json:"iterations"` + Median time.Duration `json:"median"` + P95 time.Duration `json:"p95"` + Max time.Duration `json:"max"` +} + +type PostgresPlanMetrics struct { + PlanningMS *float64 `json:"planning_ms,omitempty"` + ExecutionMS *float64 `json:"execution_ms,omitempty"` + Buffers Buffers `json:"buffers,omitempty"` +} + +type Buffers struct { + SharedHit int64 `json:"shared_hit,omitempty"` + SharedRead int64 `json:"shared_read,omitempty"` + SharedDirtied int64 `json:"shared_dirtied,omitempty"` + TempRead int64 `json:"temp_read,omitempty"` + TempWritten int64 `json:"temp_written,omitempty"` +} + +type CaseResult struct { + Source string `json:"source"` + Dataset string `json:"dataset"` + Name string `json:"name"` + Category string `json:"category"` + ExecutionMode ExecutionMode `json:"execution_mode"` + Status string `json:"status"` + Cypher string `json:"cypher"` + Params map[string]any `json:"params,omitempty"` + ExpectedRowCount *int64 `json:"expected_row_count,omitempty"` + RowCount int64 `json:"row_count,omitempty"` + Stats DurationStats `json:"stats,omitempty"` + SQL string `json:"sql,omitempty"` + PostgresPlan []string `json:"postgres_plan,omitempty"` + PostgresMetrics *PostgresPlanMetrics `json:"postgres_metrics,omitempty"` + Optimization *translate.OptimizationSummary `json:"optimization,omitempty"` + Error string `json:"error,omitempty"` +} + +func newCaseResult(testCase ScaleCase, mode ExecutionMode, params map[string]any) CaseResult { + return CaseResult{ + Source: testCase.Source, + Dataset: testCase.Dataset, + Name: testCase.Name, + Category: testCase.Category, + ExecutionMode: mode, + Status: StatusOK, + Cypher: testCase.Cypher, + Params: params, + ExpectedRowCount: testCase.Expected.RowCount, + } +} + +func computeDurationStats(durations []time.Duration) DurationStats { + sort.Slice(durations, func(i, j int) bool { + return durations[i] < durations[j] + }) + + n := len(durations) + return DurationStats{ + Iterations: n, + Median: durations[n/2], + P95: durations[n*95/100], + Max: durations[n-1], + } +} + +func applyRowExpectation(result *CaseResult) { + if result.ExpectedRowCount != nil && result.RowCount != *result.ExpectedRowCount { + result.Status = StatusRowMismatch + result.Error = fmt.Sprintf("expected %d rows, got %d", *result.ExpectedRowCount, result.RowCount) + } +} + +func writeJSONLFile(path string, records []CaseResult) error { + if path == "" { + return writeJSONL(os.Stdout, records) + } + + output, err := os.Create(path) + if err != nil { + return err + } + defer output.Close() + + return writeJSONL(output, records) +} + +func writeJSONL(w io.Writer, records []CaseResult) error { + encoder := json.NewEncoder(w) + for _, record := range records { + if err := encoder.Encode(record); err != nil { + return err + } + } + + return nil +} From 4714d8b5f20c7e5fd6a502731687567e142974e0 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 17:25:45 -0700 Subject: [PATCH 090/116] Add graphbench Neo4j runner --- cmd/graphbench/main.go | 41 ++++- cmd/graphbench/neo4j.go | 296 +++++++++++++++++++++++++++++++++++ cmd/graphbench/neo4j_test.go | 53 +++++++ cmd/graphbench/results.go | 2 + 4 files changed, 385 insertions(+), 7 deletions(-) create mode 100644 cmd/graphbench/neo4j.go create mode 100644 cmd/graphbench/neo4j_test.go diff --git a/cmd/graphbench/main.go b/cmd/graphbench/main.go index f6eb02eb..dc5efe21 100644 --- a/cmd/graphbench/main.go +++ b/cmd/graphbench/main.go @@ -26,13 +26,14 @@ import ( ) type config struct { - CorpusRoot string - DatasetDir string - Connection string - PGConnection string - Modes []ExecutionMode - Iterations int - OutputJSONL string + CorpusRoot string + DatasetDir string + Connection string + PGConnection string + Neo4jConnection string + Modes []ExecutionMode + Iterations int + OutputJSONL string } func parseConfig(args []string, env func(string) string) (config, error) { @@ -48,6 +49,7 @@ func parseConfig(args []string, env func(string) string) (config, error) { flags.StringVar(&cfg.DatasetDir, "dataset-dir", "integration/testdata", "dataset root") flags.StringVar(&cfg.Connection, "connection", env("CONNECTION_STRING"), "single backend connection string") flags.StringVar(&cfg.PGConnection, "pg-connection", env("PG_CONNECTION_STRING"), "PostgreSQL connection string") + flags.StringVar(&cfg.Neo4jConnection, "neo4j-connection", env("NEO4J_CONNECTION_STRING"), "Neo4j connection string") flags.StringVar(&rawModes, "modes", string(ModePostgresSQL), "comma-separated execution modes") flags.IntVar(&cfg.Iterations, "iterations", 3, "timed iterations per case") flags.StringVar(&cfg.OutputJSONL, "jsonl-output", "", "JSONL output path (default: stdout)") @@ -137,6 +139,31 @@ func main() { records = append(records, nextRecords...) + case ModeNeo4j: + neo4jConnection := cfg.Neo4jConnection + if neo4jConnection == "" { + neo4jConnection = cfg.Connection + } + if neo4jConnection == "" { + fatal("neo4j mode requires -neo4j-connection, -connection, NEO4J_CONNECTION_STRING, or CONNECTION_STRING") + } + + runner, err := newNeo4jRunner(ctx, cfg.DatasetDir, neo4jConnection, corpus) + if err != nil { + fatal("open neo4j runner: %v", err) + } + + nextRecords, err := runner.Run(ctx, cfg.Iterations, corpus) + closeErr := runner.Close(ctx) + if err != nil { + fatal("run neo4j: %v", err) + } + if closeErr != nil { + fatal("close neo4j: %v", closeErr) + } + + records = append(records, nextRecords...) + default: fatal("execution mode %s is not implemented yet", mode) } diff --git a/cmd/graphbench/neo4j.go b/cmd/graphbench/neo4j.go new file mode 100644 index 00000000..9c720eed --- /dev/null +++ b/cmd/graphbench/neo4j.go @@ -0,0 +1,296 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "fmt" + "net/url" + "strings" + + neo4jcore "github.com/neo4j/neo4j-go-driver/v5/neo4j" + "github.com/specterops/dawgs" + dawgsneo4j "github.com/specterops/dawgs/drivers/neo4j" + "github.com/specterops/dawgs/graph" + "github.com/specterops/dawgs/opengraph" + "github.com/specterops/dawgs/util/size" +) + +type neo4jRunner struct { + datasetDir string + db graph.Database + planDriver neo4jcore.Driver + databaseName string +} + +func newNeo4jRunner(ctx context.Context, datasetDir, connection string, corpus ScaleCorpus) (*neo4jRunner, error) { + db, err := dawgs.Open(ctx, dawgsneo4j.DriverName, dawgs.Config{ + GraphQueryMemoryLimit: size.Gibibyte, + ConnectionString: connection, + }) + if err != nil { + return nil, fmt.Errorf("open Neo4j database: %w", err) + } + + nodeKinds, edgeKinds, err := scanDatasetKinds(datasetDir, scaleCorpusDatasets(corpus)) + if err != nil { + _ = db.Close(ctx) + return nil, err + } + + if err := db.AssertSchema(ctx, benchmarkSchema(nodeKinds, edgeKinds)); err != nil { + _ = db.Close(ctx) + return nil, fmt.Errorf("assert Neo4j schema: %w", err) + } + + planDriver, databaseName, err := openNeo4jPlanDriver(connection) + if err != nil { + _ = db.Close(ctx) + return nil, err + } + + return &neo4jRunner{ + datasetDir: datasetDir, + db: db, + planDriver: planDriver, + databaseName: databaseName, + }, nil +} + +func (s *neo4jRunner) Close(ctx context.Context) error { + var closeErr error + if s.planDriver != nil { + closeErr = s.planDriver.Close() + } + if s.db != nil { + if err := s.db.Close(ctx); err != nil && closeErr == nil { + closeErr = err + } + } + + return closeErr +} + +func (s *neo4jRunner) Run(ctx context.Context, iterations int, corpus ScaleCorpus) ([]CaseResult, error) { + var records []CaseResult + casesByDataset := scaleCasesByDataset(corpus) + + for _, datasetName := range scaleCorpusDatasets(corpus) { + if err := clearGraph(ctx, s.db); err != nil { + return nil, fmt.Errorf("clear graph for %s: %w", datasetName, err) + } + + idMap, err := loadDataset(ctx, s.db, s.datasetDir, datasetName) + if err != nil { + return nil, err + } + + for _, testCase := range casesByDataset[datasetName] { + if !testCase.Supports(ModeNeo4j) { + continue + } + + record := s.runCase(ctx, iterations, testCase, idMap) + records = append(records, record) + } + } + + return records, nil +} + +func (s *neo4jRunner) runCase(ctx context.Context, iterations int, testCase ScaleCase, idMap opengraph.IDMap) CaseResult { + params, err := resolveCaseParams(testCase, idMap) + record := newCaseResult(testCase, ModeNeo4j, params) + if err != nil { + record.Status = StatusError + record.Error = err.Error() + return record + } + + rowCount, stats, err := measureCypher(ctx, s.db, testCase.Cypher, params, iterations) + if err != nil { + record.Status = StatusError + record.Error = err.Error() + return record + } + + record.RowCount = rowCount + record.Stats = stats + applyRowExpectation(&record) + + plan, operators, err := s.explain(testCase.Cypher, params) + if err != nil { + if record.Status == StatusOK { + record.Status = StatusError + record.Error = err.Error() + } + return record + } + + record.Neo4jPlan = plan + record.Neo4jOperators = operators + return record +} + +func (s *neo4jRunner) explain(cypherQuery string, params map[string]any) (*Neo4jPlanNode, []string, error) { + session := s.planDriver.NewSession(neo4jcore.SessionConfig{ + AccessMode: neo4jcore.AccessModeRead, + DatabaseName: s.databaseName, + }) + defer session.Close() + + result, err := session.Run("EXPLAIN "+cypherWithoutTerminator(cypherQuery), params) + if err != nil { + return nil, nil, err + } + + summary, err := result.Consume() + if err != nil { + return nil, nil, err + } + if summary.Plan() == nil { + return nil, nil, nil + } + + plan := convertNeo4jPlan(summary.Plan()) + return &plan, neo4jOperators(plan), nil +} + +type neo4jPlanDriverConfig struct { + Target string + Username string + Password string + DatabaseName string +} + +func parseNeo4jPlanDriverConfig(connStr string) (neo4jPlanDriverConfig, error) { + connectionURL, err := url.Parse(connStr) + if err != nil { + return neo4jPlanDriverConfig{}, fmt.Errorf("parse Neo4j connection string: %w", err) + } + + if connectionURL.Scheme != dawgsneo4j.DriverName && connectionURL.Scheme != "neo4j+s" && connectionURL.Scheme != "neo4j+ssc" { + return neo4jPlanDriverConfig{}, fmt.Errorf("expected Neo4j connection string scheme, got %q", connectionURL.Scheme) + } + + password, ok := connectionURL.User.Password() + if !ok { + return neo4jPlanDriverConfig{}, fmt.Errorf("no password provided in Neo4j connection string") + } + if connectionURL.Host == "" { + return neo4jPlanDriverConfig{}, fmt.Errorf("Neo4j connection string host is required") + } + + databaseName, err := neo4jDatabaseName(connectionURL) + if err != nil { + return neo4jPlanDriverConfig{}, err + } + + return neo4jPlanDriverConfig{ + Target: (&url.URL{ + Scheme: connectionURL.Scheme, + Host: connectionURL.Host, + RawQuery: connectionURL.RawQuery, + }).String(), + Username: connectionURL.User.Username(), + Password: password, + DatabaseName: databaseName, + }, nil +} + +func neo4jDatabaseName(connectionURL *url.URL) (string, error) { + databasePath := strings.Trim(connectionURL.EscapedPath(), "/") + if databasePath == "" { + return "", nil + } + if strings.Contains(databasePath, "/") { + return "", fmt.Errorf("Neo4j database path must contain a single database name") + } + + databaseName, err := url.PathUnescape(databasePath) + if err != nil { + return "", fmt.Errorf("parse Neo4j database name: %w", err) + } + + return databaseName, nil +} + +func openNeo4jPlanDriver(connStr string) (neo4jcore.Driver, string, error) { + cfg, err := parseNeo4jPlanDriverConfig(connStr) + if err != nil { + return nil, "", err + } + + driver, err := neo4jcore.NewDriver(cfg.Target, neo4jcore.BasicAuth(cfg.Username, cfg.Password, "")) + if err != nil { + return nil, "", err + } + + return driver, cfg.DatabaseName, nil +} + +type Neo4jPlanNode struct { + Operator string `json:"operator"` + Arguments map[string]string `json:"arguments,omitempty"` + Identifiers []string `json:"identifiers,omitempty"` + Children []Neo4jPlanNode `json:"children,omitempty"` +} + +func convertNeo4jPlan(plan neo4jcore.Plan) Neo4jPlanNode { + node := Neo4jPlanNode{ + Operator: plan.Operator(), + Arguments: stringifyArguments(plan.Arguments()), + Identifiers: append([]string(nil), plan.Identifiers()...), + } + + for _, child := range plan.Children() { + node.Children = append(node.Children, convertNeo4jPlan(child)) + } + + return node +} + +func stringifyArguments(arguments map[string]any) map[string]string { + if len(arguments) == 0 { + return nil + } + + values := make(map[string]string, len(arguments)) + for key, value := range arguments { + values[key] = fmt.Sprint(value) + } + + return values +} + +func neo4jOperators(root Neo4jPlanNode) []string { + var operators []string + var walk func(Neo4jPlanNode) + walk = func(node Neo4jPlanNode) { + operators = append(operators, node.Operator+"@neo4j") + for _, child := range node.Children { + walk(child) + } + } + walk(root) + + return operators +} + +func cypherWithoutTerminator(cypherQuery string) string { + return strings.TrimSuffix(strings.TrimSpace(cypherQuery), ";") +} diff --git a/cmd/graphbench/neo4j_test.go b/cmd/graphbench/neo4j_test.go new file mode 100644 index 00000000..dfb795db --- /dev/null +++ b/cmd/graphbench/neo4j_test.go @@ -0,0 +1,53 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseNeo4jPlanDriverConfig(t *testing.T) { + cfg, err := parseNeo4jPlanDriverConfig("neo4j://neo4j:secret@example.com:7687/neo4jdb?x=1") + + require.NoError(t, err) + require.Equal(t, "neo4j://example.com:7687?x=1", cfg.Target) + require.Equal(t, "neo4j", cfg.Username) + require.Equal(t, "secret", cfg.Password) + require.Equal(t, "neo4jdb", cfg.DatabaseName) +} + +func TestNeo4jDatabaseNameRejectsNestedPath(t *testing.T) { + parsed, err := url.Parse("neo4j://neo4j:secret@example.com:7687/a/b") + require.NoError(t, err) + + _, err = neo4jDatabaseName(parsed) + require.ErrorContains(t, err, "single database name") +} + +func TestNeo4jOperatorsAnnotatesOperators(t *testing.T) { + operators := neo4jOperators(Neo4jPlanNode{ + Operator: "ProduceResults", + Children: []Neo4jPlanNode{{ + Operator: "AllNodesScan", + }}, + }) + + require.Equal(t, []string{"ProduceResults@neo4j", "AllNodesScan@neo4j"}, operators) +} diff --git a/cmd/graphbench/results.go b/cmd/graphbench/results.go index ed383e48..90332274 100644 --- a/cmd/graphbench/results.go +++ b/cmd/graphbench/results.go @@ -69,6 +69,8 @@ type CaseResult struct { SQL string `json:"sql,omitempty"` PostgresPlan []string `json:"postgres_plan,omitempty"` PostgresMetrics *PostgresPlanMetrics `json:"postgres_metrics,omitempty"` + Neo4jPlan *Neo4jPlanNode `json:"neo4j_plan,omitempty"` + Neo4jOperators []string `json:"neo4j_operators,omitempty"` Optimization *translate.OptimizationSummary `json:"optimization,omitempty"` Error string `json:"error,omitempty"` } From be785ef1fecf942e0ac123575599c722d14f3ff2 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 17:26:20 -0700 Subject: [PATCH 091/116] Add graphbench local traversal placeholder --- cmd/graphbench/local_traversal.go | 35 ++++++++++++++++++ cmd/graphbench/local_traversal_test.go | 49 ++++++++++++++++++++++++++ cmd/graphbench/main.go | 3 ++ cmd/graphbench/results.go | 10 ++++-- 4 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 cmd/graphbench/local_traversal.go create mode 100644 cmd/graphbench/local_traversal_test.go diff --git a/cmd/graphbench/local_traversal.go b/cmd/graphbench/local_traversal.go new file mode 100644 index 00000000..6fff61bd --- /dev/null +++ b/cmd/graphbench/local_traversal.go @@ -0,0 +1,35 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package main + +const localTraversalUnavailableReason = "local traversal executor unavailable" + +func runLocalTraversalPlaceholders(corpus ScaleCorpus) []CaseResult { + records := make([]CaseResult, 0) + for _, testCase := range corpus.Cases { + if !testCase.Supports(ModeLocalTraversal) { + continue + } + + record := newCaseResult(testCase, ModeLocalTraversal, testCase.Params) + record.Status = StatusNotImplemented + record.FallbackReason = localTraversalUnavailableReason + records = append(records, record) + } + + return records +} diff --git a/cmd/graphbench/local_traversal_test.go b/cmd/graphbench/local_traversal_test.go new file mode 100644 index 00000000..39a7e728 --- /dev/null +++ b/cmd/graphbench/local_traversal_test.go @@ -0,0 +1,49 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRunLocalTraversalPlaceholders(t *testing.T) { + records := runLocalTraversalPlaceholders(ScaleCorpus{Cases: []ScaleCase{ + { + Name: "supported", + Dataset: "base", + Category: "reachability", + Cypher: "MATCH (n) RETURN n", + NodeParams: map[string]string{"start_id": "n1"}, + CandidateModes: []ExecutionMode{ModePostgresSQL, ModeLocalTraversal}, + }, + { + Name: "unsupported", + Dataset: "base", + Category: "count", + Cypher: "MATCH (n) RETURN count(n)", + CandidateModes: []ExecutionMode{ModePostgresSQL}, + }, + }}) + + require.Len(t, records, 1) + require.Equal(t, ModeLocalTraversal, records[0].ExecutionMode) + require.Equal(t, StatusNotImplemented, records[0].Status) + require.Equal(t, localTraversalUnavailableReason, records[0].FallbackReason) + require.Equal(t, map[string]string{"start_id": "n1"}, records[0].NodeParams) +} diff --git a/cmd/graphbench/main.go b/cmd/graphbench/main.go index dc5efe21..a1992f50 100644 --- a/cmd/graphbench/main.go +++ b/cmd/graphbench/main.go @@ -164,6 +164,9 @@ func main() { records = append(records, nextRecords...) + case ModeLocalTraversal: + records = append(records, runLocalTraversalPlaceholders(corpus)...) + default: fatal("execution mode %s is not implemented yet", mode) } diff --git a/cmd/graphbench/results.go b/cmd/graphbench/results.go index 90332274..db18a9ba 100644 --- a/cmd/graphbench/results.go +++ b/cmd/graphbench/results.go @@ -28,9 +28,10 @@ import ( ) const ( - StatusOK = "ok" - StatusRowMismatch = "row_mismatch" - StatusError = "error" + StatusOK = "ok" + StatusRowMismatch = "row_mismatch" + StatusError = "error" + StatusNotImplemented = "not_implemented" ) type DurationStats struct { @@ -63,6 +64,7 @@ type CaseResult struct { Status string `json:"status"` Cypher string `json:"cypher"` Params map[string]any `json:"params,omitempty"` + NodeParams map[string]string `json:"node_params,omitempty"` ExpectedRowCount *int64 `json:"expected_row_count,omitempty"` RowCount int64 `json:"row_count,omitempty"` Stats DurationStats `json:"stats,omitempty"` @@ -72,6 +74,7 @@ type CaseResult struct { Neo4jPlan *Neo4jPlanNode `json:"neo4j_plan,omitempty"` Neo4jOperators []string `json:"neo4j_operators,omitempty"` Optimization *translate.OptimizationSummary `json:"optimization,omitempty"` + FallbackReason string `json:"fallback_reason,omitempty"` Error string `json:"error,omitempty"` } @@ -85,6 +88,7 @@ func newCaseResult(testCase ScaleCase, mode ExecutionMode, params map[string]any Status: StatusOK, Cypher: testCase.Cypher, Params: params, + NodeParams: testCase.NodeParams, ExpectedRowCount: testCase.Expected.RowCount, } } From 216c9002cfccdec221796f9383b488e9d299ede6 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 17:29:25 -0700 Subject: [PATCH 092/116] Add graphbench comparison reports --- cmd/graphbench/main.go | 24 +++ cmd/graphbench/results.go | 81 +++++++++ cmd/graphbench/summary.go | 305 +++++++++++++++++++++++++++++++++ cmd/graphbench/summary_test.go | 84 +++++++++ 4 files changed, 494 insertions(+) create mode 100644 cmd/graphbench/summary.go create mode 100644 cmd/graphbench/summary_test.go diff --git a/cmd/graphbench/main.go b/cmd/graphbench/main.go index a1992f50..b4ac4102 100644 --- a/cmd/graphbench/main.go +++ b/cmd/graphbench/main.go @@ -34,6 +34,9 @@ type config struct { Modes []ExecutionMode Iterations int OutputJSONL string + Summary string + SummaryJSON string + Baseline string } func parseConfig(args []string, env func(string) string) (config, error) { @@ -53,6 +56,9 @@ func parseConfig(args []string, env func(string) string) (config, error) { flags.StringVar(&rawModes, "modes", string(ModePostgresSQL), "comma-separated execution modes") flags.IntVar(&cfg.Iterations, "iterations", 3, "timed iterations per case") flags.StringVar(&cfg.OutputJSONL, "jsonl-output", "", "JSONL output path (default: stdout)") + flags.StringVar(&cfg.Summary, "summary", "", "markdown summary output path") + flags.StringVar(&cfg.SummaryJSON, "summary-json", "", "JSON summary output path") + flags.StringVar(&cfg.Baseline, "baseline", "", "previous JSONL output for baseline comparison") if err := flags.Parse(args); err != nil { return config{}, err @@ -172,7 +178,25 @@ func main() { } } + if cfg.Baseline != "" { + if err := applyBaseline(cfg.Baseline, records); err != nil { + fatal("compare baseline: %v", err) + } + } + if err := writeJSONLFile(cfg.OutputJSONL, records); err != nil { fatal("write JSONL: %v", err) } + + summary := buildSummary(records) + if cfg.Summary != "" { + if err := writeMarkdownSummaryFile(cfg.Summary, summary); err != nil { + fatal("write markdown summary: %v", err) + } + } + if cfg.SummaryJSON != "" { + if err := writeJSONSummaryFile(cfg.SummaryJSON, summary); err != nil { + fatal("write JSON summary: %v", err) + } + } } diff --git a/cmd/graphbench/results.go b/cmd/graphbench/results.go index db18a9ba..98384df0 100644 --- a/cmd/graphbench/results.go +++ b/cmd/graphbench/results.go @@ -18,9 +18,11 @@ package main import ( "encoding/json" + "errors" "fmt" "io" "os" + "path/filepath" "sort" "time" @@ -74,10 +76,18 @@ type CaseResult struct { Neo4jPlan *Neo4jPlanNode `json:"neo4j_plan,omitempty"` Neo4jOperators []string `json:"neo4j_operators,omitempty"` Optimization *translate.OptimizationSummary `json:"optimization,omitempty"` + Baseline *BaselineComparison `json:"baseline,omitempty"` FallbackReason string `json:"fallback_reason,omitempty"` Error string `json:"error,omitempty"` } +type BaselineComparison struct { + BaselineMedian time.Duration `json:"baseline_median"` + CurrentMedian time.Duration `json:"current_median"` + Change time.Duration `json:"change"` + Ratio float64 `json:"ratio"` +} + func newCaseResult(testCase ScaleCase, mode ExecutionMode, params map[string]any) CaseResult { return CaseResult{ Source: testCase.Source, @@ -119,6 +129,10 @@ func writeJSONLFile(path string, records []CaseResult) error { return writeJSONL(os.Stdout, records) } + if err := ensureOutputDir(path); err != nil { + return err + } + output, err := os.Create(path) if err != nil { return err @@ -138,3 +152,70 @@ func writeJSONL(w io.Writer, records []CaseResult) error { return nil } + +func readJSONLFile(path string) ([]CaseResult, error) { + input, err := os.Open(path) + if err != nil { + return nil, err + } + defer input.Close() + + decoder := json.NewDecoder(input) + var records []CaseResult + for { + var record CaseResult + if err := decoder.Decode(&record); err != nil { + if errors.Is(err, io.EOF) { + break + } + + return nil, err + } + + records = append(records, record) + } + + return records, nil +} + +func ensureOutputDir(path string) error { + dir := filepath.Dir(path) + if dir == "." || dir == "" { + return nil + } + + return os.MkdirAll(dir, 0o755) +} + +func applyBaseline(path string, records []CaseResult) error { + baseline, err := readJSONLFile(path) + if err != nil { + return err + } + + byKey := make(map[string]CaseResult, len(baseline)) + for _, record := range baseline { + byKey[resultKey(record.Dataset, record.Name, record.ExecutionMode)] = record + } + + for idx := range records { + record := &records[idx] + previous, found := byKey[resultKey(record.Dataset, record.Name, record.ExecutionMode)] + if !found || previous.Stats.Iterations == 0 || record.Stats.Iterations == 0 || previous.Stats.Median == 0 { + continue + } + + record.Baseline = &BaselineComparison{ + BaselineMedian: previous.Stats.Median, + CurrentMedian: record.Stats.Median, + Change: record.Stats.Median - previous.Stats.Median, + Ratio: float64(record.Stats.Median) / float64(previous.Stats.Median), + } + } + + return nil +} + +func resultKey(dataset, name string, mode ExecutionMode) string { + return dataset + "\x00" + name + "\x00" + string(mode) +} diff --git a/cmd/graphbench/summary.go b/cmd/graphbench/summary.go new file mode 100644 index 00000000..89c78972 --- /dev/null +++ b/cmd/graphbench/summary.go @@ -0,0 +1,305 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "sort" + "strings" + "time" +) + +type Summary struct { + GeneratedAt time.Time `json:"generated_at"` + Modes []ModeSummary `json:"modes"` + Cases []CaseSummary `json:"cases"` + Regressions []BaselineEntry `json:"regressions,omitempty"` + Improvements []BaselineEntry `json:"improvements,omitempty"` +} + +type ModeSummary struct { + Mode ExecutionMode `json:"mode"` + Total int `json:"total"` + OK int `json:"ok"` + RowMismatch int `json:"row_mismatch"` + Error int `json:"error"` + NotImplemented int `json:"not_implemented"` +} + +type CaseSummary struct { + Source string `json:"source"` + Dataset string `json:"dataset"` + Name string `json:"name"` + Category string `json:"category"` + Modes map[ExecutionMode]ModeCaseCell `json:"modes"` +} + +type ModeCaseCell struct { + Status string `json:"status"` + Rows int64 `json:"rows,omitempty"` + Median time.Duration `json:"median,omitempty"` + Baseline *BaselineComparison `json:"baseline,omitempty"` + FallbackReason string `json:"fallback_reason,omitempty"` + Error string `json:"error,omitempty"` +} + +type BaselineEntry struct { + Dataset string `json:"dataset"` + Name string `json:"name"` + Mode ExecutionMode `json:"mode"` + BaselineMedian time.Duration `json:"baseline_median"` + CurrentMedian time.Duration `json:"current_median"` + Ratio float64 `json:"ratio"` +} + +func buildSummary(records []CaseResult) Summary { + summary := Summary{ + GeneratedAt: time.Now().UTC(), + } + + modeSummaries := map[ExecutionMode]*ModeSummary{} + caseSummaries := map[string]*CaseSummary{} + + for _, record := range records { + modeSummary := modeSummaries[record.ExecutionMode] + if modeSummary == nil { + modeSummary = &ModeSummary{Mode: record.ExecutionMode} + modeSummaries[record.ExecutionMode] = modeSummary + } + modeSummary.Total++ + + switch record.Status { + case StatusOK: + modeSummary.OK++ + case StatusRowMismatch: + modeSummary.RowMismatch++ + case StatusError: + modeSummary.Error++ + case StatusNotImplemented: + modeSummary.NotImplemented++ + } + + caseKey := record.Source + "\x00" + record.Dataset + "\x00" + record.Name + caseSummary := caseSummaries[caseKey] + if caseSummary == nil { + caseSummary = &CaseSummary{ + Source: record.Source, + Dataset: record.Dataset, + Name: record.Name, + Category: record.Category, + Modes: map[ExecutionMode]ModeCaseCell{}, + } + caseSummaries[caseKey] = caseSummary + } + + caseSummary.Modes[record.ExecutionMode] = ModeCaseCell{ + Status: record.Status, + Rows: record.RowCount, + Median: record.Stats.Median, + Baseline: record.Baseline, + FallbackReason: record.FallbackReason, + Error: record.Error, + } + + if record.Baseline != nil { + entry := BaselineEntry{ + Dataset: record.Dataset, + Name: record.Name, + Mode: record.ExecutionMode, + BaselineMedian: record.Baseline.BaselineMedian, + CurrentMedian: record.Baseline.CurrentMedian, + Ratio: record.Baseline.Ratio, + } + if record.Baseline.Ratio > 1 { + summary.Regressions = append(summary.Regressions, entry) + } else if record.Baseline.Ratio < 1 { + summary.Improvements = append(summary.Improvements, entry) + } + } + } + + for _, modeSummary := range modeSummaries { + summary.Modes = append(summary.Modes, *modeSummary) + } + sort.Slice(summary.Modes, func(i, j int) bool { + return summary.Modes[i].Mode < summary.Modes[j].Mode + }) + + for _, caseSummary := range caseSummaries { + summary.Cases = append(summary.Cases, *caseSummary) + } + sort.Slice(summary.Cases, func(i, j int) bool { + if summary.Cases[i].Dataset != summary.Cases[j].Dataset { + return summary.Cases[i].Dataset < summary.Cases[j].Dataset + } + + return summary.Cases[i].Name < summary.Cases[j].Name + }) + + sortBaselineEntries(summary.Regressions, true) + sortBaselineEntries(summary.Improvements, false) + return summary +} + +func sortBaselineEntries(entries []BaselineEntry, descending bool) { + sort.Slice(entries, func(i, j int) bool { + if descending { + return entries[i].Ratio > entries[j].Ratio + } + + return entries[i].Ratio < entries[j].Ratio + }) +} + +func writeMarkdownSummaryFile(path string, summary Summary) error { + if err := ensureOutputDir(path); err != nil { + return err + } + + output, err := os.Create(path) + if err != nil { + return err + } + defer output.Close() + + return writeMarkdownSummary(output, summary) +} + +func writeJSONSummaryFile(path string, summary Summary) error { + if err := ensureOutputDir(path); err != nil { + return err + } + + output, err := os.Create(path) + if err != nil { + return err + } + defer output.Close() + + encoder := json.NewEncoder(output) + encoder.SetIndent("", " ") + return encoder.Encode(summary) +} + +func writeMarkdownSummary(w io.Writer, summary Summary) error { + fmt.Fprintf(w, "# GraphBench Summary\n\n") + fmt.Fprintf(w, "Generated: %s\n\n", summary.GeneratedAt.Format(time.RFC3339)) + + fmt.Fprintf(w, "## Modes\n\n") + fmt.Fprintf(w, "| Mode | Total | OK | Row Mismatch | Error | Not Implemented |\n") + fmt.Fprintf(w, "| --- | ---: | ---: | ---: | ---: | ---: |\n") + for _, mode := range summary.Modes { + fmt.Fprintf(w, "| %s | %d | %d | %d | %d | %d |\n", + mode.Mode, + mode.Total, + mode.OK, + mode.RowMismatch, + mode.Error, + mode.NotImplemented, + ) + } + + fmt.Fprintf(w, "\n## Cases\n\n") + fmt.Fprintf(w, "| Case | Dataset | Category | postgres_sql | local_traversal | neo4j |\n") + fmt.Fprintf(w, "| --- | --- | --- | --- | --- | --- |\n") + for _, testCase := range summary.Cases { + fmt.Fprintf(w, "| %s | %s | %s | %s | %s | %s |\n", + escapeMarkdown(testCase.Name), + escapeMarkdown(testCase.Dataset), + escapeMarkdown(testCase.Category), + formatModeCell(testCase.Modes[ModePostgresSQL]), + formatModeCell(testCase.Modes[ModeLocalTraversal]), + formatModeCell(testCase.Modes[ModeNeo4j]), + ) + } + + if len(summary.Regressions) > 0 { + fmt.Fprintf(w, "\n## Baseline Regressions\n\n") + writeBaselineTable(w, summary.Regressions) + } + if len(summary.Improvements) > 0 { + fmt.Fprintf(w, "\n## Baseline Improvements\n\n") + writeBaselineTable(w, summary.Improvements) + } + + return nil +} + +func writeBaselineTable(w io.Writer, entries []BaselineEntry) { + fmt.Fprintf(w, "| Case | Dataset | Mode | Baseline | Current | Ratio |\n") + fmt.Fprintf(w, "| --- | --- | --- | ---: | ---: | ---: |\n") + for _, entry := range entries { + fmt.Fprintf(w, "| %s | %s | %s | %s | %s | %.2fx |\n", + escapeMarkdown(entry.Name), + escapeMarkdown(entry.Dataset), + entry.Mode, + formatDuration(entry.BaselineMedian), + formatDuration(entry.CurrentMedian), + entry.Ratio, + ) + } +} + +func formatModeCell(cell ModeCaseCell) string { + if cell.Status == "" { + return "-" + } + + var parts []string + if cell.Median > 0 { + parts = append(parts, formatDuration(cell.Median)) + if cell.Rows > 0 { + parts = append(parts, fmt.Sprintf("rows=%d", cell.Rows)) + } + } else { + parts = append(parts, cell.Status) + } + + if cell.Status != StatusOK && cell.Median > 0 { + parts = append(parts, cell.Status) + } + if cell.Baseline != nil { + parts = append(parts, fmt.Sprintf("%.2fx", cell.Baseline.Ratio)) + } + if cell.FallbackReason != "" { + parts = append(parts, cell.FallbackReason) + } + if cell.Error != "" { + parts = append(parts, cell.Error) + } + + return escapeMarkdown(strings.Join(parts, "; ")) +} + +func formatDuration(duration time.Duration) string { + ms := float64(duration.Microseconds()) / 1000.0 + if ms < 1 { + return fmt.Sprintf("%.2fms", ms) + } + if ms < 100 { + return fmt.Sprintf("%.1fms", ms) + } + + return fmt.Sprintf("%.0fms", ms) +} + +func escapeMarkdown(value string) string { + return strings.ReplaceAll(value, "|", "\\|") +} diff --git a/cmd/graphbench/summary_test.go b/cmd/graphbench/summary_test.go new file mode 100644 index 00000000..8bfe68f8 --- /dev/null +++ b/cmd/graphbench/summary_test.go @@ -0,0 +1,84 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestApplyBaseline(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "baseline.jsonl") + require.NoError(t, writeJSONLFile(path, []CaseResult{{ + Dataset: "base", + Name: "case", + ExecutionMode: ModePostgresSQL, + Stats: DurationStats{ + Iterations: 1, + Median: 10 * time.Millisecond, + }, + }})) + + records := []CaseResult{{ + Dataset: "base", + Name: "case", + ExecutionMode: ModePostgresSQL, + Stats: DurationStats{ + Iterations: 1, + Median: 15 * time.Millisecond, + }, + }} + + require.NoError(t, applyBaseline(path, records)) + require.NotNil(t, records[0].Baseline) + require.Equal(t, 1.5, records[0].Baseline.Ratio) + require.Equal(t, 5*time.Millisecond, records[0].Baseline.Change) +} + +func TestWriteMarkdownSummary(t *testing.T) { + summary := buildSummary([]CaseResult{ + { + Dataset: "base", + Name: "case", + Category: "counts", + ExecutionMode: ModePostgresSQL, + Status: StatusOK, + RowCount: 1, + Stats: DurationStats{ + Iterations: 1, + Median: 2 * time.Millisecond, + }, + }, + { + Dataset: "base", + Name: "case", + Category: "counts", + ExecutionMode: ModeLocalTraversal, + Status: StatusNotImplemented, + FallbackReason: localTraversalUnavailableReason, + }, + }) + + var output bytes.Buffer + require.NoError(t, writeMarkdownSummary(&output, summary)) + require.Contains(t, output.String(), "| case | base | counts | 2.0ms; rows=1 | not_implemented; local traversal executor unavailable | - |") +} From 795c687eedf7f11c624a907d3193c72eb1c73f14 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 17:30:16 -0700 Subject: [PATCH 093/116] Document graphbench AGE reference workflow --- README.md | 5 ++ benchmark/testdata/scale/README.md | 27 +++++++++-- cmd/graphbench/README.md | 74 ++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 cmd/graphbench/README.md diff --git a/README.md b/README.md index 0dc6296c..bd082303 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,11 @@ for later baseline comparison. `CONNECTION_STRING` for one backend or `PG_CONNECTION_STRING` and `NEO4J_CONNECTION_STRING` for both backends, then writes JSONL captures and markdown/JSON summaries under `.coverage/`. +`go run ./cmd/graphbench` captures runtime diagnostics for the scale corpus under `benchmark/testdata/scale`. The +current modes are `postgres_sql`, `local_traversal`, and `neo4j`; AGE is reference-design input only and is not a direct +comparison mode yet. The command can emit JSONL records plus Markdown and JSON summaries, and can compare current timings +against a previous JSONL baseline. + PostgreSQL translates exact string property equality with a JSON string type guard and `properties ->>` extraction, so indexes created on expressions such as `properties ->> 'objectid'` and `properties ->> 'name'` can be used for selective anchors without matching JSON booleans or numbers. Simple relationship count fast paths depend on the schema's diff --git a/benchmark/testdata/scale/README.md b/benchmark/testdata/scale/README.md index 487ce204..85c2f788 100644 --- a/benchmark/testdata/scale/README.md +++ b/benchmark/testdata/scale/README.md @@ -1,9 +1,28 @@ # GraphBench Scale Corpus This corpus measures graph workload shapes, not general Cypher correctness. -The shared integration corpus remains the source of backend-equivalent semantic coverage. +The shared integration corpus remains the source of backend-equivalent semantic +coverage. -Cases declare the values a query observes so benchmark reports can separate ID-only work from node, relationship, property, and path materialization. -Initial execution modes are `postgres_sql`, `local_traversal`, and `neo4j`. -Apache AGE is intentionally not a benchmark mode here; it may appear only in `reference_design` notes as input for DAWGS design choices. +Cases declare the values a query observes so benchmark reports can separate +ID-only work from node, relationship, property, and path materialization. +Current execution modes are `postgres_sql`, `local_traversal`, and `neo4j`. +Apache AGE is intentionally not a benchmark mode here; it may appear only in +`reference_design` notes as input for DAWGS design choices. +Each JSON file contains a list of scale cases with: + +- `source`: the source corpus or workload family. +- `dataset`: the fixture dataset to load from `integration/testdata`. +- `name` and `category`: stable identifiers used in reports. +- `cypher`: the Cypher query under test. +- `parameters`: named parameter values. +- `expected_rows`: the expected result cardinality. +- `observes`: whether the query observes paths, nodes, relationships, + properties, or only IDs internally. +- `candidate_modes`: the execution modes that should attempt the case. +- `reference_design`: optional design notes, including AGE observations when + useful. + +Use `cmd/graphbench` to run this corpus and produce JSONL, Markdown, and JSON +summaries. diff --git a/cmd/graphbench/README.md b/cmd/graphbench/README.md new file mode 100644 index 00000000..ac530326 --- /dev/null +++ b/cmd/graphbench/README.md @@ -0,0 +1,74 @@ +# GraphBench + +`graphbench` runs the scale benchmark corpus under `benchmark/testdata/scale`. +It is meant for runtime gap accounting: query duration, returned row counts, +PostgreSQL plan details, Neo4j plan operators, fallback reasons, and comparison +summaries. + +The current execution modes are: + +- `postgres_sql`: runs DAWGS' PostgreSQL SQL translation against a PostgreSQL database. +- `local_traversal`: records explicit `not_implemented` placeholders until the local traversal executor lands. +- `neo4j`: runs the same corpus against Neo4j through the DAWGS Neo4j backend. + +Apache AGE is not an execution mode in this harness yet. AGE behavior can be +captured in corpus `reference_design` notes so DAWGS can use it as design input +without treating it as a direct benchmark comparison. + +## Inputs + +The command loads cases from `benchmark/testdata/scale` by default and imports +the fixture datasets from `integration/testdata`. + +Connection strings can be supplied as flags or environment variables: + +- PostgreSQL: `-pg-connection`, `PG_CONNECTION_STRING`, `-connection`, or `CONNECTION_STRING`. +- Neo4j: `-neo4j-connection`, `NEO4J_CONNECTION_STRING`, `-connection`, or `CONNECTION_STRING`. + +## Examples + +Run only PostgreSQL SQL translation: + +```bash +go run ./cmd/graphbench \ + -modes postgres_sql \ + -pg-connection "$PG_CONNECTION_STRING" \ + -jsonl-output .coverage/graphbench-postgres.jsonl \ + -summary .coverage/graphbench-postgres.md \ + -summary-json .coverage/graphbench-postgres.json +``` + +Capture PostgreSQL, local traversal placeholders, and Neo4j in one report: + +```bash +go run ./cmd/graphbench \ + -modes postgres_sql,local_traversal,neo4j \ + -pg-connection "$PG_CONNECTION_STRING" \ + -neo4j-connection "$NEO4J_CONNECTION_STRING" \ + -jsonl-output .coverage/graphbench.jsonl \ + -summary .coverage/graphbench.md \ + -summary-json .coverage/graphbench.json +``` + +Compare a run against a previous JSONL capture: + +```bash +go run ./cmd/graphbench \ + -modes postgres_sql,neo4j \ + -pg-connection "$PG_CONNECTION_STRING" \ + -neo4j-connection "$NEO4J_CONNECTION_STRING" \ + -baseline .coverage/graphbench-baseline.jsonl \ + -jsonl-output .coverage/graphbench.jsonl \ + -summary .coverage/graphbench.md +``` + +## Outputs + +JSONL output contains one `CaseResult` record per case and execution mode. +Markdown and JSON summaries aggregate mode status counts, per-case timings, row +counts, fallback reasons, and baseline regressions or improvements when a +baseline capture is supplied. + +PostgreSQL records include translated SQL and `EXPLAIN (ANALYZE, BUFFERS, +TIMING OFF, FORMAT JSON)` metrics. Neo4j records include plan operator names +when an `EXPLAIN` plan can be captured. From a9cfedeea96d4986986924cc44362b12edbb8431 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 18:30:23 -0700 Subject: [PATCH 094/116] fixup build --- cmd/graphbench/postgres.go | 8 ++++++-- cmd/plancorpus/capture.go | 8 ++++++-- go.mod | 5 ----- go.sum | 7 ++----- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/cmd/graphbench/postgres.go b/cmd/graphbench/postgres.go index 7e6dfb0b..2ea47784 100644 --- a/cmd/graphbench/postgres.go +++ b/cmd/graphbench/postgres.go @@ -23,10 +23,10 @@ import ( "strconv" "strings" + "github.com/jackc/pgx/v5/pgxpool" "github.com/specterops/dawgs" "github.com/specterops/dawgs/cypher/frontend" "github.com/specterops/dawgs/cypher/models/pgsql/translate" - "github.com/specterops/dawgs/drivers" "github.com/specterops/dawgs/drivers/pg" "github.com/specterops/dawgs/graph" "github.com/specterops/dawgs/opengraph" @@ -41,7 +41,11 @@ type postgresSQLRunner struct { } func newPostgresSQLRunner(ctx context.Context, datasetDir, connection string, corpus ScaleCorpus) (*postgresSQLRunner, error) { - pool, err := pg.NewPool(drivers.DatabaseConfiguration{Connection: connection}) + poolCfg, err := pgxpool.ParseConfig(connection) + if err != nil { + return nil, fmt.Errorf("parse PostgreSQL pool configuration: %w", err) + } + pool, err := pg.NewPool(poolCfg) if err != nil { return nil, fmt.Errorf("create PostgreSQL pool: %w", err) } diff --git a/cmd/plancorpus/capture.go b/cmd/plancorpus/capture.go index 4b746e51..ffb55c31 100644 --- a/cmd/plancorpus/capture.go +++ b/cmd/plancorpus/capture.go @@ -9,12 +9,12 @@ import ( "sort" "strings" + "github.com/jackc/pgx/v5/pgxpool" neo4jcore "github.com/neo4j/neo4j-go-driver/v5/neo4j" "github.com/specterops/dawgs" "github.com/specterops/dawgs/cypher/frontend" "github.com/specterops/dawgs/cypher/models/pgsql/optimize" "github.com/specterops/dawgs/cypher/models/pgsql/translate" - "github.com/specterops/dawgs/drivers" "github.com/specterops/dawgs/drivers/neo4j" "github.com/specterops/dawgs/drivers/pg" "github.com/specterops/dawgs/graph" @@ -165,7 +165,11 @@ func openBackend(ctx context.Context, suite corpus, spec captureSpec) (*backendC } if spec.DriverName == pg.DriverName { - pool, err := pg.NewPool(drivers.DatabaseConfiguration{Connection: spec.Connection}) + poolCfg, err := pgxpool.ParseConfig(spec.Connection) + if err != nil { + return nil, fmt.Errorf("parse PostgreSQL pool configuration: %w", err) + } + pool, err := pg.NewPool(poolCfg) if err != nil { return nil, fmt.Errorf("create PostgreSQL pool: %w", err) } diff --git a/go.mod b/go.mod index d507391d..8d3a5014 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,6 @@ require ( github.com/fzipp/gocyclo v0.6.0 github.com/gammazero/deque v1.2.1 github.com/jackc/pgtype v1.14.4 - github.com/jackc/pgx/v4 v4.18.2 github.com/jackc/pgx/v5 v5.9.2 github.com/neo4j/neo4j-go-driver/v5 v5.28.4 github.com/stretchr/testify v1.11.1 @@ -125,13 +124,9 @@ require ( github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hexops/gotextdiff v1.0.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/jackc/chunkreader/v2 v2.0.1 // indirect - github.com/jackc/pgconn v1.14.3 // indirect github.com/jackc/pgio v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgproto3/v2 v2.3.3 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/puddle v1.3.0 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jgautheron/goconst v1.8.2 // indirect github.com/jingyugao/rowserrcheck v1.1.1 // indirect diff --git a/go.sum b/go.sum index 1c7c760d..40995d1e 100644 --- a/go.sum +++ b/go.sum @@ -110,7 +110,6 @@ github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQ github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/ckaznocha/intrange v0.3.1 h1:j1onQyXvHUsPWujDH6WIjhyH26gkRt/txNlV7LspvJs= github.com/ckaznocha/intrange v0.3.1/go.mod h1:QVepyz1AkUoFQkpEqksSYpNpUo3c5W7nWh/s6SHIJJk= -github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= github.com/cockroachdb/apd/v3 v3.2.2 h1:R1VaDQkMR321HBM6+6b2eYZfxi0ybPJgUh0Ztr7twzU= github.com/cockroachdb/apd/v3 v3.2.2/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc= @@ -196,7 +195,6 @@ github.com/godoc-lint/godoc-lint v0.11.2 h1:Bp0FkJWoSdNsBikdNgIcgtaoo+xz6I/Y9s5W github.com/godoc-lint/godoc-lint v0.11.2/go.mod h1:iVpGdL1JCikNH2gGeAn3Hh+AgN5Gx/I/cxV+91L41jo= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= -github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/golangci/asciicheck v0.5.0 h1:jczN/BorERZwK8oiFBOGvlGPknhvq0bjnysTj4nUfo0= github.com/golangci/asciicheck v0.5.0/go.mod h1:5RMNAInbNFw2krqN6ibBxN/zfRFa9S6tA1nPdM0l8qQ= @@ -259,6 +257,7 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0= github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= @@ -275,10 +274,10 @@ github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE= github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c= -github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc= github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A= github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= @@ -310,7 +309,6 @@ github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= -github.com/jackc/puddle v1.3.0 h1:eHK/5clGOatcjX3oWGBO/MpxpbHzSwud5EWTSCI+MX0= github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= @@ -496,7 +494,6 @@ github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08/go.mod h1:+XL github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= -github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= From 80df7936dc98b9a3cacf6230fb5898d37257d48f Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 18:38:42 -0700 Subject: [PATCH 095/116] Harden benchmark resource handling --- cmd/graphbench/measure.go | 12 ++++++++- cmd/graphbench/neo4j.go | 28 ++++++++++--------- cmd/graphbench/results.go | 21 +++++++++------ cmd/graphbench/results_test.go | 49 ++++++++++++++++++++++++++++++++++ cmd/plancorpus/capture.go | 3 +++ 5 files changed, 92 insertions(+), 21 deletions(-) create mode 100644 cmd/graphbench/results_test.go diff --git a/cmd/graphbench/measure.go b/cmd/graphbench/measure.go index b41f91ae..7aaa7a93 100644 --- a/cmd/graphbench/measure.go +++ b/cmd/graphbench/measure.go @@ -18,6 +18,7 @@ package main import ( "context" + "fmt" "time" "github.com/specterops/dawgs/graph" @@ -36,6 +37,10 @@ func countCypherRows(tx graph.Transaction, cypher string, params map[string]any) } func measureCypher(ctx context.Context, db graph.Database, cypher string, params map[string]any, iterations int) (int64, DurationStats, error) { + if iterations < 1 { + return 0, DurationStats{}, fmt.Errorf("iterations must be at least 1") + } + var warmupRows int64 if err := db.ReadTransaction(ctx, func(tx graph.Transaction) error { var err error @@ -57,5 +62,10 @@ func measureCypher(ctx context.Context, db graph.Database, cypher string, params durations[idx] = time.Since(start) } - return warmupRows, computeDurationStats(durations), nil + stats, err := computeDurationStats(durations) + if err != nil { + return 0, DurationStats{}, err + } + + return warmupRows, stats, nil } diff --git a/cmd/graphbench/neo4j.go b/cmd/graphbench/neo4j.go index 9c720eed..1756c7a3 100644 --- a/cmd/graphbench/neo4j.go +++ b/cmd/graphbench/neo4j.go @@ -33,7 +33,7 @@ import ( type neo4jRunner struct { datasetDir string db graph.Database - planDriver neo4jcore.Driver + planDriver neo4jcore.DriverWithContext databaseName string } @@ -74,7 +74,7 @@ func newNeo4jRunner(ctx context.Context, datasetDir, connection string, corpus S func (s *neo4jRunner) Close(ctx context.Context) error { var closeErr error if s.planDriver != nil { - closeErr = s.planDriver.Close() + closeErr = s.planDriver.Close(ctx) } if s.db != nil { if err := s.db.Close(ctx); err != nil && closeErr == nil { @@ -132,7 +132,7 @@ func (s *neo4jRunner) runCase(ctx context.Context, iterations int, testCase Scal record.Stats = stats applyRowExpectation(&record) - plan, operators, err := s.explain(testCase.Cypher, params) + plan, operators, err := s.explain(ctx, testCase.Cypher, params) if err != nil { if record.Status == StatusOK { record.Status = StatusError @@ -146,19 +146,23 @@ func (s *neo4jRunner) runCase(ctx context.Context, iterations int, testCase Scal return record } -func (s *neo4jRunner) explain(cypherQuery string, params map[string]any) (*Neo4jPlanNode, []string, error) { - session := s.planDriver.NewSession(neo4jcore.SessionConfig{ +func (s *neo4jRunner) explain(ctx context.Context, cypherQuery string, params map[string]any) (plan *Neo4jPlanNode, operators []string, err error) { + session := s.planDriver.NewSession(ctx, neo4jcore.SessionConfig{ AccessMode: neo4jcore.AccessModeRead, DatabaseName: s.databaseName, }) - defer session.Close() + defer func() { + if closeErr := session.Close(ctx); err == nil && closeErr != nil { + err = closeErr + } + }() - result, err := session.Run("EXPLAIN "+cypherWithoutTerminator(cypherQuery), params) + result, err := session.Run(ctx, "EXPLAIN "+cypherWithoutTerminator(cypherQuery), params) if err != nil { return nil, nil, err } - summary, err := result.Consume() + summary, err := result.Consume(ctx) if err != nil { return nil, nil, err } @@ -166,8 +170,8 @@ func (s *neo4jRunner) explain(cypherQuery string, params map[string]any) (*Neo4j return nil, nil, nil } - plan := convertNeo4jPlan(summary.Plan()) - return &plan, neo4jOperators(plan), nil + planNode := convertNeo4jPlan(summary.Plan()) + return &planNode, neo4jOperators(planNode), nil } type neo4jPlanDriverConfig struct { @@ -229,13 +233,13 @@ func neo4jDatabaseName(connectionURL *url.URL) (string, error) { return databaseName, nil } -func openNeo4jPlanDriver(connStr string) (neo4jcore.Driver, string, error) { +func openNeo4jPlanDriver(connStr string) (neo4jcore.DriverWithContext, string, error) { cfg, err := parseNeo4jPlanDriverConfig(connStr) if err != nil { return nil, "", err } - driver, err := neo4jcore.NewDriver(cfg.Target, neo4jcore.BasicAuth(cfg.Username, cfg.Password, "")) + driver, err := neo4jcore.NewDriverWithContext(cfg.Target, neo4jcore.BasicAuth(cfg.Username, cfg.Password, "")) if err != nil { return nil, "", err } diff --git a/cmd/graphbench/results.go b/cmd/graphbench/results.go index 98384df0..27d16093 100644 --- a/cmd/graphbench/results.go +++ b/cmd/graphbench/results.go @@ -103,18 +103,23 @@ func newCaseResult(testCase ScaleCase, mode ExecutionMode, params map[string]any } } -func computeDurationStats(durations []time.Duration) DurationStats { - sort.Slice(durations, func(i, j int) bool { - return durations[i] < durations[j] +func computeDurationStats(durations []time.Duration) (DurationStats, error) { + if len(durations) == 0 { + return DurationStats{}, fmt.Errorf("duration stats require at least one duration") + } + + sortedDurations := append([]time.Duration(nil), durations...) + sort.Slice(sortedDurations, func(i, j int) bool { + return sortedDurations[i] < sortedDurations[j] }) - n := len(durations) + n := len(sortedDurations) return DurationStats{ Iterations: n, - Median: durations[n/2], - P95: durations[n*95/100], - Max: durations[n-1], - } + Median: sortedDurations[n/2], + P95: sortedDurations[min(n*95/100, n-1)], + Max: sortedDurations[n-1], + }, nil } func applyRowExpectation(result *CaseResult) { diff --git a/cmd/graphbench/results_test.go b/cmd/graphbench/results_test.go new file mode 100644 index 00000000..11671641 --- /dev/null +++ b/cmd/graphbench/results_test.go @@ -0,0 +1,49 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestComputeDurationStatsRejectsEmptyDurations(t *testing.T) { + _, err := computeDurationStats(nil) + + require.ErrorContains(t, err, "at least one duration") +} + +func TestComputeDurationStatsCopiesAndSortsDurations(t *testing.T) { + durations := []time.Duration{ + 30 * time.Millisecond, + 10 * time.Millisecond, + 20 * time.Millisecond, + } + + stats, err := computeDurationStats(durations) + + require.NoError(t, err) + require.Equal(t, 3, stats.Iterations) + require.Equal(t, 20*time.Millisecond, stats.Median) + require.Equal(t, 30*time.Millisecond, stats.P95) + require.Equal(t, 30*time.Millisecond, stats.Max) + require.Equal(t, 30*time.Millisecond, durations[0]) + require.Equal(t, 10*time.Millisecond, durations[1]) + require.Equal(t, 20*time.Millisecond, durations[2]) +} diff --git a/cmd/plancorpus/capture.go b/cmd/plancorpus/capture.go index ffb55c31..6f5c5f0d 100644 --- a/cmd/plancorpus/capture.go +++ b/cmd/plancorpus/capture.go @@ -178,6 +178,9 @@ func openBackend(ctx context.Context, suite corpus, spec captureSpec) (*backendC db, err := dawgs.Open(ctx, spec.DriverName, cfg) if err != nil { + if cfg.Pool != nil { + cfg.Pool.Close() + } return nil, fmt.Errorf("open %s database: %w", spec.DriverName, err) } From 489938a2420b7fd6e102d1a6ad1c8b314af3381b Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 18:53:58 -0700 Subject: [PATCH 096/116] Fix optimizer and translation edge cases --- cypher/models/pgsql/optimize/lowering_plan.go | 62 ++++++++++++++++++- .../models/pgsql/optimize/optimizer_test.go | 27 ++++++++ .../pgsql/test/translation_cases/nodes.sql | 2 +- cypher/models/pgsql/translate/expansion.go | 49 ++++++++++----- cypher/models/pgsql/translate/expression.go | 24 +++++-- .../models/pgsql/translate/expression_test.go | 20 ++++++ cypher/models/pgsql/translate/hinting.go | 12 +++- .../pgsql/translate/optimizer_safety_test.go | 37 +++++++++-- 8 files changed, 203 insertions(+), 30 deletions(-) diff --git a/cypher/models/pgsql/optimize/lowering_plan.go b/cypher/models/pgsql/optimize/lowering_plan.go index 5c3b5556..6aba76ad 100644 --- a/cypher/models/pgsql/optimize/lowering_plan.go +++ b/cypher/models/pgsql/optimize/lowering_plan.go @@ -58,7 +58,12 @@ func BuildLoweringPlan(query *cypher.RegularQuery, predicateAttachments []Predic return LoweringPlan{}, err } - carriedSymbols, carriedSelectivity = carryProjectionSelectivity(part.With.Projection, carriedSelectivity) + currentSymbols := copyStringSet(carriedSymbols) + currentSelectivity := copyBoundSourceSelectivity(carriedSelectivity) + declareReadingClauseSymbols(currentSymbols, part.ReadingClauses) + declareReadingClauseSelectivity(currentSelectivity, part.ReadingClauses) + + carriedSymbols, carriedSelectivity = carryProjectionSelectivity(part.With.Projection, currentSymbols, currentSelectivity) } if finalPart := query.SingleQuery.MultiPartQuery.SinglePartQuery; finalPart != nil { @@ -435,7 +440,11 @@ func copyBoundSourceSelectivity(values map[string]boundSourceSelectivity) map[st return copied } -func carryProjectionSelectivity(projection *cypher.Projection, incoming map[string]boundSourceSelectivity) (map[string]struct{}, map[string]boundSourceSelectivity) { +func carryProjectionSelectivity( + projection *cypher.Projection, + incomingSymbols map[string]struct{}, + incomingSelectivity map[string]boundSourceSelectivity, +) (map[string]struct{}, map[string]boundSourceSelectivity) { carriedSymbols := map[string]struct{}{} carriedSelectivity := map[string]boundSourceSelectivity{} @@ -444,20 +453,51 @@ func carryProjectionSelectivity(projection *cypher.Projection, incoming map[stri } projectionSelectivity := projectionCardinalitySelectivity(projection) + if projectionCarriesAllSymbols(projection) { + for symbol := range incomingSymbols { + addSymbol(carriedSymbols, symbol) + mergeBoundSourceSelectivity(carriedSelectivity, symbol, incomingSelectivity[symbol]) + mergeBoundSourceSelectivity(carriedSelectivity, symbol, projectionSelectivity) + } + } + for _, item := range projection.Items { symbol, alias, ok := projectionItemVariableSymbolAndAlias(item) if !ok { continue } + if symbol == cypher.TokenLiteralAsterisk { + continue + } addSymbol(carriedSymbols, alias) - mergeBoundSourceSelectivity(carriedSelectivity, alias, incoming[symbol]) + mergeBoundSourceSelectivity(carriedSelectivity, alias, incomingSelectivity[symbol]) mergeBoundSourceSelectivity(carriedSelectivity, alias, projectionSelectivity) } return carriedSymbols, carriedSelectivity } +func projectionCarriesAllSymbols(projection *cypher.Projection) bool { + if projection == nil { + return false + } + if projection.All || len(projection.Items) == 0 { + return true + } + + for _, item := range projection.Items { + if symbol, _, ok := projectionItemVariableSymbolAndAlias(item); ok && symbol == cypher.TokenLiteralAsterisk { + return true + } + if symbol, ok := expressionVariableSymbol(item); ok && symbol == cypher.TokenLiteralAsterisk { + return true + } + } + + return false +} + func projectionCardinalitySelectivity(projection *cypher.Projection) boundSourceSelectivity { if projection == nil || projection.Limit == nil { return boundSourceSelectivityNone @@ -531,6 +571,22 @@ func declareSelectiveMatchSymbols(symbols map[string]boundSourceSelectivity, mat } } +func declareReadingClauseSymbols(symbols map[string]struct{}, readingClauses []*cypher.ReadingClause) { + for _, readingClause := range readingClauses { + if readingClause != nil { + declareMatchSymbols(symbols, readingClause.Match) + } + } +} + +func declareReadingClauseSelectivity(symbols map[string]boundSourceSelectivity, readingClauses []*cypher.ReadingClause) { + for _, readingClause := range readingClauses { + if readingClause != nil { + declareSelectiveMatchSymbols(symbols, readingClause.Match) + } + } +} + func nodePatternsForPattern(patternPart *cypher.PatternPart) []*cypher.NodePattern { if patternPart == nil { return nil diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index 3a31ace8..4ed3ec0f 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -781,6 +781,33 @@ func TestLoweringPlanSkipsBoundLeftDirectionAfterPriorLimit(t *testing.T) { }}, plan.LoweringPlan.TraversalDirection) } +func TestLoweringPlanSkipsBoundLeftDirectionAfterGreedyProjectionLimit(t *testing.T) { + t.Parallel() + + regularQuery, err := frontend.ParseCypher(frontend.NewContext(), ` + MATCH (u:User) + WHERE u.hasspn = true + WITH * + LIMIT 10 + MATCH (u)-[:MemberOf|AdminTo*1..]->(c:Computer {name: 'target'}) + RETURN c + `) + require.NoError(t, err) + + plan, err := Optimize(regularQuery) + require.NoError(t, err) + require.Contains(t, plan.LoweringPlan.Decisions(), LoweringDecision{Name: LoweringTraversalDirection}) + require.Equal(t, []TraversalDirectionDecision{{ + Target: TraversalStepTarget{ + QueryPartIndex: 1, + ClauseIndex: 0, + PatternIndex: 0, + StepIndex: 0, + }, + Reason: traversalDirectionReasonBoundSourceSelective, + }}, plan.LoweringPlan.TraversalDirection) +} + func TestLoweringPlanAllowsUniqueRightEndpointAfterPriorLimit(t *testing.T) { t.Parallel() diff --git a/cypher/models/pgsql/test/translation_cases/nodes.sql b/cypher/models/pgsql/test/translation_cases/nodes.sql index 1fa6e1c8..8565f7b4 100644 --- a/cypher/models/pgsql/test/translation_cases/nodes.sql +++ b/cypher/models/pgsql/test/translation_cases/nodes.sql @@ -220,7 +220,7 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0) select s0.n0 as s from s0 where (not (with s1 as (select s0.n0 as n0 from edge e0 join node n1 on (jsonb_typeof((n1.properties -> 'name')) = 'string' and (n1.properties ->> 'name') = 'n3') and n1.id = e0.start_id where (jsonb_typeof((e0.properties -> 'prop')) = 'string' and (e0.properties ->> 'prop') = 'a') and (s0.n0).id = e0.end_id) select count(*) > 0 from s1)); -- case: match (n:NodeKind1) where n.distinguishedname = toUpper('admin') return n -with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((n0.properties ->> 'distinguishedname') = upper('admin')::text) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s0.n0 as n from s0; +with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((jsonb_typeof((n0.properties -> 'distinguishedname')) = 'string' and (n0.properties ->> 'distinguishedname') = upper('admin')::text)) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s0.n0 as n from s0; -- case: match (n:NodeKind1) where n.distinguishedname starts with toUpper('admin') return n with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (cypher_starts_with((n0.properties ->> 'distinguishedname'), (upper('admin')::text)::text)::bool) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s0.n0 as n from s0; diff --git a/cypher/models/pgsql/translate/expansion.go b/cypher/models/pgsql/translate/expansion.go index 32c3ffcb..5cfee834 100644 --- a/cypher/models/pgsql/translate/expansion.go +++ b/cypher/models/pgsql/translate/expansion.go @@ -250,17 +250,20 @@ func (s *ExpansionBuilder) seedEndpointConstraintSplit(expression pgsql.Expressi return partitionConstraintByLocality(seedExpression, localScope) } -func (s *ExpansionBuilder) appendUnwindSourcesIfReferenced(selectBody *pgsql.Select, expression pgsql.Expression) error { - if referencesUnwind, err := expressionReferencesUnwindBinding(expression, s.unwindClauses); err != nil { - return err - } else if referencesUnwind { - var previousFrame *Frame - if s.traversalStep != nil && s.traversalStep.Frame != nil { - previousFrame = s.traversalStep.Frame.Previous - } +func (s *ExpansionBuilder) appendUnwindSourcesIfReferenced(selectBody *pgsql.Select, expressions ...pgsql.Expression) error { + for _, expression := range expressions { + if referencesUnwind, err := expressionReferencesUnwindBinding(expression, s.unwindClauses); err != nil { + return err + } else if referencesUnwind { + var previousFrame *Frame + if s.traversalStep != nil && s.traversalStep.Frame != nil { + previousFrame = s.traversalStep.Frame.Previous + } - selectBody.From = prependFrameSourceIfMissing(selectBody.From, previousFrame) - selectBody.From = append(selectBody.From, s.unwindSources...) + selectBody.From = prependFrameSourceIfMissing(selectBody.From, previousFrame) + selectBody.From = append(selectBody.From, s.unwindSources...) + return nil + } } return nil @@ -1062,6 +1065,16 @@ func (s *ExpansionBuilder) forwardTerminalSatisfaction(expansionModel *Expansion return satisfiedSelectItem } +func forwardTerminalSatisfactionProjection(expansionModel *Expansion) pgsql.Expression { + if expansionModel.TerminalNodeSatisfactionProjection != nil && + !expansionModel.UseMaterializedTerminalFilter && + !expansionModel.UseMaterializedEndpointPairFilter { + return pgsql.Expression(expansionModel.TerminalNodeSatisfactionProjection) + } + + return nil +} + func backwardContinuationSatisfaction(expansionModel *Expansion) pgsql.Expression { return pgsql.ExistsExpression{ Subquery: pgsql.Subquery{ @@ -1105,6 +1118,14 @@ func (s *ExpansionBuilder) backwardTerminalSatisfaction(expansionModel *Expansio return satisfiedSelectItem } +func backwardTerminalSatisfactionProjection(expansionModel *Expansion) pgsql.Expression { + if expansionModel.PrimerNodeSatisfactionProjection != nil && !expansionModel.UseMaterializedEndpointPairFilter { + return pgsql.Expression(expansionModel.PrimerNodeSatisfactionProjection) + } + + return nil +} + func (s *ExpansionBuilder) prepareForwardFrontPrimerQuery(expansionModel *Expansion) (pgsql.Query, pgsql.Expression, error) { var ( primerSeedConstraints pgsql.Expression @@ -1190,7 +1211,7 @@ func (s *ExpansionBuilder) prepareForwardFrontPrimerQuery(expansionModel *Expans } nextQuery.From = []pgsql.FromClause{nextQueryFrom} - if err := s.appendUnwindSourcesIfReferenced(&nextQuery, expansionModel.EdgeConstraints); err != nil { + if err := s.appendUnwindSourcesIfReferenced(&nextQuery, expansionModel.EdgeConstraints, forwardTerminalSatisfactionProjection(expansionModel)); err != nil { return pgsql.Query{}, nil, err } @@ -1284,7 +1305,7 @@ func (s *ExpansionBuilder) prepareForwardFrontRecursiveQuery(expansionModel *Exp } nextQuery.From = []pgsql.FromClause{nextQueryFrom} - if err := s.appendUnwindSourcesIfReferenced(&nextQuery, expansionModel.EdgeConstraints); err != nil { + if err := s.appendUnwindSourcesIfReferenced(&nextQuery, expansionModel.EdgeConstraints, forwardTerminalSatisfactionProjection(expansionModel)); err != nil { return pgsql.Select{}, err } @@ -1373,7 +1394,7 @@ func (s *ExpansionBuilder) prepareBackwardFrontPrimerQuery(expansionModel *Expan } nextQuery.From = []pgsql.FromClause{nextQueryFrom} - if err := s.appendUnwindSourcesIfReferenced(&nextQuery, expansionModel.EdgeConstraints); err != nil { + if err := s.appendUnwindSourcesIfReferenced(&nextQuery, expansionModel.EdgeConstraints, backwardTerminalSatisfactionProjection(expansionModel)); err != nil { return pgsql.Query{}, nil, err } @@ -1445,7 +1466,7 @@ func (s *ExpansionBuilder) prepareBackwardFrontRecursiveQuery(expansionModel *Ex } nextQuery.From = []pgsql.FromClause{nextQueryFrom} - if err := s.appendUnwindSourcesIfReferenced(&nextQuery, expansionModel.EdgeConstraints); err != nil { + if err := s.appendUnwindSourcesIfReferenced(&nextQuery, expansionModel.EdgeConstraints, backwardTerminalSatisfactionProjection(expansionModel)); err != nil { return pgsql.Select{}, err } diff --git a/cypher/models/pgsql/translate/expression.go b/cypher/models/pgsql/translate/expression.go index 883c144e..51ee2078 100644 --- a/cypher/models/pgsql/translate/expression.go +++ b/cypher/models/pgsql/translate/expression.go @@ -452,6 +452,8 @@ func (s *Builder) PopOperand(kindMapper *contextAwareKindMapper) (pgsql.Expressi case *pgsql.BinaryExpression: if err := applyBinaryExpressionTypeHints(kindMapper, typedNext); err != nil { return nil, err + } else if rewrittenExpression, rewritten := buildStringPropertyEqualityPredicate(typedNext); rewritten { + next = rewrittenExpression } } @@ -925,15 +927,17 @@ func buildStringPropertyEqualityPredicate(expression *pgsql.BinaryExpression) (p leftPropertyLookup, hasLeftPropertyLookup := expressionToPropertyLookupBinaryExpression(expression.LOperand) rightPropertyLookup, hasRightPropertyLookup := expressionToPropertyLookupBinaryExpression(expression.ROperand) - if hasLeftPropertyLookup && leftPropertyLookup.Operator == pgsql.OperatorJSONTextField { - if _, rewritten := rewriteStringEqualityOperand(expression.ROperand); rewritten { - return buildStringPropertyComparisonPredicate(leftPropertyLookup, expression.ROperand, true, expression.Operator), true + if hasLeftPropertyLookup { + if rewrittenROperand, rewritten := rewriteStringEqualityOperand(expression.ROperand); rewritten { + rewritePropertyLookupOperator(leftPropertyLookup, pgsql.Text) + return buildStringPropertyComparisonPredicate(leftPropertyLookup, rewrittenROperand, true, expression.Operator), true } } - if hasRightPropertyLookup && rightPropertyLookup.Operator == pgsql.OperatorJSONTextField { - if _, rewritten := rewriteStringEqualityOperand(expression.LOperand); rewritten { - return buildStringPropertyComparisonPredicate(rightPropertyLookup, expression.LOperand, false, expression.Operator), true + if hasRightPropertyLookup { + if rewrittenLOperand, rewritten := rewriteStringEqualityOperand(expression.LOperand); rewritten { + rewritePropertyLookupOperator(rightPropertyLookup, pgsql.Text) + return buildStringPropertyComparisonPredicate(rightPropertyLookup, rewrittenLOperand, false, expression.Operator), true } } @@ -1284,6 +1288,10 @@ func (s *ExpressionTreeTranslator) rewriteBinaryExpression(newExpression *pgsql. s.PushOperand(newExpression) case pgsql.OperatorEquals: + if err := applyBinaryExpressionTypeHints(s.kindMapper, newExpression); err != nil { + return err + } + if propertyLookup, hasEmptyArrayLiteralPropertyComparison := isEmptyArrayLiteralPropertyComparison(newExpression); hasEmptyArrayLiteralPropertyComparison { expandedExpression := buildEmptyArrayPropertyComparison(propertyLookup, false) @@ -1299,6 +1307,10 @@ func (s *ExpressionTreeTranslator) rewriteBinaryExpression(newExpression *pgsql. } case pgsql.OperatorCypherNotEquals: + if err := applyBinaryExpressionTypeHints(s.kindMapper, newExpression); err != nil { + return err + } + if propertyLookup, hasEmptyArrayLiteralPropertyComparison := isEmptyArrayLiteralPropertyComparison(newExpression); hasEmptyArrayLiteralPropertyComparison { expandedExpression := buildEmptyArrayPropertyComparison(propertyLookup, true) diff --git a/cypher/models/pgsql/translate/expression_test.go b/cypher/models/pgsql/translate/expression_test.go index 4127490c..807821e3 100644 --- a/cypher/models/pgsql/translate/expression_test.go +++ b/cypher/models/pgsql/translate/expression_test.go @@ -348,6 +348,26 @@ func TestPropertyLookupEqualityScalarRewrites(t *testing.T) { Operator: pgsql.OperatorEquals, ROperand: pgsql.Parameter{Identifier: "pi0", CastType: pgsql.Text}, Expected: "(jsonb_typeof((n.properties -> 'objectid')) = 'string' and (n.properties ->> 'objectid') = @pi0::text)", + }, { + Name: "text function uses typed text property lookup", + LOperand: propertyLookup("distinguishedname"), + Operator: pgsql.OperatorEquals, + ROperand: pgsql.FunctionCall{ + Function: pgsql.FunctionToUpper, + Parameters: []pgsql.Expression{mustAsLiteral("admin")}, + CastType: pgsql.Text, + }, + Expected: "(jsonb_typeof((n.properties -> 'distinguishedname')) = 'string' and (n.properties ->> 'distinguishedname') = upper('admin')::text)", + }, { + Name: "text function uses typed text property lookup when reversed", + LOperand: pgsql.FunctionCall{ + Function: pgsql.FunctionToUpper, + Parameters: []pgsql.Expression{mustAsLiteral("admin")}, + CastType: pgsql.Text, + }, + Operator: pgsql.OperatorEquals, + ROperand: propertyLookup("distinguishedname"), + Expected: "(jsonb_typeof((n.properties -> 'distinguishedname')) = 'string' and upper('admin')::text = (n.properties ->> 'distinguishedname'))", }, { Name: "string inequality keeps non-string JSONB branch", LOperand: propertyLookup("rank"), diff --git a/cypher/models/pgsql/translate/hinting.go b/cypher/models/pgsql/translate/hinting.go index ee743442..5d837c4e 100644 --- a/cypher/models/pgsql/translate/hinting.go +++ b/cypher/models/pgsql/translate/hinting.go @@ -417,7 +417,11 @@ func applyTypeFunctionLikeTypeHints(kindMapper *contextAwareKindMapper, expressi typedROperand.CastType = lOperandTypeHint expression.ROperand = typedROperand } else if !lOperandTypeHint.IsKnown() { - expression.LOperand = pgsql.NewTypeCast(expression.LOperand, typedROperand.CastType.ArrayBaseType()) + if propertyLookup, isPropertyLookup := expressionToPropertyLookupBinaryExpression(expression.LOperand); isPropertyLookup && typedROperand.CastType == pgsql.Text { + expression.LOperand = rewritePropertyLookupOperator(propertyLookup, pgsql.Text) + } else { + expression.LOperand = pgsql.NewTypeCast(expression.LOperand, typedROperand.CastType.ArrayBaseType()) + } } else if pgsql.OperatorIsComparator(expression.Operator) && !typedROperand.CastType.IsComparable(lOperandTypeHint, expression.Operator) { return newFunctionCallComparatorError(typedROperand, expression.Operator, lOperandTypeHint) } @@ -439,5 +443,9 @@ func applyBinaryExpressionTypeHints(kindMapper *contextAwareKindMapper, expressi return err } - return applyTypeFunctionLikeTypeHints(kindMapper, expression) + if err := applyTypeFunctionLikeTypeHints(kindMapper, expression); err != nil { + return err + } + + return nil } diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index 6f321294..aaae2fd5 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -118,6 +118,18 @@ func requireNoPlannedOptimizationLowering(t *testing.T, summary OptimizationSumm } } +func requirePlanParameterContains(t *testing.T, translation Result, expected string) { + t.Helper() + + for _, parameter := range translation.Parameters { + if planQuery, ok := parameter.(string); ok && strings.Contains(planQuery, expected) { + return + } + } + + require.Failf(t, "missing plan parameter content", "expected a plan parameter to contain %q in %#v", expected, translation.Parameters) +} + func requireSkippedOptimizationLowering(t *testing.T, summary OptimizationSummary, name string, reason string) { t.Helper() @@ -976,13 +988,30 @@ func TestOptimizerSafetyShortestPathRootCarriesUnwindSources(t *testing.T) { formattedQuery, err := Translated(translation) require.NoError(t, err) normalizedQuery := strings.Join(strings.Fields(formattedQuery), " ") - primerQuery, hasPrimerQuery := translation.Parameters["pi0"].(string) - require.True(t, hasPrimerQuery) require.Contains(t, normalizedQuery, "unidirectional_sp_harness") require.Contains(t, normalizedQuery, "unnest(array ['source']::text[]) as i0") - require.Contains(t, primerQuery, "jsonb_typeof((n1.properties -> 'name')) = 'string'") - require.Contains(t, primerQuery, "(n0.properties ->> 'name') = i0") + requirePlanParameterContains(t, translation, "jsonb_typeof((n1.properties -> 'name')) = 'string'") + requirePlanParameterContains(t, translation, "(n0.properties ->> 'name') = i0") +} + +func TestOptimizerSafetyShortestPathTerminalCarriesUnwindSources(t *testing.T) { + t.Parallel() + + translation := optimizerSafetyTranslation(t, ` + UNWIND ['target'] AS targetName + MATCH p = shortestPath((s:Group)-[:MemberOf*1..]->(e:Group)) + WHERE s.name = 'source' AND e.name = targetName + RETURN targetName, p + `) + + formattedQuery, err := Translated(translation) + require.NoError(t, err) + normalizedQuery := strings.Join(strings.Fields(formattedQuery), " ") + + require.Contains(t, normalizedQuery, "unidirectional_sp_harness") + require.Contains(t, normalizedQuery, "unnest(array ['target']::text[]) as i0") + requirePlanParameterContains(t, translation, "(n1.properties ->> 'name') = i0") } func TestOptimizerSafetyTranslationReportsOptimizerMetadata(t *testing.T) { From 618c1e10c0f7654dd5dac21ec21f9227b5ebb7ad Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 18:55:11 -0700 Subject: [PATCH 097/116] Harden Neo4j database parsing and plan assertions --- cmd/graphbench/neo4j.go | 3 +++ cmd/graphbench/neo4j_test.go | 15 ++++++++++----- cmd/plancorpus/capture.go | 3 +++ cmd/plancorpus/main_test.go | 9 +++++++-- drivers/neo4j/neo4j.go | 3 +++ drivers/neo4j/neo4j_internal_test.go | 15 ++++++++++----- .../pgsql_aggregate_traversal_plan_test.go | 5 +++-- 7 files changed, 39 insertions(+), 14 deletions(-) diff --git a/cmd/graphbench/neo4j.go b/cmd/graphbench/neo4j.go index 1756c7a3..6b1d2bef 100644 --- a/cmd/graphbench/neo4j.go +++ b/cmd/graphbench/neo4j.go @@ -229,6 +229,9 @@ func neo4jDatabaseName(connectionURL *url.URL) (string, error) { if err != nil { return "", fmt.Errorf("parse Neo4j database name: %w", err) } + if strings.Contains(databaseName, "/") { + return "", fmt.Errorf("Neo4j database path must contain a single database name") + } return databaseName, nil } diff --git a/cmd/graphbench/neo4j_test.go b/cmd/graphbench/neo4j_test.go index dfb795db..a01058c9 100644 --- a/cmd/graphbench/neo4j_test.go +++ b/cmd/graphbench/neo4j_test.go @@ -34,11 +34,16 @@ func TestParseNeo4jPlanDriverConfig(t *testing.T) { } func TestNeo4jDatabaseNameRejectsNestedPath(t *testing.T) { - parsed, err := url.Parse("neo4j://neo4j:secret@example.com:7687/a/b") - require.NoError(t, err) - - _, err = neo4jDatabaseName(parsed) - require.ErrorContains(t, err, "single database name") + for _, connStr := range []string{ + "neo4j://neo4j:secret@example.com:7687/a/b", + "neo4j://neo4j:secret@example.com:7687/a%2Fb", + } { + parsed, err := url.Parse(connStr) + require.NoError(t, err) + + _, err = neo4jDatabaseName(parsed) + require.ErrorContains(t, err, "single database name") + } } func TestNeo4jOperatorsAnnotatesOperators(t *testing.T) { diff --git a/cmd/plancorpus/capture.go b/cmd/plancorpus/capture.go index 6f5c5f0d..7cdfa02d 100644 --- a/cmd/plancorpus/capture.go +++ b/cmd/plancorpus/capture.go @@ -388,6 +388,9 @@ func neo4jDatabaseName(connectionURL *url.URL) (string, error) { if err != nil { return "", fmt.Errorf("parse Neo4j database name: %w", err) } + if strings.Contains(databaseName, "/") { + return "", fmt.Errorf("Neo4j database path must contain a single database name") + } return databaseName, nil } diff --git a/cmd/plancorpus/main_test.go b/cmd/plancorpus/main_test.go index c0f696be..deff5ec1 100644 --- a/cmd/plancorpus/main_test.go +++ b/cmd/plancorpus/main_test.go @@ -82,6 +82,11 @@ func TestParseNeo4jPlanDriverConfigPreservesURI(t *testing.T) { } func TestParseNeo4jPlanDriverConfigRejectsNestedDatabasePath(t *testing.T) { - _, err := parseNeo4jPlanDriverConfig("neo4j://neo4j:password@localhost:7687/db/extra") - require.ErrorContains(t, err, "single database name") + for _, connStr := range []string{ + "neo4j://neo4j:password@localhost:7687/db/extra", + "neo4j://neo4j:password@localhost:7687/db%2Fextra", + } { + _, err := parseNeo4jPlanDriverConfig(connStr) + require.ErrorContains(t, err, "single database name") + } } diff --git a/drivers/neo4j/neo4j.go b/drivers/neo4j/neo4j.go index 7a4f385a..ffddb6ea 100644 --- a/drivers/neo4j/neo4j.go +++ b/drivers/neo4j/neo4j.go @@ -77,6 +77,9 @@ func neo4jConnectionDatabaseName(connectionURL *url.URL) (string, error) { if err != nil { return "", fmt.Errorf("parse Neo4j database name: %w", err) } + if strings.Contains(databaseName, "/") { + return "", fmt.Errorf("Neo4j database path must contain a single database name") + } return databaseName, nil } diff --git a/drivers/neo4j/neo4j_internal_test.go b/drivers/neo4j/neo4j_internal_test.go index cfa77f5d..78086b87 100644 --- a/drivers/neo4j/neo4j_internal_test.go +++ b/drivers/neo4j/neo4j_internal_test.go @@ -48,9 +48,14 @@ func TestNeo4jConnectionTargetPreservesAcceptedSchemes(t *testing.T) { } func TestNeo4jConnectionDatabaseNameRejectsNestedPath(t *testing.T) { - connectionURL, err := url.Parse("neo4j://neo4j:password@localhost:7687/db/extra") - require.NoError(t, err) - - _, err = neo4jConnectionDatabaseName(connectionURL) - require.ErrorContains(t, err, "single database name") + for _, connStr := range []string{ + "neo4j://neo4j:password@localhost:7687/db/extra", + "neo4j://neo4j:password@localhost:7687/db%2Fextra", + } { + connectionURL, err := url.Parse(connStr) + require.NoError(t, err) + + _, err = neo4jConnectionDatabaseName(connectionURL) + require.ErrorContains(t, err, "single database name") + } } diff --git a/integration/pgsql_aggregate_traversal_plan_test.go b/integration/pgsql_aggregate_traversal_plan_test.go index c41febbb..f59e7c49 100644 --- a/integration/pgsql_aggregate_traversal_plan_test.go +++ b/integration/pgsql_aggregate_traversal_plan_test.go @@ -22,6 +22,7 @@ import ( "context" "fmt" "os" + "regexp" "strings" "testing" "time" @@ -170,9 +171,9 @@ func TestPostgreSQLLiveAggregateTraversalCountPlanShape(t *testing.T) { } } - limitIndex := strings.Index(plan, "-> Limit") + limitMatch := regexp.MustCompile(`(?m)->\s+Limit\b`).FindStringIndex(plan) sourceMaterializationIndex := strings.LastIndex(plan, "Index Scan using node_") - if limitIndex < 0 || sourceMaterializationIndex < 0 || sourceMaterializationIndex < limitIndex { + if limitMatch == nil || sourceMaterializationIndex < 0 || sourceMaterializationIndex < limitMatch[0] { t.Fatalf("expected source node materialization after top-N limiting, got:\n%s", plan) } } From df9466da1cc449cc29828c6b708978f5353cfa16 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 18:55:46 -0700 Subject: [PATCH 098/116] Document backend-selected integration skips --- cypher/models/pgsql/test/validation_integration_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cypher/models/pgsql/test/validation_integration_test.go b/cypher/models/pgsql/test/validation_integration_test.go index fc84fe4d..85ff5d90 100644 --- a/cypher/models/pgsql/test/validation_integration_test.go +++ b/cypher/models/pgsql/test/validation_integration_test.go @@ -33,6 +33,7 @@ func pgConnectionString(t *testing.T) string { connStr := os.Getenv(connectionStringEnv) require.NotEmpty(t, connStr) if isNeo4jConnectionString(connStr) { + // CONNECTION_STRING selects one active backend for integration runs. t.Skipf("%s is not a PostgreSQL connection string", connectionStringEnv) } From b6791439a71e2b271f9ce8ce45689171832f96ec Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 20:24:04 -0700 Subject: [PATCH 099/116] Document BatchOperation COPY streaming plan --- batch_operation_plan.md | 102 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 batch_operation_plan.md diff --git a/batch_operation_plan.md b/batch_operation_plan.md new file mode 100644 index 00000000..ef74722c --- /dev/null +++ b/batch_operation_plan.md @@ -0,0 +1,102 @@ +# BatchOperation COPY Streaming Plan + +## Objective + +Move PostgreSQL `BatchOperation` toward chunked streaming writes backed by `COPY` and staging tables, while documenting that `BatchOperation` is intentionally non-transactional across the whole delegate. + +## Ground Rules + +- `BatchOperation` is a buffered, non-atomic write API. +- Successful flushes may persist even if the delegate later returns an error. +- PostgreSQL flushes may use short chunk-local transactions. +- Avoid one giant transaction for large batches. +- Use PostgreSQL `COPY` into staging tables for high-volume batch paths. +- Keep backend-neutral integration cases backend-equivalent; PG-specific behavior belongs in PG-scoped tests. + +## Steps + +### 1. Clarify Public Semantics + +Update `graph.BatchOperation` documentation to state that the API is non-transactional across the whole operation. Mention that flushes may commit before the delegate returns and that delegate errors do not roll back successful flushes. + +Status: Pending. + +### 2. Introduce PG COPY Staging Helpers + +Add internal PostgreSQL helpers for chunk flushes: + +- begin a chunk-local transaction +- create a temporary staging table +- stream rows with `COPY` +- merge/upsert/delete into final graph tables +- commit or roll back the chunk transaction + +Status: Pending. + +### 3. Add Streaming `CopyFromSource` Types + +Implement row-source types that satisfy `pgx.CopyFromSource` without materializing full `[][]any` batches. Each source should expose only current-row state plus encoder state. + +Status: Pending. + +### 4. Convert Relationship Create/Upsert + +Replace relationship create array batching with staging-table `COPY`. + +The flush should: + +- stream `graph_id`, `start_id`, `end_id`, `kind_id`, and `properties` into a temporary staging table +- coalesce duplicate `(graph_id, start_id, end_id, kind_id)` rows in SQL +- insert into the edge partition with `ON CONFLICT ... DO UPDATE` + +Status: Pending. + +### 5. Convert Node Create + +Replace node create array batching with staging-table `COPY`. + +Preserve the existing behavior that a single flush may not mix preset node IDs with nodes that require generated IDs. + +Status: Pending. + +### 6. Convert Node Update + +Replace the normal parameter-array node update and the special large-update path with one staging-based implementation. The existing large-update flow is a useful starting point but should become streaming rather than pre-materialized. + +Status: Pending. + +### 7. Convert Upsert Batches + +Convert `UpdateNodeBy` and `UpdateRelationshipBy` after the simpler paths are stable. Preserve current identity-property semantics while moving the data transfer to staging-table `COPY`. + +Status: Pending. + +### 8. Add PG-Scoped Tests + +Add PostgreSQL driver-scoped tests for: + +- flushed data persists after the delegate returns an error +- relationship create duplicate coalescing +- node create with and without IDs +- node update via staging +- `UpdateNodeBy` and `UpdateRelationshipBy` +- streaming source behavior + +Status: Pending. + +### 9. Validate + +Run formatting and targeted tests: + +```bash +make format +go test ./drivers/pg/... +``` + +Run PostgreSQL integration tests only when `CONNECTION_STRING` points at PostgreSQL. + +Status: Pending. + +## Evaluation Notes + +This plan should be updated after each step is completed. If a step exposes a simpler or safer implementation order, update this file before moving on. From ca7178e7e6dcd23b140394da215ae976bce58c0d Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 20:24:27 -0700 Subject: [PATCH 100/116] Clarify BatchOperation non-transactional semantics --- batch_operation_plan.md | 4 +++- graph/graph.go | 12 +++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/batch_operation_plan.md b/batch_operation_plan.md index ef74722c..2274f7cf 100644 --- a/batch_operation_plan.md +++ b/batch_operation_plan.md @@ -19,7 +19,7 @@ Move PostgreSQL `BatchOperation` toward chunked streaming writes backed by `COPY Update `graph.BatchOperation` documentation to state that the API is non-transactional across the whole operation. Mention that flushes may commit before the delegate returns and that delegate errors do not roll back successful flushes. -Status: Pending. +Status: Complete. ### 2. Introduce PG COPY Staging Helpers @@ -100,3 +100,5 @@ Status: Pending. ## Evaluation Notes This plan should be updated after each step is completed. If a step exposes a simpler or safer implementation order, update this file before moving on. + +- Step 1 confirmed the intended contract: `BatchOperation` remains a buffered, non-atomic API. The implementation work should optimize chunk flushes without introducing whole-operation atomicity. diff --git a/graph/graph.go b/graph/graph.go index da8ea3c7..5fb9bdbd 100644 --- a/graph/graph.go +++ b/graph/graph.go @@ -354,7 +354,7 @@ type Transaction interface { // return value) results in a transactional commit of work done within the TransactionDelegate. type TransactionDelegate func(tx Transaction) error -// BatchDelegate represents a transactional database context actor. +// BatchDelegate represents a buffered write context actor. type BatchDelegate func(batch Batch) error // TransactionConfig is a generic configuration that may apply to all supported databases. @@ -396,10 +396,12 @@ type Database interface { // given logic function. WriteTransaction(ctx context.Context, txDelegate TransactionDelegate, options ...TransactionOption) error - // BatchOperation opens up a new write transactional context in the database and then defers the context to the - // given logic function. Batch operations are fundamentally different between databases supported by DAWGS, - // necessitating a different interface that lacks many of the convenience features of a regular read or write - // transaction. + // BatchOperation opens a buffered write context and passes it to the given logic function. + // + // BatchOperation is not transactional across the whole delegate. Implementations may flush and commit chunks before + // the delegate returns, and successfully flushed chunks may remain persisted if a later flush fails or if the delegate + // returns an error. Callers that need all-or-nothing behavior should use WriteTransaction or another transactional + // workflow instead. BatchOperation(ctx context.Context, batchDelegate BatchDelegate, options ...BatchOption) error // AssertSchema will apply the given schema to the underlying database. From 8ffcc3dae58aeb54af54bc365a1c44ba7abb1cbe Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 20:25:17 -0700 Subject: [PATCH 101/116] Add PostgreSQL batch COPY staging helpers --- batch_operation_plan.md | 3 +- drivers/pg/batch_copy.go | 96 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 drivers/pg/batch_copy.go diff --git a/batch_operation_plan.md b/batch_operation_plan.md index 2274f7cf..f782e726 100644 --- a/batch_operation_plan.md +++ b/batch_operation_plan.md @@ -31,7 +31,7 @@ Add internal PostgreSQL helpers for chunk flushes: - merge/upsert/delete into final graph tables - commit or roll back the chunk transaction -Status: Pending. +Status: Complete. ### 3. Add Streaming `CopyFromSource` Types @@ -102,3 +102,4 @@ Status: Pending. This plan should be updated after each step is completed. If a step exposes a simpler or safer implementation order, update this file before moving on. - Step 1 confirmed the intended contract: `BatchOperation` remains a buffered, non-atomic API. The implementation work should optimize chunk flushes without introducing whole-operation atomicity. +- Step 2 added the transaction and staging execution boundary as PG-internal helpers. The next step should focus on row sources so batch paths can stream rows into those helpers. diff --git a/drivers/pg/batch_copy.go b/drivers/pg/batch_copy.go new file mode 100644 index 00000000..056bf9ca --- /dev/null +++ b/drivers/pg/batch_copy.go @@ -0,0 +1,96 @@ +package pg + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type batchChunkStatement struct { + Name string + Statement string + Args []any +} + +type batchCopyStage struct { + Name string + TableIdentifier pgx.Identifier + Columns []string + Source pgx.CopyFromSource + BeforeCopy []batchChunkStatement + AfterCopy []batchChunkStatement +} + +func runBatchChunk(ctx context.Context, conn *pgxpool.Conn, delegate func(pgx.Tx) error) error { + tx, err := conn.Begin(ctx) + if err != nil { + return err + } + + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + + if err := delegate(tx); err != nil { + return err + } + + if err := tx.Commit(ctx); err != nil { + return err + } + + committed = true + return nil +} + +func execBatchChunkStatements(ctx context.Context, tx pgx.Tx, statements []batchChunkStatement) error { + for _, statement := range statements { + if _, err := tx.Exec(ctx, statement.Statement, statement.Args...); err != nil { + if statement.Name == "" { + return err + } + + return fmt.Errorf("%s: %w", statement.Name, err) + } + } + + return nil +} + +func copyBatchStage(ctx context.Context, tx pgx.Tx, stage batchCopyStage) (int64, error) { + if err := execBatchChunkStatements(ctx, tx, stage.BeforeCopy); err != nil { + return 0, err + } + + copied, err := tx.CopyFrom(ctx, stage.TableIdentifier, stage.Columns, stage.Source) + if err != nil { + if stage.Name == "" { + return copied, err + } + + return copied, fmt.Errorf("%s copy: %w", stage.Name, err) + } + + if err := execBatchChunkStatements(ctx, tx, stage.AfterCopy); err != nil { + return copied, err + } + + return copied, nil +} + +func copyBatchStageChunk(ctx context.Context, conn *pgxpool.Conn, stage batchCopyStage) (int64, error) { + var copied int64 + + err := runBatchChunk(ctx, conn, func(tx pgx.Tx) error { + var err error + copied, err = copyBatchStage(ctx, tx, stage) + return err + }) + + return copied, err +} From fdd19ab8c3dd7806a56af3a492041bdf4e76d2ee Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 20:25:57 -0700 Subject: [PATCH 102/116] Add streaming COPY sources for PG batches --- batch_operation_plan.md | 3 +- drivers/pg/batch_copy_source.go | 55 ++++++++++++++++++++++++++++ drivers/pg/batch_copy_source_test.go | 51 ++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 drivers/pg/batch_copy_source.go create mode 100644 drivers/pg/batch_copy_source_test.go diff --git a/batch_operation_plan.md b/batch_operation_plan.md index f782e726..55227ead 100644 --- a/batch_operation_plan.md +++ b/batch_operation_plan.md @@ -37,7 +37,7 @@ Status: Complete. Implement row-source types that satisfy `pgx.CopyFromSource` without materializing full `[][]any` batches. Each source should expose only current-row state plus encoder state. -Status: Pending. +Status: Complete. ### 4. Convert Relationship Create/Upsert @@ -103,3 +103,4 @@ This plan should be updated after each step is completed. If a step exposes a si - Step 1 confirmed the intended contract: `BatchOperation` remains a buffered, non-atomic API. The implementation work should optimize chunk flushes without introducing whole-operation atomicity. - Step 2 added the transaction and staging execution boundary as PG-internal helpers. The next step should focus on row sources so batch paths can stream rows into those helpers. +- Step 3 added a generic slice-backed `CopyFromSource`. It streams encoded rows from existing buffers without creating a second materialized row matrix; later steps can still replace the outer buffers if needed. diff --git a/drivers/pg/batch_copy_source.go b/drivers/pg/batch_copy_source.go new file mode 100644 index 00000000..6df4a2dd --- /dev/null +++ b/drivers/pg/batch_copy_source.go @@ -0,0 +1,55 @@ +package pg + +import ( + "context" + + "github.com/jackc/pgx/v5" +) + +type batchCopyRowEncoder[T any] func(context.Context, T) ([]any, error) + +type batchSliceCopySource[T any] struct { + ctx context.Context + values []T + encoder batchCopyRowEncoder[T] + idx int + row []any + err error +} + +var _ pgx.CopyFromSource = (*batchSliceCopySource[int])(nil) + +func newBatchSliceCopySource[T any](ctx context.Context, values []T, encoder batchCopyRowEncoder[T]) *batchSliceCopySource[T] { + return &batchSliceCopySource[T]{ + ctx: ctx, + values: values, + encoder: encoder, + idx: -1, + } +} + +func (s *batchSliceCopySource[T]) Next() bool { + if s.err != nil { + return false + } + + s.idx++ + if s.idx >= len(s.values) { + return false + } + + s.row, s.err = s.encoder(s.ctx, s.values[s.idx]) + return s.err == nil +} + +func (s *batchSliceCopySource[T]) Values() ([]any, error) { + if s.err != nil { + return nil, s.err + } + + return s.row, nil +} + +func (s *batchSliceCopySource[T]) Err() error { + return s.err +} diff --git a/drivers/pg/batch_copy_source_test.go b/drivers/pg/batch_copy_source_test.go new file mode 100644 index 00000000..59f32bd5 --- /dev/null +++ b/drivers/pg/batch_copy_source_test.go @@ -0,0 +1,51 @@ +package pg + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBatchSliceCopySourceStreamsRows(t *testing.T) { + source := newBatchSliceCopySource(context.Background(), []int{1, 2}, func(_ context.Context, value int) ([]any, error) { + return []any{value, value * 10}, nil + }) + + require.True(t, source.Next()) + values, err := source.Values() + require.NoError(t, err) + require.Equal(t, []any{1, 10}, values) + + require.True(t, source.Next()) + values, err = source.Values() + require.NoError(t, err) + require.Equal(t, []any{2, 20}, values) + + require.False(t, source.Next()) + require.NoError(t, source.Err()) +} + +func TestBatchSliceCopySourceStopsOnEncodeError(t *testing.T) { + expectedErr := errors.New("encode failed") + source := newBatchSliceCopySource(context.Background(), []int{1, 2}, func(_ context.Context, value int) ([]any, error) { + if value == 2 { + return nil, expectedErr + } + + return []any{value}, nil + }) + + require.True(t, source.Next()) + values, err := source.Values() + require.NoError(t, err) + require.Equal(t, []any{1}, values) + + require.False(t, source.Next()) + require.ErrorIs(t, source.Err(), expectedErr) + + values, err = source.Values() + require.Nil(t, values) + require.ErrorIs(t, err, expectedErr) +} From 5130dbb840f779d6813581a550ac7ec7a9a8ab0f Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 20:28:53 -0700 Subject: [PATCH 103/116] Stream PG relationship creates through COPY staging --- batch_operation_plan.md | 3 +- drivers/pg/batch.go | 117 +++---------------- drivers/pg/batch_relationship_source.go | 79 +++++++++++++ drivers/pg/batch_relationship_source_test.go | 60 ++++++++++ drivers/pg/query/format.go | 35 ++++++ drivers/pg/query/format_test.go | 60 ++++++++++ 6 files changed, 255 insertions(+), 99 deletions(-) create mode 100644 drivers/pg/batch_relationship_source.go create mode 100644 drivers/pg/batch_relationship_source_test.go diff --git a/batch_operation_plan.md b/batch_operation_plan.md index 55227ead..26c39bc3 100644 --- a/batch_operation_plan.md +++ b/batch_operation_plan.md @@ -49,7 +49,7 @@ The flush should: - coalesce duplicate `(graph_id, start_id, end_id, kind_id)` rows in SQL - insert into the edge partition with `ON CONFLICT ... DO UPDATE` -Status: Pending. +Status: Complete. ### 5. Convert Node Create @@ -104,3 +104,4 @@ This plan should be updated after each step is completed. If a step exposes a si - Step 1 confirmed the intended contract: `BatchOperation` remains a buffered, non-atomic API. The implementation work should optimize chunk flushes without introducing whole-operation atomicity. - Step 2 added the transaction and staging execution boundary as PG-internal helpers. The next step should focus on row sources so batch paths can stream rows into those helpers. - Step 3 added a generic slice-backed `CopyFromSource`. It streams encoded rows from existing buffers without creating a second materialized row matrix; later steps can still replace the outer buffers if needed. +- Step 4 moved relationship create/upsert to staging-table `COPY` and SQL duplicate coalescing. This removed the old in-memory relationship de-duplication path, including its ambiguous key and incorrect index lookup behavior. diff --git a/drivers/pg/batch.go b/drivers/pg/batch.go index ebea05cd..32494ce8 100644 --- a/drivers/pg/batch.go +++ b/drivers/pg/batch.go @@ -4,9 +4,7 @@ import ( "bytes" "context" "fmt" - "log/slog" "strconv" - "strings" "github.com/jackc/pgtype" "github.com/jackc/pgx/v5" @@ -607,111 +605,34 @@ func (s *batch) tryFlushRelationshipUpdateByBuffer() error { return nil } -type relationshipCreateBatch struct { - startIDs []uint64 - endIDs []uint64 - edgeKindIDs []int16 - edgePropertyBags []pgtype.JSONB -} - -func newRelationshipCreateBatch(size int) *relationshipCreateBatch { - return &relationshipCreateBatch{ - startIDs: make([]uint64, 0, size), - endIDs: make([]uint64, 0, size), - edgeKindIDs: make([]int16, 0, size), - edgePropertyBags: make([]pgtype.JSONB, 0, size), - } -} - -func (s *relationshipCreateBatch) Add(startID, endID uint64, edgeKindID int16) { - s.startIDs = append(s.startIDs, startID) - s.edgeKindIDs = append(s.edgeKindIDs, edgeKindID) - s.endIDs = append(s.endIDs, endID) -} - -func (s *relationshipCreateBatch) EncodeProperties(edgePropertiesBatch []*graph.Properties) error { - for _, edgeProperties := range edgePropertiesBatch { - if propertiesJSONB, err := pgsql.PropertiesToJSONB(edgeProperties); err != nil { - return err - } else { - s.edgePropertyBags = append(s.edgePropertyBags, propertiesJSONB) - } - } - - return nil -} - -type relationshipCreateBatchBuilder struct { - keyToEdgeID map[string]uint64 - relationshipUpdateBatch *relationshipCreateBatch - edgePropertiesIndex map[uint64]int - edgePropertiesBatch []*graph.Properties -} - -func newRelationshipCreateBatchBuilder(size int) *relationshipCreateBatchBuilder { - return &relationshipCreateBatchBuilder{ - keyToEdgeID: map[string]uint64{}, - relationshipUpdateBatch: newRelationshipCreateBatch(size), - edgePropertiesIndex: map[uint64]int{}, +func (s *batch) flushRelationshipCreateBuffer() error { + if len(s.relationshipCreateBuffer) == 0 { + return nil } -} - -func (s *relationshipCreateBatchBuilder) Build() (*relationshipCreateBatch, error) { - return s.relationshipUpdateBatch, s.relationshipUpdateBatch.EncodeProperties(s.edgePropertiesBatch) -} -func (s *relationshipCreateBatchBuilder) Add(ctx context.Context, kindMapper KindMapper, edge *graph.Relationship) error { - keyBuilder := strings.Builder{} - - keyBuilder.WriteString(edge.StartID.String()) - keyBuilder.WriteString(edge.EndID.String()) - keyBuilder.WriteString(edge.Kind.String()) - - key := keyBuilder.String() - - if existingPropertiesIdx, hasExisting := s.keyToEdgeID[key]; hasExisting { - s.edgePropertiesBatch[existingPropertiesIdx].Merge(edge.Properties) + if graphTarget, err := s.innerTransaction.getTargetGraph(); err != nil { + return err } else { - var ( - startID = edge.StartID.Uint64() - edgeID = edge.ID.Uint64() - endID = edge.EndID.Uint64() - edgeProperties = edge.Properties.Clone() - ) - - if edgeKindID, err := kindMapper.MapKind(ctx, edge.Kind); err != nil { - return err - } else { - s.relationshipUpdateBatch.Add(startID, endID, edgeKindID) + stage := batchCopyStage{ + Name: "relationship create", + TableIdentifier: pgx.Identifier{sql.RelationshipCreateStagingTable}, + Columns: sql.RelationshipCreateStagingColumns, + Source: newRelationshipCreateCopySource(s.ctx, graphTarget.ID, s.relationshipCreateBuffer, s.schemaManager), + BeforeCopy: []batchChunkStatement{{ + Name: "create relationship create staging table", + Statement: sql.FormatCreateRelationshipCreateStagingTable(sql.RelationshipCreateStagingTable), + }}, + AfterCopy: []batchChunkStatement{{ + Name: "merge relationship create staging table", + Statement: sql.FormatMergeRelationshipCreateStaging(graphTarget, sql.RelationshipCreateStagingTable), + }}, } - s.keyToEdgeID[key] = edgeID - - s.edgePropertiesBatch = append(s.edgePropertiesBatch, edgeProperties) - s.edgePropertiesIndex[edgeID] = len(s.edgePropertiesBatch) - 1 - } - - return nil -} - -func (s *batch) flushRelationshipCreateBuffer() error { - batchBuilder := newRelationshipCreateBatchBuilder(len(s.relationshipCreateBuffer)) - - for _, nextRel := range s.relationshipCreateBuffer { - if err := batchBuilder.Add(s.ctx, s.schemaManager, nextRel); err != nil { + if _, err := copyBatchStageChunk(s.ctx, s.innerTransaction.conn, stage); err != nil { return err } } - if createBatch, err := batchBuilder.Build(); err != nil { - return err - } else if graphTarget, err := s.innerTransaction.getTargetGraph(); err != nil { - return err - } else if _, err := s.innerTransaction.conn.Exec(s.ctx, createEdgeBatchStatement, graphTarget.ID, createBatch.startIDs, createBatch.endIDs, createBatch.edgeKindIDs, createBatch.edgePropertyBags); err != nil { - slog.Info(fmt.Sprintf("Num merged property bags: %d - Num edge keys: %d - StartID batch size: %d", len(batchBuilder.edgePropertiesIndex), len(batchBuilder.keyToEdgeID), len(batchBuilder.relationshipUpdateBatch.startIDs))) - return err - } - s.relationshipCreateBuffer = s.relationshipCreateBuffer[:0] return nil } diff --git a/drivers/pg/batch_relationship_source.go b/drivers/pg/batch_relationship_source.go new file mode 100644 index 00000000..2cf1b026 --- /dev/null +++ b/drivers/pg/batch_relationship_source.go @@ -0,0 +1,79 @@ +package pg + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/specterops/dawgs/cypher/models/pgsql" + "github.com/specterops/dawgs/graph" +) + +type relationshipCreateCopySource struct { + ctx context.Context + graphID int32 + relationships []*graph.Relationship + kindMapper KindMapper + idx int + row []any + err error +} + +var _ pgx.CopyFromSource = (*relationshipCreateCopySource)(nil) + +func newRelationshipCreateCopySource(ctx context.Context, graphID int32, relationships []*graph.Relationship, kindMapper KindMapper) *relationshipCreateCopySource { + return &relationshipCreateCopySource{ + ctx: ctx, + graphID: graphID, + relationships: relationships, + kindMapper: kindMapper, + idx: -1, + } +} + +func (s *relationshipCreateCopySource) Next() bool { + if s.err != nil { + return false + } + + s.idx++ + if s.idx >= len(s.relationships) { + return false + } + + nextRelationship := s.relationships[s.idx] + + kindID, err := s.kindMapper.MapKind(s.ctx, nextRelationship.Kind) + if err != nil { + s.err = err + return false + } + + propertiesJSONB, err := pgsql.PropertiesToJSONB(nextRelationship.Properties) + if err != nil { + s.err = err + return false + } + + s.row = []any{ + s.idx, + s.graphID, + nextRelationship.StartID.Int64(), + nextRelationship.EndID.Int64(), + kindID, + string(propertiesJSONB.Bytes), + } + + return true +} + +func (s *relationshipCreateCopySource) Values() ([]any, error) { + if s.err != nil { + return nil, s.err + } + + return s.row, nil +} + +func (s *relationshipCreateCopySource) Err() error { + return s.err +} diff --git a/drivers/pg/batch_relationship_source_test.go b/drivers/pg/batch_relationship_source_test.go new file mode 100644 index 00000000..50bfe710 --- /dev/null +++ b/drivers/pg/batch_relationship_source_test.go @@ -0,0 +1,60 @@ +package pg + +import ( + "context" + "encoding/json" + "testing" + + "github.com/specterops/dawgs/drivers/pg/pgutil" + "github.com/specterops/dawgs/graph" + "github.com/stretchr/testify/require" +) + +func TestRelationshipCreateCopySourceStreamsRelationships(t *testing.T) { + ctx := context.Background() + kindMapper := pgutil.NewInMemoryKindMapper() + edgeKind := graph.StringKind("MemberOf") + edgeKindID := kindMapper.Put(edgeKind) + + relationship := graph.NewRelationship( + graph.ID(5), + graph.ID(10), + graph.ID(20), + graph.NewProperties().Set("name", "alpha"), + edgeKind, + ) + + source := newRelationshipCreateCopySource(ctx, 7, []*graph.Relationship{relationship}, kindMapper) + + require.True(t, source.Next()) + values, err := source.Values() + require.NoError(t, err) + require.Len(t, values, 6) + require.Equal(t, 0, values[0]) + require.Equal(t, int32(7), values[1]) + require.Equal(t, int64(10), values[2]) + require.Equal(t, int64(20), values[3]) + require.Equal(t, edgeKindID, values[4]) + + propertiesText, ok := values[5].(string) + require.True(t, ok) + + var properties map[string]any + require.NoError(t, json.Unmarshal([]byte(propertiesText), &properties)) + require.Equal(t, map[string]any{"name": "alpha"}, properties) + + require.False(t, source.Next()) + require.NoError(t, source.Err()) +} + +func TestRelationshipCreateCopySourceStopsWhenKindIsMissing(t *testing.T) { + source := newRelationshipCreateCopySource( + context.Background(), + 7, + []*graph.Relationship{graph.NewRelationship(1, 2, 3, graph.NewProperties(), graph.StringKind("Missing"))}, + pgutil.NewInMemoryKindMapper(), + ) + + require.False(t, source.Next()) + require.Error(t, source.Err()) +} diff --git a/drivers/pg/query/format.go b/drivers/pg/query/format.go index bcf9d878..7c2a8a35 100644 --- a/drivers/pg/query/format.go +++ b/drivers/pg/query/format.go @@ -166,6 +166,41 @@ func FormatRelationshipPartitionUpsert(graphTarget model.Graph, identityProperti ) } +const RelationshipCreateStagingTable = "relationship_create_staging" + +var RelationshipCreateStagingColumns = []string{"row_ord", "graph_id", "start_id", "end_id", "kind_id", "properties"} + +func FormatCreateRelationshipCreateStagingTable(stagingTable string) string { + return join( + "create temp table if not exists ", stagingTable, " (", + "row_ord integer not null, ", + "graph_id integer not null, ", + "start_id bigint not null, ", + "end_id bigint not null, ", + "kind_id smallint not null, ", + "properties text not null", + ") on commit drop;", + ) +} + +func FormatMergeRelationshipCreateStaging(graphTarget model.Graph, stagingTable string) string { + return join( + "insert into ", graphTarget.Partitions.Edge.Name, " as e ", + "(graph_id, start_id, end_id, kind_id, properties) ", + "select graph_id, start_id, end_id, kind_id, ", + "coalesce(jsonb_object_agg(key, value) filter (where key is not null), '{}'::jsonb) as properties ", + "from (", + "select distinct on (graph_id, start_id, end_id, kind_id, key) ", + "graph_id, start_id, end_id, kind_id, key, value, row_ord ", + "from ", stagingTable, " ", + "left join lateral jsonb_each(properties::jsonb) as property(key, value) on true ", + "order by graph_id, start_id, end_id, kind_id, key, row_ord desc", + ") as deduped ", + "group by graph_id, start_id, end_id, kind_id ", + "on conflict (start_id, end_id, kind_id, graph_id) do update set properties = e.properties || excluded.properties;", + ) +} + type NodeUpdate struct { IDFuture *Future[graph.ID] Node *graph.Node diff --git a/drivers/pg/query/format_test.go b/drivers/pg/query/format_test.go index 2092182b..fce62c2f 100644 --- a/drivers/pg/query/format_test.go +++ b/drivers/pg/query/format_test.go @@ -91,3 +91,63 @@ func TestNodeUpdateStagingColumns(t *testing.T) { assert.Equal(t, expected, query.NodeUpdateStagingColumns) } + +func TestFormatCreateRelationshipCreateStagingTable(t *testing.T) { + t.Parallel() + + var ( + tableName = "my_relationship_staging" + expected = strings.Join([]string{ + "create temp table if not exists my_relationship_staging (", + "row_ord integer not null, ", + "graph_id integer not null, ", + "start_id bigint not null, ", + "end_id bigint not null, ", + "kind_id smallint not null, ", + "properties text not null", + ") on commit drop;", + }, "") + result = query.FormatCreateRelationshipCreateStagingTable(tableName) + ) + + assert.Equal(t, expected, result) +} + +func TestFormatMergeRelationshipCreateStaging(t *testing.T) { + t.Parallel() + + var ( + stagingTable = "my_relationship_staging" + graphTarget = model.Graph{ + Partitions: model.GraphPartitions{ + Edge: model.NewGraphPartition("edge_part_1"), + }, + } + expected = strings.Join([]string{ + "insert into edge_part_1 as e ", + "(graph_id, start_id, end_id, kind_id, properties) ", + "select graph_id, start_id, end_id, kind_id, ", + "coalesce(jsonb_object_agg(key, value) filter (where key is not null), '{}'::jsonb) as properties ", + "from (", + "select distinct on (graph_id, start_id, end_id, kind_id, key) ", + "graph_id, start_id, end_id, kind_id, key, value, row_ord ", + "from my_relationship_staging ", + "left join lateral jsonb_each(properties::jsonb) as property(key, value) on true ", + "order by graph_id, start_id, end_id, kind_id, key, row_ord desc", + ") as deduped ", + "group by graph_id, start_id, end_id, kind_id ", + "on conflict (start_id, end_id, kind_id, graph_id) do update set properties = e.properties || excluded.properties;", + }, "") + result = query.FormatMergeRelationshipCreateStaging(graphTarget, stagingTable) + ) + + assert.Equal(t, expected, result) +} + +func TestRelationshipCreateStagingColumns(t *testing.T) { + t.Parallel() + + expected := []string{"row_ord", "graph_id", "start_id", "end_id", "kind_id", "properties"} + + assert.Equal(t, expected, query.RelationshipCreateStagingColumns) +} From a29c1a93a5c1484735b84d992fd1e6fc2ea0f27f Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 20:30:50 -0700 Subject: [PATCH 104/116] Stream PG node creates through COPY staging --- batch_operation_plan.md | 3 +- drivers/pg/batch.go | 89 +++++++++++--------------- drivers/pg/batch_node_source.go | 95 ++++++++++++++++++++++++++++ drivers/pg/batch_node_source_test.go | 70 ++++++++++++++++++++ drivers/pg/query/format.go | 36 +++++++++++ drivers/pg/query/format_test.go | 62 ++++++++++++++++++ 6 files changed, 301 insertions(+), 54 deletions(-) create mode 100644 drivers/pg/batch_node_source.go create mode 100644 drivers/pg/batch_node_source_test.go diff --git a/batch_operation_plan.md b/batch_operation_plan.md index 26c39bc3..886c44ea 100644 --- a/batch_operation_plan.md +++ b/batch_operation_plan.md @@ -57,7 +57,7 @@ Replace node create array batching with staging-table `COPY`. Preserve the existing behavior that a single flush may not mix preset node IDs with nodes that require generated IDs. -Status: Pending. +Status: Complete. ### 6. Convert Node Update @@ -105,3 +105,4 @@ This plan should be updated after each step is completed. If a step exposes a si - Step 2 added the transaction and staging execution boundary as PG-internal helpers. The next step should focus on row sources so batch paths can stream rows into those helpers. - Step 3 added a generic slice-backed `CopyFromSource`. It streams encoded rows from existing buffers without creating a second materialized row matrix; later steps can still replace the outer buffers if needed. - Step 4 moved relationship create/upsert to staging-table `COPY` and SQL duplicate coalescing. This removed the old in-memory relationship de-duplication path, including its ambiguous key and incorrect index lookup behavior. +- Step 5 moved node creation to staging-table `COPY` while preserving the existing split between preset-ID and generated-ID batches. Kind assertion remains outside the COPY stream, and row streaming uses kind mapping only. diff --git a/drivers/pg/batch.go b/drivers/pg/batch.go index 32494ce8..fffdf9c7 100644 --- a/drivers/pg/batch.go +++ b/drivers/pg/batch.go @@ -253,77 +253,60 @@ func (s *batch) flushNodeCreateBuffer() error { return s.flushNodeCreateBufferWithIDs() } -func (s *batch) flushNodeCreateBufferWithIDs() error { - var ( - numCreates = len(s.nodeCreateBuffer) - nodeIDs = make([]uint64, numCreates) - kindIDSlices = make([]string, numCreates) - kindIDEncoder = Int2ArrayEncoder{ - buffer: &bytes.Buffer{}, - } - properties = make([]pgtype.JSONB, numCreates) - ) - - for idx, nextNode := range s.nodeCreateBuffer { - nodeIDs[idx] = nextNode.ID.Uint64() - - if mappedKindIDs, err := s.schemaManager.AssertKinds(s.ctx, nextNode.Kinds); err != nil { +func (s *batch) assertNodeCreateKinds() error { + for _, nextNode := range s.nodeCreateBuffer { + if _, err := s.schemaManager.AssertKinds(s.ctx, nextNode.Kinds); err != nil { return fmt.Errorf("unable to map kinds %w", err) - } else { - kindIDSlices[idx] = kindIDEncoder.Encode(mappedKindIDs) - } - - if propertiesJSONB, err := pgsql.PropertiesToJSONB(nextNode.Properties); err != nil { - return err - } else { - properties[idx] = propertiesJSONB } } - if graphTarget, err := s.innerTransaction.getTargetGraph(); err != nil { - return err - } else if _, err := s.innerTransaction.conn.Exec(s.ctx, createNodeWithIDBatchStatement, graphTarget.ID, nodeIDs, kindIDSlices, properties); err != nil { - return err - } - - s.nodeCreateBuffer = s.nodeCreateBuffer[:0] return nil } -func (s *batch) flushNodeCreateBufferWithoutIDs() error { - var ( - numCreates = len(s.nodeCreateBuffer) - kindIDSlices = make([]string, numCreates) - kindIDEncoder = Int2ArrayEncoder{ - buffer: &bytes.Buffer{}, - } - properties = make([]pgtype.JSONB, numCreates) - ) - - for idx, nextNode := range s.nodeCreateBuffer { - if mappedKindIDs, err := s.schemaManager.AssertKinds(s.ctx, nextNode.Kinds); err != nil { - return fmt.Errorf("unable to map kinds %w", err) - } else { - kindIDSlices[idx] = kindIDEncoder.Encode(mappedKindIDs) - } +func (s *batch) flushNodeCreateCopyStage(includeID bool, columns []string, mergeStatement func(model.Graph, string) string) error { + if len(s.nodeCreateBuffer) == 0 { + return nil + } - if propertiesJSONB, err := pgsql.PropertiesToJSONB(nextNode.Properties); err != nil { - return err - } else { - properties[idx] = propertiesJSONB - } + if err := s.assertNodeCreateKinds(); err != nil { + return err } if graphTarget, err := s.innerTransaction.getTargetGraph(); err != nil { return err - } else if _, err := s.innerTransaction.conn.Exec(s.ctx, createNodeWithoutIDBatchStatement, graphTarget.ID, kindIDSlices, properties); err != nil { - return err + } else { + stage := batchCopyStage{ + Name: "node create", + TableIdentifier: pgx.Identifier{sql.NodeCreateStagingTable}, + Columns: columns, + Source: newNodeCreateCopySource(s.ctx, graphTarget.ID, s.nodeCreateBuffer, s.schemaManager, includeID), + BeforeCopy: []batchChunkStatement{{ + Name: "create node create staging table", + Statement: sql.FormatCreateNodeCreateStagingTable(sql.NodeCreateStagingTable), + }}, + AfterCopy: []batchChunkStatement{{ + Name: "merge node create staging table", + Statement: mergeStatement(graphTarget, sql.NodeCreateStagingTable), + }}, + } + + if _, err := copyBatchStageChunk(s.ctx, s.innerTransaction.conn, stage); err != nil { + return err + } } s.nodeCreateBuffer = s.nodeCreateBuffer[:0] return nil } +func (s *batch) flushNodeCreateBufferWithIDs() error { + return s.flushNodeCreateCopyStage(true, sql.NodeCreateWithIDStagingColumns, sql.FormatMergeNodeCreateStagingWithIDs) +} + +func (s *batch) flushNodeCreateBufferWithoutIDs() error { + return s.flushNodeCreateCopyStage(false, sql.NodeCreateWithoutIDStagingColumns, sql.FormatMergeNodeCreateStagingWithoutIDs) +} + func (s *batch) flushNodeUpsertBatch(updates *sql.NodeUpdateBatch) error { parameters := NewNodeUpsertParameters(len(updates.Updates)) diff --git a/drivers/pg/batch_node_source.go b/drivers/pg/batch_node_source.go new file mode 100644 index 00000000..da03a7b4 --- /dev/null +++ b/drivers/pg/batch_node_source.go @@ -0,0 +1,95 @@ +package pg + +import ( + "bytes" + "context" + + "github.com/jackc/pgx/v5" + "github.com/specterops/dawgs/cypher/models/pgsql" + "github.com/specterops/dawgs/graph" +) + +type nodeCreateCopySource struct { + ctx context.Context + graphID int32 + nodes []*graph.Node + kindMapper KindMapper + includeID bool + kindIDEncoder Int2ArrayEncoder + idx int + row []any + err error +} + +var _ pgx.CopyFromSource = (*nodeCreateCopySource)(nil) + +func newNodeCreateCopySource(ctx context.Context, graphID int32, nodes []*graph.Node, kindMapper KindMapper, includeID bool) *nodeCreateCopySource { + return &nodeCreateCopySource{ + ctx: ctx, + graphID: graphID, + nodes: nodes, + kindMapper: kindMapper, + includeID: includeID, + kindIDEncoder: Int2ArrayEncoder{ + buffer: &bytes.Buffer{}, + }, + idx: -1, + } +} + +func (s *nodeCreateCopySource) Next() bool { + if s.err != nil { + return false + } + + s.idx++ + if s.idx >= len(s.nodes) { + return false + } + + nextNode := s.nodes[s.idx] + + kindIDs, err := s.kindMapper.MapKinds(s.ctx, nextNode.Kinds) + if err != nil { + s.err = err + return false + } + + propertiesJSONB, err := pgsql.PropertiesToJSONB(nextNode.Properties) + if err != nil { + s.err = err + return false + } + + kindIDsText := s.kindIDEncoder.Encode(kindIDs) + propertiesText := string(propertiesJSONB.Bytes) + + if s.includeID { + s.row = []any{ + nextNode.ID.Int64(), + s.graphID, + kindIDsText, + propertiesText, + } + } else { + s.row = []any{ + s.graphID, + kindIDsText, + propertiesText, + } + } + + return true +} + +func (s *nodeCreateCopySource) Values() ([]any, error) { + if s.err != nil { + return nil, s.err + } + + return s.row, nil +} + +func (s *nodeCreateCopySource) Err() error { + return s.err +} diff --git a/drivers/pg/batch_node_source_test.go b/drivers/pg/batch_node_source_test.go new file mode 100644 index 00000000..421f5a44 --- /dev/null +++ b/drivers/pg/batch_node_source_test.go @@ -0,0 +1,70 @@ +package pg + +import ( + "context" + "encoding/json" + "strconv" + "testing" + + "github.com/specterops/dawgs/drivers/pg/pgutil" + "github.com/specterops/dawgs/graph" + "github.com/stretchr/testify/require" +) + +func TestNodeCreateCopySourceStreamsNodesWithIDs(t *testing.T) { + ctx := context.Background() + kindMapper := pgutil.NewInMemoryKindMapper() + userKind := graph.StringKind("User") + userKindID := kindMapper.Put(userKind) + + node := graph.NewNode(graph.ID(10), graph.NewProperties().Set("name", "alice"), userKind) + source := newNodeCreateCopySource(ctx, 7, []*graph.Node{node}, kindMapper, true) + + require.True(t, source.Next()) + values, err := source.Values() + require.NoError(t, err) + require.Len(t, values, 4) + require.Equal(t, int64(10), values[0]) + require.Equal(t, int32(7), values[1]) + require.Equal(t, "{"+strconv.Itoa(int(userKindID))+"}", values[2]) + + propertiesText, ok := values[3].(string) + require.True(t, ok) + + var properties map[string]any + require.NoError(t, json.Unmarshal([]byte(propertiesText), &properties)) + require.Equal(t, map[string]any{"name": "alice"}, properties) + + require.False(t, source.Next()) + require.NoError(t, source.Err()) +} + +func TestNodeCreateCopySourceStreamsNodesWithoutIDs(t *testing.T) { + ctx := context.Background() + kindMapper := pgutil.NewInMemoryKindMapper() + userKind := graph.StringKind("User") + kindMapper.Put(userKind) + + node := graph.PrepareNode(graph.NewProperties(), userKind) + source := newNodeCreateCopySource(ctx, 7, []*graph.Node{node}, kindMapper, false) + + require.True(t, source.Next()) + values, err := source.Values() + require.NoError(t, err) + require.Len(t, values, 3) + require.Equal(t, int32(7), values[0]) + require.Equal(t, "{}", values[2]) +} + +func TestNodeCreateCopySourceStopsWhenKindIsMissing(t *testing.T) { + source := newNodeCreateCopySource( + context.Background(), + 7, + []*graph.Node{graph.NewNode(1, graph.NewProperties(), graph.StringKind("Missing"))}, + pgutil.NewInMemoryKindMapper(), + true, + ) + + require.False(t, source.Next()) + require.Error(t, source.Err()) +} diff --git a/drivers/pg/query/format.go b/drivers/pg/query/format.go index 7c2a8a35..0256d24e 100644 --- a/drivers/pg/query/format.go +++ b/drivers/pg/query/format.go @@ -157,6 +157,42 @@ func FormatNodeUpsert(graphTarget model.Graph, identityProperties []string) stri ) } +const NodeCreateStagingTable = "node_create_staging" + +var ( + NodeCreateWithIDStagingColumns = []string{"id", "graph_id", "kind_ids", "properties"} + NodeCreateWithoutIDStagingColumns = []string{"graph_id", "kind_ids", "properties"} +) + +func FormatCreateNodeCreateStagingTable(stagingTable string) string { + return join( + "create temp table if not exists ", stagingTable, " (", + "id bigint, ", + "graph_id integer not null, ", + "kind_ids text not null, ", + "properties text not null", + ") on commit drop;", + ) +} + +func FormatMergeNodeCreateStagingWithIDs(graphTarget model.Graph, stagingTable string) string { + return join( + "insert into ", graphTarget.Partitions.Node.Name, " ", + "(id, graph_id, kind_ids, properties) ", + "select id, graph_id, kind_ids::int2[], properties::jsonb ", + "from ", stagingTable, ";", + ) +} + +func FormatMergeNodeCreateStagingWithoutIDs(graphTarget model.Graph, stagingTable string) string { + return join( + "insert into ", graphTarget.Partitions.Node.Name, " ", + "(graph_id, kind_ids, properties) ", + "select graph_id, kind_ids::int2[], properties::jsonb ", + "from ", stagingTable, ";", + ) +} + func FormatRelationshipPartitionUpsert(graphTarget model.Graph, identityProperties []string) string { return join("insert into ", graphTarget.Partitions.Edge.Name, " as e ", "(graph_id, start_id, end_id, kind_id, properties) ", diff --git a/drivers/pg/query/format_test.go b/drivers/pg/query/format_test.go index fce62c2f..b6217219 100644 --- a/drivers/pg/query/format_test.go +++ b/drivers/pg/query/format_test.go @@ -92,6 +92,68 @@ func TestNodeUpdateStagingColumns(t *testing.T) { assert.Equal(t, expected, query.NodeUpdateStagingColumns) } +func TestFormatCreateNodeCreateStagingTable(t *testing.T) { + t.Parallel() + + var ( + tableName = "my_node_staging" + expected = strings.Join([]string{ + "create temp table if not exists my_node_staging (", + "id bigint, ", + "graph_id integer not null, ", + "kind_ids text not null, ", + "properties text not null", + ") on commit drop;", + }, "") + result = query.FormatCreateNodeCreateStagingTable(tableName) + ) + + assert.Equal(t, expected, result) +} + +func TestFormatMergeNodeCreateStagingWithIDs(t *testing.T) { + t.Parallel() + + var ( + stagingTable = "my_node_staging" + graphTarget = generateTestGraphTarget("node_part_1") + expected = strings.Join([]string{ + "insert into node_part_1 ", + "(id, graph_id, kind_ids, properties) ", + "select id, graph_id, kind_ids::int2[], properties::jsonb ", + "from my_node_staging;", + }, "") + result = query.FormatMergeNodeCreateStagingWithIDs(graphTarget, stagingTable) + ) + + assert.Equal(t, expected, result) +} + +func TestFormatMergeNodeCreateStagingWithoutIDs(t *testing.T) { + t.Parallel() + + var ( + stagingTable = "my_node_staging" + graphTarget = generateTestGraphTarget("node_part_1") + expected = strings.Join([]string{ + "insert into node_part_1 ", + "(graph_id, kind_ids, properties) ", + "select graph_id, kind_ids::int2[], properties::jsonb ", + "from my_node_staging;", + }, "") + result = query.FormatMergeNodeCreateStagingWithoutIDs(graphTarget, stagingTable) + ) + + assert.Equal(t, expected, result) +} + +func TestNodeCreateStagingColumns(t *testing.T) { + t.Parallel() + + assert.Equal(t, []string{"id", "graph_id", "kind_ids", "properties"}, query.NodeCreateWithIDStagingColumns) + assert.Equal(t, []string{"graph_id", "kind_ids", "properties"}, query.NodeCreateWithoutIDStagingColumns) +} + func TestFormatCreateRelationshipCreateStagingTable(t *testing.T) { t.Parallel() From 03297a5f87202fbeba2637743158f9ad14779e1e Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 20:32:44 -0700 Subject: [PATCH 105/116] Stream PG node updates through COPY staging --- batch_operation_plan.md | 3 +- drivers/pg/batch.go | 216 ++++---------------- drivers/pg/batch_node_update_source.go | 87 ++++++++ drivers/pg/batch_node_update_source_test.go | 60 ++++++ drivers/pg/query/format.go | 4 +- 5 files changed, 186 insertions(+), 184 deletions(-) create mode 100644 drivers/pg/batch_node_update_source.go create mode 100644 drivers/pg/batch_node_update_source_test.go diff --git a/batch_operation_plan.md b/batch_operation_plan.md index 886c44ea..a6306391 100644 --- a/batch_operation_plan.md +++ b/batch_operation_plan.md @@ -63,7 +63,7 @@ Status: Complete. Replace the normal parameter-array node update and the special large-update path with one staging-based implementation. The existing large-update flow is a useful starting point but should become streaming rather than pre-materialized. -Status: Pending. +Status: Complete. ### 7. Convert Upsert Batches @@ -106,3 +106,4 @@ This plan should be updated after each step is completed. If a step exposes a si - Step 3 added a generic slice-backed `CopyFromSource`. It streams encoded rows from existing buffers without creating a second materialized row matrix; later steps can still replace the outer buffers if needed. - Step 4 moved relationship create/upsert to staging-table `COPY` and SQL duplicate coalescing. This removed the old in-memory relationship de-duplication path, including its ambiguous key and incorrect index lookup behavior. - Step 5 moved node creation to staging-table `COPY` while preserving the existing split between preset-ID and generated-ID batches. Kind assertion remains outside the COPY stream, and row streaming uses kind mapping only. +- Step 6 unified normal and large node updates on the same staging-table `COPY` flush path. Large node update inputs now use normal batch chunking instead of a separate all-at-once row materialization path. diff --git a/drivers/pg/batch.go b/drivers/pg/batch.go index fffdf9c7..5a4800fd 100644 --- a/drivers/pg/batch.go +++ b/drivers/pg/batch.go @@ -15,10 +15,6 @@ import ( "github.com/specterops/dawgs/graph" ) -const ( - LargeNodeUpdateThreshold = 1_000_000 -) - type Int2ArrayEncoder struct { buffer *bytes.Buffer } @@ -93,112 +89,7 @@ func (s *batch) UpdateNodeBy(update graph.NodeUpdate) error { return s.tryFlush(s.batchWriteSize) } -// largeUpdate performs a bulk node update using PostgreSQL's COPY FROM to stream -// nodes into a temporary staging table and then MERGE INTO the live node partition. -// This path is more efficient than a parameterised UPDATE for very large batches -// (see LargeUpdateThreshold). -func (s *batch) largeUpdate(nodes []*graph.Node) error { - tx, err := s.innerTransaction.conn.Begin(s.ctx) - if err != nil { - return err - } - - defer tx.Rollback(s.ctx) - - if _, err := tx.Exec(s.ctx, sql.FormatCreateNodeUpdateStagingTable(sql.NodeUpdateStagingTable)); err != nil { - return fmt.Errorf("creating node update staging table: %w", err) - } - - nodeRows := NewLargeNodeUpdateRows(len(nodes)) - if err := nodeRows.AppendAll(s.ctx, nodes, s.schemaManager, s.kindIDEncoder); err != nil { - return err - } - - // Stream the rows into the staging table via COPY FROM. - if _, err := tx.Conn().CopyFrom( - s.ctx, - pgx.Identifier{sql.NodeUpdateStagingTable}, - sql.NodeUpdateStagingColumns, - pgx.CopyFromRows(nodeRows.Rows()), - ); err != nil { - return fmt.Errorf("copying nodes into staging table: %w", err) - } - - graphTarget, err := s.innerTransaction.getTargetGraph() - if err != nil { - return err - } - - if _, err := tx.Exec(s.ctx, sql.FormatMergeNodeLargeUpdate(graphTarget, sql.NodeUpdateStagingTable)); err != nil { - return fmt.Errorf("merging node updates from staging table: %w", err) - } - - if err := tx.Commit(s.ctx); err != nil { - return err - } - - return nil -} - -// LargeNodeUpdateRows accumulates encoded node rows for bulk loading via COPY FROM. -// The column order matches sql.NodeUpdateStagingColumns. -type LargeNodeUpdateRows struct { - rows [][]any -} - -func NewLargeNodeUpdateRows(size int) *LargeNodeUpdateRows { - return &LargeNodeUpdateRows{ - rows: make([][]any, 0, size), - } -} - -func (s *LargeNodeUpdateRows) Rows() [][]any { - return s.rows -} - -func (s *LargeNodeUpdateRows) Append(ctx context.Context, node *graph.Node, schemaManager *SchemaManager, kindIDEncoder Int2ArrayEncoder) error { - addedKindIDs, err := schemaManager.AssertKinds(ctx, node.Kinds) - if err != nil { - return fmt.Errorf("mapping added kinds for node %d: %w", node.ID, err) - } - - deletedKindIDs, err := schemaManager.AssertKinds(ctx, node.DeletedKinds) - if err != nil { - return fmt.Errorf("mapping deleted kinds for node %d: %w", node.ID, err) - } - - propertiesJSONB, err := pgsql.PropertiesToJSONB(node.Properties) - if err != nil { - return fmt.Errorf("encoding properties for node %d: %w", node.ID, err) - } - - s.rows = append(s.rows, []any{ - node.ID.Int64(), - kindIDEncoder.Encode(addedKindIDs), - kindIDEncoder.Encode(deletedKindIDs), - string(propertiesJSONB.Bytes), - pgsql.DeletedPropertiesToString(node.Properties), - }) - - return nil -} - -// AppendAll encodes every node in the slice and appends its row to the accumulator. -func (s *LargeNodeUpdateRows) AppendAll(ctx context.Context, nodes []*graph.Node, schemaManager *SchemaManager, kindIDEncoder Int2ArrayEncoder) error { - for _, node := range nodes { - if err := s.Append(ctx, node, schemaManager, kindIDEncoder); err != nil { - return err - } - } - - return nil -} - func (s *batch) UpdateNodes(nodes []*graph.Node) error { - if len(nodes) > LargeNodeUpdateThreshold { - return s.largeUpdate(nodes) - } - for _, node := range nodes { s.nodeUpdateBuffer = append(s.nodeUpdateBuffer, node) @@ -354,98 +245,61 @@ func (s *batch) tryFlushNodeUpdateByBuffer() error { return nil } -func (s *batch) flushNodeUpdateBatch(nodes []*graph.Node) error { - parameters := NewNodeUpdateParameters(len(nodes)) - - if err := parameters.AppendAll(s.ctx, nodes, s.schemaManager, s.kindIDEncoder); err != nil { - return err - } - - if graphTarget, err := s.innerTransaction.getTargetGraph(); err != nil { - return err - } else { - query := sql.FormatNodesUpdate(graphTarget) - - if rows, err := s.innerTransaction.conn.Query(s.ctx, query, parameters.Format()...); err != nil { - return err - } else { - rows.Close() - - return rows.Err() +func (s *batch) assertNodeUpdateKinds(nodes []*graph.Node) error { + for _, node := range nodes { + if _, err := s.schemaManager.AssertKinds(s.ctx, node.Kinds); err != nil { + return fmt.Errorf("unable to map kinds %w", err) } - } -} -func (s *batch) tryFlushNodeUpdateBuffer() error { - if err := s.flushNodeUpdateBatch(s.nodeUpdateBuffer); err != nil { - return err + if _, err := s.schemaManager.AssertKinds(s.ctx, node.DeletedKinds); err != nil { + return fmt.Errorf("unable to map kinds %w", err) + } } - s.nodeUpdateBuffer = s.nodeUpdateBuffer[:0] return nil } -type NodeUpdateParameters struct { - NodeIDs []graph.ID - KindSlices []string - DeletedKindSlices []string - Properties []pgtype.JSONB - DeletedProperties []string -} - -func NewNodeUpdateParameters(size int) *NodeUpdateParameters { - return &NodeUpdateParameters{ - NodeIDs: make([]graph.ID, 0, size), - KindSlices: make([]string, 0, size), - DeletedKindSlices: make([]string, 0, size), - Properties: make([]pgtype.JSONB, 0, size), - DeletedProperties: make([]string, 0, size), - } -} - -func (s *NodeUpdateParameters) Format() []any { - return []any{ - s.NodeIDs, - s.KindSlices, - s.DeletedKindSlices, - s.Properties, - s.DeletedProperties, - } -} - -func (s *NodeUpdateParameters) Append(ctx context.Context, node *graph.Node, schemaManager *SchemaManager, kindIDEncoder Int2ArrayEncoder) error { - s.NodeIDs = append(s.NodeIDs, node.ID) - - if mappedKindIDs, err := schemaManager.AssertKinds(ctx, node.Kinds); err != nil { - return fmt.Errorf("unable to map kinds %w", err) - } else { - s.KindSlices = append(s.KindSlices, kindIDEncoder.Encode(mappedKindIDs)) +func (s *batch) flushNodeUpdateBatch(nodes []*graph.Node) error { + if len(nodes) == 0 { + return nil } - if mappedKindIDs, err := schemaManager.AssertKinds(ctx, node.DeletedKinds); err != nil { - return fmt.Errorf("unable to map kinds %w", err) - } else { - s.DeletedKindSlices = append(s.DeletedKindSlices, kindIDEncoder.Encode(mappedKindIDs)) + if err := s.assertNodeUpdateKinds(nodes); err != nil { + return err } - if propertiesJSONB, err := pgsql.PropertiesToJSONB(node.Properties); err != nil { + if graphTarget, err := s.innerTransaction.getTargetGraph(); err != nil { return err } else { - s.Properties = append(s.Properties, propertiesJSONB) - } + stage := batchCopyStage{ + Name: "node update", + TableIdentifier: pgx.Identifier{sql.NodeUpdateStagingTable}, + Columns: sql.NodeUpdateStagingColumns, + Source: newNodeUpdateCopySource(s.ctx, nodes, s.schemaManager), + BeforeCopy: []batchChunkStatement{{ + Name: "create node update staging table", + Statement: sql.FormatCreateNodeUpdateStagingTable(sql.NodeUpdateStagingTable), + }}, + AfterCopy: []batchChunkStatement{{ + Name: "merge node update staging table", + Statement: sql.FormatMergeNodeLargeUpdate(graphTarget, sql.NodeUpdateStagingTable), + }}, + } - s.DeletedProperties = append(s.DeletedProperties, pgsql.DeletedPropertiesToString(node.Properties)) + if _, err := copyBatchStageChunk(s.ctx, s.innerTransaction.conn, stage); err != nil { + return err + } + } return nil } -func (s *NodeUpdateParameters) AppendAll(ctx context.Context, nodes []*graph.Node, schemaManager *SchemaManager, kindIDEncoder Int2ArrayEncoder) error { - for _, node := range nodes { - if err := s.Append(ctx, node, schemaManager, kindIDEncoder); err != nil { - return err - } +func (s *batch) tryFlushNodeUpdateBuffer() error { + if err := s.flushNodeUpdateBatch(s.nodeUpdateBuffer); err != nil { + return err } + s.nodeUpdateBuffer = s.nodeUpdateBuffer[:0] return nil } diff --git a/drivers/pg/batch_node_update_source.go b/drivers/pg/batch_node_update_source.go new file mode 100644 index 00000000..8cf7a048 --- /dev/null +++ b/drivers/pg/batch_node_update_source.go @@ -0,0 +1,87 @@ +package pg + +import ( + "bytes" + "context" + + "github.com/jackc/pgx/v5" + "github.com/specterops/dawgs/cypher/models/pgsql" + "github.com/specterops/dawgs/graph" +) + +type nodeUpdateCopySource struct { + ctx context.Context + nodes []*graph.Node + kindMapper KindMapper + kindIDEncoder Int2ArrayEncoder + idx int + row []any + err error +} + +var _ pgx.CopyFromSource = (*nodeUpdateCopySource)(nil) + +func newNodeUpdateCopySource(ctx context.Context, nodes []*graph.Node, kindMapper KindMapper) *nodeUpdateCopySource { + return &nodeUpdateCopySource{ + ctx: ctx, + nodes: nodes, + kindMapper: kindMapper, + kindIDEncoder: Int2ArrayEncoder{ + buffer: &bytes.Buffer{}, + }, + idx: -1, + } +} + +func (s *nodeUpdateCopySource) Next() bool { + if s.err != nil { + return false + } + + s.idx++ + if s.idx >= len(s.nodes) { + return false + } + + nextNode := s.nodes[s.idx] + + addedKindIDs, err := s.kindMapper.MapKinds(s.ctx, nextNode.Kinds) + if err != nil { + s.err = err + return false + } + + deletedKindIDs, err := s.kindMapper.MapKinds(s.ctx, nextNode.DeletedKinds) + if err != nil { + s.err = err + return false + } + + propertiesJSONB, err := pgsql.PropertiesToJSONB(nextNode.Properties) + if err != nil { + s.err = err + return false + } + + s.row = []any{ + nextNode.ID.Int64(), + s.kindIDEncoder.Encode(addedKindIDs), + s.kindIDEncoder.Encode(deletedKindIDs), + string(propertiesJSONB.Bytes), + pgsql.DeletedPropertiesToString(nextNode.Properties), + } + + return true +} + +func (s *nodeUpdateCopySource) Values() ([]any, error) { + if s.err != nil { + return nil, s.err + } + + return s.row, nil +} + +func (s *nodeUpdateCopySource) Err() error { + return s.err +} diff --git a/drivers/pg/batch_node_update_source_test.go b/drivers/pg/batch_node_update_source_test.go new file mode 100644 index 00000000..db8d9440 --- /dev/null +++ b/drivers/pg/batch_node_update_source_test.go @@ -0,0 +1,60 @@ +package pg + +import ( + "context" + "encoding/json" + "strconv" + "testing" + + "github.com/specterops/dawgs/drivers/pg/pgutil" + "github.com/specterops/dawgs/graph" + "github.com/stretchr/testify/require" +) + +func TestNodeUpdateCopySourceStreamsNodes(t *testing.T) { + ctx := context.Background() + kindMapper := pgutil.NewInMemoryKindMapper() + userKind := graph.StringKind("User") + groupKind := graph.StringKind("Group") + userKindID := kindMapper.Put(userKind) + groupKindID := kindMapper.Put(groupKind) + + properties := graph.NewProperties() + properties.Set("name", "alice") + properties.Delete("stale") + + node := graph.NewNode(graph.ID(10), properties, userKind) + node.DeletedKinds = graph.Kinds{groupKind} + + source := newNodeUpdateCopySource(ctx, []*graph.Node{node}, kindMapper) + + require.True(t, source.Next()) + values, err := source.Values() + require.NoError(t, err) + require.Len(t, values, 5) + require.Equal(t, int64(10), values[0]) + require.Equal(t, "{"+strconv.Itoa(int(userKindID))+"}", values[1]) + require.Equal(t, "{"+strconv.Itoa(int(groupKindID))+"}", values[2]) + require.Equal(t, `{"stale"}`, values[4]) + + propertiesText, ok := values[3].(string) + require.True(t, ok) + + var encodedProperties map[string]any + require.NoError(t, json.Unmarshal([]byte(propertiesText), &encodedProperties)) + require.Equal(t, map[string]any{"name": "alice"}, encodedProperties) + + require.False(t, source.Next()) + require.NoError(t, source.Err()) +} + +func TestNodeUpdateCopySourceStopsWhenKindIsMissing(t *testing.T) { + source := newNodeUpdateCopySource( + context.Background(), + []*graph.Node{graph.NewNode(1, graph.NewProperties(), graph.StringKind("Missing"))}, + pgutil.NewInMemoryKindMapper(), + ) + + require.False(t, source.Next()) + require.Error(t, source.Err()) +} diff --git a/drivers/pg/query/format.go b/drivers/pg/query/format.go index 0256d24e..84baa0a3 100644 --- a/drivers/pg/query/format.go +++ b/drivers/pg/query/format.go @@ -118,10 +118,10 @@ func FormatNodesUpdate(graphTarget model.Graph) string { ) } -// NodeUpdateStagingTable is the name of the temporary staging table used by largeUpdate. +// NodeUpdateStagingTable is the name of the temporary staging table used by node update COPY flushes. const NodeUpdateStagingTable = "node_update_staging" -// NodeUpdateStagingColumns lists the columns (in order) written by a COPY FROM during largeUpdate. +// NodeUpdateStagingColumns lists the columns (in order) written by node update COPY flushes. var NodeUpdateStagingColumns = []string{"id", "added_kinds", "deleted_kinds", "properties", "deleted_props"} func FormatCreateNodeUpdateStagingTable(stagingTable string) string { From 56db31ad0e91e83ff466a422936927be10dd674b Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 20:36:36 -0700 Subject: [PATCH 106/116] Stream PG upsert batches through COPY staging --- batch_operation_plan.md | 3 +- drivers/pg/batch.go | 194 ++++++++---------- drivers/pg/batch_node_upsert_source.go | 82 ++++++++ drivers/pg/batch_node_upsert_source_test.go | 56 +++++ .../pg/batch_relationship_update_source.go | 79 +++++++ .../batch_relationship_update_source_test.go | 65 ++++++ drivers/pg/query/format.go | 37 +++- drivers/pg/query/format_test.go | 79 +++++++ 8 files changed, 482 insertions(+), 113 deletions(-) create mode 100644 drivers/pg/batch_node_upsert_source.go create mode 100644 drivers/pg/batch_node_upsert_source_test.go create mode 100644 drivers/pg/batch_relationship_update_source.go create mode 100644 drivers/pg/batch_relationship_update_source_test.go diff --git a/batch_operation_plan.md b/batch_operation_plan.md index a6306391..cd817141 100644 --- a/batch_operation_plan.md +++ b/batch_operation_plan.md @@ -69,7 +69,7 @@ Status: Complete. Convert `UpdateNodeBy` and `UpdateRelationshipBy` after the simpler paths are stable. Preserve current identity-property semantics while moving the data transfer to staging-table `COPY`. -Status: Pending. +Status: Complete. ### 8. Add PG-Scoped Tests @@ -107,3 +107,4 @@ This plan should be updated after each step is completed. If a step exposes a si - Step 4 moved relationship create/upsert to staging-table `COPY` and SQL duplicate coalescing. This removed the old in-memory relationship de-duplication path, including its ambiguous key and incorrect index lookup behavior. - Step 5 moved node creation to staging-table `COPY` while preserving the existing split between preset-ID and generated-ID batches. Kind assertion remains outside the COPY stream, and row streaming uses kind mapping only. - Step 6 unified normal and large node updates on the same staging-table `COPY` flush path. Large node update inputs now use normal batch chunking instead of a separate all-at-once row materialization path. +- Step 7 moved `UpdateNodeBy` and `UpdateRelationshipBy` to staging-table `COPY`. Node upserts still scan returned IDs into futures in staged row order so relationship upserts can reuse the resolved endpoint IDs. diff --git a/drivers/pg/batch.go b/drivers/pg/batch.go index 5a4800fd..4ee45953 100644 --- a/drivers/pg/batch.go +++ b/drivers/pg/batch.go @@ -6,10 +6,8 @@ import ( "fmt" "strconv" - "github.com/jackc/pgtype" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" - "github.com/specterops/dawgs/cypher/models/pgsql" "github.com/specterops/dawgs/drivers/pg/model" sql "github.com/specterops/dawgs/drivers/pg/query" "github.com/specterops/dawgs/graph" @@ -198,27 +196,68 @@ func (s *batch) flushNodeCreateBufferWithoutIDs() error { return s.flushNodeCreateCopyStage(false, sql.NodeCreateWithoutIDStagingColumns, sql.FormatMergeNodeCreateStagingWithoutIDs) } +func nodeUpsertValues(updates *sql.NodeUpdateBatch) []*sql.NodeUpdate { + values := make([]*sql.NodeUpdate, 0, len(updates.Updates)) + for _, update := range updates.Updates { + values = append(values, update) + } + + return values +} + +func (s *batch) assertNodeUpsertKinds(updates []*sql.NodeUpdate) error { + for _, update := range updates { + if _, err := s.schemaManager.AssertKinds(s.ctx, update.Node.Kinds); err != nil { + return fmt.Errorf("unable to map kinds %w", err) + } + } + + return nil +} + func (s *batch) flushNodeUpsertBatch(updates *sql.NodeUpdateBatch) error { - parameters := NewNodeUpsertParameters(len(updates.Updates)) + values := nodeUpsertValues(updates) + if len(values) == 0 { + return nil + } - if err := parameters.AppendAll(s.ctx, updates, s.schemaManager, s.kindIDEncoder); err != nil { + if err := s.assertNodeUpsertKinds(values); err != nil { return err } if graphTarget, err := s.innerTransaction.getTargetGraph(); err != nil { return err } else { - query := sql.FormatNodeUpsert(graphTarget, updates.IdentityProperties) + return runBatchChunk(s.ctx, s.innerTransaction.conn, func(tx pgx.Tx) error { + if err := execBatchChunkStatements(s.ctx, tx, []batchChunkStatement{{ + Name: "create node upsert staging table", + Statement: sql.FormatCreateNodeUpsertStagingTable(sql.NodeUpsertStagingTable), + }}); err != nil { + return err + } - if rows, err := s.innerTransaction.conn.Query(s.ctx, query, parameters.Format(graphTarget)...); err != nil { - return err - } else { + if _, err := tx.CopyFrom( + s.ctx, + pgx.Identifier{sql.NodeUpsertStagingTable}, + sql.NodeUpsertStagingColumns, + newNodeUpsertCopySource(s.ctx, graphTarget.ID, values, s.schemaManager), + ); err != nil { + return fmt.Errorf("node upsert copy: %w", err) + } + + rows, err := tx.Query(s.ctx, sql.FormatMergeNodeUpsertStaging(graphTarget, updates.IdentityProperties, sql.NodeUpsertStagingTable)) + if err != nil { + return err + } defer rows.Close() idFutureIndex := 0 - for rows.Next() { - if err := rows.Scan(¶meters.IDFutures[idFutureIndex].Value); err != nil { + if idFutureIndex >= len(values) { + return fmt.Errorf("node upsert returned more ids than staged rows") + } + + if err := rows.Scan(&values[idFutureIndex].IDFuture.Value); err != nil { return err } @@ -228,10 +267,14 @@ func (s *batch) flushNodeUpsertBatch(updates *sql.NodeUpdateBatch) error { if err := rows.Err(); err != nil { return err } - } - } - return nil + if idFutureIndex != len(values) { + return fmt.Errorf("node upsert returned %d ids for %d staged rows", idFutureIndex, len(values)) + } + + return nil + }) + } } func (s *batch) tryFlushNodeUpdateByBuffer() error { @@ -303,103 +346,18 @@ func (s *batch) tryFlushNodeUpdateBuffer() error { return nil } -type NodeUpsertParameters struct { - IDFutures []*sql.Future[graph.ID] - KindIDSlices []string - Properties []pgtype.JSONB -} - -func NewNodeUpsertParameters(size int) *NodeUpsertParameters { - return &NodeUpsertParameters{ - IDFutures: make([]*sql.Future[graph.ID], 0, size), - KindIDSlices: make([]string, 0, size), - Properties: make([]pgtype.JSONB, 0, size), +func relationshipUpdateValues(updates *sql.RelationshipUpdateBatch) []*sql.RelationshipUpdate { + values := make([]*sql.RelationshipUpdate, 0, len(updates.Updates)) + for _, update := range updates.Updates { + values = append(values, update) } -} -func (s *NodeUpsertParameters) Format(graphTarget model.Graph) []any { - return []any{ - graphTarget.ID, - s.KindIDSlices, - s.Properties, - } + return values } -func (s *NodeUpsertParameters) Append(ctx context.Context, update *sql.NodeUpdate, schemaManager *SchemaManager, kindIDEncoder Int2ArrayEncoder) error { - s.IDFutures = append(s.IDFutures, update.IDFuture) - - if mappedKindIDs, err := schemaManager.AssertKinds(ctx, update.Node.Kinds); err != nil { - return fmt.Errorf("unable to map kinds %w", err) - } else { - s.KindIDSlices = append(s.KindIDSlices, kindIDEncoder.Encode(mappedKindIDs)) - } - - if propertiesJSONB, err := pgsql.PropertiesToJSONB(update.Node.Properties); err != nil { - return err - } else { - s.Properties = append(s.Properties, propertiesJSONB) - } - - return nil -} - -func (s *NodeUpsertParameters) AppendAll(ctx context.Context, updates *sql.NodeUpdateBatch, schemaManager *SchemaManager, kindIDEncoder Int2ArrayEncoder) error { - for _, nextUpdate := range updates.Updates { - if err := s.Append(ctx, nextUpdate, schemaManager, kindIDEncoder); err != nil { - return err - } - } - - return nil -} - -type RelationshipUpdateByParameters struct { - StartIDs []graph.ID - EndIDs []graph.ID - KindIDs []int16 - Properties []pgtype.JSONB -} - -func NewRelationshipUpdateByParameters(size int) *RelationshipUpdateByParameters { - return &RelationshipUpdateByParameters{ - StartIDs: make([]graph.ID, 0, size), - EndIDs: make([]graph.ID, 0, size), - KindIDs: make([]int16, 0, size), - Properties: make([]pgtype.JSONB, 0, size), - } -} - -func (s *RelationshipUpdateByParameters) Format(graphTarget model.Graph) []any { - return []any{ - graphTarget.ID, - s.StartIDs, - s.EndIDs, - s.KindIDs, - s.Properties, - } -} - -func (s *RelationshipUpdateByParameters) Append(ctx context.Context, update *sql.RelationshipUpdate, schemaManager *SchemaManager) error { - s.StartIDs = append(s.StartIDs, update.StartID.Value) - s.EndIDs = append(s.EndIDs, update.EndID.Value) - - if mappedKindIDs, err := schemaManager.AssertKinds(ctx, []graph.Kind{update.Relationship.Kind}); err != nil { - return err - } else { - s.KindIDs = append(s.KindIDs, mappedKindIDs...) - } - - if propertiesJSONB, err := pgsql.PropertiesToJSONB(update.Relationship.Properties); err != nil { - return err - } else { - s.Properties = append(s.Properties, propertiesJSONB) - } - return nil -} - -func (s *RelationshipUpdateByParameters) AppendAll(ctx context.Context, updates *sql.RelationshipUpdateBatch, schemaManager *SchemaManager) error { - for _, nextUpdate := range updates.Updates { - if err := s.Append(ctx, nextUpdate, schemaManager); err != nil { +func (s *batch) assertRelationshipUpdateKinds(updates []*sql.RelationshipUpdate) error { + for _, update := range updates { + if _, err := s.schemaManager.AssertKinds(s.ctx, []graph.Kind{update.Relationship.Kind}); err != nil { return err } } @@ -412,18 +370,34 @@ func (s *batch) flushRelationshipUpdateByBuffer(updates *sql.RelationshipUpdateB return err } - parameters := NewRelationshipUpdateByParameters(len(updates.Updates)) + values := relationshipUpdateValues(updates) + if len(values) == 0 { + return nil + } - if err := parameters.AppendAll(s.ctx, updates, s.schemaManager); err != nil { + if err := s.assertRelationshipUpdateKinds(values); err != nil { return err } if graphTarget, err := s.innerTransaction.getTargetGraph(); err != nil { return err } else { - query := sql.FormatRelationshipPartitionUpsert(graphTarget, updates.IdentityProperties) + stage := batchCopyStage{ + Name: "relationship update", + TableIdentifier: pgx.Identifier{sql.RelationshipCreateStagingTable}, + Columns: sql.RelationshipCreateStagingColumns, + Source: newRelationshipUpdateCopySource(s.ctx, graphTarget.ID, values, s.schemaManager), + BeforeCopy: []batchChunkStatement{{ + Name: "create relationship update staging table", + Statement: sql.FormatCreateRelationshipCreateStagingTable(sql.RelationshipCreateStagingTable), + }}, + AfterCopy: []batchChunkStatement{{ + Name: "merge relationship update staging table", + Statement: sql.FormatMergeRelationshipStaging(graphTarget, updates.IdentityProperties, sql.RelationshipCreateStagingTable), + }}, + } - if _, err := s.innerTransaction.conn.Exec(s.ctx, query, parameters.Format(graphTarget)...); err != nil { + if _, err := copyBatchStageChunk(s.ctx, s.innerTransaction.conn, stage); err != nil { return err } } diff --git a/drivers/pg/batch_node_upsert_source.go b/drivers/pg/batch_node_upsert_source.go new file mode 100644 index 00000000..b1a1be58 --- /dev/null +++ b/drivers/pg/batch_node_upsert_source.go @@ -0,0 +1,82 @@ +package pg + +import ( + "bytes" + "context" + + "github.com/jackc/pgx/v5" + "github.com/specterops/dawgs/cypher/models/pgsql" + sql "github.com/specterops/dawgs/drivers/pg/query" +) + +type nodeUpsertCopySource struct { + ctx context.Context + graphID int32 + updates []*sql.NodeUpdate + kindMapper KindMapper + kindIDEncoder Int2ArrayEncoder + idx int + row []any + err error +} + +var _ pgx.CopyFromSource = (*nodeUpsertCopySource)(nil) + +func newNodeUpsertCopySource(ctx context.Context, graphID int32, updates []*sql.NodeUpdate, kindMapper KindMapper) *nodeUpsertCopySource { + return &nodeUpsertCopySource{ + ctx: ctx, + graphID: graphID, + updates: updates, + kindMapper: kindMapper, + kindIDEncoder: Int2ArrayEncoder{ + buffer: &bytes.Buffer{}, + }, + idx: -1, + } +} + +func (s *nodeUpsertCopySource) Next() bool { + if s.err != nil { + return false + } + + s.idx++ + if s.idx >= len(s.updates) { + return false + } + + nextUpdate := s.updates[s.idx] + + kindIDs, err := s.kindMapper.MapKinds(s.ctx, nextUpdate.Node.Kinds) + if err != nil { + s.err = err + return false + } + + propertiesJSONB, err := pgsql.PropertiesToJSONB(nextUpdate.Node.Properties) + if err != nil { + s.err = err + return false + } + + s.row = []any{ + s.idx, + s.graphID, + s.kindIDEncoder.Encode(kindIDs), + string(propertiesJSONB.Bytes), + } + + return true +} + +func (s *nodeUpsertCopySource) Values() ([]any, error) { + if s.err != nil { + return nil, s.err + } + + return s.row, nil +} + +func (s *nodeUpsertCopySource) Err() error { + return s.err +} diff --git a/drivers/pg/batch_node_upsert_source_test.go b/drivers/pg/batch_node_upsert_source_test.go new file mode 100644 index 00000000..65b7442b --- /dev/null +++ b/drivers/pg/batch_node_upsert_source_test.go @@ -0,0 +1,56 @@ +package pg + +import ( + "context" + "encoding/json" + "strconv" + "testing" + + "github.com/specterops/dawgs/drivers/pg/pgutil" + sql "github.com/specterops/dawgs/drivers/pg/query" + "github.com/specterops/dawgs/graph" + "github.com/stretchr/testify/require" +) + +func TestNodeUpsertCopySourceStreamsUpdates(t *testing.T) { + ctx := context.Background() + kindMapper := pgutil.NewInMemoryKindMapper() + userKind := graph.StringKind("User") + userKindID := kindMapper.Put(userKind) + + update := &sql.NodeUpdate{ + IDFuture: sql.NewFuture(graph.ID(0)), + Node: graph.NewNode(0, graph.NewProperties().Set("objectid", "alice"), userKind), + } + source := newNodeUpsertCopySource(ctx, 7, []*sql.NodeUpdate{update}, kindMapper) + + require.True(t, source.Next()) + values, err := source.Values() + require.NoError(t, err) + require.Len(t, values, 4) + require.Equal(t, 0, values[0]) + require.Equal(t, int32(7), values[1]) + require.Equal(t, "{"+strconv.Itoa(int(userKindID))+"}", values[2]) + + propertiesText, ok := values[3].(string) + require.True(t, ok) + + var properties map[string]any + require.NoError(t, json.Unmarshal([]byte(propertiesText), &properties)) + require.Equal(t, map[string]any{"objectid": "alice"}, properties) +} + +func TestNodeUpsertCopySourceStopsWhenKindIsMissing(t *testing.T) { + source := newNodeUpsertCopySource( + context.Background(), + 7, + []*sql.NodeUpdate{{ + IDFuture: sql.NewFuture(graph.ID(0)), + Node: graph.NewNode(0, graph.NewProperties(), graph.StringKind("Missing")), + }}, + pgutil.NewInMemoryKindMapper(), + ) + + require.False(t, source.Next()) + require.Error(t, source.Err()) +} diff --git a/drivers/pg/batch_relationship_update_source.go b/drivers/pg/batch_relationship_update_source.go new file mode 100644 index 00000000..c93cc424 --- /dev/null +++ b/drivers/pg/batch_relationship_update_source.go @@ -0,0 +1,79 @@ +package pg + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/specterops/dawgs/cypher/models/pgsql" + sql "github.com/specterops/dawgs/drivers/pg/query" +) + +type relationshipUpdateCopySource struct { + ctx context.Context + graphID int32 + updates []*sql.RelationshipUpdate + kindMapper KindMapper + idx int + row []any + err error +} + +var _ pgx.CopyFromSource = (*relationshipUpdateCopySource)(nil) + +func newRelationshipUpdateCopySource(ctx context.Context, graphID int32, updates []*sql.RelationshipUpdate, kindMapper KindMapper) *relationshipUpdateCopySource { + return &relationshipUpdateCopySource{ + ctx: ctx, + graphID: graphID, + updates: updates, + kindMapper: kindMapper, + idx: -1, + } +} + +func (s *relationshipUpdateCopySource) Next() bool { + if s.err != nil { + return false + } + + s.idx++ + if s.idx >= len(s.updates) { + return false + } + + nextUpdate := s.updates[s.idx] + + kindID, err := s.kindMapper.MapKind(s.ctx, nextUpdate.Relationship.Kind) + if err != nil { + s.err = err + return false + } + + propertiesJSONB, err := pgsql.PropertiesToJSONB(nextUpdate.Relationship.Properties) + if err != nil { + s.err = err + return false + } + + s.row = []any{ + s.idx, + s.graphID, + nextUpdate.StartID.Value.Int64(), + nextUpdate.EndID.Value.Int64(), + kindID, + string(propertiesJSONB.Bytes), + } + + return true +} + +func (s *relationshipUpdateCopySource) Values() ([]any, error) { + if s.err != nil { + return nil, s.err + } + + return s.row, nil +} + +func (s *relationshipUpdateCopySource) Err() error { + return s.err +} diff --git a/drivers/pg/batch_relationship_update_source_test.go b/drivers/pg/batch_relationship_update_source_test.go new file mode 100644 index 00000000..f113b870 --- /dev/null +++ b/drivers/pg/batch_relationship_update_source_test.go @@ -0,0 +1,65 @@ +package pg + +import ( + "context" + "encoding/json" + "testing" + + "github.com/specterops/dawgs/drivers/pg/pgutil" + sql "github.com/specterops/dawgs/drivers/pg/query" + "github.com/specterops/dawgs/graph" + "github.com/stretchr/testify/require" +) + +func TestRelationshipUpdateCopySourceStreamsUpdates(t *testing.T) { + ctx := context.Background() + kindMapper := pgutil.NewInMemoryKindMapper() + edgeKind := graph.StringKind("MemberOf") + edgeKindID := kindMapper.Put(edgeKind) + + update := &sql.RelationshipUpdate{ + StartID: sql.NewFuture(graph.ID(10)), + EndID: sql.NewFuture(graph.ID(20)), + Relationship: graph.NewRelationship( + 0, + 0, + 0, + graph.NewProperties().Set("objectid", "edge-1"), + edgeKind, + ), + } + source := newRelationshipUpdateCopySource(ctx, 7, []*sql.RelationshipUpdate{update}, kindMapper) + + require.True(t, source.Next()) + values, err := source.Values() + require.NoError(t, err) + require.Len(t, values, 6) + require.Equal(t, 0, values[0]) + require.Equal(t, int32(7), values[1]) + require.Equal(t, int64(10), values[2]) + require.Equal(t, int64(20), values[3]) + require.Equal(t, edgeKindID, values[4]) + + propertiesText, ok := values[5].(string) + require.True(t, ok) + + var properties map[string]any + require.NoError(t, json.Unmarshal([]byte(propertiesText), &properties)) + require.Equal(t, map[string]any{"objectid": "edge-1"}, properties) +} + +func TestRelationshipUpdateCopySourceStopsWhenKindIsMissing(t *testing.T) { + source := newRelationshipUpdateCopySource( + context.Background(), + 7, + []*sql.RelationshipUpdate{{ + StartID: sql.NewFuture(graph.ID(10)), + EndID: sql.NewFuture(graph.ID(20)), + Relationship: graph.NewRelationship(0, 0, 0, graph.NewProperties(), graph.StringKind("Missing")), + }}, + pgutil.NewInMemoryKindMapper(), + ) + + require.False(t, source.Next()) + require.Error(t, source.Err()) +} diff --git a/drivers/pg/query/format.go b/drivers/pg/query/format.go index 84baa0a3..7c6b5a3c 100644 --- a/drivers/pg/query/format.go +++ b/drivers/pg/query/format.go @@ -157,6 +157,34 @@ func FormatNodeUpsert(graphTarget model.Graph, identityProperties []string) stri ) } +const NodeUpsertStagingTable = "node_upsert_staging" + +var NodeUpsertStagingColumns = []string{"row_ord", "graph_id", "kind_ids", "properties"} + +func FormatCreateNodeUpsertStagingTable(stagingTable string) string { + return join( + "create temp table if not exists ", stagingTable, " (", + "row_ord integer not null, ", + "graph_id integer not null, ", + "kind_ids text not null, ", + "properties text not null", + ") on commit drop;", + ) +} + +func FormatMergeNodeUpsertStaging(graphTarget model.Graph, identityProperties []string, stagingTable string) string { + return join( + "insert into ", graphTarget.Partitions.Node.Name, " as n ", + "(graph_id, kind_ids, properties) ", + "select graph_id, kind_ids::int2[], properties::jsonb ", + "from ", stagingTable, " ", + "order by row_ord ", + formatConflictMatcher(identityProperties, "id, graph_id"), + "do update set properties = n.properties || excluded.properties, kind_ids = uniq(sort(n.kind_ids || excluded.kind_ids)) ", + "returning id;", + ) +} + const NodeCreateStagingTable = "node_create_staging" var ( @@ -219,7 +247,7 @@ func FormatCreateRelationshipCreateStagingTable(stagingTable string) string { ) } -func FormatMergeRelationshipCreateStaging(graphTarget model.Graph, stagingTable string) string { +func FormatMergeRelationshipStaging(graphTarget model.Graph, identityProperties []string, stagingTable string) string { return join( "insert into ", graphTarget.Partitions.Edge.Name, " as e ", "(graph_id, start_id, end_id, kind_id, properties) ", @@ -233,10 +261,15 @@ func FormatMergeRelationshipCreateStaging(graphTarget model.Graph, stagingTable "order by graph_id, start_id, end_id, kind_id, key, row_ord desc", ") as deduped ", "group by graph_id, start_id, end_id, kind_id ", - "on conflict (start_id, end_id, kind_id, graph_id) do update set properties = e.properties || excluded.properties;", + formatConflictMatcher(identityProperties, "start_id, end_id, kind_id, graph_id"), + "do update set properties = e.properties || excluded.properties;", ) } +func FormatMergeRelationshipCreateStaging(graphTarget model.Graph, stagingTable string) string { + return FormatMergeRelationshipStaging(graphTarget, nil, stagingTable) +} + type NodeUpdate struct { IDFuture *Future[graph.ID] Node *graph.Node diff --git a/drivers/pg/query/format_test.go b/drivers/pg/query/format_test.go index b6217219..d9b812fa 100644 --- a/drivers/pg/query/format_test.go +++ b/drivers/pg/query/format_test.go @@ -154,6 +154,53 @@ func TestNodeCreateStagingColumns(t *testing.T) { assert.Equal(t, []string{"graph_id", "kind_ids", "properties"}, query.NodeCreateWithoutIDStagingColumns) } +func TestFormatCreateNodeUpsertStagingTable(t *testing.T) { + t.Parallel() + + var ( + tableName = "my_node_upsert_staging" + expected = strings.Join([]string{ + "create temp table if not exists my_node_upsert_staging (", + "row_ord integer not null, ", + "graph_id integer not null, ", + "kind_ids text not null, ", + "properties text not null", + ") on commit drop;", + }, "") + result = query.FormatCreateNodeUpsertStagingTable(tableName) + ) + + assert.Equal(t, expected, result) +} + +func TestFormatMergeNodeUpsertStaging(t *testing.T) { + t.Parallel() + + var ( + stagingTable = "my_node_upsert_staging" + graphTarget = generateTestGraphTarget("node_part_1") + expected = strings.Join([]string{ + "insert into node_part_1 as n ", + "(graph_id, kind_ids, properties) ", + "select graph_id, kind_ids::int2[], properties::jsonb ", + "from my_node_upsert_staging ", + "order by row_ord ", + "on conflict ((properties->>'objectid')) ", + "do update set properties = n.properties || excluded.properties, kind_ids = uniq(sort(n.kind_ids || excluded.kind_ids)) ", + "returning id;", + }, "") + result = query.FormatMergeNodeUpsertStaging(graphTarget, []string{"objectid"}, stagingTable) + ) + + assert.Equal(t, expected, result) +} + +func TestNodeUpsertStagingColumns(t *testing.T) { + t.Parallel() + + assert.Equal(t, []string{"row_ord", "graph_id", "kind_ids", "properties"}, query.NodeUpsertStagingColumns) +} + func TestFormatCreateRelationshipCreateStagingTable(t *testing.T) { t.Parallel() @@ -206,6 +253,38 @@ func TestFormatMergeRelationshipCreateStaging(t *testing.T) { assert.Equal(t, expected, result) } +func TestFormatMergeRelationshipStagingWithIdentityProperties(t *testing.T) { + t.Parallel() + + var ( + stagingTable = "my_relationship_staging" + graphTarget = model.Graph{ + Partitions: model.GraphPartitions{ + Edge: model.NewGraphPartition("edge_part_1"), + }, + } + expected = strings.Join([]string{ + "insert into edge_part_1 as e ", + "(graph_id, start_id, end_id, kind_id, properties) ", + "select graph_id, start_id, end_id, kind_id, ", + "coalesce(jsonb_object_agg(key, value) filter (where key is not null), '{}'::jsonb) as properties ", + "from (", + "select distinct on (graph_id, start_id, end_id, kind_id, key) ", + "graph_id, start_id, end_id, kind_id, key, value, row_ord ", + "from my_relationship_staging ", + "left join lateral jsonb_each(properties::jsonb) as property(key, value) on true ", + "order by graph_id, start_id, end_id, kind_id, key, row_ord desc", + ") as deduped ", + "group by graph_id, start_id, end_id, kind_id ", + "on conflict ((properties->>'objectid')) ", + "do update set properties = e.properties || excluded.properties;", + }, "") + result = query.FormatMergeRelationshipStaging(graphTarget, []string{"objectid"}, stagingTable) + ) + + assert.Equal(t, expected, result) +} + func TestRelationshipCreateStagingColumns(t *testing.T) { t.Parallel() From 7c2078e4464e03427c7246bdb878f50ee7425d20 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 20:41:30 -0700 Subject: [PATCH 107/116] Add PostgreSQL batch operation integration coverage --- batch_operation_plan.md | 3 +- integration/pgsql_batch_operation_test.go | 358 ++++++++++++++++++++++ 2 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 integration/pgsql_batch_operation_test.go diff --git a/batch_operation_plan.md b/batch_operation_plan.md index cd817141..dd3c5ef6 100644 --- a/batch_operation_plan.md +++ b/batch_operation_plan.md @@ -82,7 +82,7 @@ Add PostgreSQL driver-scoped tests for: - `UpdateNodeBy` and `UpdateRelationshipBy` - streaming source behavior -Status: Pending. +Status: Complete. ### 9. Validate @@ -108,3 +108,4 @@ This plan should be updated after each step is completed. If a step exposes a si - Step 5 moved node creation to staging-table `COPY` while preserving the existing split between preset-ID and generated-ID batches. Kind assertion remains outside the COPY stream, and row streaming uses kind mapping only. - Step 6 unified normal and large node updates on the same staging-table `COPY` flush path. Large node update inputs now use normal batch chunking instead of a separate all-at-once row materialization path. - Step 7 moved `UpdateNodeBy` and `UpdateRelationshipBy` to staging-table `COPY`. Node upserts still scan returned IDs into futures in staged row order so relationship upserts can reuse the resolved endpoint IDs. +- Step 8 added manual PostgreSQL integration coverage for non-transactional flushed chunks, node create with and without IDs, relationship duplicate coalescing, node update staging, and `UpdateNodeBy`/`UpdateRelationshipBy` staging. Existing PG unit tests cover the streaming `CopyFromSource` behavior. diff --git a/integration/pgsql_batch_operation_test.go b/integration/pgsql_batch_operation_test.go new file mode 100644 index 00000000..d668598d --- /dev/null +++ b/integration/pgsql_batch_operation_test.go @@ -0,0 +1,358 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build manual_integration + +package integration + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/specterops/dawgs/drivers/pg" + "github.com/specterops/dawgs/graph" + "github.com/specterops/dawgs/query" +) + +func requirePostgreSQLBatchConnection(t *testing.T) { + t.Helper() + + connStr := os.Getenv("CONNECTION_STRING") + if connStr == "" { + t.Skip("CONNECTION_STRING env var is not set") + } + + driver, err := driverFromConnStr(connStr) + if err != nil { + t.Fatalf("failed to detect driver: %v", err) + } + if driver != pg.DriverName { + t.Skip("CONNECTION_STRING is not a PostgreSQL connection string") + } +} + +func assertBatchOperationSchema(t *testing.T, ctx context.Context, db graph.Database, nodeKinds, edgeKinds graph.Kinds, nodeConstraints, edgeConstraints []graph.Constraint) { + t.Helper() + + schema := graph.Schema{ + Graphs: []graph.Graph{{ + Name: "integration_test", + Nodes: nodeKinds, + Edges: edgeKinds, + NodeConstraints: nodeConstraints, + EdgeConstraints: edgeConstraints, + }}, + DefaultGraph: graph.Graph{Name: "integration_test"}, + } + + if err := db.AssertSchema(ctx, schema); err != nil { + t.Fatalf("failed to assert batch operation schema: %v", err) + } +} + +func countNodesByKind(t *testing.T, ctx context.Context, db graph.Database, kind graph.Kind) int64 { + t.Helper() + + var count int64 + if err := db.ReadTransaction(ctx, func(tx graph.Transaction) error { + var err error + count, err = tx.Nodes().Filter(query.KindIn(query.Node(), kind)).Count() + return err + }); err != nil { + t.Fatalf("failed to count nodes: %v", err) + } + + return count +} + +func countRelationshipsByKind(t *testing.T, ctx context.Context, db graph.Database, kind graph.Kind) int64 { + t.Helper() + + var count int64 + if err := db.ReadTransaction(ctx, func(tx graph.Transaction) error { + var err error + count, err = tx.Relationships().Filter(query.KindIn(query.Relationship(), kind)).Count() + return err + }); err != nil { + t.Fatalf("failed to count relationships: %v", err) + } + + return count +} + +func fetchNodeByID(t *testing.T, ctx context.Context, db graph.Database, id graph.ID) *graph.Node { + t.Helper() + + var node *graph.Node + if err := db.ReadTransaction(ctx, func(tx graph.Transaction) error { + var err error + node, err = tx.Nodes().Filter(query.Equals(query.NodeID(), id)).First() + return err + }); err != nil { + t.Fatalf("failed to fetch node %d: %v", id, err) + } + + return node +} + +func firstRelationshipByKind(t *testing.T, ctx context.Context, db graph.Database, kind graph.Kind) *graph.Relationship { + t.Helper() + + var relationship *graph.Relationship + if err := db.ReadTransaction(ctx, func(tx graph.Transaction) error { + var err error + relationship, err = tx.Relationships().Filter(query.KindIn(query.Relationship(), kind)).First() + return err + }); err != nil { + t.Fatalf("failed to fetch relationship: %v", err) + } + + return relationship +} + +func requireStringProperty(t *testing.T, properties *graph.Properties, key, expected string) { + t.Helper() + + if actual, err := properties.Get(key).String(); err != nil { + t.Fatalf("property %q: %v", key, err) + } else if actual != expected { + t.Fatalf("property %q: got %q, want %q", key, actual, expected) + } +} + +func TestPostgreSQLBatchOperationFlushPersistsBeforeDelegateError(t *testing.T) { + requirePostgreSQLBatchConnection(t) + + var ( + nodeKind = graph.StringKind("PgBatchFlushNode") + db, ctx = SetupDBWithKinds(t, graph.Kinds{nodeKind}, nil) + sentinel = errors.New("delegate failed after flush") + ) + + err := db.BatchOperation(ctx, func(batch graph.Batch) error { + if err := batch.CreateNode(graph.PrepareNode(graph.NewProperties().Set("name", "first"), nodeKind)); err != nil { + return err + } + if err := batch.CreateNode(graph.PrepareNode(graph.NewProperties().Set("name", "second"), nodeKind)); err != nil { + return err + } + + return sentinel + }, graph.WithBatchSize(1)) + + if !errors.Is(err, sentinel) { + t.Fatalf("BatchOperation error: got %v, want %v", err, sentinel) + } + + if count := countNodesByKind(t, ctx, db, nodeKind); count != 2 { + t.Fatalf("persisted node count: got %d, want 2", count) + } +} + +func TestPostgreSQLBatchOperationNodeCreateWithAndWithoutIDs(t *testing.T) { + requirePostgreSQLBatchConnection(t) + + var ( + nodeKind = graph.StringKind("PgBatchCreateNode") + db, ctx = SetupDBWithKinds(t, graph.Kinds{nodeKind}, nil) + presetID = graph.ID(424242) + ) + + if err := db.BatchOperation(ctx, func(batch graph.Batch) error { + return batch.CreateNode(graph.NewNode(presetID, graph.NewProperties().Set("name", "preset"), nodeKind)) + }, graph.WithBatchSize(1)); err != nil { + t.Fatalf("failed to create preset-id node: %v", err) + } + + if err := db.BatchOperation(ctx, func(batch graph.Batch) error { + return batch.CreateNode(graph.PrepareNode(graph.NewProperties().Set("name", "generated"), nodeKind)) + }, graph.WithBatchSize(1)); err != nil { + t.Fatalf("failed to create generated-id node: %v", err) + } + + requireStringProperty(t, fetchNodeByID(t, ctx, db, presetID).Properties, "name", "preset") + + if count := countNodesByKind(t, ctx, db, nodeKind); count != 2 { + t.Fatalf("node count: got %d, want 2", count) + } +} + +func TestPostgreSQLBatchOperationRelationshipCreateCoalescesDuplicates(t *testing.T) { + requirePostgreSQLBatchConnection(t) + + var ( + nodeKind = graph.StringKind("PgBatchRelationshipNode") + edgeKind = graph.StringKind("PgBatchRelationshipEdge") + db, ctx = SetupDBWithKinds(t, graph.Kinds{nodeKind}, graph.Kinds{edgeKind}) + start *graph.Node + end *graph.Node + ) + + if err := db.WriteTransaction(ctx, func(tx graph.Transaction) error { + var err error + if start, err = tx.CreateNode(graph.NewProperties().Set("name", "start"), nodeKind); err != nil { + return err + } + if end, err = tx.CreateNode(graph.NewProperties().Set("name", "end"), nodeKind); err != nil { + return err + } + + return nil + }); err != nil { + t.Fatalf("failed to create relationship endpoints: %v", err) + } + + if err := db.BatchOperation(ctx, func(batch graph.Batch) error { + if err := batch.CreateRelationship(graph.NewRelationship(0, start.ID, end.ID, graph.NewProperties().Set("first", "one").Set("shared", "old"), edgeKind)); err != nil { + return err + } + + return batch.CreateRelationship(graph.NewRelationship(0, start.ID, end.ID, graph.NewProperties().Set("second", "two").Set("shared", "new"), edgeKind)) + }, graph.WithBatchSize(1)); err != nil { + t.Fatalf("failed to create duplicate relationships: %v", err) + } + + if count := countRelationshipsByKind(t, ctx, db, edgeKind); count != 1 { + t.Fatalf("relationship count: got %d, want 1", count) + } + + relationship := firstRelationshipByKind(t, ctx, db, edgeKind) + requireStringProperty(t, relationship.Properties, "first", "one") + requireStringProperty(t, relationship.Properties, "second", "two") + requireStringProperty(t, relationship.Properties, "shared", "new") +} + +func TestPostgreSQLBatchOperationNodeUpdateUsesStaging(t *testing.T) { + requirePostgreSQLBatchConnection(t) + + var ( + nodeKind = graph.StringKind("PgBatchUpdateNode") + extraKind = graph.StringKind("PgBatchUpdateNodeExtra") + db, ctx = SetupDBWithKinds(t, graph.Kinds{nodeKind, extraKind}, nil) + node *graph.Node + ) + + if err := db.WriteTransaction(ctx, func(tx graph.Transaction) error { + var err error + node, err = tx.CreateNode(graph.NewProperties().Set("status", "old").Set("removed", "yes"), nodeKind) + return err + }); err != nil { + t.Fatalf("failed to create node: %v", err) + } + + node.Properties.Set("status", "new") + node.Properties.Delete("removed") + node.AddKinds(extraKind) + + if err := db.BatchOperation(ctx, func(batch graph.Batch) error { + return batch.UpdateNodes([]*graph.Node{node}) + }, graph.WithBatchSize(1)); err != nil { + t.Fatalf("failed to update node: %v", err) + } + + updated := fetchNodeByID(t, ctx, db, node.ID) + requireStringProperty(t, updated.Properties, "status", "new") + + if updated.Properties.Exists("removed") { + t.Fatalf("expected removed property to be deleted") + } + if !updated.Kinds.ContainsOneOf(extraKind) { + t.Fatalf("expected updated node to contain kind %q", extraKind.String()) + } +} + +func TestPostgreSQLBatchOperationUpdateByUsesStaging(t *testing.T) { + requirePostgreSQLBatchConnection(t) + + var ( + nodeKind = graph.StringKind("PgBatchUpsertNode") + edgeKind = graph.StringKind("PgBatchUpsertEdge") + db, ctx = SetupDBWithKinds(t, graph.Kinds{nodeKind}, graph.Kinds{edgeKind}) + ) + + assertBatchOperationSchema(t, ctx, db, graph.Kinds{nodeKind}, graph.Kinds{edgeKind}, []graph.Constraint{{ + Field: "node_key", + Type: graph.BTreeIndex, + }}, []graph.Constraint{{ + Field: "edge_key", + Type: graph.BTreeIndex, + }}) + + if err := db.BatchOperation(ctx, func(batch graph.Batch) error { + return batch.UpdateNodeBy(graph.NodeUpdate{ + Node: graph.NewNode(0, graph.NewProperties().Set("node_key", "standalone").Set("value", "first"), nodeKind), + IdentityKind: nodeKind, + IdentityProperties: []string{"node_key"}, + }) + }, graph.WithBatchSize(1)); err != nil { + t.Fatalf("failed to create node by identity: %v", err) + } + + if err := db.BatchOperation(ctx, func(batch graph.Batch) error { + return batch.UpdateNodeBy(graph.NodeUpdate{ + Node: graph.NewNode(0, graph.NewProperties().Set("node_key", "standalone").Set("value", "second"), nodeKind), + IdentityKind: nodeKind, + IdentityProperties: []string{"node_key"}, + }) + }, graph.WithBatchSize(1)); err != nil { + t.Fatalf("failed to update node by identity: %v", err) + } + + if count := countNodesByKind(t, ctx, db, nodeKind); count != 1 { + t.Fatalf("upserted node count: got %d, want 1", count) + } + + var node *graph.Node + if err := db.ReadTransaction(ctx, func(tx graph.Transaction) error { + var err error + node, err = tx.Nodes().Filter(query.Equals(query.Property(query.Node(), "node_key"), "standalone")).First() + return err + }); err != nil { + t.Fatalf("failed to fetch upserted node: %v", err) + } + requireStringProperty(t, node.Properties, "value", "second") + + for _, value := range []string{"first", "second"} { + value := value + if err := db.BatchOperation(ctx, func(batch graph.Batch) error { + return batch.UpdateRelationshipBy(graph.RelationshipUpdate{ + Relationship: graph.NewRelationship(0, 0, 0, graph.NewProperties().Set("edge_key", "connected").Set("value", value), edgeKind), + IdentityProperties: []string{"edge_key"}, + Start: graph.NewNode(0, graph.NewProperties().Set("node_key", "start").Set("name", "start"), nodeKind), + StartIdentityKind: nodeKind, + StartIdentityProperties: []string{ + "node_key", + }, + End: graph.NewNode(0, graph.NewProperties().Set("node_key", "end").Set("name", "end"), nodeKind), + EndIdentityKind: nodeKind, + EndIdentityProperties: []string{ + "node_key", + }, + }) + }, graph.WithBatchSize(1)); err != nil { + t.Fatalf("failed to upsert relationship by identity: %v", err) + } + } + + if count := countRelationshipsByKind(t, ctx, db, edgeKind); count != 1 { + t.Fatalf("upserted relationship count: got %d, want 1", count) + } + + relationship := firstRelationshipByKind(t, ctx, db, edgeKind) + requireStringProperty(t, relationship.Properties, "value", "second") +} From 457bae8a55a5d1345c6923f65d5ac9ac4df0cccd Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 20:42:12 -0700 Subject: [PATCH 108/116] Record batch operation validation --- batch_operation_plan.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/batch_operation_plan.md b/batch_operation_plan.md index dd3c5ef6..b6aff455 100644 --- a/batch_operation_plan.md +++ b/batch_operation_plan.md @@ -95,7 +95,7 @@ go test ./drivers/pg/... Run PostgreSQL integration tests only when `CONNECTION_STRING` points at PostgreSQL. -Status: Pending. +Status: Complete. ## Evaluation Notes @@ -109,3 +109,4 @@ This plan should be updated after each step is completed. If a step exposes a si - Step 6 unified normal and large node updates on the same staging-table `COPY` flush path. Large node update inputs now use normal batch chunking instead of a separate all-at-once row materialization path. - Step 7 moved `UpdateNodeBy` and `UpdateRelationshipBy` to staging-table `COPY`. Node upserts still scan returned IDs into futures in staged row order so relationship upserts can reuse the resolved endpoint IDs. - Step 8 added manual PostgreSQL integration coverage for non-transactional flushed chunks, node create with and without IDs, relationship duplicate coalescing, node update staging, and `UpdateNodeBy`/`UpdateRelationshipBy` staging. Existing PG unit tests cover the streaming `CopyFromSource` behavior. +- Step 9 validation passed for `go test ./drivers/pg/...` and manual integration compilation via `go test -tags manual_integration ./integration -run '^$'`. `make format` could not run because `goimports` is not available as an executable on this PATH, so the touched Go test file was formatted with `go run golang.org/x/tools/cmd/goimports@v0.44.0 -w`. Live PostgreSQL integration tests were not run because `CONNECTION_STRING` is unset. From f4736c033a39aa1e7a27b1c1b023d99ee9052875 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 22:02:27 -0700 Subject: [PATCH 109/116] Refresh PG schema cache on assertion --- drivers/pg/manager.go | 4 ++++ integration/pgsql_batch_operation_test.go | 18 ++++++++++-------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/drivers/pg/manager.go b/drivers/pg/manager.go index 4ce56419..b52ffc8c 100644 --- a/drivers/pg/manager.go +++ b/drivers/pg/manager.go @@ -406,6 +406,10 @@ func (s *SchemaManager) AssertSchema(ctx context.Context, schema graph.Schema) e s.lock.Lock() defer s.lock.Unlock() + s.graphs = map[string]model.Graph{} + s.defaultGraph = model.Graph{} + s.hasDefaultGraph = false + return s.WriteTransaction(ctx, func(tx graph.Transaction) error { return s.assertSchema(tx, schema) }, OptionSetQueryExecMode(pgx.QueryExecModeSimpleProtocol)) diff --git a/integration/pgsql_batch_operation_test.go b/integration/pgsql_batch_operation_test.go index d668598d..526fcbd4 100644 --- a/integration/pgsql_batch_operation_test.go +++ b/integration/pgsql_batch_operation_test.go @@ -49,15 +49,17 @@ func requirePostgreSQLBatchConnection(t *testing.T) { func assertBatchOperationSchema(t *testing.T, ctx context.Context, db graph.Database, nodeKinds, edgeKinds graph.Kinds, nodeConstraints, edgeConstraints []graph.Constraint) { t.Helper() + graphSchema := graph.Graph{ + Name: "integration_test", + Nodes: nodeKinds, + Edges: edgeKinds, + NodeConstraints: nodeConstraints, + EdgeConstraints: edgeConstraints, + } + schema := graph.Schema{ - Graphs: []graph.Graph{{ - Name: "integration_test", - Nodes: nodeKinds, - Edges: edgeKinds, - NodeConstraints: nodeConstraints, - EdgeConstraints: edgeConstraints, - }}, - DefaultGraph: graph.Graph{Name: "integration_test"}, + Graphs: []graph.Graph{graphSchema}, + DefaultGraph: graphSchema, } if err := db.AssertSchema(ctx, schema); err != nil { From 582c39241f221aa514dad236e3014d06b101c300 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 22:03:47 -0700 Subject: [PATCH 110/116] Map PG node upsert IDs by staging ordinal --- drivers/pg/batch.go | 29 ++++++++++++++++------- drivers/pg/query/format.go | 27 ++++++++++++++++++++- drivers/pg/query/format_test.go | 8 ++++++- integration/pgsql_batch_operation_test.go | 5 ++++ 4 files changed, 59 insertions(+), 10 deletions(-) diff --git a/drivers/pg/batch.go b/drivers/pg/batch.go index 4ee45953..a448e714 100644 --- a/drivers/pg/batch.go +++ b/drivers/pg/batch.go @@ -251,25 +251,38 @@ func (s *batch) flushNodeUpsertBatch(updates *sql.NodeUpdateBatch) error { } defer rows.Close() - idFutureIndex := 0 + idsResolved := 0 + seenRows := make([]bool, len(values)) + for rows.Next() { - if idFutureIndex >= len(values) { - return fmt.Errorf("node upsert returned more ids than staged rows") - } + var ( + rowOrdinal int + id graph.ID + ) - if err := rows.Scan(&values[idFutureIndex].IDFuture.Value); err != nil { + if err := rows.Scan(&rowOrdinal, &id); err != nil { return err } - idFutureIndex++ + if rowOrdinal < 0 || rowOrdinal >= len(values) { + return fmt.Errorf("node upsert returned row ordinal %d outside staged row count %d", rowOrdinal, len(values)) + } + + if seenRows[rowOrdinal] { + return fmt.Errorf("node upsert returned duplicate row ordinal %d", rowOrdinal) + } + + values[rowOrdinal].IDFuture.Value = id + seenRows[rowOrdinal] = true + idsResolved++ } if err := rows.Err(); err != nil { return err } - if idFutureIndex != len(values) { - return fmt.Errorf("node upsert returned %d ids for %d staged rows", idFutureIndex, len(values)) + if idsResolved != len(values) { + return fmt.Errorf("node upsert returned %d ids for %d staged rows", idsResolved, len(values)) } return nil diff --git a/drivers/pg/query/format.go b/drivers/pg/query/format.go index 7c6b5a3c..2b86859a 100644 --- a/drivers/pg/query/format.go +++ b/drivers/pg/query/format.go @@ -174,6 +174,7 @@ func FormatCreateNodeUpsertStagingTable(stagingTable string) string { func FormatMergeNodeUpsertStaging(graphTarget model.Graph, identityProperties []string, stagingTable string) string { return join( + "with upserted as (", "insert into ", graphTarget.Partitions.Node.Name, " as n ", "(graph_id, kind_ids, properties) ", "select graph_id, kind_ids::int2[], properties::jsonb ", @@ -181,10 +182,34 @@ func FormatMergeNodeUpsertStaging(graphTarget model.Graph, identityProperties [] "order by row_ord ", formatConflictMatcher(identityProperties, "id, graph_id"), "do update set properties = n.properties || excluded.properties, kind_ids = uniq(sort(n.kind_ids || excluded.kind_ids)) ", - "returning id;", + "returning id, graph_id, properties", + ") ", + "select s.row_ord, u.id ", + "from ", stagingTable, " as s ", + "join upserted as u on ", formatNodeUpsertStagingMatcher(identityProperties), " ", + "order by s.row_ord;", ) } +func formatNodeUpsertStagingMatcher(identityProperties []string) string { + if len(identityProperties) == 0 { + return "u.graph_id = s.graph_id" + } + + builder := strings.Builder{} + builder.WriteString("u.graph_id = s.graph_id") + + for _, identityProperty := range identityProperties { + builder.WriteString(" and u.properties->>'") + builder.WriteString(identityProperty) + builder.WriteString("' = s.properties::jsonb->>'") + builder.WriteString(identityProperty) + builder.WriteString("'") + } + + return builder.String() +} + const NodeCreateStagingTable = "node_create_staging" var ( diff --git a/drivers/pg/query/format_test.go b/drivers/pg/query/format_test.go index d9b812fa..241dc8c6 100644 --- a/drivers/pg/query/format_test.go +++ b/drivers/pg/query/format_test.go @@ -180,6 +180,7 @@ func TestFormatMergeNodeUpsertStaging(t *testing.T) { stagingTable = "my_node_upsert_staging" graphTarget = generateTestGraphTarget("node_part_1") expected = strings.Join([]string{ + "with upserted as (", "insert into node_part_1 as n ", "(graph_id, kind_ids, properties) ", "select graph_id, kind_ids::int2[], properties::jsonb ", @@ -187,7 +188,12 @@ func TestFormatMergeNodeUpsertStaging(t *testing.T) { "order by row_ord ", "on conflict ((properties->>'objectid')) ", "do update set properties = n.properties || excluded.properties, kind_ids = uniq(sort(n.kind_ids || excluded.kind_ids)) ", - "returning id;", + "returning id, graph_id, properties", + ") ", + "select s.row_ord, u.id ", + "from my_node_upsert_staging as s ", + "join upserted as u on u.graph_id = s.graph_id and u.properties->>'objectid' = s.properties::jsonb->>'objectid' ", + "order by s.row_ord;", }, "") result = query.FormatMergeNodeUpsertStaging(graphTarget, []string{"objectid"}, stagingTable) ) diff --git a/integration/pgsql_batch_operation_test.go b/integration/pgsql_batch_operation_test.go index 526fcbd4..3d4d4e15 100644 --- a/integration/pgsql_batch_operation_test.go +++ b/integration/pgsql_batch_operation_test.go @@ -357,4 +357,9 @@ func TestPostgreSQLBatchOperationUpdateByUsesStaging(t *testing.T) { relationship := firstRelationshipByKind(t, ctx, db, edgeKind) requireStringProperty(t, relationship.Properties, "value", "second") + + start := fetchNodeByID(t, ctx, db, relationship.StartID) + end := fetchNodeByID(t, ctx, db, relationship.EndID) + requireStringProperty(t, start.Properties, "node_key", "start") + requireStringProperty(t, end.Properties, "node_key", "end") } From e42afa791ed8a197a3417ff07c52f42e845ee915 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 22:04:51 -0700 Subject: [PATCH 111/116] Separate PG relationship update staging merge --- drivers/pg/batch.go | 2 +- drivers/pg/query/format.go | 12 ++++++++++++ drivers/pg/query/format_test.go | 15 ++++----------- integration/pgsql_batch_operation_test.go | 20 ++++++++++++++++++++ 4 files changed, 37 insertions(+), 12 deletions(-) diff --git a/drivers/pg/batch.go b/drivers/pg/batch.go index a448e714..7a7ab801 100644 --- a/drivers/pg/batch.go +++ b/drivers/pg/batch.go @@ -406,7 +406,7 @@ func (s *batch) flushRelationshipUpdateByBuffer(updates *sql.RelationshipUpdateB }}, AfterCopy: []batchChunkStatement{{ Name: "merge relationship update staging table", - Statement: sql.FormatMergeRelationshipStaging(graphTarget, updates.IdentityProperties, sql.RelationshipCreateStagingTable), + Statement: sql.FormatMergeRelationshipUpdateStaging(graphTarget, updates.IdentityProperties, sql.RelationshipCreateStagingTable), }}, } diff --git a/drivers/pg/query/format.go b/drivers/pg/query/format.go index 2b86859a..b6ec7e3a 100644 --- a/drivers/pg/query/format.go +++ b/drivers/pg/query/format.go @@ -291,6 +291,18 @@ func FormatMergeRelationshipStaging(graphTarget model.Graph, identityProperties ) } +func FormatMergeRelationshipUpdateStaging(graphTarget model.Graph, identityProperties []string, stagingTable string) string { + return join( + "insert into ", graphTarget.Partitions.Edge.Name, " as e ", + "(graph_id, start_id, end_id, kind_id, properties) ", + "select graph_id, start_id, end_id, kind_id, properties::jsonb ", + "from ", stagingTable, " ", + "order by row_ord ", + formatConflictMatcher(identityProperties, "start_id, end_id, kind_id, graph_id"), + "do update set properties = e.properties || excluded.properties;", + ) +} + func FormatMergeRelationshipCreateStaging(graphTarget model.Graph, stagingTable string) string { return FormatMergeRelationshipStaging(graphTarget, nil, stagingTable) } diff --git a/drivers/pg/query/format_test.go b/drivers/pg/query/format_test.go index 241dc8c6..3e575d80 100644 --- a/drivers/pg/query/format_test.go +++ b/drivers/pg/query/format_test.go @@ -259,7 +259,7 @@ func TestFormatMergeRelationshipCreateStaging(t *testing.T) { assert.Equal(t, expected, result) } -func TestFormatMergeRelationshipStagingWithIdentityProperties(t *testing.T) { +func TestFormatMergeRelationshipUpdateStagingWithIdentityProperties(t *testing.T) { t.Parallel() var ( @@ -272,20 +272,13 @@ func TestFormatMergeRelationshipStagingWithIdentityProperties(t *testing.T) { expected = strings.Join([]string{ "insert into edge_part_1 as e ", "(graph_id, start_id, end_id, kind_id, properties) ", - "select graph_id, start_id, end_id, kind_id, ", - "coalesce(jsonb_object_agg(key, value) filter (where key is not null), '{}'::jsonb) as properties ", - "from (", - "select distinct on (graph_id, start_id, end_id, kind_id, key) ", - "graph_id, start_id, end_id, kind_id, key, value, row_ord ", + "select graph_id, start_id, end_id, kind_id, properties::jsonb ", "from my_relationship_staging ", - "left join lateral jsonb_each(properties::jsonb) as property(key, value) on true ", - "order by graph_id, start_id, end_id, kind_id, key, row_ord desc", - ") as deduped ", - "group by graph_id, start_id, end_id, kind_id ", + "order by row_ord ", "on conflict ((properties->>'objectid')) ", "do update set properties = e.properties || excluded.properties;", }, "") - result = query.FormatMergeRelationshipStaging(graphTarget, []string{"objectid"}, stagingTable) + result = query.FormatMergeRelationshipUpdateStaging(graphTarget, []string{"objectid"}, stagingTable) ) assert.Equal(t, expected, result) diff --git a/integration/pgsql_batch_operation_test.go b/integration/pgsql_batch_operation_test.go index 3d4d4e15..749f35c8 100644 --- a/integration/pgsql_batch_operation_test.go +++ b/integration/pgsql_batch_operation_test.go @@ -362,4 +362,24 @@ func TestPostgreSQLBatchOperationUpdateByUsesStaging(t *testing.T) { end := fetchNodeByID(t, ctx, db, relationship.EndID) requireStringProperty(t, start.Properties, "node_key", "start") requireStringProperty(t, end.Properties, "node_key", "end") + + err := db.BatchOperation(ctx, func(batch graph.Batch) error { + return batch.UpdateRelationshipBy(graph.RelationshipUpdate{ + Relationship: graph.NewRelationship(0, 0, 0, graph.NewProperties().Set("edge_key", "same-endpoints").Set("value", "conflict"), edgeKind), + IdentityProperties: []string{"edge_key"}, + Start: graph.NewNode(0, graph.NewProperties().Set("node_key", "start").Set("name", "start"), nodeKind), + StartIdentityKind: nodeKind, + StartIdentityProperties: []string{ + "node_key", + }, + End: graph.NewNode(0, graph.NewProperties().Set("node_key", "end").Set("name", "end"), nodeKind), + EndIdentityKind: nodeKind, + EndIdentityProperties: []string{ + "node_key", + }, + }) + }, graph.WithBatchSize(1)) + if err == nil { + t.Fatalf("expected relationship identity update with duplicate endpoints to fail") + } } From 8ba7d6fef2b20d2fc5d3f13474553d71617d9425 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 22:06:04 -0700 Subject: [PATCH 112/116] Coalesce duplicate PG node batch updates --- drivers/pg/batch.go | 36 +++++++++++++++++++++++ drivers/pg/batch_test.go | 33 +++++++++++++++++++++ integration/pgsql_batch_operation_test.go | 12 ++++---- 3 files changed, 76 insertions(+), 5 deletions(-) create mode 100644 drivers/pg/batch_test.go diff --git a/drivers/pg/batch.go b/drivers/pg/batch.go index 7a7ab801..a680b74f 100644 --- a/drivers/pg/batch.go +++ b/drivers/pg/batch.go @@ -315,11 +315,47 @@ func (s *batch) assertNodeUpdateKinds(nodes []*graph.Node) error { return nil } +func cloneProperties(properties *graph.Properties) *graph.Properties { + if properties == nil { + return graph.NewProperties() + } + + return properties.Clone() +} + +func cloneNodeUpdate(node *graph.Node) *graph.Node { + return &graph.Node{ + ID: node.ID, + Kinds: node.Kinds.Copy(), + AddedKinds: node.AddedKinds.Copy(), + DeletedKinds: node.DeletedKinds.Copy(), + Properties: cloneProperties(node.Properties), + } +} + +func coalesceNodeUpdates(nodes []*graph.Node) []*graph.Node { + coalesced := make([]*graph.Node, 0, len(nodes)) + nodeIndexes := make(map[graph.ID]int, len(nodes)) + + for _, node := range nodes { + if existingIndex, hasExisting := nodeIndexes[node.ID]; hasExisting { + coalesced[existingIndex].Merge(cloneNodeUpdate(node)) + } else { + nodeIndexes[node.ID] = len(coalesced) + coalesced = append(coalesced, cloneNodeUpdate(node)) + } + } + + return coalesced +} + func (s *batch) flushNodeUpdateBatch(nodes []*graph.Node) error { if len(nodes) == 0 { return nil } + nodes = coalesceNodeUpdates(nodes) + if err := s.assertNodeUpdateKinds(nodes); err != nil { return err } diff --git a/drivers/pg/batch_test.go b/drivers/pg/batch_test.go new file mode 100644 index 00000000..4df8a948 --- /dev/null +++ b/drivers/pg/batch_test.go @@ -0,0 +1,33 @@ +package pg + +import ( + "testing" + + "github.com/specterops/dawgs/graph" + "github.com/stretchr/testify/require" +) + +func TestCoalesceNodeUpdates(t *testing.T) { + t.Parallel() + + var ( + nodeKind = graph.StringKind("Node") + extraKind = graph.StringKind("Extra") + nodeID = graph.ID(1) + first = graph.NewNode(nodeID, graph.NewProperties().Set("status", "first").Set("kept", "yes"), nodeKind) + second = graph.NewNode(nodeID, graph.NewProperties().Set("status", "second"), nodeKind) + ) + + second.Properties.Delete("removed") + second.AddKinds(extraKind) + + coalesced := coalesceNodeUpdates([]*graph.Node{first, second}) + require.Len(t, coalesced, 1) + + require.Equal(t, "second", coalesced[0].Properties.Get("status").Any()) + require.Equal(t, "yes", coalesced[0].Properties.Get("kept").Any()) + _, deleted := coalesced[0].Properties.Deleted["removed"] + require.True(t, deleted) + require.True(t, coalesced[0].Kinds.ContainsOneOf(extraKind)) + require.False(t, first.Kinds.ContainsOneOf(extraKind)) +} diff --git a/integration/pgsql_batch_operation_test.go b/integration/pgsql_batch_operation_test.go index 749f35c8..0d560230 100644 --- a/integration/pgsql_batch_operation_test.go +++ b/integration/pgsql_batch_operation_test.go @@ -257,18 +257,20 @@ func TestPostgreSQLBatchOperationNodeUpdateUsesStaging(t *testing.T) { t.Fatalf("failed to create node: %v", err) } - node.Properties.Set("status", "new") - node.Properties.Delete("removed") - node.AddKinds(extraKind) + firstUpdate := graph.NewNode(node.ID, graph.NewProperties().Set("status", "first").Set("kept", "yes"), nodeKind) + secondUpdate := graph.NewNode(node.ID, graph.NewProperties().Set("status", "new"), nodeKind) + secondUpdate.Properties.Delete("removed") + secondUpdate.AddKinds(extraKind) if err := db.BatchOperation(ctx, func(batch graph.Batch) error { - return batch.UpdateNodes([]*graph.Node{node}) - }, graph.WithBatchSize(1)); err != nil { + return batch.UpdateNodes([]*graph.Node{firstUpdate, secondUpdate}) + }, graph.WithBatchSize(2)); err != nil { t.Fatalf("failed to update node: %v", err) } updated := fetchNodeByID(t, ctx, db, node.ID) requireStringProperty(t, updated.Properties, "status", "new") + requireStringProperty(t, updated.Properties, "kept", "yes") if updated.Properties.Exists("removed") { t.Fatalf("expected removed property to be deleted") From 40edfefb139d4c067980abfda23752c30d6bdd8f Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 22:07:17 -0700 Subject: [PATCH 113/116] Stream PG batch deletes through COPY staging --- drivers/pg/batch.go | 46 +++++++++++++++++++++- drivers/pg/query/format.go | 31 +++++++++++++++ drivers/pg/query/format_test.go | 29 ++++++++++++++ integration/pgsql_batch_operation_test.go | 48 +++++++++++++++++++++++ 4 files changed, 152 insertions(+), 2 deletions(-) diff --git a/drivers/pg/batch.go b/drivers/pg/batch.go index a680b74f..ba420ca3 100644 --- a/drivers/pg/batch.go +++ b/drivers/pg/batch.go @@ -99,8 +99,31 @@ func (s *batch) UpdateNodes(nodes []*graph.Node) error { return nil } +func encodeDeleteIDCopyRow(_ context.Context, id graph.ID) ([]any, error) { + return []any{id.Int64()}, nil +} + func (s *batch) flushNodeDeleteBuffer() error { - if _, err := s.innerTransaction.conn.Exec(s.ctx, deleteNodeWithIDStatement, s.nodeDeletionBuffer); err != nil { + if len(s.nodeDeletionBuffer) == 0 { + return nil + } + + stage := batchCopyStage{ + Name: "node delete", + TableIdentifier: pgx.Identifier{sql.NodeDeleteStagingTable}, + Columns: sql.DeleteStagingColumns, + Source: newBatchSliceCopySource(s.ctx, s.nodeDeletionBuffer, encodeDeleteIDCopyRow), + BeforeCopy: []batchChunkStatement{{ + Name: "create node delete staging table", + Statement: sql.FormatCreateDeleteStagingTable(sql.NodeDeleteStagingTable), + }}, + AfterCopy: []batchChunkStatement{{ + Name: "merge node delete staging table", + Statement: sql.FormatMergeNodeDeleteStaging(sql.NodeDeleteStagingTable), + }}, + } + + if _, err := copyBatchStageChunk(s.ctx, s.innerTransaction.conn, stage); err != nil { return err } @@ -109,7 +132,26 @@ func (s *batch) flushNodeDeleteBuffer() error { } func (s *batch) flushRelationshipDeleteBuffer() error { - if _, err := s.innerTransaction.conn.Exec(s.ctx, deleteEdgeWithIDStatement, s.relationshipDeletionBuffer); err != nil { + if len(s.relationshipDeletionBuffer) == 0 { + return nil + } + + stage := batchCopyStage{ + Name: "relationship delete", + TableIdentifier: pgx.Identifier{sql.RelationshipDeleteStagingTable}, + Columns: sql.DeleteStagingColumns, + Source: newBatchSliceCopySource(s.ctx, s.relationshipDeletionBuffer, encodeDeleteIDCopyRow), + BeforeCopy: []batchChunkStatement{{ + Name: "create relationship delete staging table", + Statement: sql.FormatCreateDeleteStagingTable(sql.RelationshipDeleteStagingTable), + }}, + AfterCopy: []batchChunkStatement{{ + Name: "merge relationship delete staging table", + Statement: sql.FormatMergeRelationshipDeleteStaging(sql.RelationshipDeleteStagingTable), + }}, + } + + if _, err := copyBatchStageChunk(s.ctx, s.innerTransaction.conn, stage); err != nil { return err } diff --git a/drivers/pg/query/format.go b/drivers/pg/query/format.go index b6ec7e3a..8d028f2a 100644 --- a/drivers/pg/query/format.go +++ b/drivers/pg/query/format.go @@ -246,6 +246,37 @@ func FormatMergeNodeCreateStagingWithoutIDs(graphTarget model.Graph, stagingTabl ) } +const ( + NodeDeleteStagingTable = "node_delete_staging" + RelationshipDeleteStagingTable = "relationship_delete_staging" +) + +var DeleteStagingColumns = []string{"id"} + +func FormatCreateDeleteStagingTable(stagingTable string) string { + return join( + "create temp table if not exists ", stagingTable, " (", + "id bigint not null", + ") on commit drop;", + ) +} + +func FormatMergeNodeDeleteStaging(stagingTable string) string { + return join( + "delete from node as n ", + "using ", stagingTable, " as d ", + "where n.id = d.id;", + ) +} + +func FormatMergeRelationshipDeleteStaging(stagingTable string) string { + return join( + "delete from edge as e ", + "using ", stagingTable, " as d ", + "where e.id = d.id;", + ) +} + func FormatRelationshipPartitionUpsert(graphTarget model.Graph, identityProperties []string) string { return join("insert into ", graphTarget.Partitions.Edge.Name, " as e ", "(graph_id, start_id, end_id, kind_id, properties) ", diff --git a/drivers/pg/query/format_test.go b/drivers/pg/query/format_test.go index 3e575d80..258d7aa1 100644 --- a/drivers/pg/query/format_test.go +++ b/drivers/pg/query/format_test.go @@ -154,6 +154,35 @@ func TestNodeCreateStagingColumns(t *testing.T) { assert.Equal(t, []string{"graph_id", "kind_ids", "properties"}, query.NodeCreateWithoutIDStagingColumns) } +func TestFormatCreateDeleteStagingTable(t *testing.T) { + t.Parallel() + + var ( + tableName = "my_delete_staging" + expected = strings.Join([]string{ + "create temp table if not exists my_delete_staging (", + "id bigint not null", + ") on commit drop;", + }, "") + result = query.FormatCreateDeleteStagingTable(tableName) + ) + + assert.Equal(t, expected, result) +} + +func TestFormatMergeDeleteStaging(t *testing.T) { + t.Parallel() + + assert.Equal(t, "delete from node as n using my_delete_staging as d where n.id = d.id;", query.FormatMergeNodeDeleteStaging("my_delete_staging")) + assert.Equal(t, "delete from edge as e using my_delete_staging as d where e.id = d.id;", query.FormatMergeRelationshipDeleteStaging("my_delete_staging")) +} + +func TestDeleteStagingColumns(t *testing.T) { + t.Parallel() + + assert.Equal(t, []string{"id"}, query.DeleteStagingColumns) +} + func TestFormatCreateNodeUpsertStagingTable(t *testing.T) { t.Parallel() diff --git a/integration/pgsql_batch_operation_test.go b/integration/pgsql_batch_operation_test.go index 0d560230..0970a00e 100644 --- a/integration/pgsql_batch_operation_test.go +++ b/integration/pgsql_batch_operation_test.go @@ -280,6 +280,54 @@ func TestPostgreSQLBatchOperationNodeUpdateUsesStaging(t *testing.T) { } } +func TestPostgreSQLBatchOperationDeleteUsesStaging(t *testing.T) { + requirePostgreSQLBatchConnection(t) + + var ( + nodeKind = graph.StringKind("PgBatchDeleteNode") + edgeKind = graph.StringKind("PgBatchDeleteEdge") + db, ctx = SetupDBWithKinds(t, graph.Kinds{nodeKind}, graph.Kinds{edgeKind}) + start *graph.Node + end *graph.Node + relationship *graph.Relationship + ) + + if err := db.WriteTransaction(ctx, func(tx graph.Transaction) error { + var err error + if start, err = tx.CreateNode(graph.NewProperties().Set("name", "start"), nodeKind); err != nil { + return err + } + if end, err = tx.CreateNode(graph.NewProperties().Set("name", "end"), nodeKind); err != nil { + return err + } + if relationship, err = tx.CreateRelationshipByIDs(start.ID, end.ID, edgeKind, graph.NewProperties().Set("name", "edge")); err != nil { + return err + } + + return nil + }); err != nil { + t.Fatalf("failed to create delete fixture: %v", err) + } + + if err := db.BatchOperation(ctx, func(batch graph.Batch) error { + if err := batch.DeleteRelationship(relationship.ID); err != nil { + return err + } + + return batch.DeleteNode(start.ID) + }, graph.WithBatchSize(2)); err != nil { + t.Fatalf("failed to delete batch fixture: %v", err) + } + + if count := countRelationshipsByKind(t, ctx, db, edgeKind); count != 0 { + t.Fatalf("relationship count after delete: got %d, want 0", count) + } + if count := countNodesByKind(t, ctx, db, nodeKind); count != 1 { + t.Fatalf("node count after delete: got %d, want 1", count) + } + requireStringProperty(t, fetchNodeByID(t, ctx, db, end.ID).Properties, "name", "end") +} + func TestPostgreSQLBatchOperationUpdateByUsesStaging(t *testing.T) { requirePostgreSQLBatchConnection(t) From 10ee9b697ced4a14dad63b03ee77b42e34d30131 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 22:08:39 -0700 Subject: [PATCH 114/116] Record batch finding follow-up validation --- batch_operation_plan.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/batch_operation_plan.md b/batch_operation_plan.md index b6aff455..be00c3d5 100644 --- a/batch_operation_plan.md +++ b/batch_operation_plan.md @@ -110,3 +110,20 @@ This plan should be updated after each step is completed. If a step exposes a si - Step 7 moved `UpdateNodeBy` and `UpdateRelationshipBy` to staging-table `COPY`. Node upserts still scan returned IDs into futures in staged row order so relationship upserts can reuse the resolved endpoint IDs. - Step 8 added manual PostgreSQL integration coverage for non-transactional flushed chunks, node create with and without IDs, relationship duplicate coalescing, node update staging, and `UpdateNodeBy`/`UpdateRelationshipBy` staging. Existing PG unit tests cover the streaming `CopyFromSource` behavior. - Step 9 validation passed for `go test ./drivers/pg/...` and manual integration compilation via `go test -tags manual_integration ./integration -run '^$'`. `make format` could not run because `goimports` is not available as an executable on this PATH, so the touched Go test file was formatted with `go run golang.org/x/tools/cmd/goimports@v0.44.0 -w`. Live PostgreSQL integration tests were not run because `CONNECTION_STRING` is unset. + +## Findings Follow-up + +The review findings were addressed in this order: + +- Refreshed the PostgreSQL schema graph cache during schema assertion and fixed the PG batch integration helper so default graph constraints are actually asserted. +- Made node upsert ID resolution map returned IDs back to futures by staging row ordinal instead of result position. +- Split relationship update staging from relationship create staging so identity updates no longer inherit physical-key coalescing. +- Coalesced duplicate node ID updates before staging to avoid matching the same target row more than once in a PostgreSQL `MERGE`. +- Converted node and relationship delete buffers to chunk-local `COPY` staging. + +Latest validation: + +- `go test ./drivers/pg/...` passed. +- Full tagged PostgreSQL run passed with a PostgreSQL `CONNECTION_STRING`. +- Full tagged Neo4j run passed with a Neo4j `CONNECTION_STRING`. +- `make format` still fails in this environment because `goimports` is not executable on `PATH`; touched Go files were formatted with `gofmt` and `go run golang.org/x/tools/cmd/goimports@v0.44.0 -w`. From a3b479545aa855483221365a31064d42cff04356 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sat, 23 May 2026 23:38:26 -0700 Subject: [PATCH 115/116] Fix integration schema for query-only kinds --- integration/cypher_template_test.go | 30 ++++++++++------ integration/cypher_test.go | 25 ++++++++++++- integration/harness.go | 56 +++++++++++++++++++++++++++++ integration/harness_test.go | 24 +++++++++++++ 4 files changed, 124 insertions(+), 11 deletions(-) create mode 100644 integration/harness_test.go diff --git a/integration/cypher_template_test.go b/integration/cypher_template_test.go index 63a90655..208aca43 100644 --- a/integration/cypher_template_test.go +++ b/integration/cypher_template_test.go @@ -70,7 +70,7 @@ type cypherMetamorphicQuery struct { func TestCypherTemplates(t *testing.T) { templateFiles := loadCypherTemplateFiles(t) - nodeKinds, edgeKinds := cypherTemplateKinds(templateFiles) + nodeKinds, edgeKinds := cypherTemplateKinds(t, templateFiles) db, ctx := SetupDBWithKindsNoGraphCleanup(t, nodeKinds, edgeKinds) ClearGraph(t, db, ctx) @@ -136,23 +136,33 @@ func loadCypherTemplateFiles(t *testing.T) []cypherTemplateFile { return templateFiles } -func cypherTemplateKinds(templateFiles []cypherTemplateFile) (graph.Kinds, graph.Kinds) { +func cypherTemplateKinds(t *testing.T, templateFiles []cypherTemplateFile) (graph.Kinds, graph.Kinds) { + t.Helper() + var nodeKinds, edgeKinds graph.Kinds for _, templateFile := range templateFiles { for _, family := range templateFile.Families { - if family.Fixture != nil { - familyNodeKinds, familyEdgeKinds := family.Fixture.Kinds() - nodeKinds = nodeKinds.Add(familyNodeKinds...) - edgeKinds = edgeKinds.Add(familyEdgeKinds...) + fixtureNodeKinds, fixtureEdgeKinds := collectFixtureKinds(family.Fixture) + nodeKinds = nodeKinds.Add(fixtureNodeKinds...) + edgeKinds = edgeKinds.Add(fixtureEdgeKinds...) + + for _, variant := range family.Variants { + queryNodeKinds, queryEdgeKinds := collectCypherKinds(t, renderCypherTemplate(t, family.Template, variant.Vars)) + nodeKinds = nodeKinds.Add(queryNodeKinds...) + edgeKinds = edgeKinds.Add(queryEdgeKinds...) } } for _, family := range templateFile.Metamorphic { - if family.Fixture != nil { - familyNodeKinds, familyEdgeKinds := family.Fixture.Kinds() - nodeKinds = nodeKinds.Add(familyNodeKinds...) - edgeKinds = edgeKinds.Add(familyEdgeKinds...) + fixtureNodeKinds, fixtureEdgeKinds := collectFixtureKinds(family.Fixture) + nodeKinds = nodeKinds.Add(fixtureNodeKinds...) + edgeKinds = edgeKinds.Add(fixtureEdgeKinds...) + + for _, query := range family.Queries { + queryNodeKinds, queryEdgeKinds := collectCypherKinds(t, query.Cypher) + nodeKinds = nodeKinds.Add(queryNodeKinds...) + edgeKinds = edgeKinds.Add(queryEdgeKinds...) } } } diff --git a/integration/cypher_test.go b/integration/cypher_test.go index 334a9168..11fc4b1a 100644 --- a/integration/cypher_test.go +++ b/integration/cypher_test.go @@ -68,6 +68,7 @@ func TestCypher(t *testing.T) { } groups := map[string]*group{} var datasetNames []string + var extraNodeKinds, extraEdgeKinds graph.Kinds for _, path := range files { raw, err := os.ReadFile(path) @@ -90,9 +91,13 @@ func TestCypher(t *testing.T) { datasetNames = append(datasetNames, ds) } groups[ds].files = append(groups[ds].files, cf) + + caseNodeKinds, caseEdgeKinds := collectCypherCaseKinds(t, cf.Cases) + extraNodeKinds = extraNodeKinds.Add(caseNodeKinds...) + extraEdgeKinds = extraEdgeKinds.Add(caseEdgeKinds...) } - db, ctx := SetupDB(t, datasetNames...) + db, ctx := SetupDBWithKinds(t, extraNodeKinds, extraEdgeKinds, datasetNames...) for _, g := range groups { ClearGraph(t, db, ctx) @@ -120,6 +125,24 @@ func TestCypher(t *testing.T) { } } +func collectCypherCaseKinds(t *testing.T, cases []testCase) (graph.Kinds, graph.Kinds) { + t.Helper() + + var nodeKinds, edgeKinds graph.Kinds + + for _, tc := range cases { + queryNodeKinds, queryEdgeKinds := collectCypherKinds(t, tc.Cypher) + fixtureNodeKinds, fixtureEdgeKinds := collectFixtureKinds(tc.Fixture) + + nodeKinds = nodeKinds.Add(queryNodeKinds...) + nodeKinds = nodeKinds.Add(fixtureNodeKinds...) + edgeKinds = edgeKinds.Add(queryEdgeKinds...) + edgeKinds = edgeKinds.Add(fixtureEdgeKinds...) + } + + return nodeKinds, edgeKinds +} + // parseAssertion converts a JSON assertion value into a function that checks // a query result. Supports: // diff --git a/integration/harness.go b/integration/harness.go index 8853d128..9603c631 100644 --- a/integration/harness.go +++ b/integration/harness.go @@ -28,6 +28,9 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "github.com/specterops/dawgs" + "github.com/specterops/dawgs/cypher/frontend" + "github.com/specterops/dawgs/cypher/models/cypher" + "github.com/specterops/dawgs/cypher/models/walk" "github.com/specterops/dawgs/drivers/pg" "github.com/specterops/dawgs/graph" "github.com/specterops/dawgs/opengraph" @@ -175,6 +178,59 @@ func collectKinds(t *testing.T, datasets []string) (graph.Kinds, graph.Kinds) { return nodeKinds, edgeKinds } +func collectCypherKinds(t *testing.T, queries ...string) (graph.Kinds, graph.Kinds) { + t.Helper() + + var nodeKinds, edgeKinds graph.Kinds + + for _, queryText := range queries { + if strings.TrimSpace(queryText) == "" { + continue + } + + queryModel, err := frontend.ParseCypher(frontend.NewContext(), queryText) + if err != nil { + t.Fatalf("failed to parse Cypher query for kind scanning: %v\nquery: %s", err, queryText) + } + + visitor := walk.NewSimpleVisitor[cypher.SyntaxNode](func(node cypher.SyntaxNode, _ walk.VisitorHandler) { + switch typedNode := node.(type) { + case *cypher.NodePattern: + nodeKinds = nodeKinds.Add(typedNode.Kinds...) + + case *cypher.RelationshipPattern: + edgeKinds = edgeKinds.Add(typedNode.Kinds...) + + case *cypher.KindMatcher: + nodeKinds = nodeKinds.Add(typedNode.Kinds...) + edgeKinds = edgeKinds.Add(typedNode.Kinds...) + } + }) + + if err := walk.Cypher(queryModel, visitor); err != nil { + t.Fatalf("failed to walk Cypher query for kind scanning: %v\nquery: %s", err, queryText) + } + } + + return nodeKinds, edgeKinds +} + +func collectFixtureKinds(fixtures ...*opengraph.Graph) (graph.Kinds, graph.Kinds) { + var nodeKinds, edgeKinds graph.Kinds + + for _, fixture := range fixtures { + if fixture == nil { + continue + } + + fixtureNodeKinds, fixtureEdgeKinds := fixture.Kinds() + nodeKinds = nodeKinds.Add(fixtureNodeKinds...) + edgeKinds = edgeKinds.Add(fixtureEdgeKinds...) + } + + return nodeKinds, edgeKinds +} + // ClearGraph deletes all nodes (and cascading edges) from the database. func ClearGraph(t *testing.T, db graph.Database, ctx context.Context) { t.Helper() diff --git a/integration/harness_test.go b/integration/harness_test.go new file mode 100644 index 00000000..84a272f9 --- /dev/null +++ b/integration/harness_test.go @@ -0,0 +1,24 @@ +package integration + +import ( + "testing" + + "github.com/specterops/dawgs/graph" +) + +func TestCollectCypherKindsIncludesQueryOnlyRelationshipKinds(t *testing.T) { + nodeKinds, edgeKinds := collectCypherKinds(t, "MATCH p=(n:Domain)-[:CrossForestTrust|SpoofSIDHistory|AbuseTGTDelegation]-(m:Domain) WHERE (n)-[:SpoofSIDHistory|AbuseTGTDelegation]-(m) RETURN p") + + assertKindsContain(t, nodeKinds, "Domain") + assertKindsContain(t, edgeKinds, "CrossForestTrust", "SpoofSIDHistory", "AbuseTGTDelegation") +} + +func assertKindsContain(t *testing.T, kinds graph.Kinds, expected ...string) { + t.Helper() + + for _, name := range expected { + if !kinds.ContainsOneOf(graph.StringKind(name)) { + t.Fatalf("expected kinds %v to contain %q", kinds.Strings(), name) + } + } +} From eee833367bde652fe76148fee76b0e661a59accd Mon Sep 17 00:00:00 2001 From: John Hopper Date: Sun, 24 May 2026 00:59:49 -0700 Subject: [PATCH 116/116] Group local declarations --- cmd/benchmark/report_test.go | 12 +- cmd/benchmark/scenarios.go | 12 +- cmd/graphbench/corpus.go | 6 +- cmd/graphbench/summary.go | 12 +- cmd/graphbench/summary_test.go | 6 +- cmd/plancorpus/capture.go | 32 ++-- cmd/plancorpus/main.go | 10 +- cmd/plancorpus/report.go | 21 ++- cypher/models/pgsql/optimize/analysis_test.go | 6 +- cypher/models/pgsql/optimize/lowering_plan.go | 126 ++++++++++------ cypher/models/pgsql/optimize/optimizer.go | 6 +- .../models/pgsql/optimize/optimizer_test.go | 12 +- cypher/models/pgsql/optimize/reordering.go | 6 +- .../pgsql/test/validation_integration_test.go | 62 ++++---- .../translate/aggregate_traversal_count.go | 26 ++-- .../pgsql/translate/constraints_test.go | 142 ++++++++++-------- cypher/models/pgsql/translate/expansion.go | 58 ++++--- cypher/models/pgsql/translate/expression.go | 70 +++++---- .../models/pgsql/translate/expression_test.go | 40 ++--- .../pgsql/translate/limit_pushdown_test.go | 82 +++++----- cypher/models/pgsql/translate/match.go | 12 +- cypher/models/pgsql/translate/model.go | 6 +- .../pgsql/translate/optimizer_safety_test.go | 12 +- cypher/models/pgsql/translate/predicate.go | 44 +++--- .../models/pgsql/translate/tracking_test.go | 6 +- cypher/models/pgsql/translate/translator.go | 6 +- cypher/models/pgsql/translate/traversal.go | 12 +- drivers/pg/batch.go | 12 +- drivers/pg/batch_copy_source_test.go | 18 ++- drivers/pg/batch_node_source.go | 6 +- drivers/pg/batch_node_source_test.go | 29 ++-- drivers/pg/batch_node_update_source_test.go | 14 +- drivers/pg/batch_node_upsert_source_test.go | 21 +-- drivers/pg/batch_relationship_source_test.go | 10 +- .../batch_relationship_update_source_test.go | 35 ++--- integration/cypher_template_test.go | 42 +++--- integration/cypher_test.go | 64 ++++---- .../pgsql_aggregate_traversal_plan_test.go | 6 +- integration/pgsql_batch_operation_test.go | 12 +- tools/metrics/internal/metrics/quality.go | 94 +++++++----- 40 files changed, 707 insertions(+), 501 deletions(-) diff --git a/cmd/benchmark/report_test.go b/cmd/benchmark/report_test.go index 310bdd00..1d51b5dc 100644 --- a/cmd/benchmark/report_test.go +++ b/cmd/benchmark/report_test.go @@ -27,8 +27,10 @@ import ( ) func TestWriteJSONEmitsBaselineFriendlyReport(t *testing.T) { - distinctRows := int64(2) - duplicateRows := int64(0) + var ( + distinctRows = int64(2) + duplicateRows = int64(0) + ) loweringPlan := optimize.LoweringPlan{ ProjectionPruning: []optimize.ProjectionPruningDecision{{ Target: optimize.TraversalStepTarget{ @@ -108,8 +110,10 @@ func TestWriteJSONEmitsBaselineFriendlyReport(t *testing.T) { } func TestWriteMarkdownIncludesDiagnosticColumns(t *testing.T) { - distinctRows := int64(2) - duplicateRows := int64(0) + var ( + distinctRows = int64(2) + duplicateRows = int64(0) + ) report := Report{ Driver: "pg", diff --git a/cmd/benchmark/scenarios.go b/cmd/benchmark/scenarios.go index 48a1efb6..4aad262b 100644 --- a/cmd/benchmark/scenarios.go +++ b/cmd/benchmark/scenarios.go @@ -124,8 +124,10 @@ func cypherPathQuery(cypher string, pathColumns int) func(tx graph.Transaction) for result.Next() { rowCount++ - values := make([]graph.Path, pathColumns) - targets := make([]any, pathColumns) + var ( + values = make([]graph.Path, pathColumns) + targets = make([]any, pathColumns) + ) for idx := range values { targets[idx] = &values[idx] } @@ -141,8 +143,10 @@ func cypherPathQuery(cypher string, pathColumns int) func(tx graph.Transaction) return Measurement{}, err } - distinctRowCount := int64(len(seen)) - duplicateRowCount := rowCount - distinctRowCount + var ( + distinctRowCount = int64(len(seen)) + duplicateRowCount = rowCount - distinctRowCount + ) return Measurement{ RowCount: rowCount, diff --git a/cmd/graphbench/corpus.go b/cmd/graphbench/corpus.go index 6546d248..7d1c9075 100644 --- a/cmd/graphbench/corpus.go +++ b/cmd/graphbench/corpus.go @@ -95,8 +95,10 @@ func decodeJSONFile(path string, target any) error { } func scaleCorpusDatasets(corpus ScaleCorpus) []string { - seen := map[string]struct{}{} - datasets := make([]string, 0) + var ( + seen = map[string]struct{}{} + datasets = make([]string, 0) + ) for _, testCase := range corpus.Cases { if _, duplicate := seen[testCase.Dataset]; duplicate { diff --git a/cmd/graphbench/summary.go b/cmd/graphbench/summary.go index 89c78972..437ebeae 100644 --- a/cmd/graphbench/summary.go +++ b/cmd/graphbench/summary.go @@ -74,8 +74,10 @@ func buildSummary(records []CaseResult) Summary { GeneratedAt: time.Now().UTC(), } - modeSummaries := map[ExecutionMode]*ModeSummary{} - caseSummaries := map[string]*CaseSummary{} + var ( + modeSummaries = map[ExecutionMode]*ModeSummary{} + caseSummaries = map[string]*CaseSummary{} + ) for _, record := range records { modeSummary := modeSummaries[record.ExecutionMode] @@ -96,8 +98,10 @@ func buildSummary(records []CaseResult) Summary { modeSummary.NotImplemented++ } - caseKey := record.Source + "\x00" + record.Dataset + "\x00" + record.Name - caseSummary := caseSummaries[caseKey] + var ( + caseKey = record.Source + "\x00" + record.Dataset + "\x00" + record.Name + caseSummary = caseSummaries[caseKey] + ) if caseSummary == nil { caseSummary = &CaseSummary{ Source: record.Source, diff --git a/cmd/graphbench/summary_test.go b/cmd/graphbench/summary_test.go index 8bfe68f8..d0157399 100644 --- a/cmd/graphbench/summary_test.go +++ b/cmd/graphbench/summary_test.go @@ -26,8 +26,10 @@ import ( ) func TestApplyBaseline(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "baseline.jsonl") + var ( + dir = t.TempDir() + path = filepath.Join(dir, "baseline.jsonl") + ) require.NoError(t, writeJSONLFile(path, []CaseResult{{ Dataset: "base", Name: "case", diff --git a/cmd/plancorpus/capture.go b/cmd/plancorpus/capture.go index 7cdfa02d..dc4a7d81 100644 --- a/cmd/plancorpus/capture.go +++ b/cmd/plancorpus/capture.go @@ -68,20 +68,22 @@ func captureCorpus(ctx context.Context, datasetDir string, suite corpus, spec ca continue } - datasetLoaded := false - ensureDatasetLoaded := func() error { - if datasetLoaded { + var ( + datasetLoaded = false + ensureDatasetLoaded = func() error { + if datasetLoaded { + return nil + } + if err := clearGraph(ctx, backend.db); err != nil { + return err + } + if err := loadDataset(ctx, backend.db, datasetDir, datasetName); err != nil { + return err + } + datasetLoaded = true return nil } - if err := clearGraph(ctx, backend.db); err != nil { - return err - } - if err := loadDataset(ctx, backend.db, datasetDir, datasetName); err != nil { - return err - } - datasetLoaded = true - return nil - } + ) for _, file := range group.files { for _, testCase := range file.Cases { @@ -507,8 +509,10 @@ func loweringNames(decisions []optimize.LoweringDecision) []string { return nil } - names := make([]string, 0, len(decisions)) - seen := make(map[string]struct{}, len(decisions)) + var ( + names = make([]string, 0, len(decisions)) + seen = make(map[string]struct{}, len(decisions)) + ) for _, decision := range decisions { name := decision.Name if _, duplicate := seen[name]; duplicate { diff --git a/cmd/plancorpus/main.go b/cmd/plancorpus/main.go index 6a1f06e9..3afdc069 100644 --- a/cmd/plancorpus/main.go +++ b/cmd/plancorpus/main.go @@ -115,8 +115,10 @@ func captureSpecs(cfg commandConfig) ([]captureSpec, error) { return nil, fmt.Errorf("no connection string supplied; set CONNECTION_STRING or PG_CONNECTION_STRING/NEO4J_CONNECTION_STRING") } - orderedDrivers := []string{pgDriverName(), neo4jDriverName()} - specs := make([]captureSpec, 0, len(specsByDriver)) + var ( + orderedDrivers = []string{pgDriverName(), neo4jDriverName()} + specs = make([]captureSpec, 0, len(specsByDriver)) + ) for _, driverName := range orderedDrivers { if spec, found := specsByDriver[driverName]; found { specs = append(specs, spec) @@ -138,7 +140,6 @@ func writePlanRecords(path string, records []PlanRecord) error { if err != nil { return fmt.Errorf("create %s: %w", path, err) } - defer out.Close() encoder := json.NewEncoder(out) for _, record := range records { @@ -146,7 +147,8 @@ func writePlanRecords(path string, records []PlanRecord) error { return fmt.Errorf("write %s: %w", path, err) } } - return nil + + return out.Close() } func writeSummaryFiles(markdownPath, jsonPath string, summary PlanSummary) error { diff --git a/cmd/plancorpus/report.go b/cmd/plancorpus/report.go index d0a28f4a..5e62f1a6 100644 --- a/cmd/plancorpus/report.go +++ b/cmd/plancorpus/report.go @@ -65,18 +65,17 @@ func buildSummary(records []PlanRecord, topN int) PlanSummary { topN = defaultTopPlans } - driverCounts := map[string]*DriverSummary{} - postgresOperatorCounts := map[string]int{} - neo4jOperatorCounts := map[string]int{} - plannedLoweringCounts := map[string]int{} - appliedLoweringCounts := map[string]int{} - skippedLoweringCounts := map[string]int{} - skippedReasonCounts := map[string]int{} - featureCounts := map[string]int{} - var ( - errors []PlanError - topPG []CostedPlan + driverCounts = map[string]*DriverSummary{} + postgresOperatorCounts = map[string]int{} + neo4jOperatorCounts = map[string]int{} + plannedLoweringCounts = map[string]int{} + appliedLoweringCounts = map[string]int{} + skippedLoweringCounts = map[string]int{} + skippedReasonCounts = map[string]int{} + featureCounts = map[string]int{} + errors []PlanError + topPG []CostedPlan ) for _, record := range records { diff --git a/cypher/models/pgsql/optimize/analysis_test.go b/cypher/models/pgsql/optimize/analysis_test.go index f6441e32..0ea35be7 100644 --- a/cypher/models/pgsql/optimize/analysis_test.go +++ b/cypher/models/pgsql/optimize/analysis_test.go @@ -125,8 +125,10 @@ func TestAnalyzeSegmentsRegionsAtSemanticBarriers(t *testing.T) { func TestAnalysisDiagnosticsAreStable(t *testing.T) { t.Parallel() - analysis := analyzeCypher(t, adcsQuery) - diagnostics := strings.Join(analysis.Diagnostics(), "\n") + var ( + analysis = analyzeCypher(t, adcsQuery) + diagnostics = strings.Join(analysis.Diagnostics(), "\n") + ) require.Contains(t, diagnostics, "query_part[0] kind=single projection_deps=p1,p2") require.Contains(t, diagnostics, "region[0] part=0 clauses=0..2 matches=3") diff --git a/cypher/models/pgsql/optimize/lowering_plan.go b/cypher/models/pgsql/optimize/lowering_plan.go index 6aba76ad..94977198 100644 --- a/cypher/models/pgsql/optimize/lowering_plan.go +++ b/cypher/models/pgsql/optimize/lowering_plan.go @@ -46,8 +46,10 @@ func BuildLoweringPlan(query *cypher.RegularQuery, predicateAttachments []Predic var plan LoweringPlan if query.SingleQuery.MultiPartQuery != nil { - carriedSymbols := map[string]struct{}{} - carriedSelectivity := map[string]boundSourceSelectivity{} + var ( + carriedSymbols = map[string]struct{}{} + carriedSelectivity = map[string]boundSourceSelectivity{} + ) for queryPartIndex, part := range query.SingleQuery.MultiPartQuery.Parts { if part == nil { @@ -58,8 +60,10 @@ func BuildLoweringPlan(query *cypher.RegularQuery, predicateAttachments []Predic return LoweringPlan{}, err } - currentSymbols := copyStringSet(carriedSymbols) - currentSelectivity := copyBoundSourceSelectivity(carriedSelectivity) + var ( + currentSymbols = copyStringSet(carriedSymbols) + currentSelectivity = copyBoundSourceSelectivity(carriedSelectivity) + ) declareReadingClauseSymbols(currentSymbols, part.ReadingClauses) declareReadingClauseSelectivity(currentSelectivity, part.ReadingClauses) @@ -150,8 +154,10 @@ func appendPatternProjectionPruningDecisions(plan *LoweringPlan, target PatternT decision.OmitPathBinding = !pathReferenced hasPruning = decision.OmitRelationship || decision.OmitPathBinding } else { - leftReferenced := referencesSourceIdentifier(sourceReferences, variableSymbol(step.LeftNode.Variable)) - rightReferenced := referencesSourceIdentifier(sourceReferences, variableSymbol(step.RightNode.Variable)) + var ( + leftReferenced = referencesSourceIdentifier(sourceReferences, variableSymbol(step.LeftNode.Variable)) + rightReferenced = referencesSourceIdentifier(sourceReferences, variableSymbol(step.RightNode.Variable)) + ) decision.OmitLeftNode = !(leftReferenced || pathReferenced) decision.OmitRelationship = !(edgeReferenced || pathReferenced) @@ -167,8 +173,10 @@ func appendPatternProjectionPruningDecisions(plan *LoweringPlan, target PatternT func appendPatternPredicateProjectionLowerings(plan *LoweringPlan, queryPartIndex int, queryPart cypher.SyntaxNode, sourceReferences map[string]struct{}) { for predicateIndex, predicate := range patternPredicatesInQueryPart(queryPart) { - patternPart := patternPartForPredicate(predicate) - steps := traversalStepsForPattern(patternPart) + var ( + patternPart = patternPartForPredicate(predicate) + steps = traversalStepsForPattern(patternPart) + ) if len(steps) == 0 { continue } @@ -187,8 +195,10 @@ func appendPatternPredicateProjectionLowerings(plan *LoweringPlan, queryPartInde func appendPatternPredicatePlacementDecisions(plan *LoweringPlan, queryPartIndex int, queryPart cypher.SyntaxNode) { for predicateIndex, predicate := range patternPredicatesInQueryPart(queryPart) { - patternPart := patternPartForPredicate(predicate) - steps := traversalStepsForPattern(patternPart) + var ( + patternPart = patternPartForPredicate(predicate) + steps = traversalStepsForPattern(patternPart) + ) if len(steps) != 1 { continue } @@ -293,16 +303,20 @@ func appendExpandIntoDecisions(plan *LoweringPlan, queryPartIndex int, readingCl } for patternIndex, patternPart := range match.Pattern { - steps := traversalStepsForPattern(patternPart) - declaredEndpoints := declaredSymbolsBeforeStepEndpoints(declaredSymbols, steps) + var ( + steps = traversalStepsForPattern(patternPart) + declaredEndpoints = declaredSymbolsBeforeStepEndpoints(declaredSymbols, steps) + ) for stepIndex, step := range steps { if step.Relationship.Range != nil { continue } - leftSymbol := variableSymbol(step.LeftNode.Variable) - rightSymbol := variableSymbol(step.RightNode.Variable) + var ( + leftSymbol = variableSymbol(step.LeftNode.Variable) + rightSymbol = variableSymbol(step.RightNode.Variable) + ) _, leftBound := declaredEndpoints[stepIndex].BeforeLeftNode[leftSymbol] _, rightBound := declaredEndpoints[stepIndex].BeforeRightNode[rightSymbol] @@ -336,8 +350,10 @@ type declaredStepEndpoints struct { } func declaredSymbolsBeforeStepEndpoints(initial map[string]struct{}, steps []sourceTraversalStep) []declaredStepEndpoints { - declared := copyStringSet(initial) - endpoints := make([]declaredStepEndpoints, len(steps)) + var ( + declared = copyStringSet(initial) + endpoints = make([]declaredStepEndpoints, len(steps)) + ) for idx, step := range steps { endpoints[idx].BeforeLeftNode = copyStringSet(declared) @@ -358,8 +374,10 @@ func appendTraversalDirectionDecisions( initialDeclaredSymbols map[string]struct{}, initialSelectivity map[string]boundSourceSelectivity, ) { - declaredSymbols := copyStringSet(initialDeclaredSymbols) - declaredSourceSelectivity := copyBoundSourceSelectivity(initialSelectivity) + var ( + declaredSymbols = copyStringSet(initialDeclaredSymbols) + declaredSourceSelectivity = copyBoundSourceSelectivity(initialSelectivity) + ) for clauseIndex, readingClause := range readingClauses { if readingClause == nil || readingClause.Match == nil { @@ -373,8 +391,10 @@ func appendTraversalDirectionDecisions( } for patternIndex, patternPart := range match.Pattern { - steps := traversalStepsForPattern(patternPart) - declaredEndpoints := declaredSymbolsBeforeStepEndpoints(declaredSymbols, steps) + var ( + steps = traversalStepsForPattern(patternPart) + declaredEndpoints = declaredSymbolsBeforeStepEndpoints(declaredSymbols, steps) + ) patternTarget := PatternTarget{ QueryPartIndex: queryPartIndex, ClauseIndex: clauseIndex, @@ -445,8 +465,10 @@ func carryProjectionSelectivity( incomingSymbols map[string]struct{}, incomingSelectivity map[string]boundSourceSelectivity, ) (map[string]struct{}, map[string]boundSourceSelectivity) { - carriedSymbols := map[string]struct{}{} - carriedSelectivity := map[string]boundSourceSelectivity{} + var ( + carriedSymbols = map[string]struct{}{} + carriedSelectivity = map[string]boundSourceSelectivity{} + ) if projection == nil { return carriedSymbols, carriedSelectivity @@ -819,8 +841,10 @@ func traversalDirectionDecisionForStep( return TraversalDirectionDecision{}, false } - rightSymbol := variableSymbol(step.RightNode.Variable) - leftSymbol := variableSymbol(step.LeftNode.Variable) + var ( + rightSymbol = variableSymbol(step.RightNode.Variable) + leftSymbol = variableSymbol(step.LeftNode.Variable) + ) if rightSymbol != "" { if _, rightBound := declaredEndpoints.BeforeRightNode[rightSymbol]; rightBound { if rightSymbol == leftSymbol { @@ -835,8 +859,10 @@ func traversalDirectionDecisionForStep( } } - leftConstrained := nodePatternHasConstraints(step.LeftNode) || leftHasAttachedPredicate - rightConstrained := nodePatternHasConstraints(step.RightNode) || rightHasAttachedPredicate + var ( + leftConstrained = nodePatternHasConstraints(step.LeftNode) || leftHasAttachedPredicate + rightConstrained = nodePatternHasConstraints(step.RightNode) || rightHasAttachedPredicate + ) if rightConstrained && !leftConstrained { reason := traversalDirectionReasonRightConstrained @@ -880,8 +906,10 @@ func boundLeftExpansionDirectionDecisionForStep( return TraversalDirectionDecision{}, false } - leftSymbol := variableSymbol(step.LeftNode.Variable) - rightSymbol := variableSymbol(step.RightNode.Variable) + var ( + leftSymbol = variableSymbol(step.LeftNode.Variable) + rightSymbol = variableSymbol(step.RightNode.Variable) + ) if leftSymbol == "" || leftSymbol == rightSymbol { return TraversalDirectionDecision{}, false } @@ -937,8 +965,10 @@ func appendShortestPathStrategyDecisions(plan *LoweringPlan, queryPartIndex int, continue } - steps := traversalStepsForPattern(patternPart) - declaredEndpoints := declaredSymbolsBeforeStepEndpoints(declaredSymbols, steps) + var ( + steps = traversalStepsForPattern(patternPart) + declaredEndpoints = declaredSymbolsBeforeStepEndpoints(declaredSymbols, steps) + ) patternTarget := PatternTarget{ QueryPartIndex: queryPartIndex, ClauseIndex: clauseIndex, @@ -973,8 +1003,10 @@ func shortestPathStrategyDecisionForStep( declaredEndpoints declaredStepEndpoints, predicateConstrainedSymbols map[string]struct{}, ) (ShortestPathStrategyDecision, bool) { - leftSymbol := variableSymbol(step.LeftNode.Variable) - rightSymbol := variableSymbol(step.RightNode.Variable) + var ( + leftSymbol = variableSymbol(step.LeftNode.Variable) + rightSymbol = variableSymbol(step.RightNode.Variable) + ) _, rightBound := declaredEndpoints.BeforeRightNode[rightSymbol] if leftEndpointBoundForStep(target.StepIndex, step, declaredEndpoints) && rightSymbol != "" && rightBound { @@ -1033,8 +1065,10 @@ func appendShortestPathFilterDecisions(plan *LoweringPlan, queryPartIndex int, r continue } - steps := traversalStepsForPattern(patternPart) - declaredEndpoints := declaredSymbolsBeforeStepEndpoints(declaredSymbols, steps) + var ( + steps = traversalStepsForPattern(patternPart) + declaredEndpoints = declaredSymbolsBeforeStepEndpoints(declaredSymbols, steps) + ) patternTarget := PatternTarget{ QueryPartIndex: queryPartIndex, ClauseIndex: clauseIndex, @@ -1071,16 +1105,20 @@ func shortestPathFilterDecisionForStep( declaredEndpoints declaredStepEndpoints, predicateConstrainedSymbols map[string]struct{}, ) (ShortestPathFilterDecision, bool) { - leftSymbol := variableSymbol(step.LeftNode.Variable) - rightSymbol := variableSymbol(step.RightNode.Variable) + var ( + leftSymbol = variableSymbol(step.LeftNode.Variable) + rightSymbol = variableSymbol(step.RightNode.Variable) + ) if rightSymbol != "" { if _, rightBound := declaredEndpoints.BeforeRightNode[rightSymbol]; rightBound { return ShortestPathFilterDecision{}, false } } - leftSearchConstrained := endpointHasSearchConstraint(step.LeftNode, leftSymbol, predicateConstrainedSymbols) - rightSearchConstrained := endpointHasSearchConstraint(step.RightNode, rightSymbol, predicateConstrainedSymbols) + var ( + leftSearchConstrained = endpointHasSearchConstraint(step.LeftNode, leftSymbol, predicateConstrainedSymbols) + rightSearchConstrained = endpointHasSearchConstraint(step.RightNode, rightSymbol, predicateConstrainedSymbols) + ) if !endpointHasTerminalFilterConstraint(step.RightNode, rightSymbol, predicateConstrainedSymbols) { return ShortestPathFilterDecision{}, false } @@ -1204,8 +1242,10 @@ func appendExpansionSuffixPushdownDecisions(plan *LoweringPlan, queryPartIndex i } for patternIndex, patternPart := range match.Pattern { - steps := traversalStepsForPattern(patternPart) - declaredEndpoints := declaredSymbolsBeforeStepEndpoints(declaredSymbols, steps) + var ( + steps = traversalStepsForPattern(patternPart) + declaredEndpoints = declaredSymbolsBeforeStepEndpoints(declaredSymbols, steps) + ) for stepIndex, step := range steps { if step.Relationship.Range == nil || stepIndex+1 >= len(steps) { @@ -1512,8 +1552,10 @@ func aggregateTraversalFinalProjection(queryPart *cypher.SinglePartQuery, source CountAlias: countAlias, } - sourceSeen := false - countSeen := false + var ( + sourceSeen = false + countSeen = false + ) for _, item := range projection.Items { symbol, alias, ok := projectionItemVariableSymbolAndAlias(item) if !ok { diff --git a/cypher/models/pgsql/optimize/optimizer.go b/cypher/models/pgsql/optimize/optimizer.go index b448c7e6..d115167d 100644 --- a/cypher/models/pgsql/optimize/optimizer.go +++ b/cypher/models/pgsql/optimize/optimizer.go @@ -109,8 +109,10 @@ func AttachPredicates(analysis Analysis) []PredicateAttachment { regionBindings := regionBindingSymbols(region) for _, predicate := range region.Predicates { - bindingSymbols := predicateBindingSymbols(predicate, regionBindings) - scope := PredicateAttachmentScopeRegion + var ( + bindingSymbols = predicateBindingSymbols(predicate, regionBindings) + scope = PredicateAttachmentScopeRegion + ) if len(bindingSymbols) == 1 && len(predicate.Dependencies) == 1 { scope = PredicateAttachmentScopeBinding diff --git a/cypher/models/pgsql/optimize/optimizer_test.go b/cypher/models/pgsql/optimize/optimizer_test.go index 4ed3ec0f..b1f577ac 100644 --- a/cypher/models/pgsql/optimize/optimizer_test.go +++ b/cypher/models/pgsql/optimize/optimizer_test.go @@ -284,11 +284,13 @@ func TestLoweringPlanReportsTypedPatternPredicateExistencePlacement(t *testing.T func TestSelectivityModelPlansTraversalDirection(t *testing.T) { t.Parallel() - model := NewSelectivityModel(testBindingLookup{}) - rightIDLookup := pgsql.NewBinaryExpression( - pgsql.CompoundIdentifier{pgsql.Identifier("n1"), pgsql.ColumnID}, - pgsql.OperatorEquals, - pgsql.NewLiteral(1, pgsql.Int), + var ( + model = NewSelectivityModel(testBindingLookup{}) + rightIDLookup = pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{pgsql.Identifier("n1"), pgsql.ColumnID}, + pgsql.OperatorEquals, + pgsql.NewLiteral(1, pgsql.Int), + ) ) shouldFlip, err := model.ShouldFlipTraversalDirection(false, false, nil, rightIDLookup) diff --git a/cypher/models/pgsql/optimize/reordering.go b/cypher/models/pgsql/optimize/reordering.go index 7c46a67d..4a380108 100644 --- a/cypher/models/pgsql/optimize/reordering.go +++ b/cypher/models/pgsql/optimize/reordering.go @@ -89,8 +89,10 @@ func reorderReadingClauses(readingClauses []*cypher.ReadingClause, regions []Reg } func reorderRegion(regionClauses []*cypher.ReadingClause) bool { - candidates := make([]reorderCandidate, len(regionClauses)) - declaredBefore := map[string]struct{}{} + var ( + candidates = make([]reorderCandidate, len(regionClauses)) + declaredBefore = map[string]struct{}{} + ) for idx, clause := range regionClauses { candidates[idx] = reorderCandidate{ diff --git a/cypher/models/pgsql/test/validation_integration_test.go b/cypher/models/pgsql/test/validation_integration_test.go index 85ff5d90..7e741bea 100644 --- a/cypher/models/pgsql/test/validation_integration_test.go +++ b/cypher/models/pgsql/test/validation_integration_test.go @@ -186,15 +186,17 @@ func TestBidirectionalASPHarnessOverloads(t *testing.T) { ) require.NoError(t, err) - forwardPrimer := nextFrontValues( - "(1::int8, 10::int8, 1::int4, false, false, array [101]::int8[])", - "(3::int8, 10::int8, 1::int4, false, false, array [103]::int8[])", - ) - backwardPrimer := nextFrontValues( - "(2::int8, 10::int8, 1::int4, false, false, array [202]::int8[])", - "(4::int8, 10::int8, 1::int4, false, false, array [204]::int8[])", + var ( + forwardPrimer = nextFrontValues( + "(1::int8, 10::int8, 1::int4, false, false, array [101]::int8[])", + "(3::int8, 10::int8, 1::int4, false, false, array [103]::int8[])", + ) + backwardPrimer = nextFrontValues( + "(2::int8, 10::int8, 1::int4, false, false, array [202]::int8[])", + "(4::int8, 10::int8, 1::int4, false, false, array [204]::int8[])", + ) + pairFilter = pairFilterValues("(1::int8, 2::int8)") ) - pairFilter := pairFilterValues("(1::int8, 2::int8)") rows, err := tx.Query(testCtx, "select root_id, next_id from bidirectional_asp_harness($1::text, $2::text, $3::text, $4::text, 4, ''::text, ''::text, $5::text) order by root_id, next_id", @@ -237,18 +239,20 @@ func TestBidirectionalASPHarnessOverloads(t *testing.T) { ) require.NoError(t, err) - forwardPrimer := nextFrontValues( - "(1::int8, 2::int8, 1::int4, true, false, array [102]::int8[])", - "(1::int8, 2::int8, 1::int4, true, false, array [103]::int8[])", - "(3::int8, 30::int8, 1::int4, false, false, array [330]::int8[])", - ) - backwardPrimer := nextFrontValues( - "(4::int8, 30::int8, 1::int4, false, false, array [304]::int8[])", - "(4::int8, 30::int8, 1::int4, false, false, array [305]::int8[])", - ) - pairFilter := pairFilterValues( - "(1::int8, 2::int8)", - "(3::int8, 4::int8)", + var ( + forwardPrimer = nextFrontValues( + "(1::int8, 2::int8, 1::int4, true, false, array [102]::int8[])", + "(1::int8, 2::int8, 1::int4, true, false, array [103]::int8[])", + "(3::int8, 30::int8, 1::int4, false, false, array [330]::int8[])", + ) + backwardPrimer = nextFrontValues( + "(4::int8, 30::int8, 1::int4, false, false, array [304]::int8[])", + "(4::int8, 30::int8, 1::int4, false, false, array [305]::int8[])", + ) + pairFilter = pairFilterValues( + "(1::int8, 2::int8)", + "(3::int8, 4::int8)", + ) ) rows, err := tx.Query(testCtx, @@ -334,14 +338,16 @@ func TestBidirectionalASPHarnessOverloads(t *testing.T) { require.NoError(t, err) defer tx.Rollback(testCtx) - forwardPrimer := nextFrontValues( - "(1::int8, 2::int8, 1::int4, true, false, array [102]::int8[])", - "(3::int8, 30::int8, 1::int4, false, false, array [330]::int8[])", - ) - backwardPrimer := nextFrontValues("(4::int8, 30::int8, 1::int4, false, false, array [304]::int8[])") - pairFilter := pairFilterValues( - "(1::int8, 2::int8)", - "(3::int8, 4::int8)", + var ( + forwardPrimer = nextFrontValues( + "(1::int8, 2::int8, 1::int4, true, false, array [102]::int8[])", + "(3::int8, 30::int8, 1::int4, false, false, array [330]::int8[])", + ) + backwardPrimer = nextFrontValues("(4::int8, 30::int8, 1::int4, false, false, array [304]::int8[])") + pairFilter = pairFilterValues( + "(1::int8, 2::int8)", + "(3::int8, 4::int8)", + ) ) rows, err := tx.Query(testCtx, diff --git a/cypher/models/pgsql/translate/aggregate_traversal_count.go b/cypher/models/pgsql/translate/aggregate_traversal_count.go index 28280573..330c5a48 100644 --- a/cypher/models/pgsql/translate/aggregate_traversal_count.go +++ b/cypher/models/pgsql/translate/aggregate_traversal_count.go @@ -161,15 +161,17 @@ func (s *Translator) buildAggregateTraversalCTE(shape optimize.AggregateTraversa } sourceColumn, nextColumn := aggregateTraversalColumns(shape.Direction) - primerJoin := pgsql.NewBinaryExpression( - pgsql.CompoundIdentifier{aggregateEdgeAlias, sourceColumn}, - pgsql.OperatorEquals, - pgsql.CompoundIdentifier{aggregateCandidateSourcesCTE, aggregateRootID}, - ) - recursiveJoin := pgsql.NewBinaryExpression( - pgsql.CompoundIdentifier{aggregateEdgeAlias, sourceColumn}, - pgsql.OperatorEquals, - pgsql.CompoundIdentifier{aggregateTraversalCTE, aggregateNextID}, + var ( + primerJoin = pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{aggregateEdgeAlias, sourceColumn}, + pgsql.OperatorEquals, + pgsql.CompoundIdentifier{aggregateCandidateSourcesCTE, aggregateRootID}, + ) + recursiveJoin = pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{aggregateEdgeAlias, sourceColumn}, + pgsql.OperatorEquals, + pgsql.CompoundIdentifier{aggregateTraversalCTE, aggregateNextID}, + ) ) return pgsql.CommonTableExpression{ @@ -435,8 +437,10 @@ func (s *Translator) aggregateBindingPredicate(match *cypher.Match, symbol strin return nil, nil } - translator := NewTranslator(s.ctx, s.kindMapper.kindMapper, s.parameters, s.graphID) - binding := translator.scope.Define(alias, pgsql.NodeComposite) + var ( + translator = NewTranslator(s.ctx, s.kindMapper.kindMapper, s.parameters, s.graphID) + binding = translator.scope.Define(alias, pgsql.NodeComposite) + ) translator.scope.Alias(pgsql.Identifier(symbol), binding) if err := walk.Cypher(match.Where, translator); err != nil { diff --git a/cypher/models/pgsql/translate/constraints_test.go b/cypher/models/pgsql/translate/constraints_test.go index c54dc0c6..cdde76b4 100644 --- a/cypher/models/pgsql/translate/constraints_test.go +++ b/cypher/models/pgsql/translate/constraints_test.go @@ -19,16 +19,18 @@ func TestMeasureSelectivity(t *testing.T) { } func TestCanExecuteSelectiveBidirectionalSearch(t *testing.T) { - lowSelectivity := pgd.Equals( - pgsql.Identifier("123"), - pgsql.Identifier("456"), - ) - idLookup := func(identifier pgsql.Identifier, id int64) pgsql.Expression { - return pgd.Equals( - pgsql.CompoundIdentifier{identifier, pgsql.ColumnID}, - pgd.IntLiteral(id), + var ( + lowSelectivity = pgd.Equals( + pgsql.Identifier("123"), + pgsql.Identifier("456"), ) - } + idLookup = func(identifier pgsql.Identifier, id int64) pgsql.Expression { + return pgd.Equals( + pgsql.CompoundIdentifier{identifier, pgsql.ColumnID}, + pgd.IntLiteral(id), + ) + } + ) t.Run("rejects low selectivity endpoints", func(t *testing.T) { step := &TraversalStep{ @@ -136,42 +138,46 @@ func TestCanExecuteSelectiveBidirectionalSearch(t *testing.T) { } func TestCanExecutePairAwareBidirectionalSearch(t *testing.T) { - scopeWithNodeBindings := func(identifiers ...pgsql.Identifier) *Scope { - scope := NewScope() - for _, identifier := range identifiers { - scope.Define(identifier, pgsql.NodeComposite) + var ( + scopeWithNodeBindings = func(identifiers ...pgsql.Identifier) *Scope { + scope := NewScope() + for _, identifier := range identifiers { + scope.Define(identifier, pgsql.NodeComposite) + } + + return scope } - - return scope - } - localSelectivePropertyConstraint := func(identifier pgsql.Identifier) pgsql.Expression { - return pgd.Equals( - pgd.PropertyLookup(identifier, "name"), - pgd.TextLiteral("123"), - ) - } - localBroadPropertyConstraint := func(identifier pgsql.Identifier) pgsql.Expression { - return pgd.Equals( - pgsql.CompoundIdentifier{identifier, pgsql.ColumnProperties}, - pgd.IntLiteral(1), - ) - } - localKindConstraint := func(identifier pgsql.Identifier) pgsql.Expression { - return pgd.And( - pgd.Equals( - pgsql.CompoundIdentifier{identifier, pgsql.ColumnKindIDs}, + localSelectivePropertyConstraint = func(identifier pgsql.Identifier) pgsql.Expression { + return pgd.Equals( + pgd.PropertyLookup(identifier, "name"), + pgd.TextLiteral("123"), + ) + } + localBroadPropertyConstraint = func(identifier pgsql.Identifier) pgsql.Expression { + return pgd.Equals( + pgsql.CompoundIdentifier{identifier, pgsql.ColumnProperties}, pgd.IntLiteral(1), - ), - pgd.Equals( - pgsql.CompoundIdentifier{identifier, pgsql.ColumnKindIDs}, - pgd.IntLiteral(2), - ), - ) - } + ) + } + localKindConstraint = func(identifier pgsql.Identifier) pgsql.Expression { + return pgd.And( + pgd.Equals( + pgsql.CompoundIdentifier{identifier, pgsql.ColumnKindIDs}, + pgd.IntLiteral(1), + ), + pgd.Equals( + pgsql.CompoundIdentifier{identifier, pgsql.ColumnKindIDs}, + pgd.IntLiteral(2), + ), + ) + } + ) t.Run("accepts selective property-backed local endpoint constraints for shortest path", func(t *testing.T) { - leftIdentifier := pgsql.Identifier("n0") - rightIdentifier := pgsql.Identifier("n1") + var ( + leftIdentifier = pgsql.Identifier("n0") + rightIdentifier = pgsql.Identifier("n1") + ) step := &TraversalStep{ LeftNode: &BoundIdentifier{ Identifier: leftIdentifier, @@ -195,8 +201,10 @@ func TestCanExecutePairAwareBidirectionalSearch(t *testing.T) { }) t.Run("rejects broad non-kind local endpoint constraints for shortest path", func(t *testing.T) { - leftIdentifier := pgsql.Identifier("n0") - rightIdentifier := pgsql.Identifier("n1") + var ( + leftIdentifier = pgsql.Identifier("n0") + rightIdentifier = pgsql.Identifier("n1") + ) step := &TraversalStep{ LeftNode: &BoundIdentifier{ Identifier: leftIdentifier, @@ -220,8 +228,10 @@ func TestCanExecutePairAwareBidirectionalSearch(t *testing.T) { }) t.Run("rejects pair-aware search when only one endpoint is selective", func(t *testing.T) { - leftIdentifier := pgsql.Identifier("n0") - rightIdentifier := pgsql.Identifier("n1") + var ( + leftIdentifier = pgsql.Identifier("n0") + rightIdentifier = pgsql.Identifier("n1") + ) step := &TraversalStep{ LeftNode: &BoundIdentifier{ Identifier: leftIdentifier, @@ -268,8 +278,10 @@ func TestCanExecutePairAwareBidirectionalSearch(t *testing.T) { }) t.Run("accepts selective property-backed local endpoint constraints for all shortest paths", func(t *testing.T) { - leftIdentifier := pgsql.Identifier("n0") - rightIdentifier := pgsql.Identifier("n1") + var ( + leftIdentifier = pgsql.Identifier("n0") + rightIdentifier = pgsql.Identifier("n1") + ) step := &TraversalStep{ LeftNode: &BoundIdentifier{ Identifier: leftIdentifier, @@ -293,8 +305,10 @@ func TestCanExecutePairAwareBidirectionalSearch(t *testing.T) { }) t.Run("rejects endpoint constraints that reference the other endpoint", func(t *testing.T) { - leftIdentifier := pgsql.Identifier("n0") - rightIdentifier := pgsql.Identifier("n1") + var ( + leftIdentifier = pgsql.Identifier("n0") + rightIdentifier = pgsql.Identifier("n1") + ) step := &TraversalStep{ LeftNode: &BoundIdentifier{ Identifier: leftIdentifier, @@ -325,20 +339,22 @@ func TestCanExecutePairAwareBidirectionalSearch(t *testing.T) { } func TestCanMaterializeEndpointPairFilterRequiresPairAwareConstraints(t *testing.T) { - leftIdentifier := pgsql.Identifier("n0") - rightIdentifier := pgsql.Identifier("n1") - kindOnlyConstraint := func(identifier pgsql.Identifier) pgsql.Expression { - return pgd.Equals( - pgsql.CompoundIdentifier{identifier, pgsql.ColumnKindIDs}, - pgd.IntLiteral(1), - ) - } - propertyConstraint := func(identifier pgsql.Identifier) pgsql.Expression { - return pgd.Equals( - pgd.PropertyLookup(identifier, "name"), - pgd.TextLiteral("target"), - ) - } + var ( + leftIdentifier = pgsql.Identifier("n0") + rightIdentifier = pgsql.Identifier("n1") + kindOnlyConstraint = func(identifier pgsql.Identifier) pgsql.Expression { + return pgd.Equals( + pgsql.CompoundIdentifier{identifier, pgsql.ColumnKindIDs}, + pgd.IntLiteral(1), + ) + } + propertyConstraint = func(identifier pgsql.Identifier) pgsql.Expression { + return pgd.Equals( + pgd.PropertyLookup(identifier, "name"), + pgd.TextLiteral("target"), + ) + } + ) step := &TraversalStep{ LeftNode: &BoundIdentifier{Identifier: leftIdentifier}, diff --git a/cypher/models/pgsql/translate/expansion.go b/cypher/models/pgsql/translate/expansion.go index 5cfee834..1d49d44b 100644 --- a/cypher/models/pgsql/translate/expansion.go +++ b/cypher/models/pgsql/translate/expansion.go @@ -144,8 +144,10 @@ func newExpansionNodeSeed(identifier, nodeIdentifier pgsql.Identifier, constrain } func newExpansionNodeFilterSeed(identifier, filterIdentifier, nodeIdentifier pgsql.Identifier, constraints pgsql.Expression) expansionSeed { - filterAlias := pgsql.Identifier(string(identifier) + "_filter") - filterID := pgsql.CompoundIdentifier{filterAlias, pgsql.ColumnID} + var ( + filterAlias = pgsql.Identifier(string(identifier) + "_filter") + filterID = pgsql.CompoundIdentifier{filterAlias, pgsql.ColumnID} + ) if constraints == nil { return newExpansionSeed(identifier, filterID, []pgsql.FromClause{{ @@ -238,8 +240,10 @@ func expressionReferencesUnwindBinding(expression pgsql.Expression, unwindClause } func (s *ExpansionBuilder) seedEndpointConstraintSplit(expression pgsql.Expression, nodeIdentifier pgsql.Identifier, previousFrameIdentifier pgsql.Identifier) (pgsql.Expression, pgsql.Expression) { - seedExpression := rewriteBoundEndpointSeedReference(expression, previousFrameIdentifier, nodeIdentifier) - localScope := pgsql.AsIdentifierSet(nodeIdentifier) + var ( + seedExpression = rewriteBoundEndpointSeedReference(expression, previousFrameIdentifier, nodeIdentifier) + localScope = pgsql.AsIdentifierSet(nodeIdentifier) + ) for _, clause := range s.unwindClauses { if clause.Binding != nil { @@ -733,11 +737,13 @@ func (s *ExpansionBuilder) usesBoundEndpointPairs() bool { } func (s *ExpansionBuilder) boundNodeIDsFilterStatement(filterIdentifier pgsql.Identifier, nodeIdentifier pgsql.Identifier) pgsql.Insert { - previousFrameIdentifier := s.traversalStep.Frame.Previous.Binding.Identifier - nodeIDExpression := pgsql.RowColumnReference{ - Identifier: pgsql.CompoundIdentifier{previousFrameIdentifier, nodeIdentifier}, - Column: pgsql.ColumnID, - } + var ( + previousFrameIdentifier = s.traversalStep.Frame.Previous.Binding.Identifier + nodeIDExpression = pgsql.RowColumnReference{ + Identifier: pgsql.CompoundIdentifier{previousFrameIdentifier, nodeIdentifier}, + Column: pgsql.ColumnID, + } + ) return pgsql.Insert{ Table: pgsql.TableReference{ @@ -825,15 +831,17 @@ func (s *ExpansionBuilder) boundEndpointPairFilterStatement() (pgsql.Insert, boo return pgsql.Insert{}, false } - previousFrameIdentifier := s.traversalStep.Frame.Previous.Binding.Identifier - rootIDExpression := pgsql.RowColumnReference{ - Identifier: pgsql.CompoundIdentifier{previousFrameIdentifier, s.traversalStep.LeftNode.Identifier}, - Column: pgsql.ColumnID, - } - terminalIDExpression := pgsql.RowColumnReference{ - Identifier: pgsql.CompoundIdentifier{previousFrameIdentifier, s.traversalStep.RightNode.Identifier}, - Column: pgsql.ColumnID, - } + var ( + previousFrameIdentifier = s.traversalStep.Frame.Previous.Binding.Identifier + rootIDExpression = pgsql.RowColumnReference{ + Identifier: pgsql.CompoundIdentifier{previousFrameIdentifier, s.traversalStep.LeftNode.Identifier}, + Column: pgsql.ColumnID, + } + terminalIDExpression = pgsql.RowColumnReference{ + Identifier: pgsql.CompoundIdentifier{previousFrameIdentifier, s.traversalStep.RightNode.Identifier}, + Column: pgsql.ColumnID, + } + ) return pgsql.Insert{ Table: pgsql.TableReference{ @@ -875,9 +883,11 @@ func (s *ExpansionBuilder) materializedEndpointPairFilterStatement() (pgsql.Inse return pgsql.Insert{}, false } - rootIDExpression := pgsql.CompoundIdentifier{s.traversalStep.LeftNode.Identifier, pgsql.ColumnID} - terminalIDExpression := pgsql.CompoundIdentifier{s.traversalStep.RightNode.Identifier, pgsql.ColumnID} - pairConstraints := pgsql.OptionalAnd(expansionModel.PrimerNodeConstraints, expansionModel.TerminalNodeConstraints) + var ( + rootIDExpression = pgsql.CompoundIdentifier{s.traversalStep.LeftNode.Identifier, pgsql.ColumnID} + terminalIDExpression = pgsql.CompoundIdentifier{s.traversalStep.RightNode.Identifier, pgsql.ColumnID} + pairConstraints = pgsql.OptionalAnd(expansionModel.PrimerNodeConstraints, expansionModel.TerminalNodeConstraints) + ) pairConstraints = pgsql.OptionalAnd(pairConstraints, pgsql.NewBinaryExpression( rootIDExpression, pgsql.OperatorIsNot, @@ -1578,8 +1588,10 @@ func (s *ExpansionBuilder) applyShortestPathSeedProjectionConstraints(projection // Match Neo4j's shortest-path behavior by surfacing an error for result rows // where the resolved root and terminal endpoints are the same node. func shortestPathSelfEndpointGuard(expansionFrame pgsql.Identifier) pgsql.Expression { - rootID := pgsql.CompoundIdentifier{expansionFrame, expansionRootID} - terminalID := pgsql.CompoundIdentifier{expansionFrame, expansionNextID} + var ( + rootID = pgsql.CompoundIdentifier{expansionFrame, expansionRootID} + terminalID = pgsql.CompoundIdentifier{expansionFrame, expansionNextID} + ) return shortestPathSelfEndpointGuardCase(rootID, terminalID) } diff --git a/cypher/models/pgsql/translate/expression.go b/cypher/models/pgsql/translate/expression.go index 51ee2078..6123f8a5 100644 --- a/cypher/models/pgsql/translate/expression.go +++ b/cypher/models/pgsql/translate/expression.go @@ -850,15 +850,17 @@ func jsonEmptyArrayLiteral() pgsql.Expression { func rewritePropertyLookupNullCheck(propertyLookup *pgsql.BinaryExpression, isNotNull bool) pgsql.Expression { propertyLookup.Operator = pgsql.OperatorJSONField - existsExpression := pgsql.NewBinaryExpression( - propertyLookup.LOperand, - pgsql.OperatorJSONBFieldExists, - propertyLookup.ROperand, - ) - jsonNullExpression := pgsql.NewBinaryExpression( - propertyLookup, - pgsql.OperatorEquals, - jsonNullLiteral(), + var ( + existsExpression = pgsql.NewBinaryExpression( + propertyLookup.LOperand, + pgsql.OperatorJSONBFieldExists, + propertyLookup.ROperand, + ) + jsonNullExpression = pgsql.NewBinaryExpression( + propertyLookup, + pgsql.OperatorEquals, + jsonNullLiteral(), + ) ) if isNotNull { @@ -955,15 +957,17 @@ func buildStringPropertyComparisonPredicate(propertyLookup *pgsql.BinaryExpressi )) } - nonStringTypeCheck := pgsql.NewBinaryExpression( - jsonbTypeof(jsonFieldPropertyLookup(propertyLookup)), - pgsql.OperatorCypherNotEquals, - pgsql.NewLiteral("string", pgsql.Text), - ) - nonStringComparison := pgsql.NewBinaryExpression( - jsonFieldPropertyLookup(propertyLookup), - pgsql.OperatorCypherNotEquals, - toJSONBTextOperand(textOperand), + var ( + nonStringTypeCheck = pgsql.NewBinaryExpression( + jsonbTypeof(jsonFieldPropertyLookup(propertyLookup)), + pgsql.OperatorCypherNotEquals, + pgsql.NewLiteral("string", pgsql.Text), + ) + nonStringComparison = pgsql.NewBinaryExpression( + jsonFieldPropertyLookup(propertyLookup), + pgsql.OperatorCypherNotEquals, + toJSONBTextOperand(textOperand), + ) ) return pgsql.NewParenthetical(pgsql.NewBinaryExpression( @@ -978,20 +982,22 @@ func buildStringPropertyComparisonPredicate(propertyLookup *pgsql.BinaryExpressi } func buildEmptyArrayPropertyComparison(propertyLookup *pgsql.BinaryExpression, negated bool) *pgsql.BinaryExpression { - emptyArrayExpression := pgsql.NewBinaryExpression( - jsonFieldPropertyLookup(propertyLookup), - pgsql.OperatorEquals, - jsonEmptyArrayLiteral(), - ) - nullExpression := pgsql.NewBinaryExpression( - jsonFieldPropertyLookup(propertyLookup), - pgsql.OperatorEquals, - jsonNullLiteral(), - ) - nullTaintExpression := pgsql.NewBinaryExpression( - nullExpression, - pgsql.OperatorAnd, - pgsql.NullLiteral(), + var ( + emptyArrayExpression = pgsql.NewBinaryExpression( + jsonFieldPropertyLookup(propertyLookup), + pgsql.OperatorEquals, + jsonEmptyArrayLiteral(), + ) + nullExpression = pgsql.NewBinaryExpression( + jsonFieldPropertyLookup(propertyLookup), + pgsql.OperatorEquals, + jsonNullLiteral(), + ) + nullTaintExpression = pgsql.NewBinaryExpression( + nullExpression, + pgsql.OperatorAnd, + pgsql.NullLiteral(), + ) ) if negated { diff --git a/cypher/models/pgsql/translate/expression_test.go b/cypher/models/pgsql/translate/expression_test.go index 807821e3..48faddab 100644 --- a/cypher/models/pgsql/translate/expression_test.go +++ b/cypher/models/pgsql/translate/expression_test.go @@ -298,25 +298,27 @@ func TestInferWrappedExpressionType(t *testing.T) { } func TestPropertyLookupEqualityScalarRewrites(t *testing.T) { - propertyLookup := func(property string) *pgsql.BinaryExpression { - return pgsql.NewPropertyLookup( - pgsql.CompoundIdentifier{"n", pgsql.ColumnProperties}, - mustAsLiteral(property), - ) - } - renderComparison := func(t *testing.T, lOperand pgsql.Expression, operator pgsql.Operator, rOperand pgsql.Expression) string { - t.Helper() + var ( + propertyLookup = func(property string) *pgsql.BinaryExpression { + return pgsql.NewPropertyLookup( + pgsql.CompoundIdentifier{"n", pgsql.ColumnProperties}, + mustAsLiteral(property), + ) + } + renderComparison = func(t *testing.T, lOperand pgsql.Expression, operator pgsql.Operator, rOperand pgsql.Expression) string { + t.Helper() - treeTranslator := translate.NewExpressionTreeTranslator(nil) - treeTranslator.PushOperand(lOperand) - treeTranslator.PushOperand(rOperand) - require.NoError(t, treeTranslator.CompleteBinaryExpression(translate.NewScope(), operator)) + treeTranslator := translate.NewExpressionTreeTranslator(nil) + treeTranslator.PushOperand(lOperand) + treeTranslator.PushOperand(rOperand) + require.NoError(t, treeTranslator.CompleteBinaryExpression(translate.NewScope(), operator)) - formatted, err := format.Expression(treeTranslator.PeekOperand(), format.NewOutputBuilder()) - require.NoError(t, err) + formatted, err := format.Expression(treeTranslator.PeekOperand(), format.NewOutputBuilder()) + require.NoError(t, err) - return formatted - } + return formatted + } + ) testCases := []struct { Name string @@ -482,8 +484,10 @@ func TestExpressionTreeTranslator(t *testing.T) { treeTranslator.PopRemainingExpressionsAsUserConstraints() // Pull out the 'a' constraint - aIdentifier := pgsql.AsIdentifierSet("a") - expectedTranslation := "(a.name = 'a' and a.num_a > 1)" + var ( + aIdentifier = pgsql.AsIdentifierSet("a") + expectedTranslation = "(a.name = 'a' and a.num_a > 1)" + ) validateConstraints(t, treeTranslator, aIdentifier, expectedTranslation) // Pull out the 'b' constraint next diff --git a/cypher/models/pgsql/translate/limit_pushdown_test.go b/cypher/models/pgsql/translate/limit_pushdown_test.go index 0bfd6ef9..e7edcb5f 100644 --- a/cypher/models/pgsql/translate/limit_pushdown_test.go +++ b/cypher/models/pgsql/translate/limit_pushdown_test.go @@ -110,11 +110,13 @@ func limitPushdownTestTail(where pgsql.Expression) pgsql.Select { } func TestLimitPushdownTailSourceAllowsUnidirectionalShortestPathEndpointInequality(t *testing.T) { - part := limitPushdownTestPart(pgsql.FunctionUnidirectionalSPHarness) - tailSelect := limitPushdownTestTail(limitPushdownTestEndpointInequality( - limitPushdownTestRootAlias, - limitPushdownTestTerminalAlias, - )) + var ( + part = limitPushdownTestPart(pgsql.FunctionUnidirectionalSPHarness) + tailSelect = limitPushdownTestTail(limitPushdownTestEndpointInequality( + limitPushdownTestRootAlias, + limitPushdownTestTerminalAlias, + )) + ) sourceFrame, canPushDown := limitPushdownTailSource(part, tailSelect) require.True(t, canPushDown) @@ -122,11 +124,13 @@ func TestLimitPushdownTailSourceAllowsUnidirectionalShortestPathEndpointInequali } func TestLimitPushdownTailSourceAllowsReversedUnidirectionalShortestPathEndpointInequality(t *testing.T) { - part := limitPushdownTestPart(pgsql.FunctionUnidirectionalSPHarness) - tailSelect := limitPushdownTestTail(limitPushdownTestEndpointInequality( - limitPushdownTestTerminalAlias, - limitPushdownTestRootAlias, - )) + var ( + part = limitPushdownTestPart(pgsql.FunctionUnidirectionalSPHarness) + tailSelect = limitPushdownTestTail(limitPushdownTestEndpointInequality( + limitPushdownTestTerminalAlias, + limitPushdownTestRootAlias, + )) + ) sourceFrame, canPushDown := limitPushdownTailSource(part, tailSelect) require.True(t, canPushDown) @@ -134,16 +138,18 @@ func TestLimitPushdownTailSourceAllowsReversedUnidirectionalShortestPathEndpoint } func TestLimitPushdownTailSourceBlocksMixedShortestPathWherePredicate(t *testing.T) { - part := limitPushdownTestPart(pgsql.FunctionUnidirectionalSPHarness) - tailSelect := limitPushdownTestTail(pgsql.NewBinaryExpression( - limitPushdownTestEndpointInequality(limitPushdownTestRootAlias, limitPushdownTestTerminalAlias), - pgsql.OperatorAnd, - pgsql.NewBinaryExpression( - limitPushdownTestEndpointRef(limitPushdownTestTerminalAlias), - pgsql.OperatorGreaterThan, - pgsql.NewLiteral(0, pgsql.Int), - ), - )) + var ( + part = limitPushdownTestPart(pgsql.FunctionUnidirectionalSPHarness) + tailSelect = limitPushdownTestTail(pgsql.NewBinaryExpression( + limitPushdownTestEndpointInequality(limitPushdownTestRootAlias, limitPushdownTestTerminalAlias), + pgsql.OperatorAnd, + pgsql.NewBinaryExpression( + limitPushdownTestEndpointRef(limitPushdownTestTerminalAlias), + pgsql.OperatorGreaterThan, + pgsql.NewLiteral(0, pgsql.Int), + ), + )) + ) _, canPushDown := limitPushdownTailSource(part, tailSelect) require.False(t, canPushDown) @@ -209,11 +215,13 @@ func TestLimitPushdownTailSourceAllowsBoundEndpointShortestPathSourceWithoutTail } func TestLimitPushdownTailSourceAllowsBidirectionalShortestPathEndpointInequality(t *testing.T) { - part := limitPushdownTestPart(pgsql.FunctionBidirectionalSPHarness) - tailSelect := limitPushdownTestTail(limitPushdownTestEndpointInequality( - limitPushdownTestRootAlias, - limitPushdownTestTerminalAlias, - )) + var ( + part = limitPushdownTestPart(pgsql.FunctionBidirectionalSPHarness) + tailSelect = limitPushdownTestTail(limitPushdownTestEndpointInequality( + limitPushdownTestRootAlias, + limitPushdownTestTerminalAlias, + )) + ) sourceFrame, canPushDown := limitPushdownTailSource(part, tailSelect) require.True(t, canPushDown) @@ -221,11 +229,13 @@ func TestLimitPushdownTailSourceAllowsBidirectionalShortestPathEndpointInequalit } func TestPushDownShortestPathLimitAppendsHarnessLimitWithEndpointInequality(t *testing.T) { - part := limitPushdownTestPart(pgsql.FunctionUnidirectionalSPHarness) - tailSelect := limitPushdownTestTail(limitPushdownTestEndpointInequality( - limitPushdownTestRootAlias, - limitPushdownTestTerminalAlias, - )) + var ( + part = limitPushdownTestPart(pgsql.FunctionUnidirectionalSPHarness) + tailSelect = limitPushdownTestTail(limitPushdownTestEndpointInequality( + limitPushdownTestRootAlias, + limitPushdownTestTerminalAlias, + )) + ) pushDownShortestPathLimit(part, tailSelect) @@ -246,11 +256,13 @@ func TestPushDownShortestPathLimitAppendsHarnessLimitWithEndpointInequality(t *t } func TestPushDownBidirectionalShortestPathLimitAppendsHarnessLimitWithEndpointInequality(t *testing.T) { - part := limitPushdownTestPart(pgsql.FunctionBidirectionalSPHarness) - tailSelect := limitPushdownTestTail(limitPushdownTestEndpointInequality( - limitPushdownTestRootAlias, - limitPushdownTestTerminalAlias, - )) + var ( + part = limitPushdownTestPart(pgsql.FunctionBidirectionalSPHarness) + tailSelect = limitPushdownTestTail(limitPushdownTestEndpointInequality( + limitPushdownTestRootAlias, + limitPushdownTestTerminalAlias, + )) + ) pushDownShortestPathLimit(part, tailSelect) diff --git a/cypher/models/pgsql/translate/match.go b/cypher/models/pgsql/translate/match.go index f93a5d5f..c49c3dba 100644 --- a/cypher/models/pgsql/translate/match.go +++ b/cypher/models/pgsql/translate/match.go @@ -84,8 +84,10 @@ func (s *Translator) buildOptionalMatchAggregationStep(aggregationFrame *Frame) // 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 + var ( + 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 { @@ -112,8 +114,10 @@ func (s *Translator) buildOptionalMatchAggregationStep(aggregationFrame *Frame) // 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{} + var ( + 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}, diff --git a/cypher/models/pgsql/translate/model.go b/cypher/models/pgsql/translate/model.go index a4090ff9..6bc434cf 100644 --- a/cypher/models/pgsql/translate/model.go +++ b/cypher/models/pgsql/translate/model.go @@ -200,8 +200,10 @@ func hasIDEqualityConstraint(expression pgsql.Expression, identifier pgsql.Ident continue } - leftIsID := isIdentifierIDReference(binaryExpression.LOperand, identifier) - rightIsID := isIdentifierIDReference(binaryExpression.ROperand, identifier) + var ( + leftIsID = isIdentifierIDReference(binaryExpression.LOperand, identifier) + rightIsID = isIdentifierIDReference(binaryExpression.ROperand, identifier) + ) if leftIsID && isStaticIDEqualityOperand(binaryExpression.ROperand) { return true diff --git a/cypher/models/pgsql/translate/optimizer_safety_test.go b/cypher/models/pgsql/translate/optimizer_safety_test.go index aaae2fd5..aa8ecd7e 100644 --- a/cypher/models/pgsql/translate/optimizer_safety_test.go +++ b/cypher/models/pgsql/translate/optimizer_safety_test.go @@ -436,8 +436,10 @@ MATCH (b:EnterpriseCA {name: 'target'}) MATCH p = (a)-[:MemberOf]->(b) RETURN p `) - enterpriseAnchorIndex := strings.Index(normalizedQuery, "array [5]::int2[]") - broadScanIndex := strings.Index(normalizedQuery, "from s0, node n1") + var ( + enterpriseAnchorIndex = strings.Index(normalizedQuery, "array [5]::int2[]") + broadScanIndex = strings.Index(normalizedQuery, "from s0, node n1") + ) require.NotEqual(t, -1, enterpriseAnchorIndex) require.NotEqual(t, -1, broadScanIndex) @@ -605,8 +607,10 @@ LIMIT 100 `) formattedQuery, err := Translated(translation) require.NoError(t, err) - normalizedQuery := strings.Join(strings.Fields(formattedQuery), " ") - lowerQuery := strings.ToLower(normalizedQuery) + var ( + normalizedQuery = strings.Join(strings.Fields(formattedQuery), " ") + lowerQuery = strings.ToLower(normalizedQuery) + ) requirePlannedOptimizationLowering(t, translation.Optimization, "TraversalDirectionSelection") requireNoOptimizationLowering(t, translation.Optimization, "TraversalDirectionSelection") diff --git a/cypher/models/pgsql/translate/predicate.go b/cypher/models/pgsql/translate/predicate.go index f1e8b708..f9f3b38b 100644 --- a/cypher/models/pgsql/translate/predicate.go +++ b/cypher/models/pgsql/translate/predicate.go @@ -41,27 +41,29 @@ func (s *Translator) buildOptimizedRelationshipExistPredicate(part *PatternPart, ) if traversalStep.RightNodeBound { - forward := pgsql.NewBinaryExpression( - pgsql.NewBinaryExpression( - pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnStartID}, - pgsql.OperatorEquals, - pgsql.CompoundIdentifier{traversalStep.LeftNode.Identifier, pgsql.ColumnID}), - pgsql.OperatorAnd, - pgsql.NewBinaryExpression( - pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnEndID}, - pgsql.OperatorEquals, - pgsql.CompoundIdentifier{traversalStep.RightNode.Identifier, pgsql.ColumnID}), - ) - reverse := pgsql.NewBinaryExpression( - pgsql.NewBinaryExpression( - pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnEndID}, - pgsql.OperatorEquals, - pgsql.CompoundIdentifier{traversalStep.LeftNode.Identifier, pgsql.ColumnID}), - pgsql.OperatorAnd, - pgsql.NewBinaryExpression( - pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnStartID}, - pgsql.OperatorEquals, - pgsql.CompoundIdentifier{traversalStep.RightNode.Identifier, pgsql.ColumnID}), + var ( + forward = pgsql.NewBinaryExpression( + pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnStartID}, + pgsql.OperatorEquals, + pgsql.CompoundIdentifier{traversalStep.LeftNode.Identifier, pgsql.ColumnID}), + pgsql.OperatorAnd, + pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnEndID}, + pgsql.OperatorEquals, + pgsql.CompoundIdentifier{traversalStep.RightNode.Identifier, pgsql.ColumnID}), + ) + reverse = pgsql.NewBinaryExpression( + pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnEndID}, + pgsql.OperatorEquals, + pgsql.CompoundIdentifier{traversalStep.LeftNode.Identifier, pgsql.ColumnID}), + pgsql.OperatorAnd, + pgsql.NewBinaryExpression( + pgsql.CompoundIdentifier{traversalStep.Edge.Identifier, pgsql.ColumnStartID}, + pgsql.OperatorEquals, + pgsql.CompoundIdentifier{traversalStep.RightNode.Identifier, pgsql.ColumnID}), + ) ) whereClause = pgsql.NewBinaryExpression(forward, pgsql.OperatorOr, reverse) } diff --git a/cypher/models/pgsql/translate/tracking_test.go b/cypher/models/pgsql/translate/tracking_test.go index cee793fe..3f8a02ca 100644 --- a/cypher/models/pgsql/translate/tracking_test.go +++ b/cypher/models/pgsql/translate/tracking_test.go @@ -30,8 +30,10 @@ func TestScope(t *testing.T) { } func TestScopeLookupDataTypeResolvesAliases(t *testing.T) { - scope := NewScope() - binding := scope.Define(pgsql.Identifier("n0"), pgsql.NodeComposite) + var ( + scope = NewScope() + binding = scope.Define(pgsql.Identifier("n0"), pgsql.NodeComposite) + ) scope.Alias(pgsql.Identifier("n"), binding) dataType, found := scope.LookupDataType(pgsql.Identifier("n")) diff --git a/cypher/models/pgsql/translate/translator.go b/cypher/models/pgsql/translate/translator.go index 3ba3f153..297237a5 100644 --- a/cypher/models/pgsql/translate/translator.go +++ b/cypher/models/pgsql/translate/translator.go @@ -58,8 +58,10 @@ func NewTranslator(ctx context.Context, kindMapper pgsql.KindMapper, parameters inputParameters[key] = value } - translatedParameters := map[string]any{} - ctxAwareKindMapper := newContextAwareKindMapper(ctx, kindMapper, translatedParameters) + var ( + translatedParameters = map[string]any{} + ctxAwareKindMapper = newContextAwareKindMapper(ctx, kindMapper, translatedParameters) + ) return &Translator{ Visitor: walk.NewVisitor[cypher.SyntaxNode](), diff --git a/cypher/models/pgsql/translate/traversal.go b/cypher/models/pgsql/translate/traversal.go index 7db07001..799e0610 100644 --- a/cypher/models/pgsql/translate/traversal.go +++ b/cypher/models/pgsql/translate/traversal.go @@ -776,8 +776,10 @@ func (s *Translator) applyExpansionSuffixPushdown(part *PatternPart) (int, error var applied int for stepIndex := range part.TraversalSteps { - target := part.Target.TraversalStep(stepIndex) - decisions := s.suffixPushdownDecisions[target] + var ( + target = part.Target.TraversalStep(stepIndex) + decisions = s.suffixPushdownDecisions[target] + ) if len(decisions) == 0 { continue } @@ -791,8 +793,10 @@ func (s *Translator) applyExpansionSuffixPushdown(part *PatternPart) (int, error continue } - currentStep := part.TraversalSteps[stepIndex] - suffixSteps := part.TraversalSteps[decision.SuffixStartStep : decision.SuffixEndStep+1] + var ( + currentStep = part.TraversalSteps[stepIndex] + suffixSteps = part.TraversalSteps[decision.SuffixStartStep : decision.SuffixEndStep+1] + ) if candidateApplied, err := applyExpansionSuffixPushdownCandidate(currentStep, suffixSteps); err != nil { return applied, err } else if candidateApplied { diff --git a/drivers/pg/batch.go b/drivers/pg/batch.go index ba420ca3..6fda114b 100644 --- a/drivers/pg/batch.go +++ b/drivers/pg/batch.go @@ -293,8 +293,10 @@ func (s *batch) flushNodeUpsertBatch(updates *sql.NodeUpdateBatch) error { } defer rows.Close() - idsResolved := 0 - seenRows := make([]bool, len(values)) + var ( + idsResolved = 0 + seenRows = make([]bool, len(values)) + ) for rows.Next() { var ( @@ -376,8 +378,10 @@ func cloneNodeUpdate(node *graph.Node) *graph.Node { } func coalesceNodeUpdates(nodes []*graph.Node) []*graph.Node { - coalesced := make([]*graph.Node, 0, len(nodes)) - nodeIndexes := make(map[graph.ID]int, len(nodes)) + var ( + coalesced = make([]*graph.Node, 0, len(nodes)) + nodeIndexes = make(map[graph.ID]int, len(nodes)) + ) for _, node := range nodes { if existingIndex, hasExisting := nodeIndexes[node.ID]; hasExisting { diff --git a/drivers/pg/batch_copy_source_test.go b/drivers/pg/batch_copy_source_test.go index 59f32bd5..44288523 100644 --- a/drivers/pg/batch_copy_source_test.go +++ b/drivers/pg/batch_copy_source_test.go @@ -28,14 +28,16 @@ func TestBatchSliceCopySourceStreamsRows(t *testing.T) { } func TestBatchSliceCopySourceStopsOnEncodeError(t *testing.T) { - expectedErr := errors.New("encode failed") - source := newBatchSliceCopySource(context.Background(), []int{1, 2}, func(_ context.Context, value int) ([]any, error) { - if value == 2 { - return nil, expectedErr - } - - return []any{value}, nil - }) + var ( + expectedErr = errors.New("encode failed") + source = newBatchSliceCopySource(context.Background(), []int{1, 2}, func(_ context.Context, value int) ([]any, error) { + if value == 2 { + return nil, expectedErr + } + + return []any{value}, nil + }) + ) require.True(t, source.Next()) values, err := source.Values() diff --git a/drivers/pg/batch_node_source.go b/drivers/pg/batch_node_source.go index da03a7b4..1d049a88 100644 --- a/drivers/pg/batch_node_source.go +++ b/drivers/pg/batch_node_source.go @@ -61,8 +61,10 @@ func (s *nodeCreateCopySource) Next() bool { return false } - kindIDsText := s.kindIDEncoder.Encode(kindIDs) - propertiesText := string(propertiesJSONB.Bytes) + var ( + kindIDsText = s.kindIDEncoder.Encode(kindIDs) + propertiesText = string(propertiesJSONB.Bytes) + ) if s.includeID { s.row = []any{ diff --git a/drivers/pg/batch_node_source_test.go b/drivers/pg/batch_node_source_test.go index 421f5a44..d4c03406 100644 --- a/drivers/pg/batch_node_source_test.go +++ b/drivers/pg/batch_node_source_test.go @@ -12,13 +12,14 @@ import ( ) func TestNodeCreateCopySourceStreamsNodesWithIDs(t *testing.T) { - ctx := context.Background() - kindMapper := pgutil.NewInMemoryKindMapper() - userKind := graph.StringKind("User") - userKindID := kindMapper.Put(userKind) - - node := graph.NewNode(graph.ID(10), graph.NewProperties().Set("name", "alice"), userKind) - source := newNodeCreateCopySource(ctx, 7, []*graph.Node{node}, kindMapper, true) + var ( + ctx = context.Background() + kindMapper = pgutil.NewInMemoryKindMapper() + userKind = graph.StringKind("User") + userKindID = kindMapper.Put(userKind) + node = graph.NewNode(graph.ID(10), graph.NewProperties().Set("name", "alice"), userKind) + source = newNodeCreateCopySource(ctx, 7, []*graph.Node{node}, kindMapper, true) + ) require.True(t, source.Next()) values, err := source.Values() @@ -40,13 +41,17 @@ func TestNodeCreateCopySourceStreamsNodesWithIDs(t *testing.T) { } func TestNodeCreateCopySourceStreamsNodesWithoutIDs(t *testing.T) { - ctx := context.Background() - kindMapper := pgutil.NewInMemoryKindMapper() - userKind := graph.StringKind("User") + var ( + ctx = context.Background() + kindMapper = pgutil.NewInMemoryKindMapper() + userKind = graph.StringKind("User") + ) kindMapper.Put(userKind) - node := graph.PrepareNode(graph.NewProperties(), userKind) - source := newNodeCreateCopySource(ctx, 7, []*graph.Node{node}, kindMapper, false) + var ( + node = graph.PrepareNode(graph.NewProperties(), userKind) + source = newNodeCreateCopySource(ctx, 7, []*graph.Node{node}, kindMapper, false) + ) require.True(t, source.Next()) values, err := source.Values() diff --git a/drivers/pg/batch_node_update_source_test.go b/drivers/pg/batch_node_update_source_test.go index db8d9440..170aa115 100644 --- a/drivers/pg/batch_node_update_source_test.go +++ b/drivers/pg/batch_node_update_source_test.go @@ -12,12 +12,14 @@ import ( ) func TestNodeUpdateCopySourceStreamsNodes(t *testing.T) { - ctx := context.Background() - kindMapper := pgutil.NewInMemoryKindMapper() - userKind := graph.StringKind("User") - groupKind := graph.StringKind("Group") - userKindID := kindMapper.Put(userKind) - groupKindID := kindMapper.Put(groupKind) + var ( + ctx = context.Background() + kindMapper = pgutil.NewInMemoryKindMapper() + userKind = graph.StringKind("User") + groupKind = graph.StringKind("Group") + userKindID = kindMapper.Put(userKind) + groupKindID = kindMapper.Put(groupKind) + ) properties := graph.NewProperties() properties.Set("name", "alice") diff --git a/drivers/pg/batch_node_upsert_source_test.go b/drivers/pg/batch_node_upsert_source_test.go index 65b7442b..1d61a943 100644 --- a/drivers/pg/batch_node_upsert_source_test.go +++ b/drivers/pg/batch_node_upsert_source_test.go @@ -13,16 +13,17 @@ import ( ) func TestNodeUpsertCopySourceStreamsUpdates(t *testing.T) { - ctx := context.Background() - kindMapper := pgutil.NewInMemoryKindMapper() - userKind := graph.StringKind("User") - userKindID := kindMapper.Put(userKind) - - update := &sql.NodeUpdate{ - IDFuture: sql.NewFuture(graph.ID(0)), - Node: graph.NewNode(0, graph.NewProperties().Set("objectid", "alice"), userKind), - } - source := newNodeUpsertCopySource(ctx, 7, []*sql.NodeUpdate{update}, kindMapper) + var ( + ctx = context.Background() + kindMapper = pgutil.NewInMemoryKindMapper() + userKind = graph.StringKind("User") + userKindID = kindMapper.Put(userKind) + update = &sql.NodeUpdate{ + IDFuture: sql.NewFuture(graph.ID(0)), + Node: graph.NewNode(0, graph.NewProperties().Set("objectid", "alice"), userKind), + } + source = newNodeUpsertCopySource(ctx, 7, []*sql.NodeUpdate{update}, kindMapper) + ) require.True(t, source.Next()) values, err := source.Values() diff --git a/drivers/pg/batch_relationship_source_test.go b/drivers/pg/batch_relationship_source_test.go index 50bfe710..83b07860 100644 --- a/drivers/pg/batch_relationship_source_test.go +++ b/drivers/pg/batch_relationship_source_test.go @@ -11,10 +11,12 @@ import ( ) func TestRelationshipCreateCopySourceStreamsRelationships(t *testing.T) { - ctx := context.Background() - kindMapper := pgutil.NewInMemoryKindMapper() - edgeKind := graph.StringKind("MemberOf") - edgeKindID := kindMapper.Put(edgeKind) + var ( + ctx = context.Background() + kindMapper = pgutil.NewInMemoryKindMapper() + edgeKind = graph.StringKind("MemberOf") + edgeKindID = kindMapper.Put(edgeKind) + ) relationship := graph.NewRelationship( graph.ID(5), diff --git a/drivers/pg/batch_relationship_update_source_test.go b/drivers/pg/batch_relationship_update_source_test.go index f113b870..22a1c00d 100644 --- a/drivers/pg/batch_relationship_update_source_test.go +++ b/drivers/pg/batch_relationship_update_source_test.go @@ -12,23 +12,24 @@ import ( ) func TestRelationshipUpdateCopySourceStreamsUpdates(t *testing.T) { - ctx := context.Background() - kindMapper := pgutil.NewInMemoryKindMapper() - edgeKind := graph.StringKind("MemberOf") - edgeKindID := kindMapper.Put(edgeKind) - - update := &sql.RelationshipUpdate{ - StartID: sql.NewFuture(graph.ID(10)), - EndID: sql.NewFuture(graph.ID(20)), - Relationship: graph.NewRelationship( - 0, - 0, - 0, - graph.NewProperties().Set("objectid", "edge-1"), - edgeKind, - ), - } - source := newRelationshipUpdateCopySource(ctx, 7, []*sql.RelationshipUpdate{update}, kindMapper) + var ( + ctx = context.Background() + kindMapper = pgutil.NewInMemoryKindMapper() + edgeKind = graph.StringKind("MemberOf") + edgeKindID = kindMapper.Put(edgeKind) + update = &sql.RelationshipUpdate{ + StartID: sql.NewFuture(graph.ID(10)), + EndID: sql.NewFuture(graph.ID(20)), + Relationship: graph.NewRelationship( + 0, + 0, + 0, + graph.NewProperties().Set("objectid", "edge-1"), + edgeKind, + ), + } + source = newRelationshipUpdateCopySource(ctx, 7, []*sql.RelationshipUpdate{update}, kindMapper) + ) require.True(t, source.Next()) values, err := source.Values() diff --git a/integration/cypher_template_test.go b/integration/cypher_template_test.go index 208aca43..acdc0412 100644 --- a/integration/cypher_template_test.go +++ b/integration/cypher_template_test.go @@ -82,8 +82,10 @@ func TestCypherTemplates(t *testing.T) { t.Run(family.Name, func(t *testing.T) { for _, variant := range family.Variants { t.Run(variant.Name, func(t *testing.T) { - cypher := renderCypherTemplate(t, family.Template, variant.Vars) - check := parseAssertion(t, variant.Assert) + var ( + cypher = renderCypherTemplate(t, family.Template, variant.Vars) + check = parseAssertion(t, variant.Assert) + ) tc := testCase{ Name: variant.Name, Cypher: cypher, @@ -208,22 +210,24 @@ func runWithTemplateFixture(t *testing.T, ctx context.Context, db graph.Database t.Fatal("template cases must define an inline fixture") } - queryErrorObserved := false - err := db.WriteTransaction(ctx, func(tx graph.Transaction) error { - idMap, err := opengraph.WriteGraphTx(tx, tc.Fixture) - if err != nil { - return fmt.Errorf("creating fixture: %w", err) - } + var ( + queryErrorObserved = false + err = db.WriteTransaction(ctx, func(tx graph.Transaction) error { + idMap, err := opengraph.WriteGraphTx(tx, tc.Fixture) + if err != nil { + return fmt.Errorf("creating fixture: %w", err) + } - result := tx.Query(tc.Cypher, tc.Params) - defer result.Close() - assertion.checkResult(t, result, newAssertionContext(idMap)) - if assertion.expectQueryError { - queryErrorObserved = true - } + result := tx.Query(tc.Cypher, tc.Params) + defer result.Close() + assertion.checkResult(t, result, newAssertionContext(idMap)) + if assertion.expectQueryError { + queryErrorObserved = true + } - return errFixtureRollback - }) + return errFixtureRollback + }) + ) if assertion.expectQueryError && queryErrorObserved && err != nil { return @@ -256,8 +260,10 @@ func runMetamorphicFamily(t *testing.T, ctx context.Context, db graph.Database, var baseline []string for _, query := range family.Queries { - result := tx.Query(query.Cypher, query.Params) - collected := collectResult(t, result) + var ( + result = tx.Query(query.Cypher, query.Params) + collected = collectResult(t, result) + ) result.Close() signature := comparisonSignature(t, collected, assertCtx, family.Compare) diff --git a/integration/cypher_test.go b/integration/cypher_test.go index 11fc4b1a..01563635 100644 --- a/integration/cypher_test.go +++ b/integration/cypher_test.go @@ -285,16 +285,18 @@ var errFixtureRollback = errors.New("fixture rollback") func runReadOnly(t *testing.T, ctx context.Context, db graph.Database, idMap opengraph.IDMap, tc testCase, assertion caseAssertion) { t.Helper() - queryErrorObserved := false - err := db.ReadTransaction(ctx, func(tx graph.Transaction) error { - result := tx.Query(tc.Cypher, tc.Params) - defer result.Close() - assertion.checkResult(t, result, newAssertionContext(idMap)) - if assertion.expectQueryError { - queryErrorObserved = true - } - return nil - }) + var ( + queryErrorObserved = false + err = db.ReadTransaction(ctx, func(tx graph.Transaction) error { + result := tx.Query(tc.Cypher, tc.Params) + defer result.Close() + assertion.checkResult(t, result, newAssertionContext(idMap)) + if assertion.expectQueryError { + queryErrorObserved = true + } + return nil + }) + ) if err != nil { if assertion.expectQueryError && queryErrorObserved { return @@ -309,26 +311,28 @@ func runReadOnly(t *testing.T, ctx context.Context, db graph.Database, idMap ope func runWithFixture(t *testing.T, ctx context.Context, db graph.Database, tc testCase, assertion caseAssertion) { t.Helper() - queryErrorObserved := false - err := db.WriteTransaction(ctx, func(tx graph.Transaction) error { - if err := tx.Nodes().Delete(); err != nil { - return fmt.Errorf("clearing graph before fixture: %w", err) - } + var ( + queryErrorObserved = false + err = db.WriteTransaction(ctx, func(tx graph.Transaction) error { + if err := tx.Nodes().Delete(); err != nil { + return fmt.Errorf("clearing graph before fixture: %w", err) + } - idMap, err := opengraph.WriteGraphTx(tx, tc.Fixture) - if err != nil { - return fmt.Errorf("creating fixture: %w", err) - } + idMap, err := opengraph.WriteGraphTx(tx, tc.Fixture) + if err != nil { + return fmt.Errorf("creating fixture: %w", err) + } - result := tx.Query(tc.Cypher, tc.Params) - defer result.Close() - assertion.checkResult(t, result, newAssertionContext(idMap)) - if assertion.expectQueryError { - queryErrorObserved = true - } + result := tx.Query(tc.Cypher, tc.Params) + defer result.Close() + assertion.checkResult(t, result, newAssertionContext(idMap)) + if assertion.expectQueryError { + queryErrorObserved = true + } - return errFixtureRollback - }) + return errFixtureRollback + }) + ) if assertion.expectQueryError && queryErrorObserved && err != nil { return @@ -815,8 +819,10 @@ func assertNodeListIDs(expected [][]string) resultAssertion { func collectNodeIDs(t *testing.T, result queryResult, ctx assertionContext, unique bool) []string { t.Helper() - ids := make([]string, 0, len(result.rows)) - seen := map[string]bool{} + var ( + ids = make([]string, 0, len(result.rows)) + seen = map[string]bool{} + ) for _, row := range result.rows { for _, rawVal := range row.values { diff --git a/integration/pgsql_aggregate_traversal_plan_test.go b/integration/pgsql_aggregate_traversal_plan_test.go index f59e7c49..92a42cd7 100644 --- a/integration/pgsql_aggregate_traversal_plan_test.go +++ b/integration/pgsql_aggregate_traversal_plan_test.go @@ -171,8 +171,10 @@ func TestPostgreSQLLiveAggregateTraversalCountPlanShape(t *testing.T) { } } - limitMatch := regexp.MustCompile(`(?m)->\s+Limit\b`).FindStringIndex(plan) - sourceMaterializationIndex := strings.LastIndex(plan, "Index Scan using node_") + var ( + limitMatch = regexp.MustCompile(`(?m)->\s+Limit\b`).FindStringIndex(plan) + sourceMaterializationIndex = strings.LastIndex(plan, "Index Scan using node_") + ) if limitMatch == nil || sourceMaterializationIndex < 0 || sourceMaterializationIndex < limitMatch[0] { t.Fatalf("expected source node materialization after top-N limiting, got:\n%s", plan) } diff --git a/integration/pgsql_batch_operation_test.go b/integration/pgsql_batch_operation_test.go index 0970a00e..9e67c55a 100644 --- a/integration/pgsql_batch_operation_test.go +++ b/integration/pgsql_batch_operation_test.go @@ -257,8 +257,10 @@ func TestPostgreSQLBatchOperationNodeUpdateUsesStaging(t *testing.T) { t.Fatalf("failed to create node: %v", err) } - firstUpdate := graph.NewNode(node.ID, graph.NewProperties().Set("status", "first").Set("kept", "yes"), nodeKind) - secondUpdate := graph.NewNode(node.ID, graph.NewProperties().Set("status", "new"), nodeKind) + var ( + firstUpdate = graph.NewNode(node.ID, graph.NewProperties().Set("status", "first").Set("kept", "yes"), nodeKind) + secondUpdate = graph.NewNode(node.ID, graph.NewProperties().Set("status", "new"), nodeKind) + ) secondUpdate.Properties.Delete("removed") secondUpdate.AddKinds(extraKind) @@ -408,8 +410,10 @@ func TestPostgreSQLBatchOperationUpdateByUsesStaging(t *testing.T) { relationship := firstRelationshipByKind(t, ctx, db, edgeKind) requireStringProperty(t, relationship.Properties, "value", "second") - start := fetchNodeByID(t, ctx, db, relationship.StartID) - end := fetchNodeByID(t, ctx, db, relationship.EndID) + var ( + start = fetchNodeByID(t, ctx, db, relationship.StartID) + end = fetchNodeByID(t, ctx, db, relationship.EndID) + ) requireStringProperty(t, start.Properties, "node_key", "start") requireStringProperty(t, end.Properties, "node_key", "end") diff --git a/tools/metrics/internal/metrics/quality.go b/tools/metrics/internal/metrics/quality.go index 3514ddfe..fce8b1d0 100644 --- a/tools/metrics/internal/metrics/quality.go +++ b/tools/metrics/internal/metrics/quality.go @@ -472,8 +472,10 @@ func analyzeBackendEquivalence(results []NamedPath) BackendEquivalenceReport { sort.Strings(sortedKeys) for _, key := range sortedKeys { - statuses := map[string]string{} - missing := false + var ( + statuses = map[string]string{} + missing = false + ) for driverName, tests := range driverTests { status, found := tests[key] if !found { @@ -529,9 +531,11 @@ type goTestEvent struct { } func parseBackendTestResult(result NamedPath) (map[string]string, BackendDriverResult, []QualityFinding) { - summary := BackendDriverResult{Name: result.Name, Path: result.Path} - findings := []QualityFinding{} - tests := map[string]string{} + var ( + summary = BackendDriverResult{Name: result.Name, Path: result.Path} + findings = []QualityFinding{} + tests = map[string]string{} + ) file, err := os.Open(result.Path) if err != nil { @@ -687,8 +691,10 @@ func validateTemplateFile(path string, doc qualityTemplateFile, report *Invarian findings = append(findings, fileFinding("invariants", "high", "template_family_missing_template", path, family.Name+" has no template")) } - placeholders := placeholderNames(family.Template) - variantNames := map[string]struct{}{} + var ( + placeholders = placeholderNames(family.Template) + variantNames = map[string]struct{}{} + ) for _, variant := range family.Variants { contextName := family.Name + "/" + variant.Name if variant.Name == "" { @@ -821,14 +827,16 @@ func discoverFuzzTargets(sourceRoot string) ([]FuzzTarget, []QualityFinding) { continue } - position := fileSet.Position(function.Pos()) - target := FuzzTarget{ - Package: parsedFile.Name.Name, - Name: function.Name.Name, - File: relativePath, - Line: position.Line, - CorpusFiles: countCorpusFiles(filepath.Join(filepath.Dir(path), "testdata", "fuzz", function.Name.Name)), - } + var ( + position = fileSet.Position(function.Pos()) + target = FuzzTarget{ + Package: parsedFile.Name.Name, + Name: function.Name.Name, + File: relativePath, + Line: position.Line, + CorpusFiles: countCorpusFiles(filepath.Join(filepath.Dir(path), "testdata", "fuzz", function.Name.Name)), + } + ) targets = append(targets, target) } @@ -845,8 +853,10 @@ func discoverFuzzTargets(sourceRoot string) ([]FuzzTarget, []QualityFinding) { } sort.SliceStable(targets, func(leftIndex, rightIndex int) bool { - left := targets[leftIndex] - right := targets[rightIndex] + var ( + left = targets[leftIndex] + right = targets[rightIndex] + ) if left.File != right.File { return left.File < right.File } @@ -1046,8 +1056,10 @@ func analyzeBenchmarkDrift(options QualityOptions) BenchmarkDriftReport { return report } - currentByKey := benchmarkResultsByKey(current) - baselineByKey := benchmarkResultsByKey(baseline) + var ( + currentByKey = benchmarkResultsByKey(current) + baselineByKey = benchmarkResultsByKey(baseline) + ) report.Results = len(currentByKey) report.BaselineResults = len(baselineByKey) @@ -1093,14 +1105,16 @@ func (s *BenchmarkDriftReport) compareBenchmarkMetric(key, metric string, baseli return } - delta := (float64(current) - float64(baseline)) / float64(baseline) - record := BenchmarkRegression{ - Key: key, - Metric: metric, - BaselineNanos: baseline, - CurrentNanos: current, - DeltaFraction: delta, - } + var ( + delta = (float64(current) - float64(baseline)) / float64(baseline) + record = BenchmarkRegression{ + Key: key, + Metric: metric, + BaselineNanos: baseline, + CurrentNanos: current, + DeltaFraction: delta, + } + ) if delta > s.RegressionThreshold { s.Regressions = append(s.Regressions, record) } else if delta < -s.RegressionThreshold { @@ -1128,15 +1142,17 @@ func benchmarkResultsByKey(report benchmarkInputReport) map[string]benchmarkInpu } func summarizeQuality(report QualityReport) QualitySummary { - summary := QualitySummary{} - statuses := []string{ - report.SemanticDrift.Status, - report.BackendEquivalence.Status, - report.Invariants.Status, - report.Fuzz.Status, - report.Mutation.Status, - report.BenchmarkDrift.Status, - } + var ( + summary = QualitySummary{} + statuses = []string{ + report.SemanticDrift.Status, + report.BackendEquivalence.Status, + report.Invariants.Status, + report.Fuzz.Status, + report.Mutation.Status, + report.BenchmarkDrift.Status, + } + ) for _, status := range statuses { switch status { @@ -1175,8 +1191,10 @@ func qualityFindings(report QualityReport) []QualityFinding { findings = append(findings, report.BenchmarkDrift.Findings...) sort.SliceStable(findings, func(leftIndex, rightIndex int) bool { - left := findings[leftIndex] - right := findings[rightIndex] + var ( + left = findings[leftIndex] + right = findings[rightIndex] + ) if severityRank(left.Severity) != severityRank(right.Severity) { return severityRank(left.Severity) > severityRank(right.Severity) }