diff --git a/README.md b/README.md index e5689dce..4e74fef2 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Database Abstraction Wrapper for Graph Schemas ![A Corgi Treat](logo_small.png) DAWGS provides tools and query helpers for running property graphs on vanilla PostgreSQL without extra database -plugins. It exposes a backend abstraction for graph queries, with current backend support for PostgreSQL and Neo4j. +plugins. PostgreSQL 18 or newer is required. It exposes a backend abstraction for graph queries, with current backend support for PostgreSQL and Neo4j. The query interface is built around openCypher, including a PostgreSQL SQL translator for environments that do not support Cypher natively. @@ -147,3 +147,25 @@ replace github.com/specterops/dawgs => /path/to/dawgs - `integration/`: backend-equivalent integration suites and fixtures. - `cmd/`: command-line tools for capture, export, and diagnostics. - `tools/`: developer tools such as `dawgrun` and metrics reporting. + +### MERGE support + +CySQL supports node MERGE, relationships between bound endpoints, and complete fixed-length patterns with +`ON CREATE SET`, `ON MATCH SET`, ordinary following `SET`, and named paths. Unchanged matches do not issue an UPDATE. +Match values are evaluated once and preserve their JSON types. MERGE batches property assignments per SET clause and +splits large patches to stay within PostgreSQL's function argument limit while preserving clause evaluation semantics. +It uses relational conflict checks and omits unchanged bound endpoint writes. Named paths can be carried into subsequent MERGE clauses. +Validation runs for every actual input even with no RETURN or LIMIT 0. Queries containing only bound MERGE entities +use a validation anchor that requires INSERT permission on the node table and invokes INSERT statement triggers, while +never inserting a row. Other queries consume the guard through a private DO NOTHING row in an existing entity write. +PostgreSQL 18+ is enforced when connections are opened and transactions are acquired, including supplied pools. + +This first iteration uses one SQL statement. Later clauses do not observe earlier table mutations through scans. +A materialized candidate guard rejects repeated target writes across rows or bindings with an ordered-execution error. +Single-node creations accept distinct inputs only when neither can match the other created value; multiple absent +complete-pattern inputs are rejected. Run rejected inputs as separate commands. Concurrent MERGE statements can raise +uniqueness conflicts. Property-qualified relationships retain the existing endpoint/type uniqueness constraint and +fail if their requested properties conflict with an existing relationship. See +[MERGE semantics and limits](docs/postgresql_translation.md#merge) for the execution contract and validation commands. +The [implementation evidence](docs/merge_implementation.md) describes the delivered pipeline and benchmark results; +[merge_gaps.md](merge_gaps.md) and [merge_gaps_plan.md](merge_gaps_plan.md) preserve the historical analysis and plan. diff --git a/cypher/Cypher Syntax Support.md b/cypher/Cypher Syntax Support.md index 8d1d673f..86944ca5 100644 --- a/cypher/Cypher Syntax Support.md +++ b/cypher/Cypher Syntax Support.md @@ -406,7 +406,6 @@ efforts may be pursued to add support for these language features. * List Comprehensions * Pattern Comprehensions * Existential Subqueries (e.g. exists) -* Merge Statements * Unwind Expressions * Pattern Predicates using Recursive Expansion @@ -484,3 +483,14 @@ return 1 The reference `n` is being projected by the multipart `with` statement but this projection removes the resultset from the original query, allowing for ambiguity to slip into future operations against `n.name` where some values of `n.name` may be `null`. + +### MERGE statements + +On PostgreSQL 18+, CySQL supports node merges, fixed-length complete patterns, bound endpoints, undirected relationships, +named paths, ON CREATE SET, ON MATCH SET, and following SET clauses. Match properties cannot be null. Relationship +types must be singular, ranges are invalid, and a previously bound relationship cannot be redeclared in MERGE. + +This initial implementation uses a shared SQL statement snapshot. Later clauses cannot scan earlier writes; repeated +inputs that depend on earlier creations or updates are rejected with an ordered-execution error. Multiple absent +complete-pattern inputs are also rejected. Relationship uniqueness and native +PostgreSQL concurrent-conflict behavior also apply. See [MERGE semantics](../docs/postgresql_translation.md#merge). diff --git a/cypher/models/pgsql/README.md b/cypher/models/pgsql/README.md index 893a2009..f9e3fb38 100644 --- a/cypher/models/pgsql/README.md +++ b/cypher/models/pgsql/README.md @@ -5,7 +5,7 @@ take openCypher input and output valid PostgreSQL SQL. This model is not intende available SQL dialect features but rather the subset of the dialect required to perform openCypher to PostgreSQL translation. -**Expected PostgreSQL SQL dialect version**: `16.X` +**Expected PostgreSQL SQL dialect version**: `18+` ## Formatting @@ -28,3 +28,7 @@ The `visualization` package contains a PUML digraph formatter for the PgSQL synt ## Test Cases The `test` package contains the test cases used to validate translation. + +`Merge` is a statement and a set expression, so it can be used as a CTE body. It supports returning projections, +`MergeDoNothing`, and optional `SourceQuery` while retaining table sources for existing callers. `FunctionMergeAction` +represents PostgreSQL's `merge_action()`. Empty SQL windows support pipeline row numbering. diff --git a/cypher/models/pgsql/format/format.go b/cypher/models/pgsql/format/format.go index ce4bd211..2325b918 100644 --- a/cypher/models/pgsql/format/format.go +++ b/cypher/models/pgsql/format/format.go @@ -363,10 +363,18 @@ func formatNode(builder *OutputBuilder, rootExpr pgsql.SyntaxNode) error { exprStack = append(exprStack, *typedNextExpr) case pgsql.FunctionCall: + if typedNextExpr.Over != nil { + if len(typedNextExpr.Over.PartitionBy) > 0 || len(typedNextExpr.Over.OrderBy) > 0 || typedNextExpr.Over.WindowFrame != nil { + return fmt.Errorf("only empty SQL windows are supported") + } + } if typedNextExpr.CastType.IsKnown() { exprStack = append(exprStack, typedNextExpr.CastType, pgsql.FormattingLiteral("::")) } + if typedNextExpr.Over != nil { + exprStack = append(exprStack, pgsql.FormattingLiteral(" over ()")) + } if !typedNextExpr.Bare { exprStack = append(exprStack, pgsql.FormattingLiteral(")")) } @@ -776,6 +784,12 @@ func formatSelect(builder *OutputBuilder, selectStmt pgsql.Select) error { } } + if selectStmt.Having != nil { + builder.Write(" having ") + if err := formatNode(builder, selectStmt.Having); err != nil { + return err + } + } return nil } @@ -998,6 +1012,8 @@ func formatSetExpression(builder *OutputBuilder, expression pgsql.SetExpression) case pgsql.Update: return formatUpdateStatement(builder, typedSetExpression) + case pgsql.Merge: + return formatMergeStatement(builder, typedSetExpression) default: return fmt.Errorf("unsupported set expression type %T", expression) @@ -1019,7 +1035,14 @@ func formatMergeStatement(builder *OutputBuilder, merge pgsql.Merge) error { builder.Write(" using ") - if err := formatNode(builder, merge.Source); err != nil { + if merge.SourceQuery != nil { + if err := formatNode(builder, *merge.SourceQuery); err != nil { + return err + } + if merge.Source.Binding.Set { + builder.Write(" as ", merge.Source.Binding.Value) + } + } else if err := formatNode(builder, merge.Source); err != nil { return err } @@ -1039,6 +1062,18 @@ func formatMergeStatement(builder *OutputBuilder, merge pgsql.Merge) error { builder.Write("when ") switch typedMergeAction := mergeAction.(type) { + case pgsql.MergeDoNothing: + if !typedMergeAction.Matched { + builder.Write("not ") + } + builder.Write("matched") + if typedMergeAction.Predicate != nil { + builder.Write(" and ") + if err := formatNode(builder, typedMergeAction.Predicate); err != nil { + return err + } + } + builder.Write(" then do nothing") case pgsql.MatchedUpdate: builder.Write("matched") @@ -1112,6 +1147,18 @@ func formatMergeStatement(builder *OutputBuilder, merge pgsql.Merge) error { } } + if len(merge.Returning) > 0 { + builder.Write(" returning ") + for idx, item := range merge.Returning { + if idx > 0 { + builder.Write(", ") + } + if err := formatNode(builder, item); err != nil { + return err + } + } + return nil + } return nil } diff --git a/cypher/models/pgsql/format/format_test.go b/cypher/models/pgsql/format/format_test.go index 0bc27054..e884c563 100644 --- a/cypher/models/pgsql/format/format_test.go +++ b/cypher/models/pgsql/format/format_test.go @@ -850,3 +850,17 @@ func TestFormat_NonMaterializedStringLiteralRemainsExtracted(t *testing.T) { require.Equal(t, "@__strlit0::text", formatted.Statement) requireExtractedStringLiteral(t, formatted, value) } + +func TestFormatSelectHaving(t *testing.T) { + for _, grouped := range []bool{false, true} { + query := pgsql.Select{Projection: pgsql.Projection{pgsql.NewLiteral(1, pgsql.Int4)}, Having: pgsql.NewBinaryExpression(pgsql.FunctionCall{Function: "count", Parameters: []pgsql.Expression{pgsql.WildcardIdentifier}}, pgsql.OperatorGreaterThan, pgsql.NewLiteral(1, pgsql.Int4))} + expected := "select 1 having count(*) > 1" + if grouped { + query.GroupBy = []pgsql.Expression{pgsql.Identifier("key")} + expected = "select 1 group by key having count(*) > 1" + } + formatted, err := format.Expression(query, format.NewOutputBuilder()) + require.NoError(t, err) + require.Equal(t, expected, formatted.Statement) + } +} diff --git a/cypher/models/pgsql/format/merge_test.go b/cypher/models/pgsql/format/merge_test.go new file mode 100644 index 00000000..bae4cba7 --- /dev/null +++ b/cypher/models/pgsql/format/merge_test.go @@ -0,0 +1,29 @@ +package format_test + +import ( + "testing" + + "github.com/specterops/dawgs/cypher/models/pgsql" + "github.com/specterops/dawgs/cypher/models/pgsql/format" + "github.com/stretchr/testify/require" +) + +func TestMergeReturningInCTE(t *testing.T) { + source := &pgsql.Subquery{Query: pgsql.Query{Body: pgsql.Select{Projection: pgsql.Projection{&pgsql.AliasedExpression{Expression: pgsql.NewLiteral(1, pgsql.Int4), Alias: pgsql.AsOptionalIdentifier("id")}}}}} + merge := pgsql.Merge{Into: true, Table: pgsql.TableReference{Name: pgsql.Identifier("target").AsCompoundIdentifier(), Binding: pgsql.AsOptionalIdentifier("t")}, SourceQuery: source, Source: pgsql.TableReference{Binding: pgsql.AsOptionalIdentifier("s")}, JoinTarget: pgsql.NewBinaryExpression(pgsql.CompoundIdentifier{"t", "id"}, pgsql.OperatorEquals, pgsql.CompoundIdentifier{"s", "id"}), Actions: []pgsql.MergeAction{pgsql.MergeDoNothing{Matched: true, Predicate: pgsql.NewLiteral(true, pgsql.Boolean)}, pgsql.UnmatchedAction{Columns: []pgsql.Identifier{"id"}, Values: pgsql.Values{Values: []pgsql.Expression{pgsql.CompoundIdentifier{"s", "id"}}}}}, Returning: pgsql.Projection{pgsql.CompoundIdentifier{"s", "id"}, pgsql.FunctionCall{Function: pgsql.FunctionMergeAction}}} + formatted, err := format.Statement(pgsql.Query{CommonTableExpressions: &pgsql.With{Expressions: []pgsql.CommonTableExpression{{Alias: pgsql.TableAlias{Name: "m"}, Query: pgsql.Query{Body: merge}}}}, Body: pgsql.Select{Projection: pgsql.Projection{pgsql.WildcardIdentifier}, From: []pgsql.FromClause{{Source: pgsql.Identifier("m")}}}}, format.NewOutputBuilder()) + require.NoError(t, err) + require.Equal(t, "with m as (merge into target t using (select 1 as id) as s on t.id = s.id when matched and true then do nothing when not matched then insert (id) values (s.id) returning s.id, merge_action()) select * from m;", formatted.Statement) + merge.Actions = []pgsql.MergeAction{pgsql.MergeDoNothing{Matched: false}} + formatted, err = format.Statement(merge, format.NewOutputBuilder()) + require.NoError(t, err) + require.Contains(t, formatted.Statement, "when not matched then do nothing") +} + +func TestEmptyWindowFormatting(t *testing.T) { + formatted, err := format.Expression(pgsql.FunctionCall{Function: "row_number", Over: &pgsql.Window{}, CastType: pgsql.Int8}, format.NewOutputBuilder()) + require.NoError(t, err) + require.Equal(t, "row_number() over ()::int8", formatted.Statement) + _, err = format.Expression(pgsql.FunctionCall{Function: "row_number", Over: &pgsql.Window{PartitionBy: []pgsql.Expression{pgsql.Identifier("id")}}}, format.NewOutputBuilder()) + require.ErrorContains(t, err, "only empty SQL windows") +} diff --git a/cypher/models/pgsql/functions.go b/cypher/models/pgsql/functions.go index 3d0b5f4e..4f572e88 100644 --- a/cypher/models/pgsql/functions.go +++ b/cypher/models/pgsql/functions.go @@ -38,6 +38,7 @@ const ( FunctionReplace Identifier = "replace" FunctionUnnest Identifier = "unnest" FunctionNextValue Identifier = "nextval" + FunctionMergeAction Identifier = "merge_action" FunctionPGGetSerialSequence Identifier = "pg_get_serial_sequence" FunctionJSONBSet Identifier = "jsonb_set" FunctionCount Identifier = "count" diff --git a/cypher/models/pgsql/model.go b/cypher/models/pgsql/model.go index cb65678e..9a17d9e5 100644 --- a/cypher/models/pgsql/model.go +++ b/cypher/models/pgsql/model.go @@ -985,6 +985,9 @@ type Merge struct { Source TableReference JoinTarget Expression Actions []MergeAction + Returning Projection + // SourceQuery permits a query source while retaining Source for existing callers. + SourceQuery *Subquery } func (s Merge) NodeType() string { @@ -995,6 +998,20 @@ func (s Merge) AsStatement() Statement { return s } +func (s Merge) AsExpression() Expression { return s } +func (s Merge) AsSetExpression() SetExpression { return s } + +// MergeDoNothing preserves a matched or unmatched row without writing it. +// PostgreSQL does not emit a RETURNING row for this action. +type MergeDoNothing struct { + Matched bool + Predicate Expression +} + +func (s MergeDoNothing) NodeType() string { return "merge_do_nothing" } +func (s MergeDoNothing) AsExpression() Expression { return s } +func (s MergeDoNothing) AsMergeAction() MergeAction { return s } + type ConflictTarget struct { Columns []Expression Constraint CompoundIdentifier diff --git a/cypher/models/pgsql/test/translation_cases/merge.sql b/cypher/models/pgsql/test/translation_cases/merge.sql new file mode 100644 index 00000000..37316a68 --- /dev/null +++ b/cypher/models/pgsql/test/translation_cases/merge.sql @@ -0,0 +1,96 @@ +-- 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 + +-- case: merge (n:NodeKind1 {name: 'a'}) return n +-- pgsql_params:{"__strlit0":"a","__strlit1":"string","__strlit2":"node","__strlit3":"id","__strlit4":"name"} +with s0 as materialized (select cypher_merge_value(to_jsonb((@__strlit0::text)::text)::jsonb)::jsonb as i1, row_number() over ()::int8 as i2), s1 as (select s0.i1 as i1, s0.i2 as i2, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from s0, node n0 where n0.graph_id = 0 and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (jsonb_typeof((n0.properties -> E'name')) = @__strlit1::text and (n0.properties ->> E'name') = s0.i1 #>> array []::text[])), s2 as (select s0.i1 as i1, s0.i2 as i2 from s0 where not exists (select 1 from s1 where s1.i2 = s0.i2)), s3 as (select s2.i1 as i1, s2.i2 as i2, (nextval(pg_get_serial_sequence(@__strlit2::text, @__strlit3::text))::int8, array [1]::int2[], jsonb_build_object(@__strlit4::text, s2.i1)::jsonb)::nodecomposite as n0 from s2), s4 as (select s1.i1 as i1, s1.i2 as i2, s1.n0 as n0, false as i3 from s1 union all select s3.i1 as i1, s3.i2 as i2, s3.n0 as n0, true as i3 from s3), s5 as (select s4.i1 as i1, s4.i2 as i2, s4.i3 as i3, s4.n0 as n0, row_number() over ()::int8 as i4 from s4), s6 as materialized (select cypher_merge_assert((select coalesce(bool_and(s0.i1 is not null)::bool, true)::bool from s0), false, false, false)::bool as _merge_valid), s7 as (select s5.i1 as i1, s5.i2 as i2, s5.i3 as i3, s5.i4 as i4, s5.n0 as n0 from s5, s6 where s6._merge_valid), s8 as (merge into node _merge_target using (select _merge_source.n0 as n0, _merge_source.i4 as i4, _merge_source.i3 as i3, false as _merge_anchor from s7 _merge_source where _merge_source.i3 union all select null as n0, null as i4, false as i3, s6._merge_valid as _merge_anchor from s6) as _merge_source on _merge_target.id = (_merge_source.n0).id and _merge_target.graph_id = 0 when not matched and _merge_source._merge_anchor then do nothing when matched then do nothing when not matched then insert (graph_id, id, kind_ids, properties) values (0, (_merge_source.n0).id, (_merge_source.n0).kind_ids, (_merge_source.n0).properties) returning _merge_source.i4 as i4, (_merge_target.id, _merge_target.kind_ids, _merge_target.properties)::nodecomposite as n0), s9 as (select s7.i2 as i2, s7.i3 as i3, s7.i4 as i4, coalesce(s8.n0, s7.n0)::nodecomposite as n0 from s7 left outer join s8 on s8.i4 = s7.i4) select s9.n0 as n from s9; + +-- case: merge (n:NodeKind1 {name: $name}) on create set n.score = 1 on match set n.score = 2 set n.done = true return n +-- cypher_params: {"name":"a"} +-- pgsql_params:{"__strlit0":"string","__strlit1":"node","__strlit2":"id","__strlit3":"name","__strlit4":"score","__strlit5":"done","pi0":"a"} +with s0 as materialized (select cypher_merge_value(to_jsonb((@pi0::text)::text)::jsonb)::jsonb as i1, row_number() over ()::int8 as i2), s1 as (select s0.i1 as i1, s0.i2 as i2, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from s0, node n0 where n0.graph_id = 0 and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (jsonb_typeof((n0.properties -> E'name')) = @__strlit0::text and (n0.properties ->> E'name') = s0.i1 #>> array []::text[])), s2 as (select s0.i1 as i1, s0.i2 as i2 from s0 where not exists (select 1 from s1 where s1.i2 = s0.i2)), s3 as (select s2.i1 as i1, s2.i2 as i2, (nextval(pg_get_serial_sequence(@__strlit1::text, @__strlit2::text))::int8, array [1]::int2[], jsonb_build_object(@__strlit3::text, s2.i1)::jsonb)::nodecomposite as n0 from s2), s4 as (select s1.i1 as i1, s1.i2 as i2, s1.n0 as n0, false as i3 from s1 union all select s3.i1 as i1, s3.i2 as i2, s3.n0 as n0, true as i3 from s3), s5 as (select s4.i1 as i1, s4.i2 as i2, s4.i3 as i3, s4.n0 as n0, row_number() over ()::int8 as i4 from s4), s6 as materialized (select s5.i1 as i1, s5.i2 as i2, s5.i3 as i3, s5.i4 as i4, s5.n0 as n0, case when s5.i3 then jsonb_build_object(@__strlit4::text, to_jsonb(1)::jsonb)::jsonb else jsonb_build_object()::jsonb end as i5 from s5), s7 as materialized (select s6.i1 as i1, s6.i2 as i2, s6.i3 as i3, s6.i4 as i4, case when s6.i3 then ((s6.n0).id, (s6.n0).kind_ids, cypher_apply_property_patch((s6.n0).properties, s6.i5)::jsonb)::nodecomposite else s6.n0 end as n0 from s6), s8 as materialized (select s7.i1 as i1, s7.i2 as i2, s7.i3 as i3, s7.i4 as i4, s7.n0 as n0, case when not s7.i3 then jsonb_build_object(@__strlit4::text, to_jsonb(2)::jsonb)::jsonb else jsonb_build_object()::jsonb end as i6 from s7), s9 as materialized (select s8.i1 as i1, s8.i2 as i2, s8.i3 as i3, s8.i4 as i4, case when not s8.i3 then ((s8.n0).id, (s8.n0).kind_ids, cypher_apply_property_patch((s8.n0).properties, s8.i6)::jsonb)::nodecomposite else s8.n0 end as n0 from s8), s10 as materialized (select s9.i1 as i1, s9.i2 as i2, s9.i3 as i3, s9.i4 as i4, s9.n0 as n0, case when true then jsonb_build_object(@__strlit5::text, to_jsonb(true)::jsonb)::jsonb else jsonb_build_object()::jsonb end as i7 from s9), s11 as materialized (select s10.i1 as i1, s10.i2 as i2, s10.i3 as i3, s10.i4 as i4, case when true then ((s10.n0).id, (s10.n0).kind_ids, cypher_apply_property_patch((s10.n0).properties, s10.i7)::jsonb)::nodecomposite else s10.n0 end as n0 from s10), s12 as materialized (select cypher_merge_assert((select coalesce(bool_and(s0.i1 is not null)::bool, true)::bool from s0), false, false, false)::bool as _merge_valid), s13 as (select s11.i1 as i1, s11.i2 as i2, s11.i3 as i3, s11.i4 as i4, s11.n0 as n0 from s11, s12 where s12._merge_valid), s14 as (merge into node _merge_target using (select _merge_source.n0 as n0, _merge_source.i4 as i4, _merge_source.i3 as i3, false as _merge_anchor from s13 _merge_source where (((_merge_source.i3 or not _merge_source.i3) or true) or _merge_source.i3) union all select null as n0, null as i4, false as i3, s12._merge_valid as _merge_anchor from s12) as _merge_source on _merge_target.id = (_merge_source.n0).id and _merge_target.graph_id = 0 when not matched and _merge_source._merge_anchor then do nothing when matched and ((_merge_source.i3 or not _merge_source.i3) or true) then update set properties = (_merge_source.n0).properties, kind_ids = (_merge_source.n0).kind_ids when matched then do nothing when not matched then insert (graph_id, id, kind_ids, properties) values (0, (_merge_source.n0).id, (_merge_source.n0).kind_ids, (_merge_source.n0).properties) returning _merge_source.i4 as i4, (_merge_target.id, _merge_target.kind_ids, _merge_target.properties)::nodecomposite as n0), s15 as (select s13.i2 as i2, s13.i3 as i3, s13.i4 as i4, coalesce(s14.n0, s13.n0)::nodecomposite as n0 from s13 left outer join s14 on s14.i4 = s13.i4) select s15.n0 as n from s15; + +-- case: match (a:NodeKind1), (b:NodeKind2) merge (a)-[r:EdgeKind1]->(b) return r +-- pgsql_params:{"__strlit0":"edge","__strlit1":"id","__strlit2":"edgecomposite"} +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 materialized (select s1.n0 as n0, s1.n1 as n1, row_number() over ()::int8 as i3 from s1), s3 as (select s2.i3 as i3, s2.n0 as n0, s2.n1 as n1, (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0 from s2, node n0, edge e0, node n1 where n0.graph_id = 0 and n0.id = (s2.n0).id and e0.graph_id = 0 and e0.kind_id = 3 and n0.id = e0.start_id and n1.id = e0.end_id and n1.graph_id = 0 and n1.id = (s2.n1).id), s4 as (select s2.i3 as i3, s2.n0 as n0, s2.n1 as n1 from s2 where not exists (select 1 from s3 where s3.i3 = s2.i3)), s5 as (select s4.i3 as i3, s4.n0 as n0, s4.n1 as n1, (nextval(pg_get_serial_sequence(@__strlit0::text, @__strlit1::text))::int8, (s4.n0).id, (s4.n1).id, 3, jsonb_build_object()::jsonb)::edgecomposite as e0 from s4), s6 as (select s3.e0 as e0, s3.i3 as i3, s3.n0 as n0, s3.n1 as n1, false as i4 from s3 union all select s5.e0 as e0, s5.i3 as i3, s5.n0 as n0, s5.n1 as n1, true as i4 from s5), s7 as (select s6.e0 as e0, s6.i3 as i3, s6.i4 as i4, s6.n0 as n0, s6.n1 as n1, row_number() over ()::int8 as i5 from s6), s8 as (select 0 as graph_id, @__strlit2::text as entity_type, (_merge_source.e0).id as target_id from s7 _merge_source where _merge_source.i4), s9 as (select _merge_source.i3 as input_id from s7 _merge_source where _merge_source.i4), s10 as materialized (select cypher_merge_assert((select coalesce(bool_and(true)::bool, true)::bool from s2), exists (select 1 from s8 group by s8.graph_id, s8.entity_type, s8.target_id having count(1)::int8 > 1), (select count(distinct s9.input_id)::int8 from s9) > 1, false)::bool as _merge_valid), s11 as (select s7.e0 as e0, s7.i3 as i3, s7.i4 as i4, s7.i5 as i5, s7.n0 as n0, s7.n1 as n1 from s7, s10 where s10._merge_valid), s12 as (merge into edge _merge_target using (select _merge_source.e0 as e0, _merge_source.i5 as i5, _merge_source.i4 as i4, false as _merge_anchor from s11 _merge_source where _merge_source.i4 union all select null as e0, null as i5, false as i4, s10._merge_valid as _merge_anchor from s10) as _merge_source on _merge_target.id = (_merge_source.e0).id and _merge_target.graph_id = 0 when not matched and _merge_source._merge_anchor then do nothing when matched then do nothing when not matched then insert (graph_id, id, start_id, end_id, kind_id, properties) values (0, (_merge_source.e0).id, (_merge_source.e0).start_id, (_merge_source.e0).end_id, (_merge_source.e0).kind_id, (_merge_source.e0).properties) returning _merge_source.i5 as i5, (_merge_target.id, _merge_target.start_id, _merge_target.end_id, _merge_target.kind_id, _merge_target.properties)::edgecomposite as e0), s13 as (select coalesce(s12.e0, s11.e0)::edgecomposite as e0, s11.i3 as i3, s11.i4 as i4, s11.i5 as i5, s11.n0 as n0, s11.n1 as n1 from s11 left outer join s12 on s12.i5 = s11.i5) select s13.e0 as r from s13; + +-- case: merge p = (a:NodeKind1 {name: 'a'})-[:EdgeKind1]->(b:NodeKind2 {name: 'b'}) return p +-- pgsql_params:{"__strlit0":"a","__strlit1":"b","__strlit2":"string","__strlit3":"node","__strlit4":"id","__strlit5":"name","__strlit6":"edge","__strlit7":"nodecomposite","__strlit8":"edgecomposite"} +with s0 as materialized (select cypher_merge_value(to_jsonb((@__strlit0::text)::text)::jsonb)::jsonb as i1, cypher_merge_value(to_jsonb((@__strlit1::text)::text)::jsonb)::jsonb as i4, row_number() over ()::int8 as i5), s1 as (select s0.i1 as i1, s0.i4 as i4, s0.i5 as i5, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (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 s0, node n0, edge e0, node n1 where n0.graph_id = 0 and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (jsonb_typeof((n0.properties -> E'name')) = @__strlit2::text and (n0.properties ->> E'name') = s0.i1 #>> array []::text[]) and e0.graph_id = 0 and e0.kind_id = 3 and n0.id = e0.start_id and n1.id = e0.end_id and n1.graph_id = 0 and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and (jsonb_typeof((n1.properties -> E'name')) = @__strlit2::text and (n1.properties ->> E'name') = s0.i4 #>> array []::text[])), s2 as (select s0.i1 as i1, s0.i4 as i4, s0.i5 as i5 from s0 where not exists (select 1 from s1 where s1.i5 = s0.i5)), s3 as (select s2.i1 as i1, s2.i4 as i4, s2.i5 as i5, (nextval(pg_get_serial_sequence(@__strlit3::text, @__strlit4::text))::int8, array [1]::int2[], jsonb_build_object(@__strlit5::text, s2.i1)::jsonb)::nodecomposite as n0 from s2), s4 as (select s3.i1 as i1, s3.i4 as i4, s3.i5 as i5, s3.n0 as n0, (nextval(pg_get_serial_sequence(@__strlit3::text, @__strlit4::text))::int8, array [2]::int2[], jsonb_build_object(@__strlit5::text, s3.i4)::jsonb)::nodecomposite as n1 from s3), s5 as (select s4.i1 as i1, s4.i4 as i4, s4.i5 as i5, s4.n0 as n0, s4.n1 as n1, (nextval(pg_get_serial_sequence(@__strlit6::text, @__strlit4::text))::int8, (s4.n0).id, (s4.n1).id, 3, jsonb_build_object()::jsonb)::edgecomposite as e0 from s4), s6 as (select s1.e0 as e0, s1.i1 as i1, s1.i4 as i4, s1.i5 as i5, s1.n0 as n0, s1.n1 as n1, false as i6 from s1 union all select s5.e0 as e0, s5.i1 as i1, s5.i4 as i4, s5.i5 as i5, s5.n0 as n0, s5.n1 as n1, true as i6 from s5), s7 as (select s6.e0 as e0, s6.i1 as i1, s6.i4 as i4, s6.i5 as i5, s6.i6 as i6, s6.n0 as n0, s6.n1 as n1, row_number() over ()::int8 as i7 from s6), s8 as (select 0 as graph_id, @__strlit7::text as entity_type, (_merge_source.n0).id as target_id from s7 _merge_source where _merge_source.i6 union all select 0 as graph_id, @__strlit8::text as entity_type, (_merge_source.e0).id as target_id from s7 _merge_source where _merge_source.i6 union all select 0 as graph_id, @__strlit7::text as entity_type, (_merge_source.n1).id as target_id from s7 _merge_source where _merge_source.i6), s9 as (select _merge_source.i5 as input_id from s7 _merge_source where _merge_source.i6), s10 as materialized (select cypher_merge_assert((select coalesce(bool_and(s0.i1 is not null and s0.i4 is not null)::bool, true)::bool from s0), exists (select 1 from s8 group by s8.graph_id, s8.entity_type, s8.target_id having count(1)::int8 > 1), (select count(distinct s9.input_id)::int8 from s9) > 1, false)::bool as _merge_valid), s11 as (select s7.e0 as e0, s7.i1 as i1, s7.i4 as i4, s7.i5 as i5, s7.i6 as i6, s7.i7 as i7, s7.n0 as n0, s7.n1 as n1 from s7, s10 where s10._merge_valid), s12 as (merge into node _merge_target using (select _merge_source.n0 as n0, _merge_source.i7 as i7, _merge_source.i6 as i6, false as _merge_anchor from s11 _merge_source where _merge_source.i6 union all select null as n0, null as i7, false as i6, s10._merge_valid as _merge_anchor from s10) as _merge_source on _merge_target.id = (_merge_source.n0).id and _merge_target.graph_id = 0 when not matched and _merge_source._merge_anchor then do nothing when matched then do nothing when not matched then insert (graph_id, id, kind_ids, properties) values (0, (_merge_source.n0).id, (_merge_source.n0).kind_ids, (_merge_source.n0).properties) returning _merge_source.i7 as i7, (_merge_target.id, _merge_target.kind_ids, _merge_target.properties)::nodecomposite as n0), s13 as (merge into edge _merge_target using (select _merge_source.e0 as e0, _merge_source.i7 as i7, _merge_source.i6 as i6 from s11 _merge_source where _merge_source.i6) as _merge_source on _merge_target.id = (_merge_source.e0).id and _merge_target.graph_id = 0 when matched then do nothing when not matched then insert (graph_id, id, start_id, end_id, kind_id, properties) values (0, (_merge_source.e0).id, (_merge_source.e0).start_id, (_merge_source.e0).end_id, (_merge_source.e0).kind_id, (_merge_source.e0).properties) returning _merge_source.i7 as i7, (_merge_target.id, _merge_target.start_id, _merge_target.end_id, _merge_target.kind_id, _merge_target.properties)::edgecomposite as e0), s14 as (merge into node _merge_target using (select _merge_source.n1 as n1, _merge_source.i7 as i7, _merge_source.i6 as i6 from s11 _merge_source where _merge_source.i6) as _merge_source on _merge_target.id = (_merge_source.n1).id and _merge_target.graph_id = 0 when matched then do nothing when not matched then insert (graph_id, id, kind_ids, properties) values (0, (_merge_source.n1).id, (_merge_source.n1).kind_ids, (_merge_source.n1).properties) returning _merge_source.i7 as i7, (_merge_target.id, _merge_target.kind_ids, _merge_target.properties)::nodecomposite as n1), s15 as (select coalesce(s13.e0, s11.e0)::edgecomposite as e0, s11.i5 as i5, s11.i6 as i6, s11.i7 as i7, coalesce(s12.n0, s11.n0)::nodecomposite as n0, coalesce(s14.n1, s11.n1)::nodecomposite as n1 from s11 left outer join s12 on s12.i7 = s11.i7 left outer join s13 on s13.i7 = s11.i7 left outer join s14 on s14.i7 = s11.i7) select case when (s15.n0).id is null or (s15.e0).id is null or (s15.n1).id is null then null else (array [s15.n0, s15.n1]::nodecomposite[], array [s15.e0]::edgecomposite[])::pathcomposite end as p from s15; + +-- case: unwind ['a', 'b'] as name merge (n:NodeKind1 {name: name}) return n +-- pgsql_params:{"__strlit0":"a","__strlit1":"b","__strlit2":"node","__strlit3":"id","__strlit4":"name","__strlit5":"nodecomposite"} +with s1 as materialized (select i0 as i0, cypher_merge_value(to_jsonb(i0)::jsonb)::jsonb as i2, row_number() over ()::int8 as i3 from unnest(array [@__strlit0::text, @__strlit1::text]::text[]) as i0), s2 as (select s1.i0 as i0, s1.i2 as i2, s1.i3 as i3, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from s1, node n0 where n0.graph_id = 0 and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (n0.properties -> E'name') = s1.i2), s3 as (select s1.i0 as i0, s1.i2 as i2, s1.i3 as i3 from s1 where not exists (select 1 from s2 where s2.i3 = s1.i3)), s4 as (select s3.i0 as i0, s3.i2 as i2, s3.i3 as i3, (nextval(pg_get_serial_sequence(@__strlit2::text, @__strlit3::text))::int8, array [1]::int2[], jsonb_build_object(@__strlit4::text, s3.i2)::jsonb)::nodecomposite as n0 from s3), s5 as (select s2.i0 as i0, s2.i2 as i2, s2.i3 as i3, s2.n0 as n0, false as i4 from s2 union all select s4.i0 as i0, s4.i2 as i2, s4.i3 as i3, s4.n0 as n0, true as i4 from s4), s6 as (select s5.i0 as i0, s5.i2 as i2, s5.i3 as i3, s5.i4 as i4, s5.n0 as n0, row_number() over ()::int8 as i5 from s5), s7 as (select 0 as graph_id, @__strlit5::text as entity_type, (_merge_source.n0).id as target_id from s6 _merge_source where _merge_source.i4), s8 as (select _merge_source.i3 as input_id, _merge_source.i2 as original_0 from s6 _merge_source where _merge_source.i4), s9 as materialized (select cypher_merge_assert((select coalesce(bool_and(s1.i2 is not null)::bool, true)::bool from s1), exists (select 1 from s7 group by s7.graph_id, s7.entity_type, s7.target_id having count(1)::int8 > 1), false, exists (select 1 from s8 group by s8.original_0 having count(distinct s8.input_id)::int8 > 1))::bool as _merge_valid), s10 as (select s6.i0 as i0, s6.i2 as i2, s6.i3 as i3, s6.i4 as i4, s6.i5 as i5, s6.n0 as n0 from s6, s9 where s9._merge_valid), s11 as (merge into node _merge_target using (select _merge_source.n0 as n0, _merge_source.i5 as i5, _merge_source.i4 as i4, false as _merge_anchor from s10 _merge_source where _merge_source.i4 union all select null as n0, null as i5, false as i4, s9._merge_valid as _merge_anchor from s9) as _merge_source on _merge_target.id = (_merge_source.n0).id and _merge_target.graph_id = 0 when not matched and _merge_source._merge_anchor then do nothing when matched then do nothing when not matched then insert (graph_id, id, kind_ids, properties) values (0, (_merge_source.n0).id, (_merge_source.n0).kind_ids, (_merge_source.n0).properties) returning _merge_source.i5 as i5, (_merge_target.id, _merge_target.kind_ids, _merge_target.properties)::nodecomposite as n0), s12 as (select s10.i0 as i0, s10.i3 as i3, s10.i4 as i4, s10.i5 as i5, coalesce(s11.n0, s10.n0)::nodecomposite as n0 from s10 left outer join s11 on s11.i5 = s10.i5) select s12.n0 as n from s12; + +-- case: merge (n:NodeKind1) return n limit 0 +-- pgsql_params:{"__strlit0":"node","__strlit1":"id"} +with s0 as materialized (select row_number() over ()::int8 as i1), s1 as (select s0.i1 as i1, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from s0, node n0 where n0.graph_id = 0 and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2 as (select s0.i1 as i1 from s0 where not exists (select 1 from s1 where s1.i1 = s0.i1)), s3 as (select s2.i1 as i1, (nextval(pg_get_serial_sequence(@__strlit0::text, @__strlit1::text))::int8, array [1]::int2[], jsonb_build_object()::jsonb)::nodecomposite as n0 from s2), s4 as (select s1.i1 as i1, s1.n0 as n0, false as i2 from s1 union all select s3.i1 as i1, s3.n0 as n0, true as i2 from s3), s5 as (select s4.i1 as i1, s4.i2 as i2, s4.n0 as n0, row_number() over ()::int8 as i3 from s4), s6 as materialized (select cypher_merge_assert((select coalesce(bool_and(true)::bool, true)::bool from s0), false, false, false)::bool as _merge_valid), s7 as (select s5.i1 as i1, s5.i2 as i2, s5.i3 as i3, s5.n0 as n0 from s5, s6 where s6._merge_valid), s8 as (merge into node _merge_target using (select _merge_source.n0 as n0, _merge_source.i3 as i3, _merge_source.i2 as i2, false as _merge_anchor from s7 _merge_source where _merge_source.i2 union all select null as n0, null as i3, false as i2, s6._merge_valid as _merge_anchor from s6) as _merge_source on _merge_target.id = (_merge_source.n0).id and _merge_target.graph_id = 0 when not matched and _merge_source._merge_anchor then do nothing when matched then do nothing when not matched then insert (graph_id, id, kind_ids, properties) values (0, (_merge_source.n0).id, (_merge_source.n0).kind_ids, (_merge_source.n0).properties) returning _merge_source.i3 as i3, (_merge_target.id, _merge_target.kind_ids, _merge_target.properties)::nodecomposite as n0), s9 as (select s7.i1 as i1, s7.i2 as i2, s7.i3 as i3, coalesce(s8.n0, s7.n0)::nodecomposite as n0 from s7 left outer join s8 on s8.i3 = s7.i3) select s9.n0 as n from s9 limit 0; + +-- case: merge (n:NodeKind1) +-- pgsql_params:{"__strlit0":"node","__strlit1":"id"} +with s0 as materialized (select row_number() over ()::int8 as i1), s1 as (select s0.i1 as i1, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from s0, node n0 where n0.graph_id = 0 and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s2 as (select s0.i1 as i1 from s0 where not exists (select 1 from s1 where s1.i1 = s0.i1)), s3 as (select s2.i1 as i1, (nextval(pg_get_serial_sequence(@__strlit0::text, @__strlit1::text))::int8, array [1]::int2[], jsonb_build_object()::jsonb)::nodecomposite as n0 from s2), s4 as (select s1.i1 as i1, s1.n0 as n0, false as i2 from s1 union all select s3.i1 as i1, s3.n0 as n0, true as i2 from s3), s5 as (select s4.i1 as i1, s4.i2 as i2, s4.n0 as n0, row_number() over ()::int8 as i3 from s4), s6 as materialized (select cypher_merge_assert((select coalesce(bool_and(true)::bool, true)::bool from s0), false, false, false)::bool as _merge_valid), s7 as (select s5.i1 as i1, s5.i2 as i2, s5.i3 as i3, s5.n0 as n0 from s5, s6 where s6._merge_valid), s8 as (merge into node _merge_target using (select _merge_source.n0 as n0, _merge_source.i3 as i3, _merge_source.i2 as i2, false as _merge_anchor from s7 _merge_source where _merge_source.i2 union all select null as n0, null as i3, false as i2, s6._merge_valid as _merge_anchor from s6) as _merge_source on _merge_target.id = (_merge_source.n0).id and _merge_target.graph_id = 0 when not matched and _merge_source._merge_anchor then do nothing when matched then do nothing when not matched then insert (graph_id, id, kind_ids, properties) values (0, (_merge_source.n0).id, (_merge_source.n0).kind_ids, (_merge_source.n0).properties) returning _merge_source.i3 as i3, (_merge_target.id, _merge_target.kind_ids, _merge_target.properties)::nodecomposite as n0), s9 as (select s7.i1 as i1, s7.i2 as i2, s7.i3 as i3, coalesce(s8.n0, s7.n0)::nodecomposite as n0 from s7 left outer join s8 on s8.i3 = s7.i3) select 1 where false; + +-- case: MATCH (a:NodeKind1) MERGE (n:NodeKind2 {score:a.score, active:a.active, values:a.values}) RETURN n +-- pgsql_params:{"__strlit0":"node","__strlit1":"id","__strlit2":"active","__strlit3":"score","__strlit4":"values","__strlit5":"nodecomposite"} +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 materialized (select s0.n0 as n0, cypher_merge_value(to_jsonb(((s0.n0).properties -> E'active'))::jsonb)::jsonb as i1, cypher_merge_value(to_jsonb(((s0.n0).properties -> E'score'))::jsonb)::jsonb as i2, cypher_merge_value(to_jsonb(((s0.n0).properties -> E'values'))::jsonb)::jsonb as i3, row_number() over ()::int8 as i4 from s0), s2 as (select s1.i1 as i1, s1.i2 as i2, s1.i3 as i3, s1.i4 as i4, s1.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1, node n1 where n1.graph_id = 0 and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and (n1.properties -> E'active') = s1.i1 and (n1.properties -> E'score') = s1.i2 and (n1.properties -> E'values') = s1.i3), s3 as (select s1.i1 as i1, s1.i2 as i2, s1.i3 as i3, s1.i4 as i4, s1.n0 as n0 from s1 where not exists (select 1 from s2 where s2.i4 = s1.i4)), s4 as (select s3.i1 as i1, s3.i2 as i2, s3.i3 as i3, s3.i4 as i4, s3.n0 as n0, (nextval(pg_get_serial_sequence(@__strlit0::text, @__strlit1::text))::int8, array [2]::int2[], jsonb_build_object(@__strlit2::text, s3.i1, @__strlit3::text, s3.i2, @__strlit4::text, s3.i3)::jsonb)::nodecomposite as n1 from s3), s5 as (select s2.i1 as i1, s2.i2 as i2, s2.i3 as i3, s2.i4 as i4, s2.n0 as n0, s2.n1 as n1, false as i5 from s2 union all select s4.i1 as i1, s4.i2 as i2, s4.i3 as i3, s4.i4 as i4, s4.n0 as n0, s4.n1 as n1, true as i5 from s4), s6 as (select s5.i1 as i1, s5.i2 as i2, s5.i3 as i3, s5.i4 as i4, s5.i5 as i5, s5.n0 as n0, s5.n1 as n1, row_number() over ()::int8 as i6 from s5), s7 as (select 0 as graph_id, @__strlit5::text as entity_type, (_merge_source.n1).id as target_id from s6 _merge_source where _merge_source.i5), s8 as (select _merge_source.i4 as input_id, _merge_source.i1 as original_0, _merge_source.i2 as original_1, _merge_source.i3 as original_2 from s6 _merge_source where _merge_source.i5), s9 as materialized (select cypher_merge_assert((select coalesce(bool_and(s1.i1 is not null and s1.i2 is not null and s1.i3 is not null)::bool, true)::bool from s1), exists (select 1 from s7 group by s7.graph_id, s7.entity_type, s7.target_id having count(1)::int8 > 1), false, exists (select 1 from s8 group by s8.original_0, s8.original_1, s8.original_2 having count(distinct s8.input_id)::int8 > 1))::bool as _merge_valid), s10 as (select s6.i1 as i1, s6.i2 as i2, s6.i3 as i3, s6.i4 as i4, s6.i5 as i5, s6.i6 as i6, s6.n0 as n0, s6.n1 as n1 from s6, s9 where s9._merge_valid), s11 as (merge into node _merge_target using (select _merge_source.n1 as n1, _merge_source.i6 as i6, _merge_source.i5 as i5, false as _merge_anchor from s10 _merge_source where _merge_source.i5 union all select null as n1, null as i6, false as i5, s9._merge_valid as _merge_anchor from s9) as _merge_source on _merge_target.id = (_merge_source.n1).id and _merge_target.graph_id = 0 when not matched and _merge_source._merge_anchor then do nothing when matched then do nothing when not matched then insert (graph_id, id, kind_ids, properties) values (0, (_merge_source.n1).id, (_merge_source.n1).kind_ids, (_merge_source.n1).properties) returning _merge_source.i6 as i6, (_merge_target.id, _merge_target.kind_ids, _merge_target.properties)::nodecomposite as n1), s12 as (select s10.i4 as i4, s10.i5 as i5, s10.i6 as i6, s10.n0 as n0, coalesce(s11.n1, s10.n1)::nodecomposite as n1 from s10 left outer join s11 on s11.i6 = s10.i6) select s12.n1 as n from s12; + +-- case: MATCH p=(a:NodeKind1)-[:EdgeKind1]->(b:NodeKind2) MERGE (c:NodeKind1 {name:'independent'}) RETURN p +-- pgsql_params:{"__strlit0":"independent","__strlit1":"string","__strlit2":"node","__strlit3":"id","__strlit4":"name","__strlit5":"nodecomposite"} +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 materialized (select s0.e0 as e0, s0.n0 as n0, s0.n1 as n1, 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 pc0, cypher_merge_value(to_jsonb((@__strlit0::text)::text)::jsonb)::jsonb as i1, row_number() over ()::int8 as i2 from s0), s2 as (select s1.e0 as e0, s1.i1 as i1, s1.i2 as i2, s1.n0 as n0, s1.n1 as n1, s1.pc0 as pc0, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s1, node n2 where n2.graph_id = 0 and n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (jsonb_typeof((n2.properties -> E'name')) = @__strlit1::text and (n2.properties ->> E'name') = s1.i1 #>> array []::text[])), s3 as (select s1.e0 as e0, s1.i1 as i1, s1.i2 as i2, s1.n0 as n0, s1.n1 as n1, s1.pc0 as pc0 from s1 where not exists (select 1 from s2 where s2.i2 = s1.i2)), s4 as (select s3.e0 as e0, s3.i1 as i1, s3.i2 as i2, s3.n0 as n0, s3.n1 as n1, s3.pc0 as pc0, (nextval(pg_get_serial_sequence(@__strlit2::text, @__strlit3::text))::int8, array [1]::int2[], jsonb_build_object(@__strlit4::text, s3.i1)::jsonb)::nodecomposite as n2 from s3), s5 as (select s2.e0 as e0, s2.i1 as i1, s2.i2 as i2, s2.n0 as n0, s2.n1 as n1, s2.n2 as n2, s2.pc0 as pc0, false as i3 from s2 union all select s4.e0 as e0, s4.i1 as i1, s4.i2 as i2, s4.n0 as n0, s4.n1 as n1, s4.n2 as n2, s4.pc0 as pc0, true as i3 from s4), s6 as (select s5.e0 as e0, s5.i1 as i1, s5.i2 as i2, s5.i3 as i3, s5.n0 as n0, s5.n1 as n1, s5.n2 as n2, s5.pc0 as pc0, row_number() over ()::int8 as i4 from s5), s7 as (select 0 as graph_id, @__strlit5::text as entity_type, (_merge_source.n2).id as target_id from s6 _merge_source where _merge_source.i3), s8 as (select _merge_source.i2 as input_id, _merge_source.i1 as original_0 from s6 _merge_source where _merge_source.i3), s9 as materialized (select cypher_merge_assert((select coalesce(bool_and(s1.i1 is not null)::bool, true)::bool from s1), exists (select 1 from s7 group by s7.graph_id, s7.entity_type, s7.target_id having count(1)::int8 > 1), false, exists (select 1 from s8 group by s8.original_0 having count(distinct s8.input_id)::int8 > 1))::bool as _merge_valid), s10 as (select s6.e0 as e0, s6.i1 as i1, s6.i2 as i2, s6.i3 as i3, s6.i4 as i4, s6.n0 as n0, s6.n1 as n1, s6.n2 as n2, s6.pc0 as pc0 from s6, s9 where s9._merge_valid), s11 as (merge into node _merge_target using (select _merge_source.n2 as n2, _merge_source.i4 as i4, _merge_source.i3 as i3, false as _merge_anchor from s10 _merge_source where _merge_source.i3 union all select null as n2, null as i4, false as i3, s9._merge_valid as _merge_anchor from s9) as _merge_source on _merge_target.id = (_merge_source.n2).id and _merge_target.graph_id = 0 when not matched and _merge_source._merge_anchor then do nothing when matched then do nothing when not matched then insert (graph_id, id, kind_ids, properties) values (0, (_merge_source.n2).id, (_merge_source.n2).kind_ids, (_merge_source.n2).properties) returning _merge_source.i4 as i4, (_merge_target.id, _merge_target.kind_ids, _merge_target.properties)::nodecomposite as n2), s12 as (select s10.e0 as e0, s10.i2 as i2, s10.i3 as i3, s10.i4 as i4, s10.n0 as n0, s10.n1 as n1, coalesce(s11.n2, s10.n2)::nodecomposite as n2, s10.pc0 as pc0 from s10 left outer join s11 on s11.i4 = s10.i4) select s12.pc0 as p from s12; + +-- case: MERGE p=(a:NodeKind1)-[:EdgeKind1]->(b:NodeKind2) MERGE (c:NodeKind1 {name:'independent'}) RETURN p +-- pgsql_params:{"__strlit0":"node","__strlit1":"id","__strlit2":"edge","__strlit3":"nodecomposite","__strlit4":"edgecomposite","__strlit5":"independent","__strlit6":"string","__strlit7":"name"} +with s0 as materialized (select row_number() over ()::int8 as i3), s1 as (select s0.i3 as i3, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (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 s0, node n0, edge e0, node n1 where n0.graph_id = 0 and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and e0.graph_id = 0 and e0.kind_id = 3 and n0.id = e0.start_id and n1.id = e0.end_id and n1.graph_id = 0 and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s2 as (select s0.i3 as i3 from s0 where not exists (select 1 from s1 where s1.i3 = s0.i3)), s3 as (select s2.i3 as i3, (nextval(pg_get_serial_sequence(@__strlit0::text, @__strlit1::text))::int8, array [1]::int2[], jsonb_build_object()::jsonb)::nodecomposite as n0 from s2), s4 as (select s3.i3 as i3, s3.n0 as n0, (nextval(pg_get_serial_sequence(@__strlit0::text, @__strlit1::text))::int8, array [2]::int2[], jsonb_build_object()::jsonb)::nodecomposite as n1 from s3), s5 as (select s4.i3 as i3, s4.n0 as n0, s4.n1 as n1, (nextval(pg_get_serial_sequence(@__strlit2::text, @__strlit1::text))::int8, (s4.n0).id, (s4.n1).id, 3, jsonb_build_object()::jsonb)::edgecomposite as e0 from s4), s6 as (select s1.e0 as e0, s1.i3 as i3, s1.n0 as n0, s1.n1 as n1, false as i4 from s1 union all select s5.e0 as e0, s5.i3 as i3, s5.n0 as n0, s5.n1 as n1, true as i4 from s5), s7 as (select s6.e0 as e0, s6.i3 as i3, s6.i4 as i4, s6.n0 as n0, s6.n1 as n1, row_number() over ()::int8 as i5 from s6), s8 as (select 0 as graph_id, @__strlit3::text as entity_type, (_merge_source.n0).id as target_id from s7 _merge_source where _merge_source.i4 union all select 0 as graph_id, @__strlit4::text as entity_type, (_merge_source.e0).id as target_id from s7 _merge_source where _merge_source.i4 union all select 0 as graph_id, @__strlit3::text as entity_type, (_merge_source.n1).id as target_id from s7 _merge_source where _merge_source.i4), s9 as (select _merge_source.i3 as input_id from s7 _merge_source where _merge_source.i4), s10 as materialized (select cypher_merge_assert((select coalesce(bool_and(true)::bool, true)::bool from s0), exists (select 1 from s8 group by s8.graph_id, s8.entity_type, s8.target_id having count(1)::int8 > 1), (select count(distinct s9.input_id)::int8 from s9) > 1, false)::bool as _merge_valid), s11 as (select s7.e0 as e0, s7.i3 as i3, s7.i4 as i4, s7.i5 as i5, s7.n0 as n0, s7.n1 as n1 from s7, s10 where s10._merge_valid), s12 as (merge into node _merge_target using (select _merge_source.n0 as n0, _merge_source.i5 as i5, _merge_source.i4 as i4, false as _merge_anchor from s11 _merge_source where _merge_source.i4 union all select null as n0, null as i5, false as i4, s10._merge_valid as _merge_anchor from s10) as _merge_source on _merge_target.id = (_merge_source.n0).id and _merge_target.graph_id = 0 when not matched and _merge_source._merge_anchor then do nothing when matched then do nothing when not matched then insert (graph_id, id, kind_ids, properties) values (0, (_merge_source.n0).id, (_merge_source.n0).kind_ids, (_merge_source.n0).properties) returning _merge_source.i5 as i5, (_merge_target.id, _merge_target.kind_ids, _merge_target.properties)::nodecomposite as n0), s13 as (merge into edge _merge_target using (select _merge_source.e0 as e0, _merge_source.i5 as i5, _merge_source.i4 as i4 from s11 _merge_source where _merge_source.i4) as _merge_source on _merge_target.id = (_merge_source.e0).id and _merge_target.graph_id = 0 when matched then do nothing when not matched then insert (graph_id, id, start_id, end_id, kind_id, properties) values (0, (_merge_source.e0).id, (_merge_source.e0).start_id, (_merge_source.e0).end_id, (_merge_source.e0).kind_id, (_merge_source.e0).properties) returning _merge_source.i5 as i5, (_merge_target.id, _merge_target.start_id, _merge_target.end_id, _merge_target.kind_id, _merge_target.properties)::edgecomposite as e0), s14 as (merge into node _merge_target using (select _merge_source.n1 as n1, _merge_source.i5 as i5, _merge_source.i4 as i4 from s11 _merge_source where _merge_source.i4) as _merge_source on _merge_target.id = (_merge_source.n1).id and _merge_target.graph_id = 0 when matched then do nothing when not matched then insert (graph_id, id, kind_ids, properties) values (0, (_merge_source.n1).id, (_merge_source.n1).kind_ids, (_merge_source.n1).properties) returning _merge_source.i5 as i5, (_merge_target.id, _merge_target.kind_ids, _merge_target.properties)::nodecomposite as n1), s15 as (select coalesce(s13.e0, s11.e0)::edgecomposite as e0, s11.i3 as i3, s11.i4 as i4, s11.i5 as i5, coalesce(s12.n0, s11.n0)::nodecomposite as n0, coalesce(s14.n1, s11.n1)::nodecomposite as n1 from s11 left outer join s12 on s12.i5 = s11.i5 left outer join s13 on s13.i5 = s11.i5 left outer join s14 on s14.i5 = s11.i5), s16 as materialized (select s15.e0 as e0, s15.n0 as n0, s15.n1 as n1, case when (s15.n0).id is null or (s15.e0).id is null or (s15.n1).id is null then null else (array [s15.n0, s15.n1]::nodecomposite[], array [s15.e0]::edgecomposite[])::pathcomposite end as pc0, cypher_merge_value(to_jsonb((@__strlit5::text)::text)::jsonb)::jsonb as i7, row_number() over ()::int8 as i8 from s15), s17 as (select s16.e0 as e0, s16.i7 as i7, s16.i8 as i8, s16.n0 as n0, s16.n1 as n1, s16.pc0 as pc0, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s16, node n2 where n2.graph_id = 0 and n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (jsonb_typeof((n2.properties -> E'name')) = @__strlit6::text and (n2.properties ->> E'name') = s16.i7 #>> array []::text[])), s18 as (select s16.e0 as e0, s16.i7 as i7, s16.i8 as i8, s16.n0 as n0, s16.n1 as n1, s16.pc0 as pc0 from s16 where not exists (select 1 from s17 where s17.i8 = s16.i8)), s19 as (select s18.e0 as e0, s18.i7 as i7, s18.i8 as i8, s18.n0 as n0, s18.n1 as n1, s18.pc0 as pc0, (nextval(pg_get_serial_sequence(@__strlit0::text, @__strlit1::text))::int8, array [1]::int2[], jsonb_build_object(@__strlit7::text, s18.i7)::jsonb)::nodecomposite as n2 from s18), s20 as (select s17.e0 as e0, s17.i7 as i7, s17.i8 as i8, s17.n0 as n0, s17.n1 as n1, s17.n2 as n2, s17.pc0 as pc0, false as i9 from s17 union all select s19.e0 as e0, s19.i7 as i7, s19.i8 as i8, s19.n0 as n0, s19.n1 as n1, s19.n2 as n2, s19.pc0 as pc0, true as i9 from s19), s21 as (select s20.e0 as e0, s20.i7 as i7, s20.i8 as i8, s20.i9 as i9, s20.n0 as n0, s20.n1 as n1, s20.n2 as n2, s20.pc0 as pc0, row_number() over ()::int8 as i10 from s20), s22 as (select 0 as graph_id, @__strlit3::text as entity_type, (_merge_source.n2).id as target_id from s21 _merge_source where _merge_source.i9), s23 as (select _merge_source.i8 as input_id, _merge_source.i7 as original_0 from s21 _merge_source where _merge_source.i9), s24 as materialized (select cypher_merge_assert((select coalesce(bool_and(s16.i7 is not null)::bool, true)::bool from s16), exists (select 1 from s22 group by s22.graph_id, s22.entity_type, s22.target_id having count(1)::int8 > 1), false, exists (select 1 from s23 group by s23.original_0 having count(distinct s23.input_id)::int8 > 1))::bool as _merge_valid), s25 as (select s21.e0 as e0, s21.i10 as i10, s21.i7 as i7, s21.i8 as i8, s21.i9 as i9, s21.n0 as n0, s21.n1 as n1, s21.n2 as n2, s21.pc0 as pc0 from s21, s24 where s24._merge_valid), s26 as (merge into node _merge_target using (select _merge_source.n2 as n2, _merge_source.i10 as i10, _merge_source.i9 as i9, false as _merge_anchor from s25 _merge_source where _merge_source.i9 union all select null as n2, null as i10, false as i9, s24._merge_valid as _merge_anchor from s24) as _merge_source on _merge_target.id = (_merge_source.n2).id and _merge_target.graph_id = 0 when not matched and _merge_source._merge_anchor then do nothing when matched then do nothing when not matched then insert (graph_id, id, kind_ids, properties) values (0, (_merge_source.n2).id, (_merge_source.n2).kind_ids, (_merge_source.n2).properties) returning _merge_source.i10 as i10, (_merge_target.id, _merge_target.kind_ids, _merge_target.properties)::nodecomposite as n2), s27 as (select s25.e0 as e0, s25.i10 as i10, s25.i8 as i8, s25.i9 as i9, s25.n0 as n0, s25.n1 as n1, coalesce(s26.n2, s25.n2)::nodecomposite as n2, s25.pc0 as pc0 from s25 left outer join s26 on s26.i10 = s25.i10) select s27.pc0 as p from s27; + +-- case: merge (a:NodeKind1)-[:EdgeKind1]->(a) on match set a.score = 1 on match set a.score = 2 return a +-- pgsql_params:{"__strlit0":"node","__strlit1":"id","__strlit2":"edge","__strlit3":"score","__strlit4":"nodecomposite","__strlit5":"edgecomposite"} +with s0 as materialized (select row_number() over ()::int8 as i3), s1 as (select s0.i3 as i3, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0 from s0, node n0, edge e0 where n0.graph_id = 0 and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and e0.graph_id = 0 and e0.kind_id = 3 and n0.id = e0.start_id and n0.id = e0.end_id), s2 as (select s0.i3 as i3 from s0 where not exists (select 1 from s1 where s1.i3 = s0.i3)), s3 as (select s2.i3 as i3, (nextval(pg_get_serial_sequence(@__strlit0::text, @__strlit1::text))::int8, array [1]::int2[], jsonb_build_object()::jsonb)::nodecomposite as n0 from s2), s4 as (select s3.i3 as i3, s3.n0 as n0, (nextval(pg_get_serial_sequence(@__strlit2::text, @__strlit1::text))::int8, (s3.n0).id, (s3.n0).id, 3, jsonb_build_object()::jsonb)::edgecomposite as e0 from s3), s5 as (select s1.e0 as e0, s1.i3 as i3, s1.n0 as n0, false as i4 from s1 union all select s4.e0 as e0, s4.i3 as i3, s4.n0 as n0, true as i4 from s4), s6 as (select s5.e0 as e0, s5.i3 as i3, s5.i4 as i4, s5.n0 as n0, row_number() over ()::int8 as i5 from s5), s7 as materialized (select s6.e0 as e0, s6.i3 as i3, s6.i4 as i4, s6.i5 as i5, s6.n0 as n0, case when not s6.i4 then jsonb_build_object(@__strlit3::text, to_jsonb(1)::jsonb)::jsonb else jsonb_build_object()::jsonb end as i6 from s6), s8 as materialized (select s7.e0 as e0, s7.i3 as i3, s7.i4 as i4, s7.i5 as i5, case when not s7.i4 then ((s7.n0).id, (s7.n0).kind_ids, cypher_apply_property_patch((s7.n0).properties, s7.i6)::jsonb)::nodecomposite else s7.n0 end as n0 from s7), s9 as materialized (select s8.e0 as e0, s8.i3 as i3, s8.i4 as i4, s8.i5 as i5, s8.n0 as n0, case when not s8.i4 then jsonb_build_object(@__strlit3::text, to_jsonb(2)::jsonb)::jsonb else jsonb_build_object()::jsonb end as i7 from s8), s10 as materialized (select s9.e0 as e0, s9.i3 as i3, s9.i4 as i4, s9.i5 as i5, case when not s9.i4 then ((s9.n0).id, (s9.n0).kind_ids, cypher_apply_property_patch((s9.n0).properties, s9.i7)::jsonb)::nodecomposite else s9.n0 end as n0 from s9), s11 as (select 0 as graph_id, @__strlit4::text as entity_type, (_merge_source.n0).id as target_id from s10 _merge_source where ((not _merge_source.i4 or not _merge_source.i4) or _merge_source.i4) union all select 0 as graph_id, @__strlit5::text as entity_type, (_merge_source.e0).id as target_id from s10 _merge_source where _merge_source.i4), s12 as (select _merge_source.i3 as input_id from s10 _merge_source where _merge_source.i4), s13 as materialized (select cypher_merge_assert((select coalesce(bool_and(true)::bool, true)::bool from s0), exists (select 1 from s11 group by s11.graph_id, s11.entity_type, s11.target_id having count(1)::int8 > 1), (select count(distinct s12.input_id)::int8 from s12) > 1, false)::bool as _merge_valid), s14 as (select s10.e0 as e0, s10.i3 as i3, s10.i4 as i4, s10.i5 as i5, s10.n0 as n0 from s10, s13 where s13._merge_valid), s15 as (merge into node _merge_target using (select _merge_source.n0 as n0, _merge_source.i5 as i5, _merge_source.i4 as i4, false as _merge_anchor from s14 _merge_source where ((not _merge_source.i4 or not _merge_source.i4) or _merge_source.i4) union all select null as n0, null as i5, false as i4, s13._merge_valid as _merge_anchor from s13) as _merge_source on _merge_target.id = (_merge_source.n0).id and _merge_target.graph_id = 0 when not matched and _merge_source._merge_anchor then do nothing when matched and (not _merge_source.i4 or not _merge_source.i4) then update set properties = (_merge_source.n0).properties, kind_ids = (_merge_source.n0).kind_ids when matched then do nothing when not matched then insert (graph_id, id, kind_ids, properties) values (0, (_merge_source.n0).id, (_merge_source.n0).kind_ids, (_merge_source.n0).properties) returning _merge_source.i5 as i5, (_merge_target.id, _merge_target.kind_ids, _merge_target.properties)::nodecomposite as n0), s16 as (merge into edge _merge_target using (select _merge_source.e0 as e0, _merge_source.i5 as i5, _merge_source.i4 as i4 from s14 _merge_source where _merge_source.i4) as _merge_source on _merge_target.id = (_merge_source.e0).id and _merge_target.graph_id = 0 when matched then do nothing when not matched then insert (graph_id, id, start_id, end_id, kind_id, properties) values (0, (_merge_source.e0).id, (_merge_source.e0).start_id, (_merge_source.e0).end_id, (_merge_source.e0).kind_id, (_merge_source.e0).properties) returning _merge_source.i5 as i5, (_merge_target.id, _merge_target.start_id, _merge_target.end_id, _merge_target.kind_id, _merge_target.properties)::edgecomposite as e0), s17 as (select coalesce(s16.e0, s14.e0)::edgecomposite as e0, s14.i3 as i3, s14.i4 as i4, s14.i5 as i5, coalesce(s15.n0, s14.n0)::nodecomposite as n0 from s14 left outer join s15 on s15.i5 = s14.i5 left outer join s16 on s16.i5 = s14.i5) select s17.n0 as a from s17; + +-- case: MERGE (n:NodeKind1 {name:'a'}) ON CREATE SET n.a=1,n.b=2 SET n.a=n.b,n.b=n.a RETURN n +-- pgsql_params:{"__strlit0":"a","__strlit1":"string","__strlit2":"node","__strlit3":"id","__strlit4":"name","__strlit5":"b"} +with s0 as materialized (select cypher_merge_value(to_jsonb((@__strlit0::text)::text)::jsonb)::jsonb as i1, row_number() over ()::int8 as i2), s1 as (select s0.i1 as i1, s0.i2 as i2, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from s0, node n0 where n0.graph_id = 0 and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (jsonb_typeof((n0.properties -> E'name')) = @__strlit1::text and (n0.properties ->> E'name') = s0.i1 #>> array []::text[])), s2 as (select s0.i1 as i1, s0.i2 as i2 from s0 where not exists (select 1 from s1 where s1.i2 = s0.i2)), s3 as (select s2.i1 as i1, s2.i2 as i2, (nextval(pg_get_serial_sequence(@__strlit2::text, @__strlit3::text))::int8, array [1]::int2[], jsonb_build_object(@__strlit4::text, s2.i1)::jsonb)::nodecomposite as n0 from s2), s4 as (select s1.i1 as i1, s1.i2 as i2, s1.n0 as n0, false as i3 from s1 union all select s3.i1 as i1, s3.i2 as i2, s3.n0 as n0, true as i3 from s3), s5 as (select s4.i1 as i1, s4.i2 as i2, s4.i3 as i3, s4.n0 as n0, row_number() over ()::int8 as i4 from s4), s6 as materialized (select s5.i1 as i1, s5.i2 as i2, s5.i3 as i3, s5.i4 as i4, s5.n0 as n0, case when s5.i3 then jsonb_build_object(@__strlit0::text, to_jsonb(1)::jsonb, @__strlit5::text, to_jsonb(2)::jsonb)::jsonb else jsonb_build_object()::jsonb end as i5 from s5), s7 as materialized (select s6.i1 as i1, s6.i2 as i2, s6.i3 as i3, s6.i4 as i4, case when s6.i3 then ((s6.n0).id, (s6.n0).kind_ids, cypher_apply_property_patch((s6.n0).properties, s6.i5)::jsonb)::nodecomposite else s6.n0 end as n0 from s6), s8 as materialized (select s7.i1 as i1, s7.i2 as i2, s7.i3 as i3, s7.i4 as i4, s7.n0 as n0, case when true then jsonb_build_object(@__strlit0::text, to_jsonb(((s7.n0).properties -> E'b'))::jsonb, @__strlit5::text, to_jsonb(((s7.n0).properties -> E'a'))::jsonb)::jsonb else jsonb_build_object()::jsonb end as i6 from s7), s9 as materialized (select s8.i1 as i1, s8.i2 as i2, s8.i3 as i3, s8.i4 as i4, case when true then ((s8.n0).id, (s8.n0).kind_ids, cypher_apply_property_patch((s8.n0).properties, s8.i6)::jsonb)::nodecomposite else s8.n0 end as n0 from s8), s10 as materialized (select cypher_merge_assert((select coalesce(bool_and(s0.i1 is not null)::bool, true)::bool from s0), false, false, false)::bool as _merge_valid), s11 as (select s9.i1 as i1, s9.i2 as i2, s9.i3 as i3, s9.i4 as i4, s9.n0 as n0 from s9, s10 where s10._merge_valid), s12 as (merge into node _merge_target using (select _merge_source.n0 as n0, _merge_source.i4 as i4, _merge_source.i3 as i3, false as _merge_anchor from s11 _merge_source where ((_merge_source.i3 or true) or _merge_source.i3) union all select null as n0, null as i4, false as i3, s10._merge_valid as _merge_anchor from s10) as _merge_source on _merge_target.id = (_merge_source.n0).id and _merge_target.graph_id = 0 when not matched and _merge_source._merge_anchor then do nothing when matched and (_merge_source.i3 or true) then update set properties = (_merge_source.n0).properties, kind_ids = (_merge_source.n0).kind_ids when matched then do nothing when not matched then insert (graph_id, id, kind_ids, properties) values (0, (_merge_source.n0).id, (_merge_source.n0).kind_ids, (_merge_source.n0).properties) returning _merge_source.i4 as i4, (_merge_target.id, _merge_target.kind_ids, _merge_target.properties)::nodecomposite as n0), s13 as (select s11.i2 as i2, s11.i3 as i3, s11.i4 as i4, coalesce(s12.n0, s11.n0)::nodecomposite as n0 from s11 left outer join s12 on s12.i4 = s11.i4) select s13.n0 as n from s13; + +-- case: MERGE (n:NodeKind1 {name:'a'}) SET n.a=1 SET n.b=n.a RETURN n +-- pgsql_params:{"__strlit0":"a","__strlit1":"string","__strlit2":"node","__strlit3":"id","__strlit4":"name","__strlit5":"b"} +with s0 as materialized (select cypher_merge_value(to_jsonb((@__strlit0::text)::text)::jsonb)::jsonb as i1, row_number() over ()::int8 as i2), s1 as (select s0.i1 as i1, s0.i2 as i2, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from s0, node n0 where n0.graph_id = 0 and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (jsonb_typeof((n0.properties -> E'name')) = @__strlit1::text and (n0.properties ->> E'name') = s0.i1 #>> array []::text[])), s2 as (select s0.i1 as i1, s0.i2 as i2 from s0 where not exists (select 1 from s1 where s1.i2 = s0.i2)), s3 as (select s2.i1 as i1, s2.i2 as i2, (nextval(pg_get_serial_sequence(@__strlit2::text, @__strlit3::text))::int8, array [1]::int2[], jsonb_build_object(@__strlit4::text, s2.i1)::jsonb)::nodecomposite as n0 from s2), s4 as (select s1.i1 as i1, s1.i2 as i2, s1.n0 as n0, false as i3 from s1 union all select s3.i1 as i1, s3.i2 as i2, s3.n0 as n0, true as i3 from s3), s5 as (select s4.i1 as i1, s4.i2 as i2, s4.i3 as i3, s4.n0 as n0, row_number() over ()::int8 as i4 from s4), s6 as materialized (select s5.i1 as i1, s5.i2 as i2, s5.i3 as i3, s5.i4 as i4, s5.n0 as n0, case when true then jsonb_build_object(@__strlit0::text, to_jsonb(1)::jsonb)::jsonb else jsonb_build_object()::jsonb end as i5 from s5), s7 as materialized (select s6.i1 as i1, s6.i2 as i2, s6.i3 as i3, s6.i4 as i4, case when true then ((s6.n0).id, (s6.n0).kind_ids, cypher_apply_property_patch((s6.n0).properties, s6.i5)::jsonb)::nodecomposite else s6.n0 end as n0 from s6), s8 as materialized (select s7.i1 as i1, s7.i2 as i2, s7.i3 as i3, s7.i4 as i4, s7.n0 as n0, case when true then jsonb_build_object(@__strlit5::text, to_jsonb(((s7.n0).properties -> E'a'))::jsonb)::jsonb else jsonb_build_object()::jsonb end as i6 from s7), s9 as materialized (select s8.i1 as i1, s8.i2 as i2, s8.i3 as i3, s8.i4 as i4, case when true then ((s8.n0).id, (s8.n0).kind_ids, cypher_apply_property_patch((s8.n0).properties, s8.i6)::jsonb)::nodecomposite else s8.n0 end as n0 from s8), s10 as materialized (select cypher_merge_assert((select coalesce(bool_and(s0.i1 is not null)::bool, true)::bool from s0), false, false, false)::bool as _merge_valid), s11 as (select s9.i1 as i1, s9.i2 as i2, s9.i3 as i3, s9.i4 as i4, s9.n0 as n0 from s9, s10 where s10._merge_valid), s12 as (merge into node _merge_target using (select _merge_source.n0 as n0, _merge_source.i4 as i4, _merge_source.i3 as i3, false as _merge_anchor from s11 _merge_source where ((true or true) or _merge_source.i3) union all select null as n0, null as i4, false as i3, s10._merge_valid as _merge_anchor from s10) as _merge_source on _merge_target.id = (_merge_source.n0).id and _merge_target.graph_id = 0 when not matched and _merge_source._merge_anchor then do nothing when matched and (true or true) then update set properties = (_merge_source.n0).properties, kind_ids = (_merge_source.n0).kind_ids when matched then do nothing when not matched then insert (graph_id, id, kind_ids, properties) values (0, (_merge_source.n0).id, (_merge_source.n0).kind_ids, (_merge_source.n0).properties) returning _merge_source.i4 as i4, (_merge_target.id, _merge_target.kind_ids, _merge_target.properties)::nodecomposite as n0), s13 as (select s11.i2 as i2, s11.i3 as i3, s11.i4 as i4, coalesce(s12.n0, s11.n0)::nodecomposite as n0 from s11 left outer join s12 on s12.i4 = s11.i4) select s13.n0 as n from s13; + +-- case: MERGE (n:NodeKind1 {name:'a'}) SET n.a=null,n.a=3 RETURN n +-- pgsql_params:{"__strlit0":"a","__strlit1":"string","__strlit2":"node","__strlit3":"id","__strlit4":"name"} +with s0 as materialized (select cypher_merge_value(to_jsonb((@__strlit0::text)::text)::jsonb)::jsonb as i1, row_number() over ()::int8 as i2), s1 as (select s0.i1 as i1, s0.i2 as i2, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from s0, node n0 where n0.graph_id = 0 and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (jsonb_typeof((n0.properties -> E'name')) = @__strlit1::text and (n0.properties ->> E'name') = s0.i1 #>> array []::text[])), s2 as (select s0.i1 as i1, s0.i2 as i2 from s0 where not exists (select 1 from s1 where s1.i2 = s0.i2)), s3 as (select s2.i1 as i1, s2.i2 as i2, (nextval(pg_get_serial_sequence(@__strlit2::text, @__strlit3::text))::int8, array [1]::int2[], jsonb_build_object(@__strlit4::text, s2.i1)::jsonb)::nodecomposite as n0 from s2), s4 as (select s1.i1 as i1, s1.i2 as i2, s1.n0 as n0, false as i3 from s1 union all select s3.i1 as i1, s3.i2 as i2, s3.n0 as n0, true as i3 from s3), s5 as (select s4.i1 as i1, s4.i2 as i2, s4.i3 as i3, s4.n0 as n0, row_number() over ()::int8 as i4 from s4), s6 as materialized (select s5.i1 as i1, s5.i2 as i2, s5.i3 as i3, s5.i4 as i4, s5.n0 as n0, case when true then jsonb_build_object(@__strlit0::text, to_jsonb(3)::jsonb)::jsonb else jsonb_build_object()::jsonb end as i5 from s5), s7 as materialized (select s6.i1 as i1, s6.i2 as i2, s6.i3 as i3, s6.i4 as i4, case when true then ((s6.n0).id, (s6.n0).kind_ids, cypher_apply_property_patch((s6.n0).properties, s6.i5)::jsonb)::nodecomposite else s6.n0 end as n0 from s6), s8 as materialized (select cypher_merge_assert((select coalesce(bool_and(s0.i1 is not null)::bool, true)::bool from s0), false, false, false)::bool as _merge_valid), s9 as (select s7.i1 as i1, s7.i2 as i2, s7.i3 as i3, s7.i4 as i4, s7.n0 as n0 from s7, s8 where s8._merge_valid), s10 as (merge into node _merge_target using (select _merge_source.n0 as n0, _merge_source.i4 as i4, _merge_source.i3 as i3, false as _merge_anchor from s9 _merge_source where (true or _merge_source.i3) union all select null as n0, null as i4, false as i3, s8._merge_valid as _merge_anchor from s8) as _merge_source on _merge_target.id = (_merge_source.n0).id and _merge_target.graph_id = 0 when not matched and _merge_source._merge_anchor then do nothing when matched and true then update set properties = (_merge_source.n0).properties, kind_ids = (_merge_source.n0).kind_ids when matched then do nothing when not matched then insert (graph_id, id, kind_ids, properties) values (0, (_merge_source.n0).id, (_merge_source.n0).kind_ids, (_merge_source.n0).properties) returning _merge_source.i4 as i4, (_merge_target.id, _merge_target.kind_ids, _merge_target.properties)::nodecomposite as n0), s11 as (select s9.i2 as i2, s9.i3 as i3, s9.i4 as i4, coalesce(s10.n0, s9.n0)::nodecomposite as n0 from s9 left outer join s10 on s10.i4 = s9.i4) select s11.n0 as n from s11; + +-- case: MERGE (n:NodeKind1 {name:'a'}) SET n.a=3,n.a=null RETURN n +-- pgsql_params:{"__strlit0":"a","__strlit1":"string","__strlit2":"node","__strlit3":"id","__strlit4":"name"} +with s0 as materialized (select cypher_merge_value(to_jsonb((@__strlit0::text)::text)::jsonb)::jsonb as i1, row_number() over ()::int8 as i2), s1 as (select s0.i1 as i1, s0.i2 as i2, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from s0, node n0 where n0.graph_id = 0 and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (jsonb_typeof((n0.properties -> E'name')) = @__strlit1::text and (n0.properties ->> E'name') = s0.i1 #>> array []::text[])), s2 as (select s0.i1 as i1, s0.i2 as i2 from s0 where not exists (select 1 from s1 where s1.i2 = s0.i2)), s3 as (select s2.i1 as i1, s2.i2 as i2, (nextval(pg_get_serial_sequence(@__strlit2::text, @__strlit3::text))::int8, array [1]::int2[], jsonb_build_object(@__strlit4::text, s2.i1)::jsonb)::nodecomposite as n0 from s2), s4 as (select s1.i1 as i1, s1.i2 as i2, s1.n0 as n0, false as i3 from s1 union all select s3.i1 as i1, s3.i2 as i2, s3.n0 as n0, true as i3 from s3), s5 as (select s4.i1 as i1, s4.i2 as i2, s4.i3 as i3, s4.n0 as n0, row_number() over ()::int8 as i4 from s4), s6 as materialized (select s5.i1 as i1, s5.i2 as i2, s5.i3 as i3, s5.i4 as i4, s5.n0 as n0, case when true then jsonb_build_object(@__strlit0::text, null)::jsonb else jsonb_build_object()::jsonb end as i5 from s5), s7 as materialized (select s6.i1 as i1, s6.i2 as i2, s6.i3 as i3, s6.i4 as i4, case when true then ((s6.n0).id, (s6.n0).kind_ids, cypher_apply_property_patch((s6.n0).properties, s6.i5)::jsonb)::nodecomposite else s6.n0 end as n0 from s6), s8 as materialized (select cypher_merge_assert((select coalesce(bool_and(s0.i1 is not null)::bool, true)::bool from s0), false, false, false)::bool as _merge_valid), s9 as (select s7.i1 as i1, s7.i2 as i2, s7.i3 as i3, s7.i4 as i4, s7.n0 as n0 from s7, s8 where s8._merge_valid), s10 as (merge into node _merge_target using (select _merge_source.n0 as n0, _merge_source.i4 as i4, _merge_source.i3 as i3, false as _merge_anchor from s9 _merge_source where (true or _merge_source.i3) union all select null as n0, null as i4, false as i3, s8._merge_valid as _merge_anchor from s8) as _merge_source on _merge_target.id = (_merge_source.n0).id and _merge_target.graph_id = 0 when not matched and _merge_source._merge_anchor then do nothing when matched and true then update set properties = (_merge_source.n0).properties, kind_ids = (_merge_source.n0).kind_ids when matched then do nothing when not matched then insert (graph_id, id, kind_ids, properties) values (0, (_merge_source.n0).id, (_merge_source.n0).kind_ids, (_merge_source.n0).properties) returning _merge_source.i4 as i4, (_merge_target.id, _merge_target.kind_ids, _merge_target.properties)::nodecomposite as n0), s11 as (select s9.i2 as i2, s9.i3 as i3, s9.i4 as i4, coalesce(s10.n0, s9.n0)::nodecomposite as n0 from s9 left outer join s10 on s10.i4 = s9.i4) select s11.n0 as n from s11; + +-- case: UNWIND ['a','a'] AS name MERGE (n:NodeKind1 {name:name}) ON CREATE SET n.name='away' RETURN n +-- pgsql_params:{"__strlit0":"a","__strlit1":"node","__strlit2":"id","__strlit3":"name","__strlit4":"away","__strlit5":"nodecomposite"} +with s1 as materialized (select i0 as i0, cypher_merge_value(to_jsonb(i0)::jsonb)::jsonb as i2, row_number() over ()::int8 as i3 from unnest(array [@__strlit0::text, @__strlit0::text]::text[]) as i0), s2 as (select s1.i0 as i0, s1.i2 as i2, s1.i3 as i3, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from s1, node n0 where n0.graph_id = 0 and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (n0.properties -> E'name') = s1.i2), s3 as (select s1.i0 as i0, s1.i2 as i2, s1.i3 as i3 from s1 where not exists (select 1 from s2 where s2.i3 = s1.i3)), s4 as (select s3.i0 as i0, s3.i2 as i2, s3.i3 as i3, (nextval(pg_get_serial_sequence(@__strlit1::text, @__strlit2::text))::int8, array [1]::int2[], jsonb_build_object(@__strlit3::text, s3.i2)::jsonb)::nodecomposite as n0 from s3), s5 as (select s2.i0 as i0, s2.i2 as i2, s2.i3 as i3, s2.n0 as n0, false as i4 from s2 union all select s4.i0 as i0, s4.i2 as i2, s4.i3 as i3, s4.n0 as n0, true as i4 from s4), s6 as (select s5.i0 as i0, s5.i2 as i2, s5.i3 as i3, s5.i4 as i4, s5.n0 as n0, row_number() over ()::int8 as i5 from s5), s7 as materialized (select s6.i0 as i0, s6.i2 as i2, s6.i3 as i3, s6.i4 as i4, s6.i5 as i5, s6.n0 as n0, s6.i2 as i7, case when s6.i4 then jsonb_build_object(@__strlit3::text, to_jsonb((@__strlit4::text)::text)::jsonb)::jsonb else jsonb_build_object()::jsonb end as i6 from s6), s8 as materialized (select s7.i0 as i0, s7.i2 as i2, s7.i3 as i3, s7.i4 as i4, s7.i5 as i5, case when s7.i6 ? @__strlit3::text then (s7.i6 -> @__strlit3::text) else s7.i7 end as i7, case when s7.i4 then ((s7.n0).id, (s7.n0).kind_ids, cypher_apply_property_patch((s7.n0).properties, s7.i6)::jsonb)::nodecomposite else s7.n0 end as n0 from s7), s9 as (select 0 as graph_id, @__strlit5::text as entity_type, (_merge_source.n0).id as target_id from s8 _merge_source where (_merge_source.i4 or _merge_source.i4)), s10 as (select _merge_source.i3 as input_id, _merge_source.i2 as original_0, _merge_source.i7 as final_0 from s8 _merge_source where _merge_source.i4), s11 as materialized (select cypher_merge_assert((select coalesce(bool_and(s1.i2 is not null)::bool, true)::bool from s1), exists (select 1 from s9 group by s9.graph_id, s9.entity_type, s9.target_id having count(1)::int8 > 1), false, exists (select 1 from s10 _merge_original, s10 _merge_final where _merge_original.input_id != _merge_final.input_id and _merge_original.original_0 = _merge_final.final_0))::bool as _merge_valid), s12 as (select s8.i0 as i0, s8.i2 as i2, s8.i3 as i3, s8.i4 as i4, s8.i5 as i5, s8.i7 as i7, s8.n0 as n0 from s8, s11 where s11._merge_valid), s13 as (merge into node _merge_target using (select _merge_source.n0 as n0, _merge_source.i5 as i5, _merge_source.i4 as i4, false as _merge_anchor from s12 _merge_source where (_merge_source.i4 or _merge_source.i4) union all select null as n0, null as i5, false as i4, s11._merge_valid as _merge_anchor from s11) as _merge_source on _merge_target.id = (_merge_source.n0).id and _merge_target.graph_id = 0 when not matched and _merge_source._merge_anchor then do nothing when matched and _merge_source.i4 then update set properties = (_merge_source.n0).properties, kind_ids = (_merge_source.n0).kind_ids when matched then do nothing when not matched then insert (graph_id, id, kind_ids, properties) values (0, (_merge_source.n0).id, (_merge_source.n0).kind_ids, (_merge_source.n0).properties) returning _merge_source.i5 as i5, (_merge_target.id, _merge_target.kind_ids, _merge_target.properties)::nodecomposite as n0), s14 as (select s12.i0 as i0, s12.i3 as i3, s12.i4 as i4, s12.i5 as i5, coalesce(s13.n0, s12.n0)::nodecomposite as n0 from s12 left outer join s13 on s13.i5 = s12.i5) select s14.n0 as n from s14; + +-- case: UNWIND [1,2] AS value MERGE (n:NodeKind1 {name:'same',score:value}) RETURN n +-- pgsql_params:{"__strlit0":"same","__strlit1":"string","__strlit2":"node","__strlit3":"id","__strlit4":"name","__strlit5":"score","__strlit6":"nodecomposite"} +with s1 as materialized (select i0 as i0, cypher_merge_value(to_jsonb((@__strlit0::text)::text)::jsonb)::jsonb as i2, cypher_merge_value(to_jsonb(i0)::jsonb)::jsonb as i3, row_number() over ()::int8 as i4 from unnest(array [1, 2]::int8[]) as i0), s2 as (select s1.i0 as i0, s1.i2 as i2, s1.i3 as i3, s1.i4 as i4, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from s1, node n0 where n0.graph_id = 0 and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (jsonb_typeof((n0.properties -> E'name')) = @__strlit1::text and (n0.properties ->> E'name') = s1.i2 #>> array []::text[]) and (n0.properties -> E'score') = s1.i3), s3 as (select s1.i0 as i0, s1.i2 as i2, s1.i3 as i3, s1.i4 as i4 from s1 where not exists (select 1 from s2 where s2.i4 = s1.i4)), s4 as (select s3.i0 as i0, s3.i2 as i2, s3.i3 as i3, s3.i4 as i4, (nextval(pg_get_serial_sequence(@__strlit2::text, @__strlit3::text))::int8, array [1]::int2[], jsonb_build_object(@__strlit4::text, s3.i2, @__strlit5::text, s3.i3)::jsonb)::nodecomposite as n0 from s3), s5 as (select s2.i0 as i0, s2.i2 as i2, s2.i3 as i3, s2.i4 as i4, s2.n0 as n0, false as i5 from s2 union all select s4.i0 as i0, s4.i2 as i2, s4.i3 as i3, s4.i4 as i4, s4.n0 as n0, true as i5 from s4), s6 as (select s5.i0 as i0, s5.i2 as i2, s5.i3 as i3, s5.i4 as i4, s5.i5 as i5, s5.n0 as n0, row_number() over ()::int8 as i6 from s5), s7 as (select 0 as graph_id, @__strlit6::text as entity_type, (_merge_source.n0).id as target_id from s6 _merge_source where _merge_source.i5), s8 as (select _merge_source.i4 as input_id, _merge_source.i2 as original_0, _merge_source.i3 as original_1 from s6 _merge_source where _merge_source.i5), s9 as materialized (select cypher_merge_assert((select coalesce(bool_and(s1.i2 is not null and s1.i3 is not null)::bool, true)::bool from s1), exists (select 1 from s7 group by s7.graph_id, s7.entity_type, s7.target_id having count(1)::int8 > 1), false, exists (select 1 from s8 group by s8.original_0, s8.original_1 having count(distinct s8.input_id)::int8 > 1))::bool as _merge_valid), s10 as (select s6.i0 as i0, s6.i2 as i2, s6.i3 as i3, s6.i4 as i4, s6.i5 as i5, s6.i6 as i6, s6.n0 as n0 from s6, s9 where s9._merge_valid), s11 as (merge into node _merge_target using (select _merge_source.n0 as n0, _merge_source.i6 as i6, _merge_source.i5 as i5, false as _merge_anchor from s10 _merge_source where _merge_source.i5 union all select null as n0, null as i6, false as i5, s9._merge_valid as _merge_anchor from s9) as _merge_source on _merge_target.id = (_merge_source.n0).id and _merge_target.graph_id = 0 when not matched and _merge_source._merge_anchor then do nothing when matched then do nothing when not matched then insert (graph_id, id, kind_ids, properties) values (0, (_merge_source.n0).id, (_merge_source.n0).kind_ids, (_merge_source.n0).properties) returning _merge_source.i6 as i6, (_merge_target.id, _merge_target.kind_ids, _merge_target.properties)::nodecomposite as n0), s12 as (select s10.i0 as i0, s10.i4 as i4, s10.i5 as i5, s10.i6 as i6, coalesce(s11.n0, s10.n0)::nodecomposite as n0 from s10 left outer join s11 on s11.i6 = s10.i6) select s12.n0 as n from s12; + +-- case: merge (n:NodeKind1 {name: 'a'}) on create set n.name = 'b' return * +-- pgsql_params:{"__strlit0":"a","__strlit1":"string","__strlit2":"node","__strlit3":"id","__strlit4":"name","__strlit5":"b"} +with s0 as materialized (select cypher_merge_value(to_jsonb((@__strlit0::text)::text)::jsonb)::jsonb as i1, row_number() over ()::int8 as i2), s1 as (select s0.i1 as i1, s0.i2 as i2, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from s0, node n0 where n0.graph_id = 0 and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (jsonb_typeof((n0.properties -> E'name')) = @__strlit1::text and (n0.properties ->> E'name') = s0.i1 #>> array []::text[])), s2 as (select s0.i1 as i1, s0.i2 as i2 from s0 where not exists (select 1 from s1 where s1.i2 = s0.i2)), s3 as (select s2.i1 as i1, s2.i2 as i2, (nextval(pg_get_serial_sequence(@__strlit2::text, @__strlit3::text))::int8, array [1]::int2[], jsonb_build_object(@__strlit4::text, s2.i1)::jsonb)::nodecomposite as n0 from s2), s4 as (select s1.i1 as i1, s1.i2 as i2, s1.n0 as n0, false as i3 from s1 union all select s3.i1 as i1, s3.i2 as i2, s3.n0 as n0, true as i3 from s3), s5 as (select s4.i1 as i1, s4.i2 as i2, s4.i3 as i3, s4.n0 as n0, row_number() over ()::int8 as i4 from s4), s6 as materialized (select s5.i1 as i1, s5.i2 as i2, s5.i3 as i3, s5.i4 as i4, s5.n0 as n0, s5.i1 as i6, case when s5.i3 then jsonb_build_object(@__strlit4::text, to_jsonb((@__strlit5::text)::text)::jsonb)::jsonb else jsonb_build_object()::jsonb end as i5 from s5), s7 as materialized (select s6.i1 as i1, s6.i2 as i2, s6.i3 as i3, s6.i4 as i4, case when s6.i5 ? @__strlit4::text then (s6.i5 -> @__strlit4::text) else s6.i6 end as i6, case when s6.i3 then ((s6.n0).id, (s6.n0).kind_ids, cypher_apply_property_patch((s6.n0).properties, s6.i5)::jsonb)::nodecomposite else s6.n0 end as n0 from s6), s8 as materialized (select cypher_merge_assert((select coalesce(bool_and(s0.i1 is not null)::bool, true)::bool from s0), false, false, false)::bool as _merge_valid), s9 as (select s7.i1 as i1, s7.i2 as i2, s7.i3 as i3, s7.i4 as i4, s7.i6 as i6, s7.n0 as n0 from s7, s8 where s8._merge_valid), s10 as (merge into node _merge_target using (select _merge_source.n0 as n0, _merge_source.i4 as i4, _merge_source.i3 as i3, false as _merge_anchor from s9 _merge_source where (_merge_source.i3 or _merge_source.i3) union all select null as n0, null as i4, false as i3, s8._merge_valid as _merge_anchor from s8) as _merge_source on _merge_target.id = (_merge_source.n0).id and _merge_target.graph_id = 0 when not matched and _merge_source._merge_anchor then do nothing when matched and _merge_source.i3 then update set properties = (_merge_source.n0).properties, kind_ids = (_merge_source.n0).kind_ids when matched then do nothing when not matched then insert (graph_id, id, kind_ids, properties) values (0, (_merge_source.n0).id, (_merge_source.n0).kind_ids, (_merge_source.n0).properties) returning _merge_source.i4 as i4, (_merge_target.id, _merge_target.kind_ids, _merge_target.properties)::nodecomposite as n0), s11 as (select s9.i2 as i2, s9.i3 as i3, s9.i4 as i4, coalesce(s10.n0, s9.n0)::nodecomposite as n0 from s9 left outer join s10 on s10.i4 = s9.i4) select s11.n0 as n from s11; + +-- case: MERGE (n:NodeKind1 {name:'large',left:1,right:2}) SET n.field0=0,n.field1=1,n.field2=2,n.field3=3,n.field4=4,n.field5=5,n.field6=6,n.field7=7,n.field8=8,n.field9=9,n.field10=10,n.field11=11,n.field12=12,n.field13=13,n.field14=14,n.field15=15,n.field16=16,n.field17=17,n.field18=18,n.field19=19,n.field20=20,n.field21=21,n.field22=22,n.field23=23,n.field24=24,n.field25=25,n.field26=26,n.field27=27,n.field28=28,n.field29=29,n.field30=30,n.field31=31,n.field32=32,n.field33=33,n.field34=34,n.field35=35,n.field36=36,n.field37=37,n.field38=38,n.field39=39,n.field40=40,n.field41=41,n.field42=42,n.field43=43,n.field44=44,n.field45=45,n.field46=46,n.field47=47,n.left=n.right,n.right=n.left RETURN n.field0,n.field1,n.field2,n.field3,n.field4,n.field5,n.field6,n.field7,n.field8,n.field9,n.field10,n.field11,n.field12,n.field13,n.field14,n.field15,n.field16,n.field17,n.field18,n.field19,n.field20,n.field21,n.field22,n.field23,n.field24,n.field25,n.field26,n.field27,n.field28,n.field29,n.field30,n.field31,n.field32,n.field33,n.field34,n.field35,n.field36,n.field37,n.field38,n.field39,n.field40,n.field41,n.field42,n.field43,n.field44,n.field45,n.field46,n.field47,n.left,n.right +-- pgsql_params:{"__strlit0":"large","__strlit1":"string","__strlit10":"field3","__strlit11":"field4","__strlit12":"field5","__strlit13":"field6","__strlit14":"field7","__strlit15":"field8","__strlit16":"field9","__strlit17":"field10","__strlit18":"field11","__strlit19":"field12","__strlit2":"node","__strlit20":"field13","__strlit21":"field14","__strlit22":"field15","__strlit23":"field16","__strlit24":"field17","__strlit25":"field18","__strlit26":"field19","__strlit27":"field20","__strlit28":"field21","__strlit29":"field22","__strlit3":"id","__strlit30":"field23","__strlit31":"field24","__strlit32":"field25","__strlit33":"field26","__strlit34":"field27","__strlit35":"field28","__strlit36":"field29","__strlit37":"field30","__strlit38":"field31","__strlit39":"field32","__strlit4":"left","__strlit40":"field33","__strlit41":"field34","__strlit42":"field35","__strlit43":"field36","__strlit44":"field37","__strlit45":"field38","__strlit46":"field39","__strlit47":"field40","__strlit48":"field41","__strlit49":"field42","__strlit5":"name","__strlit50":"field43","__strlit51":"field44","__strlit52":"field45","__strlit53":"field46","__strlit54":"field47","__strlit6":"right","__strlit7":"field0","__strlit8":"field1","__strlit9":"field2"} +with s0 as materialized (select cypher_merge_value(to_jsonb(1)::jsonb)::jsonb as i1, cypher_merge_value(to_jsonb((@__strlit0::text)::text)::jsonb)::jsonb as i2, cypher_merge_value(to_jsonb(2)::jsonb)::jsonb as i3, row_number() over ()::int8 as i4), s1 as (select s0.i1 as i1, s0.i2 as i2, s0.i3 as i3, s0.i4 as i4, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from s0, node n0 where n0.graph_id = 0 and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (n0.properties -> E'left') = s0.i1 and (jsonb_typeof((n0.properties -> E'name')) = @__strlit1::text and (n0.properties ->> E'name') = s0.i2 #>> array []::text[]) and (n0.properties -> E'right') = s0.i3), s2 as (select s0.i1 as i1, s0.i2 as i2, s0.i3 as i3, s0.i4 as i4 from s0 where not exists (select 1 from s1 where s1.i4 = s0.i4)), s3 as (select s2.i1 as i1, s2.i2 as i2, s2.i3 as i3, s2.i4 as i4, (nextval(pg_get_serial_sequence(@__strlit2::text, @__strlit3::text))::int8, array [1]::int2[], jsonb_build_object(@__strlit4::text, s2.i1, @__strlit5::text, s2.i2, @__strlit6::text, s2.i3)::jsonb)::nodecomposite as n0 from s2), s4 as (select s1.i1 as i1, s1.i2 as i2, s1.i3 as i3, s1.i4 as i4, s1.n0 as n0, false as i5 from s1 union all select s3.i1 as i1, s3.i2 as i2, s3.i3 as i3, s3.i4 as i4, s3.n0 as n0, true as i5 from s3), s5 as (select s4.i1 as i1, s4.i2 as i2, s4.i3 as i3, s4.i4 as i4, s4.i5 as i5, s4.n0 as n0, row_number() over ()::int8 as i6 from s4), s6 as materialized (select s5.i1 as i1, s5.i2 as i2, s5.i3 as i3, s5.i4 as i4, s5.i5 as i5, s5.i6 as i6, s5.n0 as n0, s5.i1 as i8, s5.i2 as i9, s5.i3 as i10, case when true then jsonb_build_object(@__strlit7::text, to_jsonb(0)::jsonb, @__strlit8::text, to_jsonb(1)::jsonb, @__strlit9::text, to_jsonb(2)::jsonb, @__strlit10::text, to_jsonb(3)::jsonb, @__strlit11::text, to_jsonb(4)::jsonb, @__strlit12::text, to_jsonb(5)::jsonb, @__strlit13::text, to_jsonb(6)::jsonb, @__strlit14::text, to_jsonb(7)::jsonb, @__strlit15::text, to_jsonb(8)::jsonb, @__strlit16::text, to_jsonb(9)::jsonb, @__strlit17::text, to_jsonb(10)::jsonb, @__strlit18::text, to_jsonb(11)::jsonb, @__strlit19::text, to_jsonb(12)::jsonb, @__strlit20::text, to_jsonb(13)::jsonb, @__strlit21::text, to_jsonb(14)::jsonb, @__strlit22::text, to_jsonb(15)::jsonb, @__strlit23::text, to_jsonb(16)::jsonb, @__strlit24::text, to_jsonb(17)::jsonb, @__strlit25::text, to_jsonb(18)::jsonb, @__strlit26::text, to_jsonb(19)::jsonb, @__strlit27::text, to_jsonb(20)::jsonb, @__strlit28::text, to_jsonb(21)::jsonb, @__strlit29::text, to_jsonb(22)::jsonb, @__strlit30::text, to_jsonb(23)::jsonb, @__strlit31::text, to_jsonb(24)::jsonb, @__strlit32::text, to_jsonb(25)::jsonb, @__strlit33::text, to_jsonb(26)::jsonb, @__strlit34::text, to_jsonb(27)::jsonb, @__strlit35::text, to_jsonb(28)::jsonb, @__strlit36::text, to_jsonb(29)::jsonb, @__strlit37::text, to_jsonb(30)::jsonb, @__strlit38::text, to_jsonb(31)::jsonb, @__strlit39::text, to_jsonb(32)::jsonb, @__strlit40::text, to_jsonb(33)::jsonb, @__strlit41::text, to_jsonb(34)::jsonb, @__strlit42::text, to_jsonb(35)::jsonb, @__strlit43::text, to_jsonb(36)::jsonb, @__strlit44::text, to_jsonb(37)::jsonb, @__strlit45::text, to_jsonb(38)::jsonb, @__strlit46::text, to_jsonb(39)::jsonb, @__strlit47::text, to_jsonb(40)::jsonb, @__strlit48::text, to_jsonb(41)::jsonb, @__strlit49::text, to_jsonb(42)::jsonb, @__strlit50::text, to_jsonb(43)::jsonb, @__strlit51::text, to_jsonb(44)::jsonb, @__strlit52::text, to_jsonb(45)::jsonb, @__strlit53::text, to_jsonb(46)::jsonb, @__strlit54::text, to_jsonb(47)::jsonb, @__strlit4::text, to_jsonb(((s5.n0).properties -> E'right'))::jsonb, @__strlit6::text, to_jsonb(((s5.n0).properties -> E'left'))::jsonb)::jsonb else jsonb_build_object()::jsonb end as i7 from s5), s7 as materialized (select s6.i1 as i1, case when s6.i7 ? @__strlit6::text then (s6.i7 -> @__strlit6::text) else s6.i10 end as i10, s6.i2 as i2, s6.i3 as i3, s6.i4 as i4, s6.i5 as i5, s6.i6 as i6, case when s6.i7 ? @__strlit4::text then (s6.i7 -> @__strlit4::text) else s6.i8 end as i8, case when s6.i7 ? @__strlit5::text then (s6.i7 -> @__strlit5::text) else s6.i9 end as i9, case when true then ((s6.n0).id, (s6.n0).kind_ids, cypher_apply_property_patch((s6.n0).properties, s6.i7)::jsonb)::nodecomposite else s6.n0 end as n0 from s6), s8 as materialized (select cypher_merge_assert((select coalesce(bool_and(s0.i1 is not null and s0.i2 is not null and s0.i3 is not null)::bool, true)::bool from s0), false, false, false)::bool as _merge_valid), s9 as (select s7.i1 as i1, s7.i10 as i10, s7.i2 as i2, s7.i3 as i3, s7.i4 as i4, s7.i5 as i5, s7.i6 as i6, s7.i8 as i8, s7.i9 as i9, s7.n0 as n0 from s7, s8 where s8._merge_valid), s10 as (merge into node _merge_target using (select _merge_source.n0 as n0, _merge_source.i6 as i6, _merge_source.i5 as i5, false as _merge_anchor from s9 _merge_source where (true or _merge_source.i5) union all select null as n0, null as i6, false as i5, s8._merge_valid as _merge_anchor from s8) as _merge_source on _merge_target.id = (_merge_source.n0).id and _merge_target.graph_id = 0 when not matched and _merge_source._merge_anchor then do nothing when matched and true then update set properties = (_merge_source.n0).properties, kind_ids = (_merge_source.n0).kind_ids when matched then do nothing when not matched then insert (graph_id, id, kind_ids, properties) values (0, (_merge_source.n0).id, (_merge_source.n0).kind_ids, (_merge_source.n0).properties) returning _merge_source.i6 as i6, (_merge_target.id, _merge_target.kind_ids, _merge_target.properties)::nodecomposite as n0), s11 as (select s9.i4 as i4, s9.i5 as i5, s9.i6 as i6, coalesce(s10.n0, s9.n0)::nodecomposite as n0 from s9 left outer join s10 on s10.i6 = s9.i6) select ((s11.n0).properties -> E'field0'), ((s11.n0).properties -> E'field1'), ((s11.n0).properties -> E'field2'), ((s11.n0).properties -> E'field3'), ((s11.n0).properties -> E'field4'), ((s11.n0).properties -> E'field5'), ((s11.n0).properties -> E'field6'), ((s11.n0).properties -> E'field7'), ((s11.n0).properties -> E'field8'), ((s11.n0).properties -> E'field9'), ((s11.n0).properties -> E'field10'), ((s11.n0).properties -> E'field11'), ((s11.n0).properties -> E'field12'), ((s11.n0).properties -> E'field13'), ((s11.n0).properties -> E'field14'), ((s11.n0).properties -> E'field15'), ((s11.n0).properties -> E'field16'), ((s11.n0).properties -> E'field17'), ((s11.n0).properties -> E'field18'), ((s11.n0).properties -> E'field19'), ((s11.n0).properties -> E'field20'), ((s11.n0).properties -> E'field21'), ((s11.n0).properties -> E'field22'), ((s11.n0).properties -> E'field23'), ((s11.n0).properties -> E'field24'), ((s11.n0).properties -> E'field25'), ((s11.n0).properties -> E'field26'), ((s11.n0).properties -> E'field27'), ((s11.n0).properties -> E'field28'), ((s11.n0).properties -> E'field29'), ((s11.n0).properties -> E'field30'), ((s11.n0).properties -> E'field31'), ((s11.n0).properties -> E'field32'), ((s11.n0).properties -> E'field33'), ((s11.n0).properties -> E'field34'), ((s11.n0).properties -> E'field35'), ((s11.n0).properties -> E'field36'), ((s11.n0).properties -> E'field37'), ((s11.n0).properties -> E'field38'), ((s11.n0).properties -> E'field39'), ((s11.n0).properties -> E'field40'), ((s11.n0).properties -> E'field41'), ((s11.n0).properties -> E'field42'), ((s11.n0).properties -> E'field43'), ((s11.n0).properties -> E'field44'), ((s11.n0).properties -> E'field45'), ((s11.n0).properties -> E'field46'), ((s11.n0).properties -> E'field47'), ((s11.n0).properties -> E'left'), ((s11.n0).properties -> E'right') from s11; + +-- case: MERGE (n:NodeKind1 {name:'large',left:1,right:2}) SET n.field0=0,n.field1=1,n.field2=2,n.field3=3,n.field4=4,n.field5=5,n.field6=6,n.field7=7,n.field8=8,n.field9=9,n.field10=10,n.field11=11,n.field12=12,n.field13=13,n.field14=14,n.field15=15,n.field16=16,n.field17=17,n.field18=18,n.field19=19,n.field20=20,n.field21=21,n.field22=22,n.field23=23,n.field24=24,n.field25=25,n.field26=26,n.field27=27,n.field28=28,n.field29=29,n.field30=30,n.field31=31,n.field32=32,n.field33=33,n.field34=34,n.field35=35,n.field36=36,n.field37=37,n.field38=38,n.field39=39,n.field40=40,n.field41=41,n.field42=42,n.field43=43,n.field44=44,n.field45=45,n.field46=46,n.field47=47,n.field48=48,n.left=n.right,n.right=n.left RETURN n.field0,n.field1,n.field2,n.field3,n.field4,n.field5,n.field6,n.field7,n.field8,n.field9,n.field10,n.field11,n.field12,n.field13,n.field14,n.field15,n.field16,n.field17,n.field18,n.field19,n.field20,n.field21,n.field22,n.field23,n.field24,n.field25,n.field26,n.field27,n.field28,n.field29,n.field30,n.field31,n.field32,n.field33,n.field34,n.field35,n.field36,n.field37,n.field38,n.field39,n.field40,n.field41,n.field42,n.field43,n.field44,n.field45,n.field46,n.field47,n.field48,n.left,n.right +-- pgsql_params:{"__strlit0":"large","__strlit1":"string","__strlit10":"field3","__strlit11":"field4","__strlit12":"field5","__strlit13":"field6","__strlit14":"field7","__strlit15":"field8","__strlit16":"field9","__strlit17":"field10","__strlit18":"field11","__strlit19":"field12","__strlit2":"node","__strlit20":"field13","__strlit21":"field14","__strlit22":"field15","__strlit23":"field16","__strlit24":"field17","__strlit25":"field18","__strlit26":"field19","__strlit27":"field20","__strlit28":"field21","__strlit29":"field22","__strlit3":"id","__strlit30":"field23","__strlit31":"field24","__strlit32":"field25","__strlit33":"field26","__strlit34":"field27","__strlit35":"field28","__strlit36":"field29","__strlit37":"field30","__strlit38":"field31","__strlit39":"field32","__strlit4":"left","__strlit40":"field33","__strlit41":"field34","__strlit42":"field35","__strlit43":"field36","__strlit44":"field37","__strlit45":"field38","__strlit46":"field39","__strlit47":"field40","__strlit48":"field41","__strlit49":"field42","__strlit5":"name","__strlit50":"field43","__strlit51":"field44","__strlit52":"field45","__strlit53":"field46","__strlit54":"field47","__strlit55":"field48","__strlit6":"right","__strlit7":"field0","__strlit8":"field1","__strlit9":"field2"} +with s0 as materialized (select cypher_merge_value(to_jsonb(1)::jsonb)::jsonb as i1, cypher_merge_value(to_jsonb((@__strlit0::text)::text)::jsonb)::jsonb as i2, cypher_merge_value(to_jsonb(2)::jsonb)::jsonb as i3, row_number() over ()::int8 as i4), s1 as (select s0.i1 as i1, s0.i2 as i2, s0.i3 as i3, s0.i4 as i4, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from s0, node n0 where n0.graph_id = 0 and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and (n0.properties -> E'left') = s0.i1 and (jsonb_typeof((n0.properties -> E'name')) = @__strlit1::text and (n0.properties ->> E'name') = s0.i2 #>> array []::text[]) and (n0.properties -> E'right') = s0.i3), s2 as (select s0.i1 as i1, s0.i2 as i2, s0.i3 as i3, s0.i4 as i4 from s0 where not exists (select 1 from s1 where s1.i4 = s0.i4)), s3 as (select s2.i1 as i1, s2.i2 as i2, s2.i3 as i3, s2.i4 as i4, (nextval(pg_get_serial_sequence(@__strlit2::text, @__strlit3::text))::int8, array [1]::int2[], jsonb_build_object(@__strlit4::text, s2.i1, @__strlit5::text, s2.i2, @__strlit6::text, s2.i3)::jsonb)::nodecomposite as n0 from s2), s4 as (select s1.i1 as i1, s1.i2 as i2, s1.i3 as i3, s1.i4 as i4, s1.n0 as n0, false as i5 from s1 union all select s3.i1 as i1, s3.i2 as i2, s3.i3 as i3, s3.i4 as i4, s3.n0 as n0, true as i5 from s3), s5 as (select s4.i1 as i1, s4.i2 as i2, s4.i3 as i3, s4.i4 as i4, s4.i5 as i5, s4.n0 as n0, row_number() over ()::int8 as i6 from s4), s6 as materialized (select s5.i1 as i1, s5.i2 as i2, s5.i3 as i3, s5.i4 as i4, s5.i5 as i5, s5.i6 as i6, s5.n0 as n0, s5.i1 as i8, s5.i2 as i9, s5.i3 as i10, case when true then jsonb_build_object(@__strlit7::text, to_jsonb(0)::jsonb, @__strlit8::text, to_jsonb(1)::jsonb, @__strlit9::text, to_jsonb(2)::jsonb, @__strlit10::text, to_jsonb(3)::jsonb, @__strlit11::text, to_jsonb(4)::jsonb, @__strlit12::text, to_jsonb(5)::jsonb, @__strlit13::text, to_jsonb(6)::jsonb, @__strlit14::text, to_jsonb(7)::jsonb, @__strlit15::text, to_jsonb(8)::jsonb, @__strlit16::text, to_jsonb(9)::jsonb, @__strlit17::text, to_jsonb(10)::jsonb, @__strlit18::text, to_jsonb(11)::jsonb, @__strlit19::text, to_jsonb(12)::jsonb, @__strlit20::text, to_jsonb(13)::jsonb, @__strlit21::text, to_jsonb(14)::jsonb, @__strlit22::text, to_jsonb(15)::jsonb, @__strlit23::text, to_jsonb(16)::jsonb, @__strlit24::text, to_jsonb(17)::jsonb, @__strlit25::text, to_jsonb(18)::jsonb, @__strlit26::text, to_jsonb(19)::jsonb, @__strlit27::text, to_jsonb(20)::jsonb, @__strlit28::text, to_jsonb(21)::jsonb, @__strlit29::text, to_jsonb(22)::jsonb, @__strlit30::text, to_jsonb(23)::jsonb, @__strlit31::text, to_jsonb(24)::jsonb, @__strlit32::text, to_jsonb(25)::jsonb, @__strlit33::text, to_jsonb(26)::jsonb, @__strlit34::text, to_jsonb(27)::jsonb, @__strlit35::text, to_jsonb(28)::jsonb, @__strlit36::text, to_jsonb(29)::jsonb, @__strlit37::text, to_jsonb(30)::jsonb, @__strlit38::text, to_jsonb(31)::jsonb, @__strlit39::text, to_jsonb(32)::jsonb, @__strlit40::text, to_jsonb(33)::jsonb, @__strlit41::text, to_jsonb(34)::jsonb, @__strlit42::text, to_jsonb(35)::jsonb, @__strlit43::text, to_jsonb(36)::jsonb, @__strlit44::text, to_jsonb(37)::jsonb, @__strlit45::text, to_jsonb(38)::jsonb, @__strlit46::text, to_jsonb(39)::jsonb, @__strlit47::text, to_jsonb(40)::jsonb, @__strlit48::text, to_jsonb(41)::jsonb, @__strlit49::text, to_jsonb(42)::jsonb, @__strlit50::text, to_jsonb(43)::jsonb, @__strlit51::text, to_jsonb(44)::jsonb, @__strlit52::text, to_jsonb(45)::jsonb, @__strlit53::text, to_jsonb(46)::jsonb, @__strlit54::text, to_jsonb(47)::jsonb, @__strlit55::text, to_jsonb(48)::jsonb, @__strlit4::text, to_jsonb(((s5.n0).properties -> E'right'))::jsonb)::jsonb || jsonb_build_object(@__strlit6::text, to_jsonb(((s5.n0).properties -> E'left'))::jsonb)::jsonb else jsonb_build_object()::jsonb end as i7 from s5), s7 as materialized (select s6.i1 as i1, case when s6.i7 ? @__strlit6::text then (s6.i7 -> @__strlit6::text) else s6.i10 end as i10, s6.i2 as i2, s6.i3 as i3, s6.i4 as i4, s6.i5 as i5, s6.i6 as i6, case when s6.i7 ? @__strlit4::text then (s6.i7 -> @__strlit4::text) else s6.i8 end as i8, case when s6.i7 ? @__strlit5::text then (s6.i7 -> @__strlit5::text) else s6.i9 end as i9, case when true then ((s6.n0).id, (s6.n0).kind_ids, cypher_apply_property_patch((s6.n0).properties, s6.i7)::jsonb)::nodecomposite else s6.n0 end as n0 from s6), s8 as materialized (select cypher_merge_assert((select coalesce(bool_and(s0.i1 is not null and s0.i2 is not null and s0.i3 is not null)::bool, true)::bool from s0), false, false, false)::bool as _merge_valid), s9 as (select s7.i1 as i1, s7.i10 as i10, s7.i2 as i2, s7.i3 as i3, s7.i4 as i4, s7.i5 as i5, s7.i6 as i6, s7.i8 as i8, s7.i9 as i9, s7.n0 as n0 from s7, s8 where s8._merge_valid), s10 as (merge into node _merge_target using (select _merge_source.n0 as n0, _merge_source.i6 as i6, _merge_source.i5 as i5, false as _merge_anchor from s9 _merge_source where (true or _merge_source.i5) union all select null as n0, null as i6, false as i5, s8._merge_valid as _merge_anchor from s8) as _merge_source on _merge_target.id = (_merge_source.n0).id and _merge_target.graph_id = 0 when not matched and _merge_source._merge_anchor then do nothing when matched and true then update set properties = (_merge_source.n0).properties, kind_ids = (_merge_source.n0).kind_ids when matched then do nothing when not matched then insert (graph_id, id, kind_ids, properties) values (0, (_merge_source.n0).id, (_merge_source.n0).kind_ids, (_merge_source.n0).properties) returning _merge_source.i6 as i6, (_merge_target.id, _merge_target.kind_ids, _merge_target.properties)::nodecomposite as n0), s11 as (select s9.i4 as i4, s9.i5 as i5, s9.i6 as i6, coalesce(s10.n0, s9.n0)::nodecomposite as n0 from s9 left outer join s10 on s10.i6 = s9.i6) select ((s11.n0).properties -> E'field0'), ((s11.n0).properties -> E'field1'), ((s11.n0).properties -> E'field2'), ((s11.n0).properties -> E'field3'), ((s11.n0).properties -> E'field4'), ((s11.n0).properties -> E'field5'), ((s11.n0).properties -> E'field6'), ((s11.n0).properties -> E'field7'), ((s11.n0).properties -> E'field8'), ((s11.n0).properties -> E'field9'), ((s11.n0).properties -> E'field10'), ((s11.n0).properties -> E'field11'), ((s11.n0).properties -> E'field12'), ((s11.n0).properties -> E'field13'), ((s11.n0).properties -> E'field14'), ((s11.n0).properties -> E'field15'), ((s11.n0).properties -> E'field16'), ((s11.n0).properties -> E'field17'), ((s11.n0).properties -> E'field18'), ((s11.n0).properties -> E'field19'), ((s11.n0).properties -> E'field20'), ((s11.n0).properties -> E'field21'), ((s11.n0).properties -> E'field22'), ((s11.n0).properties -> E'field23'), ((s11.n0).properties -> E'field24'), ((s11.n0).properties -> E'field25'), ((s11.n0).properties -> E'field26'), ((s11.n0).properties -> E'field27'), ((s11.n0).properties -> E'field28'), ((s11.n0).properties -> E'field29'), ((s11.n0).properties -> E'field30'), ((s11.n0).properties -> E'field31'), ((s11.n0).properties -> E'field32'), ((s11.n0).properties -> E'field33'), ((s11.n0).properties -> E'field34'), ((s11.n0).properties -> E'field35'), ((s11.n0).properties -> E'field36'), ((s11.n0).properties -> E'field37'), ((s11.n0).properties -> E'field38'), ((s11.n0).properties -> E'field39'), ((s11.n0).properties -> E'field40'), ((s11.n0).properties -> E'field41'), ((s11.n0).properties -> E'field42'), ((s11.n0).properties -> E'field43'), ((s11.n0).properties -> E'field44'), ((s11.n0).properties -> E'field45'), ((s11.n0).properties -> E'field46'), ((s11.n0).properties -> E'field47'), ((s11.n0).properties -> E'field48'), ((s11.n0).properties -> E'left'), ((s11.n0).properties -> E'right') from s11; diff --git a/cypher/models/pgsql/translate/README.md b/cypher/models/pgsql/translate/README.md index 000c8fd0..ec26d6ad 100644 --- a/cypher/models/pgsql/translate/README.md +++ b/cypher/models/pgsql/translate/README.md @@ -1,4 +1,4 @@ -# openCypher to PgSQL 16 Translation +# openCypher to PgSQL 18 Translation ## Renaming @@ -521,3 +521,29 @@ with s0 as (with ex0(root_id, next_id, depth, satisfied, is_cycle, path) select edges_to_path(variadic ep0)::pathcomposite as p from s0; ``` + +## MERGE translation + +`merge.go` builds a complete-pattern match/create plan with clause-entry boundness, input identity, and result identity. +Materialized inputs hold individually validated fixed-key values or a validated dynamic map. Lookup predicates reuse +those values; the creation branch assembles fixed property objects. Text arguments to `to_jsonb` retain an explicit +cast when SQL parameters are materialized into literals. Each action clause projects its effective RHS +values into a materialized patch before applying one property-bag transformation per updated binding. Patches with more +than 50 fields concatenate bounded `jsonb_build_object` calls before application, keeping each call within PostgreSQL's +100-argument limit. All RHS values +observe the incoming clause. Parallel final-key columns follow patches without reading unrelated entity payloads. +Entity composites are retained conservatively at clause boundaries for downstream property, whole-entity, and path +consumers; dynamic overlap remains a general per-key comparison of original maps and final properties. + +Relational action projections carry only graph, type, and target ID. Fixed preserved keys use GROUP BY/HAVING; +changed keys use original-to-final equality joins. A materialized scalar guard combines a full-consumption input +aggregate with conflict facts. Every real write filters validated candidates to effective actions. The first entity write with an INSERT action consumes a sentinel source row: its null target ID and guard-valued +DO NOTHING predicate force validation without writing or returning a row. With only bound entities, a conditional INSERT +MERGE anchor guarantees guard consumption when outputs/writes are unused; the assertion returns true or raises, +so its `WHEN NOT MATCHED AND NOT guard.valid THEN INSERT` never inserts. The separate anchor requires INSERT permission and can invoke +INSERT statement triggers on node. A MERGE with only a DO NOTHING action was experimentally pruned on PostgreSQL 18.6. + +Only unbound entities and bound entities with explicit actions emit entity writes. RETURNING values are joined by +result identity and coalesced with unchanged candidates. Private input, result, key, patch, and branch fields are hidden +from RETURN *; key and patch projections are dropped after their last consumer. Unchanged matches are returned without updating them. See [MERGE](../../../../docs/postgresql_translation.md#merge) +for supported patterns, statement-snapshot limits, repeated inputs, storage conflicts, and validation. diff --git a/cypher/models/pgsql/translate/merge.go b/cypher/models/pgsql/translate/merge.go new file mode 100644 index 00000000..2f5d75fd --- /dev/null +++ b/cypher/models/pgsql/translate/merge.go @@ -0,0 +1,1045 @@ +package translate + +import ( + "fmt" + "sort" + + "github.com/specterops/dawgs/cypher/models/cypher" + "github.com/specterops/dawgs/cypher/models/pgsql" + "github.com/specterops/dawgs/cypher/models/walk" + "github.com/specterops/dawgs/graph" +) + +// mergeEntity records boundness at clause entry, independently of the bindings +// introduced while translating the rest of the pattern. +type mergeEntity struct { + binding *BoundIdentifier + bound bool + kinds graph.Kinds + properties pgsql.Identifier + left, right *BoundIdentifier + direction graph.Direction + matchProperties TranslatedProperties + keys []string + keyColumns map[string]pgsql.Identifier + finalKeyColumns map[string]pgsql.Identifier + keysChanged bool +} + +type mergePlan struct { + entities []*mergeEntity + input *Frame + guard *Frame + singleInput bool + inputRow *BoundIdentifier + created *BoundIdentifier + resultRow *BoundIdentifier + path *BoundIdentifier +} + +func mergeAlias(expression pgsql.Expression, id pgsql.Identifier) pgsql.SelectItem { + return &pgsql.AliasedExpression{Expression: expression, Alias: pgsql.AsOptionalIdentifier(id)} +} + +func mergeField(frame *Frame, binding *BoundIdentifier, field pgsql.Identifier) pgsql.Expression { + return pgsql.RowColumnReference{Identifier: pgsql.CompoundIdentifier{frame.Binding.Identifier, binding.Identifier}, Column: field} +} + +func mergeColumns(dataType pgsql.DataType) []pgsql.Identifier { + if dataType == pgsql.NodeComposite { + return []pgsql.Identifier{pgsql.ColumnID, pgsql.ColumnKindIDs, pgsql.ColumnProperties} + } + return []pgsql.Identifier{pgsql.ColumnID, pgsql.ColumnStartID, pgsql.ColumnEndID, pgsql.ColumnKindID, pgsql.ColumnProperties} +} + +func (s *Translator) flushCollectedMutations() error { + part := s.query.CurrentPart() + if part.mutations.Creations.Len() == 0 && part.mutations.EdgeCreations.Len() == 0 && part.mutations.Updates.Len() == 0 { + return nil + } + count := part.numUpdatingClauses + part.numUpdatingClauses = 1 + err := s.translateUpdates() + part.numUpdatingClauses = count + part.mutations = NewMutations() + return err +} + +func (s *Translator) flushMerge() error { + if s.pendingMerge == nil { + return nil + } + merge := s.pendingMerge + s.pendingMerge = nil + return s.translateMerge(merge) +} + +// mergeStage carries a pipeline into a new read CTE. Writes are emitted only +// after all branch actions have been folded into candidate composite values. +func (s *Translator) mergeStage(projection pgsql.Projection, from []pgsql.FromClause, where pgsql.Expression) (*Frame, error) { + frame, err := s.scope.PushFrame() + if err != nil { + return nil, err + } + s.addCTE(frame, pgsql.Select{Projection: projection, From: from, Where: where}) + for _, id := range frame.Known().Slice() { + binding, _ := s.scope.Lookup(id) + binding.MaterializedBy(frame) + } + return frame, nil +} + +func (s *Translator) mergeCarry(frame *Frame) (pgsql.Projection, error) { + return buildCarryProjection(s.scope, frame.Known(), frame) +} + +func (s *Translator) translateMerge(merge *cypher.Merge) error { + pattern := merge.PatternPart + if pattern == nil || len(pattern.PatternElements) == 0 { + return fmt.Errorf("MERGE requires a pattern") + } + if pattern.ShortestPathPattern || pattern.AllShortestPathsPattern { + return fmt.Errorf("MERGE does not support shortest paths") + } + plan := mergePlan{} + s.query.CurrentPart().containsMerge = true + entry := s.scope.CurrentFrame() + sourceFrame := entry + if entry != nil && entry == s.query.CurrentPart().Frame && !entry.Synthetic { + sourceFrame = entry.Previous + } + var projection pgsql.Projection + if entry != nil { + var err error + projection, err = buildCarryProjection(s.scope, entry.Known(), sourceFrame) + if err != nil { + return err + } + // Paths can still be represented by their dependencies at clause entry. + // Build their value before carrying them as columns through the pipeline. + for idx, identifier := range entry.Known().Slice() { + binding, _ := s.scope.Lookup(identifier) + if binding.DataType == pgsql.PathComposite && binding.LastProjection == nil { + expression, err := expressionForPathComposite(binding, s.scope) + if err != nil { + return err + } + projection[idx] = mergeAlias(expression, identifier) + } + } + } + // Current-part UNWIND aliases come from lateral sources, rather than columns + // of the preceding MATCH/WITH frame. + for _, clause := range s.query.CurrentPart().unwindClauses { + for idx, item := range projection { + if alias, ok := item.(*pgsql.AliasedExpression); ok && alias.Alias.Value == clause.Binding.Identifier { + projection[idx] = mergeAlias(clause.Binding.Identifier, clause.Binding.Identifier) + } + } + } + known := pgsql.NewIdentifierSet() + if entry != nil { + known = entry.Known() + } + entitiesByID := map[pgsql.Identifier]*mergeEntity{} + for _, element := range pattern.PatternElements { + var kinds graph.Kinds + var properties cypher.Expression + dataType := pgsql.NodeComposite + direction := graph.DirectionOutbound + switch typed := element.Element.(type) { + case *cypher.NodePattern: + kinds = typed.Kinds + properties = typed.Properties + case *cypher.RelationshipPattern: + dataType = pgsql.EdgeComposite + kinds = typed.Kinds + properties = typed.Properties + direction = typed.Direction + if len(kinds) != 1 || typed.Range != nil { + return fmt.Errorf("MERGE relationships require exactly one type and no range") + } + default: + return fmt.Errorf("invalid MERGE pattern element %T", element.Element) + } + binding, err := s.bindPatternExpression(element.Element, dataType) + if err != nil { + return err + } + if binding.Binding.DataType != dataType { + return fmt.Errorf("invalid MERGE binding %s: expected %s", binding.Binding.Aliased(), dataType) + } + bound := known.Contains(binding.Binding.Identifier) + if (bound || binding.AlreadyBound) && dataType == pgsql.NodeComposite && (len(kinds) > 0 || properties != nil) { + return fmt.Errorf("MERGE cannot redeclare labels or properties on a declared node %s", binding.Binding.Aliased()) + } + if dataType == pgsql.EdgeComposite && binding.AlreadyBound { + return fmt.Errorf("MERGE cannot redeclare a bound relationship") + } + entity := &mergeEntity{binding: binding.Binding, bound: bound, kinds: kinds, direction: direction} + if err := s.translateMergeProperties(properties); err != nil { + return err + } + entity.matchProperties = s.query.CurrentPart().ConsumeProperties() + // A property value copied from another entity must retain its JSON type. + // Keep casts used by arithmetic and other typed expressions intact. + for _, value := range entity.matchProperties.Map { + if lookup, ok := expressionToPropertyLookupBinaryExpression(value); ok { + lookup.Operator = pgsql.OperatorJSONField + } + } + props, err := s.buildPropertiesObject(entity.matchProperties) + if err == nil { + references, refErr := ExtractSyntaxNodeReferences(props) + if refErr != nil { + return refErr + } + for _, id := range references.Slice() { + if definition, ok := s.scope.Lookup(id); ok && definition.DataType.MatchesOneOf(pgsql.NodeComposite, pgsql.EdgeComposite) && !known.Contains(id) { + return fmt.Errorf("MERGE properties reference an unbound entity %s", definition.Aliased()) + } + } + } + if err != nil { + return err + } + propertyBinding, err := s.scope.DefineNew(pgsql.JSONB) + if err != nil { + return err + } + entity.properties = propertyBinding.Identifier + entity.keyColumns = map[string]pgsql.Identifier{} + entity.finalKeyColumns = map[string]pgsql.Identifier{} + for key := range entity.matchProperties.Map { + entity.keys = append(entity.keys, key) + } + sort.Strings(entity.keys) + if entity.matchProperties.Parameter != nil { + projection = append(projection, mergeAlias(pgsql.FunctionCall{Function: "cypher_merge_properties", Parameters: []pgsql.Expression{mergeJSONValue(props)}, CastType: pgsql.JSONB}, entity.properties)) + } else { + // Fixed values are validated and carried individually. The creation + // branch assembles the object only when it needs a property bag. + for _, key := range entity.keys { + column, err := s.scope.DefineNew(pgsql.JSONB) + if err != nil { + return err + } + entity.keyColumns[key] = column.Identifier + value := mergeJSONValue(entity.matchProperties.Map[key]) + projection = append(projection, mergeAlias(pgsql.FunctionCall{Function: "cypher_merge_value", Parameters: []pgsql.Expression{value}, CastType: pgsql.JSONB}, column.Identifier)) + } + } + if dataType == pgsql.NodeComposite { + if len(plan.entities) > 0 && plan.entities[len(plan.entities)-1].binding.DataType == pgsql.EdgeComposite { + plan.entities[len(plan.entities)-1].right = entity.binding + } + } else { + if len(plan.entities) == 0 { + return fmt.Errorf("MERGE relationship is missing a left endpoint") + } + entity.left = plan.entities[len(plan.entities)-1].binding + } + plan.entities = append(plan.entities, entity) + entitiesByID[entity.binding.Identifier] = entity + // Assert before matching so an unknown kind never maps to an empty matcher. + if _, err := s.kindMapper.AssertKinds(kinds); err != nil { + return err + } + } + if pattern.Variable != nil { + if _, bound := s.scope.AliasedLookup(pgsql.Identifier(pattern.Variable.Symbol)); bound { + return fmt.Errorf("MERGE cannot redeclare a path") + } + binding, err := s.scope.DefineNew(pgsql.PathComposite) + if err != nil { + return err + } + s.scope.Alias(pgsql.Identifier(pattern.Variable.Symbol), binding) + for _, entity := range plan.entities { + binding.DependOn(entity.binding) + } + plan.path = binding + } + row, err := s.scope.DefineNew(pgsql.Int8) + if err != nil { + return err + } + plan.inputRow = row + projection = append(projection, mergeAlias(pgsql.FunctionCall{Function: "row_number", Over: &pgsql.Window{}, CastType: pgsql.Int8}, row.Identifier)) + for idx, clause := range s.query.CurrentPart().unwindClauses { + if array, ok := clause.Expression.(pgsql.ArrayLiteral); ok && len(array.Values) == 0 { + array.CastType = pgsql.JSONBArray + s.query.CurrentPart().unwindClauses[idx].Expression = array + } + } + plan.singleInput = (sourceFrame == nil || sourceFrame.Synthetic) && len(s.query.CurrentPart().unwindClauses) == 0 + from := s.createSourceFromClauses(sourceFrame) + input, err := s.mergeStage(projection, from, nil) + if err != nil { + return err + } + plan.input = input + // Clause-entry paths may be visible without having been exported by MATCH. + // Every carried column is now materialized and available to both branches. + for _, identifier := range known.Slice() { + input.Reveal(identifier) + input.Export(identifier) + binding, _ := s.scope.Lookup(identifier) + binding.MaterializedBy(input) + } + input.Reveal(row.Identifier) + input.Export(row.Identifier) + row.MaterializedBy(input) + for _, entity := range plan.entities { + columns := entity.inputColumns() + for _, column := range columns { + input.Reveal(column) + input.Export(column) + binding, _ := s.scope.Lookup(column) + binding.MaterializedBy(input) + } + } + s.query.CurrentPart().Model.CommonTableExpressions.Expressions[len(s.query.CurrentPart().Model.CommonTableExpressions.Expressions)-1].Materialized = &pgsql.Materialized{Materialized: true} + // Match the entire pattern in one read branch. Each table is graph-scoped; + // repeated node variables reuse the same alias and relationships are unique. + matchProjection, err := s.mergeCarry(input) + if err != nil { + return err + } + matchFrom := []pgsql.FromClause{frameReference(input)} + var constraints pgsql.Expression + emitted := pgsql.NewIdentifierSet() + var previousEdges []*BoundIdentifier + for _, entity := range plan.entities { + binding := entity.binding + table := pgsql.TableNode + if binding.DataType == pgsql.EdgeComposite { + table = pgsql.TableEdge + } + if !emitted.Contains(binding.Identifier) { + emitted.Add(binding.Identifier) + matchFrom = append(matchFrom, pgsql.FromClause{Source: pgsql.TableReference{Name: table.AsCompoundIdentifier(), Binding: pgsql.AsOptionalIdentifier(binding.Identifier)}}) + values := []pgsql.Expression{} + for _, column := range mergeColumns(binding.DataType) { + values = append(values, pgsql.CompoundIdentifier{binding.Identifier, column}) + } + if !entity.bound { + matchProjection = append(matchProjection, mergeAlias(pgsql.CompositeValue{DataType: binding.DataType, Values: values}, binding.Identifier)) + } + constraints = pgsql.OptionalAnd(constraints, pgsql.NewBinaryExpression(pgsql.CompoundIdentifier{binding.Identifier, pgsql.ColumnGraphID}, pgsql.OperatorEquals, pgsql.NewLiteral(s.graphID, pgsql.Int4))) + if entity.bound { + constraints = pgsql.OptionalAnd(constraints, pgsql.NewBinaryExpression(pgsql.CompoundIdentifier{binding.Identifier, pgsql.ColumnID}, pgsql.OperatorEquals, mergeField(input, binding, pgsql.ColumnID))) + } + } + ids, err := s.kindMapper.AssertKinds(entity.kinds) + if err != nil { + return err + } + if len(ids) > 0 { + var kindConstraint pgsql.Expression + if binding.DataType == pgsql.NodeComposite { + kindConstraint = pgsql.NewBinaryExpression(pgsql.CompoundIdentifier{binding.Identifier, pgsql.ColumnKindIDs}, pgsql.OperatorPGArrayLHSContainsRHS, pgsql.NewLiteral(ids, pgsql.Int2Array)) + } else { + kindConstraint = pgsql.NewBinaryExpression(pgsql.CompoundIdentifier{binding.Identifier, pgsql.ColumnKindID}, pgsql.OperatorEquals, pgsql.NewLiteral(ids[0], pgsql.Int2)) + } + constraints = pgsql.OptionalAnd(constraints, kindConstraint) + } + + for _, key := range entity.keys { + lookup := pgsql.NewPropertyLookup(pgsql.CompoundIdentifier{binding.Identifier, pgsql.ColumnProperties}, pgsql.NewLiteral(key, pgsql.Text)) + expected := pgsql.CompoundIdentifier{input.Binding.Identifier, entity.keyColumns[key]} + // Type eligibility comes from the AST, never the current map contents. + if _, stringValue := rewriteStringEqualityOperand(entity.matchProperties.Map[key]); stringValue { + textValue := pgsql.NewBinaryExpression(expected, pgsql.Operator("#>>"), pgsql.NewLiteral([]string{}, pgsql.TextArray)) + constraints = pgsql.OptionalAnd(constraints, buildStringPropertyComparisonPredicate(lookup, textValue, true, pgsql.OperatorEquals)) + } else { + lookup.Operator = pgsql.OperatorJSONField + constraints = pgsql.OptionalAnd(constraints, pgsql.NewBinaryExpression(lookup, pgsql.OperatorEquals, expected)) + } + } + if entity.matchProperties.Parameter != nil { + // Dynamic property maps require equality for every field. JSON + // containment would incorrectly accept array subsets as matches. + constraints = pgsql.OptionalAnd(constraints, pgsql.ExistsExpression{Negated: true, Subquery: pgsql.Subquery{Query: pgsql.Query{Body: pgsql.Select{Projection: pgsql.Projection{pgsql.NewLiteral(1, pgsql.Int4)}, From: []pgsql.FromClause{{Source: pgsql.AliasedExpression{Expression: pgsql.FunctionCall{Function: "jsonb_each", Parameters: []pgsql.Expression{pgsql.CompoundIdentifier{input.Binding.Identifier, entity.properties}}}, Alias: pgsql.AsOptionalIdentifier("_merge_property")}}}, Where: pgsql.NewBinaryExpression(pgsql.NewBinaryExpression(pgsql.CompoundIdentifier{binding.Identifier, pgsql.ColumnProperties}, pgsql.OperatorJSONField, pgsql.CompoundIdentifier{"_merge_property", "key"}), pgsql.Operator("is distinct from"), pgsql.CompoundIdentifier{"_merge_property", "value"})}}}}) + } + + if binding.DataType == pgsql.EdgeComposite { + if entity.right == nil { + return fmt.Errorf("MERGE relationship is missing a right endpoint") + } + left, err := leftNodeConstraint(binding.Identifier, entity.left.Identifier, entity.direction) + if err != nil { + return err + } + rightDirection := graph.DirectionInbound + if entity.direction == graph.DirectionInbound { + rightDirection = graph.DirectionOutbound + } else if entity.direction == graph.DirectionBoth { + rightDirection = graph.DirectionBoth + } + right, err := leftNodeConstraint(binding.Identifier, entity.right.Identifier, rightDirection) + if err != nil { + return err + } + // For undirected patterns pair the endpoints in one direction or the other. + if entity.direction == graph.DirectionBoth { + forwardLeft, _ := leftNodeConstraint(binding.Identifier, entity.left.Identifier, graph.DirectionOutbound) + forwardRight, _ := leftNodeConstraint(binding.Identifier, entity.right.Identifier, graph.DirectionInbound) + backwardLeft, _ := leftNodeConstraint(binding.Identifier, entity.left.Identifier, graph.DirectionInbound) + backwardRight, _ := leftNodeConstraint(binding.Identifier, entity.right.Identifier, graph.DirectionOutbound) + left = pgsql.NewParenthetical(pgsql.NewBinaryExpression(pgsql.OptionalAnd(forwardLeft, forwardRight), pgsql.OperatorOr, pgsql.OptionalAnd(backwardLeft, backwardRight))) + right = nil + } + constraints = pgsql.OptionalAnd(constraints, pgsql.OptionalAnd(left, right)) + for _, other := range previousEdges { + constraints = pgsql.OptionalAnd(constraints, pgsql.NewBinaryExpression(pgsql.CompoundIdentifier{binding.Identifier, pgsql.ColumnID}, pgsql.OperatorNotEquals, pgsql.CompoundIdentifier{other.Identifier, pgsql.ColumnID})) + } + previousEdges = append(previousEdges, binding) + } + } + match, err := s.mergeStage(matchProjection, matchFrom, constraints) + if err != nil { + return err + } + for _, entity := range plan.entities { + match.Reveal(entity.binding.Identifier) + match.Export(entity.binding.Identifier) + entity.binding.MaterializedBy(match) + } + // Create only the unbound elements, once the complete pattern failed to match. + s.scope.stack = append(s.scope.stack, input) + carry, err := s.mergeCarry(input) + if err != nil { + return err + } + absent := pgsql.ExistsExpression{Negated: true, Subquery: pgsql.Subquery{Query: pgsql.Query{Body: pgsql.Select{Projection: pgsql.Projection{pgsql.NewLiteral(1, pgsql.Int4)}, From: []pgsql.FromClause{frameReference(match)}, Where: pgsql.NewBinaryExpression(pgsql.CompoundIdentifier{match.Binding.Identifier, row.Identifier}, pgsql.OperatorEquals, pgsql.CompoundIdentifier{input.Binding.Identifier, row.Identifier})}}}} + create, err := s.mergeStage(carry, []pgsql.FromClause{frameReference(input)}, absent) + if err != nil { + return err + } + createdIDs := pgsql.NewIdentifierSet() + creationOrder := []*mergeEntity{} + for _, entity := range plan.entities { + if entity.binding.DataType == pgsql.NodeComposite { + creationOrder = append(creationOrder, entity) + } + } + for _, entity := range plan.entities { + if entity.binding.DataType == pgsql.EdgeComposite { + creationOrder = append(creationOrder, entity) + } + } + for _, entity := range creationOrder { + binding := entity.binding + if entity.bound || createdIDs.Contains(binding.Identifier) { + continue + } + createdIDs.Add(binding.Identifier) + projection, err := s.mergeCarry(create) + if err != nil { + return err + } + table := pgsql.TableNode + if binding.DataType == pgsql.EdgeComposite { + table = pgsql.TableEdge + } + values := []pgsql.Expression{sequenceValue(table)} + ids, err := s.kindMapper.AssertKinds(entity.kinds) + if err != nil { + return err + } + if binding.DataType == pgsql.NodeComposite { + values = append(values, pgsql.NewLiteral(ids, pgsql.Int2Array)) + } else { + left, right := entity.left, entity.right + if entity.direction == graph.DirectionInbound { + left, right = right, left + } + values = append(values, mergeField(create, left, pgsql.ColumnID), mergeField(create, right, pgsql.ColumnID), pgsql.NewLiteral(ids[0], pgsql.Int2)) + } + // Property columns are private pipeline fields; carry them through creations. + values = append(values, entity.propertiesAt(create)) + projection = append(projection, mergeAlias(pgsql.CompositeValue{DataType: binding.DataType, Values: values}, binding.Identifier)) + create, err = s.mergeStage(projection, []pgsql.FromClause{frameReference(create)}, nil) + if err != nil { + return err + } + create.Reveal(binding.Identifier) + create.Export(binding.Identifier) + binding.MaterializedBy(create) + } + flag, err := s.scope.DefineNew(pgsql.Boolean) + if err != nil { + return err + } + plan.created = flag + candidateIDs := match.Known() + // Map columns explicitly: creation and match preserve every incoming row. + matchItems := pgsql.Projection{} + createItems := pgsql.Projection{} + for _, id := range candidateIDs.Slice() { + matchItems = append(matchItems, mergeAlias(pgsql.CompoundIdentifier{match.Binding.Identifier, id}, id)) + createItems = append(createItems, mergeAlias(pgsql.CompoundIdentifier{create.Binding.Identifier, id}, id)) + } + matchItems = append(matchItems, mergeAlias(pgsql.NewLiteral(false, pgsql.Boolean), flag.Identifier)) + createItems = append(createItems, mergeAlias(pgsql.NewLiteral(true, pgsql.Boolean), flag.Identifier)) + candidate, err := s.scope.PushFrame() + if err != nil { + return err + } + s.addCTE(candidate, pgsql.SetOperation{Operator: pgsql.OperatorUnion, All: true, LOperand: pgsql.Select{Projection: matchItems, From: []pgsql.FromClause{frameReference(match)}}, ROperand: pgsql.Select{Projection: createItems, From: []pgsql.FromClause{frameReference(create)}}}) + candidate.Visible = candidateIDs.Copy().Add(flag.Identifier) + candidate.Exported = candidate.Visible.Copy() + for _, id := range candidate.Known().Slice() { + binding, _ := s.scope.Lookup(id) + binding.MaterializedBy(candidate) + } + resultRow, err := s.scope.DefineNew(pgsql.Int8) + if err != nil { + return err + } + plan.resultRow = resultRow + projection, err = s.mergeCarry(candidate) + if err != nil { + return err + } + projection = append(projection, mergeAlias(pgsql.FunctionCall{Function: "row_number", Over: &pgsql.Window{}, CastType: pgsql.Int8}, resultRow.Identifier)) + candidate, err = s.mergeStage(projection, []pgsql.FromClause{frameReference(candidate)}, nil) + if err != nil { + return err + } + candidate.Reveal(resultRow.Identifier) + candidate.Export(resultRow.Identifier) + resultRow.MaterializedBy(candidate) + updates := map[pgsql.Identifier]pgsql.Expression{} + singleNodePattern := len(plan.entities) == 1 && !plan.entities[0].bound && plan.entities[0].binding.DataType == pgsql.NodeComposite + // Each SET clause is a read projection, preserving clause order without + // attempting to update the same stored row from sibling write CTEs. + for _, action := range merge.MergeActions { + if action.Set == nil || (!action.OnCreate && !action.OnMatch) { + return fmt.Errorf("invalid MERGE action") + } + { + s.query.CurrentPart().mutations = NewMutations() + if err := walk.Cypher(action.Set, s); err != nil { + return err + } + var condition pgsql.Expression = pgsql.CompoundIdentifier{candidate.Binding.Identifier, flag.Identifier} + if action.OnCreate && action.OnMatch { + condition = pgsql.NewLiteral(true, pgsql.Boolean) + } else if action.OnMatch { + condition = &pgsql.UnaryExpression{Operator: pgsql.OperatorNot, Operand: condition} + } + patches := map[pgsql.Identifier]pgsql.Identifier{} + projection, err = s.mergeCarry(candidate) + if err != nil { + return err + } + for _, update := range s.query.CurrentPart().mutations.Updates.Values() { + if update.PropertyAssignments.Len() == 0 { + continue + } + column, err := s.scope.DefineNew(pgsql.JSONB) + if err != nil { + return err + } + patches[update.TargetBinding.Identifier] = column.Identifier + for _, assignment := range update.PropertyAssignments.Values() { + if entity := entitiesByID[update.TargetBinding.Identifier]; entity != nil && action.OnCreate { + if _, exists := entity.keyColumns[assignment.Field]; exists { + entity.keysChanged = true + } + } + } + if entity := entitiesByID[update.TargetBinding.Identifier]; entity != nil && entity.keysChanged && len(entity.finalKeyColumns) == 0 { + // Only creation-key mutations need a separate final tuple. + for _, key := range entity.keys { + column, err := s.scope.DefineNew(pgsql.JSONB) + if err != nil { + return err + } + entity.finalKeyColumns[key] = column.Identifier + projection = append(projection, mergeAlias(pgsql.CompoundIdentifier{candidate.Binding.Identifier, entity.keyColumns[key]}, column.Identifier)) + } + } + patch := mergePropertyPatch(update.PropertyAssignments.Values()) + projection = append(projection, mergeAlias(pgsql.Case{Conditions: []pgsql.Expression{condition}, Then: []pgsql.Expression{patch}, Else: pgsql.FunctionCall{Function: pgsql.FunctionJSONBBuildObject, CastType: pgsql.JSONB}}, column.Identifier)) + } + if len(patches) > 0 { + candidate, err = s.mergeStage(projection, []pgsql.FromClause{frameReference(candidate)}, nil) + if err != nil { + return err + } + s.query.CurrentPart().Model.CommonTableExpressions.Expressions[len(s.query.CurrentPart().Model.CommonTableExpressions.Expressions)-1].Materialized = &pgsql.Materialized{Materialized: true} + for _, column := range patches { + candidate.Reveal(column) + candidate.Export(column) + binding, _ := s.scope.Lookup(column) + binding.MaterializedBy(candidate) + } + for _, entity := range plan.entities { + for _, column := range entity.finalKeyColumns { + candidate.Reveal(column) + candidate.Export(column) + binding, _ := s.scope.Lookup(column) + binding.MaterializedBy(candidate) + } + } + condition = pgsql.CompoundIdentifier{candidate.Binding.Identifier, flag.Identifier} + if action.OnCreate && action.OnMatch { + condition = pgsql.NewLiteral(true, pgsql.Boolean) + } else if action.OnMatch { + condition = pgsql.NewUnaryExpression(pgsql.OperatorNot, condition) + } + } + projection, err = s.mergeCarry(candidate) + if err != nil { + return err + } + for _, update := range s.query.CurrentPart().mutations.Updates.Values() { + binding := update.TargetBinding + if binding.DataType == pgsql.EdgeComposite && len(update.KindAssignments) > 0 { + return fmt.Errorf("MERGE SET labels require a node binding") + } + if binding.DataType != pgsql.NodeComposite && binding.DataType != pgsql.EdgeComposite { + return fmt.Errorf("invalid MERGE SET binding %s", binding.Aliased()) + } + values := []pgsql.Expression{} + for _, column := range mergeColumns(binding.DataType) { + var value pgsql.Expression = mergeField(candidate, binding, column) + switch column { + case pgsql.ColumnProperties: + if update.PropertyAssignments.Len() > 0 { + patch := pgsql.CompoundIdentifier{candidate.Binding.Identifier, patches[binding.Identifier]} + value = pgsql.FunctionCall{Function: "cypher_apply_property_patch", Parameters: []pgsql.Expression{value, patch}, CastType: pgsql.JSONB} + } + case pgsql.ColumnKindIDs: + if len(update.KindAssignments) > 0 { + ids, err := s.kindMapper.AssertKinds(update.KindAssignments) + if err != nil { + return err + } + value = pgsql.FunctionCall{Function: pgsql.FunctionIntArrayUnique, Parameters: []pgsql.Expression{pgsql.FunctionCall{Function: pgsql.FunctionIntArraySort, Parameters: []pgsql.Expression{pgsql.NewBinaryExpression(value, pgsql.OperatorConcatenate, pgsql.NewLiteral(ids, pgsql.Int2Array))}, CastType: pgsql.Int2Array}}, CastType: pgsql.Int2Array} + } + } + values = append(values, value) + } + modified := pgsql.CompositeValue{DataType: binding.DataType, Values: values} + choice := pgsql.Case{Conditions: []pgsql.Expression{condition}, Then: []pgsql.Expression{modified}, Else: pgsql.CompoundIdentifier{candidate.Binding.Identifier, binding.Identifier}} + for idx, item := range projection { + if aliased, ok := item.(*pgsql.AliasedExpression); ok && aliased.Alias.Value == binding.Identifier { + projection[idx] = mergeAlias(choice, binding.Identifier) + } + } + if entity := entitiesByID[binding.Identifier]; entity != nil && patches[binding.Identifier] != "" { + patch := pgsql.CompoundIdentifier{candidate.Binding.Identifier, patches[binding.Identifier]} + for _, key := range entity.keys { + column := entity.finalKeyColumns[key] + if column == "" { + continue + } + choice := pgsql.Case{Conditions: []pgsql.Expression{pgsql.NewBinaryExpression(patch, pgsql.Operator("?"), pgsql.NewLiteral(key, pgsql.Text))}, Then: []pgsql.Expression{pgsql.NewBinaryExpression(patch, pgsql.OperatorJSONField, pgsql.NewLiteral(key, pgsql.Text))}, Else: pgsql.CompoundIdentifier{candidate.Binding.Identifier, column}} + for idx, item := range projection { + if alias, ok := item.(*pgsql.AliasedExpression); ok && alias.Alias.Value == column { + projection[idx] = mergeAlias(choice, column) + } + } + } + } + // Save a frame-independent branch predicate for the native write. + var writeCondition pgsql.Expression = pgsql.CompoundIdentifier{"_merge_source", flag.Identifier} + if action.OnCreate && action.OnMatch { + writeCondition = pgsql.NewLiteral(true, pgsql.Boolean) + } else if action.OnMatch { + writeCondition = &pgsql.UnaryExpression{Operator: pgsql.OperatorNot, Operand: writeCondition} + } + if existing := updates[binding.Identifier]; existing != nil { + updates[binding.Identifier] = pgsql.NewParenthetical(pgsql.NewBinaryExpression(existing, pgsql.OperatorOr, writeCondition)) + } else { + updates[binding.Identifier] = writeCondition + } + if entitiesByID[binding.Identifier] == nil { + entity := &mergeEntity{binding: binding, bound: true} + plan.entities = append(plan.entities, entity) + entitiesByID[binding.Identifier] = entity + } + } + for _, column := range patches { + candidate.Visible.Remove(column) + candidate.Exported.Remove(column) + filtered := pgsql.Projection{} + for _, item := range projection { + if alias, ok := item.(*pgsql.AliasedExpression); !ok || alias.Alias.Value != column { + filtered = append(filtered, item) + } + } + projection = filtered + } + candidate, err = s.mergeStage(projection, []pgsql.FromClause{frameReference(candidate)}, nil) + if err != nil { + return err + } + s.query.CurrentPart().Model.CommonTableExpressions.Expressions[len(s.query.CurrentPart().Model.CommonTableExpressions.Expressions)-1].Materialized = &pgsql.Materialized{Materialized: true} + } + } + s.query.CurrentPart().mutations = NewMutations() + // Validate the complete candidate set before any native write. In particular, + // different bindings can resolve to the same stored target. Never discard an + // input action by choosing an arbitrary DISTINCT row. + candidate, err = s.validateMergeCandidates(candidate, &plan, updates, singleNodePattern) + if err != nil { + return err + } + // Native MERGE writes each target once. DO NOTHING matches are preserved by + // the separate candidate branch and never require a dummy UPDATE. + for _, entity := range plan.entities { + for _, column := range append(entity.inputColumns(), mapColumns(entity.finalKeyColumns)...) { + candidate.Visible.Remove(column) + candidate.Exported.Remove(column) + } + } + finalProjection, err := s.mergeCarry(candidate) + if err != nil { + return err + } + finalFrom := []pgsql.FromClause{frameReference(candidate)} + written := pgsql.NewIdentifierSet() + anchored := false + for _, entity := range plan.entities { + binding := entity.binding + if written.Contains(binding.Identifier) || (entity.bound && updates[binding.Identifier] == nil) { + continue + } + written.Add(binding.Identifier) + writeFrame, err := s.scope.PushFrame() + if err != nil { + return err + } + target := pgsql.Identifier("_merge_target") + sourceBinding := *binding + sourceBinding.Identifier = binding.Identifier + sourceFrame := &Frame{Binding: &BoundIdentifier{Identifier: "_merge_source"}} + table := pgsql.TableNode + if binding.DataType == pgsql.EdgeComposite { + table = pgsql.TableEdge + } + actions := []pgsql.MergeAction{} + if condition := updates[binding.Identifier]; condition != nil { + assignments := []pgsql.Assignment{pgsql.NewBinaryExpression(pgsql.ColumnProperties, pgsql.OperatorAssignment, mergeField(sourceFrame, &sourceBinding, pgsql.ColumnProperties))} + if binding.DataType == pgsql.NodeComposite { + assignments = append(assignments, pgsql.NewBinaryExpression(pgsql.ColumnKindIDs, pgsql.OperatorAssignment, mergeField(sourceFrame, &sourceBinding, pgsql.ColumnKindIDs))) + } + actions = append(actions, pgsql.MatchedUpdate{Predicate: condition, Assignments: assignments}) + } + actions = append(actions, pgsql.MergeDoNothing{Matched: true}) + if !entity.bound { + columns := append([]pgsql.Identifier{pgsql.ColumnGraphID}, mergeColumns(binding.DataType)...) + values := []pgsql.Expression{pgsql.NewLiteral(s.graphID, pgsql.Int4)} + for _, column := range mergeColumns(binding.DataType) { + values = append(values, mergeField(sourceFrame, &sourceBinding, column)) + } + actions = append(actions, pgsql.UnmatchedAction{Columns: columns, Values: pgsql.Values{Values: values}}) + } else { + actions = append(actions, pgsql.MergeDoNothing{Matched: false}) + } + targetValues := []pgsql.Expression{} + for _, column := range mergeColumns(binding.DataType) { + targetValues = append(targetValues, pgsql.CompoundIdentifier{target, column}) + } + native := pgsql.Merge{Into: true, Table: pgsql.TableReference{Name: table.AsCompoundIdentifier(), Binding: pgsql.AsOptionalIdentifier(target)}, Source: pgsql.TableReference{Name: candidate.Binding.Identifier.AsCompoundIdentifier(), Binding: pgsql.AsOptionalIdentifier("_merge_source")}, JoinTarget: pgsql.OptionalAnd(pgsql.NewBinaryExpression(pgsql.CompoundIdentifier{target, pgsql.ColumnID}, pgsql.OperatorEquals, mergeField(sourceFrame, binding, pgsql.ColumnID)), pgsql.NewBinaryExpression(pgsql.CompoundIdentifier{target, pgsql.ColumnGraphID}, pgsql.OperatorEquals, pgsql.NewLiteral(s.graphID, pgsql.Int4))), Actions: actions, Returning: pgsql.Projection{mergeAlias(pgsql.CompoundIdentifier{"_merge_source", resultRow.Identifier}, resultRow.Identifier), mergeAlias(pgsql.CompositeValue{DataType: binding.DataType, Values: targetValues}, binding.Identifier)}} + var effective pgsql.Expression = updates[binding.Identifier] + if !entity.bound { + created := pgsql.CompoundIdentifier{"_merge_source", flag.Identifier} + if effective == nil { + effective = created + } else { + effective = pgsql.NewParenthetical(pgsql.NewBinaryExpression(effective, pgsql.OperatorOr, created)) + } + } + writeProjection := pgsql.Projection{mergeAlias(pgsql.CompoundIdentifier{"_merge_source", binding.Identifier}, binding.Identifier), mergeAlias(pgsql.CompoundIdentifier{"_merge_source", resultRow.Identifier}, resultRow.Identifier), mergeAlias(pgsql.CompoundIdentifier{"_merge_source", flag.Identifier}, flag.Identifier)} + var writeSource pgsql.SetExpression = pgsql.Select{Projection: writeProjection, From: []pgsql.FromClause{mergeTable(candidate, "_merge_source")}, Where: effective} + if !entity.bound && !anchored { + // An unbound entity's INSERT action prevents PostgreSQL from pruning + // the sentinel. Its DO NOTHING predicate demands the shared guard. + anchored = true + marker := pgsql.Identifier("_merge_anchor") + writeProjection = append(writeProjection, mergeAlias(pgsql.NewLiteral(false, pgsql.Boolean), marker)) + real := pgsql.Select{Projection: writeProjection, From: []pgsql.FromClause{mergeTable(candidate, "_merge_source")}, Where: effective} + sentinel := pgsql.Select{Projection: pgsql.Projection{mergeAlias(pgsql.Literal{Null: true, CastType: binding.DataType}, binding.Identifier), mergeAlias(pgsql.Literal{Null: true, CastType: pgsql.Int8}, resultRow.Identifier), mergeAlias(pgsql.NewLiteral(false, pgsql.Boolean), flag.Identifier), mergeAlias(pgsql.CompoundIdentifier{plan.guard.Binding.Identifier, "_merge_valid"}, marker)}, From: []pgsql.FromClause{frameReference(plan.guard)}} + writeSource = pgsql.SetOperation{Operator: pgsql.OperatorUnion, All: true, LOperand: real, ROperand: sentinel} + native.Actions = append([]pgsql.MergeAction{pgsql.MergeDoNothing{Matched: false, Predicate: pgsql.CompoundIdentifier{"_merge_source", marker}}}, native.Actions...) + } + native.SourceQuery = &pgsql.Subquery{Query: pgsql.Query{Body: writeSource}} + s.addCTE(writeFrame, native) + finalFrom[0].Joins = append(finalFrom[0].Joins, pgsql.Join{Table: writeFrame.Binding.Identifier, JoinOperator: pgsql.JoinOperator{JoinType: pgsql.JoinTypeLeftOuter, Constraint: pgsql.NewBinaryExpression(pgsql.CompoundIdentifier{writeFrame.Binding.Identifier, resultRow.Identifier}, pgsql.OperatorEquals, pgsql.CompoundIdentifier{candidate.Binding.Identifier, resultRow.Identifier})}}) + for idx, item := range finalProjection { + if aliased, ok := item.(*pgsql.AliasedExpression); ok && aliased.Alias.Value == binding.Identifier { + finalProjection[idx] = mergeAlias(pgsql.FunctionCall{Function: pgsql.FunctionCoalesce, Parameters: []pgsql.Expression{pgsql.CompoundIdentifier{writeFrame.Binding.Identifier, binding.Identifier}, pgsql.CompoundIdentifier{candidate.Binding.Identifier, binding.Identifier}}, CastType: binding.DataType}, binding.Identifier) + } + } + } + output, err := s.mergeStage(finalProjection, finalFrom, nil) + if err != nil { + return err + } + // Private bookkeeping never becomes a user-visible RETURN * binding. + output.Visible.Remove(row.Identifier).Remove(resultRow.Identifier).Remove(flag.Identifier) + output.Exported.Remove(row.Identifier).Remove(resultRow.Identifier).Remove(flag.Identifier) + for _, entity := range plan.entities { + output.Visible.Remove(entity.properties) + output.Exported.Remove(entity.properties) + } + if plan.path != nil { + output.Reveal(plan.path.Identifier) + output.Export(plan.path.Identifier) + } + return nil +} + +func (s *Translator) translateMergeProperties(properties cypher.Expression) error { + s.query.CurrentPart().properties = NewTranslatedProperties() + if properties == nil { + return nil + } + if err := walk.Cypher(properties, s); err != nil { + return err + } + for _, binding := range s.scope.definitions { + if binding.Parameter != nil && !binding.Parameter.CastType.IsKnown() && s.translation.Parameters[binding.Parameter.Identifier.String()] == nil { + binding.Parameter.CastType = pgsql.JSONB + } + } + return nil +} + +// mergePropertyPatch keeps each jsonb_build_object call within PostgreSQL's +// default 100-argument limit. Concatenate patches before applying them so every +// RHS still observes the incoming clause frame and the base bag is updated once. +func mergePropertyPatch(assignments []PropertyAssignment) pgsql.Expression { + const propertiesPerObject = 50 + var patch pgsql.Expression + for start := 0; start < len(assignments); start += propertiesPerObject { + object := pgsql.FunctionCall{Function: pgsql.FunctionJSONBBuildObject, CastType: pgsql.JSONB} + for _, assignment := range assignments[start:min(start+propertiesPerObject, len(assignments))] { + object.Parameters = append(object.Parameters, pgsql.NewLiteral(assignment.Field, pgsql.Text), mergeJSONValue(assignment.ValueExpression)) + } + if patch == nil { + patch = object + } else { + patch = pgsql.NewBinaryExpression(patch, pgsql.OperatorConcatenate, object) + } + } + return patch +} + +// mergeJSONValue preserves JSON types and leaves SQL NULL visible to validation +// and patch removal. Parameter casts belong to the reusable compiled structure. +func mergeJSONValue(rhs pgsql.Expression) pgsql.Expression { + if parameter, ok := rhs.(*pgsql.Parameter); ok && !parameter.CastType.IsKnown() { + parameter.CastType = pgsql.JSONB + } + if lookup, ok := expressionToPropertyLookupBinaryExpression(rhs); ok { + lookup.Operator = pgsql.OperatorJSONField + } + if literal, ok := rhs.(pgsql.Literal); ok && literal.Null { + return pgsql.Literal{Null: true, CastType: pgsql.JSONB} + } + // Inlined strings have PostgreSQL's unknown type. The polymorphic to_jsonb + // argument needs its text cast even when parameters are materialized. + if _, textValue := rewriteStringEqualityOperand(rhs); textValue { + rhs = pgsql.NewTypeCast(rhs, pgsql.Text) + } + return pgsql.FunctionCall{Function: pgsql.FunctionToJSONB, Parameters: []pgsql.Expression{rhs}, CastType: pgsql.JSONB} +} + +func (e *mergeEntity) inputColumns() []pgsql.Identifier { + if e.matchProperties.Parameter != nil { + return []pgsql.Identifier{e.properties} + } + columns := make([]pgsql.Identifier, 0, len(e.keys)) + for _, key := range e.keys { + columns = append(columns, e.keyColumns[key]) + } + return columns +} + +func (e *mergeEntity) propertiesAt(frame *Frame) pgsql.Expression { + if e.matchProperties.Parameter != nil { + return pgsql.CompoundIdentifier{frame.Binding.Identifier, e.properties} + } + object := pgsql.FunctionCall{Function: pgsql.FunctionJSONBBuildObject, CastType: pgsql.JSONB} + for _, key := range e.keys { + object.Parameters = append(object.Parameters, pgsql.NewLiteral(key, pgsql.Text), pgsql.CompoundIdentifier{frame.Binding.Identifier, e.keyColumns[key]}) + } + return object +} + +func mergeExists(selectQuery pgsql.Select) pgsql.Expression { + return pgsql.ExistsExpression{Subquery: pgsql.Subquery{Query: pgsql.Query{Body: selectQuery}}} +} + +func mergeCountDistinct(value pgsql.Expression) pgsql.FunctionCall { + return pgsql.FunctionCall{Function: "count", Distinct: true, Parameters: []pgsql.Expression{value}, CastType: pgsql.Int8} +} + +func mergeTable(frame *Frame, alias pgsql.Identifier) pgsql.FromClause { + return pgsql.FromClause{Source: pgsql.TableReference{Name: frame.Binding.Identifier.AsCompoundIdentifier(), Binding: pgsql.AsOptionalIdentifier(alias)}} +} + +func (s *Translator) validateMergeCandidates(candidate *Frame, plan *mergePlan, updates map[pgsql.Identifier]pgsql.Expression, singleNodePattern bool) (*Frame, error) { + one := pgsql.NewLiteral(1, pgsql.Int4) + falseValue := pgsql.NewLiteral(false, pgsql.Boolean) + // The aggregate consumes every input value even if no match, write source, + // or final output row demands it. Empty upstream inputs are successful. + var demanded pgsql.Expression + for _, entity := range plan.entities { + for _, column := range entity.inputColumns() { + demanded = pgsql.OptionalAnd(demanded, pgsql.NewBinaryExpression(pgsql.CompoundIdentifier{plan.input.Binding.Identifier, column}, pgsql.OperatorIsNot, pgsql.Literal{Null: true, CastType: pgsql.JSONB})) + } + } + if demanded == nil { + demanded = pgsql.NewLiteral(true, pgsql.Boolean) + } + inputValid := pgsql.Subquery{Query: pgsql.Query{Body: pgsql.Select{Projection: pgsql.Projection{pgsql.FunctionCall{Function: pgsql.FunctionCoalesce, Parameters: []pgsql.Expression{pgsql.FunctionCall{Function: "bool_and", Parameters: []pgsql.Expression{demanded}, CastType: pgsql.Boolean}, pgsql.NewLiteral(true, pgsql.Boolean)}, CastType: pgsql.Boolean}}, From: []pgsql.FromClause{frameReference(plan.input)}}}} + // A standalone single-node MERGE has one input. Its matches have unique + // target IDs, and at most one creation can occur. This proof does not + // depend on parameter contents and excludes carried action targets. + if plan.singleInput && singleNodePattern && len(plan.entities) == 1 { + return s.guardMergeCandidates(candidate, plan, inputValid, falseValue, falseValue, falseValue) + } + // UNION ALL retains distinct actions, including different bindings resolving + // to one stored ID. No properties enter target grouping. + source := &Frame{Binding: &BoundIdentifier{Identifier: "_merge_source"}} + created := pgsql.CompoundIdentifier{source.Binding.Identifier, plan.created.Identifier} + var actions pgsql.SetExpression + seen := pgsql.NewIdentifierSet() + for _, entity := range plan.entities { + binding := entity.binding + if seen.Contains(binding.Identifier) { + continue + } + seen.Add(binding.Identifier) + var writes pgsql.Expression = updates[binding.Identifier] + if !entity.bound { + if writes == nil { + writes = created + } else { + writes = pgsql.NewParenthetical(pgsql.NewBinaryExpression(writes, pgsql.OperatorOr, created)) + } + } + if writes == nil { + continue + } + projection := pgsql.Projection{mergeAlias(pgsql.NewLiteral(s.graphID, pgsql.Int4), "graph_id"), mergeAlias(pgsql.NewLiteral(binding.DataType.String(), pgsql.Text), "entity_type"), mergeAlias(mergeField(source, binding, pgsql.ColumnID), "target_id")} + selectQuery := pgsql.Select{Projection: projection, From: []pgsql.FromClause{mergeTable(candidate, source.Binding.Identifier)}, Where: writes} + if actions == nil { + actions = selectQuery + } else { + actions = pgsql.SetOperation{Operator: pgsql.OperatorUnion, All: true, LOperand: actions, ROperand: selectQuery} + } + } + var repeated pgsql.Expression = falseValue + if actions != nil { + actionFrame, err := s.scope.PushFrame() + if err != nil { + return nil, err + } + s.addCTE(actionFrame, actions) + groups := []pgsql.Expression{pgsql.CompoundIdentifier{actionFrame.Binding.Identifier, "graph_id"}, pgsql.CompoundIdentifier{actionFrame.Binding.Identifier, "entity_type"}, pgsql.CompoundIdentifier{actionFrame.Binding.Identifier, "target_id"}} + repeated = mergeExists(pgsql.Select{Projection: pgsql.Projection{one}, From: []pgsql.FromClause{frameReference(actionFrame)}, GroupBy: groups, Having: pgsql.NewBinaryExpression(pgsql.FunctionCall{Function: "count", Parameters: []pgsql.Expression{one}, CastType: pgsql.Int8}, pgsql.OperatorGreaterThan, one)}) + } + // A narrow absence relation separates input identity from result fanout. + absent, err := s.scope.PushFrame() + if err != nil { + return nil, err + } + absentProjection := pgsql.Projection{mergeAlias(pgsql.CompoundIdentifier{source.Binding.Identifier, plan.inputRow.Identifier}, "input_id")} + if singleNodePattern { + entity := plan.entities[0] + if entity.matchProperties.Parameter != nil { + absentProjection = append(absentProjection, mergeAlias(entity.propertiesAt(source), "original"), mergeAlias(mergeField(source, entity.binding, pgsql.ColumnProperties), "final")) + } else { + for idx, key := range entity.keys { + absentProjection = append(absentProjection, mergeAlias(pgsql.CompoundIdentifier{source.Binding.Identifier, entity.keyColumns[key]}, pgsql.Identifier(fmt.Sprintf("original_%d", idx)))) + if entity.keysChanged { + absentProjection = append(absentProjection, mergeAlias(pgsql.CompoundIdentifier{source.Binding.Identifier, entity.finalKeyColumns[key]}, pgsql.Identifier(fmt.Sprintf("final_%d", idx)))) + } + } + } + } + s.addCTE(absent, pgsql.Select{Projection: absentProjection, From: []pgsql.FromClause{mergeTable(candidate, source.Binding.Identifier)}, Where: created}) + absentCount := pgsql.Subquery{Query: pgsql.Query{Body: pgsql.Select{Projection: pgsql.Projection{mergeCountDistinct(pgsql.CompoundIdentifier{absent.Binding.Identifier, "input_id"})}, From: []pgsql.FromClause{frameReference(absent)}}}} + multiple := pgsql.NewBinaryExpression(absentCount, pgsql.OperatorGreaterThan, one) + var patterns, overlap pgsql.Expression = multiple, falseValue + if singleNodePattern { + patterns = falseValue + entity := plan.entities[0] + switch { + case entity.matchProperties.Parameter == nil && len(entity.keys) == 0: + overlap = multiple + case entity.matchProperties.Parameter == nil && !entity.keysChanged: + groups := []pgsql.Expression{} + for idx := range entity.keys { + groups = append(groups, pgsql.CompoundIdentifier{absent.Binding.Identifier, pgsql.Identifier(fmt.Sprintf("original_%d", idx))}) + } + overlap = mergeExists(pgsql.Select{Projection: pgsql.Projection{one}, From: []pgsql.FromClause{frameReference(absent)}, GroupBy: groups, Having: pgsql.NewBinaryExpression(mergeCountDistinct(pgsql.CompoundIdentifier{absent.Binding.Identifier, "input_id"}), pgsql.OperatorGreaterThan, one)}) + default: + a, b := pgsql.Identifier("_merge_original"), pgsql.Identifier("_merge_final") + constraint := pgsql.NewBinaryExpression(pgsql.CompoundIdentifier{a, "input_id"}, pgsql.OperatorNotEquals, pgsql.CompoundIdentifier{b, "input_id"}) + var equal pgsql.Expression + if entity.matchProperties.Parameter != nil { + unequal := pgsql.NewBinaryExpression(pgsql.CompoundIdentifier{"_merge_key", "value"}, pgsql.Operator("is distinct from"), pgsql.NewBinaryExpression(pgsql.CompoundIdentifier{b, "final"}, pgsql.OperatorJSONField, pgsql.CompoundIdentifier{"_merge_key", "key"})) + equal = pgsql.ExistsExpression{Negated: true, Subquery: pgsql.Subquery{Query: pgsql.Query{Body: pgsql.Select{Projection: pgsql.Projection{one}, From: []pgsql.FromClause{{Source: pgsql.AliasedExpression{Expression: pgsql.FunctionCall{Function: "jsonb_each", Parameters: []pgsql.Expression{pgsql.CompoundIdentifier{a, "original"}}}, Alias: pgsql.AsOptionalIdentifier("_merge_key")}}}, Where: unequal}}}} + } else { + for idx := range entity.keys { + equal = pgsql.OptionalAnd(equal, pgsql.NewBinaryExpression(pgsql.CompoundIdentifier{a, pgsql.Identifier(fmt.Sprintf("original_%d", idx))}, pgsql.OperatorEquals, pgsql.CompoundIdentifier{b, pgsql.Identifier(fmt.Sprintf("final_%d", idx))})) + } + } + overlap = mergeExists(pgsql.Select{Projection: pgsql.Projection{one}, From: []pgsql.FromClause{mergeTable(absent, a), mergeTable(absent, b)}, Where: pgsql.OptionalAnd(constraint, equal)}) + } + } + return s.guardMergeCandidates(candidate, plan, inputValid, repeated, patterns, overlap) +} + +func (s *Translator) guardMergeCandidates(candidate *Frame, plan *mergePlan, inputValid, repeated, patterns, overlap pgsql.Expression) (*Frame, error) { + falseValue := pgsql.NewLiteral(false, pgsql.Boolean) + guard, err := s.scope.PushFrame() + if err != nil { + return nil, err + } + check := pgsql.Identifier("_merge_valid") + s.addCTE(guard, pgsql.Select{Projection: pgsql.Projection{mergeAlias(pgsql.FunctionCall{Function: "cypher_merge_assert", Parameters: []pgsql.Expression{inputValid, repeated, patterns, overlap}, CastType: pgsql.Boolean}, check)}}) + s.query.CurrentPart().Model.CommonTableExpressions.Expressions[len(s.query.CurrentPart().Model.CommonTableExpressions.Expressions)-1].Materialized = &pgsql.Materialized{Materialized: true} + plan.guard = guard + needsAnchor := true + for _, entity := range plan.entities { + if !entity.bound { + needsAnchor = false + break + } + } + if needsAnchor { + // PostgreSQL prunes a DO NOTHING-only MERGE. A conditional INSERT keeps the + // source demanded, but the assertion either returns true or raises, so this + // anchor never writes a row (and never consumes an entity sequence value). + anchor, err := s.scope.PushFrame() + if err != nil { + return nil, err + } + s.addCTE(anchor, pgsql.Merge{Into: true, Table: pgsql.TableReference{Name: pgsql.TableNode.AsCompoundIdentifier()}, Source: pgsql.TableReference{Name: guard.Binding.Identifier.AsCompoundIdentifier()}, JoinTarget: falseValue, Actions: []pgsql.MergeAction{pgsql.UnmatchedAction{Predicate: pgsql.NewUnaryExpression(pgsql.OperatorNot, pgsql.CompoundIdentifier{guard.Binding.Identifier, check}), Columns: []pgsql.Identifier{pgsql.ColumnGraphID}, Values: pgsql.Values{Values: []pgsql.Expression{pgsql.Literal{Null: true, CastType: pgsql.Int4}}}}}}) + } + + projection, err := s.mergeCarry(candidate) + if err != nil { + return nil, err + } + return s.mergeStage(projection, []pgsql.FromClause{frameReference(candidate), frameReference(guard)}, pgsql.CompoundIdentifier{guard.Binding.Identifier, check}) +} + +func mapColumns(columns map[string]pgsql.Identifier) []pgsql.Identifier { + result := make([]pgsql.Identifier, 0, len(columns)) + for _, column := range columns { + result = append(result, column) + } + return result +} diff --git a/cypher/models/pgsql/translate/merge_test.go b/cypher/models/pgsql/translate/merge_test.go new file mode 100644 index 00000000..417f640c --- /dev/null +++ b/cypher/models/pgsql/translate/merge_test.go @@ -0,0 +1,355 @@ +package translate + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/specterops/dawgs/cypher/frontend" + "github.com/specterops/dawgs/cypher/models/pgsql" + "github.com/specterops/dawgs/cypher/models/pgsql/format" + "github.com/specterops/dawgs/cypher/models/walk" + "github.com/specterops/dawgs/drivers/pg/pgutil" + "github.com/specterops/dawgs/graph" + "github.com/stretchr/testify/require" +) + +func TestMergeMaterializedStringValues(t *testing.T) { + for _, mode := range []OptimizerMode{OptimizerEnabled, OptimizerDisabled} { + for _, query := range []string{ + `MERGE (n:NodeKind1 {name:'a'}) RETURN n`, + `MERGE (n:NodeKind1 {name:$name}) RETURN n`, + `MERGE (n:NodeKind1) ON CREATE SET n.name='a' RETURN n`, + `MERGE (n:NodeKind1) ON MATCH SET n.name=$name RETURN n`, + } { + t.Run(string(mode)+query, func(t *testing.T) { + mapper := pgutil.NewInMemoryKindMapper() + mapper.Put(graph.StringKind("NodeKind1")) + parsed, err := frontend.ParseCypher(frontend.NewContext(), query) + require.NoError(t, err) + result, err := TranslateWithOptions(context.Background(), parsed, mapper, map[string]any{"name": "a"}, 42, Options{OptimizerMode: mode}) + require.NoError(t, err) + materialized, err := format.Statement(result.Statement, format.NewOutputBuilder().WithMaterializedParameters(result.Parameters)) + require.NoError(t, err) + require.Contains(t, materialized.Statement, `to_jsonb((E'a')::text)`) + require.NotContains(t, materialized.Statement, `to_jsonb(E'a')`) + require.Empty(t, materialized.Parameters) + parameterized, err := Translated(result) + require.NoError(t, err) + require.Contains(t, parameterized.Statement, "::text)::jsonb") + }) + } + } +} + +func TestMergeTranslation(t *testing.T) { + for _, mode := range []OptimizerMode{OptimizerEnabled, OptimizerDisabled} { + for _, query := range []string{ + `MERGE (n:NodeKind1 {name:'new'}) RETURN n`, + `MERGE (n:NodeKind1 {name:$name}) ON CREATE SET n.score=1 ON MATCH SET n.score=2 SET n.done=true RETURN n`, + `MATCH (a:NodeKind1), (b:NodeKind2) MERGE (a)-[r:EdgeKind1]->(b) RETURN r`, + `MERGE p=(a:NodeKind1 {name:'a'})-[r:EdgeKind1]->(b:NodeKind2 {name:'b'}) RETURN p`, + `MERGE (a:NodeKind1)-[:EdgeKind1]-(b:NodeKind2) RETURN a,b`, + `MERGE (a:NodeKind1)-[:EdgeKind1]->(b:NodeKind2)-[:EdgeKind2]->(c:NodeKind1) RETURN a,c`, + `UNWIND ['a','b'] AS name MERGE (n:NodeKind1 {name:name}) RETURN n`, + `MERGE (n:NodeKind1) ON CREATE SET n:NodeKind2`, + `MERGE (n:NodeKind1) RETURN n LIMIT 0`, + `MERGE (n:NodeKind1) WITH n RETURN n`, + `MATCH (s:NodeKind1) WITH s MERGE (n:NodeKind2 {name:s.name}) RETURN n`, + `MERGE (n:NodeKind1) RETURN count(n)`, `MERGE (n:NodeKind1) RETURN count(*)`, `MERGE (n:NodeKind1) RETURN 1`, + `MERGE (n {name:'unlabeled'}) RETURN n`, + `MATCH (a:NodeKind1),(b:NodeKind2) MERGE (a)<-[r:EdgeKind1]-(b) ON CREATE SET a.flag=true RETURN r`, + } { + t.Run(string(mode)+query, func(t *testing.T) { + mapper := pgutil.NewInMemoryKindMapper() + for _, kind := range []string{"NodeKind1", "NodeKind2", "EdgeKind1", "EdgeKind2"} { + mapper.Put(graph.StringKind(kind)) + } + parsed, err := frontend.ParseCypher(frontend.NewContext(), query) + require.NoError(t, err) + result, err := TranslateWithOptions(context.Background(), parsed, mapper, map[string]any{"name": "a"}, 42, Options{OptimizerMode: mode}) + require.NoError(t, err) + _, ok := result.Statement.(pgsql.Query) + require.True(t, ok) + sql, err := Translated(result) + require.NoError(t, err) + require.Contains(t, sql.Statement, "merge into") + require.Contains(t, sql.Statement, "then do nothing") + require.Contains(t, sql.Statement, "union all") + require.Contains(t, sql.Statement, "returning") + require.NotContains(t, sql.Statement, "cypher_execute") + require.NotContains(t, sql.Statement, "update set id") + if strings.Contains(query, "ON MATCH") { + require.Contains(t, sql.Statement, "then update set") + } + }) + } + } +} + +func TestMergeLargePropertyPatches(t *testing.T) { + for _, mode := range []OptimizerMode{OptimizerEnabled, OptimizerDisabled} { + for _, count := range []int{50, 51, 100, 101} { + for _, action := range []string{"SET", "ON CREATE SET", "ON MATCH SET"} { + t.Run(fmt.Sprintf("%s/%s/%d", mode, action, count), func(t *testing.T) { + assignments := make([]string, count) + for idx := range assignments { + assignments[idx] = fmt.Sprintf("n.field%d=n.seed", idx) + } + query := `MERGE (n:NodeKind1 {name:'a'}) ` + action + " " + strings.Join(assignments, ",") + " RETURN n" + mapper := pgutil.NewInMemoryKindMapper() + mapper.Put(graph.StringKind("NodeKind1")) + parsed, err := frontend.ParseCypher(frontend.NewContext(), query) + require.NoError(t, err) + result, err := TranslateWithOptions(context.Background(), parsed, mapper, nil, 42, Options{OptimizerMode: mode}) + require.NoError(t, err) + fields := map[string]int{} + patchSizes := []int{} + require.NoError(t, walk.PgSQL(result.Statement, walk.NewSimpleVisitor[pgsql.SyntaxNode](func(node pgsql.SyntaxNode, _ walk.VisitorHandler) { + if call, ok := node.(pgsql.FunctionCall); ok && call.Function == pgsql.FunctionJSONBBuildObject { + require.LessOrEqual(t, len(call.Parameters), 100, "PostgreSQL rejects calls with more than 100 arguments") + isPatch := false + for idx := 0; idx < len(call.Parameters); idx += 2 { + key, ok := call.Parameters[idx].(pgsql.Literal) + require.True(t, ok) + if field, ok := key.Value.(string); ok && strings.HasPrefix(field, "field") { + fields[field]++ + isPatch = true + } + } + if isPatch { + patchSizes = append(patchSizes, len(call.Parameters)/2) + } + } + }))) + require.Len(t, fields, count) + for _, occurrences := range fields { + require.Equal(t, 1, occurrences, "each effective assignment appears once") + } + require.Len(t, patchSizes, (count+49)/50) + sql, err := Translated(result) + require.NoError(t, err) + require.Equal(t, 1, strings.Count(sql.Statement, "cypher_apply_property_patch("), "apply the complete patch once") + }) + } + } + } +} + +func TestInvalidMergePatterns(t *testing.T) { + for _, mode := range []OptimizerMode{OptimizerEnabled, OptimizerDisabled} { + for _, test := range []struct { + name, query, message string + }{ + {"missing relationship type", `MERGE ()-[]->() RETURN 1`, "MERGE relationships require exactly one type and no range"}, + {"relationship range", `MERGE ()-[:EdgeKind1*1..2]->() RETURN 1`, "MERGE relationships require exactly one type and no range"}, + {"scalar binding", `WITH 1 AS n MERGE (n) RETURN n`, "invalid MERGE binding n: expected nodecomposite"}, + {"repeated node labels", `MERGE (a:NodeKind1)-[:EdgeKind1]->(a:NodeKind2) RETURN a`, "MERGE cannot redeclare labels or properties on a declared node a"}, + {"repeated node properties", `MERGE (a:NodeKind1)-[:EdgeKind1]->(a {score:1}) RETURN a`, "MERGE cannot redeclare labels or properties on a declared node a"}, + {"bound node labels", `MATCH (n:NodeKind1) MERGE (n:NodeKind2) RETURN n`, "MERGE cannot redeclare labels or properties on a declared node n"}, + {"unbound property reference", `MERGE (n:NodeKind1 {name:n.name}) RETURN n`, "MERGE properties reference an unbound entity n"}, + {"bound relationship", `MATCH ()-[r:EdgeKind1]->() MERGE ()-[r:EdgeKind1]->() RETURN r`, "MERGE cannot redeclare a bound relationship"}, + } { + t.Run(string(mode)+"/"+test.name, func(t *testing.T) { + mapper := pgutil.NewInMemoryKindMapper() + for _, kind := range []string{"NodeKind1", "NodeKind2", "EdgeKind1"} { + mapper.Put(graph.StringKind(kind)) + } + parsed, err := frontend.ParseCypher(frontend.NewContext(), test.query) + require.NoError(t, err) + _, err = TranslateWithOptions(context.Background(), parsed, mapper, nil, 0, Options{OptimizerMode: mode}) + require.ErrorContains(t, err, test.message) + }) + } + } +} + +func TestMergeKindMapping(t *testing.T) { + for _, mode := range []OptimizerMode{OptimizerEnabled, OptimizerDisabled} { + for _, test := range []struct { + name, query, message string + }{ + {"register merge kind", `MERGE (n:NodeKind1) RETURN n`, ""}, + {"reject unknown match kind", `MATCH (n:NodeKind1) MERGE (n) RETURN n`, "failed to translate kinds: missing kinds: [NodeKind1]"}, + } { + t.Run(string(mode)+"/"+test.name, func(t *testing.T) { + mapper := pgutil.NewInMemoryKindMapper() + parsed, err := frontend.ParseCypher(frontend.NewContext(), test.query) + require.NoError(t, err) + _, err = TranslateWithOptions(context.Background(), parsed, mapper, nil, 0, Options{OptimizerMode: mode}) + if test.message != "" { + require.ErrorContains(t, err, test.message) + } else { + require.NoError(t, err) + _, err = mapper.MapKind(context.Background(), graph.StringKind("NodeKind1")) + require.NoError(t, err, "MERGE must register its kind before matching") + } + }) + } + } +} + +func TestMergeCopiedPropertyTypes(t *testing.T) { + for _, mode := range []OptimizerMode{OptimizerEnabled, OptimizerDisabled} { + mapper := pgutil.NewInMemoryKindMapper() + for _, kind := range []string{"NodeKind1", "NodeKind2", "EdgeKind1"} { + mapper.Put(graph.StringKind(kind)) + } + parsed, err := frontend.ParseCypher(frontend.NewContext(), `MATCH (a:NodeKind1) MERGE (n:NodeKind2 {score:a.score, active:a.active, values:a.values}) RETURN n`) + require.NoError(t, err) + result, err := TranslateWithOptions(context.Background(), parsed, mapper, nil, 42, Options{OptimizerMode: mode}) + require.NoError(t, err) + sql, err := Translated(result) + require.NoError(t, err) + for _, field := range []string{"score", "active", "values"} { + require.Contains(t, sql.Statement, "properties -> E'"+field+"'") + require.NotContains(t, sql.Statement, "properties ->> E'"+field+"'") + } + } +} + +func TestMergeCarriesLazyPath(t *testing.T) { + for _, mode := range []OptimizerMode{OptimizerEnabled, OptimizerDisabled} { + for _, prefix := range []string{ + `MATCH p=(a:NodeKind1)-[:EdgeKind1]->(b:NodeKind2)`, + `MERGE p=(a:NodeKind1)-[:EdgeKind1]->(b:NodeKind2)`, + } { + t.Run(string(mode)+prefix, func(t *testing.T) { + mapper := pgutil.NewInMemoryKindMapper() + for _, kind := range []string{"NodeKind1", "NodeKind2", "EdgeKind1"} { + mapper.Put(graph.StringKind(kind)) + } + parsed, err := frontend.ParseCypher(frontend.NewContext(), prefix+` MERGE (c:NodeKind1 {name:'independent'}) RETURN p`) + require.NoError(t, err) + result, err := TranslateWithOptions(context.Background(), parsed, mapper, nil, 42, Options{OptimizerMode: mode}) + require.NoError(t, err) + query := result.Statement.(pgsql.Query) + found := false + for _, cte := range query.CommonTableExpressions.Expressions { + selectQuery, ok := cte.Query.Body.(pgsql.Select) + if !ok { + continue + } + for _, item := range selectQuery.Projection { + alias, ok := item.(*pgsql.AliasedExpression) + if !ok || alias.Alias.Value != "pc0" || found { + continue + } + _, columnReference := alias.Expression.(pgsql.CompoundIdentifier) + require.False(t, columnReference, "the first path projection must build its value from dependencies") + found = true + } + } + require.True(t, found, "the incoming path must be projected into the MERGE pipeline") + }) + } + } +} + +func TestMergeWritesDependOnCandidateValidation(t *testing.T) { + for _, mode := range []OptimizerMode{OptimizerEnabled, OptimizerDisabled} { + mapper := pgutil.NewInMemoryKindMapper() + mapper.Put(graph.StringKind("NodeKind1")) + mapper.Put(graph.StringKind("EdgeKind1")) + parsed, err := frontend.ParseCypher(frontend.NewContext(), `MERGE (a:NodeKind1)-[:EdgeKind1]->(b:NodeKind1) ON MATCH SET a.score=1,b.score=2 RETURN a,b LIMIT 0`) + require.NoError(t, err) + result, err := TranslateWithOptions(context.Background(), parsed, mapper, nil, 42, Options{OptimizerMode: mode}) + require.NoError(t, err) + query := result.Statement.(pgsql.Query) + var guardID pgsql.Identifier + guardedSources := pgsql.NewIdentifierSet() + writes := 0 + for _, cte := range query.CommonTableExpressions.Expressions { + if selectQuery, ok := cte.Query.Body.(pgsql.Select); ok { + if len(selectQuery.Projection) == 1 { + if alias, ok := selectQuery.Projection[0].(*pgsql.AliasedExpression); ok { + if call, ok := alias.Expression.(pgsql.FunctionCall); ok && call.Function == "cypher_merge_assert" { + require.NotNil(t, cte.Materialized) + require.True(t, cte.Materialized.Materialized) + guardID = cte.Alias.Name + } + } + } + for _, from := range selectQuery.From { + if table, ok := from.Source.(pgsql.TableReference); ok && guardID != "" && table.Name[0] == guardID { + require.Equal(t, pgsql.CompoundIdentifier{guardID, "_merge_valid"}, selectQuery.Where) + guardedSources.Add(cte.Alias.Name) + } + } + } + if merge, ok := cte.Query.Body.(pgsql.Merge); ok { + if merge.Source.Name[0] == guardID { + continue + } + require.True(t, guardedSources.Contains(merge.Source.Name[0]), "each native write must read validated candidates") + writes++ + } + } + require.NotEmpty(t, guardID) + require.Equal(t, 3, writes) + } +} + +func TestMergeRelationalPipeline(t *testing.T) { + for _, mode := range []OptimizerMode{OptimizerEnabled, OptimizerDisabled} { + for _, test := range []struct { + query string + writes, values, patches int + grouped, dynamic bool + }{ + {`MERGE (n:NodeKind1 {name:$name,score:1}) RETURN n`, 1, 2, 0, true, false}, + {`MATCH (a:NodeKind1) MERGE (n:NodeKind1 {name:a.name}) RETURN n`, 1, 1, 0, true, false}, + {`MERGE (n:NodeKind1 $props) RETURN n`, 1, 0, 0, false, true}, + {`UNWIND ['a','b'] AS name MERGE (n:NodeKind1 {name:name}) ON CREATE SET n.name='b',n.x=1,n.y=2 RETURN n`, 1, 1, 1, false, false}, + {`MATCH (a:NodeKind1),(b:NodeKind1) MERGE (a)-[r:EdgeKind1]->(b) RETURN r`, 1, 0, 0, false, false}, + {`MATCH (a:NodeKind1) MERGE (a) RETURN a`, 0, 0, 0, false, false}, + {`MATCH (a:NodeKind1) UNWIND [1,2] AS input MERGE (a) RETURN *`, 0, 0, 0, false, false}, + {`MERGE (n:NodeKind1 {name:'a'}) SET n.name='b' RETURN *`, 1, 1, 1, false, false}, + {`MERGE (n:NodeKind1 {name:'a'}) SET n.x=1 SET n.y=n.x RETURN n`, 1, 1, 2, true, false}, + } { + t.Run(string(mode)+test.query, func(t *testing.T) { + mapper := pgutil.NewInMemoryKindMapper() + mapper.Put(graph.StringKind("NodeKind1")) + mapper.Put(graph.StringKind("EdgeKind1")) + parsed, err := frontend.ParseCypher(frontend.NewContext(), test.query) + require.NoError(t, err) + result, err := TranslateWithOptions(context.Background(), parsed, mapper, map[string]any{"name": "a", "props": map[string]any{"name": "a"}}, 42, Options{OptimizerMode: mode}) + require.NoError(t, err) + sql, err := Translated(result) + require.NoError(t, err) + require.NotContains(t, sql.Statement, "cypher_merge_candidates") + require.NotContains(t, sql.Statement, "jsonb_agg") + require.NotContains(t, sql.Statement, "cypher_set_property") + require.Equal(t, test.values, strings.Count(sql.Statement, "cypher_merge_value(")) + require.Equal(t, test.patches, strings.Count(sql.Statement, "cypher_apply_property_patch(")) + expectedWrites := test.writes + if test.writes == 0 { + expectedWrites++ + } + require.Equal(t, expectedWrites, strings.Count(sql.Statement, "merge into")) + if test.grouped && (strings.HasPrefix(test.query, "UNWIND") || strings.HasPrefix(test.query, "MATCH")) { + require.Contains(t, sql.Statement, "group by") + require.Contains(t, sql.Statement, "having count(distinct") + } + if test.dynamic { + require.Equal(t, 1, strings.Count(sql.Statement, "cypher_merge_properties(")) + require.Contains(t, sql.Statement, "jsonb_each") + } + query := result.Statement.(pgsql.Query) + if strings.HasSuffix(test.query, "RETURN *") { + output := query.Body.(pgsql.Select) + for _, item := range output.Projection { + if alias, ok := item.(*pgsql.AliasedExpression); ok { + require.Contains(t, []pgsql.Identifier{"n", "a", "input"}, alias.Alias.Value) + } + } + } + input := query.CommonTableExpressions.Expressions[0].Query.Body.(pgsql.Select) + if !strings.HasPrefix(test.query, "MATCH") { + require.Nil(t, input.Where, "validated input expressions are not duplicated in a predicate") + } + }) + } + } +} diff --git a/cypher/models/pgsql/translate/model.go b/cypher/models/pgsql/translate/model.go index 9315b327..65301ebe 100644 --- a/cypher/models/pgsql/translate/model.go +++ b/cypher/models/pgsql/translate/model.go @@ -616,6 +616,7 @@ type QueryPart struct { quantifierIdentifiers *pgsql.IdentifierSet unwindClauses []UnwindClause isCreating bool + containsMerge bool } type UnwindClause struct { diff --git a/cypher/models/pgsql/translate/projection.go b/cypher/models/pgsql/translate/projection.go index 74adafa3..3089777d 100644 --- a/cypher/models/pgsql/translate/projection.go +++ b/cypher/models/pgsql/translate/projection.go @@ -1357,6 +1357,14 @@ func (s *Translator) buildTailProjection() error { ) singlePartQuerySelect.From = s.collectProjectionFromFrames(currentPart.projections.Items) + if len(singlePartQuerySelect.From) == 0 && s.scope.CurrentFrame() != nil { + for _, part := range s.query.Parts { + if part.containsMerge { + singlePartQuerySelect.From = []pgsql.FromClause{frameReference(s.scope.CurrentFrame())} + break + } + } + } singlePartQuerySelect.From = append(singlePartQuerySelect.From, unwindFromClauses(currentPart.ConsumeUnwindClauses())...) if projectionConstraint, err := s.treeTranslator.ConsumeAllConstraints(); err != nil { diff --git a/cypher/models/pgsql/translate/query.go b/cypher/models/pgsql/translate/query.go index 9fd30beb..98a833e7 100644 --- a/cypher/models/pgsql/translate/query.go +++ b/cypher/models/pgsql/translate/query.go @@ -55,6 +55,11 @@ func (s *Translator) buildSinglePartQuery(singlePartQuery *cypher.SinglePartQuer s.query.CurrentPart().Model.Body = pgsql.Select{ Projection: []pgsql.SelectItem{literalReturn}, } + if s.query.CurrentPart().containsMerge { + body := s.query.CurrentPart().Model.Body.(pgsql.Select) + body.Where = pgsql.NewLiteral(false, pgsql.Boolean) + s.query.CurrentPart().Model.Body = body + } } } else if err := s.buildTailProjection(); err != nil { s.SetError(err) @@ -91,6 +96,10 @@ func (s *Translator) buildMultiPartQuery(singlePartQuery *cypher.SinglePartQuery nextCTE.Query.Body = inlineSelect } + if part.containsMerge && nextCTE.Query.CommonTableExpressions != nil { + multipartCTEChain = append(multipartCTEChain, nextCTE.Query.CommonTableExpressions.Expressions...) + nextCTE.Query.CommonTableExpressions = nil + } multipartCTEChain = append(multipartCTEChain, nextCTE) } diff --git a/cypher/models/pgsql/translate/translator.go b/cypher/models/pgsql/translate/translator.go index e7f354f2..ed1d9963 100644 --- a/cypher/models/pgsql/translate/translator.go +++ b/cypher/models/pgsql/translate/translator.go @@ -3,6 +3,7 @@ package translate import ( "context" "fmt" + "sort" "strings" "github.com/specterops/dawgs/cypher/models/cypher" @@ -56,6 +57,8 @@ func (s Options) normalized() (Options, error) { type Translator struct { walk.Visitor[cypher.SyntaxNode] + pendingMerge *cypher.Merge + ctx context.Context kindMapper *contextAwareKindMapper graphID int32 @@ -187,6 +190,18 @@ func (s *Translator) SetOptimizationPlan(plan optimize.Plan) { } func (s *Translator) Enter(expression cypher.SyntaxNode) { + switch expression.(type) { + case *cypher.Merge, *cypher.Create, *cypher.Delete, *cypher.Remove, *cypher.With, *cypher.Return: + if err := s.flushMerge(); err != nil { + s.SetError(err) + return + } + } + if set, ok := expression.(*cypher.Set); ok && s.pendingMerge != nil { + s.pendingMerge.MergeActions = append(s.pendingMerge.MergeActions, &cypher.MergeAction{OnCreate: true, OnMatch: true, Set: cypher.Copy(set)}) + s.Consume() + return + } switch typedExpression := expression.(type) { case *cypher.RegularQuery, *cypher.SingleQuery, *cypher.PatternElement, *cypher.Comparison, *cypher.Skip, *cypher.Limit, cypher.Operator, *cypher.ArithmeticExpression, @@ -216,6 +231,15 @@ func (s *Translator) Enter(expression cypher.SyntaxNode) { s.unwindTargets[typedExpression.Variable] = struct{}{} } + case *cypher.Merge: + // Preserve clause-entry bindings and route following SETs into both branches. + if err := s.flushCollectedMutations(); err != nil { + s.SetError(err) + return + } + s.pendingMerge = cypher.Copy(typedExpression) + s.Consume() + case *cypher.Create: // CREATE pattern nodes and relationships are collected first, then // translated into mutation CTEs after the full pattern is known. @@ -326,6 +350,27 @@ func (s *Translator) Enter(expression cypher.SyntaxNode) { } case *cypher.ProjectionItem: + if variable, ok := typedExpression.Expression.(*cypher.Variable); ok && variable.Symbol == "*" && typedExpression.Alias == nil && s.query.CurrentPart().containsMerge { + // Expand user aliases after MERGE has hidden its private pipeline + // fields. Anonymous entities and bookkeeping have no user alias. + part := s.query.CurrentPart() + part.projections.Frame = s.scope.CurrentFrame() + bindings := []*BoundIdentifier{} + for _, id := range s.scope.CurrentFrame().Known().Slice() { + binding, _ := s.scope.Lookup(id) + if binding.Alias.Set { + bindings = append(bindings, binding) + } + } + sort.Slice(bindings, func(i, j int) bool { return bindings[i].Alias.Value < bindings[j].Alias.Value }) + for _, binding := range bindings { + part.PrepareProjection() + part.CurrentProjection().SelectItem = binding.Identifier + part.CurrentProjection().SetAlias(binding.Alias.Value) + } + s.Consume() + return + } if typedExpression.Alias != nil { if _, collectIDs := s.collectIDMembershipAliases[pgsql.Identifier(typedExpression.Alias.Symbol)]; collectIDs { s.collectIDProjectionDepth++ @@ -636,6 +681,10 @@ func (s *Translator) Exit(expression cypher.SyntaxNode) { } case *cypher.ProjectionItem: + if variable, ok := typedExpression.Expression.(*cypher.Variable); ok && variable.Symbol == "*" && typedExpression.Alias == nil && s.query.CurrentPart().containsMerge { + return + } + if err := s.translateProjectionItem(s.scope, typedExpression); err != nil { s.SetError(err) } @@ -666,6 +715,10 @@ func (s *Translator) Exit(expression cypher.SyntaxNode) { } case *cypher.SinglePartQuery: + if err := s.flushMerge(); err != nil { + s.SetError(err) + return + } if err := s.buildSinglePartQuery(typedExpression); err != nil { s.SetError(err) } diff --git a/cypher/models/walk/merge_test.go b/cypher/models/walk/merge_test.go new file mode 100644 index 00000000..9c887c01 --- /dev/null +++ b/cypher/models/walk/merge_test.go @@ -0,0 +1,35 @@ +package walk_test + +import ( + "testing" + + "github.com/specterops/dawgs/cypher/models/pgsql" + "github.com/specterops/dawgs/cypher/models/walk" + "github.com/stretchr/testify/require" +) + +func TestPostgreSQLMergeWalker(t *testing.T) { + for _, subquery := range []bool{false, true} { + merge := pgsql.Merge{Table: pgsql.TableReference{Name: pgsql.Identifier("target").AsCompoundIdentifier()}, Source: pgsql.TableReference{Name: pgsql.Identifier("source").AsCompoundIdentifier()}, JoinTarget: pgsql.NewLiteral(true, pgsql.Boolean), Actions: []pgsql.MergeAction{pgsql.MergeDoNothing{Matched: true, Predicate: pgsql.Identifier("nothing_predicate")}, pgsql.MatchedUpdate{Predicate: pgsql.Identifier("update_predicate"), Assignments: []pgsql.Assignment{pgsql.NewBinaryExpression(pgsql.Identifier("prop"), pgsql.OperatorAssignment, pgsql.Identifier("assigned_value"))}}, pgsql.MatchedDelete{Predicate: pgsql.Identifier("delete_predicate")}, pgsql.UnmatchedAction{Predicate: pgsql.Identifier("insert_predicate"), Columns: []pgsql.Identifier{"prop"}, Values: pgsql.Values{Values: []pgsql.Expression{pgsql.Identifier("inserted_value")}}}}, Returning: pgsql.Projection{pgsql.Identifier("returned_value")}} + if subquery { + merge.SourceQuery = &pgsql.Subquery{Query: pgsql.Query{Body: pgsql.Select{Projection: pgsql.Projection{pgsql.Identifier("source_value")}}}} + } + seen := map[pgsql.Identifier]bool{} + require.NoError(t, walk.PgSQL(pgsql.Query{Body: merge}, walk.NewSimpleVisitor(func(node pgsql.SyntaxNode, _ walk.VisitorHandler) { + if id, ok := node.(pgsql.Identifier); ok { + seen[id] = true + } + if id, ok := node.(pgsql.CompoundIdentifier); ok && len(id) > 0 { + seen[id[0]] = true + } + }))) + for _, id := range []pgsql.Identifier{"nothing_predicate", "update_predicate", "assigned_value", "delete_predicate", "insert_predicate", "inserted_value", "returned_value"} { + require.True(t, seen[id], "missing %s", id) + } + if subquery { + require.True(t, seen["source_value"]) + } else { + require.True(t, seen["source"]) + } + } +} diff --git a/cypher/models/walk/walk_pgsql.go b/cypher/models/walk/walk_pgsql.go index aabf2e08..1dd5b985 100644 --- a/cypher/models/walk/walk_pgsql.go +++ b/cypher/models/walk/walk_pgsql.go @@ -40,6 +40,55 @@ func newSQLWalkCursor(node pgsql.SyntaxNode) (*Cursor[pgsql.SyntaxNode], error) } switch typedNode := node.(type) { + case pgsql.Values: + branches, err := pgsqlSyntaxNodeSliceTypeConvert(typedNode.Values) + if err != nil { + return nil, err + } + return &Cursor[pgsql.SyntaxNode]{Node: node, Branches: branches}, nil + case pgsql.Merge: + cursor := &Cursor[pgsql.SyntaxNode]{Node: node} + cursor.AddBranches(typedNode.Table, typedNode.JoinTarget, typedNode.Returning) + if typedNode.SourceQuery != nil { + cursor.AddBranches(*typedNode.SourceQuery) + } else { + cursor.AddBranches(typedNode.Source) + } + for _, action := range typedNode.Actions { + cursor.AddBranches(action) + } + return cursor, nil + case pgsql.MatchedUpdate: + cursor := &Cursor[pgsql.SyntaxNode]{Node: node} + if typedNode.Predicate != nil { + cursor.AddBranches(typedNode.Predicate) + } + for _, assignment := range typedNode.Assignments { + cursor.AddBranches(assignment) + } + return cursor, nil + case pgsql.MatchedDelete: + cursor := &Cursor[pgsql.SyntaxNode]{Node: node} + if typedNode.Predicate != nil { + cursor.AddBranches(typedNode.Predicate) + } + return cursor, nil + case pgsql.MergeDoNothing: + cursor := &Cursor[pgsql.SyntaxNode]{Node: node} + if typedNode.Predicate != nil { + cursor.AddBranches(typedNode.Predicate) + } + return cursor, nil + case pgsql.UnmatchedAction: + cursor := &Cursor[pgsql.SyntaxNode]{Node: node} + if typedNode.Predicate != nil { + cursor.AddBranches(typedNode.Predicate) + } + for _, column := range typedNode.Columns { + cursor.AddBranches(column) + } + cursor.AddBranches(typedNode.Values) + return cursor, nil case pgsql.Query: nextCursor := &Cursor[pgsql.SyntaxNode]{ Node: node, diff --git a/cypher/test/cases/mutation_tests.json b/cypher/test/cases/mutation_tests.json index dc73b031..189476bb 100644 --- a/cypher/test/cases/mutation_tests.json +++ b/cypher/test/cases/mutation_tests.json @@ -1,5 +1,13 @@ { "test_cases": [ + { + "name": "merge string match and action values", + "type": "string_match", + "details": { + "query": "merge (n:NodeKind1 {name: 'a'}) on create set n.status = 'created' on match set n.status = 'matched' return n", + "fitness": 6 + } + }, { "name": "Multipart query with mutation", "type": "string_match", @@ -248,6 +256,126 @@ "query": "match (a:Thing1), (b:Thing2) detach delete a, b return b", "fitness": 4 } + }, + { + "name": "Merge bound endpoints with conditional relationship actions", + "type": "string_match", + "details": { + "query": "match (a:NodeKind1), (b:NodeKind2) merge (a)-[r:EdgeKind1]-\u003e(b) on create set r.score = 1 on match set r.score = 2 return r", + "fitness": 5 + } + }, + { + "name": "Merge complete named path with two relationships", + "type": "string_match", + "details": { + "query": "merge p = (a:NodeKind1)-[:EdgeKind1]-\u003e(b:NodeKind2)-[:EdgeKind2]-\u003e(c:NodeKind1) return p", + "fitness": 3 + } + }, + { + "name": "Merge unwound parameter properties", + "type": "string_match", + "details": { + "query": "unwind ['a', 'b'] as name merge (n:NodeKind1 {name: name}) on create set n.score = 1 return n", + "fitness": 6 + } + }, + { + "name": "Merge copies typed entity properties", + "type": "string_match", + "details": { + "query": "match (a:NodeKind1) merge (n:NodeKind2 {active: a.active, score: a.score, values: a.values}) return n", + "fitness": 13 + } + }, + { + "name": "Merge carries a preceding named path", + "type": "string_match", + "details": { + "query": "merge p = (a:NodeKind1)-[:EdgeKind1]-\u003e(b:NodeKind2) merge (c:NodeKind1 {name: 'independent'}) return p", + "fitness": 11 + } + }, + { + "name": "Merge repeated binding keeps ordered actions", + "type": "string_match", + "details": { + "query": "merge (a:NodeKind1)-[:EdgeKind1]-\u003e(a) on match set a.score = 1 on match set a.score = 2 return a", + "fitness": 3 + } + }, + { + "name": "merge clause swap", + "type": "string_match", + "details": { + "query": "merge (n:NodeKind1 {name: 'a'}) on create set n.a = 1, n.b = 2 set n.a = n.b, n.b = n.a return n", + "fitness": 6 + } + }, + { + "name": "merge separate clause dependency", + "type": "string_match", + "details": { + "query": "merge (n:NodeKind1 {name: 'a'}) set n.a = 1 set n.b = n.a return n", + "fitness": 6 + } + }, + { + "name": "merge repeated field final non-null", + "type": "string_match", + "details": { + "query": "merge (n:NodeKind1 {name: 'a'}) set n.a = null, n.a = 3 return n", + "fitness": 6 + } + }, + { + "name": "merge repeated field final null", + "type": "string_match", + "details": { + "query": "merge (n:NodeKind1 {name: 'a'}) set n.a = 3, n.a = null return n", + "fitness": 6 + } + }, + { + "name": "merge changes duplicate originals away", + "type": "string_match", + "details": { + "query": "unwind ['a', 'a'] as name merge (n:NodeKind1 {name: name}) on create set n.name = 'away' return n", + "fitness": 6 + } + }, + { + "name": "merge composite distinct originals", + "type": "string_match", + "details": { + "query": "unwind [1, 2] as value merge (n:NodeKind1 {name: 'same', score: value}) return n", + "fitness": 9 + } + }, + { + "name": "Merge wildcard hides pipeline columns", + "type": "string_match", + "details": { + "query": "merge (n:NodeKind1 {name: 'a'}) on create set n.name = 'b' return *", + "fitness": 6 + } + }, + { + "name": "merge 50 property patch", + "type": "string_match", + "details": { + "query": "merge (n:NodeKind1 {left: 1, name: 'large', right: 2}) set n.field0 = 0, n.field1 = 1, n.field2 = 2, n.field3 = 3, n.field4 = 4, n.field5 = 5, n.field6 = 6, n.field7 = 7, n.field8 = 8, n.field9 = 9, n.field10 = 10, n.field11 = 11, n.field12 = 12, n.field13 = 13, n.field14 = 14, n.field15 = 15, n.field16 = 16, n.field17 = 17, n.field18 = 18, n.field19 = 19, n.field20 = 20, n.field21 = 21, n.field22 = 22, n.field23 = 23, n.field24 = 24, n.field25 = 25, n.field26 = 26, n.field27 = 27, n.field28 = 28, n.field29 = 29, n.field30 = 30, n.field31 = 31, n.field32 = 32, n.field33 = 33, n.field34 = 34, n.field35 = 35, n.field36 = 36, n.field37 = 37, n.field38 = 38, n.field39 = 39, n.field40 = 40, n.field41 = 41, n.field42 = 42, n.field43 = 43, n.field44 = 44, n.field45 = 45, n.field46 = 46, n.field47 = 47, n.left = n.right, n.right = n.left return n.field0, n.field1, n.field2, n.field3, n.field4, n.field5, n.field6, n.field7, n.field8, n.field9, n.field10, n.field11, n.field12, n.field13, n.field14, n.field15, n.field16, n.field17, n.field18, n.field19, n.field20, n.field21, n.field22, n.field23, n.field24, n.field25, n.field26, n.field27, n.field28, n.field29, n.field30, n.field31, n.field32, n.field33, n.field34, n.field35, n.field36, n.field37, n.field38, n.field39, n.field40, n.field41, n.field42, n.field43, n.field44, n.field45, n.field46, n.field47, n.left, n.right", + "fitness": 12 + } + }, + { + "name": "merge 51 property patch", + "type": "string_match", + "details": { + "query": "merge (n:NodeKind1 {left: 1, name: 'large', right: 2}) set n.field0 = 0, n.field1 = 1, n.field2 = 2, n.field3 = 3, n.field4 = 4, n.field5 = 5, n.field6 = 6, n.field7 = 7, n.field8 = 8, n.field9 = 9, n.field10 = 10, n.field11 = 11, n.field12 = 12, n.field13 = 13, n.field14 = 14, n.field15 = 15, n.field16 = 16, n.field17 = 17, n.field18 = 18, n.field19 = 19, n.field20 = 20, n.field21 = 21, n.field22 = 22, n.field23 = 23, n.field24 = 24, n.field25 = 25, n.field26 = 26, n.field27 = 27, n.field28 = 28, n.field29 = 29, n.field30 = 30, n.field31 = 31, n.field32 = 32, n.field33 = 33, n.field34 = 34, n.field35 = 35, n.field36 = 36, n.field37 = 37, n.field38 = 38, n.field39 = 39, n.field40 = 40, n.field41 = 41, n.field42 = 42, n.field43 = 43, n.field44 = 44, n.field45 = 45, n.field46 = 46, n.field47 = 47, n.field48 = 48, n.left = n.right, n.right = n.left return n.field0, n.field1, n.field2, n.field3, n.field4, n.field5, n.field6, n.field7, n.field8, n.field9, n.field10, n.field11, n.field12, n.field13, n.field14, n.field15, n.field16, n.field17, n.field18, n.field19, n.field20, n.field21, n.field22, n.field23, n.field24, n.field25, n.field26, n.field27, n.field28, n.field29, n.field30, n.field31, n.field32, n.field33, n.field34, n.field35, n.field36, n.field37, n.field38, n.field39, n.field40, n.field41, n.field42, n.field43, n.field44, n.field45, n.field46, n.field47, n.field48, n.left, n.right", + "fitness": 12 + } } ] } diff --git a/docker-compose.yml b/docker-compose.yml index 83a951ce..04a471cc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: postgres: - image: index.docker.io/library/postgres@sha256:06cad38a5d9f5d24b4d83d86def30795d5e4b757fedbf5281172b576dedcd941 + image: index.docker.io/library/postgres@sha256:06cad38a5d9f5d24b4d83d86def30795d5e4b757fedbf5281172b576dedcd941 # ratchet:postgres:18 environment: POSTGRES_USER: dawgs POSTGRES_PASSWORD: weneedbetterpasswords diff --git a/docs/development.md b/docs/development.md index b39bcfe7..6a613544 100644 --- a/docs/development.md +++ b/docs/development.md @@ -17,6 +17,9 @@ make test - `.coverage/unit.out` - `.coverage/coverage.txt` +The PostgreSQL driver requires PostgreSQL 18 or newer. CI and `docker-compose.yml` use the same PostgreSQL 18 image. +Connection hooks and schema/transaction acquisition reject older servers, including supplied pools. + Run the integration suite when a backend is available: ```bash diff --git a/docs/merge_implementation.md b/docs/merge_implementation.md new file mode 100644 index 00000000..fddc0440 --- /dev/null +++ b/docs/merge_implementation.md @@ -0,0 +1,127 @@ +# PostgreSQL MERGE implementation evidence + +This delivery implements the initial pipeline in `merge_gaps_plan.md` (phases 0–5). Ordered execution is the separate +extension in section 5 of that plan. The single-statement snapshot, ordered-input rejection messages, and concurrent +storage-conflict contract remain in force. + +## Implementation and semantic gates + +- Fixed match values are individually projected/validated once. Dynamic maps are validated once per input; string, + array, SQL NULL, and other non-object map parameters receive SQLSTATE 22023. Nested JSON nulls remain valid. +- Matching references evaluated input columns. String lookups retain their stored-value type guard and expression + index shape. Fixed property objects are assembled on creation. +- A full aggregate demands every actual input value. A materialized assertion combines that input validation with + relational conflict facts. Empty upstream inputs succeed. The first entity MERGE with an INSERT action consumes a + private sentinel whose DO NOTHING predicate demands the assertion. With only bound entities, a separate conditional + INSERT MERGE anchors validation; it never inserts or consumes a sequence. Its INSERT statement triggers run, requiring + INSERT permission on node. A DO NOTHING-only anchor and an UPDATE-only statically null sentinel were pruned in the + PostgreSQL 18.6 execution spikes, including with LIMIT 0. +- Effective actions use UNION ALL and group graph/type/target ID; properties are excluded. Absent input identity is + counted separately from result/action fanout. Fixed preserved keys group the complete JSONB tuple. Changed keys join + original to final tuples in both directed possibilities. Empty fixed maps explicitly conflict for multiple absent + inputs. Dynamic maps compare runtime fields exactly and retain a potentially quadratic fallback. A proven standalone + single-node input cannot repeat target writes or overlap another creation, so its candidate conflict scans are omitted. + That proof excludes upstream row sources, UNWIND, complete patterns, and carried action targets. +- A materialized patch evaluates each effective RHS against the incoming clause frame. Duplicate-field precedence + comes from IndexedSlice's last-value replacement. One helper applies top-level null removals and non-null assignments + using one subtraction and concatenation per binding/clause. Composite action stages are also materialized, preventing + a later clause's many property reads from repeating an earlier bag reconstruction. Final fixed keys follow projected + patches independently of payload properties. Whole composites are conservatively assembled at clause boundaries for + downstream entity/property/path consumers; general whole-bag demand analysis is not introduced. +- Bound entities without actions emit no entity write. Runtime sources filter to effective actions without comparing + changed values. Explicit same-value SET remains a write. RETURNING is joined by result identity; unchanged candidates + supply fallback values. Private key/patch columns are pruned after use. MERGE wildcard projections expand visible + user aliases in name order, excluding bookkeeping and anonymous entities. +- New helpers have exact-signature teardown and repeated/fresh/populated-schema lifecycle tests. The legacy JSON + candidate helper remains installed for older running processes. Compiler policy is `compiler-v5:optimized`; existing + schema assertion and cache generation invalidation remain in place. + +The regression inventory covers no RETURN, LIMIT 0, scalar/count output, late invalid rows, several pattern maps, +empty upstream input, read-only bound repetitions, bound endpoints, SQLSTATE/atomicity, cached map shape changes, +composite/empty keys, changed-away duplicates, removal, dynamic subset maps, array order/duplicates/subsets, clause +swaps/dependencies, repeated assignments, nested nulls, labels, complete patterns, graph isolation, paths, and indexed +lookups. The input counter test observes four fixed validations for two keys on two rows and two dynamic validations +for two matched inputs under LIMIT 0. Core cases/templates have identical expectations on PostgreSQL and Neo4j. A MERGE containing only one already-bound +node is PostgreSQL-scoped because Neo4j rejects that syntax; shared read-only repetition cases use matched relationships +between bound endpoints. + +## Reproducible evidence + +Baseline source: `69bb2e8`, with the expanded benchmark/plan harness copied into an isolated checkout. Both versions +use the same local PostgreSQL 18.6 server on an Intel Core i9-12900HK. Server settings: work_mem 4 MB, +synchronous_commit on, fsync on, full_page_writes on. The benchmark graph has a name B-tree index. Parameters and +seeds are prepared outside timing; translation is warm. Matrix runs include result consumption and transaction +rollback; the small legacy scenarios include commit. Rolled-back sequence values are not reset. Creation fixtures +remain creations across iterations. Dynamic matching uses one map against a batch-sized fixture. Go allocation +figures measure client allocations, not PostgreSQL memory. + +Generated SQL and plan evidence is under `.coverage/merge-plans/{before,after}`. Benchmark logs, including unsuccessful +samples, are under `.coverage/merge-evidence`. Reproduce captures with the commands in `integration/BENCHMARKS.md`. +Run captures, benchmarks, and database suites serially; shared integration setup can reset database tables. + +The captured plans confirm these structural changes: + +| Workload | Baseline | New pipeline | +| --- | --- | --- | +| Fixed preserved-key creation | JSON candidate aggregation; overlap inside helper | Key grouping with distinct input IDs; no candidate JSON envelope | +| Unrelated payload 0 versus 4096 bytes | Payload serialized into candidate validation | Identical target-group width 48 and absent-key group width 36 in both captured plans | +| Changed fixed keys | Pair enumeration inside JSON helper | Equality join on projected original/final JSONB keys, excluding self input | +| Relationship between unchanged bound nodes | Three entity MERGE statements | One edge MERGE, including the guard sentinel; endpoint versions unchanged | +| Compatible multi-field SET | Nested bag replacement per field | One patch helper per binding/clause, with materialized RHS and composite stages | +| Indexed string lookup | Type guard plus text comparison | Same stored index expression, projected text RHS; property-index test passes | + +The optimizer may remove constant graph/type group dimensions, or choose nested loops for small equality joins. +These are relational key checks; neither algorithm nor planner choice justifies a universal linear-time claim. +Estimated widths are planner figures, not total or peak memory. The broader result/mutation candidate relation still +carries entity composites and therefore still incurs payload, materialization, TOAST, and WAL costs. + +PostgreSQL 18.6 emitted malformed execution-plan JSON for partitioned MERGE: tuple counters were placed inside the +Target Tables array. A minimal standalone MERGE reproduced this. The harness retains the raw server output and obtains +valid planning JSON and execution text in separate rollback transactions. It does not silently repair instrumentation. +Rejected workloads use non-executing EXPLAIN, followed by an independently measured/asserted runtime rejection. + +## Timing observations and limits + +Exploratory five-sample, 200 ms matrix medians are shown below. These are observations from this environment, not +portable performance guarantees; generated plans establish which work was removed. + +| Workload | Baseline median | Changed median | +| --- | ---: | ---: | +| Fixed creation, 8 inputs, no payload | 1.225 ms | 0.996 ms | +| Fixed creation, 64 inputs, no payload | 9.206 ms | 3.738 ms | +| Fixed creation, 512 inputs, no payload | 401.212 ms | 18.901 ms | +| Fixed creation, 64 inputs, 4096-byte payload | 13.652 ms | 9.307 ms | +| Matched update, 64 inputs, 4096-byte payload, 8 fields | 8.229 ms | 6.606 ms | +| Matched update, 64 inputs, 4096-byte payload, 32 fields | 17.093 ms | 8.146 ms | +| Dynamic unchanged match, 64-node fixture, 4096-byte payload | 3.609 ms | 3.958 ms | + +The exploratory 4096-input baseline took roughly 22 seconds per operation, compared with under 0.2 seconds in changed +samples. That baseline run overlapped unrelated validation work and is preserved as exploratory evidence rather than +used to set a numerical acceptance threshold. + +Small-query measurements did not establish a regression-free result. In later five-sample, one-second runs the indexed +unchanged median was 0.264 ms for baseline versus 0.767 ms for the changed pipeline; the alternating-update medians were +0.661 ms versus 1.322 ms. Other runs gave substantially different absolute timings. Host load rose above 10 during +sampling; fixture churn, plan preparation, and shared-server activity also limit cross-run comparisons. The initial +separate guard MERGE was removed from creation-capable queries after small-query regressions were observed; the +remaining singleton input scan/assertion/sentinel and extra clause materialization are real costs. Dynamic fallback +also remains expensive. These unsuccessful observations must not be interpreted as a passed wall-clock regression +budget. A stable, isolated baseline with interleaved samples is needed before setting and adjudicating numerical +regression/improvement thresholds. No general speedup or total-memory claim is made here. + +## Validation + +Use `make format` (or goimports on the touched files), `make test_update`, and `make test_all` separately with each +user-supplied backend connection. The generator writes updated fixtures before checking existing expectations; a first +regeneration with changed expectations may fail before Makefile's copy step. Install/review the generated artifacts +and rerun the target. Preserve source cases and remove unrelated serialization/whitespace diffs. + +Both PostgreSQL and Neo4j `make test_all` runs passed. Comparable unit coverage without a database connection rose +from 61.1% to 61.4%; the PostgreSQL-selected unit run reported 62.7%. `make test_update` passed and generated artifacts +were reviewed. A separate temporary-sequence spike confirmed that the fallback anchor does not invoke column defaults +or insert rows under LIMIT 0. + +The Go launcher in this environment cannot execute build artifacts in /tmp. Validation uses a project-local GOTMPDIR; +when builds are active, format only touched sources so the repository-wide find does not traverse transient Go files. +A missing transitive randomstring checksum prevented the original coverage report command; its two go.sum hashes were +added without changing dependency versions. diff --git a/docs/postgresql_translation.md b/docs/postgresql_translation.md index 5484ae70..0205eb87 100644 --- a/docs/postgresql_translation.md +++ b/docs/postgresql_translation.md @@ -1,6 +1,6 @@ # PostgreSQL Translation -DAWGS translates supported Cypher queries to vanilla PostgreSQL 16 SQL. The implementation lives under +DAWGS translates supported Cypher queries to vanilla PostgreSQL 18 SQL. The implementation lives under [`cypher/models/pgsql`](../cypher/models/pgsql). ## Package Layout @@ -150,3 +150,86 @@ CONNECTION_STRING="postgresql://dawgs:weneedbetterpasswords@localhost:65432/dawg PostgreSQL-only plan-corpus validation should confirm that `ExactRangeExpansion` and `PathRelationshipPredicate` are planned and applied for their supported cases without skipped entries for either lowering. + +## MERGE + +The PostgreSQL driver requires version 18 or newer, including callers supplying their own pool. Constructor signatures +remain unchanged; connection hooks and transaction acquisition reject older servers before schema creation or queries. +CI uses PostgreSQL 18. + +MERGE snapshots incoming bindings before introducing pattern variables. A materialized input CTE evaluates and validates match +values once on both branches, including cached translations. Fixed maps project one JSONB column per AST key; dynamic +maps retain runtime map validation. Matching uses these columns, including the typed text RHS of indexed string lookups. +Fixed property objects are assembled only in the creation branch. A read branch searches for the **complete** pattern. If +that search fails for an input row, the creation branch allocates identifiers for its unbound nodes and relationships. +Partially matching unbound nodes are not reused. Undirected patterns match either orientation and create relationships +from the left endpoint to the right. Relationship types must be singular; variable-length relationships and already +bound relationship declarations are rejected. Previously declared nodes, including repeated variables within a pattern, must be referenced without redeclaring labels or properties. + +Conditional property/label SET clauses and ordinary SET immediately following MERGE use a materialized patch projection +per clause. Each effective RHS is evaluated once against the incoming clause frame. The patch helper splits top-level +null removals from values to set, applying `(properties - removed_keys) || set_values` once per binding/clause. +Nested nulls survive. Final fixed match keys are projected from patches independently of unrelated payloads. +Candidate composites are conservatively assembled at clause boundaries for entity, property, and path consumers before native +`MERGE ... RETURNING` writes. Right-hand sides within one SET clause observe that clause's incoming values. Separate +SET clauses are processed in order. Null assignments remove the selected property; null match properties raise an +error. Changed rows are returned by native MERGE; unchanged matches use `DO NOTHING` and are carried through a read +branch with `UNION ALL`, preserving all existing matches without a dummy UPDATE. Named paths use the returned entity +composites. A singleton materialized guard demands every actual input value through a full aggregate scan and checks candidate +conflicts. Each write source depends on that guard and filters to effective actions. The first native MERGE that can create an entity consumes a private sentinel source row whose DO NOTHING predicate +references the guard. The INSERT action keeps PostgreSQL from pruning that source, even with LIMIT 0 or no actual +entity actions. The sentinel has no target ID and produces no RETURNING row. When all MERGE entities are bound, a +separate native MERGE anchor consumes the guard even without RETURN or with empty write sources. Its unmatched INSERT predicate +is `NOT guard.valid`; the assertion returns true or raises, so no anchor row is inserted and no sequence is consumed. +PostgreSQL prunes a MERGE with only DO NOTHING actions, and can prune an UPDATE-only sentinel source whose target ID +is statically null. Neither form anchors validation. The separate anchor requires INSERT +permission on `node` and invokes INSERT statement triggers; it invokes no row triggers. Database errors roll back the query. +Bound entities with no mutation actions emit no entity write statement; unchanged results use their candidate values. +String property predicates retain the JSON type guard and text equality expression used by property indexes. +Match properties copied from existing entities retain their JSON types, including numbers, booleans, and arrays. +Incoming named paths are materialized from their components before being carried into a subsequent MERGE. + +### First iteration limits + +This iteration defers visibility of earlier mutations to later clauses. All CTEs share the +statement snapshot. Projected composites can be carried through WITH and RETURN, but a later MATCH cannot discover +newly inserted rows and later separate mutations cannot safely modify a row already written in this statement. +Ordered execution remains the separate extension described in `merge_gaps_plan.md`; this pipeline retains one statement +snapshot and its established rejection contract. + +Repeated unchanged matches preserve input cardinality. Before native writes, a materialized candidate guard rejects +repeated target writes, including writes through distinct bindings, with SQLSTATE 22023 and an explicit +`MERGE requires ordered execution` error. Single-node creations accept multiple absent inputs only when neither input +can match the other input's final created properties. Property values are compared exactly, including arrays. Multiple +absent inputs for complete patterns are conservatively rejected. Target conflicts group only graph, entity type, and +target ID across effective actions using UNION ALL. Preserved fixed keys group the complete original JSONB tuple and +count distinct input IDs; changed keys join original tuples to final tuples, excluding the same input. Empty fixed +maps explicitly conflict when more than one input is absent. Dynamic maps use relational per-key comparisons, including +subset maps and exact arrays, and may still require pairwise work. No JSON candidate envelope is built. Narrow key +relations exclude unrelated payload properties, while the mutation/result candidate relation still carries full entity +composites. No input actions are silently discarded. Run rejected inputs as separate database commands until ordered execution is implemented. + +The edge uniqueness constraint on `(start_id, kind_id, end_id, graph_id)` is unchanged. A property-qualified merge +requiring another relationship with that tuple raises SQLSTATE 23505 and rolls back; conflicting properties are never +accepted as a match. Concurrent MERGE has PostgreSQL's native conflict behavior, with no automatic lock/recheck or +retry. Concurrent relationship insertions may raise 23505; concurrent node creations without a unique property +constraint may create duplicates. Applications requiring serialization must coordinate transactions externally. +The batch/upsert storage contract remains unchanged; removing relationship uniqueness is a separate schema change. + +Shared integration cases and templates run against both backends. PostgreSQL-only tests verify cache hits, runtime +validation, index usage, unchanged row versions, and storage conflicts. Use: + +```sh +make format +make test_update +CONNECTION_STRING="$PG_CONNECTION_STRING" make test_all +CONNECTION_STRING="$NEO4J_CONNECTION_STRING" make test_all +CONNECTION_STRING="$PG_CONNECTION_STRING" go test -tags manual_integration ./integration \ + -run '^$' -bench BenchmarkPostgreSQLMerge -benchtime=1s -count=3 +``` + +The semantic execution experiments on PostgreSQL 18.6 showed that a sibling table scan sees zero rows after a MERGE +insert CTE, that DO NOTHING emits no RETURNING row, and that a volatile PL/pgSQL helper can see its preceding insert +and update. The helper was removed from this iteration after the scope change. These observations agree with +[PostgreSQL MERGE](https://www.postgresql.org/docs/18/sql-merge.html) and +[CTE snapshot behavior](https://www.postgresql.org/docs/18/queries-with.html). diff --git a/drivers/pg/pg.go b/drivers/pg/pg.go index 88a5d17e..8ccc827c 100644 --- a/drivers/pg/pg.go +++ b/drivers/pg/pg.go @@ -23,6 +23,9 @@ const ( ) func AfterPooledConnectionEstablished(ctx context.Context, conn *pgx.Conn) error { + if err := validatePostgreSQLVersion(conn.PgConn().ParameterStatus("server_version")); err != nil { + return err + } for _, dataType := range pgsql.CompositeTypes { if definition, err := conn.LoadType(ctx, dataType.String()); err != nil { if !StateObjectDoesNotExist.ErrorMatches(err) { diff --git a/drivers/pg/query/schema_integration_test.go b/drivers/pg/query/schema_integration_test.go index 6d380c8d..76a08ecc 100644 --- a/drivers/pg/query/schema_integration_test.go +++ b/drivers/pg/query/schema_integration_test.go @@ -279,3 +279,94 @@ func edgeSchemaPlan(t *testing.T, ctx context.Context, tx pgx.Tx, statement stri require.NoError(t, rows.Err()) return strings.Join(lines, "\n") } + +func TestPostgreSQLMergeHelperLifecycle(t *testing.T) { + connection := os.Getenv("CONNECTION_STRING") + parsed, err := url.Parse(connection) + require.NoError(t, err) + if parsed.Scheme != "postgres" && parsed.Scheme != "postgresql" { + t.Skip("PostgreSQL CONNECTION_STRING required") + } + ctx := context.Background() + conn, err := pgx.Connect(ctx, connection) + require.NoError(t, err) + defer conn.Close(ctx) + tx, err := conn.Begin(ctx) + require.NoError(t, err) + defer tx.Rollback(ctx) + schema := pgx.Identifier{fmt.Sprintf("merge_helpers_%d", time.Now().UnixNano())}.Sanitize() + execEdgeSchemaSQL(t, ctx, tx, "create schema "+schema) + execEdgeSchemaSQL(t, ctx, tx, "set local search_path to "+schema) + _, ddl, found := strings.Cut(sqlSchemaUp, "create or replace function cypher_merge_properties") + require.True(t, found) + ddl = "create or replace function cypher_merge_properties" + ddl + for idx := 0; idx < 2; idx++ { + execEdgeSchemaSQL(t, ctx, tx, ddl) + if idx == 0 { + execEdgeSchemaSQL(t, ctx, tx, `create table populated (properties jsonb); insert into populated values ('{"name":"existing"}')`) + } + var valid bool + require.NoError(t, tx.QueryRow(ctx, `select cypher_merge_assert(true,false,false,false)`).Scan(&valid)) + require.True(t, valid) + var patched string + require.NoError(t, tx.QueryRow(ctx, `select cypher_apply_property_patch(properties,'{"removed":null,"nested":{"x":null},"score":2}')::text from populated`).Scan(&patched)) + require.JSONEq(t, `{"name":"existing","nested":{"x":null},"score":2}`, patched) + } + for _, expression := range []string{`cypher_merge_assert(null,false,false,false)`, `cypher_merge_assert(true,null,false,false)`, `cypher_merge_assert(true,false,null,false)`, `cypher_merge_assert(true,false,false,null)`, `cypher_merge_value('null')`, `cypher_merge_properties('[]')`} { + execEdgeSchemaSQL(t, ctx, tx, "savepoint invalid_guard") + _, err := tx.Exec(ctx, "select "+expression) + var pgError *pgconn.PgError + require.ErrorAs(t, err, &pgError) + require.Equal(t, "22023", pgError.Code) + execEdgeSchemaSQL(t, ctx, tx, "rollback to savepoint invalid_guard") + } + for _, line := range strings.Split(sqlSchemaDown, "\n") { + if strings.Contains(line, "cypher_merge_") || strings.Contains(line, "cypher_apply_property_patch(") || strings.Contains(line, "cypher_set_property(") { + execEdgeSchemaSQL(t, ctx, tx, line) + } + } + var absent bool + require.NoError(t, tx.QueryRow(ctx, `select to_regprocedure('cypher_merge_assert(boolean,boolean,boolean,boolean)') is null and to_regprocedure('cypher_merge_value(jsonb)') is null and to_regprocedure('cypher_apply_property_patch(jsonb,jsonb)') is null`).Scan(&absent)) + require.True(t, absent) +} + +func TestPostgreSQLMergeValidationAnchor(t *testing.T) { + connection := os.Getenv("CONNECTION_STRING") + parsed, err := url.Parse(connection) + require.NoError(t, err) + if parsed.Scheme != "postgres" && parsed.Scheme != "postgresql" { + t.Skip("PostgreSQL CONNECTION_STRING required") + } + ctx := context.Background() + conn, err := pgx.Connect(ctx, connection) + require.NoError(t, err) + defer conn.Close(ctx) + tx, err := conn.Begin(ctx) + require.NoError(t, err) + defer tx.Rollback(ctx) + execEdgeSchemaSQL(t, ctx, tx, `create temporary table merge_anchor_target (id int not null); create temporary table merge_anchor_audit (calls int not null); insert into merge_anchor_audit values (0)`) + execEdgeSchemaSQL(t, ctx, tx, `create temporary table merge_anchor_statement_audit (statement_calls int, row_calls int); insert into merge_anchor_statement_audit values (0,0); +create function pg_temp.merge_anchor_trigger() returns trigger language plpgsql as $$begin if TG_LEVEL='STATEMENT' then update merge_anchor_statement_audit set statement_calls=statement_calls+1; else update merge_anchor_statement_audit set row_calls=row_calls+1; end if; return null; end$$; +create trigger anchor_statement after insert on merge_anchor_target for each statement execute function pg_temp.merge_anchor_trigger(); +create trigger anchor_row after insert on merge_anchor_target for each row execute function pg_temp.merge_anchor_trigger()`) + execEdgeSchemaSQL(t, ctx, tx, `create function pg_temp.merge_anchor_guard(fail boolean) returns boolean language plpgsql volatile as $$begin update merge_anchor_audit set calls=calls+1; if fail then raise exception 'anchor validation' using errcode='22023'; end if; return true; end$$`) + for _, tail := range []string{"select 1 limit 0", "select count(*) from merge_anchor_target", "select 1"} { + statement := `with guard as materialized (select pg_temp.merge_anchor_guard($1) as valid), anchor as (merge into merge_anchor_target using guard on false when not matched and not guard.valid then insert (id) values (null)) ` + tail + execEdgeSchemaSQL(t, ctx, tx, "savepoint guard_failure") + _, err := tx.Exec(ctx, statement, true) + var pgError *pgconn.PgError + require.ErrorAs(t, err, &pgError) + require.Equal(t, "22023", pgError.Code) + execEdgeSchemaSQL(t, ctx, tx, "rollback to savepoint guard_failure") + _, err = tx.Exec(ctx, statement, false) + require.NoError(t, err) + } + var calls, count int + require.NoError(t, tx.QueryRow(ctx, `select calls,(select count(*) from merge_anchor_target) from merge_anchor_audit`).Scan(&calls, &count)) + require.Equal(t, 3, calls) + require.Zero(t, count) + var statementCalls, rowCalls int + require.NoError(t, tx.QueryRow(ctx, "select statement_calls,row_calls from merge_anchor_statement_audit").Scan(&statementCalls, &rowCalls)) + require.Equal(t, 3, statementCalls) + require.Zero(t, rowCalls) +} diff --git a/drivers/pg/query/sql/schema_down.sql b/drivers/pg/query/sql/schema_down.sql index 6e2c0de0..211501dd 100644 --- a/drivers/pg/query/sql/schema_down.sql +++ b/drivers/pg/query/sql/schema_down.sql @@ -1,3 +1,7 @@ +drop function if exists cypher_merge_candidates(jsonb, boolean); +drop function if exists cypher_set_property(jsonb, text, jsonb); +drop function if exists cypher_merge_properties(jsonb); + -- Drop triggers drop trigger if exists delete_node_edges on node; drop function if exists delete_node_edges; @@ -127,3 +131,7 @@ drop extension if exists intarray; drop extension if exists pg_stat_statements; + +drop function if exists cypher_apply_property_patch(jsonb, jsonb); +drop function if exists cypher_merge_assert(boolean, boolean, boolean, boolean); +drop function if exists cypher_merge_value(jsonb); diff --git a/drivers/pg/query/sql/schema_up.sql b/drivers/pg/query/sql/schema_up.sql index aa836857..cbf6557c 100644 --- a/drivers/pg/query/sql/schema_up.sql +++ b/drivers/pg/query/sql/schema_up.sql @@ -2637,3 +2637,108 @@ from public.bidirectional_sp_harness(forward_primer, forward_recursive, backward $$ language sql volatile strict; + +-- MERGE properties must be non-null, including on the matched branch and when +-- a compiled query is reused with different parameter values. +create or replace function cypher_merge_properties(properties jsonb) returns jsonb +language plpgsql volatile as $$ +begin + if properties is null or jsonb_typeof(properties) <> 'object' then + raise exception 'MERGE properties must be a non-null map' using errcode = '22023'; + end if; + if exists (select 1 from jsonb_each(properties) p where p.value = 'null'::jsonb) then + raise exception 'Cannot merge an entity using a null property value' using errcode = '22023'; + end if; + return properties; +end; +$$; + +-- SET null removes only the selected property, preserving nested JSON nulls. +create or replace function cypher_set_property(properties jsonb, key text, value jsonb) +returns jsonb language sql immutable as $$ + select case when value is null or value = 'null'::jsonb + then properties - key else properties || jsonb_build_object(key, value) end; +$$; + +-- Reject candidates requiring ordered command snapshots before native MERGE +-- writes. Keep every input action; never resolve conflicts by deduplication. +create or replace function cypher_merge_candidates(candidates jsonb, single_node boolean) +returns boolean language plpgsql volatile as $$ +begin + if exists ( + select 1 from jsonb_array_elements(candidates) c, + lateral jsonb_array_elements(c->'entities') e + where (e->>'write')::boolean + group by e->>'type', e->>'id' having count(*) > 1 + ) then + raise exception 'MERGE requires ordered execution: repeated writes to the same target' + using errcode = '22023'; + end if; + + if (select count(distinct c->>'input') from jsonb_array_elements(candidates) c + where (c->>'created')::boolean) > 1 then + if not single_node then + raise exception 'MERGE requires ordered execution: multiple absent complete-pattern inputs' + using errcode = '22023'; + end if; + -- Single-node creations are safe only if neither input can match the other + -- input's final created value. Compare fields exactly, including arrays. + if exists ( + select 1 from jsonb_array_elements(candidates) a, + jsonb_array_elements(candidates) b + where (a->>'created')::boolean and (b->>'created')::boolean + and a->>'input' <> b->>'input' + and not exists ( + select 1 from jsonb_each(a->'match_properties') p + where p.value is distinct from b->'entities'->0->'properties'->p.key + ) + ) then + raise exception 'MERGE requires ordered execution: overlapping absent node inputs' + using errcode = '22023'; + end if; + end if; + return true; +end; +$$; + +-- Validate a projected fixed-key value without assembling a match map. +create or replace function cypher_merge_value(value jsonb) returns jsonb +language plpgsql volatile as $$ +begin + if value is null or value = 'null'::jsonb then + raise exception 'Cannot merge an entity using a null property value' using errcode = '22023'; + end if; + return value; +end; +$$; + +-- Compact relational MERGE assertion. SQL NULL flags are invalid, never success. +create or replace function cypher_merge_assert(inputs_valid boolean, repeated boolean, + multiple_patterns boolean, overlap boolean) +returns boolean language plpgsql volatile as $$ +begin + if inputs_valid is distinct from true or repeated is null + or multiple_patterns is null or overlap is null then + raise exception 'Invalid MERGE validation flags' using errcode = '22023'; + end if; + if repeated then + raise exception 'MERGE requires ordered execution: repeated writes to the same target' using errcode = '22023'; + end if; + if multiple_patterns then + raise exception 'MERGE requires ordered execution: multiple absent complete-pattern inputs' using errcode = '22023'; + end if; + if overlap then + raise exception 'MERGE requires ordered execution: overlapping absent node inputs' using errcode = '22023'; + end if; + return true; +end; +$$; + +-- The patch argument evaluates each effective clause RHS once. Remove only +-- top-level null values; nested JSON nulls are legitimate property contents. +create or replace function cypher_apply_property_patch(properties jsonb, patch jsonb) +returns jsonb language sql immutable as $$ + select (properties - coalesce(array_agg(key) filter (where value = 'null'::jsonb), '{}'::text[])) + || coalesce(jsonb_object_agg(key, value) filter (where value <> 'null'::jsonb), '{}'::jsonb) + from jsonb_each(patch); +$$; diff --git a/drivers/pg/transaction.go b/drivers/pg/transaction.go index ccfb10e2..f77bfdd8 100644 --- a/drivers/pg/transaction.go +++ b/drivers/pg/transaction.go @@ -52,6 +52,9 @@ type transaction struct { } func newTransactionWrapper(ctx context.Context, conn *pgxpool.Conn, schemaManager *SchemaManager, cfg *Config, allocateTransaction bool) (*transaction, error) { + if err := validatePostgreSQLVersion(conn.Conn().PgConn().ParameterStatus("server_version")); err != nil { + return nil, err + } wrapper := &transaction{ schemaManager: schemaManager, queryExecMode: cfg.QueryExecMode, diff --git a/drivers/pg/translation_cache.go b/drivers/pg/translation_cache.go index 1cd8c4e8..21237171 100644 --- a/drivers/pg/translation_cache.go +++ b/drivers/pg/translation_cache.go @@ -18,7 +18,7 @@ const ( translationCacheCapacity = 256 translationCacheKeyFormat = 3 maxCachedCypherBytes = 64 * 1024 - translationCachePolicy = "compiler-v4:optimized" + translationCachePolicy = "compiler-v5:optimized" ) type translationCacheKey struct { diff --git a/drivers/pg/version.go b/drivers/pg/version.go new file mode 100644 index 00000000..6509a9ba --- /dev/null +++ b/drivers/pg/version.go @@ -0,0 +1,18 @@ +package pg + +import ( + "fmt" + "strconv" + "strings" +) + +const minimumPostgreSQLMajor = 18 + +func validatePostgreSQLVersion(version string) error { + majorText := strings.SplitN(version, ".", 2)[0] + major, err := strconv.Atoi(majorText) + if err != nil || major < minimumPostgreSQLMajor { + return fmt.Errorf("PostgreSQL %d or newer is required (server version %q)", minimumPostgreSQLMajor, version) + } + return nil +} diff --git a/drivers/pg/version_test.go b/drivers/pg/version_test.go new file mode 100644 index 00000000..d377e7a9 --- /dev/null +++ b/drivers/pg/version_test.go @@ -0,0 +1,16 @@ +package pg + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPostgreSQLMinimumVersion(t *testing.T) { + for _, version := range []string{"18.0", "18.6 (Debian 18.6-1)", "19.1"} { + require.NoError(t, validatePostgreSQLVersion(version)) + } + for _, version := range []string{"", "garbage", "16.9", "17.6"} { + require.ErrorContains(t, validatePostgreSQLVersion(version), "PostgreSQL 18 or newer is required") + } +} diff --git a/go.sum b/go.sum index 9ab22710..362597cf 100644 --- a/go.sum +++ b/go.sum @@ -606,6 +606,8 @@ github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pH github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yagipy/maintidx v1.0.0 h1:h5NvIsCz+nRDapQ0exNv4aJ0yXSI0420omVANTv3GJM= github.com/yagipy/maintidx v1.0.0/go.mod h1:0qNf/I/CCZXSMhsRsrEPDZ+DkekpKLXAJfsTACwgXLk= github.com/yeya24/promlinter v0.3.0 h1:JVDbMp08lVCP7Y6NP3qHroGAO6z2yGKQtS5JsjqtoFs= diff --git a/integration/BENCHMARKS.md b/integration/BENCHMARKS.md index 09c4677f..95fc0b96 100644 --- a/integration/BENCHMARKS.md +++ b/integration/BENCHMARKS.md @@ -62,3 +62,34 @@ | linear | a | 1 | 0.17ms | 0.27ms | 0.38ms | | wide_diamond | a | 3 | 0.21ms | 0.34ms | 0.47ms | | local/phantom | - | - | - | - | - | + + +### PostgreSQL MERGE matrix and plan captures + +`BenchmarkPostgreSQLMerge` separates explicit same-value SET from alternating updates. Its selected matrix varies +batch size (1, 8, 64, 512, 4096), unrelated payload (0, 256, 4096, 65536 bytes), and independent assignments (0, 1, 8, 32). +Creation, unchanged matches, updates, mixed composite keys, dynamic maps, and target conflicts have separate cases. +Dynamic matching uses one map input against a batch-sized fixture; Cypher parameter maps are not specialized from +runtime keys. Seeds and parameters are prepared outside timing. Matrix transactions roll back, preserving the +match/create mix across iterations; result drain and transaction rollback are included. The small legacy scenarios +commit. Compilation is warmed before timing. Sequence values consumed by rolled-back creations are not reset. + +With a PostgreSQL CONNECTION_STRING supplied: + +```sh +go test -tags manual_integration ./integration -run '^$' \ + -bench BenchmarkPostgreSQLMerge -benchtime=1s -count=5 -benchmem +MERGE_PLAN_DIR="$PWD/.coverage/merge-plans" go test -tags manual_integration ./integration \ + -run '^TestPostgreSQLMergePlans$' -count=1 +``` + +Plan capture writes SQL and JSON with runtime parameters for fixed keys, wide payloads, changed keys, dynamic maps, +bound endpoints, and repeated-target rejection. Successful plans use EXPLAIN (ANALYZE, BUFFERS, WAL, VERBOSE, FORMAT JSON) +in rollback transactions. PostgreSQL 18.6 can emit malformed execution JSON for partitioned MERGE (tuple counters +appear inside the Target Tables array). The harness preserves that raw output and captures valid non-executing JSON +and EXPLAIN ANALYZE text in separate rollback transactions. It does not rewrite the server output. Rejected workloads use non-executing EXPLAIN and assert the runtime error separately. +Run captures and database suites serially: integration setup can reset shared database tables. Do not run EXPLAIN ANALYZE +on production fixtures. For before/after comparisons use the same harness and server settings in a separate baseline +checkout. Record work_mem, durability, fixture/index setup, cache warmth, and timing boundaries with results. + +See [MERGE implementation evidence](../docs/merge_implementation.md) for measurements and remaining costs. diff --git a/integration/merge_test.go b/integration/merge_test.go new file mode 100644 index 00000000..8386c429 --- /dev/null +++ b/integration/merge_test.go @@ -0,0 +1,81 @@ +//go:build manual_integration + +package integration + +import ( + "testing" + + "github.com/specterops/dawgs/drivers/pg" + "github.com/specterops/dawgs/graph" + "github.com/specterops/dawgs/opengraph" + "github.com/stretchr/testify/require" +) + +func drainMerge(tx graph.Transaction, query string, params map[string]any) error { + result := tx.Query(query, params) + defer result.Close() + for result.Next() { + } + return result.Error() +} + +func mergeCount(t *testing.T, tx graph.Transaction, query string) int64 { + t.Helper() + result := tx.Query(query, nil) + defer result.Close() + require.True(t, result.Next(), "%v", result.Error()) + var count int64 + require.NoError(t, result.Scan(&count)) + require.False(t, result.Next()) + require.NoError(t, result.Error()) + return count +} + +// Shared assertions inspect stored state in subsequent database commands. +// The MVP deliberately does not require visibility across clauses of one query. +func TestMergePersistedState(t *testing.T) { + session := Open(t, Options{SkipIfNoConnection: true, CleanupMode: CleanupGraph, ExtraNodeKinds: graph.Kinds{graph.StringKind("MergeNode")}, ExtraEdgeKinds: graph.Kinds{graph.StringKind("MergeEdge")}}) + for _, optimized := range []bool{true, false} { + previous := pg.SetOptimizedTranslation(optimized) + t.Run(map[bool]string{true: "optimized", false: "baseline"}[optimized], func(t *testing.T) { + for _, tail := range []string{"", " RETURN n LIMIT 0", " RETURN n"} { + t.Run(tail, func(t *testing.T) { + require.NoError(t, session.WithRollbackFixture(t, &opengraph.Graph{}, true, func(tx graph.Transaction, _ opengraph.IDMap) error { + require.NoError(t, drainMerge(tx, `MERGE (n:MergeNode {name:'a'}) ON CREATE SET n.score=1`+tail, nil)) + require.EqualValues(t, 1, mergeCount(t, tx, `MATCH (n:MergeNode {name:'a',score:1}) RETURN count(n)`)) + require.NoError(t, drainMerge(tx, `MERGE (n:MergeNode {name:'a'}) ON MATCH SET n.score=2`+tail, nil)) + require.EqualValues(t, 1, mergeCount(t, tx, `MATCH (n:MergeNode {name:'a',score:2}) RETURN count(n)`)) + require.NoError(t, drainMerge(tx, `UNWIND [] AS name MERGE (n:MergeNode {name:name}) RETURN n`, nil)) + require.EqualValues(t, 1, mergeCount(t, tx, `MATCH (n:MergeNode) RETURN count(n)`)) + return nil + })) + }) + } + t.Run("whole pattern creation", func(t *testing.T) { + require.NoError(t, session.WithRollbackFixture(t, &opengraph.Graph{}, true, func(tx graph.Transaction, _ opengraph.IDMap) error { + require.NoError(t, drainMerge(tx, `CREATE (:MergeNode {name:'a'})`, nil)) + require.NoError(t, drainMerge(tx, `MERGE p=(a:MergeNode {name:'a'})-[:MergeEdge]->(b:MergeNode {name:'b'}) RETURN p`, nil)) + require.EqualValues(t, 2, mergeCount(t, tx, `MATCH (n:MergeNode {name:'a'}) RETURN count(n)`)) + require.EqualValues(t, 1, mergeCount(t, tx, `MATCH ()-[r:MergeEdge]->() RETURN count(r)`)) + return nil + })) + }) + }) + pg.SetOptimizedTranslation(previous) + } +} + +func TestMergeFailureRollsBack(t *testing.T) { + session := Open(t, Options{SkipIfNoConnection: true, CleanupMode: CleanupGraph, ExtraNodeKinds: graph.Kinds{graph.StringKind("MergeRollback")}}) + err := session.DB.WriteTransaction(session.Ctx, func(tx graph.Transaction) error { + if err := drainMerge(tx, `MERGE (:MergeRollback {name:'rollback'})`, nil); err != nil { + return err + } + return drainMerge(tx, `MERGE (:MergeRollback {name:null})`, nil) + }) + require.Error(t, err) + require.NoError(t, session.DB.ReadTransaction(session.Ctx, func(tx graph.Transaction) error { + require.Zero(t, mergeCount(t, tx, `MATCH (n:MergeRollback) RETURN count(n)`)) + return nil + })) +} diff --git a/integration/pgsql_merge_benchmark_test.go b/integration/pgsql_merge_benchmark_test.go new file mode 100644 index 00000000..bf095026 --- /dev/null +++ b/integration/pgsql_merge_benchmark_test.go @@ -0,0 +1,154 @@ +//go:build manual_integration + +package integration + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/specterops/dawgs/drivers/pg" + "github.com/specterops/dawgs/graph" + "github.com/specterops/dawgs/util/size" + "github.com/stretchr/testify/require" +) + +func BenchmarkPostgreSQLMerge(b *testing.B) { + connection := os.Getenv(ConnectionStringEnv) + backend, err := DriverFromConnectionString(connection) + if err != nil || backend != pg.DriverName { + b.Skip("PostgreSQL CONNECTION_STRING is required") + } + ctx := context.Background() + cfg, err := pgxpool.ParseConfig(connection) + require.NoError(b, err) + pool, err := pg.NewPool(cfg) + require.NoError(b, err) + db := pg.NewDriver(size.Size(0), pool) + kind := graph.StringKind("MergeBenchmarkNode") + require.NoError(b, db.AssertSchema(ctx, graph.Schema{DefaultGraph: graph.Graph{Name: "merge_benchmark", Nodes: graph.Kinds{kind}, NodeIndexes: []graph.Index{{Field: "name", Type: graph.BTreeIndex}}}})) + b.Cleanup(func() { + _ = db.WriteTransaction(ctx, func(tx graph.Transaction) error { return tx.Nodes().Delete() }) + _ = db.Close(ctx) + }) + require.NoError(b, db.WriteTransaction(ctx, func(tx graph.Transaction) error { + if err := tx.Nodes().Delete(); err != nil { + return err + } + for _, name := range []string{"target", "other"} { + if _, err := tx.CreateNode(graph.NewProperties().Set("name", name), kind); err != nil { + return err + } + } + return nil + })) + for _, scenario := range []struct{ name, query string }{ + {"indexed unchanged", `MERGE (n:MergeBenchmarkNode {name:$name}) RETURN n`}, + {"repeated unchanged inputs", `UNWIND [1,2,3,4,5,6,7,8] AS input MERGE (n:MergeBenchmarkNode {name:$name}) RETURN n`}, + {"indexed on match update", `MERGE (n:MergeBenchmarkNode {name:$name}) ON MATCH SET n.score=1 RETURN n`}, + {"alternating on match update", `MERGE (n:MergeBenchmarkNode {name:$name}) ON MATCH SET n.score=$score RETURN n`}, + } { + b.Run(scenario.name, func(b *testing.B) { + parameters := map[string]any{"name": "target", "score": 1} + require.NoError(b, db.WriteTransaction(ctx, func(tx graph.Transaction) error { return drainMerge(tx, scenario.query, parameters) })) + b.ResetTimer() + for i := 0; i < b.N; i++ { + parameters["score"] = 1 + i%2 + require.NoError(b, db.WriteTransaction(ctx, func(tx graph.Transaction) error { return drainMerge(tx, scenario.query, parameters) })) + } + }) + } + benchmarkMergeMatrix(b, ctx, db, kind) +} + +// Each timed transaction rolls back, keeping creation/mixed workloads stable. +// Parameters and seeds are prepared outside timing. Result drain and rollback +// are included; compilation is warmed before ResetTimer. +func benchmarkMergeMatrix(b *testing.B, ctx context.Context, db graph.Database, kind graph.Kind) { + rollback := errors.New("benchmark rollback") + for _, scenario := range []struct { + batch, payload, assignments int + mix, shape string + }{ + {1, 0, 0, "created", "fixed"}, {8, 0, 0, "created", "fixed"}, {64, 0, 0, "created", "fixed"}, {512, 0, 0, "created", "fixed"}, {4096, 0, 0, "created", "fixed"}, + {64, 256, 0, "created", "fixed"}, {64, 4096, 0, "created", "fixed"}, {64, 65536, 0, "created", "fixed"}, + {64, 4096, 1, "matched", "fixed"}, {64, 4096, 8, "matched", "fixed"}, {64, 4096, 32, "matched", "fixed"}, + {64, 0, 0, "matched", "fixed"}, {64, 4096, 8, "mixed", "composite"}, + {64, 4096, 0, "matched", "dynamic"}, {64, 0, 1, "conflict", "fixed"}, + } { + name := fmt.Sprintf("matrix/%s/%s/batch%d/payload%d/set%d", scenario.mix, scenario.shape, scenario.batch, scenario.payload, scenario.assignments) + b.Run(name, func(b *testing.B) { + values := make([]string, scenario.batch) + payload := strings.Repeat("x", scenario.payload) + require.NoError(b, db.WriteTransaction(ctx, func(tx graph.Transaction) error { + if err := tx.Nodes().Delete(); err != nil { + return err + } + for idx := range values { + values[idx] = fmt.Sprintf("matrix-%d", idx) + seeded := scenario.mix == "matched" || scenario.mix == "conflict" || (scenario.mix == "mixed" && idx%2 == 0) + if seeded { + if _, err := tx.CreateNode(graph.NewProperties().Set("name", values[idx]).Set("domain", 1).Set("payload", payload), kind); err != nil { + return err + } + } + } + return nil + })) + match := "{name:name}" + if scenario.shape == "composite" { + match = "{name:name,domain:1}" + } + if scenario.shape == "dynamic" { + match = "$props" + } + if scenario.mix == "conflict" { + match = "{name:'matrix-0'}" + } + query := "UNWIND $names AS name MERGE (n:MergeBenchmarkNode " + match + ")" + assignments := []string{} + if scenario.mix == "created" || scenario.mix == "mixed" { + assignments = append(assignments, "n.payload=$payload") + } + for idx := 0; idx < scenario.assignments; idx++ { + assignments = append(assignments, fmt.Sprintf("n.field%d=$score", idx)) + } + if len(assignments) > 0 { + query += " SET " + strings.Join(assignments, ",") + } + query += " RETURN n" + params := map[string]any{"names": values, "payload": payload, "score": 1, "props": map[string]any{"domain": 1}} + // Dynamic workloads use a single match input to preserve the contract; the + // fixture size remains an independent lookup cardinality dimension. + if scenario.shape == "dynamic" { + params["names"] = []string{"input"} + } + execute := func() error { + return db.WriteTransaction(ctx, func(tx graph.Transaction) error { + if err := drainMerge(tx, query, params); err != nil { + return err + } + return rollback + }) + } + check := func(err error) { + if scenario.mix == "conflict" { + require.ErrorContains(b, err, "repeated writes to the same target") + } else { + require.ErrorIs(b, err, rollback) + } + } + check(execute()) + b.ResetTimer() + for idx := 0; idx < b.N; idx++ { + params["score"] = 1 + idx%2 + check(execute()) + } + b.StopTimer() + }) + } +} diff --git a/integration/pgsql_merge_plan_test.go b/integration/pgsql_merge_plan_test.go new file mode 100644 index 00000000..117c9968 --- /dev/null +++ b/integration/pgsql_merge_plan_test.go @@ -0,0 +1,106 @@ +//go:build manual_integration + +package integration + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/jackc/pgx/v5" + "github.com/specterops/dawgs/graph" + "github.com/stretchr/testify/require" +) + +// Captures repeatable plans in rollback transactions. Sequences are deliberately +// not reset: nextval effects survive rollback, but fixtures and match keys do not. +func TestPostgreSQLMergePlans(t *testing.T) { + db := setupIndexedPostgresDB(t, "integration_merge_plans", []graph.Index{{Field: "name", Type: graph.BTreeIndex}}) + require.NoError(t, db.db.WriteTransaction(db.ctx, func(tx graph.Transaction) error { + for idx := 0; idx < 128; idx++ { + if _, err := tx.CreateNode(graph.NewProperties().Set("name", fmt.Sprintf("seed-%d", idx)).Set("domain", 1).Set("payload", strings.Repeat("x", 4096)), db.propertyKind); err != nil { + return err + } + } + return nil + })) + analyzeIndexedNodePartition(t, db) + conn, err := pgx.Connect(db.ctx, os.Getenv("CONNECTION_STRING")) + require.NoError(t, err) + defer conn.Close(db.ctx) + names := make([]string, 64) + for idx := range names { + names[idx] = fmt.Sprintf("created-%d", idx) + } + for _, scenario := range []struct { + name, query string + params map[string]any + failing bool + }{ + {"fixed_created", `UNWIND $names AS name MERGE (n:IndexedNode {name:name}) RETURN n`, map[string]any{"names": names}, false}, + {"fixed_wide", `UNWIND $names AS name MERGE (n:IndexedNode {name:name}) ON CREATE SET n.payload=$payload RETURN n`, map[string]any{"names": names, "payload": strings.Repeat("x", 4096)}, false}, + {"fixed_changed_keys", `UNWIND $names AS name MERGE (n:IndexedNode {name:name}) ON CREATE SET n.name='away' RETURN n`, map[string]any{"names": names}, false}, + {"dynamic_matched", `MERGE (n:IndexedNode $props) RETURN n`, map[string]any{"props": map[string]any{"name": "seed-0"}}, false}, + {"bound_endpoints", `MATCH (a:IndexedNode {name:'seed-0'}),(b:IndexedNode {name:'seed-1'}) MERGE (a)-[r:EdgeKind1]->(b) RETURN r`, nil, false}, + {"repeated_targets", `UNWIND [1,2] AS value MERGE (n:IndexedNode {name:'seed-0'}) SET n.score=value RETURN n`, nil, true}, + } { + t.Run(scenario.name, func(t *testing.T) { + sql, params := translateIndexedCypher(t, db, scenario.query, scenario.params) + var plan any + command := "explain (analyze, buffers, wal, verbose, format json) " + if scenario.failing { + command = "explain (verbose, format json) " + } + tx, err := conn.Begin(db.ctx) + require.NoError(t, err) + var raw string + err = tx.QueryRow(db.ctx, command+sql, pgx.NamedArgs(params)).Scan(&raw) + require.NoError(t, tx.Rollback(db.ctx)) + require.NoError(t, err) + var executionText string + if parseErr := json.Unmarshal([]byte(raw), &plan); parseErr != nil { + // PostgreSQL 18.6 places MERGE tuple instrumentation inside the + // Target Tables array, producing invalid JSON for partitions. + // Keep the raw output and capture independent valid representations. + require.True(t, regexp.MustCompile(`(?s)"Target Tables": \[.*?\},\s*"Tuples Inserted":`).MatchString(raw), "unexpected invalid server plan: %v", parseErr) + tx, err = conn.Begin(db.ctx) + require.NoError(t, err) + var planned string + err = tx.QueryRow(db.ctx, "explain (verbose, format json) "+sql, pgx.NamedArgs(params)).Scan(&planned) + require.NoError(t, tx.Rollback(db.ctx)) + require.NoError(t, err) + require.NoError(t, json.Unmarshal([]byte(planned), &plan)) + tx, err = conn.Begin(db.ctx) + require.NoError(t, err) + rows, err := tx.Query(db.ctx, "explain (analyze, buffers, wal, verbose, format text) "+sql, pgx.NamedArgs(params)) + require.NoError(t, err) + var lines []string + for rows.Next() { + var line string + require.NoError(t, rows.Scan(&line)) + lines = append(lines, line) + } + require.NoError(t, rows.Err()) + rows.Close() + require.NoError(t, tx.Rollback(db.ctx)) + executionText = strings.Join(lines, "\n") + } + require.NotNil(t, plan) + if scenario.failing { + err = db.db.WriteTransaction(db.ctx, func(tx graph.Transaction) error { return drainMerge(tx, scenario.query, scenario.params) }) + require.ErrorContains(t, err, "repeated writes to the same target") + } + if directory := os.Getenv("MERGE_PLAN_DIR"); directory != "" { + require.NoError(t, os.MkdirAll(directory, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(directory, scenario.name+".sql"), []byte(sql), 0644)) + encoded, err := json.MarshalIndent(map[string]any{"cypher": scenario.query, "parameters": params, "plan": plan, "raw_execution_json": raw, "execution_text": executionText}, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(directory, scenario.name+".json"), encoded, 0644)) + } + }) + } +} diff --git a/integration/pgsql_merge_test.go b/integration/pgsql_merge_test.go new file mode 100644 index 00000000..dd03c0d3 --- /dev/null +++ b/integration/pgsql_merge_test.go @@ -0,0 +1,388 @@ +//go:build manual_integration + +package integration + +import ( + "fmt" + "testing" + "time" + + "github.com/specterops/dawgs/cypher/frontend" + "github.com/specterops/dawgs/cypher/models/pgsql/format" + "github.com/specterops/dawgs/cypher/models/pgsql/translate" + "github.com/specterops/dawgs/drivers/pg" + "github.com/specterops/dawgs/graph" + "github.com/stretchr/testify/require" +) + +func TestPostgreSQLMergeMaterializedStringValues(t *testing.T) { + session := Open(t, Options{RequireDriver: pg.DriverName, SkipIfNoConnection: true, SkipIfDriverMismatch: true, CleanupMode: CleanupGraph, ExtraNodeKinds: graph.Kinds{graph.StringKind("MergeMaterialized")}}) + driver := session.DB.(*pg.Driver) + graphSchema, ok := driver.DefaultGraph() + require.True(t, ok) + for _, mode := range []translate.OptimizerMode{translate.OptimizerEnabled, translate.OptimizerDisabled} { + for _, query := range []string{ + `MERGE (n:MergeMaterialized {name:'a'}) ON CREATE SET n.status='created' ON MATCH SET n.status='matched' RETURN 1`, + `MERGE (n:MergeMaterialized {name:$name}) ON CREATE SET n.status=$created ON MATCH SET n.status=$matched RETURN 1`, + } { + t.Run(string(mode)+query, func(t *testing.T) { + parsed, err := frontend.ParseCypher(frontend.NewContext(), query) + require.NoError(t, err) + translation, err := translate.TranslateWithOptions(session.Ctx, parsed, driver.KindMapper(), map[string]any{"name": "a", "created": "created", "matched": "matched"}, graphSchema.ID, translate.Options{OptimizerMode: mode}) + require.NoError(t, err) + sql, err := format.Statement(translation.Statement, format.NewOutputBuilder().WithMaterializedParameters(translation.Parameters)) + require.NoError(t, err) + for _, expected := range []string{"created", "matched"} { + require.NoError(t, session.DB.WriteTransaction(session.Ctx, func(tx graph.Transaction) error { + if expected == "created" { + if err := tx.Nodes().Delete(); err != nil { + return err + } + } + result := tx.Raw(sql.Statement, nil) + defer result.Close() + rows := 0 + for result.Next() { + rows++ + } + if err := result.Error(); err != nil { + return err + } + require.Equal(t, 1, rows) + return nil + })) + var name, status string + require.NoError(t, session.PGPool.QueryRow(session.Ctx, `select properties->>'name',properties->>'status' from node where graph_id=$1`, graphSchema.ID).Scan(&name, &status)) + require.Equal(t, "a", name) + require.Equal(t, expected, status) + } + }) + } + } +} + +func TestPostgreSQLMergeCacheAndUnchangedRows(t *testing.T) { + session := Open(t, Options{RequireDriver: pg.DriverName, SkipIfNoConnection: true, SkipIfDriverMismatch: true, CleanupMode: CleanupGraph, ExtraNodeKinds: graph.Kinds{graph.StringKind("MergeCacheNode")}}) + driver := session.DB.(*pg.Driver) + previous := pg.SetOptimizedTranslation(true) + defer pg.SetOptimizedTranslation(previous) + query := `MERGE (n:MergeCacheNode {name:$name}) RETURN n` + before := driver.TranslationCacheStats() + for _, name := range []string{"first", "second", "first"} { + require.NoError(t, session.DB.WriteTransaction(session.Ctx, func(tx graph.Transaction) error { return drainMerge(tx, query, map[string]any{"name": name}) })) + } + after := driver.TranslationCacheStats() + require.GreaterOrEqual(t, after.Hits, before.Hits+2) + require.NoError(t, session.DB.ReadTransaction(session.Ctx, func(tx graph.Transaction) error { + require.EqualValues(t, 2, mergeCount(t, tx, `MATCH (n:MergeCacheNode) RETURN count(n)`)) + return nil + })) + // xmin and ctid remain unchanged: an unchanged match does not issue UPDATE. + var original, current string + graphSchema, _ := driver.DefaultGraph() + require.NoError(t, session.PGPool.QueryRow(session.Ctx, `select xmin::text || ':' || ctid::text from node where graph_id=$1 and properties->>'name'='first'`, graphSchema.ID).Scan(&original)) + require.NoError(t, session.DB.WriteTransaction(session.Ctx, func(tx graph.Transaction) error { return drainMerge(tx, query, map[string]any{"name": "first"}) })) + require.NoError(t, session.PGPool.QueryRow(session.Ctx, `select xmin::text || ':' || ctid::text from node where graph_id=$1 and properties->>'name'='first'`, graphSchema.ID).Scan(¤t)) + require.Equal(t, original, current) + // Warm compilation must validate values, rather than trusting the first map. + mapQuery := `MERGE (n:MergeCacheNode $props) RETURN n` + require.NoError(t, session.DB.WriteTransaction(session.Ctx, func(tx graph.Transaction) error { + return drainMerge(tx, mapQuery, map[string]any{"props": map[string]any{"name": "map"}}) + })) + require.Error(t, session.DB.WriteTransaction(session.Ctx, func(tx graph.Transaction) error { + return drainMerge(tx, mapQuery, map[string]any{"props": map[string]any{"name": nil}}) + })) +} + +func TestPostgreSQLMergeRelationshipStorageConflict(t *testing.T) { + session := Open(t, Options{RequireDriver: pg.DriverName, SkipIfNoConnection: true, SkipIfDriverMismatch: true, CleanupMode: CleanupGraph, ExtraNodeKinds: graph.Kinds{graph.StringKind("MergeStorageNode")}, ExtraEdgeKinds: graph.Kinds{graph.StringKind("MergeStorageEdge")}}) + require.NoError(t, session.DB.WriteTransaction(session.Ctx, func(tx graph.Transaction) error { + return drainMerge(tx, `CREATE (a:MergeStorageNode {name:'a'})-[:MergeStorageEdge {score:1}]->(b:MergeStorageNode {name:'b'})`, nil) + })) + err := session.DB.WriteTransaction(session.Ctx, func(tx graph.Transaction) error { + return drainMerge(tx, `MATCH (a:MergeStorageNode {name:'a'}),(b:MergeStorageNode {name:'b'}) MERGE (a)-[r:MergeStorageEdge {score:2}]->(b) ON CREATE SET a.changed=true RETURN r`, nil) + }) + require.ErrorContains(t, err, "23505") + require.NoError(t, session.DB.ReadTransaction(session.Ctx, func(tx graph.Transaction) error { + require.EqualValues(t, 1, mergeCount(t, tx, `MATCH ()-[r:MergeStorageEdge {score:1}]->() RETURN count(r)`)) + require.Zero(t, mergeCount(t, tx, `MATCH (n:MergeStorageNode {changed:true}) RETURN count(n)`)) + return nil + })) +} + +func TestPostgreSQLMergeUsesPropertyIndex(t *testing.T) { + db := setupIndexedPostgresDB(t, "integration_merge_index_test", []graph.Index{{Field: "name", Type: graph.BTreeIndex}}) + loadPropertyIndexFixture(t, db) + analyzeIndexedNodePartition(t, db) + assertTranslatedPlanUsesIndex(t, db, `MERGE (n:IndexedNode {name:$name}) RETURN n`, map[string]any{"name": "indexed-name"}, db.nodeIndexes["name"]) +} + +func TestPostgreSQLMergeGraphIsolation(t *testing.T) { + kind := graph.StringKind("MergeIsolationNode") + session := Open(t, Options{RequireDriver: pg.DriverName, SkipIfNoConnection: true, SkipIfDriverMismatch: true, CleanupMode: CleanupGraph, ExtraNodeKinds: graph.Kinds{kind}}) + other := graph.Graph{Name: "merge_isolation_other", Nodes: graph.Kinds{kind}} + require.NoError(t, session.DB.AssertSchema(session.Ctx, graph.Schema{Graphs: []graph.Graph{other}})) + require.NoError(t, session.DB.WriteTransaction(session.Ctx, func(tx graph.Transaction) error { return tx.WithGraph(other).Nodes().Delete() })) + t.Cleanup(func() { + _ = session.DB.WriteTransaction(session.Ctx, func(tx graph.Transaction) error { return tx.WithGraph(other).Nodes().Delete() }) + }) + for _, target := range []bool{true, false} { + require.NoError(t, session.DB.WriteTransaction(session.Ctx, func(tx graph.Transaction) error { + if target { + tx = tx.WithGraph(other) + } + return drainMerge(tx, `MERGE (:MergeIsolationNode {name:'same'})`, nil) + })) + } + // Inspect storage directly so unrelated read optimizations do not hide the + // graph boundary being exercised by the MERGE compiler. + defaultGraph, _ := session.DB.(*pg.Driver).DefaultGraph() + for _, graphName := range []string{other.Name, defaultGraph.Name} { + var count int64 + require.NoError(t, session.PGPool.QueryRow(session.Ctx, `select count(*) from node n join graph g on g.id=n.graph_id where g.name=$1 and n.properties->>'name'='same'`, graphName).Scan(&count)) + require.EqualValues(t, 1, count) + } + +} + +func TestPostgreSQLMergeConcurrentRelationshipConflict(t *testing.T) { + session := Open(t, Options{RequireDriver: pg.DriverName, SkipIfNoConnection: true, SkipIfDriverMismatch: true, CleanupMode: CleanupGraph, ExtraNodeKinds: graph.Kinds{graph.StringKind("MergeConcurrentNode")}, ExtraEdgeKinds: graph.Kinds{graph.StringKind("MergeConcurrentEdge")}}) + require.NoError(t, session.DB.WriteTransaction(session.Ctx, func(tx graph.Transaction) error { + return drainMerge(tx, `CREATE (:MergeConcurrentNode {name:'a'}),(:MergeConcurrentNode {name:'b'})`, nil) + })) + start := make(chan struct{}) + results := make(chan error, 2) + for i := 0; i < 2; i++ { + go func() { + <-start + results <- session.DB.WriteTransaction(session.Ctx, func(tx graph.Transaction) error { + return drainMerge(tx, `MATCH (a:MergeConcurrentNode {name:'a'}),(b:MergeConcurrentNode {name:'b'}) MERGE (a)-[r:MergeConcurrentEdge]->(b) RETURN r`, nil) + }) + }() + } + close(start) + successes := 0 + for i := 0; i < 2; i++ { + if err := <-results; err == nil { + successes++ + } else { + require.ErrorContains(t, err, "23505") + } + } + require.GreaterOrEqual(t, successes, 1) + require.NoError(t, session.DB.ReadTransaction(session.Ctx, func(tx graph.Transaction) error { + require.EqualValues(t, 1, mergeCount(t, tx, `MATCH ()-[r:MergeConcurrentEdge]->() RETURN count(r)`)) + return nil + })) +} + +func TestPostgreSQLMergeRejectsUnorderedCandidates(t *testing.T) { + kind := graph.StringKind("MergeOrderedNode") + for _, test := range []struct { + name, query, message string + }{ + {"repeated bound updates", `MATCH (n:MergeOrderedNode {name:'existing'}) UNWIND [1,2] AS value MERGE (n) ON MATCH SET n.score=value RETURN n`, "repeated writes to the same target"}, + {"repeated updates", `UNWIND [1,2] AS value MERGE (n:MergeOrderedNode {name:'existing'}) ON MATCH SET n.score=value RETURN n`, "repeated writes to the same target"}, + {"repeated absent inputs", `UNWIND [1,2] AS value MERGE (n:MergeOrderedNode {name:'absent'}) ON CREATE SET n.score=value RETURN n`, "overlapping absent node inputs"}, + {"distinct bindings", `MERGE (a:MergeOrderedNode {name:'existing'})-[:MergeOrderedEdge]->(b:MergeOrderedNode {name:'existing'}) ON MATCH SET a.score=1,b.score=2 RETURN a,b`, "repeated writes to the same target"}, + {"repeated updates without return", `UNWIND [1,2] AS value MERGE (n:MergeOrderedNode {name:'existing'}) ON MATCH SET n.score=value`, "repeated writes to the same target"}, + {"repeated absent inputs with limit zero", `UNWIND [1,2] AS value MERGE (n:MergeOrderedNode {name:'absent'}) RETURN n LIMIT 0`, "overlapping absent node inputs"}, + {"creation changes matching key", `UNWIND ['a','b'] AS name MERGE (n:MergeOrderedNode {name:name}) ON CREATE SET n.name='b' RETURN n`, "overlapping absent node inputs"}, + {"multiple absent complete patterns", `UNWIND ['a','b'] AS name MERGE (:MergeOrderedNode {name:name})-[:MergeOrderedEdge]->(:MergeOrderedNode) RETURN name`, "multiple absent complete-pattern inputs"}, + } { + t.Run(test.name, func(t *testing.T) { + session := Open(t, Options{RequireDriver: pg.DriverName, SkipIfNoConnection: true, SkipIfDriverMismatch: true, CleanupMode: CleanupGraph, ExtraNodeKinds: graph.Kinds{kind}, ExtraEdgeKinds: graph.Kinds{graph.StringKind("MergeOrderedEdge")}}) + require.NoError(t, session.DB.WriteTransaction(session.Ctx, func(tx graph.Transaction) error { + return drainMerge(tx, `CREATE (n:MergeOrderedNode {name:'existing',score:0})-[:MergeOrderedEdge]->(n)`, nil) + })) + err := session.DB.WriteTransaction(session.Ctx, func(tx graph.Transaction) error { + return drainMerge(tx, test.query, nil) + }) + require.ErrorContains(t, err, test.message) + require.NoError(t, session.DB.ReadTransaction(session.Ctx, func(tx graph.Transaction) error { + require.EqualValues(t, 1, mergeCount(t, tx, `MATCH (n:MergeOrderedNode) RETURN count(n)`)) + require.EqualValues(t, 1, mergeCount(t, tx, `MATCH (n:MergeOrderedNode {name:'existing',score:0}) RETURN count(n)`)) + return nil + })) + }) + } +} + +func TestPostgreSQLMergeValidationDemand(t *testing.T) { + session := Open(t, Options{RequireDriver: pg.DriverName, SkipIfNoConnection: true, SkipIfDriverMismatch: true, CleanupMode: CleanupGraph, ExtraNodeKinds: graph.Kinds{graph.StringKind("MergeDemand"), graph.StringKind("MergeDemandSource")}, ExtraEdgeKinds: graph.Kinds{graph.StringKind("MergeDemandEdge")}}) + require.NoError(t, session.DB.WriteTransaction(session.Ctx, func(tx graph.Transaction) error { + for idx := 0; idx < 513; idx++ { + props := graph.NewProperties() + if idx < 512 { + props.Set("name", idx) + } + if _, err := tx.CreateNode(props, graph.StringKind("MergeDemandSource")); err != nil { + return err + } + } + return nil + })) + for _, optimized := range []bool{true, false} { + previous := pg.SetOptimizedTranslation(optimized) + for _, tail := range []string{"", " RETURN n LIMIT 0", " RETURN 1 LIMIT 0", " RETURN count(*)"} { + for _, query := range []string{ + `MATCH (value:MergeDemandSource) WITH value ORDER BY id(value) MERGE (n:MergeDemand {name:value.name})` + tail, + `MERGE (n:MergeDemand {name:'valid'})-[:MergeDemandEdge {value:null}]->(:MergeDemand {name:'other'})` + tail, + `MERGE (n:MergeDemand $props)` + tail, + } { + t.Run(query, func(t *testing.T) { + values := make([]int64, 513) + for idx := range values { + values[idx] = int64(idx) + } + err := session.DB.WriteTransaction(session.Ctx, func(tx graph.Transaction) error { + return drainMerge(tx, query, map[string]any{"values": values, "props": map[string]any{"name": nil}}) + }) + require.ErrorContains(t, err, "null property value") + require.ErrorContains(t, err, "22023") + }) + } + } + for _, props := range []any{nil, "not a map", []any{1, 2}} { + err := session.DB.WriteTransaction(session.Ctx, func(tx graph.Transaction) error { + return drainMerge(tx, `MERGE (n:MergeDemand $props) RETURN 1 LIMIT 0`, map[string]any{"props": props}) + }) + require.ErrorContains(t, err, "non-null map") + } + require.NoError(t, session.DB.WriteTransaction(session.Ctx, func(tx graph.Transaction) error { + return drainMerge(tx, `UNWIND [] AS value MERGE (n:MergeDemand {name:null}) RETURN n LIMIT 0`, nil) + })) + pg.SetOptimizedTranslation(previous) + } + require.NoError(t, session.DB.ReadTransaction(session.Ctx, func(tx graph.Transaction) error { + require.Zero(t, mergeCount(t, tx, `MATCH (n:MergeDemand) RETURN count(n)`)) + return nil + })) +} + +func TestPostgreSQLMergeKeyOverlap(t *testing.T) { + session := Open(t, Options{RequireDriver: pg.DriverName, SkipIfNoConnection: true, SkipIfDriverMismatch: true, CleanupMode: CleanupGraph, ExtraNodeKinds: graph.Kinds{graph.StringKind("MergeKeys")}}) + for _, test := range []struct { + query string + params map[string]any + conflict bool + }{ + {`UNWIND ['a','a'] AS value MERGE (n:MergeKeys {name:value}) ON CREATE SET n.name='away' RETURN n`, nil, false}, + {`UNWIND ['a','b'] AS value MERGE (n:MergeKeys {name:value}) ON CREATE SET n.name='a' RETURN n`, nil, true}, + {`UNWIND ['a','b'] AS value MERGE (n:MergeKeys {name:value}) ON CREATE SET n.name='b' RETURN n`, nil, true}, + {`UNWIND ['a','a'] AS value MERGE (n:MergeKeys {name:value}) ON CREATE SET n.name=null RETURN n`, nil, false}, + {`UNWIND [1,2] AS value MERGE (n:MergeKeys {}) RETURN n`, nil, true}, + {`UNWIND [1,2] AS value MERGE (n:MergeKeys {name:'same',score:value}) RETURN n`, nil, false}, + {`UNWIND ['a','b'] AS value MERGE (n:MergeKeys $props) ON CREATE SET n.name=value RETURN n`, map[string]any{"props": map[string]any{"name": "a"}}, true}, + {`UNWIND ['a','b'] AS value MERGE (n:MergeKeys $props) ON CREATE SET n.name=value RETURN n`, map[string]any{"props": map[string]any{"name": "away", "values": []any{1, 2}}}, false}, + {`UNWIND ['a','b'] AS value MERGE (n:MergeKeys $props) ON CREATE SET n.name=value RETURN n`, map[string]any{"props": map[string]any{}}, true}, + } { + t.Run(test.query, func(t *testing.T) { + err := session.DB.WriteTransaction(session.Ctx, func(tx graph.Transaction) error { + if err := tx.Nodes().Delete(); err != nil { + return err + } + return drainMerge(tx, test.query, test.params) + }) + if test.conflict { + require.ErrorContains(t, err, "overlapping absent node inputs") + require.ErrorContains(t, err, "22023") + } else { + require.NoError(t, err) + } + }) + } +} + +func TestPostgreSQLMergeReadOnlyBindings(t *testing.T) { + session := Open(t, Options{RequireDriver: pg.DriverName, SkipIfNoConnection: true, SkipIfDriverMismatch: true, CleanupMode: CleanupGraph, ExtraNodeKinds: graph.Kinds{graph.StringKind("MergeReadOnly")}, ExtraEdgeKinds: graph.Kinds{graph.StringKind("MergeReadOnlyEdge")}}) + require.NoError(t, session.DB.WriteTransaction(session.Ctx, func(tx graph.Transaction) error { + return drainMerge(tx, `CREATE (:MergeReadOnly {name:'a'}),(:MergeReadOnly {name:'b'})`, nil) + })) + graphSchema, _ := session.DB.(*pg.Driver).DefaultGraph() + versions := func() []string { + rows, err := session.PGPool.Query(session.Ctx, `select xmin::text || ':' || ctid::text from node where graph_id=$1 order by id`, graphSchema.ID) + require.NoError(t, err) + defer rows.Close() + var result []string + for rows.Next() { + var value string + require.NoError(t, rows.Scan(&value)) + result = append(result, value) + } + require.NoError(t, rows.Err()) + return result + } + before := versions() + require.NoError(t, session.DB.WriteTransaction(session.Ctx, func(tx graph.Transaction) error { + return drainMerge(tx, `MATCH (a:MergeReadOnly {name:'a'}),(b:MergeReadOnly {name:'b'}) MERGE (a)-[r:MergeReadOnlyEdge]->(b) RETURN r`, nil) + })) + require.Equal(t, before, versions()) + require.NoError(t, session.DB.WriteTransaction(session.Ctx, func(tx graph.Transaction) error { + return drainMerge(tx, `MATCH (a:MergeReadOnly) UNWIND [1,2] AS input MERGE (a) RETURN a`, nil) + })) + require.Equal(t, before, versions()) +} + +func TestPostgreSQLMergeDynamicCacheShapes(t *testing.T) { + session := Open(t, Options{RequireDriver: pg.DriverName, SkipIfNoConnection: true, SkipIfDriverMismatch: true, CleanupMode: CleanupGraph, ExtraNodeKinds: graph.Kinds{graph.StringKind("MergeDynamicCache")}}) + previous := pg.SetOptimizedTranslation(true) + defer pg.SetOptimizedTranslation(previous) + driver := session.DB.(*pg.Driver) + query := `MERGE (n:MergeDynamicCache $props) RETURN n` + before := driver.TranslationCacheStats() + for _, props := range []map[string]any{{"name": "first", "active": true}, {"score": 1}, {"values": []any{1, 2}}, {"values": []any{2, 1}}, {"values": []any{1, 1}}, {"values": []any{1}}, {"nested": map[string]any{"value": nil}}, {}} { + require.NoError(t, session.DB.WriteTransaction(session.Ctx, func(tx graph.Transaction) error { return drainMerge(tx, query, map[string]any{"props": props}) })) + } + after := driver.TranslationCacheStats() + require.GreaterOrEqual(t, after.Hits, before.Hits+7) + err := session.DB.WriteTransaction(session.Ctx, func(tx graph.Transaction) error { + return drainMerge(tx, query, map[string]any{"props": map[string]any{"score": nil}}) + }) + require.ErrorContains(t, err, "22023") + require.ErrorContains(t, err, "null property value") + require.NoError(t, session.DB.ReadTransaction(session.Ctx, func(tx graph.Transaction) error { + require.EqualValues(t, 7, mergeCount(t, tx, `MATCH (n:MergeDynamicCache) RETURN count(n)`)) + return nil + })) +} + +func TestPostgreSQLMergeInputEvaluationCount(t *testing.T) { + session := Open(t, Options{RequireDriver: pg.DriverName, SkipIfNoConnection: true, SkipIfDriverMismatch: true, CleanupMode: CleanupGraph, ExtraNodeKinds: graph.Kinds{graph.StringKind("MergeEvaluation")}}) + require.NoError(t, session.DB.WriteTransaction(session.Ctx, func(tx graph.Transaction) error { + schema := fmt.Sprintf("merge_evaluation_%d", time.Now().UnixNano()) + // Instrumentation is removed before this transaction commits. + statements := []string{ + "create schema " + schema, + "set local search_path to " + schema + ", public", + `create temporary table merge_evaluations (fixed int,dynamic int)`, + `insert into merge_evaluations values (0,0)`, + `create function ` + schema + `.cypher_merge_value(value jsonb) returns jsonb language plpgsql volatile as $$begin update merge_evaluations set fixed=fixed+1; return public.cypher_merge_value(value); end$$`, + `create function ` + schema + `.cypher_merge_properties(value jsonb) returns jsonb language plpgsql volatile as $$begin update merge_evaluations set dynamic=dynamic+1; return public.cypher_merge_properties(value); end$$`, + } + for _, statement := range statements { + result := tx.Raw(statement, nil) + result.Close() + if err := result.Error(); err != nil { + return err + } + } + if err := drainMerge(tx, `UNWIND ['a','b'] AS name MERGE (n:MergeEvaluation {name:name,score:1}) RETURN n LIMIT 0`, nil); err != nil { + return err + } + if err := drainMerge(tx, `UNWIND [1,2] AS input MERGE (n:MergeEvaluation $props) RETURN n LIMIT 0`, map[string]any{"props": map[string]any{"name": "a"}}); err != nil { + return err + } + result := tx.Raw("select fixed,dynamic from merge_evaluations", nil) + defer result.Close() + require.True(t, result.Next(), "%v", result.Error()) + var fixed, dynamic int64 + require.NoError(t, result.Scan(&fixed, &dynamic)) + require.EqualValues(t, 4, fixed) + require.EqualValues(t, 2, dynamic) + result.Close() + cleanup := tx.Raw("drop schema "+schema+" cascade", nil) + cleanup.Close() + return cleanup.Error() + })) +} diff --git a/integration/testdata/cases/merge_inline.json b/integration/testdata/cases/merge_inline.json new file mode 100644 index 00000000..1324b01b --- /dev/null +++ b/integration/testdata/cases/merge_inline.json @@ -0,0 +1,1256 @@ +{ + "cases": [ + { + "name": "merge absent node", + "cypher": "MERGE (n:NodeKind1 {name:'a'}) RETURN n", + "fixture": { + "nodes": [], + "edges": [] + }, + "assert": { + "row_count": 1, + "contains_node_with_prop": [ + "name", + "a" + ] + } + }, + { + "name": "merge existing node unchanged", + "cypher": "MERGE (n:NodeKind1 {name:'a'}) RETURN n", + "fixture": { + "nodes": [ + { + "id": "a", + "kinds": [ + "NodeKind1" + ], + "properties": { + "name": "a", + "score": 4 + } + } + ], + "edges": [] + }, + "assert": { + "node_ids": [ + "a" + ] + } + }, + { + "name": "merge multiple existing nodes", + "cypher": "MERGE (n:NodeKind1 {name:'a'}) RETURN n", + "fixture": { + "nodes": [ + { + "id": "a", + "kinds": [ + "NodeKind1" + ], + "properties": { + "name": "a", + "score": 4 + } + }, + { + "id": "b", + "kinds": [ + "NodeKind1" + ], + "properties": { + "name": "a", + "score": 4 + } + } + ], + "edges": [] + }, + "assert": { + "row_count": 2 + } + }, + { + "name": "merge create branch", + "cypher": "MERGE (n:NodeKind1 {name:'a'}) ON CREATE SET n.score=1 ON MATCH SET n.score=2 RETURN n", + "fixture": { + "nodes": [], + "edges": [] + }, + "assert": { + "contains_node_with_props": { + "name": "a", + "score": 1 + } + } + }, + { + "name": "merge match branch", + "cypher": "MERGE (n:NodeKind1 {name:'a'}) ON CREATE SET n.score=1 ON MATCH SET n.score=2 RETURN n", + "fixture": { + "nodes": [ + { + "id": "a", + "kinds": [ + "NodeKind1" + ], + "properties": { + "name": "a", + "score": 4 + } + } + ], + "edges": [] + }, + "assert": { + "contains_node_with_props": { + "name": "a", + "score": 2 + } + } + }, + { + "name": "merge ordinary assignment", + "cypher": "MERGE (n:NodeKind1 {name:'a'}) SET n.score=3 RETURN n", + "fixture": { + "nodes": [], + "edges": [] + }, + "assert": { + "contains_node_with_props": { + "name": "a", + "score": 3 + } + } + }, + { + "name": "merge ordered branch assignments", + "cypher": "MERGE (n:NodeKind1 {name:'a'}) ON CREATE SET n.score=1 SET n.score=n.score+1 RETURN n", + "fixture": { + "nodes": [], + "edges": [] + }, + "assert": { + "contains_node_with_props": { + "score": 2 + } + } + }, + { + "name": "merge distinct input nodes", + "cypher": "UNWIND ['a','b'] AS name MERGE (n:NodeKind1 {name:name}) RETURN n", + "fixture": { + "nodes": [], + "edges": [] + }, + "assert": { + "row_count": 2 + } + }, + { + "name": "merge zero inputs", + "cypher": "UNWIND [] AS name MERGE (n:NodeKind1 {name:name}) RETURN n", + "fixture": { + "nodes": [], + "edges": [] + }, + "assert": "empty" + }, + { + "name": "merge no return", + "cypher": "MERGE (n:NodeKind1 {name:'a'})", + "fixture": { + "nodes": [], + "edges": [] + }, + "assert": "no_error" + }, + { + "name": "merge limit zero", + "cypher": "MERGE (n:NodeKind1 {name:'a'}) RETURN n LIMIT 0", + "fixture": { + "nodes": [], + "edges": [] + }, + "assert": "empty" + }, + { + "name": "merge null property", + "cypher": "MERGE (n:NodeKind1 {name:null}) RETURN n", + "fixture": { + "nodes": [], + "edges": [] + }, + "assert": "query_error" + }, + { + "name": "merge null assignment removes field", + "cypher": "MERGE (n:NodeKind1 {name:'a'}) ON MATCH SET n.score=null RETURN n.score", + "fixture": { + "nodes": [ + { + "id": "a", + "kinds": [ + "NodeKind1" + ], + "properties": { + "name": "a", + "score": 4 + } + } + ], + "edges": [] + }, + "assert": { + "scalar_values": [ + null + ] + } + }, + { + "name": "merge kind assignment", + "cypher": "MERGE (n:NodeKind1 {name:'a'}) ON CREATE SET n:NodeKind2 RETURN labels(n)", + "fixture": { + "nodes": [], + "edges": [] + }, + "assert": { + "row_count": 1 + } + }, + { + "name": "merge complete absent pattern", + "cypher": "MERGE p=(a:NodeKind1 {name:'a'})-[:EdgeKind1]->(b:NodeKind2 {name:'b'}) RETURN p", + "fixture": { + "nodes": [], + "edges": [] + }, + "assert": { + "row_count": 1, + "path_lengths": [ + 1 + ] + } + }, + { + "name": "merge complete pattern does not reuse partial node", + "cypher": "MERGE (a:NodeKind1 {name:'a'})-[:EdgeKind1]->(b:NodeKind2 {name:'b'}) RETURN a", + "fixture": { + "nodes": [ + { + "id": "a", + "kinds": [ + "NodeKind1" + ], + "properties": { + "name": "a", + "score": 4 + } + } + ], + "edges": [] + }, + "assert": { + "row_count": 1, + "contains_node_with_prop": [ + "name", + "a" + ] + } + }, + { + "name": "merge bound endpoints", + "cypher": "MATCH (a:NodeKind1 {name:'a'}), (b:NodeKind2 {name:'b'}) MERGE (a)-[r:EdgeKind1]->(b) RETURN r", + "fixture": { + "nodes": [ + { + "id": "a", + "kinds": [ + "NodeKind1" + ], + "properties": { + "name": "a", + "score": 4 + } + }, + { + "id": "b", + "kinds": [ + "NodeKind2" + ], + "properties": { + "name": "b" + } + } + ], + "edges": [] + }, + "assert": { + "row_count": 1, + "contains_edge": { + "kind": "EdgeKind1" + } + } + }, + { + "name": "merge existing relationship", + "cypher": "MATCH (a:NodeKind1 {name:'a'}), (b:NodeKind2 {name:'b'}) MERGE (a)-[r:EdgeKind1]->(b) ON MATCH SET r.score=6 RETURN r", + "fixture": { + "nodes": [ + { + "id": "a", + "kinds": [ + "NodeKind1" + ], + "properties": { + "name": "a", + "score": 4 + } + }, + { + "id": "b", + "kinds": [ + "NodeKind2" + ], + "properties": { + "name": "b" + } + } + ], + "edges": [ + { + "start_id": "a", + "end_id": "b", + "kind": "EdgeKind1", + "properties": { + "score": 5 + } + } + ] + }, + "assert": { + "row_count": 1, + "contains_edge": { + "kind": "EdgeKind1", + "props": { + "score": 6 + } + } + } + }, + { + "name": "merge undirected existing reverse", + "cypher": "MATCH (a:NodeKind2 {name:'b'}), (b:NodeKind1 {name:'a'}) MERGE (a)-[r:EdgeKind1]-(b) RETURN r", + "fixture": { + "nodes": [ + { + "id": "a", + "kinds": [ + "NodeKind1" + ], + "properties": { + "name": "a", + "score": 4 + } + }, + { + "id": "b", + "kinds": [ + "NodeKind2" + ], + "properties": { + "name": "b" + } + } + ], + "edges": [ + { + "start_id": "a", + "end_id": "b", + "kind": "EdgeKind1", + "properties": { + "score": 5 + } + } + ] + }, + "assert": { + "row_count": 1 + } + }, + { + "name": "merge multiple relationships named path", + "cypher": "MERGE p=(a:NodeKind1)-[:EdgeKind1]->(b:NodeKind2)-[:EdgeKind2]->(c:NodeKind1) RETURN p", + "fixture": { + "nodes": [], + "edges": [] + }, + "assert": { + "row_count": 1, + "path_lengths": [ + 2 + ] + } + }, + { + "name": "merge repeated unchanged inputs", + "cypher": "UNWIND ['a','a'] AS name MERGE (n:NodeKind1 {name:name}) RETURN n", + "fixture": { + "nodes": [ + { + "id": "a", + "kinds": [ + "NodeKind1" + ], + "properties": { + "name": "a", + "score": 4 + } + } + ], + "edges": [] + }, + "assert": { + "node_ids": [ + "a", + "a" + ] + } + }, + { + "name": "merge assignments use clause entry values", + "cypher": "MERGE (n:NodeKind1 {name:'a'}) ON MATCH SET n.score=1, n.score=n.score+1 RETURN n", + "fixture": { + "nodes": [ + { + "id": "a", + "kinds": [ + "NodeKind1" + ], + "properties": { + "name": "a", + "score": 4 + } + } + ], + "edges": [] + }, + "assert": { + "contains_node_with_props": { + "score": 5 + } + } + }, + { + "name": "merge consecutive independent clauses", + "cypher": "MERGE (a:NodeKind1 {name:'a'}) MERGE (b:NodeKind2 {name:'b'}) RETURN a,b", + "fixture": { + "nodes": [], + "edges": [] + }, + "assert": { + "row_count": 1 + } + }, + { + "name": "merge carried through with", + "cypher": "MERGE (n:NodeKind1 {name:'a'}) WITH n RETURN n", + "fixture": { + "nodes": [], + "edges": [] + }, + "assert": { + "row_count": 1 + } + }, + { + "name": "merge undirected creation", + "cypher": "MERGE p=(a:NodeKind1)-[r:EdgeKind1]-(b:NodeKind2) RETURN p", + "fixture": { + "nodes": [], + "edges": [] + }, + "assert": { + "row_count": 1, + "path_lengths": [ + 1 + ] + } + }, + { + "name": "merge self relationship", + "cypher": "MERGE (a:NodeKind1 {name:'a'})-[r:EdgeKind1]->(a) RETURN r", + "fixture": { + "nodes": [], + "edges": [] + }, + "assert": { + "row_count": 1 + } + }, + { + "name": "merge unlabeled node", + "cypher": "MERGE (n {name:'a'}) RETURN n", + "fixture": { + "nodes": [], + "edges": [] + }, + "assert": { + "row_count": 1 + } + }, + { + "name": "merge missing property matched null still errors", + "cypher": "MERGE (n:NodeKind1 {name:null}) RETURN n LIMIT 0", + "fixture": { + "nodes": [ + { + "id": "a", + "kinds": [ + "NodeKind1" + ], + "properties": { + "name": "a", + "score": 4 + } + } + ], + "edges": [] + }, + "assert": "query_error" + }, + { + "name": "merge array equality does not accept subsets", + "cypher": "MERGE (n:NodeKind1 {values:[1]}) RETURN size(n.values)", + "fixture": { + "nodes": [ + { + "id": "original", + "kinds": [ + "NodeKind1" + ], + "properties": { + "values": [ + 1, + 2 + ] + } + } + ], + "edges": [] + }, + "assert": { + "exact_int": 1 + } + }, + { + "name": "merge constant return preserves matches", + "cypher": "MERGE (n:NodeKind1 {name:'a'}) RETURN 1", + "fixture": { + "nodes": [ + { + "id": "a", + "kinds": [ + "NodeKind1" + ], + "properties": { + "name": "a", + "score": 4 + } + }, + { + "id": "b", + "kinds": [ + "NodeKind1" + ], + "properties": { + "name": "a", + "score": 4 + } + } + ], + "edges": [] + }, + "assert": { + "row_count": 2 + } + }, + { + "name": "merge zero input scalar projection", + "cypher": "UNWIND [] AS name MERGE (n:NodeKind1 {name:name}) RETURN 1", + "fixture": { + "nodes": [], + "edges": [] + }, + "assert": "empty" + }, + { + "name": "merge zero input aggregate projection", + "cypher": "UNWIND [] AS name MERGE (n:NodeKind1 {name:name}) RETURN count(*)", + "fixture": { + "nodes": [], + "edges": [] + }, + "assert": { + "exact_int": 0 + } + }, + { + "name": "merge no return yields no rows", + "cypher": "MERGE (n:NodeKind1 {name:'a'})", + "fixture": { + "nodes": [], + "edges": [] + }, + "assert": "empty" + }, + { + "name": "merge bound node redeclaration is invalid", + "cypher": "MATCH (n:NodeKind1) MERGE (n:NodeKind2) RETURN n", + "fixture": { + "nodes": [ + { + "id": "a", + "kinds": [ + "NodeKind1" + ], + "properties": { + "name": "a", + "score": 4 + } + } + ], + "edges": [] + }, + "assert": "query_error" + }, + { + "name": "merge null scalar match parameter", + "cypher": "MERGE (n:NodeKind1 {name:$name}) RETURN n", + "params": { + "name": null + }, + "fixture": { + "nodes": [ + { + "id": "a", + "kinds": [ + "NodeKind1" + ], + "properties": { + "name": "a", + "score": 4 + } + } + ], + "edges": [] + }, + "assert": "query_error" + }, + { + "name": "merge null scalar assignment parameter", + "cypher": "MERGE (n:NodeKind1 {name:'a'}) ON MATCH SET n.score=$value RETURN n.score", + "params": { + "value": null + }, + "fixture": { + "nodes": [ + { + "id": "a", + "kinds": [ + "NodeKind1" + ], + "properties": { + "name": "a", + "score": 4 + } + } + ], + "edges": [] + }, + "assert": { + "scalar_values": [ + null + ] + } + }, + { + "name": "merge repeated node label declaration is invalid", + "cypher": "MERGE (a:NodeKind1 {name:'a'})-[:EdgeKind1]->(a:NodeKind2) RETURN a", + "fixture": { + "nodes": [], + "edges": [] + }, + "assert": "query_error" + }, + { + "name": "merge repeated node property declaration is invalid", + "cypher": "MERGE (a:NodeKind1 {name:'a'})-[:EdgeKind1]->(a {score:1}) RETURN a", + "fixture": { + "nodes": [], + "edges": [] + }, + "assert": "query_error" + }, + { + "name": "merge copied property types match existing node", + "cypher": "MATCH (a:NodeKind1) MERGE (n:NodeKind2 {score:a.score, active:a.active, values:a.values}) RETURN n", + "fixture": { + "nodes": [ + { + "id": "source", + "kinds": [ + "NodeKind1" + ], + "properties": { + "score": 4, + "active": true, + "values": [ + 1, + 2 + ] + } + }, + { + "id": "target", + "kinds": [ + "NodeKind2" + ], + "properties": { + "score": 4, + "active": true, + "values": [ + 1, + 2 + ] + } + } + ], + "edges": [] + }, + "assert": { + "node_ids": [ + "target" + ] + } + }, + { + "name": "merge copied property types create node", + "cypher": "MATCH (a:NodeKind1) MERGE (n:NodeKind2 {score:a.score, active:a.active, values:a.values}) RETURN n", + "fixture": { + "nodes": [ + { + "id": "source", + "kinds": [ + "NodeKind1" + ], + "properties": { + "score": 4, + "active": true, + "values": [ + 1, + 2 + ] + } + } + ], + "edges": [] + }, + "assert": { + "row_count": 1, + "contains_node_with_props": { + "score": 4, + "active": true, + "values": [ + 1, + 2 + ] + } + } + }, + { + "name": "merge carries path from preceding merge", + "cypher": "MERGE p=(a:NodeKind1 {name:'a'})-[:EdgeKind1]->(b:NodeKind2 {name:'b'}) MERGE (c:NodeKind1 {name:'independent'}) RETURN p", + "fixture": { + "nodes": [], + "edges": [] + }, + "assert": { + "row_count": 1, + "path_lengths": [ + 1 + ], + "path_edge_kinds": [ + [ + "EdgeKind1" + ] + ] + } + }, + { + "name": "merge carries path from preceding match", + "cypher": "MATCH p=(a:NodeKind1)-[:EdgeKind1]->(b:NodeKind2) MERGE (c:NodeKind1 {name:'independent'}) RETURN p", + "fixture": { + "nodes": [ + { + "id": "a", + "kinds": [ + "NodeKind1" + ], + "properties": {} + }, + { + "id": "b", + "kinds": [ + "NodeKind2" + ], + "properties": {} + } + ], + "edges": [ + { + "start_id": "a", + "end_id": "b", + "kind": "EdgeKind1", + "properties": {} + } + ] + }, + "assert": { + "path_node_ids": [ + [ + "a", + "b" + ] + ], + "path_lengths": [ + 1 + ] + } + }, + { + "name": "merge clause swap", + "cypher": "MERGE (n:NodeKind1 {name:'a'}) ON CREATE SET n.a=1,n.b=2 SET n.a=n.b,n.b=n.a RETURN n", + "fixture": { + "nodes": [], + "edges": [] + }, + "assert": { + "row_count": 1, + "contains_node_with_props": { + "name": "a", + "a": 2, + "b": 1 + } + } + }, + { + "name": "merge separate clause dependency", + "cypher": "MERGE (n:NodeKind1 {name:'a'}) SET n.a=1 SET n.b=n.a RETURN n", + "fixture": { + "nodes": [], + "edges": [] + }, + "assert": { + "row_count": 1, + "contains_node_with_props": { + "name": "a", + "a": 1, + "b": 1 + } + } + }, + { + "name": "merge repeated field final non-null", + "cypher": "MERGE (n:NodeKind1 {name:'a'}) SET n.a=null,n.a=3 RETURN n", + "fixture": { + "nodes": [], + "edges": [] + }, + "assert": { + "row_count": 1, + "contains_node_with_props": { + "name": "a", + "a": 3 + } + } + }, + { + "name": "merge repeated field final null", + "cypher": "MERGE (n:NodeKind1 {name:'a'}) SET n.a=3,n.a=null RETURN n", + "fixture": { + "nodes": [], + "edges": [] + }, + "assert": { + "row_count": 1, + "contains_node_with_props": { + "name": "a" + } + } + }, + { + "name": "merge changes duplicate originals away", + "cypher": "UNWIND ['a','a'] AS name MERGE (n:NodeKind1 {name:name}) ON CREATE SET n.name='away' RETURN n", + "fixture": { + "nodes": [], + "edges": [] + }, + "assert": { + "row_count": 2, + "contains_node_with_props": { + "name": "away" + } + } + }, + { + "name": "merge composite distinct originals", + "cypher": "UNWIND [1,2] AS value MERGE (n:NodeKind1 {name:'same',score:value}) RETURN n", + "fixture": { + "nodes": [], + "edges": [] + }, + "assert": { + "row_count": 2, + "contains_node_with_props": { + "name": "same", + "score": 1 + } + } + }, + { + "name": "merge repeated bound-endpoint matches", + "cypher": "MATCH (a:NodeKind1 {name:'a'}),(b:NodeKind2 {name:'b'}) UNWIND [1,2] AS input MERGE (a)-[:EdgeKind1]->(b) RETURN a", + "fixture": { + "nodes": [ + { + "id": "a", + "kinds": [ + "NodeKind1" + ], + "properties": { + "name": "a" + } + }, + { + "id": "b", + "kinds": [ + "NodeKind2" + ], + "properties": { + "name": "b" + } + } + ], + "edges": [ + { + "start_id": "a", + "end_id": "b", + "kind": "EdgeKind1", + "properties": {} + } + ] + }, + "assert": { + "row_count": 2, + "contains_node_with_props": { + "name": "a" + } + } + }, + { + "name": "merge return star hides private fields", + "cypher": "merge (n:NodeKind1 {name: 'a'}) on create set n.name = 'b' return *", + "fixture": { + "nodes": [], + "edges": [] + }, + "assert": { + "row_count": 1, + "contains_node_with_props": { + "name": "b" + }, + "keys": [ + "n" + ] + } + }, + { + "name": "merge 51 property create patch", + "cypher": "MERGE (n:NodeKind1 {name:'large',left:1,right:2}) ON CREATE SET n.field0=0,n.field1=1,n.field2=2,n.field3=3,n.field4=4,n.field5=5,n.field6=6,n.field7=7,n.field8=8,n.field9=9,n.field10=10,n.field11=11,n.field12=12,n.field13=13,n.field14=14,n.field15=15,n.field16=16,n.field17=17,n.field18=18,n.field19=19,n.field20=20,n.field21=21,n.field22=22,n.field23=23,n.field24=24,n.field25=25,n.field26=26,n.field27=27,n.field28=28,n.field29=29,n.field30=30,n.field31=31,n.field32=32,n.field33=33,n.field34=34,n.field35=35,n.field36=36,n.field37=37,n.field38=38,n.field39=39,n.field40=40,n.field41=41,n.field42=42,n.field43=43,n.field44=44,n.field45=45,n.field46=46,n.field47=47,n.field48=48,n.left=n.right,n.right=n.left RETURN n.field0,n.field1,n.field2,n.field3,n.field4,n.field5,n.field6,n.field7,n.field8,n.field9,n.field10,n.field11,n.field12,n.field13,n.field14,n.field15,n.field16,n.field17,n.field18,n.field19,n.field20,n.field21,n.field22,n.field23,n.field24,n.field25,n.field26,n.field27,n.field28,n.field29,n.field30,n.field31,n.field32,n.field33,n.field34,n.field35,n.field36,n.field37,n.field38,n.field39,n.field40,n.field41,n.field42,n.field43,n.field44,n.field45,n.field46,n.field47,n.field48,n.left,n.right", + "fixture": { + "nodes": [], + "edges": [] + }, + "assert": { + "row_count": 1, + "row_values": [ + [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 2, + 1 + ] + ] + } + }, + { + "name": "merge 51 property match patch", + "cypher": "MERGE (n:NodeKind1 {name:'large'}) ON MATCH SET n.field0=0,n.field1=1,n.field2=2,n.field3=3,n.field4=4,n.field5=5,n.field6=6,n.field7=7,n.field8=8,n.field9=9,n.field10=10,n.field11=11,n.field12=12,n.field13=13,n.field14=14,n.field15=15,n.field16=16,n.field17=17,n.field18=18,n.field19=19,n.field20=20,n.field21=21,n.field22=22,n.field23=23,n.field24=24,n.field25=25,n.field26=26,n.field27=27,n.field28=28,n.field29=29,n.field30=30,n.field31=31,n.field32=32,n.field33=33,n.field34=34,n.field35=35,n.field36=36,n.field37=37,n.field38=38,n.field39=39,n.field40=40,n.field41=41,n.field42=42,n.field43=43,n.field44=44,n.field45=45,n.field46=46,n.field47=47,n.field48=48,n.left=n.right,n.right=n.left RETURN n.field0,n.field1,n.field2,n.field3,n.field4,n.field5,n.field6,n.field7,n.field8,n.field9,n.field10,n.field11,n.field12,n.field13,n.field14,n.field15,n.field16,n.field17,n.field18,n.field19,n.field20,n.field21,n.field22,n.field23,n.field24,n.field25,n.field26,n.field27,n.field28,n.field29,n.field30,n.field31,n.field32,n.field33,n.field34,n.field35,n.field36,n.field37,n.field38,n.field39,n.field40,n.field41,n.field42,n.field43,n.field44,n.field45,n.field46,n.field47,n.field48,n.left,n.right", + "fixture": { + "nodes": [ + { + "id": "n", + "kinds": [ + "NodeKind1" + ], + "properties": { + "name": "large", + "left": 1, + "right": 2 + } + } + ], + "edges": [] + }, + "assert": { + "row_count": 1, + "row_values": [ + [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 2, + 1 + ] + ] + } + }, + { + "name": "merge 51 relationship property patch", + "cypher": "MERGE (:NodeKind1)-[n:EdgeKind1 {left:1,right:2}]->(:NodeKind2) SET n.field0=0,n.field1=1,n.field2=2,n.field3=3,n.field4=4,n.field5=5,n.field6=6,n.field7=7,n.field8=8,n.field9=9,n.field10=10,n.field11=11,n.field12=12,n.field13=13,n.field14=14,n.field15=15,n.field16=16,n.field17=17,n.field18=18,n.field19=19,n.field20=20,n.field21=21,n.field22=22,n.field23=23,n.field24=24,n.field25=25,n.field26=26,n.field27=27,n.field28=28,n.field29=29,n.field30=30,n.field31=31,n.field32=32,n.field33=33,n.field34=34,n.field35=35,n.field36=36,n.field37=37,n.field38=38,n.field39=39,n.field40=40,n.field41=41,n.field42=42,n.field43=43,n.field44=44,n.field45=45,n.field46=46,n.field47=47,n.field48=48,n.left=n.right,n.right=n.left RETURN n.field0,n.field1,n.field2,n.field3,n.field4,n.field5,n.field6,n.field7,n.field8,n.field9,n.field10,n.field11,n.field12,n.field13,n.field14,n.field15,n.field16,n.field17,n.field18,n.field19,n.field20,n.field21,n.field22,n.field23,n.field24,n.field25,n.field26,n.field27,n.field28,n.field29,n.field30,n.field31,n.field32,n.field33,n.field34,n.field35,n.field36,n.field37,n.field38,n.field39,n.field40,n.field41,n.field42,n.field43,n.field44,n.field45,n.field46,n.field47,n.field48,n.left,n.right", + "fixture": { + "nodes": [], + "edges": [] + }, + "assert": { + "row_count": 1, + "row_values": [ + [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 2, + 1 + ] + ] + } + }, + { + "name": "merge property removal in second patch object", + "cypher": "MERGE (n:NodeKind1 {name:'large',removed:7}) SET n.field0=0,n.field1=1,n.field2=2,n.field3=3,n.field4=4,n.field5=5,n.field6=6,n.field7=7,n.field8=8,n.field9=9,n.field10=10,n.field11=11,n.field12=12,n.field13=13,n.field14=14,n.field15=15,n.field16=16,n.field17=17,n.field18=18,n.field19=19,n.field20=20,n.field21=21,n.field22=22,n.field23=23,n.field24=24,n.field25=25,n.field26=26,n.field27=27,n.field28=28,n.field29=29,n.field30=30,n.field31=31,n.field32=32,n.field33=33,n.field34=34,n.field35=35,n.field36=36,n.field37=37,n.field38=38,n.field39=39,n.field40=40,n.field41=41,n.field42=42,n.field43=43,n.field44=44,n.field45=45,n.field46=46,n.field47=47,n.field48=48,n.field49=49,n.removed=null RETURN n.field0,n.field1,n.field2,n.field3,n.field4,n.field5,n.field6,n.field7,n.field8,n.field9,n.field10,n.field11,n.field12,n.field13,n.field14,n.field15,n.field16,n.field17,n.field18,n.field19,n.field20,n.field21,n.field22,n.field23,n.field24,n.field25,n.field26,n.field27,n.field28,n.field29,n.field30,n.field31,n.field32,n.field33,n.field34,n.field35,n.field36,n.field37,n.field38,n.field39,n.field40,n.field41,n.field42,n.field43,n.field44,n.field45,n.field46,n.field47,n.field48,n.field49,n.removed", + "fixture": { + "nodes": [], + "edges": [] + }, + "assert": { + "row_count": 1, + "row_values": [ + [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + null + ] + ] + } + } + ] +} diff --git a/integration/testdata/templates/merge_shapes.json b/integration/testdata/templates/merge_shapes.json new file mode 100644 index 00000000..e977e080 --- /dev/null +++ b/integration/testdata/templates/merge_shapes.json @@ -0,0 +1,900 @@ +{ + "families": [ + { + "name": "merge nodes", + "template": "{{query}}", + "fixture": { + "nodes": [], + "edges": [] + }, + "variants": [ + { + "name": "string match and action values", + "vars": { + "query": "MERGE (n:TemplateNodeKind1 {name:$name}) ON CREATE SET n.status='created' ON MATCH SET n.status=$matched RETURN n" + }, + "params": { + "name": "a", + "matched": "matched" + }, + "assert": { + "row_count": 1, + "contains_node_with_props": { + "name": "a", + "status": "created" + } + } + }, + { + "name": "create action", + "vars": { + "query": "MERGE (n:TemplateNodeKind1 {name:'new'}) ON CREATE SET n.score=$score RETURN n" + }, + "params": { + "score": 1 + }, + "assert": { + "row_count": 1, + "contains_node_with_props": { + "name": "new", + "score": 1 + } + } + }, + { + "name": "ordinary set", + "vars": { + "query": "MERGE (n:TemplateNodeKind1 {name:'new'}) SET n.score=$score RETURN n" + }, + "params": { + "score": 1 + }, + "assert": { + "row_count": 1, + "contains_node_with_props": { + "name": "new", + "score": 1 + } + } + }, + { + "name": "no assignments", + "vars": { + "query": "MERGE (n:TemplateNodeKind1 {name:'new'}) RETURN n" + }, + "assert": { + "row_count": 1 + } + }, + { + "name": "limit zero", + "vars": { + "query": "MERGE (n:TemplateNodeKind1 {name:'new'}) RETURN n LIMIT 0" + }, + "assert": "empty" + }, + { + "name": "no return", + "vars": { + "query": "MERGE (n:TemplateNodeKind1 {name:'new'})" + }, + "assert": "no_error" + } + ] + }, + { + "name": "merge relationships", + "template": "{{query}}", + "fixture": { + "nodes": [], + "edges": [] + }, + "variants": [ + { + "name": "create action", + "vars": { + "query": "MERGE (a:TemplateNodeKind1)-[r:TemplateEdgeKind1]->(b:TemplateNodeKind2) ON CREATE SET r.score=$score RETURN r" + }, + "params": { + "score": 1 + }, + "assert": { + "row_count": 1, + "contains_edge": { + "kind": "TemplateEdgeKind1", + "props": { + "score": 1 + } + } + } + }, + { + "name": "ordinary set", + "vars": { + "query": "MERGE (a:TemplateNodeKind1)-[r:TemplateEdgeKind1]->(b:TemplateNodeKind2) SET r.score=$score RETURN r" + }, + "params": { + "score": 1 + }, + "assert": { + "row_count": 1, + "contains_edge": { + "kind": "TemplateEdgeKind1", + "props": { + "score": 1 + } + } + } + }, + { + "name": "no assignments", + "vars": { + "query": "MERGE (a:TemplateNodeKind1)-[r:TemplateEdgeKind1]->(b:TemplateNodeKind2) RETURN r" + }, + "assert": { + "row_count": 1 + } + }, + { + "name": "limit zero", + "vars": { + "query": "MERGE (a:TemplateNodeKind1)-[r:TemplateEdgeKind1]->(b:TemplateNodeKind2) RETURN r LIMIT 0" + }, + "assert": "empty" + }, + { + "name": "no return", + "vars": { + "query": "MERGE (a:TemplateNodeKind1)-[r:TemplateEdgeKind1]->(b:TemplateNodeKind2)" + }, + "assert": "no_error" + } + ] + }, + { + "name": "merge paths", + "template": "{{query}}", + "fixture": { + "nodes": [], + "edges": [] + }, + "variants": [ + { + "name": "create action", + "vars": { + "query": "MERGE p=(a:TemplateNodeKind1)-[r:TemplateEdgeKind1]->(b:TemplateNodeKind2) ON CREATE SET r.score=$score RETURN p" + }, + "params": { + "score": 1 + }, + "assert": { + "row_count": 1, + "path_lengths": [ + 1 + ] + } + }, + { + "name": "ordinary set", + "vars": { + "query": "MERGE p=(a:TemplateNodeKind1)-[r:TemplateEdgeKind1]->(b:TemplateNodeKind2) SET r.score=$score RETURN p" + }, + "params": { + "score": 1 + }, + "assert": { + "row_count": 1, + "path_lengths": [ + 1 + ] + } + }, + { + "name": "no assignments", + "vars": { + "query": "MERGE p=(a:TemplateNodeKind1)-[r:TemplateEdgeKind1]->(b:TemplateNodeKind2) RETURN p" + }, + "assert": { + "row_count": 1 + } + }, + { + "name": "limit zero", + "vars": { + "query": "MERGE p=(a:TemplateNodeKind1)-[r:TemplateEdgeKind1]->(b:TemplateNodeKind2) RETURN p LIMIT 0" + }, + "assert": "empty" + }, + { + "name": "no return", + "vars": { + "query": "MERGE p=(a:TemplateNodeKind1)-[r:TemplateEdgeKind1]->(b:TemplateNodeKind2)" + }, + "assert": "no_error" + } + ] + }, + { + "name": "merge copied property types matched", + "template": "MATCH (a:TemplateNodeKind1) MERGE (n:TemplateNodeKind2 {value:a.{{field}}}) RETURN n", + "variants": [ + { + "name": "number", + "vars": { + "field": "number" + }, + "assert": { + "node_ids": [ + "number" + ] + } + }, + { + "name": "boolean", + "vars": { + "field": "boolean" + }, + "assert": { + "node_ids": [ + "boolean" + ] + } + }, + { + "name": "array", + "vars": { + "field": "array" + }, + "assert": { + "node_ids": [ + "array" + ] + } + } + ], + "fixture": { + "nodes": [ + { + "id": "source", + "kinds": [ + "TemplateNodeKind1" + ], + "properties": { + "number": 4, + "boolean": true, + "array": [ + 1, + 2 + ] + } + }, + { + "id": "number", + "kinds": [ + "TemplateNodeKind2" + ], + "properties": { + "value": 4 + } + }, + { + "id": "boolean", + "kinds": [ + "TemplateNodeKind2" + ], + "properties": { + "value": true + } + }, + { + "id": "array", + "kinds": [ + "TemplateNodeKind2" + ], + "properties": { + "value": [ + 1, + 2 + ] + } + } + ], + "edges": [] + } + }, + { + "name": "merge copied property types created", + "template": "MATCH (a:TemplateNodeKind1) MERGE (n:TemplateNodeKind2 {value:a.{{field}}}) RETURN n", + "variants": [ + { + "name": "number", + "vars": { + "field": "number" + }, + "assert": { + "row_count": 1, + "contains_node_with_props": { + "value": 4 + } + } + }, + { + "name": "boolean", + "vars": { + "field": "boolean" + }, + "assert": { + "row_count": 1, + "contains_node_with_props": { + "value": true + } + } + }, + { + "name": "array", + "vars": { + "field": "array" + }, + "assert": { + "row_count": 1, + "contains_node_with_props": { + "value": [ + 1, + 2 + ] + } + } + } + ], + "fixture": { + "nodes": [ + { + "id": "source", + "kinds": [ + "TemplateNodeKind1" + ], + "properties": { + "number": 4, + "boolean": true, + "array": [ + 1, + 2 + ] + } + } + ], + "edges": [] + } + }, + { + "name": "merge carries incoming paths", + "template": "{{prefix}} p=(a:TemplateNodeKind1)-[:TemplateEdgeKind1]->(b:TemplateNodeKind2) MERGE (c:TemplateNodeKind1 {name:'independent'}) RETURN p", + "fixture": { + "nodes": [ + { + "id": "a", + "kinds": [ + "TemplateNodeKind1" + ], + "properties": {} + }, + { + "id": "b", + "kinds": [ + "TemplateNodeKind2" + ], + "properties": {} + } + ], + "edges": [ + { + "start_id": "a", + "end_id": "b", + "kind": "TemplateEdgeKind1", + "properties": {} + } + ] + }, + "variants": [ + { + "name": "match", + "vars": { + "prefix": "MATCH" + }, + "assert": { + "path_node_ids": [ + [ + "a", + "b" + ] + ], + "path_lengths": [ + 1 + ] + } + }, + { + "name": "merge", + "vars": { + "prefix": "MERGE" + }, + "assert": { + "path_node_ids": [ + [ + "a", + "b" + ] + ], + "path_lengths": [ + 1 + ] + } + } + ] + }, + { + "name": "merge distinct absent inputs with actions", + "template": "UNWIND ['a','b'] AS name MERGE (n:TemplateNodeKind1 {name:name}) ON CREATE SET n.score={{score}} RETURN n", + "fixture": { + "nodes": [], + "edges": [] + }, + "variants": [ + { + "name": "integer", + "vars": { + "score": "1" + }, + "assert": { + "row_count": 2, + "contains_node_with_props": { + "name": "a", + "score": 1 + } + } + }, + { + "name": "null removes property", + "vars": { + "score": "null" + }, + "assert": { + "row_count": 2, + "contains_node_with_prop": [ + "name", + "b" + ] + } + } + ] + }, + { + "name": "merge clause patches", + "template": "{{query}}", + "fixture": { + "nodes": [], + "edges": [] + }, + "variants": [ + { + "name": "merge clause swap", + "vars": { + "query": "MERGE (n:TemplateNodeKind1 {name:'a'}) ON CREATE SET n.a=1,n.b=2 SET n.a=n.b,n.b=n.a RETURN n" + }, + "assert": { + "row_count": 1, + "contains_node_with_props": { + "name": "a", + "a": 2, + "b": 1 + } + } + }, + { + "name": "merge separate clause dependency", + "vars": { + "query": "MERGE (n:TemplateNodeKind1 {name:'a'}) SET n.a=1 SET n.b=n.a RETURN n" + }, + "assert": { + "row_count": 1, + "contains_node_with_props": { + "name": "a", + "a": 1, + "b": 1 + } + } + }, + { + "name": "merge repeated field final non-null", + "vars": { + "query": "MERGE (n:TemplateNodeKind1 {name:'a'}) SET n.a=null,n.a=3 RETURN n" + }, + "assert": { + "row_count": 1, + "contains_node_with_props": { + "name": "a", + "a": 3 + } + } + }, + { + "name": "merge repeated field final null", + "vars": { + "query": "MERGE (n:TemplateNodeKind1 {name:'a'}) SET n.a=3,n.a=null RETURN n" + }, + "assert": { + "row_count": 1, + "contains_node_with_props": { + "name": "a" + } + } + }, + { + "name": "merge changes duplicate originals away", + "vars": { + "query": "UNWIND ['a','a'] AS name MERGE (n:TemplateNodeKind1 {name:name}) ON CREATE SET n.name='away' RETURN n" + }, + "assert": { + "row_count": 2, + "contains_node_with_props": { + "name": "away" + } + } + }, + { + "name": "merge composite distinct originals", + "vars": { + "query": "UNWIND [1,2] AS value MERGE (n:TemplateNodeKind1 {name:'same',score:value}) RETURN n" + }, + "assert": { + "row_count": 2, + "contains_node_with_props": { + "name": "same", + "score": 1 + } + } + } + ] + }, + { + "name": "merge repeated bound-endpoint matches", + "template": "MATCH (a:TemplateNodeKind1 {name:'a'}),(b:TemplateNodeKind2 {name:'b'}) UNWIND [1,2] AS input MERGE (a)-[:TemplateEdgeKind1]->(b) {{tail}}", + "fixture": { + "nodes": [ + { + "id": "a", + "kinds": [ + "TemplateNodeKind1" + ], + "properties": { + "name": "a" + } + }, + { + "id": "b", + "kinds": [ + "TemplateNodeKind2" + ], + "properties": { + "name": "b" + } + } + ], + "edges": [ + { + "start_id": "a", + "end_id": "b", + "kind": "TemplateEdgeKind1", + "properties": {} + } + ] + }, + "variants": [ + { + "name": "return entity", + "vars": { + "tail": "RETURN a" + }, + "assert": { + "row_count": 2, + "contains_node_with_props": { + "name": "a" + } + } + }, + { + "name": "unused entity", + "vars": { + "tail": "RETURN input" + }, + "assert": { + "row_count": 2 + } + }, + { + "name": "zero output", + "vars": { + "tail": "RETURN a LIMIT 0" + }, + "assert": { + "row_count": 0 + } + } + ] + }, + { + "name": "merge wildcard projection", + "template": "merge (n:TemplateNodeKind1 {name: 'a'}) on create set n.name = 'b' return *", + "fixture": { + "nodes": [], + "edges": [] + }, + "variants": [ + { + "name": "user columns only", + "vars": {}, + "assert": { + "row_count": 1, + "contains_node_with_props": { + "name": "b" + }, + "keys": [ + "n" + ] + } + } + ] + }, + { + "name": "merge large property patches", + "template": "{{query}}", + "fixture": { + "nodes": [], + "edges": [] + }, + "variants": [ + { + "name": "50 assignments with clause swap", + "vars": { + "query": "MERGE (n:TemplateNodeKind1 {name:'large',left:1,right:2}) SET n.field0=0,n.field1=1,n.field2=2,n.field3=3,n.field4=4,n.field5=5,n.field6=6,n.field7=7,n.field8=8,n.field9=9,n.field10=10,n.field11=11,n.field12=12,n.field13=13,n.field14=14,n.field15=15,n.field16=16,n.field17=17,n.field18=18,n.field19=19,n.field20=20,n.field21=21,n.field22=22,n.field23=23,n.field24=24,n.field25=25,n.field26=26,n.field27=27,n.field28=28,n.field29=29,n.field30=30,n.field31=31,n.field32=32,n.field33=33,n.field34=34,n.field35=35,n.field36=36,n.field37=37,n.field38=38,n.field39=39,n.field40=40,n.field41=41,n.field42=42,n.field43=43,n.field44=44,n.field45=45,n.field46=46,n.field47=47,n.left=n.right,n.right=n.left RETURN n.field0,n.field1,n.field2,n.field3,n.field4,n.field5,n.field6,n.field7,n.field8,n.field9,n.field10,n.field11,n.field12,n.field13,n.field14,n.field15,n.field16,n.field17,n.field18,n.field19,n.field20,n.field21,n.field22,n.field23,n.field24,n.field25,n.field26,n.field27,n.field28,n.field29,n.field30,n.field31,n.field32,n.field33,n.field34,n.field35,n.field36,n.field37,n.field38,n.field39,n.field40,n.field41,n.field42,n.field43,n.field44,n.field45,n.field46,n.field47,n.left,n.right" + }, + "assert": { + "row_count": 1, + "row_values": [ + [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 2, + 1 + ] + ] + } + }, + { + "name": "51 assignments with clause swap", + "vars": { + "query": "MERGE (n:TemplateNodeKind1 {name:'large',left:1,right:2}) SET n.field0=0,n.field1=1,n.field2=2,n.field3=3,n.field4=4,n.field5=5,n.field6=6,n.field7=7,n.field8=8,n.field9=9,n.field10=10,n.field11=11,n.field12=12,n.field13=13,n.field14=14,n.field15=15,n.field16=16,n.field17=17,n.field18=18,n.field19=19,n.field20=20,n.field21=21,n.field22=22,n.field23=23,n.field24=24,n.field25=25,n.field26=26,n.field27=27,n.field28=28,n.field29=29,n.field30=30,n.field31=31,n.field32=32,n.field33=33,n.field34=34,n.field35=35,n.field36=36,n.field37=37,n.field38=38,n.field39=39,n.field40=40,n.field41=41,n.field42=42,n.field43=43,n.field44=44,n.field45=45,n.field46=46,n.field47=47,n.field48=48,n.left=n.right,n.right=n.left RETURN n.field0,n.field1,n.field2,n.field3,n.field4,n.field5,n.field6,n.field7,n.field8,n.field9,n.field10,n.field11,n.field12,n.field13,n.field14,n.field15,n.field16,n.field17,n.field18,n.field19,n.field20,n.field21,n.field22,n.field23,n.field24,n.field25,n.field26,n.field27,n.field28,n.field29,n.field30,n.field31,n.field32,n.field33,n.field34,n.field35,n.field36,n.field37,n.field38,n.field39,n.field40,n.field41,n.field42,n.field43,n.field44,n.field45,n.field46,n.field47,n.field48,n.left,n.right" + }, + "assert": { + "row_count": 1, + "row_values": [ + [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 2, + 1 + ] + ] + } + }, + { + "name": "101 assignments with clause swap", + "vars": { + "query": "MERGE (n:TemplateNodeKind1 {name:'large',left:1,right:2}) SET n.field0=0,n.field1=1,n.field2=2,n.field3=3,n.field4=4,n.field5=5,n.field6=6,n.field7=7,n.field8=8,n.field9=9,n.field10=10,n.field11=11,n.field12=12,n.field13=13,n.field14=14,n.field15=15,n.field16=16,n.field17=17,n.field18=18,n.field19=19,n.field20=20,n.field21=21,n.field22=22,n.field23=23,n.field24=24,n.field25=25,n.field26=26,n.field27=27,n.field28=28,n.field29=29,n.field30=30,n.field31=31,n.field32=32,n.field33=33,n.field34=34,n.field35=35,n.field36=36,n.field37=37,n.field38=38,n.field39=39,n.field40=40,n.field41=41,n.field42=42,n.field43=43,n.field44=44,n.field45=45,n.field46=46,n.field47=47,n.field48=48,n.field49=49,n.field50=50,n.field51=51,n.field52=52,n.field53=53,n.field54=54,n.field55=55,n.field56=56,n.field57=57,n.field58=58,n.field59=59,n.field60=60,n.field61=61,n.field62=62,n.field63=63,n.field64=64,n.field65=65,n.field66=66,n.field67=67,n.field68=68,n.field69=69,n.field70=70,n.field71=71,n.field72=72,n.field73=73,n.field74=74,n.field75=75,n.field76=76,n.field77=77,n.field78=78,n.field79=79,n.field80=80,n.field81=81,n.field82=82,n.field83=83,n.field84=84,n.field85=85,n.field86=86,n.field87=87,n.field88=88,n.field89=89,n.field90=90,n.field91=91,n.field92=92,n.field93=93,n.field94=94,n.field95=95,n.field96=96,n.field97=97,n.field98=98,n.left=n.right,n.right=n.left RETURN n.field0,n.field1,n.field2,n.field3,n.field4,n.field5,n.field6,n.field7,n.field8,n.field9,n.field10,n.field11,n.field12,n.field13,n.field14,n.field15,n.field16,n.field17,n.field18,n.field19,n.field20,n.field21,n.field22,n.field23,n.field24,n.field25,n.field26,n.field27,n.field28,n.field29,n.field30,n.field31,n.field32,n.field33,n.field34,n.field35,n.field36,n.field37,n.field38,n.field39,n.field40,n.field41,n.field42,n.field43,n.field44,n.field45,n.field46,n.field47,n.field48,n.field49,n.field50,n.field51,n.field52,n.field53,n.field54,n.field55,n.field56,n.field57,n.field58,n.field59,n.field60,n.field61,n.field62,n.field63,n.field64,n.field65,n.field66,n.field67,n.field68,n.field69,n.field70,n.field71,n.field72,n.field73,n.field74,n.field75,n.field76,n.field77,n.field78,n.field79,n.field80,n.field81,n.field82,n.field83,n.field84,n.field85,n.field86,n.field87,n.field88,n.field89,n.field90,n.field91,n.field92,n.field93,n.field94,n.field95,n.field96,n.field97,n.field98,n.left,n.right" + }, + "assert": { + "row_count": 1, + "row_values": [ + [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68, + 69, + 70, + 71, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + 83, + 84, + 85, + 86, + 87, + 88, + 89, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 2, + 1 + ] + ] + } + } + ] + } + ] +}