Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,19 @@ indexes created on expressions such as `properties ->> 'objectid'` and `properti
anchors without matching JSON booleans or numbers. Simple relationship count fast paths depend on the schema's
`kind_id`-first edge index for efficient typed counts.

PostgreSQL property index regression coverage is hard-failing under the `manual_integration` tag. The synthetic plan
test translates Cypher to PgSQL, disables sequential scans for the `EXPLAIN`, and requires explicit node property
indexes to appear in the JSON plan:

```bash
CONNECTION_STRING="postgresql://dawgs:weneedbetterpasswords@localhost:65432/dawgs" \
go test -tags manual_integration ./integration -run TestPostgreSQLPropertyIndexPlans
```

Substring and suffix predicates are intentionally not promoted to blanket schema indexes. PostgreSQL deployments can
request explicit `TextSearchIndex`/trigram property indexes for fields that need `CONTAINS`, `STARTS WITH`, or
`ENDS WITH`, but default schema assertion should wait until all suffix forms share one semantics-preserving lowering.
`ENDS WITH`. The hard regression only asserts current index-compatible literal forms; dynamic parameter/property forms
that lower to helper functions are intentionally outside that contract until their lowering changes.

Thresholds are report-only by default. To enforce the configured thresholds, run:

Expand Down
6 changes: 4 additions & 2 deletions cypher/Cypher Syntax Support.md
Original file line number Diff line number Diff line change
Expand Up @@ -452,8 +452,10 @@ match (n) where n.name = '1234' return n
will use the `name` index regardless of node label.

For substring and suffix searches, PostgreSQL can use explicit `TextSearchIndex`/trigram expression indexes requested
by schema, but CySQL does not add blanket suffix indexes during default schema assertion. Suffix forms are still being
kept conservative so `ENDS WITH`, reversed operands, null handling, and string type semantics remain backend-equivalent.
by schema, but CySQL does not add blanket suffix indexes during default schema assertion. Current hard PostgreSQL plan
regression coverage is limited to literal `CONTAINS`, `STARTS WITH`, and `ENDS WITH` forms that lower directly to
`LIKE` over `properties ->> key`. Dynamic parameter/property forms that lower to helper functions remain outside the
index-match contract until their lowering changes.

### null Behavior

Expand Down
81 changes: 76 additions & 5 deletions cypher/models/pgsql/translate/predicate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,83 @@ func TestDynamicStringPredicatesUseHelperFunctions(t *testing.T) {
}

func TestLiteralStringPredicatesKeepLikePatterns(t *testing.T) {
formatted := translatePredicateQuery(t, `MATCH (n:NodeKind1) WHERE n.name CONTAINS 'needle' RETURN n`, nil)
for _, testCase := range []struct {
name string
query string
expected string
}{
{
name: "contains",
query: `MATCH (n:NodeKind1) WHERE n.name CONTAINS 'needle' RETURN n`,
expected: "((n0.properties ->> 'name') like '%needle%')",
},
{
name: "starts with",
query: `MATCH (n:NodeKind1) WHERE n.name STARTS WITH 'prefix' RETURN n`,
expected: "((n0.properties ->> 'name') like 'prefix%')",
},
{
name: "ends with",
query: `MATCH (n:NodeKind1) WHERE n.name ENDS WITH 'suffix' RETURN n`,
expected: "((n0.properties ->> 'name') like '%suffix')",
},
} {
t.Run(testCase.name, func(t *testing.T) {
formatted := translatePredicateQuery(t, testCase.query, nil)

require.Contains(t, formatted, " like ")
require.Contains(t, formatted, "'%needle%'")
require.NotContains(t, formatted, "cypher_contains(")
require.Equal(t, 1, strings.Count(formatted, " like "))
require.Contains(t, formatted, testCase.expected)
require.Contains(t, formatted, " like ")
require.NotContains(t, formatted, "cypher_contains(")
require.NotContains(t, formatted, "cypher_starts_with(")
require.NotContains(t, formatted, "cypher_ends_with(")
require.NotContains(t, formatted, "coalesce(")
require.Equal(t, 1, strings.Count(formatted, " like "))
})
}
}

func TestStringPropertyEqualityKeepsBTreeIndexableTextLookup(t *testing.T) {
for _, testCase := range []struct {
name string
query string
parameters map[string]any
expected string
}{
{
name: "untyped parameter equality",
query: `MATCH (n) WHERE n.objectid = $objectid RETURN n`,
parameters: map[string]any{"objectid": "S-1-5-21-1"},
expected: "jsonb_typeof((n0.properties -> 'objectid')) = 'string' and (n0.properties ->> 'objectid') = @pi0::text",
},
{
name: "typed parameter equality",
query: `MATCH (n:NodeKind1) WHERE n.objectid = $objectid RETURN n`,
parameters: map[string]any{"objectid": "S-1-5-21-1"},
expected: "jsonb_typeof((n0.properties -> 'objectid')) = 'string' and (n0.properties ->> 'objectid') = @pi0::text",
},
{
name: "inline property map equality",
query: `MATCH (n:NodeKind1 {name: 'indexed-name'}) RETURN n`,
expected: "jsonb_typeof((n0.properties -> 'name')) = 'string' and (n0.properties ->> 'name') = 'indexed-name'",
},
{
name: "reversed literal equality",
query: `MATCH (n) WHERE 'S-1-5-21-1' = n.objectid RETURN n`,
expected: "jsonb_typeof((n0.properties -> 'objectid')) = 'string' and 'S-1-5-21-1' = (n0.properties ->> 'objectid')",
},
} {
t.Run(testCase.name, func(t *testing.T) {
formatted := translatePredicateQuery(t, testCase.query, testCase.parameters)
normalized := strings.Join(strings.Fields(formatted), " ")

require.Contains(t, normalized, testCase.expected)
require.NotContains(t, normalized, "coalesce(")
require.NotContains(t, normalized, "lower(")
require.NotContains(t, normalized, "to_jsonb(")
require.NotContains(t, normalized, "->> 'objectid')::")
require.NotContains(t, normalized, "->> 'name')::")
})
}
}

func TestNegatedDynamicStringPredicatesCoalescePropertyLookups(t *testing.T) {
Expand Down
Loading
Loading