From f215df3efe5e484b7773a6b572b0e5223db45d7f Mon Sep 17 00:00:00 2001 From: John Hopper Date: Tue, 24 Mar 2026 21:09:28 -0700 Subject: [PATCH 1/2] fix: omit empty node joins from recursive expansion --- cypher/models/pgsql/test/assertions.go | 187 ++ cypher/models/pgsql/test/cases.go | 2065 +++++++++++++++++ cypher/models/pgsql/test/fixture.go | 108 + .../pgsql/test/semantic_integration_test.go | 170 ++ .../test/translation_cases/multipart.sql | 2 +- .../pgsql/test/translation_cases/nodes.sql | 1 + .../translation_cases/pattern_binding.sql | 4 +- .../translation_cases/pattern_expansion.sql | 16 +- cypher/models/pgsql/test/translation_test.go | 8 - .../pgsql/test/validation_integration_test.go | 9 +- cypher/models/pgsql/translate/expansion.go | 142 +- 11 files changed, 2631 insertions(+), 81 deletions(-) create mode 100644 cypher/models/pgsql/test/assertions.go create mode 100644 cypher/models/pgsql/test/cases.go create mode 100644 cypher/models/pgsql/test/fixture.go create mode 100644 cypher/models/pgsql/test/semantic_integration_test.go diff --git a/cypher/models/pgsql/test/assertions.go b/cypher/models/pgsql/test/assertions.go new file mode 100644 index 0000000..6be898a --- /dev/null +++ b/cypher/models/pgsql/test/assertions.go @@ -0,0 +1,187 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package test + +import ( + "testing" + + "github.com/specterops/dawgs/graph" + "github.com/stretchr/testify/require" +) + +// ResultAssertion inspects a live query result. When called, Next() has not +// yet been advanced — the assertion is responsible for iteration. +type ResultAssertion func(t *testing.T, result graph.Result) + +// bufferedResult implements graph.Result over an in-memory row buffer, enabling +// multiple assertion passes over the same result without re-executing the query. +type bufferedResult struct { + rows [][]any + keys []string + mapper graph.ValueMapper + current int + err error +} + +// newBufferedResult exhausts r into memory and returns a replayable result. +// The caller must not use r after this call. +func newBufferedResult(r graph.Result) *bufferedResult { + br := &bufferedResult{mapper: r.Mapper(), current: -1} + for r.Next() { + if br.keys == nil { + br.keys = r.Keys() + } + vals := r.Values() + row := make([]any, len(vals)) + copy(row, vals) + br.rows = append(br.rows, row) + } + br.err = r.Error() + return br +} + +func (b *bufferedResult) Reset() { b.current = -1 } +func (b *bufferedResult) Next() bool { b.current++; return b.current < len(b.rows) } +func (b *bufferedResult) Keys() []string { return b.keys } +func (b *bufferedResult) Mapper() graph.ValueMapper { return b.mapper } +func (b *bufferedResult) Error() error { return b.err } +func (b *bufferedResult) Close() {} + +func (b *bufferedResult) Values() []any { + if b.current < 0 || b.current >= len(b.rows) { + return nil + } + return b.rows[b.current] +} + +func (b *bufferedResult) Scan(targets ...any) error { + return graph.ScanNextResult(b, targets...) +} + +// AssertNoError drains the result and asserts it carries no error. +func AssertNoError() ResultAssertion { + return func(t *testing.T, result graph.Result) { + t.Helper() + for result.Next() { + } + require.NoError(t, result.Error()) + } +} + +// AssertEmpty asserts the result set contains zero rows. +func AssertEmpty() ResultAssertion { + return func(t *testing.T, result graph.Result) { + t.Helper() + br := newBufferedResult(result) + require.NoError(t, br.err) + require.Empty(t, br.rows, "expected empty result but got %d rows", len(br.rows)) + } +} + +// AssertNonEmpty asserts the result set contains at least one row. +func AssertNonEmpty() ResultAssertion { + return func(t *testing.T, result graph.Result) { + t.Helper() + br := newBufferedResult(result) + require.NoError(t, br.err) + require.NotEmpty(t, br.rows, "expected non-empty result set") + } +} + +// AssertRowCount asserts the result set contains exactly n rows. +func AssertRowCount(n int) ResultAssertion { + return func(t *testing.T, result graph.Result) { + t.Helper() + br := newBufferedResult(result) + require.NoError(t, br.err) + require.Len(t, br.rows, n, "unexpected row count") + } +} + +// AssertScalarString asserts the first column of the first row is the given string. +func AssertScalarString(expected string) ResultAssertion { + return func(t *testing.T, result graph.Result) { + t.Helper() + br := newBufferedResult(result) + require.NoError(t, br.err) + require.NotEmpty(t, br.rows, "no rows returned; cannot assert scalar string") + require.Equal(t, expected, br.rows[0][0], "scalar string mismatch") + } +} + +// AssertAtLeastInt64 asserts the first column of the first row is an int64 +// greater than or equal to min. This is suitable for count/aggregate queries +// where the database may contain pre-existing rows alongside the fixture data. +func AssertAtLeastInt64(min int64) ResultAssertion { + return func(t *testing.T, result graph.Result) { + t.Helper() + br := newBufferedResult(result) + require.NoError(t, br.err) + require.NotEmpty(t, br.rows, "no rows returned; cannot assert scalar int64") + val, ok := br.rows[0][0].(int64) + require.True(t, ok, "expected int64 scalar, got %T: %v", br.rows[0][0], br.rows[0][0]) + require.GreaterOrEqual(t, val, min, "scalar int64 below expected minimum") + } +} + +// AssertContainsNodeWithProp asserts that at least one row in the result +// contains a node (in any column) with the given string property value. +func AssertContainsNodeWithProp(key, val string) ResultAssertion { + return func(t *testing.T, result graph.Result) { + t.Helper() + br := newBufferedResult(result) + require.NoError(t, br.err) + require.NotEmpty(t, br.rows, "no rows; cannot check node property %s", key) + for _, row := range br.rows { + for _, rawVal := range row { + var node graph.Node + if br.mapper.Map(rawVal, &node) { + if s, err := node.Properties.Get(key).String(); err == nil && s == val { + return + } + } + } + } + t.Errorf("no result row contains a node with %s = %q", key, val) + } +} + +// AssertExactInt64 asserts the first column of the first row is exactly the given int64 value. +func AssertExactInt64(expected int64) ResultAssertion { + return func(t *testing.T, result graph.Result) { + t.Helper() + br := newBufferedResult(result) + require.NoError(t, br.err) + require.NotEmpty(t, br.rows, "no rows returned; cannot assert scalar int64") + val, ok := br.rows[0][0].(int64) + require.True(t, ok, "expected int64 scalar, got %T: %v", br.rows[0][0], br.rows[0][0]) + require.Equal(t, expected, val, "scalar int64 mismatch") + } +} + +// AssertAll buffers the result once and runs each assertion against the same +// buffered data, resetting the cursor between assertions. +func AssertAll(assertions ...ResultAssertion) ResultAssertion { + return func(t *testing.T, result graph.Result) { + t.Helper() + br := newBufferedResult(result) + for _, a := range assertions { + br.Reset() + a(t, br) + } + } +} diff --git a/cypher/models/pgsql/test/cases.go b/cypher/models/pgsql/test/cases.go new file mode 100644 index 0000000..d66dda9 --- /dev/null +++ b/cypher/models/pgsql/test/cases.go @@ -0,0 +1,2065 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package test + +import ( + "slices" + "testing" + + "github.com/specterops/dawgs/graph" +) + +// baseFixture is shared by test cases that only require a small, general-purpose +// graph. It provides two typed nodes connected by a typed edge, plus a node +// carrying both kinds and a variety of property types. +// +// n1 (NodeKind1) --[EdgeKind1]--> n2 (NodeKind2) --[EdgeKind2]--> n3 (NodeKind1+NodeKind2) +var baseFixture = GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("n1", map[string]any{ + "name": "SOME NAME", + "value": 1, + "objectid": "S-1-5-21-1", + "enabled": true, + "hasspn": true, + "pwdlastset": float64(-2), // not -1 or 0, so excluded by typical filters + "functionallevel": "2012", + "system_tags": "admin_tier_0", + "domain": "test.local", + "other": "SOME NAME", + "tid": "tid1", + "selected": true, + "array_value": []any{float64(1), float64(2)}, + "arrayProperty": []any{"DES-CBC-CRC", "DES-CBC-MD5"}, + "distinguishedname": "CN=TEST,DC=example,DC=com", + "samaccountname": "testuser", + "email": "test@example.com", + }, NodeKind1), + NewNodeWithProperties("n2", map[string]any{ + "name": "1234", + "value": 2, + "objectid": "S-1-5-21-2", + "tid": "tid1", + "distinguishedname": "CN=ADMINSDHOLDER,CN=SYSTEM,CN=TEST,DC=example,DC=com", + "samaccountname": "adminuser", + "email": "admin@example.com", + "domain": "other.local", + }, NodeKind2), + NewNodeWithProperties("n3", map[string]any{ + "name": "n3", + "value": 3, + "prop": "a", + }, NodeKind1, NodeKind2), + }, + Edges: []EdgeFixture{ + NewEdgeWithProperties("n1", "n2", EdgeKind1, map[string]any{ + "prop": "a", + "value": 42, + "bool_prop": true, + }), + NewEdge("n2", "n3", EdgeKind2), + }, +} + +// semanticTestCases is the complete list of semantic integration test cases. +var semanticTestCases = func() []SemanticTestCase { + return slices.Concat( + nodesSemanticCases, + stepwiseSemanticCases, + expansionSemanticCases, + aggregationSemanticCases, + multipartSemanticCases, + patternBindingSemanticCases, + deleteSemanticCases, + updateSemanticCases, + quantifiersSemanticCases, + ) +}() + +var nodesSemanticCases = []SemanticTestCase{ + { + Name: "return kind labels for all nodes", + Cypher: `match (n) return labels(n)`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), + }, + { + Name: "filter any node by string property equality", + Cypher: `match (n) where n.name = '1234' return n`, + Fixture: baseFixture, + Assert: AssertContainsNodeWithProp("name", "1234"), + }, + { + Name: "filter a typed node using an inline property map", + Cypher: `match (n:NodeKind1 {name: "SOME NAME"}) return n`, + Fixture: baseFixture, + Assert: AssertContainsNodeWithProp("name", "SOME NAME"), + }, + { + Name: "return all nodes", + Cypher: `match (s) return s`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), + }, + { + Name: "filter nodes matching a kind disjunction", + Cypher: `match (s) where (s:NodeKind1 or s:NodeKind2) return s`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), + }, + { + Name: "cross-product filter where two nodes share a property value", + Cypher: `match (n:NodeKind1), (e) where n.name = e.name return n`, + Fixture: baseFixture, + // n1 (NodeKind1, name="SOME NAME") self-joins because it also exists as e + Assert: AssertNonEmpty(), + }, + { + Name: "filter any node by string property equality (s binding)", + Cypher: `match (s) where s.name = '1234' return s`, + Fixture: baseFixture, + Assert: AssertContainsNodeWithProp("name", "1234"), + }, + { + Name: "filter node where property value appears in a literal list", + Cypher: `match (s) where s.name in ['option 1', 'option 2'] return s`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("a", map[string]any{"name": "option 1"}, NodeKind1), + NewNodeWithProperties("b", map[string]any{"name": "option 2"}, NodeKind2), + NewNodeWithProperties("c", map[string]any{"name": "option 3"}, NodeKind1), + }, + }, + Assert: AssertNonEmpty(), + }, + { + Name: "filter node by string starts-with prefix", + Cypher: `match (s) where s.name starts with '123' return s`, + Fixture: baseFixture, + Assert: AssertContainsNodeWithProp("name", "1234"), + }, + { + Name: "filter node by string ends-with suffix", + Cypher: `match (s) where s.name ends with 'NAME' return s`, + Fixture: baseFixture, + Assert: AssertContainsNodeWithProp("name", "SOME NAME"), + }, + { + Name: "filter node where a property is not null", + Cypher: `match (n) where n.system_tags is not null return n`, + Fixture: baseFixture, + Assert: AssertContainsNodeWithProp("system_tags", "admin_tier_0"), + }, + { + Name: "filter typed node using coalesce with contains predicate", + Cypher: `match (n:NodeKind1) where coalesce(n.system_tags, '') contains 'admin_tier_0' return n`, + Fixture: baseFixture, + Assert: AssertContainsNodeWithProp("system_tags", "admin_tier_0"), + }, + { + Name: "filter typed node by array property size", + Cypher: `match (n:NodeKind1) where size(n.array_value) > 0 return n`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), + }, + { + Name: "filter typed node where array property overlaps a literal list", + Cypher: `match (n:NodeKind1) where ['DES-CBC-CRC', 'DES-CBC-MD5', 'RC4-HMAC-MD5'] in n.arrayProperty return n`, + Fixture: baseFixture, + // n1.arrayProperty contains DES-CBC-CRC and DES-CBC-MD5, so overlap matches + Assert: AssertNonEmpty(), + }, + { + Name: "filter typed node where array property contains one of several scalar values", + Cypher: `match (u:NodeKind1) where 'DES-CBC-CRC' in u.arrayProperty or 'DES-CBC-MD5' in u.arrayProperty return u`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), + }, + { + Name: "filter node carrying two kind labels simultaneously", + Cypher: `match (s) where s:NodeKind1 and s:NodeKind2 return s`, + Fixture: baseFixture, + // n3 has both NodeKind1 and NodeKind2 + Assert: AssertContainsNodeWithProp("name", "n3"), + }, + { + Name: "cross-product filter where two nodes match different typed properties", + Cypher: `match (s), (e) where s.name = '1234' and e.other = 1234 return s`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("a", map[string]any{"name": "1234"}, NodeKind1), + NewNodeWithProperties("b", map[string]any{"other": 1234}, NodeKind2), + }, + }, + Assert: AssertNonEmpty(), + }, + { + Name: "paginate results using SKIP and LIMIT", + Cypher: `match (n) return n skip 5 limit 10`, + Fixture: baseFixture, + // Behaviour depends on total node count, just assert no error + Assert: AssertNoError(), + }, + { + Name: "order results by node ID descending", + Cypher: `match (s) return s order by id(s) desc`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), + }, + { + Name: "filter isolated nodes with no adjacent edges", + Cypher: `match (s) where not (s)-[]-() return s`, + Fixture: baseFixture, + // Cannot predict with a shared database; just verify the query runs + Assert: AssertNoError(), + }, + { + Name: "filter nodes where node ID appears in another node's array property", + Cypher: `match (s), (e) where id(s) in e.captured_ids return s, e`, + // Cannot assert non-empty because fixture node IDs are unknown at + // definition time; just verify the query executes without error + Fixture: baseFixture, + Assert: AssertNoError(), + }, + { + Name: "filter typed node with starts-with using a function call as the prefix", + Cypher: `match (n:NodeKind1) where n.distinguishedname starts with toUpper('admin') return n`, + Fixture: baseFixture, + Assert: AssertEmpty(), + }, + { + Name: "optional match returns results even when the pattern may be absent", + Cypher: `optional match (n:NodeKind1) return n`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), + }, + { + Name: "double-negation filter selects nodes where a property is null", + Cypher: `match (n) where not n.property is not null return n`, + Fixture: baseFixture, + // n1, n2, n3 have no 'property' key, so all qualify + Assert: AssertNonEmpty(), + }, + { + Name: "filter nodes where array property equals an empty array literal", + Cypher: `match (s) where s.prop = [] return s`, + Fixture: baseFixture, + // None of our nodes have prop = [], n3 has prop = "a" + Assert: AssertNoError(), + }, + // ---- contains ------------------------------------------------------- + { + Name: "filter node by string contains predicate", + Cypher: `match (s) where s.name contains '123' return s`, + Fixture: baseFixture, + // n2.name = "1234" contains "123" + Assert: AssertContainsNodeWithProp("name", "1234"), + }, + // ---- negated string predicates -------------------------------------- + { + Name: "filter node using negated starts-with predicate", + Cypher: `match (s) where not s.name starts with 'XYZ' return s`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), + }, + { + Name: "filter node using negated contains predicate", + Cypher: `match (s) where not s.name contains 'XYZ' return s`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), + }, + { + Name: "filter node using negated ends-with predicate", + Cypher: `match (s) where not s.name ends with 'XYZ' return s`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), + }, + // ---- dynamic (variable-to-variable) string predicates --------------- + { + Name: "filter node where string property starts with another property (dynamic)", + Cypher: `match (s) where s.name starts with s.other return s`, + Fixture: baseFixture, + // n1: name="SOME NAME", other="SOME NAME" — name starts with itself + Assert: AssertNonEmpty(), + }, + { + Name: "filter node where string property contains another property (dynamic)", + Cypher: `match (s) where s.name contains s.other return s`, + Fixture: baseFixture, + // n1: name="SOME NAME", other="SOME NAME" — name contains itself + Assert: AssertNonEmpty(), + }, + { + Name: "filter node where string property ends with another property (dynamic)", + Cypher: `match (s) where s.name ends with s.other return s`, + Fixture: baseFixture, + // n1: name="SOME NAME", other="SOME NAME" — name ends with itself + Assert: AssertNonEmpty(), + }, + // ---- IS NULL -------------------------------------------------------- + { + Name: "filter nodes where a datetime property is null", + Cypher: `match (s) where s.created_at is null return s`, + Fixture: baseFixture, + // No fixture node has created_at set → property is null for all three + Assert: AssertNonEmpty(), + }, + // ---- arithmetic in projection --------------------------------------- + { + Name: "project an arithmetic expression on a node property", + Cypher: `match (s) return s.value + 1`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), + }, + // ---- datetime().epochseconds ---------------------------------------- + { + Name: "filter typed node using datetime arithmetic against epoch seconds", + Cypher: `match (u:NodeKind1) where u.pwdlastset < (datetime().epochseconds - (365 * 86400)) and not u.pwdlastset IN [-1.0, 0.0] return u limit 100`, + Fixture: baseFixture, + // n1: pwdlastset=-2; (-2 < current_epoch-31536000) is true; -2 not in {-1,0} is true + Assert: AssertNonEmpty(), + }, + // ---- element in property array -------------------------------------- + { + Name: "filter node where a scalar value appears in an array property", + Cypher: `match (n) where 1 in n.array return n`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("a", map[string]any{"array": []any{float64(1), float64(2), float64(3)}}, NodeKind1), + }, + }, + Assert: AssertNonEmpty(), + }, + // ---- coalesce equality forms ------------------------------------------------ + { + Name: "filter node using coalesce equality on a named property", + Cypher: `match (n) where coalesce(n.name, '') = '1234' return n`, + Fixture: baseFixture, + // n2.name='1234'; coalesce('1234','')='1234' → matches + Assert: AssertContainsNodeWithProp("name", "1234"), + }, + { + Name: "filter typed node using three-argument coalesce equality against an integer", + Cypher: `match (n:NodeKind1) where coalesce(n.a, n.b, 1) = 1 return n`, + Fixture: baseFixture, + // n1 and n3 have no 'a' or 'b' → coalesce(null,null,1)=1 → matches both + Assert: AssertNonEmpty(), + }, + { + Name: "filter typed node using two-property coalesce that resolves to null", + Cypher: `match (n:NodeKind1) where coalesce(n.a, n.b) = 1 return n`, + Fixture: baseFixture, + Assert: AssertNoError(), + }, + { + Name: "filter typed node with coalesce on the right-hand side of equality", + Cypher: `match (n:NodeKind1) where 1 = coalesce(n.a, n.b) return n`, + Fixture: baseFixture, + Assert: AssertNoError(), + }, + { + Name: "filter typed node with coalesce equality on both sides", + Cypher: `match (n:NodeKind1) where coalesce(n.name, '') = coalesce(n.migrated_name, '') return n`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("a", map[string]any{"name": "mirror", "migrated_name": "mirror"}, NodeKind1), + NewNodeWithProperties("b", map[string]any{"name": "differ"}, NodeKind1), + }, + }, + Assert: AssertNonEmpty(), + }, + // ---- arithmetic in WHERE and projection ------------------------------------- + { + Name: "filter node using an arithmetic expression in the WHERE clause", + Cypher: `match (s) where s.value + 2 / 3 > 10 return s`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("a", map[string]any{"value": 20}, NodeKind1), + NewNodeWithProperties("b", map[string]any{"value": 1}, NodeKind2), + }, + }, + // Integer division: 2/3=0, so s.value+0>10 → a.value=20>10 matches + Assert: AssertNonEmpty(), + }, + { + Name: "project a compound arithmetic expression dividing a shifted property", + Cypher: `match (s) return (s.value + 1) / 3`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), + }, + // ---- toLower equality with DISTINCT ----------------------------------------- + { + Name: "filter node using toLower equality and return distinct results", + Cypher: `match (s) where toLower(s.name) = '1234' return distinct s`, + Fixture: baseFixture, + // n2.name='1234'; toLower('1234')='1234' → matches + Assert: AssertContainsNodeWithProp("name", "1234"), + }, + { + Name: "filter typed node using toLower contains with a compound AND predicate", + Cypher: `match (n:NodeKind1) where n:NodeKind1 and toLower(n.tenantid) contains 'myid' and n.system_tags contains 'tag' return n`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("a", map[string]any{"tenantid": "MyID-Corp", "system_tags": "tag_admin"}, NodeKind1), + }, + }, + Assert: AssertNonEmpty(), + }, + // ---- toUpper in string predicates ------------------------------------------- + { + Name: "filter typed node where a property contains a toUpper() result", + Cypher: `match (n:NodeKind1) where n.distinguishedname contains toUpper('test') return n`, + Fixture: baseFixture, + // n1.distinguishedname='CN=TEST,DC=example,DC=com'; toUpper('test')='TEST' → contains + Assert: AssertNonEmpty(), + }, + { + Name: "filter typed node where a property equals a toUpper() result (no match)", + Cypher: `match (n:NodeKind1) where n.distinguishedname = toUpper('admin') return n`, + Fixture: baseFixture, + // toUpper('admin')='ADMIN'; no node has distinguishedname='ADMIN' + Assert: AssertEmpty(), + }, + { + Name: "filter typed node where a property ends with a toUpper() result (no match)", + Cypher: `match (n:NodeKind1) where n.distinguishedname ends with toUpper('com') return n`, + Fixture: baseFixture, + // toUpper('com')='COM'; distinguishedname ends with 'com' not 'COM' + Assert: AssertEmpty(), + }, + // ---- toString / toInt in membership ----------------------------------------- + { + Name: "filter typed node where toString of a property appears in a literal list", + Cypher: `match (n:NodeKind1) where toString(n.functionallevel) in ['2008 R2', '2012', '2008', '2003'] return n`, + Fixture: baseFixture, + // n1.functionallevel='2012'; toString('2012')='2012' → in list → matches + Assert: AssertNonEmpty(), + }, + { + Name: "filter typed node where toInt of a property appears in a literal integer list", + Cypher: `match (n:NodeKind1) where toInt(n.value) in [1, 2, 3, 4] return n`, + Fixture: baseFixture, + // n1.value=1; toInt(1)=1 → in [1,2,3,4] → matches + Assert: AssertNonEmpty(), + }, + // ---- datetime().epochmillis ------------------------------------------------- + { + Name: "filter typed node using datetime arithmetic against epoch milliseconds", + Cypher: `match (u:NodeKind1) where u.pwdlastset < (datetime().epochmillis - 86400000) and not u.pwdlastset IN [-1.0, 0.0] return u limit 100`, + Fixture: baseFixture, + // n1.pwdlastset=-2; current epochmillis ≫ 86400000; -2 < big_number → true; -2 ∉ {-1,0} → true + Assert: AssertNonEmpty(), + }, + // ---- date / time function comparisons --------------------------------------- + { + Name: "filter node where a datetime property equals the current date", + Cypher: `match (s) where s.created_at = date() return s`, + Fixture: baseFixture, + // No fixture node has created_at; query runs but returns nothing + Assert: AssertNoError(), + }, + { + Name: "filter node where a property equals date minus a duration", + Cypher: `match (s) where s.created_at = date() - duration('P1D') return s`, + Fixture: baseFixture, + Assert: AssertNoError(), + }, + { + Name: "filter node where a property equals date plus a duration string", + Cypher: `match (s) where s.created_at = date() + duration('4 hours') return s`, + Fixture: baseFixture, + Assert: AssertNoError(), + }, + { + Name: "filter node where a property equals a literal date value", + Cypher: `match (s) where s.created_at = date('2023-4-4') return s`, + Fixture: baseFixture, + Assert: AssertNoError(), + }, + { + Name: "filter node where a datetime property equals the current datetime", + Cypher: `match (s) where s.created_at = datetime() return s`, + Fixture: baseFixture, + Assert: AssertNoError(), + }, + { + Name: "filter node where a property equals a literal datetime value", + Cypher: `match (s) where s.created_at = datetime('2019-06-01T18:40:32.142+0100') return s`, + Fixture: baseFixture, + Assert: AssertNoError(), + }, + { + Name: "filter node where a property equals the current local datetime", + Cypher: `match (s) where s.created_at = localdatetime() return s`, + Fixture: baseFixture, + Assert: AssertNoError(), + }, + { + Name: "filter node where a property equals a literal local datetime value", + Cypher: `match (s) where s.created_at = localdatetime('2019-06-01T18:40:32.142') return s`, + Fixture: baseFixture, + Assert: AssertNoError(), + }, + { + Name: "filter node where a property equals the current local time", + Cypher: `match (s) where s.created_at = localtime() return s`, + Fixture: baseFixture, + Assert: AssertNoError(), + }, + { + Name: "filter node where a property equals a literal local time value", + Cypher: `match (s) where s.created_at = localtime('4:4:4') return s`, + Fixture: baseFixture, + Assert: AssertNoError(), + }, + // ---- negation / NOT forms --------------------------------------------------- + { + Name: "filter node using a negated parenthesized equality predicate", + Cypher: `match (s) where not (s.name = '123') return s`, + Fixture: baseFixture, + // n1.name='SOME NAME', n2.name='1234', n3.name='n3'; none equals '123' + Assert: AssertNonEmpty(), + }, + { + Name: "filter node using negated 2-hop path existence", + Cypher: `match (s) where not (s)-[]->()-[]->() return s`, + Fixture: baseFixture, + Assert: AssertNoError(), + }, + { + Name: "filter node using negated directed edge pattern with property constraints", + Cypher: `match (s) where not (s)-[{prop: 'a'}]->({name: 'n3'}) return s`, + Fixture: baseFixture, + // n1 has edge {prop='a'} to n2 (name='1234'), not to a node named 'n3' → pattern absent for n1 + Assert: AssertNonEmpty(), + }, + { + Name: "filter node using negated incoming edge pattern with property constraints", + Cypher: `match (s) where not (s)<-[{prop: 'a'}]-({name: 'n3'}) return s`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), + }, + { + Name: "return id of node where negated kind filter removes typed results", + Cypher: `match (s) where not (s)-[]-() return id(s)`, + Fixture: baseFixture, + Assert: AssertNoError(), + }, + // ---- id() in integer literal list ------------------------------------------- + { + Name: "filter node where id appears in a literal integer list", + Cypher: `match (s) where id(s) in [1, 2, 3, 4] return s`, + Fixture: baseFixture, + // Node IDs are assigned by the database; we cannot predict them at definition time + Assert: AssertNoError(), + }, + // ---- three-way OR membership in array property ------------------------------ + { + Name: "filter typed node where array property contains one of three scalar values", + Cypher: `match (u:NodeKind1) where 'DES-CBC-CRC' in u.arrayProperty or 'DES-CBC-MD5' in u.arrayProperty or 'RC4-HMAC-MD5' in u.arrayProperty return u`, + Fixture: baseFixture, + // n1.arrayProperty=['DES-CBC-CRC','DES-CBC-MD5'] → first OR branch matches + Assert: AssertNonEmpty(), + }, + { + Name: "filter typed node where a scalar appears in an array property concatenated with a literal list", + Cypher: `match (n:NodeKind1) where '1' in n.array_prop + ['1', '2'] return n`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("a", map[string]any{"array_prop": []any{"x", "y"}}, NodeKind1), + }, + }, + // ['x','y'] + ['1','2'] = ['x','y','1','2']; '1' is in result → matches + Assert: AssertNonEmpty(), + }, + // ---- empty-array comparison variants ---------------------------------------- + { + Name: "filter node where an empty array literal equals a property (reversed operands)", + Cypher: `match (s) where [] = s.prop return s`, + Fixture: baseFixture, + // n3.prop='a' ≠ []; no node has prop=[] → empty result, no error + Assert: AssertNoError(), + }, + { + Name: "filter node where a property is not equal to an empty array", + Cypher: `match (s) where s.prop <> [] return s`, + Fixture: baseFixture, + // n3.prop='a' ≠ [] → matches; others have no prop (null) which is excluded + Assert: AssertNonEmpty(), + }, + { + Name: "filter node using negated equality to an empty array", + Cypher: `match (s) where not s.prop = [] return s`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), + }, + // ---- string concatenation in WHERE ------------------------------------------ + { + Name: "filter typed node using property equality with literal-then-property concatenation", + Cypher: `match (n:NodeKind1) match (m:NodeKind2) where m.distinguishedname = 'CN=ADMINSDHOLDER,CN=SYSTEM,' + n.distinguishedname return m`, + Fixture: baseFixture, + // 'CN=ADMINSDHOLDER,CN=SYSTEM,' + 'CN=TEST,DC=example,DC=com' matches n2.distinguishedname + Assert: AssertNonEmpty(), + }, + { + Name: "filter typed node using property equality with property-then-literal concatenation", + Cypher: `match (n:NodeKind1) match (m:NodeKind2) where m.distinguishedname = n.distinguishedname + 'CN=ADMINSDHOLDER,CN=SYSTEM,' return m`, + Fixture: baseFixture, + // concat yields different string; n2.distinguishedname does not match → empty + Assert: AssertNoError(), + }, + { + Name: "filter typed node using property equality with two literal strings concatenated", + Cypher: `match (n:NodeKind1) match (m:NodeKind2) where m.distinguishedname = '1' + '2' return m`, + Fixture: baseFixture, + // '1'+'2'='12'; no node has that distinguishedname → empty + Assert: AssertNoError(), + }, + // ---- multiple ORDER BY columns ---------------------------------------------- + { + Name: "order results by two properties with mixed sort directions", + Cypher: `match (s) return s order by s.name, s.other_prop desc`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), + }, + // ---- cross-product with aliased property projection ------------------------- + { + Name: "return source and an aliased property from an unrelated node in a cross-product", + Cypher: `match (s), (e) where s.name = 'n1' return s, e.name as othername`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("a", map[string]any{"name": "n1"}, NodeKind1), + NewNodeWithProperties("b", map[string]any{"name": "n2"}, NodeKind2), + }, + }, + Assert: AssertNonEmpty(), + }, + // ---- cross-product with OR predicate ---------------------------------------- + { + Name: "filter cross-product where either node satisfies a different property predicate", + Cypher: `match (s), (e) where s.name = '1234' or e.other = 1234 return s`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("a", map[string]any{"name": "1234"}, NodeKind1), + NewNodeWithProperties("b", map[string]any{"other": 1234}, NodeKind2), + }, + }, + Assert: AssertNonEmpty(), + }, + // ---- double optional match -------------------------------------------------- + { + Name: "two sequential optional matches where only the anchor node is required", + Cypher: `match (n:NodeKind1) optional match (m:NodeKind2) where m.distinguishedname starts with n.distinguishedname optional match (o:NodeKind2) where o.distinguishedname <> n.distinguishedname return n, m, o`, + Fixture: baseFixture, + // n1 is always returned; m and o may or may not match + Assert: AssertNonEmpty(), + }, + // Currently fails + // + // { + // Name: "optional match with string concatenation in the filter joining two nodes", + // Cypher: `match (n:NodeKind1) optional match (m:NodeKind2) where m.distinguishedname = n.unknown + m.unknown return n, m`, + // Fixture: baseFixture, + // // n1 has no 'unknown' → null+null; no m matches → optional returns null for m; n returned + // Assert: AssertNonEmpty(), + // }, + // ---- complex hasspn / not-ends-with filter ---------------------------------- + { + Name: "filter typed node with compound hasspn, enabled, and not-ends-with checks", + Cypher: `match (u:NodeKind1) where u.hasspn = true and u.enabled = true and not '-502' ends with u.objectid and not coalesce(u.gmsa, false) = true and not coalesce(u.msa, false) = true return u limit 10`, + Fixture: baseFixture, + // n1: hasspn=true, enabled=true, objectid='S-1-5-21-1' → '-502' does not end with that → not false=true; gmsa/msa absent → coalesce false ≠ true → matches + Assert: AssertNonEmpty(), + }, + // ---- non-empty array literal equality --------------------------------------- + // + // Broken test case + // + // { + // Name: "filter node where a property equals a non-empty integer array literal", + // Cypher: `match (s) where s.prop = [1, 2, 3] return s`, + // Fixture: GraphFixture{ + // Nodes: []NodeFixture{ + // NewNodeWithProperties("a", map[string]any{"prop": []any{float64(1), float64(2), float64(3)}}, NodeKind1), + // NewNodeWithProperties("b", map[string]any{"prop": "other"}, NodeKind2), + // }, + // }, + // Assert: AssertNonEmpty(), + // }, +} + +var stepwiseSemanticCases = []SemanticTestCase{ + { + Name: "return all edges", + Cypher: `match ()-[r]->() return r`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), + }, + { + Name: "filter edges by type() string comparison", + Cypher: `match ()-[r]->() where type(r) = 'EdgeKind1' return r`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), + }, + { + Name: "count edges of a specific kind", + Cypher: `match ()-[r:EdgeKind1]->() return count(r) as the_count`, + Fixture: baseFixture, + Assert: AssertAtLeastInt64(1), + }, + { + Name: "count typed edges reaching a node matching an inline property map", + Cypher: `match ()-[r:EdgeKind1]->({name: "123"}) return count(r) as the_count`, + Fixture: baseFixture, + // No target node has name "123" in our fixture; count returns exactly 0 + Assert: AssertExactInt64(0), + }, + { + Name: "traverse one edge filtering both endpoints by property", + Cypher: `match (s)-[r]->(e) where s.name = '123' and e.name = '321' return s, r, e`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("s", map[string]any{"name": "123"}, NodeKind1), + NewNodeWithProperties("e", map[string]any{"name": "321"}, NodeKind2), + }, + Edges: []EdgeFixture{NewEdge("s", "e", EdgeKind1)}, + }, + Assert: AssertNonEmpty(), + }, + { + Name: "return source node and outgoing edge filtered by source property", + Cypher: `match (n)-[r]->() where n.name = '123' return n, r`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("a", map[string]any{"name": "123"}, NodeKind1), + NewNode("b", NodeKind2), + }, + Edges: []EdgeFixture{NewEdge("a", "b", EdgeKind1)}, + }, + Assert: AssertNonEmpty(), + }, + { + Name: "filter edges by a numeric property value", + Cypher: `match ()-[r]->() where r.value = 42 return r`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), // baseFixture edge n1->n2 has value=42 + }, + { + Name: "filter edges by a boolean property", + Cypher: `match ()-[r]->() where r.bool_prop return r`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), // baseFixture edge n1->n2 has bool_prop=true + }, + { + Name: "one-hop traversal filtering where source and target are not the same node", + Cypher: `match (n1)-[]->(n2) where n1 <> n2 return n2`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), + }, + { + Name: "traverse between typed endpoints with edge kind alternatives", + Cypher: `match (s:NodeKind1)-[r:EdgeKind1|EdgeKind2]->(e:NodeKind2) return s.name, e.name`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), + }, + { + Name: "traverse between multi-kind endpoints using edge kind alternatives", + Cypher: `match (s:NodeKind1:NodeKind2)-[r:EdgeKind1|EdgeKind2]->(e:NodeKind2:NodeKind1) return s.name, e.name`, + Fixture: baseFixture, + // n3 has both kinds and connects to nobody; need a direct n3->n3 edge or different fixture + Assert: AssertNoError(), + }, + // ---- reversed type comparison --------------------------------------- + { + Name: "filter edges with reversed type() equality (literal on left)", + Cypher: `match ()-[r]->() where 'EdgeKind1' = type(r) return r`, + Fixture: baseFixture, + // baseFixture n1->n2 edge is EdgeKind1 + Assert: AssertNonEmpty(), + }, + // ---- incoming edge direction ---------------------------------------- + { + Name: "traverse incoming edges filtering by kind alternatives", + Cypher: `match (s)<-[r:EdgeKind1|EdgeKind2]-(e) return s.name, e.name`, + Fixture: baseFixture, + // n1->n2(EK1): incoming to n2 from n1; n2->n3(EK2): incoming to n3 from n2 + Assert: AssertNonEmpty(), + }, + // ---- diamond (two edges converging on one node) --------------------- + { + Name: "diamond pattern where two edges converge on one node", + Cypher: `match ()-[e0]->(n)<-[e1]-() return e0, n, e1`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNode("a", NodeKind1), + NewNode("b", NodeKind1), + NewNode("m", NodeKind2), + }, + Edges: []EdgeFixture{ + NewEdge("a", "m", EdgeKind1), + NewEdge("b", "m", EdgeKind2), + }, + }, + // both edges converge on m + Assert: AssertNonEmpty(), + }, + // ---- shared-node forward chain -------------------------------------- + { + Name: "shared-node forward chain with two outgoing edges", + Cypher: `match ()-[e0]->(n)-[e1]->() return e0, n, e1`, + Fixture: baseFixture, + // n1->n2(EK1)->n3(EK2): e0=n1->n2, n=n2, e1=n2->n3 + Assert: AssertNonEmpty(), + }, + // ---- edge inequality ------------------------------------------------ + { + Name: "two-hop chain filtering where the two traversed edges are not equal", + Cypher: `match ()-[r]->()-[e]->(n) where r <> e return n`, + Fixture: baseFixture, + // r=n1->n2, e=n2->n3; they are different edges so r <> e holds, n=n3 + Assert: AssertNonEmpty(), + }, + // ---- unrelated cross-products with edges -------------------------------- + { + Name: "cross-product of an unrelated node and an edge", + Cypher: `match (n), ()-[r]->() return n, r`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), + }, + { + Name: "cross-product of two independent edge traversals", + Cypher: `match ()-[r]->(), ()-[e]->() return r, e`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), + }, + // ---- node with both an incoming and an outgoing edge ------------------- + // Pattern: ()<-[e0]-(n)<-[e1]-() + // - ()<-[e0]-(n) means n is the SOURCE of e0 (n has an outgoing edge) + // - (n)<-[e1]-() means n is the TARGET of e1 (n has an incoming edge) + // n must therefore have at least one outgoing edge AND at least one + // incoming edge. The chain a→mid→b gives mid exactly that. + { + Name: "pattern where a middle node has both an outgoing and an incoming edge", + Cypher: `match ()<-[e0]-(n)<-[e1]-() return e0, n, e1`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNode("a", NodeKind1), + NewNode("mid", NodeKind2), + NewNode("b", NodeKind1), + }, + Edges: []EdgeFixture{ + NewEdge("a", "mid", EdgeKind1), // incoming to mid → satisfies (n)<-[e1]-() + NewEdge("mid", "b", EdgeKind2), // outgoing from mid → satisfies ()<-[e0]-(n) + }, + }, + // e0 = mid→b, n = mid, e1 = a→mid + Assert: AssertNonEmpty(), + }, + // ---- negated boolean edge property --------------------------------------- + // The translator emits: NOT ((e0.properties ->> 'property'))::bool + // SQL three-valued logic: NOT NULL = NULL (not TRUE), so an absent key + // does NOT satisfy the predicate — the row is discarded. The property + // must be present and explicitly false for NOT false = true to hold. + { + Name: "traverse edge where the edge property flag is explicitly false", + Cypher: `match (s)-[r]->(e) where s.name = '123' and e:NodeKind1 and not r.property return s, r, e`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("s", map[string]any{"name": "123"}, NodeKind2), + NewNode("e", NodeKind1), + }, + // property=false → NOT false = true → row included + Edges: []EdgeFixture{NewEdgeWithProperties("s", "e", EdgeKind1, map[string]any{"property": false})}, + }, + Assert: AssertNonEmpty(), + }, + // ---- labels() and type() in the RETURN projection ----------------------- + { + Name: "return id, labels, and type from an edge traversal with a numeric id filter", + Cypher: `match (s)-[r:EdgeKind1]->(e) where not (s.system_tags contains 'admin_tier_0') and id(e) = 1 return id(s), labels(s), id(r), type(r)`, + Fixture: baseFixture, + // id(e)=1 is unlikely to match fixture data; query validates translation + Assert: AssertNoError(), + }, + // ---- chained edges with aliased property projections -------------------- + { + Name: "traverse two chained typed edges and return aliased endpoint properties", + Cypher: `match (s)-[:EdgeKind1|EdgeKind2]->(e)-[:EdgeKind1]->() return s.name as s_name, e.name as e_name`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("a", map[string]any{"name": "src"}, NodeKind1), + NewNodeWithProperties("b", map[string]any{"name": "mid"}, NodeKind2), + NewNode("c", NodeKind1), + }, + Edges: []EdgeFixture{ + NewEdge("a", "b", EdgeKind1), + NewEdge("b", "c", EdgeKind1), + }, + }, + Assert: AssertNonEmpty(), + }, + // ---- multi-OR name filter on a typed source node ----------------------- + { + Name: "filter typed source node by four alternative name values with OR", + Cypher: `match (n:NodeKind1)-[r]->() where n.name = '123' or n.name = '321' or n.name = '222' or n.name = '333' return n, r`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("a", map[string]any{"name": "123"}, NodeKind1), + NewNode("b", NodeKind2), + }, + Edges: []EdgeFixture{NewEdge("a", "b", EdgeKind1)}, + }, + Assert: AssertNonEmpty(), + }, + // ---- path binding with array membership and size check ------------------ + { + Name: "bind a one-hop typed path filtered by array membership or empty array size", + Cypher: `match p = (:NodeKind1)-[:EdgeKind1|EdgeKind2]->(c:NodeKind2) where '123' in c.prop2 or '243' in c.prop2 or size(c.prop2) = 0 return p limit 10`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNode("src", NodeKind1), + NewNodeWithProperties("dst", map[string]any{"prop2": []any{}}, NodeKind2), + }, + Edges: []EdgeFixture{NewEdge("src", "dst", EdgeKind1)}, + }, + // dst.prop2=[] → size=0 → matches + Assert: AssertNonEmpty(), + }, +} + +var expansionSemanticCases = []SemanticTestCase{ + { + Name: "unbounded variable-length traversal returning both endpoints", + Cypher: `match (n)-[*..]->(e) return n, e`, + Fixture: baseFixture, + // n1->n2, n2->n3, n1->n3 (via n2) are all reachable + Assert: AssertNonEmpty(), + }, + { + Name: "variable-length traversal bounded to depth 1–2", + Cypher: `match (n)-[*1..2]->(e) return n, e`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), + }, + { + Name: "variable-length traversal bounded to depth 3–5 (expect empty with shallow fixture)", + Cypher: `match (n)-[*3..5]->(e) return n, e`, + Fixture: baseFixture, + // base fixture only has 2-hop paths; depth 3+ should be empty for our data + Assert: AssertNoError(), + }, + { + Name: "bind unbounded path variable reaching a typed endpoint", + Cypher: `match p = (n)-[*..]->(e:NodeKind1) return p`, + Fixture: baseFixture, + // n2->n3 where n3 is NodeKind1; n1->n2->n3 also + Assert: AssertNonEmpty(), + }, + { + Name: "unbounded traversal from a named source to a typed endpoint", + Cypher: `match (n)-[*..]->(e:NodeKind1) where n.name = 'n1' return e`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("n1", map[string]any{"name": "n1"}, NodeKind1), + NewNodeWithProperties("n2", map[string]any{"name": "n2"}, NodeKind2), + NewNodeWithProperties("n3", map[string]any{"name": "n3"}, NodeKind1), + }, + Edges: []EdgeFixture{ + NewEdge("n1", "n2", EdgeKind1), + NewEdge("n2", "n3", EdgeKind2), + }, + }, + Assert: AssertContainsNodeWithProp("name", "n3"), + }, + { + Name: "unbounded traversal filtering every traversed edge by a property", + Cypher: `match (n)-[r*..]->(e:NodeKind1) where n.name = 'n1' and r.prop = 'a' return e`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("n1", map[string]any{"name": "n1"}, NodeKind1), + NewNodeWithProperties("n2", map[string]any{"name": "n2"}, NodeKind2), + NewNodeWithProperties("n3", map[string]any{"name": "n3"}, NodeKind1), + }, + Edges: []EdgeFixture{ + NewEdgeWithProperties("n1", "n2", EdgeKind1, map[string]any{"prop": "a"}), + NewEdgeWithProperties("n2", "n3", EdgeKind2, map[string]any{"prop": "a"}), + }, + }, + Assert: AssertContainsNodeWithProp("name", "n3"), + }, + { + Name: "bind path variable for unbounded traversal between typed endpoints", + Cypher: `match p = (s:NodeKind1)-[*..]->(e:NodeKind2) return p`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), + }, + // ---- incoming with depth range ---------------------------- + { + Name: "bounded incoming variable-length traversal with depth range 2–5", + Cypher: `match (n)<-[*2..5]-(e) return n, e`, + Fixture: baseFixture, + // n1->n2->n3: going backward from n3, n1 is reachable at depth 2 + Assert: AssertNonEmpty(), + }, + // ---- followed by a single step ---------------------------- + { + Name: "unbounded expansion followed by a single fixed step", + Cypher: `match (n)-[*..]->(e:NodeKind1)-[]->(l) where n.name = 'start' return l`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("start", map[string]any{"name": "start"}, NodeKind2), + NewNodeWithProperties("mid", map[string]any{"name": "mid"}, NodeKind1), + NewNodeWithProperties("leaf", map[string]any{"name": "leaf"}, NodeKind2), + }, + Edges: []EdgeFixture{ + NewEdge("start", "mid", EdgeKind1), + NewEdge("mid", "leaf", EdgeKind2), + }, + }, + // start -[EK1]-> mid(NK1) -[EK2]-> leaf: reaches mid, one step to leaf + Assert: AssertContainsNodeWithProp("name", "leaf"), + }, + // ---- step followed by ------------------------------------- + { + Name: "fixed step followed by a bounded variable-length expansion", + Cypher: `match (n)-[]->(e:NodeKind1)-[*2..3]->(l) where n.name = 'start' return l`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("start", map[string]any{"name": "start"}, NodeKind2), + NewNodeWithProperties("mid", map[string]any{"name": "mid"}, NodeKind1), + NewNodeWithProperties("hop1", map[string]any{"name": "hop1"}, NodeKind2), + NewNodeWithProperties("hop2", map[string]any{"name": "hop2"}, NodeKind2), + }, + Edges: []EdgeFixture{ + NewEdge("start", "mid", EdgeKind1), + NewEdge("mid", "hop1", EdgeKind2), + NewEdge("hop1", "hop2", EdgeKind2), + }, + }, + // one step to mid(NK1), then 2 hops to hop2 + Assert: AssertContainsNodeWithProp("name", "hop2"), + }, + // ---- expansion returning the source node (not the destination) ---------- + { + Name: "unbounded expansion to a typed endpoint returning the source node", + Cypher: `match (n)-[*..]->(e:NodeKind1) where n.name = 'n2' return n`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("n2", map[string]any{"name": "n2"}, NodeKind2), + NewNodeWithProperties("n3", map[string]any{"name": "n3"}, NodeKind1), + }, + Edges: []EdgeFixture{NewEdge("n2", "n3", EdgeKind1)}, + }, + // n2 reaches n3(NK1); return n (=n2) + Assert: AssertContainsNodeWithProp("name", "n2"), + }, + // ---- bounded expansion followed by a fixed step ------------------------- + { + Name: "bounded variable-length expansion followed by a single fixed step", + Cypher: `match (n)-[*2..3]->(e:NodeKind1)-[]->(l) where n.name = 'n1' return l`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("n1", map[string]any{"name": "n1"}, NodeKind2), + NewNodeWithProperties("hop", map[string]any{"name": "hop"}, NodeKind2), + NewNodeWithProperties("mid", map[string]any{"name": "mid"}, NodeKind1), + NewNodeWithProperties("leaf", map[string]any{"name": "leaf"}, NodeKind2), + }, + Edges: []EdgeFixture{ + NewEdge("n1", "hop", EdgeKind1), + NewEdge("hop", "mid", EdgeKind2), + NewEdge("mid", "leaf", EdgeKind1), + }, + }, + // 2 hops: n1→hop→mid(NK1); one fixed step: mid→leaf → return leaf + Assert: AssertContainsNodeWithProp("name", "leaf"), + }, + // ---- two variable-length segments with a typed fixed step between -------- + { + Name: "two unbounded expansions joined through a typed fixed step", + Cypher: `match (n)-[*..]->(e)-[:EdgeKind1|EdgeKind2]->()-[*..]->(l) where n.name = 'n1' and e.name = 'n2' return l`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("n1", map[string]any{"name": "n1"}, NodeKind1), + NewNodeWithProperties("n2", map[string]any{"name": "n2"}, NodeKind2), + NewNode("bridge", NodeKind2), + NewNodeWithProperties("leaf", map[string]any{"name": "leaf"}, NodeKind1), + }, + Edges: []EdgeFixture{ + NewEdge("n1", "n2", EdgeKind1), + NewEdge("n2", "bridge", EdgeKind2), + NewEdge("bridge", "leaf", EdgeKind1), + }, + }, + Assert: AssertContainsNodeWithProp("name", "leaf"), + }, + // ---- split() function in a WHERE predicate on an expansion endpoint ----- + { + Name: "bind expansion path filtered by a split() membership check on the endpoint", + Cypher: `match p = (:NodeKind1)-[:EdgeKind1*1..]->(n:NodeKind2) where 'admin_tier_0' in split(n.system_tags, ' ') return p limit 1000`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNode("src", NodeKind1), + NewNodeWithProperties("dst", map[string]any{"system_tags": "admin_tier_0 extra_tag"}, NodeKind2), + }, + Edges: []EdgeFixture{NewEdge("src", "dst", EdgeKind1)}, + }, + // split('admin_tier_0 extra_tag',' ')=['admin_tier_0','extra_tag']; 'admin_tier_0' in list + Assert: AssertNonEmpty(), + }, + // ---- node inequality constraint inside an expansion path ---------------- + { + Name: "bind expansion path where the source and destination must be distinct nodes", + Cypher: `match p = (s:NodeKind1)-[*..]->(e:NodeKind2) where s <> e return p`, + Fixture: baseFixture, + // n1(NK1)-[EK1]->n2(NK2); s=n1, e=n2; n1 ≠ n2 → matches + Assert: AssertNonEmpty(), + }, + // ---- both-endpoint ends-with filters in an expansion path --------------- + { + Name: "bind expansion path filtering both endpoints using ends-with on objectid", + Cypher: `match p = (g:NodeKind1)-[:EdgeKind1|EdgeKind2*]->(target:NodeKind1) where g.objectid ends with '-src' and target.objectid ends with '-tgt' return p`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("src", map[string]any{"objectid": "S-1-src"}, NodeKind1), + NewNodeWithProperties("tgt", map[string]any{"objectid": "S-1-tgt"}, NodeKind1), + }, + Edges: []EdgeFixture{NewEdge("src", "tgt", EdgeKind1)}, + }, + Assert: AssertNonEmpty(), + }, + // ---- incoming unbounded expansion returning a bound path ---------------- + { + Name: "bind an incoming unbounded expansion path to a typed source", + Cypher: `match p = (:NodeKind1)<-[:EdgeKind1|EdgeKind2*..]-() return p limit 10`, + Fixture: baseFixture, + // n3(NK1) is reached by n2-[EK2]->n3; incoming expansion from NK1 finds this path + Assert: AssertNonEmpty(), + }, + // ---- expansion with a regex filter on the endpoint ---------------------- + { + Name: "bind expansion path filtered by a regular expression on the endpoint name", + Cypher: `match p = (n:NodeKind1)-[:EdgeKind1|EdgeKind2*1..2]->(r:NodeKind2) where r.name =~ '1.*' return p limit 10`, + Fixture: baseFixture, + // n1(NK1)-[EK1]->n2(NK2,name='1234'); '1234' =~ '1.*' → matches + Assert: AssertNonEmpty(), + }, + // ---- incoming expansion with a disjunction kind filter on the source ---- + { + Name: "bind incoming expansion path where source matches a kind disjunction", + Cypher: `match p = (t:NodeKind2)<-[:EdgeKind1*1..]-(a) where (a:NodeKind1 or a:NodeKind2) and t.objectid ends with '-2' return p limit 1000`, + Fixture: baseFixture, + // t=n2(NK2,objectid='S-1-5-21-2'); n1(NK1)-[EK1]->n2; a=n1 is NK1 → matches + Assert: AssertNonEmpty(), + }, +} + +var aggregationSemanticCases = []SemanticTestCase{ + { + Name: "count all nodes", + Cypher: `MATCH (n) RETURN count(n)`, + Fixture: baseFixture, + Assert: AssertAtLeastInt64(3), // at least the 3 base fixture nodes + }, + { + Name: "return a constant string literal", + Cypher: `RETURN 'hello world'`, + Fixture: GraphFixture{}, + Assert: AssertNonEmpty(), + }, + { + Name: "return a constant arithmetic expression", + Cypher: `RETURN 2 + 3`, + Fixture: GraphFixture{}, + Assert: AssertNonEmpty(), + }, + { + Name: "collect all node name properties into a list", + Cypher: `MATCH (n) RETURN collect(n.name)`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), + }, + { + Name: "return the size of a collected list of node properties", + Cypher: `MATCH (n) RETURN size(collect(n.name))`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), + }, + { + Name: "filter on an aggregate result using WITH and WHERE", + Cypher: `MATCH (n) WITH count(n) as cnt WHERE cnt > 1 RETURN cnt`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), // db has at least 3 nodes, so cnt > 1 is satisfied + }, + { + Name: "group by node and filter on per-node count", + Cypher: `MATCH (n) WITH n, count(n) as node_count WHERE node_count > 1 RETURN n, node_count`, + // Each node grouped by itself gives count=1; with >1 filter result is empty + Fixture: baseFixture, + Assert: AssertNoError(), + }, + // ---- sum / avg / min / max ------------------------------------------ + { + Name: "sum a numeric node property across all nodes", + Cypher: `MATCH (n) RETURN sum(n.value)`, + Fixture: baseFixture, + // n1.value=1, n2.value=2, n3.value=3 → sum ≥ 6 + Assert: AssertNonEmpty(), + }, + { + Name: "average a numeric node property across all nodes", + Cypher: `MATCH (n) RETURN avg(n.value)`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), + }, + { + Name: "minimum of a numeric node property across all nodes", + Cypher: `MATCH (n) RETURN min(n.value)`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), + }, + { + Name: "maximum of a numeric node property across all nodes", + Cypher: `MATCH (n) RETURN max(n.value)`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), + }, + // ---- grouped -------------------------------------------- + { + Name: "group nodes by a property and count each group", + Cypher: `MATCH (n) RETURN n.domain, count(n)`, + Fixture: baseFixture, + // groups: "test.local"→n1, "other.local"→n2, null→n3 + Assert: AssertNonEmpty(), + }, + // ---- multi-aggregate in one projection ------------------------------ + { + Name: "compute multiple aggregates in a single projection", + Cypher: `MATCH (n) RETURN count(n), sum(n.value)`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), + }, + // ---- size() in WHERE ------------------------------------------------ + { + Name: "filter nodes using size() on an array property in WHERE", + Cypher: `MATCH (n) WHERE size(n.array_value) > 0 RETURN n`, + Fixture: baseFixture, + // n1.array_value=[1,2] has size 2 > 0 + Assert: AssertNonEmpty(), + }, + // ---- count-then-match (aggregate feeds a subsequent MATCH) ---------- + { + Name: "feed an aggregate result from a WITH stage into a subsequent MATCH", + Cypher: `MATCH (n) WITH count(n) as lim MATCH (o) RETURN o`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), + }, + // ---- grouped sum and avg ------------------------------------------------ + { + Name: "group nodes by a property and return the sum of another property per group", + Cypher: `MATCH (n) RETURN n.department, sum(n.salary)`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("a", map[string]any{"department": "eng", "salary": 100}, NodeKind1), + NewNodeWithProperties("b", map[string]any{"department": "eng", "salary": 200}, NodeKind1), + NewNodeWithProperties("c", map[string]any{"department": "hr", "salary": 150}, NodeKind2), + }, + }, + Assert: AssertNonEmpty(), + }, + { + Name: "group nodes by a property and return the average of another property per group", + Cypher: `MATCH (n) RETURN n.department, avg(n.age)`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("a", map[string]any{"department": "eng", "age": 30}, NodeKind1), + NewNodeWithProperties("b", map[string]any{"department": "eng", "age": 40}, NodeKind1), + }, + }, + Assert: AssertNonEmpty(), + }, + // ---- all aggregates in one projection ----------------------------------- + { + Name: "compute count sum avg min and max of a property in a single projection", + Cypher: `MATCH (n) RETURN count(n), sum(n.age), avg(n.age), min(n.age), max(n.age)`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("a", map[string]any{"age": 25}, NodeKind1), + NewNodeWithProperties("b", map[string]any{"age": 35}, NodeKind2), + }, + }, + Assert: AssertNonEmpty(), + }, + // ---- grouped collect and grouped collect+count -------------------------- + { + Name: "group nodes by a property and collect names per group", + Cypher: `MATCH (n) RETURN n.department, collect(n.name)`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("a", map[string]any{"department": "eng", "name": "alice"}, NodeKind1), + NewNodeWithProperties("b", map[string]any{"department": "eng", "name": "bob"}, NodeKind1), + }, + }, + Assert: AssertNonEmpty(), + }, + { + Name: "group nodes by a property and return both a collected list and a count", + Cypher: `MATCH (n) RETURN n.department, collect(n.name), count(n)`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("a", map[string]any{"department": "ops", "name": "carol"}, NodeKind1), + NewNodeWithProperties("b", map[string]any{"department": "ops", "name": "dave"}, NodeKind2), + }, + }, + Assert: AssertNonEmpty(), + }, + // ---- size() in the RETURN projection ------------------------------------ + { + Name: "return the size of an array property in the projection", + Cypher: `MATCH (n) RETURN size(n.tags)`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("a", map[string]any{"tags": []any{"admin", "user"}}, NodeKind1), + }, + }, + Assert: AssertNonEmpty(), + }, + // ---- arithmetic on aggregate results ------------------------------------ + { + Name: "compute a ratio by dividing two aggregate results in a WITH stage", + Cypher: `MATCH (n) WITH sum(n.age) as total_age, count(n) as total_count RETURN total_age / total_count as avg_age`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("a", map[string]any{"age": 30}, NodeKind1), + NewNodeWithProperties("b", map[string]any{"age": 50}, NodeKind2), + }, + }, + Assert: AssertNonEmpty(), + }, + // ---- collect in WITH then size filter ----------------------------------- + { + Name: "collect node properties in a WITH stage then filter by the collected size", + Cypher: `MATCH (n) WITH n, collect(n.prop) as props WHERE size(props) > 1 RETURN n, props`, + Fixture: baseFixture, + // Each node is its own group so collect returns [value] with size=1; >1 is empty + Assert: AssertNoError(), + }, +} + +// --------------------------------------------------------------------------- +// multipart.sql +// --------------------------------------------------------------------------- + +var multipartSemanticCases = []SemanticTestCase{ + { + Name: "bind a literal as a WITH variable and filter typed nodes by it", + Cypher: `with '1' as target match (n:NodeKind1) where n.value = target return n`, + Fixture: baseFixture, + // n1 has value=1 but stored as int; string comparison depends on translation + Assert: AssertNoError(), + }, + { + Name: "carry a node through WITH and re-match it by ID", + Cypher: `match (n:NodeKind1) where n.value = 1 with n match (b) where id(b) = id(n) return b`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("n", map[string]any{"value": 1}, NodeKind1), + }, + }, + Assert: AssertNonEmpty(), + }, + { + Name: "exclude second-stage results using a collected list from the first stage", + Cypher: `match (g1:NodeKind1) where g1.name starts with 'test' with collect(g1.domain) as excludes match (d:NodeKind2) where d.name starts with 'other' and not d.name in excludes return d`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("g1", map[string]any{"name": "testnode", "domain": "test.local"}, NodeKind1), + NewNodeWithProperties("d1", map[string]any{"name": "othernode"}, NodeKind2), + NewNodeWithProperties("d2", map[string]any{"name": "othertest"}, NodeKind2), + }, + }, + Assert: AssertNonEmpty(), + }, + // ---- triple-part chain: match → with → match → with → match --------- + { + Name: "three-stage pipeline carrying nodes through successive WITH clauses", + Cypher: `match (n:NodeKind1) where n.value = 1 with n match (f) where f.name = 'me' with f match (b) where id(b) = id(f) return b`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("n", map[string]any{"value": 1}, NodeKind1), + NewNodeWithProperties("f", map[string]any{"name": "me"}, NodeKind2), + }, + }, + // b = f (the node whose id matches f's id) + Assert: AssertContainsNodeWithProp("name", "me"), + }, + // ---- bind a variable, then find all paths leading to it ------------- + { + Name: "bind any node then find all one-hop paths that reach it", + Cypher: `match (e) match p = ()-[]->(e) return p limit 1`, + Fixture: baseFixture, + // any one-hop path whose end is any node; n1->n2 and n2->n3 qualify + Assert: AssertNonEmpty(), + }, + // ---- re-match a WITH-carried node under its own label --------------- + { + Name: "carry a node through WITH and re-match it under its original kind label", + Cypher: `match (u:NodeKind1)-[:EdgeKind1]->(g:NodeKind2) with g match (g)<-[:EdgeKind1]-(u:NodeKind1) return g`, + Fixture: baseFixture, + // n1(NK1)-[EK1]->n2(NK2); carried g=n2; n2<-[EK1]-n1(NK1) still holds + Assert: AssertNonEmpty(), + }, + // ---- numeric literal WITH used in subsequent arithmetic ----------------- + { + Name: "bind a numeric literal as a WITH variable and use it in arithmetic in the next MATCH", + Cypher: `with 365 as max_days match (n:NodeKind1) where n.pwdlastset < (datetime().epochseconds - (max_days * 86400)) and not n.pwdlastset IN [-1.0, 0.0] return n limit 100`, + Fixture: baseFixture, + // n1.pwdlastset=-2; current epochseconds ≫ 365*86400; -2 < big_number is true; -2 ∉ {-1,0} + Assert: AssertNonEmpty(), + }, + // ---- multi-match then variable-length expansion path -------------------- + { + Name: "match a typed node then bind its variable-length expansion to a path", + Cypher: `match (n:NodeKind1) where n.objectid = 'S-1-5-21-1' match p = (n)-[:EdgeKind1|EdgeKind2*1..]->(c:NodeKind2) return p`, + Fixture: baseFixture, + // n1.objectid='S-1-5-21-1'; n1-[EK1]->n2(NK2) → path exists + Assert: AssertNonEmpty(), + }, + // ---- WITH count as a filter in the same stage --------------------------- + { + Name: "filter a carried node using a per-group count in a WITH stage", + Cypher: `match (n:NodeKind1)<-[:EdgeKind1]-(:NodeKind2) where n.objectid ends with '-516' with n, count(n) as dc_count where dc_count = 1 return n`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("dst", map[string]any{"objectid": "S-1-5-21-516"}, NodeKind1), + NewNode("src", NodeKind2), + }, + Edges: []EdgeFixture{NewEdge("src", "dst", EdgeKind1)}, + }, + // exactly one NK2-[EK1]->dst edge; count=1 → matches + Assert: AssertNonEmpty(), + }, + // ---- two path variables sharing a node ---------------------------------- + { + Name: "match two paths that share a common middle node and return both", + Cypher: `match p = (a)-[]->() match q = ()-[]->(a) return p, q`, + Fixture: baseFixture, + // baseFixture: n1->n2->n3; a=n2 satisfies both (n2-[]->n3) and (n1-[]->(n2)) + Assert: AssertNonEmpty(), + }, + // ---- regex filter in a multipart pipeline ------------------------------- + { + Name: "filter typed nodes by a regular expression and carry collected results to the next stage", + Cypher: `match (cg:NodeKind1) where cg.name =~ ".*TT" with collect(cg.name) as names return names`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("a", map[string]any{"name": "SCOTT"}, NodeKind1), + NewNodeWithProperties("b", map[string]any{"name": "admin"}, NodeKind1), + }, + }, + // 'SCOTT' =~ '.*TT' → true; 'admin' → false; names=['SCOTT'] + Assert: AssertNonEmpty(), + }, + // ---- expansion with distinct count and ORDER BY on the aggregate -------- + { + Name: "expand from a typed node, count reachable typed targets, and order by that count", + Cypher: `match (n:NodeKind1) where n.hasspn = true match (n)-[:EdgeKind1|EdgeKind2*1..]->(c:NodeKind2) with distinct n, count(c) as adminCount return n order by adminCount desc limit 100`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("src", map[string]any{"hasspn": true}, NodeKind1), + NewNode("c1", NodeKind2), + NewNode("c2", NodeKind2), + }, + Edges: []EdgeFixture{ + NewEdge("src", "c1", EdgeKind1), + NewEdge("src", "c2", EdgeKind2), + }, + }, + Assert: AssertNonEmpty(), + }, +} + +// --------------------------------------------------------------------------- +// pattern_binding.sql +// --------------------------------------------------------------------------- + +var patternBindingSemanticCases = []SemanticTestCase{ + { + Name: "bind a single typed node to a path variable", + Cypher: `match p = (:NodeKind1) return p`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), + }, + { + Name: "bind a one-hop traversal to a path variable", + Cypher: `match p = ()-[]->() return p`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), + }, + { + Name: "bind an unbounded variable-length path to a path variable", + Cypher: `match p = ()-[*..]->(e) return p limit 1`, + Fixture: baseFixture, + Assert: AssertNonEmpty(), + }, + { + Name: "bind a two-hop path and return the terminal node", + Cypher: `match p = ()-[r1]->()-[r2]->(e) return e`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNode("a", NodeKind1), + NewNode("b", NodeKind2), + NewNode("c", NodeKind1), + }, + Edges: []EdgeFixture{ + NewEdge("a", "b", EdgeKind1), + NewEdge("b", "c", EdgeKind2), + }, + }, + Assert: AssertNonEmpty(), + }, + { + Name: "bind a converging diamond path with endpoint property filters", + Cypher: `match p = (a)-[]->()<-[]-(f) where a.name = 'value' and f.is_target return p`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("a", map[string]any{"name": "value"}, NodeKind1), + NewNodeWithProperties("mid", map[string]any{}, NodeKind2), + NewNodeWithProperties("f", map[string]any{"is_target": true}, NodeKind1), + }, + Edges: []EdgeFixture{ + NewEdge("a", "mid", EdgeKind1), + NewEdge("f", "mid", EdgeKind1), + }, + }, + Assert: AssertNonEmpty(), + }, + // ---- node-only path with property filter ---------------------------- + { + Name: "bind a node-only path with a contains property filter", + Cypher: `match p = (n:NodeKind1) where n.name contains 'test' return p`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("a", map[string]any{"name": "testuser"}, NodeKind1), + NewNodeWithProperties("b", map[string]any{"name": "admin"}, NodeKind1), + }, + }, + Assert: AssertNonEmpty(), + }, + // ---- undirected edge in path ---------------------------------------- + { + Name: "bind a path with an undirected edge between typed nodes", + Cypher: `match p = (n:NodeKind1)-[r]-(m:NodeKind1) return p`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNode("a", NodeKind1), + NewNode("b", NodeKind1), + }, + Edges: []EdgeFixture{ + NewEdge("a", "b", EdgeKind1), + }, + }, + // undirected: a-[r]-b is found in both (a,b) and (b,a) orientations + Assert: AssertNonEmpty(), + }, + // ---- 3-hop path with named-edge property filters -------------------- + { + Name: "three-hop traversal filtering named edges by their property", + Cypher: `match ()-[r1]->()-[r2]->()-[]->() where r1.label = 'first' and r2.label = 'second' return r1`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNode("a", NodeKind1), + NewNode("b", NodeKind2), + NewNode("c", NodeKind1), + NewNode("d", NodeKind2), + }, + Edges: []EdgeFixture{ + NewEdgeWithProperties("a", "b", EdgeKind1, map[string]any{"label": "first"}), + NewEdgeWithProperties("b", "c", EdgeKind2, map[string]any{"label": "second"}), + NewEdge("c", "d", EdgeKind1), + }, + }, + Assert: AssertNonEmpty(), + }, + // ---- edge boolean property as a path filter ------------------------------ + { + Name: "bind a one-hop path between typed nodes filtered by a boolean edge property", + Cypher: `match p = (:NodeKind1)-[r]->(:NodeKind1) where r.isacl return p limit 100`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNode("a", NodeKind1), + NewNode("b", NodeKind1), + }, + Edges: []EdgeFixture{ + NewEdgeWithProperties("a", "b", EdgeKind1, map[string]any{"isacl": true}), + }, + }, + Assert: AssertNonEmpty(), + }, + // ---- fixed step plus variable-length expansion with named first edge ---- + { + Name: "return a named first edge and the full path including its subsequent expansion", + Cypher: `match p = ()-[e:EdgeKind1]->()-[:EdgeKind1*1..]->() return e, p`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNode("a", NodeKind1), + NewNode("b", NodeKind2), + NewNode("c", NodeKind1), + }, + Edges: []EdgeFixture{ + NewEdge("a", "b", EdgeKind1), + NewEdge("b", "c", EdgeKind1), + }, + }, + // e=a→b(EK1); then b→c(EK1*1..); full path a→b→c + Assert: AssertNonEmpty(), + }, + // ---- toUpper not-contains in a path filter ------------------------------ + { + Name: "bind a typed one-hop path where the target property does not contain a toUpper result", + Cypher: `match p = (m:NodeKind1)-[:EdgeKind1]->(c:NodeKind2) where m.objectid ends with '-1' and not toUpper(c.operatingsystem) contains 'SERVER' return p limit 1000`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("m", map[string]any{"objectid": "S-1-5-21-1"}, NodeKind1), + NewNodeWithProperties("c", map[string]any{"operatingsystem": "workstation"}, NodeKind2), + }, + Edges: []EdgeFixture{NewEdge("m", "c", EdgeKind1)}, + }, + // toUpper('workstation')='WORKSTATION'; 'WORKSTATION' not contains 'SERVER' → true + Assert: AssertNonEmpty(), + }, + // ---- array membership on a middle node in a chained path ---------------- + { + Name: "bind a two-hop typed path filtered by array membership on the intermediate node", + Cypher: `match p = (:NodeKind1)-[:EdgeKind1|EdgeKind2]->(e:NodeKind2)-[:EdgeKind2]->(:NodeKind1) where 'a' in e.values or 'b' in e.values or size(e.values) = 0 return p`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNode("src", NodeKind1), + NewNodeWithProperties("mid", map[string]any{"values": []any{"a", "c"}}, NodeKind2), + NewNode("dst", NodeKind1), + }, + Edges: []EdgeFixture{ + NewEdge("src", "mid", EdgeKind1), + NewEdge("mid", "dst", EdgeKind2), + }, + }, + // 'a' in ['a','c'] → true → matches + Assert: AssertNonEmpty(), + }, + // ---- fixed step then variable expansion with coalesce in path filter ---- + { + Name: "bind a path with one fixed hop then variable expansion filtered by coalesce contains", + Cypher: `match p = (:NodeKind1)-[:EdgeKind1]->(:NodeKind2)-[:EdgeKind2*1..]->(t:NodeKind2) where coalesce(t.system_tags, '') contains 'admin_tier_0' return p limit 1000`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNode("a", NodeKind1), + NewNode("b", NodeKind2), + NewNodeWithProperties("t", map[string]any{"system_tags": "admin_tier_0"}, NodeKind2), + }, + Edges: []EdgeFixture{ + NewEdge("a", "b", EdgeKind1), + NewEdge("b", "t", EdgeKind2), + }, + }, + Assert: AssertNonEmpty(), + }, + // ---- multi-match with WHERE then variable-length expansion path --------- + { + Name: "filter a typed node with WHERE then bind its variable-length expansion path", + Cypher: `match (u:NodeKind1) where u.samaccountname in ['foo', 'bar'] match p = (u)-[:EdgeKind1|EdgeKind2*1..3]->(t) where coalesce(t.system_tags, '') contains 'admin_tier_0' return p limit 1000`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("u", map[string]any{"samaccountname": "foo"}, NodeKind1), + NewNodeWithProperties("t", map[string]any{"system_tags": "admin_tier_0"}, NodeKind2), + }, + Edges: []EdgeFixture{NewEdge("u", "t", EdgeKind1)}, + }, + Assert: AssertNonEmpty(), + }, + // ---- three-match pattern: anchor + second + path ------------------------ + { + Name: "three consecutive MATCHes that anchor two nodes and bind the connecting path", + Cypher: `match (x:NodeKind1) where x.name = 'foo' match (y:NodeKind2) where y.name = 'bar' match p=(x)-[:EdgeKind1]->(y) return p`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("x", map[string]any{"name": "foo"}, NodeKind1), + NewNodeWithProperties("y", map[string]any{"name": "bar"}, NodeKind2), + }, + Edges: []EdgeFixture{NewEdge("x", "y", EdgeKind1)}, + }, + Assert: AssertNonEmpty(), + }, + // ---- inline property map in MATCH then path binding --------------------- + { + Name: "match a node with an inline property map then bind its outgoing path to a second inline-map node", + Cypher: `match (x:NodeKind1{name:'foo'}) match p=(x)-[:EdgeKind1]->(y:NodeKind2{name:'bar'}) return p`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("x", map[string]any{"name": "foo"}, NodeKind1), + NewNodeWithProperties("y", map[string]any{"name": "bar"}, NodeKind2), + }, + Edges: []EdgeFixture{NewEdge("x", "y", EdgeKind1)}, + }, + Assert: AssertNonEmpty(), + }, +} + +// --------------------------------------------------------------------------- +// delete.sql +// --------------------------------------------------------------------------- + +var deleteSemanticCases = []SemanticTestCase{ + { + Name: "detach-delete a typed node and its incident edges", + Cypher: `match (s:NodeKind1) detach delete s`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("victim", map[string]any{"name": "victim"}, NodeKind1), + }, + }, + Assert: AssertNoError(), + PostAssert: func(t *testing.T, tx graph.Transaction) { + t.Helper() + result := tx.Query(`match (n:NodeKind1) where n.name = 'victim' return n`, nil) + defer result.Close() + for result.Next() { + } + if err := result.Error(); err != nil { + t.Errorf("post-delete query error: %v", err) + } + }, + }, + { + Name: "delete a specific typed edge", + Cypher: `match ()-[r:EdgeKind1]->() delete r`, + Fixture: baseFixture, + Assert: AssertNoError(), + }, + // ---- multi-hop: traverse two hops then delete the second edge ------- + { + Name: "traverse two hops then delete the typed edge at the second hop", + Cypher: `match ()-[]->()-[r:EdgeKind2]->() delete r`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNode("a", NodeKind1), + NewNode("b", NodeKind2), + NewNode("c", NodeKind1), + }, + Edges: []EdgeFixture{ + NewEdge("a", "b", EdgeKind1), + NewEdge("b", "c", EdgeKind2), + }, + }, + Assert: AssertNoError(), + }, +} + +// --------------------------------------------------------------------------- +// update.sql +// --------------------------------------------------------------------------- + +var updateSemanticCases = []SemanticTestCase{ + { + Name: "set a string property on a filtered node and return the updated node", + Cypher: `match (n) where n.name = 'n3' set n.name = 'RENAMED' return n`, + Fixture: baseFixture, + Assert: AssertContainsNodeWithProp("name", "RENAMED"), + }, + { + Name: "chain multiple SET clauses to update several properties on a node", + Cypher: `match (n) set n.other = 1 set n.prop = '1' return n`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("a", map[string]any{"name": "updateme"}, NodeKind1), + }, + }, + Assert: AssertNonEmpty(), + }, + { + Name: "add multiple kind labels to a node", + Cypher: `match (n) set n:NodeKind1:NodeKind2 return n`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNode("a", NodeKind1), + }, + }, + Assert: AssertNonEmpty(), + }, + { + Name: "remove multiple kind labels from a node", + Cypher: `match (n) remove n:NodeKind1:NodeKind2 return n`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNode("a", NodeKind1, NodeKind2), + }, + }, + Assert: AssertNonEmpty(), + }, + { + Name: "set a boolean property on a filtered node (no RETURN)", + Cypher: `match (n) where n.name = '1234' set n.is_target = true`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("a", map[string]any{"name": "1234"}, NodeKind1), + }, + }, + Assert: AssertNoError(), + }, + { + Name: "set a property on a traversed edge from a typed source node", + Cypher: `match (n)-[r:EdgeKind1]->() where n:NodeKind1 set r.visited = true return r`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNode("a", NodeKind1), + NewNode("b", NodeKind2), + }, + Edges: []EdgeFixture{NewEdge("a", "b", EdgeKind1)}, + }, + Assert: AssertNonEmpty(), + }, + // ---- set kind + remove kind (combined in one statement) ------------- + { + Name: "add one kind label and remove another in the same statement", + Cypher: `match (n) set n:NodeKind1 remove n:NodeKind2 return n`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNode("a", NodeKind1, NodeKind2), + }, + }, + Assert: AssertNonEmpty(), + }, + // ---- set kind + set property (combined) ---------------------------- + { + Name: "add a kind label and set a property in the same statement", + Cypher: `match (n) set n:NodeKind1 set n.flag = '1' return n`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNode("a", NodeKind2), + }, + }, + Assert: AssertNonEmpty(), + }, + // ---- remove kind + remove property (combined) ---------------------- + { + Name: "remove a kind label and a property in the same statement", + Cypher: `match (n) remove n:NodeKind1 remove n.prop return n`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("a", map[string]any{"prop": "val"}, NodeKind1), + }, + }, + Assert: AssertNonEmpty(), + }, + // ---- remove single property ----------------------------------------- + { + Name: "remove a single node property (no RETURN)", + Cypher: `match (s) remove s.name`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("a", map[string]any{"name": "drop-me"}, NodeKind1), + }, + }, + Assert: AssertNoError(), + }, + // ---- edge-only update (no RETURN) ----------------------------------- + { + Name: "set a property on an edge leading to a typed target node (no RETURN)", + Cypher: `match ()-[r]->(:NodeKind1) set r.is_special_outbound = true`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNode("src", NodeKind2), + NewNode("dst", NodeKind1), + }, + Edges: []EdgeFixture{NewEdge("src", "dst", EdgeKind1)}, + }, + Assert: AssertNoError(), + }, + // ---- node + edge updated together ----------------------------------- + { + Name: "update a source node property and an edge property together", + Cypher: `match (a)-[r]->(:NodeKind1) set a.name = '123', r.is_special_outbound = true`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNode("src", NodeKind2), + NewNode("dst", NodeKind1), + }, + Edges: []EdgeFixture{NewEdge("src", "dst", EdgeKind1)}, + }, + Assert: AssertNoError(), + }, +} + +// --------------------------------------------------------------------------- +// quantifiers.sql +// --------------------------------------------------------------------------- + +// quantifierFixture provides nodes whose array properties exercise ANY, ALL, +// NONE, and SINGLE quantifier semantics against the string predicate +// "type CONTAINS 'DES'". +// +// qAny (NK1): supportedencryptiontypes=["DES-CBC-CRC","AES-128"], usedeskeyonly=false +// → ANY matches DES-CBC-CRC (count≥1) → true +// qAll (NK1): supportedencryptiontypes=["DES-CBC-CRC","DES-CBC-MD5"], usedeskeyonly=false +// → ALL both match (count=2=len=2) → true +// qNone (NK1): supportedencryptiontypes=["AES-128","RC4-HMAC"], usedeskeyonly=false +// → NONE no match (count=0) → true +// qSingle(NK1):supportedencryptiontypes=["DES-CBC-CRC","AES-128"], usedeskeyonly=false +// → SINGLE exactly one match (count=1) → true +// +// All four nodes have usedeskeyonly=false so the OR short-circuit is never +// taken and the quantifier itself is the deciding factor. +var quantifierFixture = GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("qAny", map[string]any{ + "usedeskeyonly": false, + "supportedencryptiontypes": []any{"DES-CBC-CRC", "AES-128"}, + }, NodeKind1), + NewNodeWithProperties("qAll", map[string]any{ + "usedeskeyonly": false, + "supportedencryptiontypes": []any{"DES-CBC-CRC", "DES-CBC-MD5"}, + }, NodeKind1), + NewNodeWithProperties("qNone", map[string]any{ + "usedeskeyonly": false, + "supportedencryptiontypes": []any{"AES-128", "RC4-HMAC"}, + }, NodeKind1), + NewNodeWithProperties("qSingle", map[string]any{ + "usedeskeyonly": false, + "supportedencryptiontypes": []any{"DES-CBC-CRC", "AES-128"}, + }, NodeKind1), + }, +} + +var quantifiersSemanticCases = []SemanticTestCase{ + // ---- ANY ------------------------------------------------------------ + { + Name: "ANY quantifier over an array property with a contains predicate", + Cypher: `MATCH (n:NodeKind1) WHERE n.usedeskeyonly OR ANY(type IN n.supportedencryptiontypes WHERE type CONTAINS 'DES') RETURN n LIMIT 100`, + Fixture: quantifierFixture, + // qAny: "DES-CBC-CRC" contains 'DES' → count≥1 → ANY=true; false OR true → matches + Assert: AssertNonEmpty(), + }, + // ---- ALL ------------------------------------------------------------ + { + Name: "ALL quantifier over an array property with a contains predicate", + Cypher: `MATCH (n:NodeKind1) WHERE n.usedeskeyonly OR ALL(type IN n.supportedencryptiontypes WHERE type CONTAINS 'DES') RETURN n LIMIT 100`, + Fixture: quantifierFixture, + // qAll: both entries contain 'DES' → count=len=2 → ALL=true; false OR true → matches + Assert: AssertNonEmpty(), + }, + // ---- NONE ----------------------------------------------------------- + { + Name: "NONE quantifier over an array property with a contains predicate", + Cypher: `MATCH (n:NodeKind1) WHERE n.usedeskeyonly OR NONE(type IN n.supportedencryptiontypes WHERE type CONTAINS 'DES') RETURN n LIMIT 100`, + Fixture: quantifierFixture, + // qNone: neither "AES-128" nor "RC4-HMAC" contains 'DES' → count=0 → NONE=true + Assert: AssertNonEmpty(), + }, + // ---- SINGLE --------------------------------------------------------- + { + Name: "SINGLE quantifier over an array property with a contains predicate", + Cypher: `MATCH (n:NodeKind1) WHERE n.usedeskeyonly OR SINGLE(type IN n.supportedencryptiontypes WHERE type CONTAINS 'DES') RETURN n LIMIT 100`, + Fixture: quantifierFixture, + // qSingle: exactly one entry ("DES-CBC-CRC") matches → count=1 → SINGLE=true + Assert: AssertNonEmpty(), + }, + // ---- NONE inside a WITH-piped stage --------------------------------- + // The second MATCH is a required (non-optional) match. The translator + // renders it as an inner join in CTE s3. If s3 is empty (no matching + // n→g edges), GROUP BY returns no groups at all and m drops out of the + // pipeline — "NONE of empty is vacuously true" does not apply. + // + // Fixture: m ← NodeKind1 with unconstraineddelegation=true + // n ← NodeKind1 with a *different* objectid ("other-id") + // g ← NodeKind2 with objectid ending '-516' + // n -[EdgeKind1]→ g + // + // Second MATCH finds (n,g) → matchingNs=[n]. + // NONE(n IN [n] WHERE n.objectid = "test-m"): + // n.objectid="other-id" ≠ "test-m" → count=0 → NONE=true → m returned. + { + Name: "NONE quantifier over a collected list in a WITH-piped stage", + Cypher: `MATCH (m:NodeKind1) WHERE m.unconstraineddelegation = true WITH m MATCH (n:NodeKind1)-[:EdgeKind1]->(g:NodeKind2) WHERE g.objectid ENDS WITH '-516' WITH m, COLLECT(n) AS matchingNs WHERE NONE(n IN matchingNs WHERE n.objectid = m.objectid) RETURN m`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("m", map[string]any{"unconstraineddelegation": true, "objectid": "test-m"}, NodeKind1), + NewNodeWithProperties("n", map[string]any{"objectid": "other-id"}, NodeKind1), + NewNodeWithProperties("g", map[string]any{"objectid": "S-1-5-21-516"}, NodeKind2), + }, + Edges: []EdgeFixture{ + NewEdge("n", "g", EdgeKind1), + }, + }, + Assert: AssertNonEmpty(), + }, + // ---- ALL inside a WITH-piped stage ---------------------------------- + // Same structural requirement as NONE: the second MATCH must produce + // rows so GROUP BY has groups to evaluate. + // + // Fixture: m ← NodeKind1 with unconstraineddelegation=true + // n ← NodeKind1 with the *same* objectid as m ("test-m") + // g ← NodeKind2 with objectid ending '-516' + // n -[EdgeKind1]→ g + // + // Second MATCH finds (n,g) → matchingNs=[n]. + // ALL(n IN [n] WHERE n.objectid = "test-m"): + // n.objectid="test-m" = "test-m" → count=1=len=1 → ALL=true → m returned. + { + Name: "ALL quantifier over a collected list in a WITH-piped stage", + Cypher: `MATCH (m:NodeKind1) WHERE m.unconstraineddelegation = true WITH m MATCH (n:NodeKind1)-[:EdgeKind1]->(g:NodeKind2) WHERE g.objectid ENDS WITH '-516' WITH m, COLLECT(n) AS matchingNs WHERE ALL(n IN matchingNs WHERE n.objectid = m.objectid) RETURN m`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + NewNodeWithProperties("m", map[string]any{"unconstraineddelegation": true, "objectid": "test-m"}, NodeKind1), + NewNodeWithProperties("n", map[string]any{"objectid": "test-m"}, NodeKind1), + NewNodeWithProperties("g", map[string]any{"objectid": "S-1-5-21-516"}, NodeKind2), + }, + Edges: []EdgeFixture{ + NewEdge("n", "g", EdgeKind1), + }, + }, + Assert: AssertNonEmpty(), + }, + // ---- multiple ANY quantifiers with a compound OR predicate inside -------- + // The second ANY uses a compound OR predicate inside its WHERE clause, + // exercising the translation of disjunctions within quantifier bodies. + { + Name: "multiple ANY quantifiers where the second ANY has a compound OR predicate", + Cypher: `MATCH (n:NodeKind1) WHERE n.usedeskeyonly OR ANY(type IN n.supportedencryptiontypes WHERE type CONTAINS 'DES') OR ANY(type IN n.serviceprincipalnames WHERE toLower(type) CONTAINS 'mssql' OR toLower(type) CONTAINS 'mssqlcluster') RETURN n LIMIT 100`, + Fixture: quantifierFixture, + // qAny has 'DES-CBC-CRC' in supportedencryptiontypes → first ANY=true → matches + Assert: AssertNonEmpty(), + }, + // ---- ANY in first WITH stage, NONE over collected results in second ------ + // This exercises ANY driving a WITH pipeline where NONE then filters over + // the collected output of the second MATCH stage. + { + Name: "ANY quantifier in first stage gates a pipeline where NONE filters the collected output", + Cypher: `MATCH (m:NodeKind1) WHERE ANY(name IN m.serviceprincipalnames WHERE name CONTAINS 'PHANTOM') WITH m MATCH (n:NodeKind1)-[:EdgeKind1]->(g:NodeKind2) WHERE g.objectid ENDS WITH '-525' WITH m, COLLECT(n) AS matchingNs WHERE NONE(t IN matchingNs WHERE t.objectid = m.objectid) RETURN m`, + Fixture: GraphFixture{ + Nodes: []NodeFixture{ + // m has 'PHANTOM' in serviceprincipalnames → ANY=true; objectid='m-obj' + NewNodeWithProperties("m", map[string]any{ + "objectid": "m-obj", + "serviceprincipalnames": []any{"PHANTOM/host"}, + }, NodeKind1), + // n has a different objectid so NONE(t.objectid = m.objectid) is true + NewNodeWithProperties("n", map[string]any{"objectid": "other-obj"}, NodeKind1), + NewNodeWithProperties("g", map[string]any{"objectid": "S-1-5-21-525"}, NodeKind2), + }, + Edges: []EdgeFixture{NewEdge("n", "g", EdgeKind1)}, + }, + Assert: AssertNonEmpty(), + }, +} diff --git a/cypher/models/pgsql/test/fixture.go b/cypher/models/pgsql/test/fixture.go new file mode 100644 index 0000000..d7138df --- /dev/null +++ b/cypher/models/pgsql/test/fixture.go @@ -0,0 +1,108 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package test + +import ( + "testing" + + "github.com/specterops/dawgs/graph" +) + +var ( + // Node and edge kinds to keep queries consistent + NodeKind1 = graph.StringKind("NodeKind1") + NodeKind2 = graph.StringKind("NodeKind2") + EdgeKind1 = graph.StringKind("EdgeKind1") + EdgeKind2 = graph.StringKind("EdgeKind2") +) + +// SemanticGraphSchema is the graph schema used by semantic integration tests. +// It must be kept in sync with the schema in validation_integration_test.go so +// that kind IDs are assigned in the same order and match the golden SQL files. +var SemanticGraphSchema = graph.Schema{ + Graphs: []graph.Graph{{ + Name: "test", + Nodes: graph.Kinds{NodeKind1, NodeKind2}, + Edges: graph.Kinds{EdgeKind1, EdgeKind2}, + }}, + DefaultGraph: graph.Graph{Name: "test"}, +} + +// NodeRef is a symbolic name used to wire nodes into edges within a fixture. +type NodeRef = string + +// NodeFixture describes a single node to be created in the fixture graph. +type NodeFixture struct { + Ref NodeRef + Kinds graph.Kinds + Props map[string]any +} + +// EdgeFixture describes a directed edge to be created in the fixture graph. +type EdgeFixture struct { + StartRef NodeRef + EndRef NodeRef + Kind graph.Kind + Props map[string]any +} + +// GraphFixture is the complete description of the minimal graph state required +// by a SemanticTestCase. Nodes and edges are created inside a write transaction +// that is always rolled back after the test completes. +type GraphFixture struct { + Nodes []NodeFixture + Edges []EdgeFixture +} + +// SemanticTestCase pairs a Cypher query with a fixture graph and assertions +// on the result set produced by executing that query against the fixture. +type SemanticTestCase struct { + // Name is a human-readable label shown by the test runner. + Name string + // Cypher is the query passed verbatim to graph.Transaction.Query. + Cypher string + // Params are optional Cypher-level parameters forwarded to the query. + Params map[string]any + // Fixture is the graph state created before executing Cypher. + Fixture GraphFixture + // Assert inspects the query result. Next() has not yet been called. + Assert ResultAssertion + // PostAssert runs after Assert while the transaction is still open. + // Use this for destructive queries (delete/update) that require a + // follow-up read to verify the mutation took effect. + PostAssert func(*testing.T, graph.Transaction) +} + +// NewNode creates a NodeFixture with no properties. +func NewNode(ref NodeRef, kinds ...graph.Kind) NodeFixture { + return NodeFixture{Ref: ref, Kinds: kinds} +} + +// NewNodeWithProperties creates a NodeFixture with the given properties and kinds. +func NewNodeWithProperties(ref NodeRef, props map[string]any, kinds ...graph.Kind) NodeFixture { + return NodeFixture{Ref: ref, Kinds: kinds, Props: props} +} + +// NewEdge creates an EdgeFixture with no properties. +func NewEdge(start, end NodeRef, kind graph.Kind) EdgeFixture { + return EdgeFixture{StartRef: start, EndRef: end, Kind: kind} +} + +// NewEdgeWithProperties creates an EdgeFixture with properties. +func NewEdgeWithProperties(start, end NodeRef, kind graph.Kind, props map[string]any) EdgeFixture { + return EdgeFixture{StartRef: start, EndRef: end, Kind: kind, Props: props} +} diff --git a/cypher/models/pgsql/test/semantic_integration_test.go b/cypher/models/pgsql/test/semantic_integration_test.go new file mode 100644 index 0000000..1ed83b2 --- /dev/null +++ b/cypher/models/pgsql/test/semantic_integration_test.go @@ -0,0 +1,170 @@ +//go:build manual_integration + +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package test + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "testing" + + "github.com/specterops/dawgs" + "github.com/specterops/dawgs/drivers/pg" + "github.com/specterops/dawgs/graph" + "github.com/specterops/dawgs/util/size" +) + +// errFixtureRollback is returned from every test transaction delegate to +// unconditionally roll back fixture data after each semantic test case. +var errFixtureRollback = errors.New("semantic test fixture rollback") + +func TestSemanticTranslation(t *testing.T) { + pgConnectionStr := os.Getenv(PGConnectionStringEV) + if pgConnectionStr == "" { + t.Fatalf("%s environment variable is not set", PGConnectionStringEV) + } + + testCtx, done := context.WithCancel(context.Background()) + defer done() + + pgxPool, err := pg.NewPool(pgConnectionStr) + if err != nil { + t.Fatalf("Failed creating pgx pool: %v", err) + } + + db, err := dawgs.Open(context.TODO(), pg.DriverName, dawgs.Config{ + GraphQueryMemoryLimit: size.Gibibyte, + Pool: pgxPool, + }) + if err != nil { + t.Fatalf("Failed opening database connection: %v", err) + } + defer db.Close(testCtx) + + var ( + numCasesRun = 0 + ) + + // Each test case calls AssertSchema independently so that the kind mapper + // is always in a known state regardless of test ordering. + for _, testCase := range semanticTestCases { + t.Run(testCase.Name, func(t *testing.T) { + if err := db.AssertSchema(testCtx, SemanticGraphSchema); err != nil { + t.Fatalf("Failed asserting graph schema: %v", err) + } + + numCasesRun += 1 + slog.Info("Semantic Test", slog.String("name", testCase.Name), slog.Int("num_cases_run", numCasesRun)) + + runSemanticCase(testCtx, t, db, testCase) + }) + } + + slog.Info("Semantic Tests Finished", slog.Int("num_cases_run", numCasesRun)) +} + +// runSemanticCase creates tc.Fixture inside a write transaction, executes +// tc.Cypher, runs tc.Assert against the live result, optionally runs +// tc.PostAssert, then rolls back all fixture data. +func runSemanticCase(ctx context.Context, t *testing.T, db graph.Database, tc SemanticTestCase) { + t.Helper() + + err := db.WriteTransaction(ctx, func(tx graph.Transaction) error { + nodeMap, err := createFixtureNodes(tx, tc.Fixture) + if err != nil { + return fmt.Errorf("creating fixture nodes: %w", err) + } + + if err := createFixtureEdges(tx, nodeMap, tc.Fixture); err != nil { + return fmt.Errorf("creating fixture edges: %w", err) + } + + result := tx.Query(tc.Cypher, tc.Params) + defer result.Close() + + if tc.Assert != nil { + tc.Assert(t, result) + } + + if tc.PostAssert != nil { + tc.PostAssert(t, tx) + } + + return errFixtureRollback + }) + + if !errors.Is(err, errFixtureRollback) { + t.Errorf("unexpected transaction error in %q: %v", tc.Name, err) + } +} + +// createFixtureNodes creates all nodes described by fixture and returns a +// map from NodeRef to the created *graph.Node so edges can reference them. +func createFixtureNodes(tx graph.Transaction, fixture GraphFixture) (map[NodeRef]*graph.Node, error) { + nodeMap := make(map[NodeRef]*graph.Node, len(fixture.Nodes)) + + for _, nf := range fixture.Nodes { + var props *graph.Properties + if len(nf.Props) > 0 { + props = graph.AsProperties(nf.Props) + } else { + props = graph.NewProperties() + } + + node, err := tx.CreateNode(props, nf.Kinds...) + if err != nil { + return nil, fmt.Errorf("node %q: %w", nf.Ref, err) + } + + nodeMap[nf.Ref] = node + } + + return nodeMap, nil +} + +// createFixtureEdges creates all edges described by fixture, resolving node +// references from nodeMap. +func createFixtureEdges(tx graph.Transaction, nodeMap map[NodeRef]*graph.Node, fixture GraphFixture) error { + for _, ef := range fixture.Edges { + start, ok := nodeMap[ef.StartRef] + if !ok { + return fmt.Errorf("edge start ref %q not in fixture node map", ef.StartRef) + } + + end, ok := nodeMap[ef.EndRef] + if !ok { + return fmt.Errorf("edge end ref %q not in fixture node map", ef.EndRef) + } + + var props *graph.Properties + if len(ef.Props) > 0 { + props = graph.AsProperties(ef.Props) + } else { + props = graph.NewProperties() + } + + if _, err := tx.CreateRelationshipByIDs(start.ID, end.ID, ef.Kind, props); err != nil { + return fmt.Errorf("edge %q->%q (%s): %w", ef.StartRef, ef.EndRef, ef.Kind, err) + } + } + + return nil +} diff --git a/cypher/models/pgsql/test/translation_cases/multipart.sql b/cypher/models/pgsql/test/translation_cases/multipart.sql index a22e9d5..004e15a 100644 --- a/cypher/models/pgsql/test/translation_cases/multipart.sql +++ b/cypher/models/pgsql/test/translation_cases/multipart.sql @@ -45,7 +45,7 @@ with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposit with s0 as (select 'a' as i0), s1 as (select s0.i0 as i0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from s0, node n0 where ((n0.properties ->> 'domain') = ' ' and (n0.properties ->> 'name') like s0.i0 || '%') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s1.n0 as o from s1; -- case: match (dc)-[r:EdgeKind1*0..]->(g:NodeKind1) where g.objectid ends with '-516' with collect(dc) as exclude match p = (c:NodeKind2)-[n:EdgeKind2]->(u:NodeKind2)-[:EdgeKind2*1..]->(g:NodeKind1) where g.objectid ends with '-512' and not c in exclude return p limit 100 -with s0 as (with s1 as (with recursive s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, false, e0.end_id = e0.start_id, array [e0.id] from edge e0 join node n1 on n1.id = e0.end_id join node n0 on n0.id = e0.start_id where ((n1.properties ->> 'objectid') like '%-516') and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and e0.kind_id = any (array [3]::int2[]) union select s2.root_id, e0.start_id, s2.depth + 1, false, e0.id = any (s2.path), s2.path || e0.id from s2 join edge e0 on e0.end_id = s2.next_id join node n0 on n0.id = e0.start_id where e0.kind_id = any (array [3]::int2[]) and s2.depth < 15 and not s2.is_cycle) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s2.path)) as e0, s2.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s2 join node n1 on n1.id = s2.root_id join node n0 on n0.id = s2.next_id) select array_remove(coalesce(array_agg(s1.n0)::nodecomposite[], array []::nodecomposite[])::nodecomposite[], null)::nodecomposite[] as i0 from s1), s3 as (select (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.i0 as i0, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s0, edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e1.start_id join node n3 on n3.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n3.id = e1.end_id where e1.kind_id = any (array [4]::int2[])), s4 as (with recursive s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, ((n4.properties ->> 'objectid') like '%-512') and n4.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.start_id = e2.end_id, array [e2.id] from s3 join edge e2 on (s3.n3).id = e2.start_id join node n4 on n4.id = e2.end_id where e2.kind_id = any (array [4]::int2[]) union select s5.root_id, e2.end_id, s5.depth + 1, ((n4.properties ->> 'objectid') like '%-512') and n4.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.id = any (s5.path), s5.path || e2.id from s5 join edge e2 on e2.start_id = s5.next_id join node n4 on n4.id = e2.end_id where e2.kind_id = any (array [4]::int2[]) and s5.depth < 15 and not s5.is_cycle) select s3.e1 as e1, (select array_agg((e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties)::edgecomposite) from edge e2 where e2.id = any (s5.path)) as e2, s5.path as ep1, s3.i0 as i0, s3.n2 as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3, (n4.id, n4.kind_ids, n4.properties)::nodecomposite as n4 from s3, s5 join node n3 on n3.id = s5.root_id join node n4 on n4.id = s5.next_id where s5.satisfied and (s3.n3).id = s5.root_id) select edges_to_path(variadic array [(s4.e1).id]::int8[] || s4.ep1)::pathcomposite as p from s4 where (not (s4.n2).id = any ((select (_unnest_elem).id from unnest(s4.i0) as _unnest_elem))) limit 100; +with s0 as (with s1 as (with recursive s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, false, e0.end_id = e0.start_id, array [e0.id] from edge e0 join node n1 on n1.id = e0.end_id where ((n1.properties ->> 'objectid') like '%-516') and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and e0.kind_id = any (array [3]::int2[]) union select s2.root_id, e0.start_id, s2.depth + 1, false, e0.id = any (s2.path), s2.path || e0.id from s2 join edge e0 on e0.end_id = s2.next_id where e0.kind_id = any (array [3]::int2[]) and s2.depth < 15 and not s2.is_cycle) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s2.path)) as e0, s2.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s2 join node n1 on n1.id = s2.root_id join node n0 on n0.id = s2.next_id) select array_remove(coalesce(array_agg(s1.n0)::nodecomposite[], array []::nodecomposite[])::nodecomposite[], null)::nodecomposite[] as i0 from s1), s3 as (select (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.i0 as i0, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s0, edge e1 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e1.start_id join node n3 on n3.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n3.id = e1.end_id where e1.kind_id = any (array [4]::int2[])), s4 as (with recursive s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, ((n4.properties ->> 'objectid') like '%-512') and n4.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.start_id = e2.end_id, array [e2.id] from s3 join edge e2 on (s3.n3).id = e2.start_id join node n4 on n4.id = e2.end_id where e2.kind_id = any (array [4]::int2[]) union select s5.root_id, e2.end_id, s5.depth + 1, ((n4.properties ->> 'objectid') like '%-512') and n4.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.id = any (s5.path), s5.path || e2.id from s5 join edge e2 on e2.start_id = s5.next_id join node n4 on n4.id = e2.end_id where e2.kind_id = any (array [4]::int2[]) and s5.depth < 15 and not s5.is_cycle) select s3.e1 as e1, (select array_agg((e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties)::edgecomposite) from edge e2 where e2.id = any (s5.path)) as e2, s5.path as ep1, s3.i0 as i0, s3.n2 as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3, (n4.id, n4.kind_ids, n4.properties)::nodecomposite as n4 from s3, s5 join node n3 on n3.id = s5.root_id join node n4 on n4.id = s5.next_id where s5.satisfied and (s3.n3).id = s5.root_id) select edges_to_path(variadic array [(s4.e1).id]::int8[] || s4.ep1)::pathcomposite as p from s4 where (not (s4.n2).id = any ((select (_unnest_elem).id from unnest(s4.i0) as _unnest_elem))) limit 100; -- case: match (n:NodeKind1)<-[:EdgeKind1]-(:NodeKind2) where n.objectid ends with '-516' with n, count(n) as dc_count where dc_count = 1 return n with s0 as (with s1 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on ((n0.properties ->> 'objectid') like '%-516') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id = e0.end_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.start_id where e0.kind_id = any (array [3]::int2[])) select s1.n0 as n0, count(s1.n0)::int8 as i0 from s1 group by n0) select s0.n0 as n from s0 where (s0.i0 = 1); diff --git a/cypher/models/pgsql/test/translation_cases/nodes.sql b/cypher/models/pgsql/test/translation_cases/nodes.sql index 1639fa2..10e65a1 100644 --- a/cypher/models/pgsql/test/translation_cases/nodes.sql +++ b/cypher/models/pgsql/test/translation_cases/nodes.sql @@ -311,3 +311,4 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from -- case: match (n:NodeKind1) optional match (m:NodeKind2) where m.distinguishedname = n.unknown + m.unknown optional match (o:NodeKind2) where o.distinguishedname <> n.otherunknown return n, m, o with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]), s1 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where ((n1.properties ->> 'distinguishedname') = ((s0.n0).properties -> 'unknown') + (n1.properties -> 'unknown')) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s2 as (select s0.n0 as n0, s1.n1 as n1 from s0 left outer join s1 on (s0.n0 = s1.n0)), s3 as (select s2.n0 as n0, s2.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s2, node n2 where ((n2.properties -> 'distinguishedname') <> ((s2.n0).properties -> 'otherunknown')) and n2.kind_ids operator (pg_catalog.@>) array [2]::int2[]), s4 as (select s2.n0 as n0, s2.n1 as n1, s3.n2 as n2 from s2 left outer join s3 on (s2.n1 = s3.n1) and (s2.n0 = s3.n0)) select s4.n0 as n, s4.n1 as m, s4.n2 as o from s4; + diff --git a/cypher/models/pgsql/test/translation_cases/pattern_binding.sql b/cypher/models/pgsql/test/translation_cases/pattern_binding.sql index 9d131ce..69d6942 100644 --- a/cypher/models/pgsql/test/translation_cases/pattern_binding.sql +++ b/cypher/models/pgsql/test/translation_cases/pattern_binding.sql @@ -36,13 +36,13 @@ with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::e with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on ((n0.properties ->> 'name') = 'value') and n0.id = e0.start_id join node n1 on n1.id = e0.end_id), s1 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.end_id join node n2 on (((n2.properties ->> 'is_target'))::bool) and n2.id = e1.start_id) select edges_to_path(variadic array [(s1.e0).id, (s1.e1).id]::int8[])::pathcomposite as p from s1; -- case: match p = ()-[*..]->() return p limit 1 -with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, false, e0.start_id = e0.end_id, array [e0.id] from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.id = e0.end_id union select s1.root_id, e0.end_id, s1.depth + 1, false, e0.id = any (s1.path), s1.path || e0.id from s1 join edge e0 on e0.start_id = s1.next_id join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s1.path)) as e0, s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id) select edges_to_path(variadic ep0)::pathcomposite as p from s0 limit 1; +with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, false, e0.start_id = e0.end_id, array [e0.id] from edge e0 union select s1.root_id, e0.end_id, s1.depth + 1, false, e0.id = any (s1.path), s1.path || e0.id from s1 join edge e0 on e0.start_id = s1.next_id where s1.depth < 15 and not s1.is_cycle) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s1.path)) as e0, s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id) select edges_to_path(variadic ep0)::pathcomposite as p from s0 limit 1; -- case: match p = (s)-[*..]->(i)-[]->() where id(s) = 1 and i.name = 'n3' return p limit 1 with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, ((n1.properties ->> 'name') = 'n3'), e0.start_id = e0.end_id, array [e0.id] from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.id = e0.end_id where (n0.id = 1) union select s1.root_id, e0.end_id, s1.depth + 1, ((n1.properties ->> 'name') = 'n3'), e0.id = any (s1.path), s1.path || e0.id from s1 join edge e0 on e0.start_id = s1.next_id join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s1.path)) as e0, s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where s1.satisfied), s2 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.ep0 as ep0, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id) select edges_to_path(variadic s2.ep0 || array [(s2.e1).id]::int8[])::pathcomposite as p from s2 limit 1; -- case: match p = ()-[e:EdgeKind1]->()-[:EdgeKind1*..]->() return e, p -with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[])), s1 as (with recursive s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.start_id, e1.end_id, 1, false, e1.start_id = e1.end_id, array [e1.id] from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.kind_id = any (array [3]::int2[]) union select s2.root_id, e1.end_id, s2.depth + 1, false, e1.id = any (s2.path), s2.path || e1.id from s2 join edge e1 on e1.start_id = s2.next_id join node n2 on n2.id = e1.end_id where e1.kind_id = any (array [3]::int2[]) and s2.depth < 15 and not s2.is_cycle) select s0.e0 as e0, (select array_agg((e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite) from edge e1 where e1.id = any (s2.path)) as e1, s2.path as ep0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join node n1 on n1.id = s2.root_id join node n2 on n2.id = s2.next_id where (s0.n1).id = s2.root_id) select s1.e0 as e, edges_to_path(variadic array [(s1.e0).id]::int8[] || s1.ep0)::pathcomposite as p from s1; +with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[])), s1 as (with recursive s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.start_id, e1.end_id, 1, false, e1.start_id = e1.end_id, array [e1.id] from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.kind_id = any (array [3]::int2[]) union select s2.root_id, e1.end_id, s2.depth + 1, false, e1.id = any (s2.path), s2.path || e1.id from s2 join edge e1 on e1.start_id = s2.next_id where e1.kind_id = any (array [3]::int2[]) and s2.depth < 15 and not s2.is_cycle) select s0.e0 as e0, (select array_agg((e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite) from edge e1 where e1.id = any (s2.path)) as e1, s2.path as ep0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join node n1 on n1.id = s2.root_id join node n2 on n2.id = s2.next_id where (s0.n1).id = s2.root_id) select s1.e0 as e, edges_to_path(variadic array [(s1.e0).id]::int8[] || s1.ep0)::pathcomposite as p from s1; -- case: match p = (m:NodeKind1)-[:EdgeKind1]->(c:NodeKind2) where m.objectid ends with "-513" and not toUpper(c.operatingsystem) contains "SERVER" return p limit 1000 with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on ((n0.properties ->> 'objectid') like '%-513') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id = e0.start_id join node n1 on (not upper((n1.properties ->> 'operatingsystem'))::text like '%SERVER%') and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select edges_to_path(variadic array [(s0.e0).id]::int8[])::pathcomposite as p from s0 limit 1000; diff --git a/cypher/models/pgsql/test/translation_cases/pattern_expansion.sql b/cypher/models/pgsql/test/translation_cases/pattern_expansion.sql index 80ec63d..8351a57 100644 --- a/cypher/models/pgsql/test/translation_cases/pattern_expansion.sql +++ b/cypher/models/pgsql/test/translation_cases/pattern_expansion.sql @@ -15,19 +15,19 @@ -- SPDX-License-Identifier: Apache-2.0 -- case: match (n)-[*..]->(e) return n, e -with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, false, e0.start_id = e0.end_id, array [e0.id] from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.id = e0.end_id union select s1.root_id, e0.end_id, s1.depth + 1, false, e0.id = any (s1.path), s1.path || e0.id from s1 join edge e0 on e0.start_id = s1.next_id join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s1.path)) as e0, s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id) select s0.n0 as n, s0.n1 as e from s0; +with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, false, e0.start_id = e0.end_id, array [e0.id] from edge e0 union select s1.root_id, e0.end_id, s1.depth + 1, false, e0.id = any (s1.path), s1.path || e0.id from s1 join edge e0 on e0.start_id = s1.next_id where s1.depth < 15 and not s1.is_cycle) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s1.path)) as e0, s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id) select s0.n0 as n, s0.n1 as e from s0; -- case: match (n)-[*1..2]->(e) return n, e -with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, false, e0.start_id = e0.end_id, array [e0.id] from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.id = e0.end_id union select s1.root_id, e0.end_id, s1.depth + 1, false, e0.id = any (s1.path), s1.path || e0.id from s1 join edge e0 on e0.start_id = s1.next_id join node n1 on n1.id = e0.end_id where s1.depth < 2 and not s1.is_cycle) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s1.path)) as e0, s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id) select s0.n0 as n, s0.n1 as e from s0; +with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, false, e0.start_id = e0.end_id, array [e0.id] from edge e0 union select s1.root_id, e0.end_id, s1.depth + 1, false, e0.id = any (s1.path), s1.path || e0.id from s1 join edge e0 on e0.start_id = s1.next_id where s1.depth < 2 and not s1.is_cycle) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s1.path)) as e0, s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id) select s0.n0 as n, s0.n1 as e from s0; -- case: match (n)-[*3..5]->(e) return n, e -with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, false, e0.start_id = e0.end_id, array [e0.id] from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.id = e0.end_id union select s1.root_id, e0.end_id, s1.depth + 1, false, e0.id = any (s1.path), s1.path || e0.id from s1 join edge e0 on e0.start_id = s1.next_id join node n1 on n1.id = e0.end_id where s1.depth < 5 and not s1.is_cycle) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s1.path)) as e0, s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where s1.depth >= 3) select s0.n0 as n, s0.n1 as e from s0; +with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, false, e0.start_id = e0.end_id, array [e0.id] from edge e0 union select s1.root_id, e0.end_id, s1.depth + 1, false, e0.id = any (s1.path), s1.path || e0.id from s1 join edge e0 on e0.start_id = s1.next_id where s1.depth < 5 and not s1.is_cycle) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s1.path)) as e0, s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where s1.depth >= 3) select s0.n0 as n, s0.n1 as e from s0; -- case: match (n)<-[*2..5]-(e) return n, e -with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, false, e0.end_id = e0.start_id, array [e0.id] from edge e0 join node n0 on n0.id = e0.end_id join node n1 on n1.id = e0.start_id union select s1.root_id, e0.start_id, s1.depth + 1, false, e0.id = any (s1.path), s1.path || e0.id from s1 join edge e0 on e0.end_id = s1.next_id join node n1 on n1.id = e0.start_id where s1.depth < 5 and not s1.is_cycle) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s1.path)) as e0, s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where s1.depth >= 2) select s0.n0 as n, s0.n1 as e from s0; +with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, false, e0.end_id = e0.start_id, array [e0.id] from edge e0 union select s1.root_id, e0.start_id, s1.depth + 1, false, e0.id = any (s1.path), s1.path || e0.id from s1 join edge e0 on e0.end_id = s1.next_id where s1.depth < 5 and not s1.is_cycle) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s1.path)) as e0, s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where s1.depth >= 2) select s0.n0 as n, s0.n1 as e from s0; -- case: match p = (n)-[*..]->(e:NodeKind1) return p -with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, n1.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.start_id = e0.end_id, array [e0.id] from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.id = e0.end_id union select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.id = any (s1.path), s1.path || e0.id from s1 join edge e0 on e0.start_id = s1.next_id join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s1.path)) as e0, s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where s1.satisfied) select edges_to_path(variadic ep0)::pathcomposite as p from s0; +with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, n1.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.start_id = e0.end_id, array [e0.id] from edge e0 join node n1 on n1.id = e0.end_id union select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.id = any (s1.path), s1.path || e0.id from s1 join edge e0 on e0.start_id = s1.next_id join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s1.path)) as e0, s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where s1.satisfied) select edges_to_path(variadic ep0)::pathcomposite as p from s0; -- case: match (n)-[*..]->(e:NodeKind1) where n.name = 'n1' return e with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, n1.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.start_id = e0.end_id, array [e0.id] from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.id = e0.end_id where ((n0.properties ->> 'name') = 'n1') union select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.id = any (s1.path), s1.path || e0.id from s1 join edge e0 on e0.start_id = s1.next_id join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s1.path)) as e0, s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where s1.satisfied) select s0.n1 as e from s0; @@ -45,10 +45,10 @@ with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, n1.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.start_id = e0.end_id, array [e0.id] from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.id = e0.end_id where ((n0.properties ->> 'name') = 'n1') union select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.id = any (s1.path), s1.path || e0.id from s1 join edge e0 on e0.start_id = s1.next_id join node n1 on n1.id = e0.end_id where s1.depth < 3 and not s1.is_cycle) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s1.path)) as e0, s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where s1.depth >= 2 and s1.satisfied), s2 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.ep0 as ep0, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id) select s2.n2 as l from s2; -- case: match (n)-[]->(e:NodeKind1)-[*2..3]->(l) where n.name = 'n1' return l -with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on ((n0.properties ->> 'name') = 'n1') and n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.end_id), s1 as (with recursive s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.start_id, e1.end_id, 1, false, e1.start_id = e1.end_id, array [e1.id] from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id union select s2.root_id, e1.end_id, s2.depth + 1, false, e1.id = any (s2.path), s2.path || e1.id from s2 join edge e1 on e1.start_id = s2.next_id join node n2 on n2.id = e1.end_id where s2.depth < 3 and not s2.is_cycle) select s0.e0 as e0, (select array_agg((e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite) from edge e1 where e1.id = any (s2.path)) as e1, s2.path as ep0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join node n1 on n1.id = s2.root_id join node n2 on n2.id = s2.next_id where s2.depth >= 2 and (s0.n1).id = s2.root_id) select s1.n2 as l from s1; +with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on ((n0.properties ->> 'name') = 'n1') and n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.end_id), s1 as (with recursive s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.start_id, e1.end_id, 1, false, e1.start_id = e1.end_id, array [e1.id] from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id union select s2.root_id, e1.end_id, s2.depth + 1, false, e1.id = any (s2.path), s2.path || e1.id from s2 join edge e1 on e1.start_id = s2.next_id where s2.depth < 3 and not s2.is_cycle) select s0.e0 as e0, (select array_agg((e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite) from edge e1 where e1.id = any (s2.path)) as e1, s2.path as ep0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join node n1 on n1.id = s2.root_id join node n2 on n2.id = s2.next_id where s2.depth >= 2 and (s0.n1).id = s2.root_id) select s1.n2 as l from s1; -- case: match (n)-[*..]->(e)-[:EdgeKind1|EdgeKind2]->()-[*..]->(l) where n.name = 'n1' and e.name = 'n2' return l -with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, ((n1.properties ->> 'name') = 'n2'), e0.start_id = e0.end_id, array [e0.id] from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.id = e0.end_id where ((n0.properties ->> 'name') = 'n1') union select s1.root_id, e0.end_id, s1.depth + 1, ((n1.properties ->> 'name') = 'n2'), e0.id = any (s1.path), s1.path || e0.id from s1 join edge e0 on e0.start_id = s1.next_id join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s1.path)) as e0, s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where s1.satisfied), s2 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.ep0 as ep0, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.kind_id = any (array [3, 4]::int2[])), s3 as (with recursive s4(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, false, e2.start_id = e2.end_id, array [e2.id] from s2 join edge e2 on (s2.n2).id = e2.start_id join node n3 on n3.id = e2.end_id union select s4.root_id, e2.end_id, s4.depth + 1, false, e2.id = any (s4.path), s4.path || e2.id from s4 join edge e2 on e2.start_id = s4.next_id join node n3 on n3.id = e2.end_id where s4.depth < 15 and not s4.is_cycle) select s2.e0 as e0, s2.e1 as e1, (select array_agg((e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties)::edgecomposite) from edge e2 where e2.id = any (s4.path)) as e2, s2.ep0 as ep0, s4.path as ep1, s2.n0 as n0, s2.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s2, s4 join node n2 on n2.id = s4.root_id join node n3 on n3.id = s4.next_id where (s2.n2).id = s4.root_id) select s3.n3 as l from s3; +with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, ((n1.properties ->> 'name') = 'n2'), e0.start_id = e0.end_id, array [e0.id] from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.id = e0.end_id where ((n0.properties ->> 'name') = 'n1') union select s1.root_id, e0.end_id, s1.depth + 1, ((n1.properties ->> 'name') = 'n2'), e0.id = any (s1.path), s1.path || e0.id from s1 join edge e0 on e0.start_id = s1.next_id join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s1.path)) as e0, s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where s1.satisfied), s2 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.ep0 as ep0, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.id = e1.end_id where e1.kind_id = any (array [3, 4]::int2[])), s3 as (with recursive s4(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, false, e2.start_id = e2.end_id, array [e2.id] from s2 join edge e2 on (s2.n2).id = e2.start_id join node n3 on n3.id = e2.end_id union select s4.root_id, e2.end_id, s4.depth + 1, false, e2.id = any (s4.path), s4.path || e2.id from s4 join edge e2 on e2.start_id = s4.next_id where s4.depth < 15 and not s4.is_cycle) select s2.e0 as e0, s2.e1 as e1, (select array_agg((e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties)::edgecomposite) from edge e2 where e2.id = any (s4.path)) as e2, s2.ep0 as ep0, s4.path as ep1, s2.n0 as n0, s2.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s2, s4 join node n2 on n2.id = s4.root_id join node n3 on n3.id = s4.next_id where (s2.n2).id = s4.root_id) select s3.n3 as l from s3; -- case: match p = (:NodeKind1)-[:EdgeKind1*1..]->(n:NodeKind2) where 'admin_tier_0' in split(n.system_tags, ' ') return p limit 1000 with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, n0.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.end_id = e0.start_id, array [e0.id] from edge e0 join node n1 on n1.id = e0.end_id join node n0 on n0.id = e0.start_id where ('admin_tier_0' = any (string_to_array((n1.properties ->> 'system_tags'), ' ')::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and e0.kind_id = any (array [3]::int2[]) union select s1.root_id, e0.start_id, s1.depth + 1, n0.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.id = any (s1.path), s1.path || e0.id from s1 join edge e0 on e0.end_id = s1.next_id join node n0 on n0.id = e0.start_id where e0.kind_id = any (array [3]::int2[]) and s1.depth < 15 and not s1.is_cycle) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s1.path)) as e0, s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join node n1 on n1.id = s1.root_id join node n0 on n0.id = s1.next_id where s1.satisfied) select edges_to_path(variadic ep0)::pathcomposite as p from s0 limit 1000; @@ -63,7 +63,7 @@ with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, n0.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.end_id = e0.start_id, array [e0.id] from edge e0 join node n1 on n1.id = e0.end_id join node n0 on n0.id = e0.start_id where ((n1.properties ->> 'objectid') = '1234') and n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and e0.kind_id = any (array [3]::int2[]) union select s1.root_id, e0.start_id, s1.depth + 1, n0.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.id = any (s1.path), s1.path || e0.id from s1 join edge e0 on e0.end_id = s1.next_id join node n0 on n0.id = e0.start_id where e0.kind_id = any (array [3]::int2[]) and s1.depth < 15 and not s1.is_cycle) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s1.path)) as e0, s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join node n1 on n1.id = s1.root_id join node n0 on n0.id = s1.next_id where s1.satisfied) select edges_to_path(variadic ep0)::pathcomposite as p from s0 limit 10; -- case: match p = (:NodeKind1)<-[:EdgeKind1|EdgeKind2*..]-() return p limit 10 -with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, false, e0.end_id = e0.start_id, array [e0.id] from edge e0 join node n0 on n0.id = e0.end_id join node n1 on n1.id = e0.start_id where n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and e0.kind_id = any (array [3, 4]::int2[]) union select s1.root_id, e0.start_id, s1.depth + 1, false, e0.id = any (s1.path), s1.path || e0.id from s1 join edge e0 on e0.end_id = s1.next_id join node n1 on n1.id = e0.start_id where e0.kind_id = any (array [3, 4]::int2[]) and s1.depth < 15 and not s1.is_cycle) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s1.path)) as e0, s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id) select edges_to_path(variadic ep0)::pathcomposite as p from s0 limit 10; +with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, false, e0.end_id = e0.start_id, array [e0.id] from edge e0 join node n0 on n0.id = e0.end_id where n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and e0.kind_id = any (array [3, 4]::int2[]) union select s1.root_id, e0.start_id, s1.depth + 1, false, e0.id = any (s1.path), s1.path || e0.id from s1 join edge e0 on e0.end_id = s1.next_id where e0.kind_id = any (array [3, 4]::int2[]) and s1.depth < 15 and not s1.is_cycle) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s1.path)) as e0, s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id) select edges_to_path(variadic ep0)::pathcomposite as p from s0 limit 10; -- case: match p = (:NodeKind1)<-[:EdgeKind1|EdgeKind2*..]-(:NodeKind2)<-[:EdgeKind1|EdgeKind2*2..]-(:NodeKind1) return p limit 10 with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.end_id = e0.start_id, array [e0.id] from edge e0 join node n0 on n0.id = e0.end_id join node n1 on n1.id = e0.start_id where n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and e0.kind_id = any (array [3, 4]::int2[]) union select s1.root_id, e0.start_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.id = any (s1.path), s1.path || e0.id from s1 join edge e0 on e0.end_id = s1.next_id join node n1 on n1.id = e0.start_id where e0.kind_id = any (array [3, 4]::int2[]) and s1.depth < 15 and not s1.is_cycle) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s1.path)) as e0, s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where s1.satisfied), s2 as (with recursive s3(root_id, next_id, depth, satisfied, is_cycle, path) as (select e1.end_id, e1.start_id, 1, n2.kind_ids operator (pg_catalog.@>) array [1]::int2[], e1.end_id = e1.start_id, array [e1.id] from s0 join edge e1 on (s0.n1).id = e1.end_id join node n2 on n2.id = e1.start_id where e1.kind_id = any (array [3, 4]::int2[]) union select s3.root_id, e1.start_id, s3.depth + 1, n2.kind_ids operator (pg_catalog.@>) array [1]::int2[], e1.id = any (s3.path), s3.path || e1.id from s3 join edge e1 on e1.end_id = s3.next_id join node n2 on n2.id = e1.start_id where e1.kind_id = any (array [3, 4]::int2[]) and s3.depth < 15 and not s3.is_cycle) select s0.e0 as e0, (select array_agg((e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite) from edge e1 where e1.id = any (s3.path)) as e1, s0.ep0 as ep0, s3.path as ep1, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s3 join node n1 on n1.id = s3.root_id join node n2 on n2.id = s3.next_id where s3.depth >= 2 and s3.satisfied and (s0.n1).id = s3.root_id) select edges_to_path(variadic s2.ep1 || s2.ep0)::pathcomposite as p from s2 limit 10; diff --git a/cypher/models/pgsql/test/translation_test.go b/cypher/models/pgsql/test/translation_test.go index 26aaa9a..07c93d6 100644 --- a/cypher/models/pgsql/test/translation_test.go +++ b/cypher/models/pgsql/test/translation_test.go @@ -10,14 +10,6 @@ import ( "github.com/specterops/dawgs/drivers/pg/pgutil" "github.com/specterops/dawgs/cypher/models/pgsql" - "github.com/specterops/dawgs/graph" -) - -var ( - NodeKind1 = graph.StringKind("NodeKind1") - NodeKind2 = graph.StringKind("NodeKind2") - EdgeKind1 = graph.StringKind("EdgeKind1") - EdgeKind2 = graph.StringKind("EdgeKind2") ) func newKindMapper() pgsql.KindMapper { diff --git a/cypher/models/pgsql/test/validation_integration_test.go b/cypher/models/pgsql/test/validation_integration_test.go index 6d2c620..1c0fe39 100644 --- a/cypher/models/pgsql/test/validation_integration_test.go +++ b/cypher/models/pgsql/test/validation_integration_test.go @@ -9,8 +9,6 @@ import ( "runtime/debug" "testing" - "github.com/jackc/pgx/v5/pgxpool" - "github.com/specterops/dawgs" "github.com/specterops/dawgs/drivers/pg" "github.com/specterops/dawgs/graph" @@ -32,7 +30,12 @@ func TestTranslationTestCases(t *testing.T) { require.NotEmpty(t, pgConnectionStr) - if pgxPool, err := pgxpool.New(testCtx, pgConnectionStr); err != nil { + // pg.NewPool installs the AfterConnect and AfterRelease hooks that register + // the composite types (nodecomposite, edgecomposite, pathcomposite) on every + // pool connection. Using pgxpool.New directly omits these hooks; after + // AssertSchema calls pool.Reset(), new connections would return composite + // values as raw []uint8 instead of map[string]any, causing scan failures. + if pgxPool, err := pg.NewPool(pgConnectionStr); err != nil { t.Fatalf("Failed opening database connection: %v", err) } else if connection, err := dawgs.Open(context.TODO(), pg.DriverName, dawgs.Config{ GraphQueryMemoryLimit: size.Gibibyte, diff --git a/cypher/models/pgsql/translate/expansion.go b/cypher/models/pgsql/translate/expansion.go index ddea674..03c6396 100644 --- a/cypher/models/pgsql/translate/expansion.go +++ b/cypher/models/pgsql/translate/expansion.go @@ -812,10 +812,18 @@ func (s *Translator) buildExpansionPatternRoot(traversalStepContext TraversalSte expansion.RecursiveStatement.Projection = projection } + // Craft the from clause + nextQueryFrom := pgsql.FromClause{ + Source: pgsql.TableReference{ + Name: pgsql.CompoundIdentifier{pgsql.TableEdge}, + Binding: models.OptionalValue(traversalStep.Edge.Identifier), + }, + } + // If the left node was already bound at time of translation connect this expansion to the // previously materialized node if traversalStep.LeftNodeBound { - expansion.PrimerStatement.From = append(expansion.PrimerStatement.From, pgsql.FromClause{ + nextQueryFrom = pgsql.FromClause{ Source: pgsql.TableReference{ Name: pgsql.CompoundIdentifier{traversalStep.Frame.Previous.Binding.Identifier}, }, @@ -834,19 +842,11 @@ func (s *Translator) buildExpansionPatternRoot(traversalStepContext TraversalSte pgsql.CompoundIdentifier{traversalStep.LeftNode.Identifier, pgsql.ColumnID}, )), }, - }, { - Table: pgsql.TableReference{ - Name: pgsql.CompoundIdentifier{pgsql.TableNode}, - Binding: models.OptionalValue(traversalStep.RightNode.Identifier), - }, - JoinOperator: pgsql.JoinOperator{ - JoinType: pgsql.JoinTypeInner, - Constraint: expansionModel.ExpansionNodeJoinCondition, - }, }}, - }) - } else { - expansion.PrimerStatement.From = append(expansion.PrimerStatement.From, pgsql.FromClause{ + } + } else if expansionModel.PrimerNodeConstraints != nil { + // Primer node constraints require a join of of the left node + nextQueryFrom = pgsql.FromClause{ Source: pgsql.TableReference{ Name: pgsql.CompoundIdentifier{pgsql.TableEdge}, Binding: models.OptionalValue(traversalStep.Edge.Identifier), @@ -858,40 +858,48 @@ func (s *Translator) buildExpansionPatternRoot(traversalStepContext TraversalSte }, JoinOperator: pgsql.JoinOperator{ JoinType: pgsql.JoinTypeInner, - Constraint: expansionModel.PrimerNodeJoinCondition, - }, - }, { - Table: pgsql.TableReference{ - Name: pgsql.CompoundIdentifier{pgsql.TableNode}, - Binding: models.OptionalValue(traversalStep.RightNode.Identifier), - }, - JoinOperator: pgsql.JoinOperator{ - JoinType: pgsql.JoinTypeInner, - Constraint: expansionModel.ExpansionNodeJoinCondition, + Constraint: traversalStep.Expansion.PrimerNodeJoinCondition, }, }}, - }) + } } - // Make sure the recursive query has the expansion bound - expansion.RecursiveStatement.From = append(expansion.RecursiveStatement.From, pgsql.FromClause{ - Source: pgsql.TableReference{ - Name: pgsql.CompoundIdentifier{expansionModel.Frame.Binding.Identifier}, - }, - Joins: []pgsql.Join{{ + // If there are terminal node constraints then the right node must be joined + if expansionModel.TerminalNodeSatisfactionProjection != nil { + nextQueryFrom.Joins = append(nextQueryFrom.Joins, pgsql.Join{ Table: pgsql.TableReference{ - Name: pgsql.CompoundIdentifier{pgsql.TableEdge}, - Binding: models.OptionalValue(traversalStep.Edge.Identifier), + Name: pgsql.CompoundIdentifier{pgsql.TableNode}, + Binding: models.OptionalValue(traversalStep.RightNode.Identifier), }, JoinOperator: pgsql.JoinOperator{ - JoinType: pgsql.JoinTypeInner, - Constraint: pgsql.NewBinaryExpression( - expansionModel.EdgeStartColumn, - pgsql.OperatorEquals, - pgsql.CompoundIdentifier{expansionModel.Frame.Binding.Identifier, expansionNextID}, - ), + JoinType: pgsql.JoinTypeInner, + Constraint: traversalStep.Expansion.ExpansionNodeJoinCondition, }, - }, { + }) + } + + expansion.PrimerStatement.From = append(expansion.PrimerStatement.From, nextQueryFrom) + + // Build recursive step joins. The terminal node join is only added when the + // expansion carries terminal-node constraints, which are the only cases where + // node columns appear in the recursive body. + recursiveJoins := []pgsql.Join{{ + Table: pgsql.TableReference{ + Name: pgsql.CompoundIdentifier{pgsql.TableEdge}, + Binding: models.OptionalValue(traversalStep.Edge.Identifier), + }, + JoinOperator: pgsql.JoinOperator{ + JoinType: pgsql.JoinTypeInner, + Constraint: pgsql.NewBinaryExpression( + expansionModel.EdgeStartColumn, + pgsql.OperatorEquals, + pgsql.CompoundIdentifier{expansionModel.Frame.Binding.Identifier, expansionNextID}, + ), + }, + }} + + if expansionModel.TerminalNodeConstraints != nil { + recursiveJoins = append(recursiveJoins, pgsql.Join{ Table: pgsql.TableReference{ Name: pgsql.CompoundIdentifier{pgsql.TableNode}, Binding: models.OptionalValue(traversalStep.RightNode.Identifier), @@ -900,7 +908,14 @@ func (s *Translator) buildExpansionPatternRoot(traversalStepContext TraversalSte JoinType: pgsql.JoinTypeInner, Constraint: expansionModel.ExpansionNodeJoinCondition, }, - }}, + }) + } + + expansion.RecursiveStatement.From = append(expansion.RecursiveStatement.From, pgsql.FromClause{ + Source: pgsql.TableReference{ + Name: pgsql.CompoundIdentifier{expansionModel.Frame.Binding.Identifier}, + }, + Joins: recursiveJoins, }) // The current query part may not have a frame associated with it if is a single part query component @@ -999,25 +1014,27 @@ func (s *Translator) buildExpansionPatternStep(traversalStepContext TraversalSte }}, }) - // Make sure the recursive query has the expansion bound - expansion.RecursiveStatement.From = append(expansion.RecursiveStatement.From, pgsql.FromClause{ - Source: pgsql.TableReference{ - Name: pgsql.CompoundIdentifier{expansionModel.Frame.Binding.Identifier}, + // Build recursive step joins. The terminal node join is only added when the + // expansion carries terminal-node constraints, which are the only cases where + // node columns appear in the recursive body. + recursiveJoins := []pgsql.Join{{ + Table: pgsql.TableReference{ + Name: pgsql.CompoundIdentifier{pgsql.TableEdge}, + Binding: models.OptionalValue(traversalStep.Edge.Identifier), }, - Joins: []pgsql.Join{{ - Table: pgsql.TableReference{ - Name: pgsql.CompoundIdentifier{pgsql.TableEdge}, - Binding: models.OptionalValue(traversalStep.Edge.Identifier), - }, - JoinOperator: pgsql.JoinOperator{ - JoinType: pgsql.JoinTypeInner, - Constraint: pgsql.NewBinaryExpression( - expansionModel.EdgeStartColumn, - pgsql.OperatorEquals, - pgsql.CompoundIdentifier{expansionModel.Frame.Binding.Identifier, expansionNextID}, - ), - }, - }, { + JoinOperator: pgsql.JoinOperator{ + JoinType: pgsql.JoinTypeInner, + Constraint: pgsql.NewBinaryExpression( + expansionModel.EdgeStartColumn, + pgsql.OperatorEquals, + pgsql.CompoundIdentifier{expansionModel.Frame.Binding.Identifier, expansionNextID}, + ), + }, + }} + + // If there are terminal node constraints then the right node must be joined + if expansionModel.TerminalNodeSatisfactionProjection != nil { + recursiveJoins = append(recursiveJoins, pgsql.Join{ Table: pgsql.TableReference{ Name: pgsql.CompoundIdentifier{pgsql.TableNode}, Binding: models.OptionalValue(traversalStep.RightNode.Identifier), @@ -1026,7 +1043,14 @@ func (s *Translator) buildExpansionPatternStep(traversalStepContext TraversalSte JoinType: pgsql.JoinTypeInner, Constraint: expansionModel.ExpansionNodeJoinCondition, }, - }}, + }) + } + + expansion.RecursiveStatement.From = append(expansion.RecursiveStatement.From, pgsql.FromClause{ + Source: pgsql.TableReference{ + Name: pgsql.CompoundIdentifier{expansionModel.Frame.Binding.Identifier}, + }, + Joins: recursiveJoins, }) // Select the expansion components for the projection statement From 413f16fab9b5b779b36640056dbdf2b15be692bb Mon Sep 17 00:00:00 2001 From: John Hopper Date: Mon, 30 Mar 2026 13:15:56 -0700 Subject: [PATCH 2/2] chore: fixup selectivity measurement; update makefile --- Makefile | 97 +- cypher/models/pgsql/test/assertions.go | 187 -- cypher/models/pgsql/test/cases.go | 2065 ----------------- cypher/models/pgsql/test/fixture.go | 108 - cypher/models/pgsql/test/query_test.go | 9 + .../pgsql/test/semantic_integration_test.go | 170 -- .../test/translation_cases/multipart.sql | 10 +- .../translation_cases/pattern_binding.sql | 1 + .../translation_cases/pattern_expansion.sql | 5 +- .../test/translation_cases/quantifiers.sql | 12 +- .../test/translation_cases/shortest_paths.sql | 11 +- .../translation_cases/stepwise_traversal.sql | 6 +- .../pgsql/test/translation_cases/update.sql | 7 +- cypher/models/pgsql/translate/constraints.go | 14 +- .../pgsql/translate/constraints_test.go | 2 +- cypher/models/pgsql/translate/selectivity.go | 72 +- cypher/models/pgsql/translate/traversal.go | 4 + go.mod | 1 - go.sum | 34 +- 19 files changed, 196 insertions(+), 2619 deletions(-) delete mode 100644 cypher/models/pgsql/test/assertions.go delete mode 100644 cypher/models/pgsql/test/cases.go delete mode 100644 cypher/models/pgsql/test/fixture.go delete mode 100644 cypher/models/pgsql/test/semantic_integration_test.go diff --git a/Makefile b/Makefile index 6bf867f..cb5473d 100644 --- a/Makefile +++ b/Makefile @@ -1,20 +1,97 @@ THIS_FILE := $(lastword $(MAKEFILE_LIST)) -GO_CMD?=go -CGO_ENABLED?=0 +# Go configuration +GO_CMD ?= go +CGO_ENABLED ?= 0 -MAIN_PACKAGES=$$($(GO_CMD) list ./...) +# Main packages to test/build +MAIN_PACKAGES := $(shell $(GO_CMD) list ./...) -default: format test -all: generate format test +# Default target +default: help +all: generate format tidy test -generate: - @$(GO_CMD) generate ./... +# Build targets +build: + @echo "Building all packages..." + @$(GO_CMD) build ./... + +# Dependency management +deps: + @echo "Downloading dependencies..." + @$(GO_CMD) mod download + +tidy: + @echo "Tidying go modules..." + @$(GO_CMD) mod tidy + +# Code quality +lint: + @echo "Running linter..." + @$(GO_CMD) vet ./... format: + @echo "Formatting code..." @find ./ -name '*.go' -print0 | xargs -P 12 -0 -I '{}' goimports -w '{}' +# Test targets test: - @for pkg in $(MAIN_PACKAGES) ; do \ - $(GO_CMD) test -cover $$pkg -parallel=20 ; \ - done \ No newline at end of file + @echo "Running tests..." + @$(GO_CMD) test -race -cover -count=1 -parallel=10 $(MAIN_PACKAGES) + +test_neo4j: + @echo "Running Neo4j integration tests..." + @$(GO_CMD) test -tags neo4j_integration -race -cover -count=1 -parallel=1 $(MAIN_PACKAGES) + +test_pg: + @echo "Running PostgreSQL integration tests..." + @$(GO_CMD) test -tags pg_integration -race -cover -count=1 -parallel=1 $(MAIN_PACKAGES) + +test_update: + @echo "Updating test cases..." + @CYSQL_UPDATE_CASES=true $(GO_CMD) test -parallel=10 $(MAIN_PACKAGES) + + @cp -fv cypher/analyzer/updated_cases/* cypher/test/cases + @rm -rf cypher/analyzer/updated_cases/ + @cp -fv cypher/models/pgsql/test/updated_cases/* cypher/models/pgsql/test/translation_cases + @rm -rf cypher/models/pgsql/test/updated_cases + +# Utility targets +generate: + @echo "Running code generation..." + @$(GO_CMD) generate ./... + +clean: + @echo "Cleaning build artifacts..." + @$(GO_CMD) clean ./... + + @rm -rf cypher/analyzer/updated_cases/ + @rm -rf cypher/models/pgsql/test/updated_cases + +help: + @echo "Available targets:" + @echo " default - Show this help message" + @echo " all - Runs all prep steps for prepare a changeset for review" + @echo "" + @echo "Build:" + @echo " build - Build all packages" + @echo "" + @echo "Dependencies:" + @echo " deps - Download dependencies" + @echo " tidy - Tidy go modules" + @echo "" + @echo "Code Quality:" + @echo " lint - Run go vet" + @echo " format - Format all Go files" + @echo " generate - Run code generation" + @echo "" + @echo "Testing:" + @echo " test - Run all unit tests with coverage" + @echo " test_bench - Run benchmark test" + @echo " test_neo4j - Run Neo4j integration tests" + @echo " test_pg - Run PostgreSQL integration tests" + @echo " test_update - Update test cases" + @echo "" + @echo "Utility:" + @echo " clean - Clean build artifacts" + @echo " help - Show this help message" diff --git a/cypher/models/pgsql/test/assertions.go b/cypher/models/pgsql/test/assertions.go deleted file mode 100644 index 6be898a..0000000 --- a/cypher/models/pgsql/test/assertions.go +++ /dev/null @@ -1,187 +0,0 @@ -// Copyright 2026 Specter Ops, Inc. -// -// Licensed under the Apache License, Version 2.0 -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// SPDX-License-Identifier: Apache-2.0 - -package test - -import ( - "testing" - - "github.com/specterops/dawgs/graph" - "github.com/stretchr/testify/require" -) - -// ResultAssertion inspects a live query result. When called, Next() has not -// yet been advanced — the assertion is responsible for iteration. -type ResultAssertion func(t *testing.T, result graph.Result) - -// bufferedResult implements graph.Result over an in-memory row buffer, enabling -// multiple assertion passes over the same result without re-executing the query. -type bufferedResult struct { - rows [][]any - keys []string - mapper graph.ValueMapper - current int - err error -} - -// newBufferedResult exhausts r into memory and returns a replayable result. -// The caller must not use r after this call. -func newBufferedResult(r graph.Result) *bufferedResult { - br := &bufferedResult{mapper: r.Mapper(), current: -1} - for r.Next() { - if br.keys == nil { - br.keys = r.Keys() - } - vals := r.Values() - row := make([]any, len(vals)) - copy(row, vals) - br.rows = append(br.rows, row) - } - br.err = r.Error() - return br -} - -func (b *bufferedResult) Reset() { b.current = -1 } -func (b *bufferedResult) Next() bool { b.current++; return b.current < len(b.rows) } -func (b *bufferedResult) Keys() []string { return b.keys } -func (b *bufferedResult) Mapper() graph.ValueMapper { return b.mapper } -func (b *bufferedResult) Error() error { return b.err } -func (b *bufferedResult) Close() {} - -func (b *bufferedResult) Values() []any { - if b.current < 0 || b.current >= len(b.rows) { - return nil - } - return b.rows[b.current] -} - -func (b *bufferedResult) Scan(targets ...any) error { - return graph.ScanNextResult(b, targets...) -} - -// AssertNoError drains the result and asserts it carries no error. -func AssertNoError() ResultAssertion { - return func(t *testing.T, result graph.Result) { - t.Helper() - for result.Next() { - } - require.NoError(t, result.Error()) - } -} - -// AssertEmpty asserts the result set contains zero rows. -func AssertEmpty() ResultAssertion { - return func(t *testing.T, result graph.Result) { - t.Helper() - br := newBufferedResult(result) - require.NoError(t, br.err) - require.Empty(t, br.rows, "expected empty result but got %d rows", len(br.rows)) - } -} - -// AssertNonEmpty asserts the result set contains at least one row. -func AssertNonEmpty() ResultAssertion { - return func(t *testing.T, result graph.Result) { - t.Helper() - br := newBufferedResult(result) - require.NoError(t, br.err) - require.NotEmpty(t, br.rows, "expected non-empty result set") - } -} - -// AssertRowCount asserts the result set contains exactly n rows. -func AssertRowCount(n int) ResultAssertion { - return func(t *testing.T, result graph.Result) { - t.Helper() - br := newBufferedResult(result) - require.NoError(t, br.err) - require.Len(t, br.rows, n, "unexpected row count") - } -} - -// AssertScalarString asserts the first column of the first row is the given string. -func AssertScalarString(expected string) ResultAssertion { - return func(t *testing.T, result graph.Result) { - t.Helper() - br := newBufferedResult(result) - require.NoError(t, br.err) - require.NotEmpty(t, br.rows, "no rows returned; cannot assert scalar string") - require.Equal(t, expected, br.rows[0][0], "scalar string mismatch") - } -} - -// AssertAtLeastInt64 asserts the first column of the first row is an int64 -// greater than or equal to min. This is suitable for count/aggregate queries -// where the database may contain pre-existing rows alongside the fixture data. -func AssertAtLeastInt64(min int64) ResultAssertion { - return func(t *testing.T, result graph.Result) { - t.Helper() - br := newBufferedResult(result) - require.NoError(t, br.err) - require.NotEmpty(t, br.rows, "no rows returned; cannot assert scalar int64") - val, ok := br.rows[0][0].(int64) - require.True(t, ok, "expected int64 scalar, got %T: %v", br.rows[0][0], br.rows[0][0]) - require.GreaterOrEqual(t, val, min, "scalar int64 below expected minimum") - } -} - -// AssertContainsNodeWithProp asserts that at least one row in the result -// contains a node (in any column) with the given string property value. -func AssertContainsNodeWithProp(key, val string) ResultAssertion { - return func(t *testing.T, result graph.Result) { - t.Helper() - br := newBufferedResult(result) - require.NoError(t, br.err) - require.NotEmpty(t, br.rows, "no rows; cannot check node property %s", key) - for _, row := range br.rows { - for _, rawVal := range row { - var node graph.Node - if br.mapper.Map(rawVal, &node) { - if s, err := node.Properties.Get(key).String(); err == nil && s == val { - return - } - } - } - } - t.Errorf("no result row contains a node with %s = %q", key, val) - } -} - -// AssertExactInt64 asserts the first column of the first row is exactly the given int64 value. -func AssertExactInt64(expected int64) ResultAssertion { - return func(t *testing.T, result graph.Result) { - t.Helper() - br := newBufferedResult(result) - require.NoError(t, br.err) - require.NotEmpty(t, br.rows, "no rows returned; cannot assert scalar int64") - val, ok := br.rows[0][0].(int64) - require.True(t, ok, "expected int64 scalar, got %T: %v", br.rows[0][0], br.rows[0][0]) - require.Equal(t, expected, val, "scalar int64 mismatch") - } -} - -// AssertAll buffers the result once and runs each assertion against the same -// buffered data, resetting the cursor between assertions. -func AssertAll(assertions ...ResultAssertion) ResultAssertion { - return func(t *testing.T, result graph.Result) { - t.Helper() - br := newBufferedResult(result) - for _, a := range assertions { - br.Reset() - a(t, br) - } - } -} diff --git a/cypher/models/pgsql/test/cases.go b/cypher/models/pgsql/test/cases.go deleted file mode 100644 index d66dda9..0000000 --- a/cypher/models/pgsql/test/cases.go +++ /dev/null @@ -1,2065 +0,0 @@ -// Copyright 2026 Specter Ops, Inc. -// -// Licensed under the Apache License, Version 2.0 -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// SPDX-License-Identifier: Apache-2.0 - -package test - -import ( - "slices" - "testing" - - "github.com/specterops/dawgs/graph" -) - -// baseFixture is shared by test cases that only require a small, general-purpose -// graph. It provides two typed nodes connected by a typed edge, plus a node -// carrying both kinds and a variety of property types. -// -// n1 (NodeKind1) --[EdgeKind1]--> n2 (NodeKind2) --[EdgeKind2]--> n3 (NodeKind1+NodeKind2) -var baseFixture = GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("n1", map[string]any{ - "name": "SOME NAME", - "value": 1, - "objectid": "S-1-5-21-1", - "enabled": true, - "hasspn": true, - "pwdlastset": float64(-2), // not -1 or 0, so excluded by typical filters - "functionallevel": "2012", - "system_tags": "admin_tier_0", - "domain": "test.local", - "other": "SOME NAME", - "tid": "tid1", - "selected": true, - "array_value": []any{float64(1), float64(2)}, - "arrayProperty": []any{"DES-CBC-CRC", "DES-CBC-MD5"}, - "distinguishedname": "CN=TEST,DC=example,DC=com", - "samaccountname": "testuser", - "email": "test@example.com", - }, NodeKind1), - NewNodeWithProperties("n2", map[string]any{ - "name": "1234", - "value": 2, - "objectid": "S-1-5-21-2", - "tid": "tid1", - "distinguishedname": "CN=ADMINSDHOLDER,CN=SYSTEM,CN=TEST,DC=example,DC=com", - "samaccountname": "adminuser", - "email": "admin@example.com", - "domain": "other.local", - }, NodeKind2), - NewNodeWithProperties("n3", map[string]any{ - "name": "n3", - "value": 3, - "prop": "a", - }, NodeKind1, NodeKind2), - }, - Edges: []EdgeFixture{ - NewEdgeWithProperties("n1", "n2", EdgeKind1, map[string]any{ - "prop": "a", - "value": 42, - "bool_prop": true, - }), - NewEdge("n2", "n3", EdgeKind2), - }, -} - -// semanticTestCases is the complete list of semantic integration test cases. -var semanticTestCases = func() []SemanticTestCase { - return slices.Concat( - nodesSemanticCases, - stepwiseSemanticCases, - expansionSemanticCases, - aggregationSemanticCases, - multipartSemanticCases, - patternBindingSemanticCases, - deleteSemanticCases, - updateSemanticCases, - quantifiersSemanticCases, - ) -}() - -var nodesSemanticCases = []SemanticTestCase{ - { - Name: "return kind labels for all nodes", - Cypher: `match (n) return labels(n)`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), - }, - { - Name: "filter any node by string property equality", - Cypher: `match (n) where n.name = '1234' return n`, - Fixture: baseFixture, - Assert: AssertContainsNodeWithProp("name", "1234"), - }, - { - Name: "filter a typed node using an inline property map", - Cypher: `match (n:NodeKind1 {name: "SOME NAME"}) return n`, - Fixture: baseFixture, - Assert: AssertContainsNodeWithProp("name", "SOME NAME"), - }, - { - Name: "return all nodes", - Cypher: `match (s) return s`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), - }, - { - Name: "filter nodes matching a kind disjunction", - Cypher: `match (s) where (s:NodeKind1 or s:NodeKind2) return s`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), - }, - { - Name: "cross-product filter where two nodes share a property value", - Cypher: `match (n:NodeKind1), (e) where n.name = e.name return n`, - Fixture: baseFixture, - // n1 (NodeKind1, name="SOME NAME") self-joins because it also exists as e - Assert: AssertNonEmpty(), - }, - { - Name: "filter any node by string property equality (s binding)", - Cypher: `match (s) where s.name = '1234' return s`, - Fixture: baseFixture, - Assert: AssertContainsNodeWithProp("name", "1234"), - }, - { - Name: "filter node where property value appears in a literal list", - Cypher: `match (s) where s.name in ['option 1', 'option 2'] return s`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("a", map[string]any{"name": "option 1"}, NodeKind1), - NewNodeWithProperties("b", map[string]any{"name": "option 2"}, NodeKind2), - NewNodeWithProperties("c", map[string]any{"name": "option 3"}, NodeKind1), - }, - }, - Assert: AssertNonEmpty(), - }, - { - Name: "filter node by string starts-with prefix", - Cypher: `match (s) where s.name starts with '123' return s`, - Fixture: baseFixture, - Assert: AssertContainsNodeWithProp("name", "1234"), - }, - { - Name: "filter node by string ends-with suffix", - Cypher: `match (s) where s.name ends with 'NAME' return s`, - Fixture: baseFixture, - Assert: AssertContainsNodeWithProp("name", "SOME NAME"), - }, - { - Name: "filter node where a property is not null", - Cypher: `match (n) where n.system_tags is not null return n`, - Fixture: baseFixture, - Assert: AssertContainsNodeWithProp("system_tags", "admin_tier_0"), - }, - { - Name: "filter typed node using coalesce with contains predicate", - Cypher: `match (n:NodeKind1) where coalesce(n.system_tags, '') contains 'admin_tier_0' return n`, - Fixture: baseFixture, - Assert: AssertContainsNodeWithProp("system_tags", "admin_tier_0"), - }, - { - Name: "filter typed node by array property size", - Cypher: `match (n:NodeKind1) where size(n.array_value) > 0 return n`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), - }, - { - Name: "filter typed node where array property overlaps a literal list", - Cypher: `match (n:NodeKind1) where ['DES-CBC-CRC', 'DES-CBC-MD5', 'RC4-HMAC-MD5'] in n.arrayProperty return n`, - Fixture: baseFixture, - // n1.arrayProperty contains DES-CBC-CRC and DES-CBC-MD5, so overlap matches - Assert: AssertNonEmpty(), - }, - { - Name: "filter typed node where array property contains one of several scalar values", - Cypher: `match (u:NodeKind1) where 'DES-CBC-CRC' in u.arrayProperty or 'DES-CBC-MD5' in u.arrayProperty return u`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), - }, - { - Name: "filter node carrying two kind labels simultaneously", - Cypher: `match (s) where s:NodeKind1 and s:NodeKind2 return s`, - Fixture: baseFixture, - // n3 has both NodeKind1 and NodeKind2 - Assert: AssertContainsNodeWithProp("name", "n3"), - }, - { - Name: "cross-product filter where two nodes match different typed properties", - Cypher: `match (s), (e) where s.name = '1234' and e.other = 1234 return s`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("a", map[string]any{"name": "1234"}, NodeKind1), - NewNodeWithProperties("b", map[string]any{"other": 1234}, NodeKind2), - }, - }, - Assert: AssertNonEmpty(), - }, - { - Name: "paginate results using SKIP and LIMIT", - Cypher: `match (n) return n skip 5 limit 10`, - Fixture: baseFixture, - // Behaviour depends on total node count, just assert no error - Assert: AssertNoError(), - }, - { - Name: "order results by node ID descending", - Cypher: `match (s) return s order by id(s) desc`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), - }, - { - Name: "filter isolated nodes with no adjacent edges", - Cypher: `match (s) where not (s)-[]-() return s`, - Fixture: baseFixture, - // Cannot predict with a shared database; just verify the query runs - Assert: AssertNoError(), - }, - { - Name: "filter nodes where node ID appears in another node's array property", - Cypher: `match (s), (e) where id(s) in e.captured_ids return s, e`, - // Cannot assert non-empty because fixture node IDs are unknown at - // definition time; just verify the query executes without error - Fixture: baseFixture, - Assert: AssertNoError(), - }, - { - Name: "filter typed node with starts-with using a function call as the prefix", - Cypher: `match (n:NodeKind1) where n.distinguishedname starts with toUpper('admin') return n`, - Fixture: baseFixture, - Assert: AssertEmpty(), - }, - { - Name: "optional match returns results even when the pattern may be absent", - Cypher: `optional match (n:NodeKind1) return n`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), - }, - { - Name: "double-negation filter selects nodes where a property is null", - Cypher: `match (n) where not n.property is not null return n`, - Fixture: baseFixture, - // n1, n2, n3 have no 'property' key, so all qualify - Assert: AssertNonEmpty(), - }, - { - Name: "filter nodes where array property equals an empty array literal", - Cypher: `match (s) where s.prop = [] return s`, - Fixture: baseFixture, - // None of our nodes have prop = [], n3 has prop = "a" - Assert: AssertNoError(), - }, - // ---- contains ------------------------------------------------------- - { - Name: "filter node by string contains predicate", - Cypher: `match (s) where s.name contains '123' return s`, - Fixture: baseFixture, - // n2.name = "1234" contains "123" - Assert: AssertContainsNodeWithProp("name", "1234"), - }, - // ---- negated string predicates -------------------------------------- - { - Name: "filter node using negated starts-with predicate", - Cypher: `match (s) where not s.name starts with 'XYZ' return s`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), - }, - { - Name: "filter node using negated contains predicate", - Cypher: `match (s) where not s.name contains 'XYZ' return s`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), - }, - { - Name: "filter node using negated ends-with predicate", - Cypher: `match (s) where not s.name ends with 'XYZ' return s`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), - }, - // ---- dynamic (variable-to-variable) string predicates --------------- - { - Name: "filter node where string property starts with another property (dynamic)", - Cypher: `match (s) where s.name starts with s.other return s`, - Fixture: baseFixture, - // n1: name="SOME NAME", other="SOME NAME" — name starts with itself - Assert: AssertNonEmpty(), - }, - { - Name: "filter node where string property contains another property (dynamic)", - Cypher: `match (s) where s.name contains s.other return s`, - Fixture: baseFixture, - // n1: name="SOME NAME", other="SOME NAME" — name contains itself - Assert: AssertNonEmpty(), - }, - { - Name: "filter node where string property ends with another property (dynamic)", - Cypher: `match (s) where s.name ends with s.other return s`, - Fixture: baseFixture, - // n1: name="SOME NAME", other="SOME NAME" — name ends with itself - Assert: AssertNonEmpty(), - }, - // ---- IS NULL -------------------------------------------------------- - { - Name: "filter nodes where a datetime property is null", - Cypher: `match (s) where s.created_at is null return s`, - Fixture: baseFixture, - // No fixture node has created_at set → property is null for all three - Assert: AssertNonEmpty(), - }, - // ---- arithmetic in projection --------------------------------------- - { - Name: "project an arithmetic expression on a node property", - Cypher: `match (s) return s.value + 1`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), - }, - // ---- datetime().epochseconds ---------------------------------------- - { - Name: "filter typed node using datetime arithmetic against epoch seconds", - Cypher: `match (u:NodeKind1) where u.pwdlastset < (datetime().epochseconds - (365 * 86400)) and not u.pwdlastset IN [-1.0, 0.0] return u limit 100`, - Fixture: baseFixture, - // n1: pwdlastset=-2; (-2 < current_epoch-31536000) is true; -2 not in {-1,0} is true - Assert: AssertNonEmpty(), - }, - // ---- element in property array -------------------------------------- - { - Name: "filter node where a scalar value appears in an array property", - Cypher: `match (n) where 1 in n.array return n`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("a", map[string]any{"array": []any{float64(1), float64(2), float64(3)}}, NodeKind1), - }, - }, - Assert: AssertNonEmpty(), - }, - // ---- coalesce equality forms ------------------------------------------------ - { - Name: "filter node using coalesce equality on a named property", - Cypher: `match (n) where coalesce(n.name, '') = '1234' return n`, - Fixture: baseFixture, - // n2.name='1234'; coalesce('1234','')='1234' → matches - Assert: AssertContainsNodeWithProp("name", "1234"), - }, - { - Name: "filter typed node using three-argument coalesce equality against an integer", - Cypher: `match (n:NodeKind1) where coalesce(n.a, n.b, 1) = 1 return n`, - Fixture: baseFixture, - // n1 and n3 have no 'a' or 'b' → coalesce(null,null,1)=1 → matches both - Assert: AssertNonEmpty(), - }, - { - Name: "filter typed node using two-property coalesce that resolves to null", - Cypher: `match (n:NodeKind1) where coalesce(n.a, n.b) = 1 return n`, - Fixture: baseFixture, - Assert: AssertNoError(), - }, - { - Name: "filter typed node with coalesce on the right-hand side of equality", - Cypher: `match (n:NodeKind1) where 1 = coalesce(n.a, n.b) return n`, - Fixture: baseFixture, - Assert: AssertNoError(), - }, - { - Name: "filter typed node with coalesce equality on both sides", - Cypher: `match (n:NodeKind1) where coalesce(n.name, '') = coalesce(n.migrated_name, '') return n`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("a", map[string]any{"name": "mirror", "migrated_name": "mirror"}, NodeKind1), - NewNodeWithProperties("b", map[string]any{"name": "differ"}, NodeKind1), - }, - }, - Assert: AssertNonEmpty(), - }, - // ---- arithmetic in WHERE and projection ------------------------------------- - { - Name: "filter node using an arithmetic expression in the WHERE clause", - Cypher: `match (s) where s.value + 2 / 3 > 10 return s`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("a", map[string]any{"value": 20}, NodeKind1), - NewNodeWithProperties("b", map[string]any{"value": 1}, NodeKind2), - }, - }, - // Integer division: 2/3=0, so s.value+0>10 → a.value=20>10 matches - Assert: AssertNonEmpty(), - }, - { - Name: "project a compound arithmetic expression dividing a shifted property", - Cypher: `match (s) return (s.value + 1) / 3`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), - }, - // ---- toLower equality with DISTINCT ----------------------------------------- - { - Name: "filter node using toLower equality and return distinct results", - Cypher: `match (s) where toLower(s.name) = '1234' return distinct s`, - Fixture: baseFixture, - // n2.name='1234'; toLower('1234')='1234' → matches - Assert: AssertContainsNodeWithProp("name", "1234"), - }, - { - Name: "filter typed node using toLower contains with a compound AND predicate", - Cypher: `match (n:NodeKind1) where n:NodeKind1 and toLower(n.tenantid) contains 'myid' and n.system_tags contains 'tag' return n`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("a", map[string]any{"tenantid": "MyID-Corp", "system_tags": "tag_admin"}, NodeKind1), - }, - }, - Assert: AssertNonEmpty(), - }, - // ---- toUpper in string predicates ------------------------------------------- - { - Name: "filter typed node where a property contains a toUpper() result", - Cypher: `match (n:NodeKind1) where n.distinguishedname contains toUpper('test') return n`, - Fixture: baseFixture, - // n1.distinguishedname='CN=TEST,DC=example,DC=com'; toUpper('test')='TEST' → contains - Assert: AssertNonEmpty(), - }, - { - Name: "filter typed node where a property equals a toUpper() result (no match)", - Cypher: `match (n:NodeKind1) where n.distinguishedname = toUpper('admin') return n`, - Fixture: baseFixture, - // toUpper('admin')='ADMIN'; no node has distinguishedname='ADMIN' - Assert: AssertEmpty(), - }, - { - Name: "filter typed node where a property ends with a toUpper() result (no match)", - Cypher: `match (n:NodeKind1) where n.distinguishedname ends with toUpper('com') return n`, - Fixture: baseFixture, - // toUpper('com')='COM'; distinguishedname ends with 'com' not 'COM' - Assert: AssertEmpty(), - }, - // ---- toString / toInt in membership ----------------------------------------- - { - Name: "filter typed node where toString of a property appears in a literal list", - Cypher: `match (n:NodeKind1) where toString(n.functionallevel) in ['2008 R2', '2012', '2008', '2003'] return n`, - Fixture: baseFixture, - // n1.functionallevel='2012'; toString('2012')='2012' → in list → matches - Assert: AssertNonEmpty(), - }, - { - Name: "filter typed node where toInt of a property appears in a literal integer list", - Cypher: `match (n:NodeKind1) where toInt(n.value) in [1, 2, 3, 4] return n`, - Fixture: baseFixture, - // n1.value=1; toInt(1)=1 → in [1,2,3,4] → matches - Assert: AssertNonEmpty(), - }, - // ---- datetime().epochmillis ------------------------------------------------- - { - Name: "filter typed node using datetime arithmetic against epoch milliseconds", - Cypher: `match (u:NodeKind1) where u.pwdlastset < (datetime().epochmillis - 86400000) and not u.pwdlastset IN [-1.0, 0.0] return u limit 100`, - Fixture: baseFixture, - // n1.pwdlastset=-2; current epochmillis ≫ 86400000; -2 < big_number → true; -2 ∉ {-1,0} → true - Assert: AssertNonEmpty(), - }, - // ---- date / time function comparisons --------------------------------------- - { - Name: "filter node where a datetime property equals the current date", - Cypher: `match (s) where s.created_at = date() return s`, - Fixture: baseFixture, - // No fixture node has created_at; query runs but returns nothing - Assert: AssertNoError(), - }, - { - Name: "filter node where a property equals date minus a duration", - Cypher: `match (s) where s.created_at = date() - duration('P1D') return s`, - Fixture: baseFixture, - Assert: AssertNoError(), - }, - { - Name: "filter node where a property equals date plus a duration string", - Cypher: `match (s) where s.created_at = date() + duration('4 hours') return s`, - Fixture: baseFixture, - Assert: AssertNoError(), - }, - { - Name: "filter node where a property equals a literal date value", - Cypher: `match (s) where s.created_at = date('2023-4-4') return s`, - Fixture: baseFixture, - Assert: AssertNoError(), - }, - { - Name: "filter node where a datetime property equals the current datetime", - Cypher: `match (s) where s.created_at = datetime() return s`, - Fixture: baseFixture, - Assert: AssertNoError(), - }, - { - Name: "filter node where a property equals a literal datetime value", - Cypher: `match (s) where s.created_at = datetime('2019-06-01T18:40:32.142+0100') return s`, - Fixture: baseFixture, - Assert: AssertNoError(), - }, - { - Name: "filter node where a property equals the current local datetime", - Cypher: `match (s) where s.created_at = localdatetime() return s`, - Fixture: baseFixture, - Assert: AssertNoError(), - }, - { - Name: "filter node where a property equals a literal local datetime value", - Cypher: `match (s) where s.created_at = localdatetime('2019-06-01T18:40:32.142') return s`, - Fixture: baseFixture, - Assert: AssertNoError(), - }, - { - Name: "filter node where a property equals the current local time", - Cypher: `match (s) where s.created_at = localtime() return s`, - Fixture: baseFixture, - Assert: AssertNoError(), - }, - { - Name: "filter node where a property equals a literal local time value", - Cypher: `match (s) where s.created_at = localtime('4:4:4') return s`, - Fixture: baseFixture, - Assert: AssertNoError(), - }, - // ---- negation / NOT forms --------------------------------------------------- - { - Name: "filter node using a negated parenthesized equality predicate", - Cypher: `match (s) where not (s.name = '123') return s`, - Fixture: baseFixture, - // n1.name='SOME NAME', n2.name='1234', n3.name='n3'; none equals '123' - Assert: AssertNonEmpty(), - }, - { - Name: "filter node using negated 2-hop path existence", - Cypher: `match (s) where not (s)-[]->()-[]->() return s`, - Fixture: baseFixture, - Assert: AssertNoError(), - }, - { - Name: "filter node using negated directed edge pattern with property constraints", - Cypher: `match (s) where not (s)-[{prop: 'a'}]->({name: 'n3'}) return s`, - Fixture: baseFixture, - // n1 has edge {prop='a'} to n2 (name='1234'), not to a node named 'n3' → pattern absent for n1 - Assert: AssertNonEmpty(), - }, - { - Name: "filter node using negated incoming edge pattern with property constraints", - Cypher: `match (s) where not (s)<-[{prop: 'a'}]-({name: 'n3'}) return s`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), - }, - { - Name: "return id of node where negated kind filter removes typed results", - Cypher: `match (s) where not (s)-[]-() return id(s)`, - Fixture: baseFixture, - Assert: AssertNoError(), - }, - // ---- id() in integer literal list ------------------------------------------- - { - Name: "filter node where id appears in a literal integer list", - Cypher: `match (s) where id(s) in [1, 2, 3, 4] return s`, - Fixture: baseFixture, - // Node IDs are assigned by the database; we cannot predict them at definition time - Assert: AssertNoError(), - }, - // ---- three-way OR membership in array property ------------------------------ - { - Name: "filter typed node where array property contains one of three scalar values", - Cypher: `match (u:NodeKind1) where 'DES-CBC-CRC' in u.arrayProperty or 'DES-CBC-MD5' in u.arrayProperty or 'RC4-HMAC-MD5' in u.arrayProperty return u`, - Fixture: baseFixture, - // n1.arrayProperty=['DES-CBC-CRC','DES-CBC-MD5'] → first OR branch matches - Assert: AssertNonEmpty(), - }, - { - Name: "filter typed node where a scalar appears in an array property concatenated with a literal list", - Cypher: `match (n:NodeKind1) where '1' in n.array_prop + ['1', '2'] return n`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("a", map[string]any{"array_prop": []any{"x", "y"}}, NodeKind1), - }, - }, - // ['x','y'] + ['1','2'] = ['x','y','1','2']; '1' is in result → matches - Assert: AssertNonEmpty(), - }, - // ---- empty-array comparison variants ---------------------------------------- - { - Name: "filter node where an empty array literal equals a property (reversed operands)", - Cypher: `match (s) where [] = s.prop return s`, - Fixture: baseFixture, - // n3.prop='a' ≠ []; no node has prop=[] → empty result, no error - Assert: AssertNoError(), - }, - { - Name: "filter node where a property is not equal to an empty array", - Cypher: `match (s) where s.prop <> [] return s`, - Fixture: baseFixture, - // n3.prop='a' ≠ [] → matches; others have no prop (null) which is excluded - Assert: AssertNonEmpty(), - }, - { - Name: "filter node using negated equality to an empty array", - Cypher: `match (s) where not s.prop = [] return s`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), - }, - // ---- string concatenation in WHERE ------------------------------------------ - { - Name: "filter typed node using property equality with literal-then-property concatenation", - Cypher: `match (n:NodeKind1) match (m:NodeKind2) where m.distinguishedname = 'CN=ADMINSDHOLDER,CN=SYSTEM,' + n.distinguishedname return m`, - Fixture: baseFixture, - // 'CN=ADMINSDHOLDER,CN=SYSTEM,' + 'CN=TEST,DC=example,DC=com' matches n2.distinguishedname - Assert: AssertNonEmpty(), - }, - { - Name: "filter typed node using property equality with property-then-literal concatenation", - Cypher: `match (n:NodeKind1) match (m:NodeKind2) where m.distinguishedname = n.distinguishedname + 'CN=ADMINSDHOLDER,CN=SYSTEM,' return m`, - Fixture: baseFixture, - // concat yields different string; n2.distinguishedname does not match → empty - Assert: AssertNoError(), - }, - { - Name: "filter typed node using property equality with two literal strings concatenated", - Cypher: `match (n:NodeKind1) match (m:NodeKind2) where m.distinguishedname = '1' + '2' return m`, - Fixture: baseFixture, - // '1'+'2'='12'; no node has that distinguishedname → empty - Assert: AssertNoError(), - }, - // ---- multiple ORDER BY columns ---------------------------------------------- - { - Name: "order results by two properties with mixed sort directions", - Cypher: `match (s) return s order by s.name, s.other_prop desc`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), - }, - // ---- cross-product with aliased property projection ------------------------- - { - Name: "return source and an aliased property from an unrelated node in a cross-product", - Cypher: `match (s), (e) where s.name = 'n1' return s, e.name as othername`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("a", map[string]any{"name": "n1"}, NodeKind1), - NewNodeWithProperties("b", map[string]any{"name": "n2"}, NodeKind2), - }, - }, - Assert: AssertNonEmpty(), - }, - // ---- cross-product with OR predicate ---------------------------------------- - { - Name: "filter cross-product where either node satisfies a different property predicate", - Cypher: `match (s), (e) where s.name = '1234' or e.other = 1234 return s`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("a", map[string]any{"name": "1234"}, NodeKind1), - NewNodeWithProperties("b", map[string]any{"other": 1234}, NodeKind2), - }, - }, - Assert: AssertNonEmpty(), - }, - // ---- double optional match -------------------------------------------------- - { - Name: "two sequential optional matches where only the anchor node is required", - Cypher: `match (n:NodeKind1) optional match (m:NodeKind2) where m.distinguishedname starts with n.distinguishedname optional match (o:NodeKind2) where o.distinguishedname <> n.distinguishedname return n, m, o`, - Fixture: baseFixture, - // n1 is always returned; m and o may or may not match - Assert: AssertNonEmpty(), - }, - // Currently fails - // - // { - // Name: "optional match with string concatenation in the filter joining two nodes", - // Cypher: `match (n:NodeKind1) optional match (m:NodeKind2) where m.distinguishedname = n.unknown + m.unknown return n, m`, - // Fixture: baseFixture, - // // n1 has no 'unknown' → null+null; no m matches → optional returns null for m; n returned - // Assert: AssertNonEmpty(), - // }, - // ---- complex hasspn / not-ends-with filter ---------------------------------- - { - Name: "filter typed node with compound hasspn, enabled, and not-ends-with checks", - Cypher: `match (u:NodeKind1) where u.hasspn = true and u.enabled = true and not '-502' ends with u.objectid and not coalesce(u.gmsa, false) = true and not coalesce(u.msa, false) = true return u limit 10`, - Fixture: baseFixture, - // n1: hasspn=true, enabled=true, objectid='S-1-5-21-1' → '-502' does not end with that → not false=true; gmsa/msa absent → coalesce false ≠ true → matches - Assert: AssertNonEmpty(), - }, - // ---- non-empty array literal equality --------------------------------------- - // - // Broken test case - // - // { - // Name: "filter node where a property equals a non-empty integer array literal", - // Cypher: `match (s) where s.prop = [1, 2, 3] return s`, - // Fixture: GraphFixture{ - // Nodes: []NodeFixture{ - // NewNodeWithProperties("a", map[string]any{"prop": []any{float64(1), float64(2), float64(3)}}, NodeKind1), - // NewNodeWithProperties("b", map[string]any{"prop": "other"}, NodeKind2), - // }, - // }, - // Assert: AssertNonEmpty(), - // }, -} - -var stepwiseSemanticCases = []SemanticTestCase{ - { - Name: "return all edges", - Cypher: `match ()-[r]->() return r`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), - }, - { - Name: "filter edges by type() string comparison", - Cypher: `match ()-[r]->() where type(r) = 'EdgeKind1' return r`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), - }, - { - Name: "count edges of a specific kind", - Cypher: `match ()-[r:EdgeKind1]->() return count(r) as the_count`, - Fixture: baseFixture, - Assert: AssertAtLeastInt64(1), - }, - { - Name: "count typed edges reaching a node matching an inline property map", - Cypher: `match ()-[r:EdgeKind1]->({name: "123"}) return count(r) as the_count`, - Fixture: baseFixture, - // No target node has name "123" in our fixture; count returns exactly 0 - Assert: AssertExactInt64(0), - }, - { - Name: "traverse one edge filtering both endpoints by property", - Cypher: `match (s)-[r]->(e) where s.name = '123' and e.name = '321' return s, r, e`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("s", map[string]any{"name": "123"}, NodeKind1), - NewNodeWithProperties("e", map[string]any{"name": "321"}, NodeKind2), - }, - Edges: []EdgeFixture{NewEdge("s", "e", EdgeKind1)}, - }, - Assert: AssertNonEmpty(), - }, - { - Name: "return source node and outgoing edge filtered by source property", - Cypher: `match (n)-[r]->() where n.name = '123' return n, r`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("a", map[string]any{"name": "123"}, NodeKind1), - NewNode("b", NodeKind2), - }, - Edges: []EdgeFixture{NewEdge("a", "b", EdgeKind1)}, - }, - Assert: AssertNonEmpty(), - }, - { - Name: "filter edges by a numeric property value", - Cypher: `match ()-[r]->() where r.value = 42 return r`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), // baseFixture edge n1->n2 has value=42 - }, - { - Name: "filter edges by a boolean property", - Cypher: `match ()-[r]->() where r.bool_prop return r`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), // baseFixture edge n1->n2 has bool_prop=true - }, - { - Name: "one-hop traversal filtering where source and target are not the same node", - Cypher: `match (n1)-[]->(n2) where n1 <> n2 return n2`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), - }, - { - Name: "traverse between typed endpoints with edge kind alternatives", - Cypher: `match (s:NodeKind1)-[r:EdgeKind1|EdgeKind2]->(e:NodeKind2) return s.name, e.name`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), - }, - { - Name: "traverse between multi-kind endpoints using edge kind alternatives", - Cypher: `match (s:NodeKind1:NodeKind2)-[r:EdgeKind1|EdgeKind2]->(e:NodeKind2:NodeKind1) return s.name, e.name`, - Fixture: baseFixture, - // n3 has both kinds and connects to nobody; need a direct n3->n3 edge or different fixture - Assert: AssertNoError(), - }, - // ---- reversed type comparison --------------------------------------- - { - Name: "filter edges with reversed type() equality (literal on left)", - Cypher: `match ()-[r]->() where 'EdgeKind1' = type(r) return r`, - Fixture: baseFixture, - // baseFixture n1->n2 edge is EdgeKind1 - Assert: AssertNonEmpty(), - }, - // ---- incoming edge direction ---------------------------------------- - { - Name: "traverse incoming edges filtering by kind alternatives", - Cypher: `match (s)<-[r:EdgeKind1|EdgeKind2]-(e) return s.name, e.name`, - Fixture: baseFixture, - // n1->n2(EK1): incoming to n2 from n1; n2->n3(EK2): incoming to n3 from n2 - Assert: AssertNonEmpty(), - }, - // ---- diamond (two edges converging on one node) --------------------- - { - Name: "diamond pattern where two edges converge on one node", - Cypher: `match ()-[e0]->(n)<-[e1]-() return e0, n, e1`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNode("a", NodeKind1), - NewNode("b", NodeKind1), - NewNode("m", NodeKind2), - }, - Edges: []EdgeFixture{ - NewEdge("a", "m", EdgeKind1), - NewEdge("b", "m", EdgeKind2), - }, - }, - // both edges converge on m - Assert: AssertNonEmpty(), - }, - // ---- shared-node forward chain -------------------------------------- - { - Name: "shared-node forward chain with two outgoing edges", - Cypher: `match ()-[e0]->(n)-[e1]->() return e0, n, e1`, - Fixture: baseFixture, - // n1->n2(EK1)->n3(EK2): e0=n1->n2, n=n2, e1=n2->n3 - Assert: AssertNonEmpty(), - }, - // ---- edge inequality ------------------------------------------------ - { - Name: "two-hop chain filtering where the two traversed edges are not equal", - Cypher: `match ()-[r]->()-[e]->(n) where r <> e return n`, - Fixture: baseFixture, - // r=n1->n2, e=n2->n3; they are different edges so r <> e holds, n=n3 - Assert: AssertNonEmpty(), - }, - // ---- unrelated cross-products with edges -------------------------------- - { - Name: "cross-product of an unrelated node and an edge", - Cypher: `match (n), ()-[r]->() return n, r`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), - }, - { - Name: "cross-product of two independent edge traversals", - Cypher: `match ()-[r]->(), ()-[e]->() return r, e`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), - }, - // ---- node with both an incoming and an outgoing edge ------------------- - // Pattern: ()<-[e0]-(n)<-[e1]-() - // - ()<-[e0]-(n) means n is the SOURCE of e0 (n has an outgoing edge) - // - (n)<-[e1]-() means n is the TARGET of e1 (n has an incoming edge) - // n must therefore have at least one outgoing edge AND at least one - // incoming edge. The chain a→mid→b gives mid exactly that. - { - Name: "pattern where a middle node has both an outgoing and an incoming edge", - Cypher: `match ()<-[e0]-(n)<-[e1]-() return e0, n, e1`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNode("a", NodeKind1), - NewNode("mid", NodeKind2), - NewNode("b", NodeKind1), - }, - Edges: []EdgeFixture{ - NewEdge("a", "mid", EdgeKind1), // incoming to mid → satisfies (n)<-[e1]-() - NewEdge("mid", "b", EdgeKind2), // outgoing from mid → satisfies ()<-[e0]-(n) - }, - }, - // e0 = mid→b, n = mid, e1 = a→mid - Assert: AssertNonEmpty(), - }, - // ---- negated boolean edge property --------------------------------------- - // The translator emits: NOT ((e0.properties ->> 'property'))::bool - // SQL three-valued logic: NOT NULL = NULL (not TRUE), so an absent key - // does NOT satisfy the predicate — the row is discarded. The property - // must be present and explicitly false for NOT false = true to hold. - { - Name: "traverse edge where the edge property flag is explicitly false", - Cypher: `match (s)-[r]->(e) where s.name = '123' and e:NodeKind1 and not r.property return s, r, e`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("s", map[string]any{"name": "123"}, NodeKind2), - NewNode("e", NodeKind1), - }, - // property=false → NOT false = true → row included - Edges: []EdgeFixture{NewEdgeWithProperties("s", "e", EdgeKind1, map[string]any{"property": false})}, - }, - Assert: AssertNonEmpty(), - }, - // ---- labels() and type() in the RETURN projection ----------------------- - { - Name: "return id, labels, and type from an edge traversal with a numeric id filter", - Cypher: `match (s)-[r:EdgeKind1]->(e) where not (s.system_tags contains 'admin_tier_0') and id(e) = 1 return id(s), labels(s), id(r), type(r)`, - Fixture: baseFixture, - // id(e)=1 is unlikely to match fixture data; query validates translation - Assert: AssertNoError(), - }, - // ---- chained edges with aliased property projections -------------------- - { - Name: "traverse two chained typed edges and return aliased endpoint properties", - Cypher: `match (s)-[:EdgeKind1|EdgeKind2]->(e)-[:EdgeKind1]->() return s.name as s_name, e.name as e_name`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("a", map[string]any{"name": "src"}, NodeKind1), - NewNodeWithProperties("b", map[string]any{"name": "mid"}, NodeKind2), - NewNode("c", NodeKind1), - }, - Edges: []EdgeFixture{ - NewEdge("a", "b", EdgeKind1), - NewEdge("b", "c", EdgeKind1), - }, - }, - Assert: AssertNonEmpty(), - }, - // ---- multi-OR name filter on a typed source node ----------------------- - { - Name: "filter typed source node by four alternative name values with OR", - Cypher: `match (n:NodeKind1)-[r]->() where n.name = '123' or n.name = '321' or n.name = '222' or n.name = '333' return n, r`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("a", map[string]any{"name": "123"}, NodeKind1), - NewNode("b", NodeKind2), - }, - Edges: []EdgeFixture{NewEdge("a", "b", EdgeKind1)}, - }, - Assert: AssertNonEmpty(), - }, - // ---- path binding with array membership and size check ------------------ - { - Name: "bind a one-hop typed path filtered by array membership or empty array size", - Cypher: `match p = (:NodeKind1)-[:EdgeKind1|EdgeKind2]->(c:NodeKind2) where '123' in c.prop2 or '243' in c.prop2 or size(c.prop2) = 0 return p limit 10`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNode("src", NodeKind1), - NewNodeWithProperties("dst", map[string]any{"prop2": []any{}}, NodeKind2), - }, - Edges: []EdgeFixture{NewEdge("src", "dst", EdgeKind1)}, - }, - // dst.prop2=[] → size=0 → matches - Assert: AssertNonEmpty(), - }, -} - -var expansionSemanticCases = []SemanticTestCase{ - { - Name: "unbounded variable-length traversal returning both endpoints", - Cypher: `match (n)-[*..]->(e) return n, e`, - Fixture: baseFixture, - // n1->n2, n2->n3, n1->n3 (via n2) are all reachable - Assert: AssertNonEmpty(), - }, - { - Name: "variable-length traversal bounded to depth 1–2", - Cypher: `match (n)-[*1..2]->(e) return n, e`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), - }, - { - Name: "variable-length traversal bounded to depth 3–5 (expect empty with shallow fixture)", - Cypher: `match (n)-[*3..5]->(e) return n, e`, - Fixture: baseFixture, - // base fixture only has 2-hop paths; depth 3+ should be empty for our data - Assert: AssertNoError(), - }, - { - Name: "bind unbounded path variable reaching a typed endpoint", - Cypher: `match p = (n)-[*..]->(e:NodeKind1) return p`, - Fixture: baseFixture, - // n2->n3 where n3 is NodeKind1; n1->n2->n3 also - Assert: AssertNonEmpty(), - }, - { - Name: "unbounded traversal from a named source to a typed endpoint", - Cypher: `match (n)-[*..]->(e:NodeKind1) where n.name = 'n1' return e`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("n1", map[string]any{"name": "n1"}, NodeKind1), - NewNodeWithProperties("n2", map[string]any{"name": "n2"}, NodeKind2), - NewNodeWithProperties("n3", map[string]any{"name": "n3"}, NodeKind1), - }, - Edges: []EdgeFixture{ - NewEdge("n1", "n2", EdgeKind1), - NewEdge("n2", "n3", EdgeKind2), - }, - }, - Assert: AssertContainsNodeWithProp("name", "n3"), - }, - { - Name: "unbounded traversal filtering every traversed edge by a property", - Cypher: `match (n)-[r*..]->(e:NodeKind1) where n.name = 'n1' and r.prop = 'a' return e`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("n1", map[string]any{"name": "n1"}, NodeKind1), - NewNodeWithProperties("n2", map[string]any{"name": "n2"}, NodeKind2), - NewNodeWithProperties("n3", map[string]any{"name": "n3"}, NodeKind1), - }, - Edges: []EdgeFixture{ - NewEdgeWithProperties("n1", "n2", EdgeKind1, map[string]any{"prop": "a"}), - NewEdgeWithProperties("n2", "n3", EdgeKind2, map[string]any{"prop": "a"}), - }, - }, - Assert: AssertContainsNodeWithProp("name", "n3"), - }, - { - Name: "bind path variable for unbounded traversal between typed endpoints", - Cypher: `match p = (s:NodeKind1)-[*..]->(e:NodeKind2) return p`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), - }, - // ---- incoming with depth range ---------------------------- - { - Name: "bounded incoming variable-length traversal with depth range 2–5", - Cypher: `match (n)<-[*2..5]-(e) return n, e`, - Fixture: baseFixture, - // n1->n2->n3: going backward from n3, n1 is reachable at depth 2 - Assert: AssertNonEmpty(), - }, - // ---- followed by a single step ---------------------------- - { - Name: "unbounded expansion followed by a single fixed step", - Cypher: `match (n)-[*..]->(e:NodeKind1)-[]->(l) where n.name = 'start' return l`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("start", map[string]any{"name": "start"}, NodeKind2), - NewNodeWithProperties("mid", map[string]any{"name": "mid"}, NodeKind1), - NewNodeWithProperties("leaf", map[string]any{"name": "leaf"}, NodeKind2), - }, - Edges: []EdgeFixture{ - NewEdge("start", "mid", EdgeKind1), - NewEdge("mid", "leaf", EdgeKind2), - }, - }, - // start -[EK1]-> mid(NK1) -[EK2]-> leaf: reaches mid, one step to leaf - Assert: AssertContainsNodeWithProp("name", "leaf"), - }, - // ---- step followed by ------------------------------------- - { - Name: "fixed step followed by a bounded variable-length expansion", - Cypher: `match (n)-[]->(e:NodeKind1)-[*2..3]->(l) where n.name = 'start' return l`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("start", map[string]any{"name": "start"}, NodeKind2), - NewNodeWithProperties("mid", map[string]any{"name": "mid"}, NodeKind1), - NewNodeWithProperties("hop1", map[string]any{"name": "hop1"}, NodeKind2), - NewNodeWithProperties("hop2", map[string]any{"name": "hop2"}, NodeKind2), - }, - Edges: []EdgeFixture{ - NewEdge("start", "mid", EdgeKind1), - NewEdge("mid", "hop1", EdgeKind2), - NewEdge("hop1", "hop2", EdgeKind2), - }, - }, - // one step to mid(NK1), then 2 hops to hop2 - Assert: AssertContainsNodeWithProp("name", "hop2"), - }, - // ---- expansion returning the source node (not the destination) ---------- - { - Name: "unbounded expansion to a typed endpoint returning the source node", - Cypher: `match (n)-[*..]->(e:NodeKind1) where n.name = 'n2' return n`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("n2", map[string]any{"name": "n2"}, NodeKind2), - NewNodeWithProperties("n3", map[string]any{"name": "n3"}, NodeKind1), - }, - Edges: []EdgeFixture{NewEdge("n2", "n3", EdgeKind1)}, - }, - // n2 reaches n3(NK1); return n (=n2) - Assert: AssertContainsNodeWithProp("name", "n2"), - }, - // ---- bounded expansion followed by a fixed step ------------------------- - { - Name: "bounded variable-length expansion followed by a single fixed step", - Cypher: `match (n)-[*2..3]->(e:NodeKind1)-[]->(l) where n.name = 'n1' return l`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("n1", map[string]any{"name": "n1"}, NodeKind2), - NewNodeWithProperties("hop", map[string]any{"name": "hop"}, NodeKind2), - NewNodeWithProperties("mid", map[string]any{"name": "mid"}, NodeKind1), - NewNodeWithProperties("leaf", map[string]any{"name": "leaf"}, NodeKind2), - }, - Edges: []EdgeFixture{ - NewEdge("n1", "hop", EdgeKind1), - NewEdge("hop", "mid", EdgeKind2), - NewEdge("mid", "leaf", EdgeKind1), - }, - }, - // 2 hops: n1→hop→mid(NK1); one fixed step: mid→leaf → return leaf - Assert: AssertContainsNodeWithProp("name", "leaf"), - }, - // ---- two variable-length segments with a typed fixed step between -------- - { - Name: "two unbounded expansions joined through a typed fixed step", - Cypher: `match (n)-[*..]->(e)-[:EdgeKind1|EdgeKind2]->()-[*..]->(l) where n.name = 'n1' and e.name = 'n2' return l`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("n1", map[string]any{"name": "n1"}, NodeKind1), - NewNodeWithProperties("n2", map[string]any{"name": "n2"}, NodeKind2), - NewNode("bridge", NodeKind2), - NewNodeWithProperties("leaf", map[string]any{"name": "leaf"}, NodeKind1), - }, - Edges: []EdgeFixture{ - NewEdge("n1", "n2", EdgeKind1), - NewEdge("n2", "bridge", EdgeKind2), - NewEdge("bridge", "leaf", EdgeKind1), - }, - }, - Assert: AssertContainsNodeWithProp("name", "leaf"), - }, - // ---- split() function in a WHERE predicate on an expansion endpoint ----- - { - Name: "bind expansion path filtered by a split() membership check on the endpoint", - Cypher: `match p = (:NodeKind1)-[:EdgeKind1*1..]->(n:NodeKind2) where 'admin_tier_0' in split(n.system_tags, ' ') return p limit 1000`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNode("src", NodeKind1), - NewNodeWithProperties("dst", map[string]any{"system_tags": "admin_tier_0 extra_tag"}, NodeKind2), - }, - Edges: []EdgeFixture{NewEdge("src", "dst", EdgeKind1)}, - }, - // split('admin_tier_0 extra_tag',' ')=['admin_tier_0','extra_tag']; 'admin_tier_0' in list - Assert: AssertNonEmpty(), - }, - // ---- node inequality constraint inside an expansion path ---------------- - { - Name: "bind expansion path where the source and destination must be distinct nodes", - Cypher: `match p = (s:NodeKind1)-[*..]->(e:NodeKind2) where s <> e return p`, - Fixture: baseFixture, - // n1(NK1)-[EK1]->n2(NK2); s=n1, e=n2; n1 ≠ n2 → matches - Assert: AssertNonEmpty(), - }, - // ---- both-endpoint ends-with filters in an expansion path --------------- - { - Name: "bind expansion path filtering both endpoints using ends-with on objectid", - Cypher: `match p = (g:NodeKind1)-[:EdgeKind1|EdgeKind2*]->(target:NodeKind1) where g.objectid ends with '-src' and target.objectid ends with '-tgt' return p`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("src", map[string]any{"objectid": "S-1-src"}, NodeKind1), - NewNodeWithProperties("tgt", map[string]any{"objectid": "S-1-tgt"}, NodeKind1), - }, - Edges: []EdgeFixture{NewEdge("src", "tgt", EdgeKind1)}, - }, - Assert: AssertNonEmpty(), - }, - // ---- incoming unbounded expansion returning a bound path ---------------- - { - Name: "bind an incoming unbounded expansion path to a typed source", - Cypher: `match p = (:NodeKind1)<-[:EdgeKind1|EdgeKind2*..]-() return p limit 10`, - Fixture: baseFixture, - // n3(NK1) is reached by n2-[EK2]->n3; incoming expansion from NK1 finds this path - Assert: AssertNonEmpty(), - }, - // ---- expansion with a regex filter on the endpoint ---------------------- - { - Name: "bind expansion path filtered by a regular expression on the endpoint name", - Cypher: `match p = (n:NodeKind1)-[:EdgeKind1|EdgeKind2*1..2]->(r:NodeKind2) where r.name =~ '1.*' return p limit 10`, - Fixture: baseFixture, - // n1(NK1)-[EK1]->n2(NK2,name='1234'); '1234' =~ '1.*' → matches - Assert: AssertNonEmpty(), - }, - // ---- incoming expansion with a disjunction kind filter on the source ---- - { - Name: "bind incoming expansion path where source matches a kind disjunction", - Cypher: `match p = (t:NodeKind2)<-[:EdgeKind1*1..]-(a) where (a:NodeKind1 or a:NodeKind2) and t.objectid ends with '-2' return p limit 1000`, - Fixture: baseFixture, - // t=n2(NK2,objectid='S-1-5-21-2'); n1(NK1)-[EK1]->n2; a=n1 is NK1 → matches - Assert: AssertNonEmpty(), - }, -} - -var aggregationSemanticCases = []SemanticTestCase{ - { - Name: "count all nodes", - Cypher: `MATCH (n) RETURN count(n)`, - Fixture: baseFixture, - Assert: AssertAtLeastInt64(3), // at least the 3 base fixture nodes - }, - { - Name: "return a constant string literal", - Cypher: `RETURN 'hello world'`, - Fixture: GraphFixture{}, - Assert: AssertNonEmpty(), - }, - { - Name: "return a constant arithmetic expression", - Cypher: `RETURN 2 + 3`, - Fixture: GraphFixture{}, - Assert: AssertNonEmpty(), - }, - { - Name: "collect all node name properties into a list", - Cypher: `MATCH (n) RETURN collect(n.name)`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), - }, - { - Name: "return the size of a collected list of node properties", - Cypher: `MATCH (n) RETURN size(collect(n.name))`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), - }, - { - Name: "filter on an aggregate result using WITH and WHERE", - Cypher: `MATCH (n) WITH count(n) as cnt WHERE cnt > 1 RETURN cnt`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), // db has at least 3 nodes, so cnt > 1 is satisfied - }, - { - Name: "group by node and filter on per-node count", - Cypher: `MATCH (n) WITH n, count(n) as node_count WHERE node_count > 1 RETURN n, node_count`, - // Each node grouped by itself gives count=1; with >1 filter result is empty - Fixture: baseFixture, - Assert: AssertNoError(), - }, - // ---- sum / avg / min / max ------------------------------------------ - { - Name: "sum a numeric node property across all nodes", - Cypher: `MATCH (n) RETURN sum(n.value)`, - Fixture: baseFixture, - // n1.value=1, n2.value=2, n3.value=3 → sum ≥ 6 - Assert: AssertNonEmpty(), - }, - { - Name: "average a numeric node property across all nodes", - Cypher: `MATCH (n) RETURN avg(n.value)`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), - }, - { - Name: "minimum of a numeric node property across all nodes", - Cypher: `MATCH (n) RETURN min(n.value)`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), - }, - { - Name: "maximum of a numeric node property across all nodes", - Cypher: `MATCH (n) RETURN max(n.value)`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), - }, - // ---- grouped -------------------------------------------- - { - Name: "group nodes by a property and count each group", - Cypher: `MATCH (n) RETURN n.domain, count(n)`, - Fixture: baseFixture, - // groups: "test.local"→n1, "other.local"→n2, null→n3 - Assert: AssertNonEmpty(), - }, - // ---- multi-aggregate in one projection ------------------------------ - { - Name: "compute multiple aggregates in a single projection", - Cypher: `MATCH (n) RETURN count(n), sum(n.value)`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), - }, - // ---- size() in WHERE ------------------------------------------------ - { - Name: "filter nodes using size() on an array property in WHERE", - Cypher: `MATCH (n) WHERE size(n.array_value) > 0 RETURN n`, - Fixture: baseFixture, - // n1.array_value=[1,2] has size 2 > 0 - Assert: AssertNonEmpty(), - }, - // ---- count-then-match (aggregate feeds a subsequent MATCH) ---------- - { - Name: "feed an aggregate result from a WITH stage into a subsequent MATCH", - Cypher: `MATCH (n) WITH count(n) as lim MATCH (o) RETURN o`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), - }, - // ---- grouped sum and avg ------------------------------------------------ - { - Name: "group nodes by a property and return the sum of another property per group", - Cypher: `MATCH (n) RETURN n.department, sum(n.salary)`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("a", map[string]any{"department": "eng", "salary": 100}, NodeKind1), - NewNodeWithProperties("b", map[string]any{"department": "eng", "salary": 200}, NodeKind1), - NewNodeWithProperties("c", map[string]any{"department": "hr", "salary": 150}, NodeKind2), - }, - }, - Assert: AssertNonEmpty(), - }, - { - Name: "group nodes by a property and return the average of another property per group", - Cypher: `MATCH (n) RETURN n.department, avg(n.age)`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("a", map[string]any{"department": "eng", "age": 30}, NodeKind1), - NewNodeWithProperties("b", map[string]any{"department": "eng", "age": 40}, NodeKind1), - }, - }, - Assert: AssertNonEmpty(), - }, - // ---- all aggregates in one projection ----------------------------------- - { - Name: "compute count sum avg min and max of a property in a single projection", - Cypher: `MATCH (n) RETURN count(n), sum(n.age), avg(n.age), min(n.age), max(n.age)`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("a", map[string]any{"age": 25}, NodeKind1), - NewNodeWithProperties("b", map[string]any{"age": 35}, NodeKind2), - }, - }, - Assert: AssertNonEmpty(), - }, - // ---- grouped collect and grouped collect+count -------------------------- - { - Name: "group nodes by a property and collect names per group", - Cypher: `MATCH (n) RETURN n.department, collect(n.name)`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("a", map[string]any{"department": "eng", "name": "alice"}, NodeKind1), - NewNodeWithProperties("b", map[string]any{"department": "eng", "name": "bob"}, NodeKind1), - }, - }, - Assert: AssertNonEmpty(), - }, - { - Name: "group nodes by a property and return both a collected list and a count", - Cypher: `MATCH (n) RETURN n.department, collect(n.name), count(n)`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("a", map[string]any{"department": "ops", "name": "carol"}, NodeKind1), - NewNodeWithProperties("b", map[string]any{"department": "ops", "name": "dave"}, NodeKind2), - }, - }, - Assert: AssertNonEmpty(), - }, - // ---- size() in the RETURN projection ------------------------------------ - { - Name: "return the size of an array property in the projection", - Cypher: `MATCH (n) RETURN size(n.tags)`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("a", map[string]any{"tags": []any{"admin", "user"}}, NodeKind1), - }, - }, - Assert: AssertNonEmpty(), - }, - // ---- arithmetic on aggregate results ------------------------------------ - { - Name: "compute a ratio by dividing two aggregate results in a WITH stage", - Cypher: `MATCH (n) WITH sum(n.age) as total_age, count(n) as total_count RETURN total_age / total_count as avg_age`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("a", map[string]any{"age": 30}, NodeKind1), - NewNodeWithProperties("b", map[string]any{"age": 50}, NodeKind2), - }, - }, - Assert: AssertNonEmpty(), - }, - // ---- collect in WITH then size filter ----------------------------------- - { - Name: "collect node properties in a WITH stage then filter by the collected size", - Cypher: `MATCH (n) WITH n, collect(n.prop) as props WHERE size(props) > 1 RETURN n, props`, - Fixture: baseFixture, - // Each node is its own group so collect returns [value] with size=1; >1 is empty - Assert: AssertNoError(), - }, -} - -// --------------------------------------------------------------------------- -// multipart.sql -// --------------------------------------------------------------------------- - -var multipartSemanticCases = []SemanticTestCase{ - { - Name: "bind a literal as a WITH variable and filter typed nodes by it", - Cypher: `with '1' as target match (n:NodeKind1) where n.value = target return n`, - Fixture: baseFixture, - // n1 has value=1 but stored as int; string comparison depends on translation - Assert: AssertNoError(), - }, - { - Name: "carry a node through WITH and re-match it by ID", - Cypher: `match (n:NodeKind1) where n.value = 1 with n match (b) where id(b) = id(n) return b`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("n", map[string]any{"value": 1}, NodeKind1), - }, - }, - Assert: AssertNonEmpty(), - }, - { - Name: "exclude second-stage results using a collected list from the first stage", - Cypher: `match (g1:NodeKind1) where g1.name starts with 'test' with collect(g1.domain) as excludes match (d:NodeKind2) where d.name starts with 'other' and not d.name in excludes return d`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("g1", map[string]any{"name": "testnode", "domain": "test.local"}, NodeKind1), - NewNodeWithProperties("d1", map[string]any{"name": "othernode"}, NodeKind2), - NewNodeWithProperties("d2", map[string]any{"name": "othertest"}, NodeKind2), - }, - }, - Assert: AssertNonEmpty(), - }, - // ---- triple-part chain: match → with → match → with → match --------- - { - Name: "three-stage pipeline carrying nodes through successive WITH clauses", - Cypher: `match (n:NodeKind1) where n.value = 1 with n match (f) where f.name = 'me' with f match (b) where id(b) = id(f) return b`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("n", map[string]any{"value": 1}, NodeKind1), - NewNodeWithProperties("f", map[string]any{"name": "me"}, NodeKind2), - }, - }, - // b = f (the node whose id matches f's id) - Assert: AssertContainsNodeWithProp("name", "me"), - }, - // ---- bind a variable, then find all paths leading to it ------------- - { - Name: "bind any node then find all one-hop paths that reach it", - Cypher: `match (e) match p = ()-[]->(e) return p limit 1`, - Fixture: baseFixture, - // any one-hop path whose end is any node; n1->n2 and n2->n3 qualify - Assert: AssertNonEmpty(), - }, - // ---- re-match a WITH-carried node under its own label --------------- - { - Name: "carry a node through WITH and re-match it under its original kind label", - Cypher: `match (u:NodeKind1)-[:EdgeKind1]->(g:NodeKind2) with g match (g)<-[:EdgeKind1]-(u:NodeKind1) return g`, - Fixture: baseFixture, - // n1(NK1)-[EK1]->n2(NK2); carried g=n2; n2<-[EK1]-n1(NK1) still holds - Assert: AssertNonEmpty(), - }, - // ---- numeric literal WITH used in subsequent arithmetic ----------------- - { - Name: "bind a numeric literal as a WITH variable and use it in arithmetic in the next MATCH", - Cypher: `with 365 as max_days match (n:NodeKind1) where n.pwdlastset < (datetime().epochseconds - (max_days * 86400)) and not n.pwdlastset IN [-1.0, 0.0] return n limit 100`, - Fixture: baseFixture, - // n1.pwdlastset=-2; current epochseconds ≫ 365*86400; -2 < big_number is true; -2 ∉ {-1,0} - Assert: AssertNonEmpty(), - }, - // ---- multi-match then variable-length expansion path -------------------- - { - Name: "match a typed node then bind its variable-length expansion to a path", - Cypher: `match (n:NodeKind1) where n.objectid = 'S-1-5-21-1' match p = (n)-[:EdgeKind1|EdgeKind2*1..]->(c:NodeKind2) return p`, - Fixture: baseFixture, - // n1.objectid='S-1-5-21-1'; n1-[EK1]->n2(NK2) → path exists - Assert: AssertNonEmpty(), - }, - // ---- WITH count as a filter in the same stage --------------------------- - { - Name: "filter a carried node using a per-group count in a WITH stage", - Cypher: `match (n:NodeKind1)<-[:EdgeKind1]-(:NodeKind2) where n.objectid ends with '-516' with n, count(n) as dc_count where dc_count = 1 return n`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("dst", map[string]any{"objectid": "S-1-5-21-516"}, NodeKind1), - NewNode("src", NodeKind2), - }, - Edges: []EdgeFixture{NewEdge("src", "dst", EdgeKind1)}, - }, - // exactly one NK2-[EK1]->dst edge; count=1 → matches - Assert: AssertNonEmpty(), - }, - // ---- two path variables sharing a node ---------------------------------- - { - Name: "match two paths that share a common middle node and return both", - Cypher: `match p = (a)-[]->() match q = ()-[]->(a) return p, q`, - Fixture: baseFixture, - // baseFixture: n1->n2->n3; a=n2 satisfies both (n2-[]->n3) and (n1-[]->(n2)) - Assert: AssertNonEmpty(), - }, - // ---- regex filter in a multipart pipeline ------------------------------- - { - Name: "filter typed nodes by a regular expression and carry collected results to the next stage", - Cypher: `match (cg:NodeKind1) where cg.name =~ ".*TT" with collect(cg.name) as names return names`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("a", map[string]any{"name": "SCOTT"}, NodeKind1), - NewNodeWithProperties("b", map[string]any{"name": "admin"}, NodeKind1), - }, - }, - // 'SCOTT' =~ '.*TT' → true; 'admin' → false; names=['SCOTT'] - Assert: AssertNonEmpty(), - }, - // ---- expansion with distinct count and ORDER BY on the aggregate -------- - { - Name: "expand from a typed node, count reachable typed targets, and order by that count", - Cypher: `match (n:NodeKind1) where n.hasspn = true match (n)-[:EdgeKind1|EdgeKind2*1..]->(c:NodeKind2) with distinct n, count(c) as adminCount return n order by adminCount desc limit 100`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("src", map[string]any{"hasspn": true}, NodeKind1), - NewNode("c1", NodeKind2), - NewNode("c2", NodeKind2), - }, - Edges: []EdgeFixture{ - NewEdge("src", "c1", EdgeKind1), - NewEdge("src", "c2", EdgeKind2), - }, - }, - Assert: AssertNonEmpty(), - }, -} - -// --------------------------------------------------------------------------- -// pattern_binding.sql -// --------------------------------------------------------------------------- - -var patternBindingSemanticCases = []SemanticTestCase{ - { - Name: "bind a single typed node to a path variable", - Cypher: `match p = (:NodeKind1) return p`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), - }, - { - Name: "bind a one-hop traversal to a path variable", - Cypher: `match p = ()-[]->() return p`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), - }, - { - Name: "bind an unbounded variable-length path to a path variable", - Cypher: `match p = ()-[*..]->(e) return p limit 1`, - Fixture: baseFixture, - Assert: AssertNonEmpty(), - }, - { - Name: "bind a two-hop path and return the terminal node", - Cypher: `match p = ()-[r1]->()-[r2]->(e) return e`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNode("a", NodeKind1), - NewNode("b", NodeKind2), - NewNode("c", NodeKind1), - }, - Edges: []EdgeFixture{ - NewEdge("a", "b", EdgeKind1), - NewEdge("b", "c", EdgeKind2), - }, - }, - Assert: AssertNonEmpty(), - }, - { - Name: "bind a converging diamond path with endpoint property filters", - Cypher: `match p = (a)-[]->()<-[]-(f) where a.name = 'value' and f.is_target return p`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("a", map[string]any{"name": "value"}, NodeKind1), - NewNodeWithProperties("mid", map[string]any{}, NodeKind2), - NewNodeWithProperties("f", map[string]any{"is_target": true}, NodeKind1), - }, - Edges: []EdgeFixture{ - NewEdge("a", "mid", EdgeKind1), - NewEdge("f", "mid", EdgeKind1), - }, - }, - Assert: AssertNonEmpty(), - }, - // ---- node-only path with property filter ---------------------------- - { - Name: "bind a node-only path with a contains property filter", - Cypher: `match p = (n:NodeKind1) where n.name contains 'test' return p`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("a", map[string]any{"name": "testuser"}, NodeKind1), - NewNodeWithProperties("b", map[string]any{"name": "admin"}, NodeKind1), - }, - }, - Assert: AssertNonEmpty(), - }, - // ---- undirected edge in path ---------------------------------------- - { - Name: "bind a path with an undirected edge between typed nodes", - Cypher: `match p = (n:NodeKind1)-[r]-(m:NodeKind1) return p`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNode("a", NodeKind1), - NewNode("b", NodeKind1), - }, - Edges: []EdgeFixture{ - NewEdge("a", "b", EdgeKind1), - }, - }, - // undirected: a-[r]-b is found in both (a,b) and (b,a) orientations - Assert: AssertNonEmpty(), - }, - // ---- 3-hop path with named-edge property filters -------------------- - { - Name: "three-hop traversal filtering named edges by their property", - Cypher: `match ()-[r1]->()-[r2]->()-[]->() where r1.label = 'first' and r2.label = 'second' return r1`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNode("a", NodeKind1), - NewNode("b", NodeKind2), - NewNode("c", NodeKind1), - NewNode("d", NodeKind2), - }, - Edges: []EdgeFixture{ - NewEdgeWithProperties("a", "b", EdgeKind1, map[string]any{"label": "first"}), - NewEdgeWithProperties("b", "c", EdgeKind2, map[string]any{"label": "second"}), - NewEdge("c", "d", EdgeKind1), - }, - }, - Assert: AssertNonEmpty(), - }, - // ---- edge boolean property as a path filter ------------------------------ - { - Name: "bind a one-hop path between typed nodes filtered by a boolean edge property", - Cypher: `match p = (:NodeKind1)-[r]->(:NodeKind1) where r.isacl return p limit 100`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNode("a", NodeKind1), - NewNode("b", NodeKind1), - }, - Edges: []EdgeFixture{ - NewEdgeWithProperties("a", "b", EdgeKind1, map[string]any{"isacl": true}), - }, - }, - Assert: AssertNonEmpty(), - }, - // ---- fixed step plus variable-length expansion with named first edge ---- - { - Name: "return a named first edge and the full path including its subsequent expansion", - Cypher: `match p = ()-[e:EdgeKind1]->()-[:EdgeKind1*1..]->() return e, p`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNode("a", NodeKind1), - NewNode("b", NodeKind2), - NewNode("c", NodeKind1), - }, - Edges: []EdgeFixture{ - NewEdge("a", "b", EdgeKind1), - NewEdge("b", "c", EdgeKind1), - }, - }, - // e=a→b(EK1); then b→c(EK1*1..); full path a→b→c - Assert: AssertNonEmpty(), - }, - // ---- toUpper not-contains in a path filter ------------------------------ - { - Name: "bind a typed one-hop path where the target property does not contain a toUpper result", - Cypher: `match p = (m:NodeKind1)-[:EdgeKind1]->(c:NodeKind2) where m.objectid ends with '-1' and not toUpper(c.operatingsystem) contains 'SERVER' return p limit 1000`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("m", map[string]any{"objectid": "S-1-5-21-1"}, NodeKind1), - NewNodeWithProperties("c", map[string]any{"operatingsystem": "workstation"}, NodeKind2), - }, - Edges: []EdgeFixture{NewEdge("m", "c", EdgeKind1)}, - }, - // toUpper('workstation')='WORKSTATION'; 'WORKSTATION' not contains 'SERVER' → true - Assert: AssertNonEmpty(), - }, - // ---- array membership on a middle node in a chained path ---------------- - { - Name: "bind a two-hop typed path filtered by array membership on the intermediate node", - Cypher: `match p = (:NodeKind1)-[:EdgeKind1|EdgeKind2]->(e:NodeKind2)-[:EdgeKind2]->(:NodeKind1) where 'a' in e.values or 'b' in e.values or size(e.values) = 0 return p`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNode("src", NodeKind1), - NewNodeWithProperties("mid", map[string]any{"values": []any{"a", "c"}}, NodeKind2), - NewNode("dst", NodeKind1), - }, - Edges: []EdgeFixture{ - NewEdge("src", "mid", EdgeKind1), - NewEdge("mid", "dst", EdgeKind2), - }, - }, - // 'a' in ['a','c'] → true → matches - Assert: AssertNonEmpty(), - }, - // ---- fixed step then variable expansion with coalesce in path filter ---- - { - Name: "bind a path with one fixed hop then variable expansion filtered by coalesce contains", - Cypher: `match p = (:NodeKind1)-[:EdgeKind1]->(:NodeKind2)-[:EdgeKind2*1..]->(t:NodeKind2) where coalesce(t.system_tags, '') contains 'admin_tier_0' return p limit 1000`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNode("a", NodeKind1), - NewNode("b", NodeKind2), - NewNodeWithProperties("t", map[string]any{"system_tags": "admin_tier_0"}, NodeKind2), - }, - Edges: []EdgeFixture{ - NewEdge("a", "b", EdgeKind1), - NewEdge("b", "t", EdgeKind2), - }, - }, - Assert: AssertNonEmpty(), - }, - // ---- multi-match with WHERE then variable-length expansion path --------- - { - Name: "filter a typed node with WHERE then bind its variable-length expansion path", - Cypher: `match (u:NodeKind1) where u.samaccountname in ['foo', 'bar'] match p = (u)-[:EdgeKind1|EdgeKind2*1..3]->(t) where coalesce(t.system_tags, '') contains 'admin_tier_0' return p limit 1000`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("u", map[string]any{"samaccountname": "foo"}, NodeKind1), - NewNodeWithProperties("t", map[string]any{"system_tags": "admin_tier_0"}, NodeKind2), - }, - Edges: []EdgeFixture{NewEdge("u", "t", EdgeKind1)}, - }, - Assert: AssertNonEmpty(), - }, - // ---- three-match pattern: anchor + second + path ------------------------ - { - Name: "three consecutive MATCHes that anchor two nodes and bind the connecting path", - Cypher: `match (x:NodeKind1) where x.name = 'foo' match (y:NodeKind2) where y.name = 'bar' match p=(x)-[:EdgeKind1]->(y) return p`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("x", map[string]any{"name": "foo"}, NodeKind1), - NewNodeWithProperties("y", map[string]any{"name": "bar"}, NodeKind2), - }, - Edges: []EdgeFixture{NewEdge("x", "y", EdgeKind1)}, - }, - Assert: AssertNonEmpty(), - }, - // ---- inline property map in MATCH then path binding --------------------- - { - Name: "match a node with an inline property map then bind its outgoing path to a second inline-map node", - Cypher: `match (x:NodeKind1{name:'foo'}) match p=(x)-[:EdgeKind1]->(y:NodeKind2{name:'bar'}) return p`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("x", map[string]any{"name": "foo"}, NodeKind1), - NewNodeWithProperties("y", map[string]any{"name": "bar"}, NodeKind2), - }, - Edges: []EdgeFixture{NewEdge("x", "y", EdgeKind1)}, - }, - Assert: AssertNonEmpty(), - }, -} - -// --------------------------------------------------------------------------- -// delete.sql -// --------------------------------------------------------------------------- - -var deleteSemanticCases = []SemanticTestCase{ - { - Name: "detach-delete a typed node and its incident edges", - Cypher: `match (s:NodeKind1) detach delete s`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("victim", map[string]any{"name": "victim"}, NodeKind1), - }, - }, - Assert: AssertNoError(), - PostAssert: func(t *testing.T, tx graph.Transaction) { - t.Helper() - result := tx.Query(`match (n:NodeKind1) where n.name = 'victim' return n`, nil) - defer result.Close() - for result.Next() { - } - if err := result.Error(); err != nil { - t.Errorf("post-delete query error: %v", err) - } - }, - }, - { - Name: "delete a specific typed edge", - Cypher: `match ()-[r:EdgeKind1]->() delete r`, - Fixture: baseFixture, - Assert: AssertNoError(), - }, - // ---- multi-hop: traverse two hops then delete the second edge ------- - { - Name: "traverse two hops then delete the typed edge at the second hop", - Cypher: `match ()-[]->()-[r:EdgeKind2]->() delete r`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNode("a", NodeKind1), - NewNode("b", NodeKind2), - NewNode("c", NodeKind1), - }, - Edges: []EdgeFixture{ - NewEdge("a", "b", EdgeKind1), - NewEdge("b", "c", EdgeKind2), - }, - }, - Assert: AssertNoError(), - }, -} - -// --------------------------------------------------------------------------- -// update.sql -// --------------------------------------------------------------------------- - -var updateSemanticCases = []SemanticTestCase{ - { - Name: "set a string property on a filtered node and return the updated node", - Cypher: `match (n) where n.name = 'n3' set n.name = 'RENAMED' return n`, - Fixture: baseFixture, - Assert: AssertContainsNodeWithProp("name", "RENAMED"), - }, - { - Name: "chain multiple SET clauses to update several properties on a node", - Cypher: `match (n) set n.other = 1 set n.prop = '1' return n`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("a", map[string]any{"name": "updateme"}, NodeKind1), - }, - }, - Assert: AssertNonEmpty(), - }, - { - Name: "add multiple kind labels to a node", - Cypher: `match (n) set n:NodeKind1:NodeKind2 return n`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNode("a", NodeKind1), - }, - }, - Assert: AssertNonEmpty(), - }, - { - Name: "remove multiple kind labels from a node", - Cypher: `match (n) remove n:NodeKind1:NodeKind2 return n`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNode("a", NodeKind1, NodeKind2), - }, - }, - Assert: AssertNonEmpty(), - }, - { - Name: "set a boolean property on a filtered node (no RETURN)", - Cypher: `match (n) where n.name = '1234' set n.is_target = true`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("a", map[string]any{"name": "1234"}, NodeKind1), - }, - }, - Assert: AssertNoError(), - }, - { - Name: "set a property on a traversed edge from a typed source node", - Cypher: `match (n)-[r:EdgeKind1]->() where n:NodeKind1 set r.visited = true return r`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNode("a", NodeKind1), - NewNode("b", NodeKind2), - }, - Edges: []EdgeFixture{NewEdge("a", "b", EdgeKind1)}, - }, - Assert: AssertNonEmpty(), - }, - // ---- set kind + remove kind (combined in one statement) ------------- - { - Name: "add one kind label and remove another in the same statement", - Cypher: `match (n) set n:NodeKind1 remove n:NodeKind2 return n`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNode("a", NodeKind1, NodeKind2), - }, - }, - Assert: AssertNonEmpty(), - }, - // ---- set kind + set property (combined) ---------------------------- - { - Name: "add a kind label and set a property in the same statement", - Cypher: `match (n) set n:NodeKind1 set n.flag = '1' return n`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNode("a", NodeKind2), - }, - }, - Assert: AssertNonEmpty(), - }, - // ---- remove kind + remove property (combined) ---------------------- - { - Name: "remove a kind label and a property in the same statement", - Cypher: `match (n) remove n:NodeKind1 remove n.prop return n`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("a", map[string]any{"prop": "val"}, NodeKind1), - }, - }, - Assert: AssertNonEmpty(), - }, - // ---- remove single property ----------------------------------------- - { - Name: "remove a single node property (no RETURN)", - Cypher: `match (s) remove s.name`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("a", map[string]any{"name": "drop-me"}, NodeKind1), - }, - }, - Assert: AssertNoError(), - }, - // ---- edge-only update (no RETURN) ----------------------------------- - { - Name: "set a property on an edge leading to a typed target node (no RETURN)", - Cypher: `match ()-[r]->(:NodeKind1) set r.is_special_outbound = true`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNode("src", NodeKind2), - NewNode("dst", NodeKind1), - }, - Edges: []EdgeFixture{NewEdge("src", "dst", EdgeKind1)}, - }, - Assert: AssertNoError(), - }, - // ---- node + edge updated together ----------------------------------- - { - Name: "update a source node property and an edge property together", - Cypher: `match (a)-[r]->(:NodeKind1) set a.name = '123', r.is_special_outbound = true`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNode("src", NodeKind2), - NewNode("dst", NodeKind1), - }, - Edges: []EdgeFixture{NewEdge("src", "dst", EdgeKind1)}, - }, - Assert: AssertNoError(), - }, -} - -// --------------------------------------------------------------------------- -// quantifiers.sql -// --------------------------------------------------------------------------- - -// quantifierFixture provides nodes whose array properties exercise ANY, ALL, -// NONE, and SINGLE quantifier semantics against the string predicate -// "type CONTAINS 'DES'". -// -// qAny (NK1): supportedencryptiontypes=["DES-CBC-CRC","AES-128"], usedeskeyonly=false -// → ANY matches DES-CBC-CRC (count≥1) → true -// qAll (NK1): supportedencryptiontypes=["DES-CBC-CRC","DES-CBC-MD5"], usedeskeyonly=false -// → ALL both match (count=2=len=2) → true -// qNone (NK1): supportedencryptiontypes=["AES-128","RC4-HMAC"], usedeskeyonly=false -// → NONE no match (count=0) → true -// qSingle(NK1):supportedencryptiontypes=["DES-CBC-CRC","AES-128"], usedeskeyonly=false -// → SINGLE exactly one match (count=1) → true -// -// All four nodes have usedeskeyonly=false so the OR short-circuit is never -// taken and the quantifier itself is the deciding factor. -var quantifierFixture = GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("qAny", map[string]any{ - "usedeskeyonly": false, - "supportedencryptiontypes": []any{"DES-CBC-CRC", "AES-128"}, - }, NodeKind1), - NewNodeWithProperties("qAll", map[string]any{ - "usedeskeyonly": false, - "supportedencryptiontypes": []any{"DES-CBC-CRC", "DES-CBC-MD5"}, - }, NodeKind1), - NewNodeWithProperties("qNone", map[string]any{ - "usedeskeyonly": false, - "supportedencryptiontypes": []any{"AES-128", "RC4-HMAC"}, - }, NodeKind1), - NewNodeWithProperties("qSingle", map[string]any{ - "usedeskeyonly": false, - "supportedencryptiontypes": []any{"DES-CBC-CRC", "AES-128"}, - }, NodeKind1), - }, -} - -var quantifiersSemanticCases = []SemanticTestCase{ - // ---- ANY ------------------------------------------------------------ - { - Name: "ANY quantifier over an array property with a contains predicate", - Cypher: `MATCH (n:NodeKind1) WHERE n.usedeskeyonly OR ANY(type IN n.supportedencryptiontypes WHERE type CONTAINS 'DES') RETURN n LIMIT 100`, - Fixture: quantifierFixture, - // qAny: "DES-CBC-CRC" contains 'DES' → count≥1 → ANY=true; false OR true → matches - Assert: AssertNonEmpty(), - }, - // ---- ALL ------------------------------------------------------------ - { - Name: "ALL quantifier over an array property with a contains predicate", - Cypher: `MATCH (n:NodeKind1) WHERE n.usedeskeyonly OR ALL(type IN n.supportedencryptiontypes WHERE type CONTAINS 'DES') RETURN n LIMIT 100`, - Fixture: quantifierFixture, - // qAll: both entries contain 'DES' → count=len=2 → ALL=true; false OR true → matches - Assert: AssertNonEmpty(), - }, - // ---- NONE ----------------------------------------------------------- - { - Name: "NONE quantifier over an array property with a contains predicate", - Cypher: `MATCH (n:NodeKind1) WHERE n.usedeskeyonly OR NONE(type IN n.supportedencryptiontypes WHERE type CONTAINS 'DES') RETURN n LIMIT 100`, - Fixture: quantifierFixture, - // qNone: neither "AES-128" nor "RC4-HMAC" contains 'DES' → count=0 → NONE=true - Assert: AssertNonEmpty(), - }, - // ---- SINGLE --------------------------------------------------------- - { - Name: "SINGLE quantifier over an array property with a contains predicate", - Cypher: `MATCH (n:NodeKind1) WHERE n.usedeskeyonly OR SINGLE(type IN n.supportedencryptiontypes WHERE type CONTAINS 'DES') RETURN n LIMIT 100`, - Fixture: quantifierFixture, - // qSingle: exactly one entry ("DES-CBC-CRC") matches → count=1 → SINGLE=true - Assert: AssertNonEmpty(), - }, - // ---- NONE inside a WITH-piped stage --------------------------------- - // The second MATCH is a required (non-optional) match. The translator - // renders it as an inner join in CTE s3. If s3 is empty (no matching - // n→g edges), GROUP BY returns no groups at all and m drops out of the - // pipeline — "NONE of empty is vacuously true" does not apply. - // - // Fixture: m ← NodeKind1 with unconstraineddelegation=true - // n ← NodeKind1 with a *different* objectid ("other-id") - // g ← NodeKind2 with objectid ending '-516' - // n -[EdgeKind1]→ g - // - // Second MATCH finds (n,g) → matchingNs=[n]. - // NONE(n IN [n] WHERE n.objectid = "test-m"): - // n.objectid="other-id" ≠ "test-m" → count=0 → NONE=true → m returned. - { - Name: "NONE quantifier over a collected list in a WITH-piped stage", - Cypher: `MATCH (m:NodeKind1) WHERE m.unconstraineddelegation = true WITH m MATCH (n:NodeKind1)-[:EdgeKind1]->(g:NodeKind2) WHERE g.objectid ENDS WITH '-516' WITH m, COLLECT(n) AS matchingNs WHERE NONE(n IN matchingNs WHERE n.objectid = m.objectid) RETURN m`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("m", map[string]any{"unconstraineddelegation": true, "objectid": "test-m"}, NodeKind1), - NewNodeWithProperties("n", map[string]any{"objectid": "other-id"}, NodeKind1), - NewNodeWithProperties("g", map[string]any{"objectid": "S-1-5-21-516"}, NodeKind2), - }, - Edges: []EdgeFixture{ - NewEdge("n", "g", EdgeKind1), - }, - }, - Assert: AssertNonEmpty(), - }, - // ---- ALL inside a WITH-piped stage ---------------------------------- - // Same structural requirement as NONE: the second MATCH must produce - // rows so GROUP BY has groups to evaluate. - // - // Fixture: m ← NodeKind1 with unconstraineddelegation=true - // n ← NodeKind1 with the *same* objectid as m ("test-m") - // g ← NodeKind2 with objectid ending '-516' - // n -[EdgeKind1]→ g - // - // Second MATCH finds (n,g) → matchingNs=[n]. - // ALL(n IN [n] WHERE n.objectid = "test-m"): - // n.objectid="test-m" = "test-m" → count=1=len=1 → ALL=true → m returned. - { - Name: "ALL quantifier over a collected list in a WITH-piped stage", - Cypher: `MATCH (m:NodeKind1) WHERE m.unconstraineddelegation = true WITH m MATCH (n:NodeKind1)-[:EdgeKind1]->(g:NodeKind2) WHERE g.objectid ENDS WITH '-516' WITH m, COLLECT(n) AS matchingNs WHERE ALL(n IN matchingNs WHERE n.objectid = m.objectid) RETURN m`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - NewNodeWithProperties("m", map[string]any{"unconstraineddelegation": true, "objectid": "test-m"}, NodeKind1), - NewNodeWithProperties("n", map[string]any{"objectid": "test-m"}, NodeKind1), - NewNodeWithProperties("g", map[string]any{"objectid": "S-1-5-21-516"}, NodeKind2), - }, - Edges: []EdgeFixture{ - NewEdge("n", "g", EdgeKind1), - }, - }, - Assert: AssertNonEmpty(), - }, - // ---- multiple ANY quantifiers with a compound OR predicate inside -------- - // The second ANY uses a compound OR predicate inside its WHERE clause, - // exercising the translation of disjunctions within quantifier bodies. - { - Name: "multiple ANY quantifiers where the second ANY has a compound OR predicate", - Cypher: `MATCH (n:NodeKind1) WHERE n.usedeskeyonly OR ANY(type IN n.supportedencryptiontypes WHERE type CONTAINS 'DES') OR ANY(type IN n.serviceprincipalnames WHERE toLower(type) CONTAINS 'mssql' OR toLower(type) CONTAINS 'mssqlcluster') RETURN n LIMIT 100`, - Fixture: quantifierFixture, - // qAny has 'DES-CBC-CRC' in supportedencryptiontypes → first ANY=true → matches - Assert: AssertNonEmpty(), - }, - // ---- ANY in first WITH stage, NONE over collected results in second ------ - // This exercises ANY driving a WITH pipeline where NONE then filters over - // the collected output of the second MATCH stage. - { - Name: "ANY quantifier in first stage gates a pipeline where NONE filters the collected output", - Cypher: `MATCH (m:NodeKind1) WHERE ANY(name IN m.serviceprincipalnames WHERE name CONTAINS 'PHANTOM') WITH m MATCH (n:NodeKind1)-[:EdgeKind1]->(g:NodeKind2) WHERE g.objectid ENDS WITH '-525' WITH m, COLLECT(n) AS matchingNs WHERE NONE(t IN matchingNs WHERE t.objectid = m.objectid) RETURN m`, - Fixture: GraphFixture{ - Nodes: []NodeFixture{ - // m has 'PHANTOM' in serviceprincipalnames → ANY=true; objectid='m-obj' - NewNodeWithProperties("m", map[string]any{ - "objectid": "m-obj", - "serviceprincipalnames": []any{"PHANTOM/host"}, - }, NodeKind1), - // n has a different objectid so NONE(t.objectid = m.objectid) is true - NewNodeWithProperties("n", map[string]any{"objectid": "other-obj"}, NodeKind1), - NewNodeWithProperties("g", map[string]any{"objectid": "S-1-5-21-525"}, NodeKind2), - }, - Edges: []EdgeFixture{NewEdge("n", "g", EdgeKind1)}, - }, - Assert: AssertNonEmpty(), - }, -} diff --git a/cypher/models/pgsql/test/fixture.go b/cypher/models/pgsql/test/fixture.go deleted file mode 100644 index d7138df..0000000 --- a/cypher/models/pgsql/test/fixture.go +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright 2026 Specter Ops, Inc. -// -// Licensed under the Apache License, Version 2.0 -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// SPDX-License-Identifier: Apache-2.0 - -package test - -import ( - "testing" - - "github.com/specterops/dawgs/graph" -) - -var ( - // Node and edge kinds to keep queries consistent - NodeKind1 = graph.StringKind("NodeKind1") - NodeKind2 = graph.StringKind("NodeKind2") - EdgeKind1 = graph.StringKind("EdgeKind1") - EdgeKind2 = graph.StringKind("EdgeKind2") -) - -// SemanticGraphSchema is the graph schema used by semantic integration tests. -// It must be kept in sync with the schema in validation_integration_test.go so -// that kind IDs are assigned in the same order and match the golden SQL files. -var SemanticGraphSchema = graph.Schema{ - Graphs: []graph.Graph{{ - Name: "test", - Nodes: graph.Kinds{NodeKind1, NodeKind2}, - Edges: graph.Kinds{EdgeKind1, EdgeKind2}, - }}, - DefaultGraph: graph.Graph{Name: "test"}, -} - -// NodeRef is a symbolic name used to wire nodes into edges within a fixture. -type NodeRef = string - -// NodeFixture describes a single node to be created in the fixture graph. -type NodeFixture struct { - Ref NodeRef - Kinds graph.Kinds - Props map[string]any -} - -// EdgeFixture describes a directed edge to be created in the fixture graph. -type EdgeFixture struct { - StartRef NodeRef - EndRef NodeRef - Kind graph.Kind - Props map[string]any -} - -// GraphFixture is the complete description of the minimal graph state required -// by a SemanticTestCase. Nodes and edges are created inside a write transaction -// that is always rolled back after the test completes. -type GraphFixture struct { - Nodes []NodeFixture - Edges []EdgeFixture -} - -// SemanticTestCase pairs a Cypher query with a fixture graph and assertions -// on the result set produced by executing that query against the fixture. -type SemanticTestCase struct { - // Name is a human-readable label shown by the test runner. - Name string - // Cypher is the query passed verbatim to graph.Transaction.Query. - Cypher string - // Params are optional Cypher-level parameters forwarded to the query. - Params map[string]any - // Fixture is the graph state created before executing Cypher. - Fixture GraphFixture - // Assert inspects the query result. Next() has not yet been called. - Assert ResultAssertion - // PostAssert runs after Assert while the transaction is still open. - // Use this for destructive queries (delete/update) that require a - // follow-up read to verify the mutation took effect. - PostAssert func(*testing.T, graph.Transaction) -} - -// NewNode creates a NodeFixture with no properties. -func NewNode(ref NodeRef, kinds ...graph.Kind) NodeFixture { - return NodeFixture{Ref: ref, Kinds: kinds} -} - -// NewNodeWithProperties creates a NodeFixture with the given properties and kinds. -func NewNodeWithProperties(ref NodeRef, props map[string]any, kinds ...graph.Kind) NodeFixture { - return NodeFixture{Ref: ref, Kinds: kinds, Props: props} -} - -// NewEdge creates an EdgeFixture with no properties. -func NewEdge(start, end NodeRef, kind graph.Kind) EdgeFixture { - return EdgeFixture{StartRef: start, EndRef: end, Kind: kind} -} - -// NewEdgeWithProperties creates an EdgeFixture with properties. -func NewEdgeWithProperties(start, end NodeRef, kind graph.Kind, props map[string]any) EdgeFixture { - return EdgeFixture{StartRef: start, EndRef: end, Kind: kind, Props: props} -} diff --git a/cypher/models/pgsql/test/query_test.go b/cypher/models/pgsql/test/query_test.go index 3e3a617..f43979b 100644 --- a/cypher/models/pgsql/test/query_test.go +++ b/cypher/models/pgsql/test/query_test.go @@ -9,9 +9,18 @@ import ( "github.com/specterops/dawgs/cypher/models/pgsql" "github.com/specterops/dawgs/cypher/models/pgsql/translate" "github.com/specterops/dawgs/cypher/models/walk" + "github.com/specterops/dawgs/graph" "github.com/specterops/dawgs/query" ) +var ( + // Node and edge kinds to keep queries consistent + NodeKind1 = graph.StringKind("NodeKind1") + NodeKind2 = graph.StringKind("NodeKind2") + EdgeKind1 = graph.StringKind("EdgeKind1") + EdgeKind2 = graph.StringKind("EdgeKind2") +) + func TestQuery_KindGeneratesInclusiveKindMatcher(t *testing.T) { mapper := newKindMapper() diff --git a/cypher/models/pgsql/test/semantic_integration_test.go b/cypher/models/pgsql/test/semantic_integration_test.go deleted file mode 100644 index 1ed83b2..0000000 --- a/cypher/models/pgsql/test/semantic_integration_test.go +++ /dev/null @@ -1,170 +0,0 @@ -//go:build manual_integration - -// Copyright 2026 Specter Ops, Inc. -// -// Licensed under the Apache License, Version 2.0 -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// SPDX-License-Identifier: Apache-2.0 - -package test - -import ( - "context" - "errors" - "fmt" - "log/slog" - "os" - "testing" - - "github.com/specterops/dawgs" - "github.com/specterops/dawgs/drivers/pg" - "github.com/specterops/dawgs/graph" - "github.com/specterops/dawgs/util/size" -) - -// errFixtureRollback is returned from every test transaction delegate to -// unconditionally roll back fixture data after each semantic test case. -var errFixtureRollback = errors.New("semantic test fixture rollback") - -func TestSemanticTranslation(t *testing.T) { - pgConnectionStr := os.Getenv(PGConnectionStringEV) - if pgConnectionStr == "" { - t.Fatalf("%s environment variable is not set", PGConnectionStringEV) - } - - testCtx, done := context.WithCancel(context.Background()) - defer done() - - pgxPool, err := pg.NewPool(pgConnectionStr) - if err != nil { - t.Fatalf("Failed creating pgx pool: %v", err) - } - - db, err := dawgs.Open(context.TODO(), pg.DriverName, dawgs.Config{ - GraphQueryMemoryLimit: size.Gibibyte, - Pool: pgxPool, - }) - if err != nil { - t.Fatalf("Failed opening database connection: %v", err) - } - defer db.Close(testCtx) - - var ( - numCasesRun = 0 - ) - - // Each test case calls AssertSchema independently so that the kind mapper - // is always in a known state regardless of test ordering. - for _, testCase := range semanticTestCases { - t.Run(testCase.Name, func(t *testing.T) { - if err := db.AssertSchema(testCtx, SemanticGraphSchema); err != nil { - t.Fatalf("Failed asserting graph schema: %v", err) - } - - numCasesRun += 1 - slog.Info("Semantic Test", slog.String("name", testCase.Name), slog.Int("num_cases_run", numCasesRun)) - - runSemanticCase(testCtx, t, db, testCase) - }) - } - - slog.Info("Semantic Tests Finished", slog.Int("num_cases_run", numCasesRun)) -} - -// runSemanticCase creates tc.Fixture inside a write transaction, executes -// tc.Cypher, runs tc.Assert against the live result, optionally runs -// tc.PostAssert, then rolls back all fixture data. -func runSemanticCase(ctx context.Context, t *testing.T, db graph.Database, tc SemanticTestCase) { - t.Helper() - - err := db.WriteTransaction(ctx, func(tx graph.Transaction) error { - nodeMap, err := createFixtureNodes(tx, tc.Fixture) - if err != nil { - return fmt.Errorf("creating fixture nodes: %w", err) - } - - if err := createFixtureEdges(tx, nodeMap, tc.Fixture); err != nil { - return fmt.Errorf("creating fixture edges: %w", err) - } - - result := tx.Query(tc.Cypher, tc.Params) - defer result.Close() - - if tc.Assert != nil { - tc.Assert(t, result) - } - - if tc.PostAssert != nil { - tc.PostAssert(t, tx) - } - - return errFixtureRollback - }) - - if !errors.Is(err, errFixtureRollback) { - t.Errorf("unexpected transaction error in %q: %v", tc.Name, err) - } -} - -// createFixtureNodes creates all nodes described by fixture and returns a -// map from NodeRef to the created *graph.Node so edges can reference them. -func createFixtureNodes(tx graph.Transaction, fixture GraphFixture) (map[NodeRef]*graph.Node, error) { - nodeMap := make(map[NodeRef]*graph.Node, len(fixture.Nodes)) - - for _, nf := range fixture.Nodes { - var props *graph.Properties - if len(nf.Props) > 0 { - props = graph.AsProperties(nf.Props) - } else { - props = graph.NewProperties() - } - - node, err := tx.CreateNode(props, nf.Kinds...) - if err != nil { - return nil, fmt.Errorf("node %q: %w", nf.Ref, err) - } - - nodeMap[nf.Ref] = node - } - - return nodeMap, nil -} - -// createFixtureEdges creates all edges described by fixture, resolving node -// references from nodeMap. -func createFixtureEdges(tx graph.Transaction, nodeMap map[NodeRef]*graph.Node, fixture GraphFixture) error { - for _, ef := range fixture.Edges { - start, ok := nodeMap[ef.StartRef] - if !ok { - return fmt.Errorf("edge start ref %q not in fixture node map", ef.StartRef) - } - - end, ok := nodeMap[ef.EndRef] - if !ok { - return fmt.Errorf("edge end ref %q not in fixture node map", ef.EndRef) - } - - var props *graph.Properties - if len(ef.Props) > 0 { - props = graph.AsProperties(ef.Props) - } else { - props = graph.NewProperties() - } - - if _, err := tx.CreateRelationshipByIDs(start.ID, end.ID, ef.Kind, props); err != nil { - return fmt.Errorf("edge %q->%q (%s): %w", ef.StartRef, ef.EndRef, ef.Kind, err) - } - } - - return nil -} diff --git a/cypher/models/pgsql/test/translation_cases/multipart.sql b/cypher/models/pgsql/test/translation_cases/multipart.sql index 004e15a..4bfe3c5 100644 --- a/cypher/models/pgsql/test/translation_cases/multipart.sql +++ b/cypher/models/pgsql/test/translation_cases/multipart.sql @@ -24,10 +24,13 @@ with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposit with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties ->> 'value'))::int8 = 1) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s1.n0 as n0 from s1), s2 as (with s3 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1 where ((n1.properties ->> 'name') = 'me')) select s3.n1 as n1 from s3), s4 as (select s2.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s2, node n2 where (n2.id = (s2.n1).id)) select s4.n2 as b from s4; -- case: match (n:NodeKind1)-[:EdgeKind1*1..]->(:NodeKind2)-[:EdgeKind2]->(m:NodeKind1) where (n:NodeKind1 or n:NodeKind2) and n.enabled = true with m, collect(distinct(n)) as p where size(p) >= 10 return m -with s0 as (with s1 as (with recursive s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, ((n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]) and ((n0.properties ->> 'enabled'))::bool = true) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.end_id = e0.start_id, array [e0.id] from edge e0 join node n1 on n1.id = e0.end_id join node n0 on n0.id = e0.start_id where n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and e0.kind_id = any (array [3]::int2[]) union select s2.root_id, e0.start_id, s2.depth + 1, ((n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]) and ((n0.properties ->> 'enabled'))::bool = true) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.id = any (s2.path), s2.path || e0.id from s2 join edge e0 on e0.end_id = s2.next_id join node n0 on n0.id = e0.start_id where e0.kind_id = any (array [3]::int2[]) and s2.depth < 15 and not s2.is_cycle) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s2.path)) as e0, s2.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s2 join node n1 on n1.id = s2.root_id join node n0 on n0.id = s2.next_id where s2.satisfied), s3 as (select s1.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s1.ep0 as ep0, s1.n0 as n0, s1.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s1 join edge e1 on (s1.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[])) select s3.n2 as n2, array_remove(coalesce(array_agg(distinct (s3.n0))::nodecomposite[], array []::nodecomposite[])::nodecomposite[], null)::nodecomposite[] as i0 from s3 group by n2) select s0.n2 as m from s0 where (array_length(s0.i0, 1)::int >= 10); +with s0 as (with s1 as (with recursive s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.start_id = e0.end_id, array [e0.id] from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.id = e0.end_id where ((n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]) and ((n0.properties ->> 'enabled'))::bool = true) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and e0.kind_id = any (array [3]::int2[]) union select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.id = any (s2.path), s2.path || e0.id from s2 join edge e0 on e0.start_id = s2.next_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) and s2.depth < 15 and not s2.is_cycle) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s2.path)) as e0, s2.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s2 join node n0 on n0.id = s2.root_id join node n1 on n1.id = s2.next_id where s2.satisfied), s3 as (select s1.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s1.ep0 as ep0, s1.n0 as n0, s1.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s1 join edge e1 on (s1.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[])) select s3.n2 as n2, array_remove(coalesce(array_agg(distinct (s3.n0))::nodecomposite[], array []::nodecomposite[])::nodecomposite[], null)::nodecomposite[] as i0 from s3 group by n2) select s0.n2 as m from s0 where (array_length(s0.i0, 1)::int >= 10); -- case: match (n:NodeKind1)-[:EdgeKind1*1..]->(:NodeKind2)-[:EdgeKind2]->(m:NodeKind1) where (n:NodeKind1 or n:NodeKind2) and n.enabled = true with m, count(distinct(n)) as p where p >= 10 return m -with s0 as (with s1 as (with recursive s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, ((n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]) and ((n0.properties ->> 'enabled'))::bool = true) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.end_id = e0.start_id, array [e0.id] from edge e0 join node n1 on n1.id = e0.end_id join node n0 on n0.id = e0.start_id where n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and e0.kind_id = any (array [3]::int2[]) union select s2.root_id, e0.start_id, s2.depth + 1, ((n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]) and ((n0.properties ->> 'enabled'))::bool = true) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.id = any (s2.path), s2.path || e0.id from s2 join edge e0 on e0.end_id = s2.next_id join node n0 on n0.id = e0.start_id where e0.kind_id = any (array [3]::int2[]) and s2.depth < 15 and not s2.is_cycle) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s2.path)) as e0, s2.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s2 join node n1 on n1.id = s2.root_id join node n0 on n0.id = s2.next_id where s2.satisfied), s3 as (select s1.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s1.ep0 as ep0, s1.n0 as n0, s1.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s1 join edge e1 on (s1.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[])) select s3.n2 as n2, count(distinct (s3.n0))::int8 as i0 from s3 group by n2) select s0.n2 as m from s0 where (s0.i0 >= 10); +with s0 as (with s1 as (with recursive s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.start_id = e0.end_id, array [e0.id] from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.id = e0.end_id where ((n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]) and ((n0.properties ->> 'enabled'))::bool = true) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and e0.kind_id = any (array [3]::int2[]) union select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.id = any (s2.path), s2.path || e0.id from s2 join edge e0 on e0.start_id = s2.next_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) and s2.depth < 15 and not s2.is_cycle) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s2.path)) as e0, s2.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s2 join node n0 on n0.id = s2.root_id join node n1 on n1.id = s2.next_id where s2.satisfied), s3 as (select s1.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s1.ep0 as ep0, s1.n0 as n0, s1.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s1 join edge e1 on (s1.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[])) select s3.n2 as n2, count(distinct (s3.n0))::int8 as i0 from s3 group by n2) select s0.n2 as m from s0 where (s0.i0 >= 10); + +-- case: match (n:NodeKind1)-[:EdgeKind1*1..]->(:NodeKind2)-[:EdgeKind2]->(m:NodeKind1) where (n:NodeKind1 or n:NodeKind2) and n.enabled = true with m, count(distinct(n)) as p where p >= 10 return m +with s0 as (with s1 as (with recursive s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.start_id = e0.end_id, array [e0.id] from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.id = e0.end_id where ((n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n0.kind_ids operator (pg_catalog.@>) array [2]::int2[]) and ((n0.properties ->> 'enabled'))::bool = true) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and e0.kind_id = any (array [3]::int2[]) union select s2.root_id, e0.end_id, s2.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.id = any (s2.path), s2.path || e0.id from s2 join edge e0 on e0.start_id = s2.next_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]) and s2.depth < 15 and not s2.is_cycle) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s2.path)) as e0, s2.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s2 join node n0 on n0.id = s2.root_id join node n1 on n1.id = s2.next_id where s2.satisfied), s3 as (select s1.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s1.ep0 as ep0, s1.n0 as n0, s1.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s1 join edge e1 on (s1.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[])) select s3.n2 as n2, count(distinct (s3.n0))::int8 as i0 from s3 group by n2) select s0.n2 as m from s0 where (s0.i0 >= 10); -- case: with 365 as max_days match (n:NodeKind1) where n.pwdlastset < (datetime().epochseconds - (max_days * 86400)) and not n.pwdlastset IN [-1.0, 0.0] return n limit 100 with s0 as (select 365 as i0), s1 as (select s0.i0 as i0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from s0, node n0 where (not ((n0.properties ->> 'pwdlastset'))::float8 = any (array [- 1, 0]::float8[]) and ((n0.properties ->> 'pwdlastset'))::numeric < (extract(epoch from now()::timestamp with time zone)::numeric - (s0.i0 * 86400))) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s1.n0 as n from s1 limit 100; @@ -69,7 +72,7 @@ with s0 as (with s1 as (with recursive s2(root_id, next_id, depth, satisfied, is with s0 as (with s1 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select s1.n1 as n1 from s1), s2 as (select (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.end_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.start_id where e1.kind_id = any (array [3]::int2[])) select s2.n1 as g from s2; -- case: match (cg:NodeKind1) where cg.name =~ ".*TT" and cg.domain = "MY DOMAIN" with collect (cg.email) as emails match (o:NodeKind1)-[:EdgeKind1]->(g:NodeKind2) where g.name starts with "blah" and not g.email in emails return o -with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((n0.properties ->> 'name') ~ '.*TT' and (n0.properties ->> 'domain') = 'MY DOMAIN') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select array_remove(coalesce(array_agg(((s1.n0).properties ->> 'email'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s1), s2 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, s0.i0 as i0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, edge e0 join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e0.end_id where (not (n2.properties ->> 'email') = any (s0.i0) and (n2.properties ->> 'name') like 'blah%') and e0.kind_id = any (array [3]::int2[])) select s2.n1 as o from s2; +with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where ((n0.properties ->> 'name') ~ '.*TT' and (n0.properties ->> 'domain') = 'MY DOMAIN') and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select array_remove(coalesce(array_agg(((s1.n0).properties ->> 'email'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s1), s2 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, s0.i0 as i0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, edge e0 join node n2 on n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e0.end_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.start_id where e0.kind_id = any (array [3]::int2[]) and (not (n2.properties ->> 'email') = any (s0.i0) and (n2.properties ->> 'name') like 'blah%')) select s2.n1 as o from s2; -- case: match (e) match p = ()-[]->(e) return p limit 1 with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0), s1 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0 join edge e0 on (s0.n0).id = e0.end_id join node n1 on n1.id = e0.start_id) select edges_to_path(variadic array [(s1.e0).id]::int8[])::pathcomposite as p from s1 limit 1; @@ -88,3 +91,4 @@ with s0 as (with s1 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.pr -- case: optional match (g:NodeKind1)<-[r:EdgeKind1]-(m:NodeKind2) with g, count(r) as memberCount where memberCount = 0 return g with s0 as (with s1 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id = e0.end_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.start_id where e0.kind_id = any (array [3]::int2[])) select s1.n0 as n0, count(s1.e0)::int8 as i0 from s1 group by n0) select s0.n0 as g from s0 where (s0.i0 = 0); + diff --git a/cypher/models/pgsql/test/translation_cases/pattern_binding.sql b/cypher/models/pgsql/test/translation_cases/pattern_binding.sql index 69d6942..4f95084 100644 --- a/cypher/models/pgsql/test/translation_cases/pattern_binding.sql +++ b/cypher/models/pgsql/test/translation_cases/pattern_binding.sql @@ -76,3 +76,4 @@ with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::e -- case: match (m:NodeKind1)-[*1..]->(g:NodeKind2)-[]->(c3:NodeKind1) where not g.name in ["foo"] with collect(g.name) as bar match p=(m:NodeKind1)-[*1..]->(g:NodeKind2) where g.name in bar return p with s0 as (with s1 as (with recursive s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.start_id = e0.end_id, array [e0.id] from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.id = e0.end_id where n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] union select s2.root_id, e0.end_id, s2.depth + 1, (not (n1.properties ->> 'name') = any (array ['foo']::text[])) and n1.kind_ids operator (pg_catalog.@>) array [2]::int2[], e0.id = any (s2.path), s2.path || e0.id from s2 join edge e0 on e0.start_id = s2.next_id join node n1 on n1.id = e0.end_id where s2.depth < 15 and not s2.is_cycle) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s2.path)) as e0, s2.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s2 join node n0 on n0.id = s2.root_id join node n1 on n1.id = s2.next_id where s2.satisfied), s3 as (select s1.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s1.ep0 as ep0, s1.n0 as n0, s1.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s1 join edge e1 on (s1.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n2.id = e1.end_id) select array_remove(coalesce(array_agg(((s3.n1).properties ->> 'name'))::anyarray, array []::text[])::anyarray, null)::anyarray as i0 from s3), s4 as (with recursive s5(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.end_id, e2.start_id, 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.end_id = e2.start_id, array [e2.id] from s0, edge e2 join node n4 on n4.id = e2.end_id join node n3 on n3.id = e2.start_id where n4.kind_ids operator (pg_catalog.@>) array [2]::int2[] and ((n4.properties ->> 'name') = any (s0.i0)) union select s5.root_id, e2.start_id, s5.depth + 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.id = any (s5.path), s5.path || e2.id from s5 join edge e2 on e2.end_id = s5.next_id join node n3 on n3.id = e2.start_id where s5.depth < 15 and not s5.is_cycle) select (select array_agg((e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties)::edgecomposite) from edge e2 where e2.id = any (s5.path)) as e2, s5.path as ep1, s0.i0 as i0, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3, (n4.id, n4.kind_ids, n4.properties)::nodecomposite as n4 from s0, s5 join node n4 on n4.id = s5.root_id join node n3 on n3.id = s5.next_id where s5.satisfied) select edges_to_path(variadic ep1)::pathcomposite as p from s4; + diff --git a/cypher/models/pgsql/test/translation_cases/pattern_expansion.sql b/cypher/models/pgsql/test/translation_cases/pattern_expansion.sql index 8351a57..085e8fc 100644 --- a/cypher/models/pgsql/test/translation_cases/pattern_expansion.sql +++ b/cypher/models/pgsql/test/translation_cases/pattern_expansion.sql @@ -27,7 +27,7 @@ with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, false, e0.end_id = e0.start_id, array [e0.id] from edge e0 union select s1.root_id, e0.start_id, s1.depth + 1, false, e0.id = any (s1.path), s1.path || e0.id from s1 join edge e0 on e0.end_id = s1.next_id where s1.depth < 5 and not s1.is_cycle) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s1.path)) as e0, s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where s1.depth >= 2) select s0.n0 as n, s0.n1 as e from s0; -- case: match p = (n)-[*..]->(e:NodeKind1) return p -with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, n1.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.start_id = e0.end_id, array [e0.id] from edge e0 join node n1 on n1.id = e0.end_id union select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.id = any (s1.path), s1.path || e0.id from s1 join edge e0 on e0.start_id = s1.next_id join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s1.path)) as e0, s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where s1.satisfied) select edges_to_path(variadic ep0)::pathcomposite as p from s0; +with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, false, e0.end_id = e0.start_id, array [e0.id] from edge e0 join node n1 on n1.id = e0.end_id where n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] union select s1.root_id, e0.start_id, s1.depth + 1, false, e0.id = any (s1.path), s1.path || e0.id from s1 join edge e0 on e0.end_id = s1.next_id where s1.depth < 15 and not s1.is_cycle) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s1.path)) as e0, s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join node n1 on n1.id = s1.root_id join node n0 on n0.id = s1.next_id) select edges_to_path(variadic ep0)::pathcomposite as p from s0; -- case: match (n)-[*..]->(e:NodeKind1) where n.name = 'n1' return e with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.start_id, e0.end_id, 1, n1.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.start_id = e0.end_id, array [e0.id] from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.id = e0.end_id where ((n0.properties ->> 'name') = 'n1') union select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.id = any (s1.path), s1.path || e0.id from s1 join edge e0 on e0.start_id = s1.next_id join node n1 on n1.id = e0.end_id where s1.depth < 15 and not s1.is_cycle) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s1.path)) as e0, s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where s1.satisfied) select s0.n1 as e from s0; @@ -78,4 +78,5 @@ with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path with s0 as (with recursive s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select e0.end_id, e0.start_id, 1, ((n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n1.kind_ids operator (pg_catalog.@>) array [2]::int2[])), e0.end_id = e0.start_id, array [e0.id] from edge e0 join node n0 on n0.id = e0.end_id join node n1 on n1.id = e0.start_id where ((n0.properties ->> 'objectid') like '%-512') and n0.kind_ids operator (pg_catalog.@>) array [2]::int2[] and e0.kind_id = any (array [3]::int2[]) union select s1.root_id, e0.start_id, s1.depth + 1, ((n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] or n1.kind_ids operator (pg_catalog.@>) array [2]::int2[])), e0.id = any (s1.path), s1.path || e0.id from s1 join edge e0 on e0.end_id = s1.next_id join node n1 on n1.id = e0.start_id where e0.kind_id = any (array [3]::int2[]) and s1.depth < 15 and not s1.is_cycle) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s1.path)) as e0, s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id where s1.satisfied) select edges_to_path(variadic ep0)::pathcomposite as p from s0 limit 1000; -- case: match p=(n:NodeKind1)-[:EdgeKind1|EdgeKind2]->(g:NodeKind1)-[:EdgeKind2]->(:NodeKind2)-[:EdgeKind1*1..]->(m:NodeKind1) where n.objectid = m.objectid return p limit 100 -with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[])), s1 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[])), s2 as (with recursive s3(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, (((s1.n0).properties -> 'objectid') = (n3.properties -> 'objectid')) and n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.start_id = e2.end_id, array [e2.id] from s1 join edge e2 on (s1.n2).id = e2.start_id join node n3 on n3.id = e2.end_id where e2.kind_id = any (array [3]::int2[]) union select s3.root_id, e2.end_id, s3.depth + 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.id = any (s3.path), s3.path || e2.id from s3 join edge e2 on e2.start_id = s3.next_id join node n3 on n3.id = e2.end_id where e2.kind_id = any (array [3]::int2[]) and s3.depth < 15 and not s3.is_cycle) select s1.e0 as e0, s1.e1 as e1, (select array_agg((e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties)::edgecomposite) from edge e2 where e2.id = any (s3.path)) as e2, s3.path as ep0, s1.n0 as n0, s1.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s1, s3 join node n2 on n2.id = s3.root_id join node n3 on n3.id = s3.next_id where s3.satisfied and (s1.n2).id = s3.root_id and (((s1.n0).properties -> 'objectid') = (n3.properties -> 'objectid'))) select edges_to_path(variadic array [(s2.e0).id, (s2.e1).id]::int8[] || s2.ep0)::pathcomposite as p from s2 limit 100; \ No newline at end of file +with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.end_id where e0.kind_id = any (array [3, 4]::int2[])), s1 as (select s0.e0 as e0, (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e1, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e1 on (s0.n1).id = e1.start_id join node n2 on n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e1.end_id where e1.kind_id = any (array [4]::int2[])), s2 as (with recursive s3(root_id, next_id, depth, satisfied, is_cycle, path) as (select e2.start_id, e2.end_id, 1, (((s1.n0).properties -> 'objectid') = (n3.properties -> 'objectid')) and n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.start_id = e2.end_id, array [e2.id] from s1 join edge e2 on (s1.n2).id = e2.start_id join node n3 on n3.id = e2.end_id where e2.kind_id = any (array [3]::int2[]) union select s3.root_id, e2.end_id, s3.depth + 1, n3.kind_ids operator (pg_catalog.@>) array [1]::int2[], e2.id = any (s3.path), s3.path || e2.id from s3 join edge e2 on e2.start_id = s3.next_id join node n3 on n3.id = e2.end_id where e2.kind_id = any (array [3]::int2[]) and s3.depth < 15 and not s3.is_cycle) select s1.e0 as e0, s1.e1 as e1, (select array_agg((e2.id, e2.start_id, e2.end_id, e2.kind_id, e2.properties)::edgecomposite) from edge e2 where e2.id = any (s3.path)) as e2, s3.path as ep0, s1.n0 as n0, s1.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n3 from s1, s3 join node n2 on n2.id = s3.root_id join node n3 on n3.id = s3.next_id where s3.satisfied and (s1.n2).id = s3.root_id and (((s1.n0).properties -> 'objectid') = (n3.properties -> 'objectid'))) select edges_to_path(variadic array [(s2.e0).id, (s2.e1).id]::int8[] || s2.ep0)::pathcomposite as p from s2 limit 100; + diff --git a/cypher/models/pgsql/test/translation_cases/quantifiers.sql b/cypher/models/pgsql/test/translation_cases/quantifiers.sql index cfa2312..7b3ff63 100644 --- a/cypher/models/pgsql/test/translation_cases/quantifiers.sql +++ b/cypher/models/pgsql/test/translation_cases/quantifiers.sql @@ -30,11 +30,17 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties ->> 'usedeskeyonly'))::bool or ((select count(*)::int from unnest(jsonb_to_text_array((n0.properties -> 'supportedencryptiontypes'))) as i0 where (i0 like '%DES%')) >= 1)::bool or ((select count(*)::int from unnest(jsonb_to_text_array((n0.properties -> 'serviceprincipalnames'))) as i1 where (lower(i1)::text like '%mssqlservercluster%' or lower(i1)::text like '%mssqlserverclustermgmtapi%' or lower(i1)::text like '%msclustervirtualserver%')) >= 1)::bool) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s0.n0 as n from s0 limit 100; -- case: MATCH (m:NodeKind1) WHERE m.unconstraineddelegation = true WITH m MATCH (n:NodeKind1)-[:EdgeKind1]->(g:NodeKind2) WHERE g.objectid ENDS WITH '-516' WITH m, COLLECT(n) AS matchingNs WHERE NONE(n IN matchingNs WHERE n.objectid = m.objectid) RETURN m -with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties ->> 'unconstraineddelegation'))::bool = true) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s1.n0 as n0 from s1), s2 as (with s3 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, edge e0 join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.start_id join node n2 on ((n2.properties ->> 'objectid') like '%-516') and n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select s3.n0 as n0, array_remove(coalesce(array_agg(s3.n1)::nodecomposite[], array []::nodecomposite[])::nodecomposite[], null)::nodecomposite[] as i0 from s3 group by n0) select s2.n0 as m from s2 where (((select count(*)::int from unnest(s2.i0) as i1 where ((i1.properties -> 'objectid') = ((s2.n0).properties -> 'objectid'))) = 0)::bool); +with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties ->> 'unconstraineddelegation'))::bool = true) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s1.n0 as n0 from s1), s2 as (with s3 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, edge e0 join node n2 on ((n2.properties ->> 'objectid') like '%-516') and n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e0.end_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.start_id where e0.kind_id = any (array [3]::int2[])) select s3.n0 as n0, array_remove(coalesce(array_agg(s3.n1)::nodecomposite[], array []::nodecomposite[])::nodecomposite[], null)::nodecomposite[] as i0 from s3 group by n0) select s2.n0 as m from s2 where (((select count(*)::int from unnest(s2.i0) as i1 where ((i1.properties -> 'objectid') = ((s2.n0).properties -> 'objectid'))) = 0)::bool); -- case: MATCH (m:NodeKind1) WHERE m.unconstraineddelegation = true WITH m MATCH (n:NodeKind1)-[:EdgeKind1]->(g:NodeKind2) WHERE g.objectid ENDS WITH '-516' WITH m, COLLECT(n) AS matchingNs WHERE ALL(n IN matchingNs WHERE n.objectid = m.objectid) RETURN m -with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties ->> 'unconstraineddelegation'))::bool = true) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s1.n0 as n0 from s1), s2 as (with s3 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, edge e0 join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.start_id join node n2 on ((n2.properties ->> 'objectid') like '%-516') and n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select s3.n0 as n0, array_remove(coalesce(array_agg(s3.n1)::nodecomposite[], array []::nodecomposite[])::nodecomposite[], null)::nodecomposite[] as i0 from s3 group by n0) select s2.n0 as m from s2 where (((select count(*)::int from unnest(s2.i0) as i1 where ((i1.properties -> 'objectid') = ((s2.n0).properties -> 'objectid'))) = array_length(s2.i0, 1))::bool); +with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties ->> 'unconstraineddelegation'))::bool = true) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s1.n0 as n0 from s1), s2 as (with s3 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, edge e0 join node n2 on ((n2.properties ->> 'objectid') like '%-516') and n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e0.end_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.start_id where e0.kind_id = any (array [3]::int2[])) select s3.n0 as n0, array_remove(coalesce(array_agg(s3.n1)::nodecomposite[], array []::nodecomposite[])::nodecomposite[], null)::nodecomposite[] as i0 from s3 group by n0) select s2.n0 as m from s2 where (((select count(*)::int from unnest(s2.i0) as i1 where ((i1.properties -> 'objectid') = ((s2.n0).properties -> 'objectid'))) = array_length(s2.i0, 1))::bool); -- case: MATCH (m:NodeKind1) WHERE ANY(name in m.serviceprincipalnames WHERE name CONTAINS "PHANTOM") WITH m MATCH (n:NodeKind1)-[:EdgeKind1]->(g:NodeKind2) WHERE g.objectid ENDS WITH '-525' WITH m, COLLECT(n) AS matchingNs WHERE NONE(t IN matchingNs WHERE t.objectid = m.objectid) RETURN m -with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((select count(*)::int from unnest(jsonb_to_text_array((n0.properties -> 'serviceprincipalnames'))) as i0 where (i0 like '%PHANTOM%')) >= 1)::bool) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s1.n0 as n0 from s1), s2 as (with s3 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, edge e0 join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.start_id join node n2 on ((n2.properties ->> 'objectid') like '%-525') and n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select s3.n0 as n0, array_remove(coalesce(array_agg(s3.n1)::nodecomposite[], array []::nodecomposite[])::nodecomposite[], null)::nodecomposite[] as i1 from s3 group by n0) select s2.n0 as m from s2 where (((select count(*)::int from unnest(s2.i1) as i2 where ((i2.properties -> 'objectid') = ((s2.n0).properties -> 'objectid'))) = 0)::bool); +with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((select count(*)::int from unnest(jsonb_to_text_array((n0.properties -> 'serviceprincipalnames'))) as i0 where (i0 like '%PHANTOM%')) >= 1)::bool) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s1.n0 as n0 from s1), s2 as (with s3 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, edge e0 join node n2 on ((n2.properties ->> 'objectid') like '%-525') and n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e0.end_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.start_id where e0.kind_id = any (array [3]::int2[])) select s3.n0 as n0, array_remove(coalesce(array_agg(s3.n1)::nodecomposite[], array []::nodecomposite[])::nodecomposite[], null)::nodecomposite[] as i1 from s3 group by n0) select s2.n0 as m from s2 where (((select count(*)::int from unnest(s2.i1) as i2 where ((i2.properties -> 'objectid') = ((s2.n0).properties -> 'objectid'))) = 0)::bool); + +-- case: MATCH (m:NodeKind1) WHERE m.unconstraineddelegation = true WITH m MATCH (n:NodeKind1)-[:EdgeKind1]->(g:NodeKind2) WHERE g.objectid ENDS WITH '-516' WITH m, COLLECT(n) AS matchingNs WHERE ALL(n IN matchingNs WHERE n.objectid = m.objectid) RETURN m +with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((n0.properties ->> 'unconstraineddelegation'))::bool = true) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s1.n0 as n0 from s1), s2 as (with s3 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, edge e0 join node n2 on ((n2.properties ->> 'objectid') like '%-516') and n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e0.end_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.start_id where e0.kind_id = any (array [3]::int2[])) select s3.n0 as n0, array_remove(coalesce(array_agg(s3.n1)::nodecomposite[], array []::nodecomposite[])::nodecomposite[], null)::nodecomposite[] as i0 from s3 group by n0) select s2.n0 as m from s2 where (((select count(*)::int from unnest(s2.i0) as i1 where ((i1.properties -> 'objectid') = ((s2.n0).properties -> 'objectid'))) = array_length(s2.i0, 1))::bool); + +-- case: MATCH (m:NodeKind1) WHERE ANY(name in m.serviceprincipalnames WHERE name CONTAINS "PHANTOM") WITH m MATCH (n:NodeKind1)-[:EdgeKind1]->(g:NodeKind2) WHERE g.objectid ENDS WITH '-525' WITH m, COLLECT(n) AS matchingNs WHERE NONE(t IN matchingNs WHERE t.objectid = m.objectid) RETURN m +with s0 as (with s1 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0 where (((select count(*)::int from unnest(jsonb_to_text_array((n0.properties -> 'serviceprincipalnames'))) as i0 where (i0 like '%PHANTOM%')) >= 1)::bool) and n0.kind_ids operator (pg_catalog.@>) array [1]::int2[]) select s1.n0 as n0 from s1), s2 as (with s3 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, edge e0 join node n2 on ((n2.properties ->> 'objectid') like '%-525') and n2.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n2.id = e0.end_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.start_id where e0.kind_id = any (array [3]::int2[])) select s3.n0 as n0, array_remove(coalesce(array_agg(s3.n1)::nodecomposite[], array []::nodecomposite[])::nodecomposite[], null)::nodecomposite[] as i1 from s3 group by n0) select s2.n0 as m from s2 where (((select count(*)::int from unnest(s2.i1) as i2 where ((i2.properties -> 'objectid') = ((s2.n0).properties -> 'objectid'))) = 0)::bool); diff --git a/cypher/models/pgsql/test/translation_cases/shortest_paths.sql b/cypher/models/pgsql/test/translation_cases/shortest_paths.sql index ae8ae6c..15c91fd 100644 --- a/cypher/models/pgsql/test/translation_cases/shortest_paths.sql +++ b/cypher/models/pgsql/test/translation_cases/shortest_paths.sql @@ -43,8 +43,8 @@ with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (sele with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_sp_harness(@pi0::text, @pi1::text, 15)) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s1.path)) as e0, s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id) select edges_to_path(variadic ep0)::pathcomposite as p from s0; -- case: match p=shortestPath((a)-[:EdgeKind1*]->(b:NodeKind1)) where a <> b return p --- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select e0.start_id, e0.end_id, 1, n1.kind_ids operator (pg_catalog.@\u003e) array [1]::int2[], e0.start_id = e0.end_id, array [e0.id] from edge e0 join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]);","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@\u003e) array [1]::int2[], e0.id = any (s1.path), s1.path || e0.id from forward_front s1 join edge e0 on e0.start_id = s1.next_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]);"} -with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_sp_harness(@pi0::text, @pi1::text, 15)) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s1.path)) as e0, s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id) select edges_to_path(variadic ep0)::pathcomposite as p from s0 where ((s0.n0).id <> (s0.n1).id); +-- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select e0.end_id, e0.start_id, 1, exists (select 1 from edge where end_id = e0.end_id), e0.start_id = e0.end_id, array [e0.id] from edge e0 join node n1 on n1.id = e0.end_id where n1.kind_ids operator (pg_catalog.@\u003e) array [1]::int2[] and e0.kind_id = any (array [3]::int2[]);","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.start_id, s1.depth + 1, exists (select 1 from edge where end_id = e0.end_id), e0.id = any (s1.path), s1.path || e0.id from forward_front s1 join edge e0 on e0.end_id = s1.next_id where e0.kind_id = any (array [3]::int2[]);"} +with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_sp_harness(@pi0::text, @pi1::text, 15)) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s1.path)) as e0, s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join node n1 on n1.id = s1.root_id join node n0 on n0.id = s1.next_id) select edges_to_path(variadic ep0)::pathcomposite as p from s0 where ((s0.n0).id <> (s0.n1).id); -- case: match p=shortestPath((a:NodeKind2)-[:EdgeKind1*]->(b)) where a <> b return p -- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select e0.start_id, e0.end_id, 1, exists (select 1 from edge where end_id = e0.start_id), e0.start_id = e0.end_id, array [e0.id] from edge e0 join node n0 on n0.id = e0.start_id where n0.kind_ids operator (pg_catalog.@\u003e) array [2]::int2[] and e0.kind_id = any (array [3]::int2[]);","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.end_id, s1.depth + 1, exists (select 1 from edge where end_id = e0.start_id), e0.id = any (s1.path), s1.path || e0.id from forward_front s1 join edge e0 on e0.start_id = s1.next_id where e0.kind_id = any (array [3]::int2[]);"} @@ -59,9 +59,10 @@ with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (sele with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from bidirectional_asp_harness(@pi0::text, @pi1::text, @pi2::text, @pi3::text, 15)) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s1.path)) as e0, s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join node n1 on n1.id = s1.root_id join node n0 on n0.id = s1.next_id) select edges_to_path(variadic ep0)::pathcomposite as p from s0 where ((s0.n1).id <> (s0.n0).id); -- case: match p=shortestPath((a)-[:EdgeKind1*]->(b:NodeKind1)) where a <> b return p --- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select e0.start_id, e0.end_id, 1, n1.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.start_id = e0.end_id, array [e0.id] from edge e0 join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]);", "pi1": "insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.end_id, s1.depth + 1, n1.kind_ids operator (pg_catalog.@>) array [1]::int2[], e0.id = any (s1.path), s1.path || e0.id from forward_front s1 join edge e0 on e0.start_id = s1.next_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[]);"} -with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_sp_harness(@pi0::text, @pi1::text, 15)) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s1.path)) as e0, s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join node n0 on n0.id = s1.root_id join node n1 on n1.id = s1.next_id) select edges_to_path(variadic ep0)::pathcomposite as p from s0 where ((s0.n0).id <> (s0.n1).id); +-- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select e0.end_id, e0.start_id, 1, exists (select 1 from edge where end_id = e0.end_id), e0.start_id = e0.end_id, array [e0.id] from edge e0 join node n1 on n1.id = e0.end_id where n1.kind_ids operator (pg_catalog.@\u003e) array [1]::int2[] and e0.kind_id = any (array [3]::int2[]);","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s1.root_id, e0.start_id, s1.depth + 1, exists (select 1 from edge where end_id = e0.end_id), e0.id = any (s1.path), s1.path || e0.id from forward_front s1 join edge e0 on e0.end_id = s1.next_id where e0.kind_id = any (array [3]::int2[]);"} +with s0 as (with s1(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_sp_harness(@pi0::text, @pi1::text, 15)) select (select array_agg((e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite) from edge e0 where e0.id = any (s1.path)) as e0, s1.path as ep0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s1 join node n1 on n1.id = s1.root_id join node n0 on n0.id = s1.next_id) select edges_to_path(variadic ep0)::pathcomposite as p from s0 where ((s0.n0).id <> (s0.n1).id); -- case: match p=(c:NodeKind1)-[]->(u:NodeKind2) match p2=shortestPath((u:NodeKind2)-[*1..]->(d:NodeKind1)) return p, p2 limit 500 --- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select e1.start_id, e1.end_id, 1, n2.kind_ids operator (pg_catalog.@>) array [1]::int2[], e1.start_id = e1.end_id, array [e1.id] from edge e1 join node n1 on n1.id = e1.start_id join node n2 on n2.id = e1.end_id where n1.kind_ids operator (pg_catalog.@>) array [2]::int2[];", "pi1": "insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s2.root_id, e1.end_id, s2.depth + 1, n2.kind_ids operator (pg_catalog.@>) array [1]::int2[], e1.id = any (s2.path), s2.path || e1.id from forward_front s2 join edge e1 on e1.start_id = s2.next_id join node n2 on n2.id = e1.end_id;"} +-- pgsql_params:{"pi0":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select e1.start_id, e1.end_id, 1, n2.kind_ids operator (pg_catalog.@\u003e) array [1]::int2[], e1.start_id = e1.end_id, array [e1.id] from edge e1 join node n1 on n1.id = e1.start_id join node n2 on n2.id = e1.end_id where n1.kind_ids operator (pg_catalog.@\u003e) array [2]::int2[];","pi1":"insert into next_front (root_id, next_id, depth, satisfied, is_cycle, path) select s2.root_id, e1.end_id, s2.depth + 1, n2.kind_ids operator (pg_catalog.@\u003e) array [1]::int2[], e1.id = any (s2.path), s2.path || e1.id from forward_front s2 join edge e1 on e1.start_id = s2.next_id join node n2 on n2.id = e1.end_id;"} with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [2]::int2[] and n1.id = e0.end_id), s1 as (with s2(root_id, next_id, depth, satisfied, is_cycle, path) as (select * from unidirectional_sp_harness(@pi0::text, @pi1::text, 15)) select s0.e0 as e0, (select array_agg((e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite) from edge e1 where e1.id = any (s2.path)) as e1, s2.path as ep0, s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0, s2 join node n1 on n1.id = s2.root_id join node n2 on n2.id = s2.next_id where (s0.n1).id = s2.root_id) select edges_to_path(variadic array [(s1.e0).id]::int8[])::pathcomposite as p, edges_to_path(variadic ep0)::pathcomposite as p2 from s1 limit 500; + diff --git a/cypher/models/pgsql/test/translation_cases/stepwise_traversal.sql b/cypher/models/pgsql/test/translation_cases/stepwise_traversal.sql index cf43c8b..ec41768 100644 --- a/cypher/models/pgsql/test/translation_cases/stepwise_traversal.sql +++ b/cypher/models/pgsql/test/translation_cases/stepwise_traversal.sql @@ -36,12 +36,12 @@ with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::e with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select count(s0.e0)::int8 as the_count from s0; -- case: match ()-[r:EdgeKind1]->({name: "123"}) return count(r) as the_count -with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on n0.id = e0.start_id join node n1 on (n1.properties ->> 'name') = '123' and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select count(s0.e0)::int8 as the_count from s0; +with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n1 on (n1.properties ->> 'name') = '123' and n1.id = e0.end_id join node n0 on n0.id = e0.start_id where e0.kind_id = any (array [3]::int2[])) select count(s0.e0)::int8 as the_count from s0; -- case: match (s)-[r]->(e) where id(e) = $a and not (id(s) = $b) and (r:EdgeKind1 or r:EdgeKind2) and not (s.objectid ends with $c or e.objectid ends with $d) return distinct id(s), id(r), id(e) -- cypher_params: {"a":1,"b":2,"c":"123","d":"456"} -- pgsql_params:{"pi0":1,"pi1":2,"pi2":"123","pi3":"456"} -with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on (not (n0.id = @pi1::float8)) and n0.id = e0.start_id join node n1 on n1.id = e0.end_id where (not ((n0.properties ->> 'objectid') like '%' || @pi2::text or (n1.properties ->> 'objectid') like '%' || @pi3::text) and n1.id = @pi0::float8) and ((e0.kind_id = any (array [3]::int2[]) or e0.kind_id = any (array [4]::int2[])))) select (s0.n0).id, (s0.e0).id, (s0.n1).id from s0; +with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n1 on n1.id = e0.end_id join node n0 on (not (n0.id = @pi1::float8)) and n0.id = e0.start_id where ((e0.kind_id = any (array [3]::int2[]) or e0.kind_id = any (array [4]::int2[]))) and (not ((n0.properties ->> 'objectid') like '%' || @pi2::text or (n1.properties ->> 'objectid') like '%' || @pi3::text) and n1.id = @pi0::float8)) select (s0.n0).id, (s0.e0).id, (s0.n1).id from s0; -- case: match (s)-[r]->(e) where s.name = '123' and e:NodeKind1 and not r.property return s, r, e with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on ((n0.properties ->> 'name') = '123') and n0.id = e0.start_id join node n1 on (n1.kind_ids operator (pg_catalog.@>) array [1]::int2[]) and n1.id = e0.end_id where (not ((e0.properties ->> 'property'))::bool)) select s0.n0 as s, s0.e0 as r, s0.n1 as e from s0; @@ -86,7 +86,7 @@ with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::e with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.id = e0.end_id where (e0.properties ->> 'prop') = 'a' and e0.kind_id = any (array [3]::int2[])) select s0.n0 as s from s0 where ((with s1 as (select s0.e0 as e0, s0.n0 as n0, s0.n1 as n1, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n2 from s0 join edge e0 on (s0.n0).id = (s0.e0).start_id join node n2 on n2.id = (s0.e0).end_id) select count(*) > 0 from s1)); -- case: match (s)-[r:EdgeKind1]->(e) where not (s.system_tags contains 'admin_tier_0') and id(e) = 1 return id(s), labels(s), id(r), type(r) -with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on (not (coalesce((n0.properties ->> 'system_tags'), '')::text like '%admin\_tier\_0%')) and n0.id = e0.start_id join node n1 on (n1.id = 1) and n1.id = e0.end_id where e0.kind_id = any (array [3]::int2[])) select (s0.n0).id, (s0.n0).kind_ids, (s0.e0).id, (s0.e0).kind_id from s0; +with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n1 on (n1.id = 1) and n1.id = e0.end_id join node n0 on (not (coalesce((n0.properties ->> 'system_tags'), '')::text like '%admin\_tier\_0%')) and n0.id = e0.start_id where e0.kind_id = any (array [3]::int2[])) select (s0.n0).id, (s0.n0).kind_ids, (s0.e0).id, (s0.e0).kind_id from s0; -- case: match (s)-[r]->(e) where s:NodeKind1 and toLower(s.name) starts with 'test' and r:EdgeKind1 and id(e) in [1, 2] return r limit 1 with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on (n0.kind_ids operator (pg_catalog.@>) array [1]::int2[] and lower((n0.properties ->> 'name'))::text like 'test%') and n0.id = e0.start_id join node n1 on (n1.id = any (array [1, 2]::int8[])) and n1.id = e0.end_id where (e0.kind_id = any (array [3]::int2[]))) select s0.e0 as r from s0 limit 1; diff --git a/cypher/models/pgsql/test/translation_cases/update.sql b/cypher/models/pgsql/test/translation_cases/update.sql index 04741d2..85de1f0 100644 --- a/cypher/models/pgsql/test/translation_cases/update.sql +++ b/cypher/models/pgsql/test/translation_cases/update.sql @@ -45,10 +45,13 @@ with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0), s1 as (select s0.n0 as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from s0, node n1), s2 as (update node n2 set properties = n2.properties || jsonb_build_object('target', true)::jsonb from s1 where (s1.n0).id = n2.id returning (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n0, s1.n1 as n1), s3 as (update node n3 set properties = n3.properties || jsonb_build_object('target', true)::jsonb from s2 where (s2.n1).id = n3.id returning s2.n0 as n0, (n3.id, n3.kind_ids, n3.properties)::nodecomposite as n1) select s3.n0 as n1, s3.n1 as n3 from s3; -- case: match ()-[r]->(:NodeKind1) set r.is_special_outbound = true -with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.end_id), s1 as (update edge e1 set properties = e1.properties || jsonb_build_object('is_special_outbound', true)::jsonb from s0 where (s0.e0).id = e1.id returning (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e0, s0.n0 as n0, s0.n1 as n1) select 1; +with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.end_id join node n0 on n0.id = e0.start_id), s1 as (update edge e1 set properties = e1.properties || jsonb_build_object('is_special_outbound', true)::jsonb from s0 where (s0.e0).id = e1.id returning (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e0, s0.n0 as n0, s0.n1 as n1) select 1; -- case: match (a)-[r]->(:NodeKind1) set a.name = '123', r.is_special_outbound = true -with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n0 on n0.id = e0.start_id join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.end_id), s1 as (update node n2 set properties = n2.properties || jsonb_build_object('name', '123')::jsonb from s0 where (s0.n0).id = n2.id returning s0.e0 as e0, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n0, s0.n1 as n1), s2 as (update edge e1 set properties = e1.properties || jsonb_build_object('is_special_outbound', true)::jsonb from s1 where (s1.e0).id = e1.id returning (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e0, s1.n0 as n0, s1.n1 as n1) select 1; +with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.end_id join node n0 on n0.id = e0.start_id), s1 as (update node n2 set properties = n2.properties || jsonb_build_object('name', '123')::jsonb from s0 where (s0.n0).id = n2.id returning s0.e0 as e0, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n0, s0.n1 as n1), s2 as (update edge e1 set properties = e1.properties || jsonb_build_object('is_special_outbound', true)::jsonb from s1 where (s1.e0).id = e1.id returning (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e0, s1.n0 as n0, s1.n1 as n1) select 1; + +-- case: match (a)-[r]->(:NodeKind1) set a.name = '123', r.is_special_outbound = true +with s0 as (select (e0.id, e0.start_id, e0.end_id, e0.kind_id, e0.properties)::edgecomposite as e0, (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0, (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n1 from edge e0 join node n1 on n1.kind_ids operator (pg_catalog.@>) array [1]::int2[] and n1.id = e0.end_id join node n0 on n0.id = e0.start_id), s1 as (update node n2 set properties = n2.properties || jsonb_build_object('name', '123')::jsonb from s0 where (s0.n0).id = n2.id returning s0.e0 as e0, (n2.id, n2.kind_ids, n2.properties)::nodecomposite as n0, s0.n1 as n1), s2 as (update edge e1 set properties = e1.properties || jsonb_build_object('is_special_outbound', true)::jsonb from s1 where (s1.e0).id = e1.id returning (e1.id, e1.start_id, e1.end_id, e1.kind_id, e1.properties)::edgecomposite as e0, s1.n0 as n0, s1.n1 as n1) select 1; -- case: match (s) remove s.name with s0 as (select (n0.id, n0.kind_ids, n0.properties)::nodecomposite as n0 from node n0), s1 as (update node n1 set properties = n1.properties - array ['name']::text[] from s0 where (s0.n0).id = n1.id returning (n1.id, n1.kind_ids, n1.properties)::nodecomposite as n0) select 1; diff --git a/cypher/models/pgsql/translate/constraints.go b/cypher/models/pgsql/translate/constraints.go index 6aea707..56a3ace 100644 --- a/cypher/models/pgsql/translate/constraints.go +++ b/cypher/models/pgsql/translate/constraints.go @@ -383,11 +383,19 @@ func (s *PatternConstraints) OptimizePatternConstraintBalance(scope *Scope, trav return nil } - if leftNodeSelectivity, err := MeasureSelectivity(scope, traversalStep.LeftNodeBound, s.LeftNode.Expression); err != nil { + if traversalStep.RightNodeBound { + // Right is the tighter anchor by definition; flip once to normalize it to the left. + traversalStep.FlipNodes() + s.FlipNodes() + + return nil + } + + if leftNodeSelectivity, err := MeasureSelectivity(scope, s.LeftNode.Expression); err != nil { return err - } else if rightNodeSelectivity, err := MeasureSelectivity(scope, traversalStep.RightNodeBound, s.RightNode.Expression); err != nil { + } else if rightNodeSelectivity, err := MeasureSelectivity(scope, s.RightNode.Expression); err != nil { return err - } else if rightNodeSelectivity > leftNodeSelectivity { + } else if rightNodeSelectivity-leftNodeSelectivity >= selectivityFlipThreshold { // (a)-[*..]->(b:Constraint) // (a)<-[*..]-(b:Constraint) traversalStep.FlipNodes() diff --git a/cypher/models/pgsql/translate/constraints_test.go b/cypher/models/pgsql/translate/constraints_test.go index 0cfb548..32b9d19 100644 --- a/cypher/models/pgsql/translate/constraints_test.go +++ b/cypher/models/pgsql/translate/constraints_test.go @@ -9,7 +9,7 @@ import ( ) func TestMeasureSelectivity(t *testing.T) { - selectivity, err := MeasureSelectivity(NewScope(), false, pgd.Equals( + selectivity, err := MeasureSelectivity(NewScope(), pgd.Equals( pgsql.Identifier("123"), pgsql.Identifier("456"), )) diff --git a/cypher/models/pgsql/translate/selectivity.go b/cypher/models/pgsql/translate/selectivity.go index df4c00a..d758260 100644 --- a/cypher/models/pgsql/translate/selectivity.go +++ b/cypher/models/pgsql/translate/selectivity.go @@ -42,6 +42,15 @@ const ( // Disjunctions expand search space by adding a secondary, conditional operation selectivityWeightDisjunction = -100 + + // selectivityFlipThreshold is the minimum score advantage the right-hand node must hold + // over the left-hand node before OptimizePatternConstraintBalance commits to a traversal + // direction flip. It is set to selectivityWeightNarrowSearch so that structural AST noise + // — in particular the per-AND-node conjunction bonus — cannot trigger a flip on its own. + // A single meaningful narrowing predicate (=, IN, kind filter) on the right side is + // sufficient to clear this bar; a bare AND connector (weight 5) or a range comparison on + // an unindexed property (weight 10) is not. + selectivityFlipThreshold = selectivityWeightNarrowSearch ) // knownNodePropertySelectivity is a hack to enable the selectivity measurement to take advantage of known property indexes @@ -92,6 +101,20 @@ func (s *measureSelectivityVisitor) addSelectivity(value int) { } } +func isColumnIDRef(expression pgsql.Expression) bool { + switch typedExpression := expression.(type) { + case pgsql.CompoundIdentifier: + if typedExpression.HasField() { + switch typedExpression.Field() { + case pgsql.ColumnID: + return true + } + } + } + + return false +} + func (s *measureSelectivityVisitor) Enter(node pgsql.SyntaxNode) { switch typedNode := node.(type) { case *pgsql.UnaryExpression: @@ -101,29 +124,20 @@ func (s *measureSelectivityVisitor) Enter(node pgsql.SyntaxNode) { } case *pgsql.BinaryExpression: - switch typedLOperand := typedNode.LOperand.(type) { - case pgsql.CompoundIdentifier: - if typedLOperand.HasField() { - switch typedLOperand.Field() { - case pgsql.ColumnID: - // Identifier references typically have high selectivity. This might be a nested reference, reducing the - // effectiveness of the heuristic but the benefits outweigh this deficiency - s.addSelectivity(selectivityWeightEntityIDReference) - } - } + var ( + lOperandIsID = isColumnIDRef(typedNode.LOperand) + rOperandIsID = isColumnIDRef(typedNode.ROperand) + ) + + if lOperandIsID && !rOperandIsID { + // Point lookup: n0.id = — highly selective + s.addSelectivity(selectivityWeightEntityIDReference) + } else if rOperandIsID && !lOperandIsID { + // Canonically unusual, but handle it the same + s.addSelectivity(selectivityWeightEntityIDReference) } - switch typedROperand := typedNode.ROperand.(type) { - case pgsql.CompoundIdentifier: - if typedROperand.HasField() { - switch typedROperand.Field() { - case pgsql.ColumnID: - // Identifier references typically have high selectivity. This might be a nested reference, reducing the - // effectiveness of the heuristic but the benefits outweigh this deficiency - s.addSelectivity(selectivityWeightEntityIDReference) - } - } - } + // If both sides are ID refs, this is a join condition — do not score as a point lookup switch typedNode.Operator { case pgsql.OperatorOr: @@ -141,9 +155,16 @@ func (s *measureSelectivityVisitor) Enter(node pgsql.SyntaxNode) { case pgsql.OperatorLike, pgsql.OperatorILike, pgsql.OperatorRegexMatch, pgsql.OperatorSimilarTo: s.addSelectivity(selectivityWeightStringSearch) - case pgsql.OperatorIn, pgsql.OperatorEquals, pgsql.OperatorIs, pgsql.OperatorPGArrayOverlap, pgsql.OperatorArrayOverlap: + case pgsql.OperatorIn, pgsql.OperatorEquals, pgsql.OperatorIs: + s.addSelectivity(selectivityWeightNarrowSearch) + + case pgsql.OperatorPGArrayOverlap, pgsql.OperatorArrayOverlap: s.addSelectivity(selectivityWeightNarrowSearch) + case pgsql.OperatorPGArrayLHSContainsRHS: + // @> is strictly more selective than &&: all kind_ids must be present. + s.addSelectivity(selectivityWeightNarrowSearch + selectivityWeightConjunction) + case pgsql.OperatorJSONField, pgsql.OperatorJSONTextField, pgsql.OperatorPropertyLookup: if propertyLookup, err := binaryExpressionToPropertyLookup(typedNode); err != nil { s.SetError(err) @@ -187,14 +208,9 @@ func (s *measureSelectivityVisitor) Exit(node pgsql.SyntaxNode) { // bound component is considered to be highly-selective. // // Many numbers are magic values selected based on implementor's perception of selectivity of certain operators. -func MeasureSelectivity(scope *Scope, owningIdentifierBound bool, expression pgsql.Expression) (int, error) { +func MeasureSelectivity(scope *Scope, expression pgsql.Expression) (int, error) { visitor := newMeasureSelectivityVisitor(scope) - // If the identifier is reified at this stage in the query then it's already selected - if owningIdentifierBound { - visitor.addSelectivity(selectivityWeightBoundIdentifier) - } - if expression != nil { if err := walk.PgSQL(expression, visitor); err != nil { return 0, err diff --git a/cypher/models/pgsql/translate/traversal.go b/cypher/models/pgsql/translate/traversal.go index 20553d2..2776c79 100644 --- a/cypher/models/pgsql/translate/traversal.go +++ b/cypher/models/pgsql/translate/traversal.go @@ -383,6 +383,10 @@ func (s *Translator) translateTraversalPatternPartWithoutExpansion(isFirstTraver return err } else { if isFirstTraversalStep { + if err := constraints.OptimizePatternConstraintBalance(s.scope, traversalStep); err != nil { + return err + } + hasPreviousFrame := traversalStep.Frame.Previous != nil if hasPreviousFrame { diff --git a/go.mod b/go.mod index 39ae1dd..b6f080b 100644 --- a/go.mod +++ b/go.mod @@ -30,7 +30,6 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect - golang.org/x/net v0.50.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/text v0.35.0 // indirect google.golang.org/protobuf v1.36.6 // indirect diff --git a/go.sum b/go.sum index 8f95986..b4fd42a 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,9 @@ -cuelabs.dev/go/oci/ociregistry v0.0.0-20250722084951-074d06050084 h1:4k1yAtPvZJZQTu8DRY8muBo0LHv6TqtrE0AO5n6IPYs= -cuelabs.dev/go/oci/ociregistry v0.0.0-20250722084951-074d06050084/go.mod h1:4WWeZNxUO1vRoZWAHIG0KZOd6dA25ypyWuwD3ti0Tdc= cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819 h1:Zh+Ur3OsoWpvALHPLT45nOekHkgOt+IOfutBbPqM17I= -cuelang.org/go v0.15.3 h1:JKR/lZVwuIGlLTGIaJ0jONz9+CK3UDx06sQ6DDxNkaE= -cuelang.org/go v0.15.3/go.mod h1:NYw6n4akZcTjA7QQwJ1/gqWrrhsN4aZwhcAL0jv9rZE= +cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819/go.mod h1:WjmQxb+W6nVNCgj8nXrF24lIz95AHwnSl36tpjDZSU8= cuelang.org/go v0.16.0 h1:mmt9SL/IzfSIiBKuP5wxdO4xLjvIHr3urpbjCDdMV5U= cuelang.org/go v0.16.0/go.mod h1:4veMX+GpsK0B91b1seGXoozG80LJCczvG1M1Re/knxo= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= -github.com/RoaringBitmap/roaring/v2 v2.14.4 h1:4aKySrrg9G/5oRtJ3TrZLObVqxgQ9f1znCRBwEwjuVw= -github.com/RoaringBitmap/roaring/v2 v2.14.4/go.mod h1:oMvV6omPWr+2ifRdeZvVJyaz+aoEUopyv5iH0u/+wbY= github.com/RoaringBitmap/roaring/v2 v2.16.0 h1:Kys1UNf49d5W8Tq3bpuAhIr/Z8/yPB+59CO8A6c/BbE= github.com/RoaringBitmap/roaring/v2 v2.16.0/go.mod h1:eq4wdNXxtJIS/oikeCzdX1rBzek7ANzbth041hrU8Q4= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= @@ -19,10 +14,7 @@ github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoG github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= -github.com/cockroachdb/apd/v3 v3.2.1 h1:U+8j7t0axsIgvQUqthuNm82HIrYXodOV2iWLWtEaIwg= -github.com/cockroachdb/apd/v3 v3.2.1/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc= github.com/cockroachdb/apd/v3 v3.2.2 h1:R1VaDQkMR321HBM6+6b2eYZfxi0ybPJgUh0Ztr7twzU= github.com/cockroachdb/apd/v3 v3.2.2/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= @@ -33,11 +25,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 h1:ucRHb6/lvW/+mTEIGbvhcYU3S8+uSNkuMjx/qZFfhtM= github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw= -github.com/emicklei/proto v1.14.2 h1:wJPxPy2Xifja9cEMrcA/g08art5+7CGJNFNk35iXC1I= -github.com/emicklei/proto v1.14.2/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A= github.com/emicklei/proto v1.14.3 h1:zEhlzNkpP8kN6utonKMzlPfIvy82t5Kb9mufaJxSe1Q= -github.com/gammazero/deque v1.2.0 h1:scEFO8Uidhw6KDU5qg1HA5fYwM0+us2qdeJqm43bitU= -github.com/gammazero/deque v1.2.0/go.mod h1:JVrR+Bj1NMQbPnYclvDlvSX0nVGReLrQZ0aUMuWLctg= +github.com/emicklei/proto v1.14.3/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A= github.com/gammazero/deque v1.2.1 h1:9fnQVFCCZ9/NOc7ccTNqzoKd1tCWOqeI05/lPqFPMGQ= github.com/gammazero/deque v1.2.1/go.mod h1:5nSFkzVm+afG9+gy0VIowlqVAW4N8zNcMne+CMQVD2g= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= @@ -103,7 +92,6 @@ github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshN github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= -github.com/jackc/puddle v1.3.0 h1:eHK/5clGOatcjX3oWGBO/MpxpbHzSwud5EWTSCI+MX0= github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= @@ -146,9 +134,8 @@ github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/protocolbuffers/txtpbfmt v0.0.0-20251016062345-16587c79cd91 h1:s1LvMaU6mVwoFtbxv/rCZKE7/fwDmDY684FfUe4c1Io= -github.com/protocolbuffers/txtpbfmt v0.0.0-20251016062345-16587c79cd91/go.mod h1:JSbkp0BviKovYYt9XunS95M3mLPibE9bGg+Y95DsEEY= github.com/protocolbuffers/txtpbfmt v0.0.0-20260217160748-a481f6a22f94 h1:2PC6Ql3jipz1KvBlqUHjjk6v4aMwE86mfDu1XMH0LR8= +github.com/protocolbuffers/txtpbfmt v0.0.0-20260217160748-a481f6a22f94/go.mod h1:JSbkp0BviKovYYt9XunS95M3mLPibE9bGg+Y95DsEEY= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= @@ -204,8 +191,6 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= -golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= @@ -213,9 +198,8 @@ golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKG golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -227,14 +211,11 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= -golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= -golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -269,8 +250,6 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -284,9 +263,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=