Skip to content

fix(vlp): #1100 bare-node identity in a VLP filter predicate - #1102

Merged
genezhang merged 2 commits into
mainfrom
fix/1100-bare-node-identity-vlp-filter
Aug 31, 2026
Merged

fix(vlp): #1100 bare-node identity in a VLP filter predicate#1102
genezhang merged 2 commits into
mainfrom
fix/1100-bare-node-identity-vlp-filter

Conversation

@genezhang

@genezhang genezhang commented Aug 31, 2026

Copy link
Copy Markdown
Owner

Summary

Second slice of #1100. Unblocks LDBC IC9 and IC5 end-to-end.

A predicate like WHERE friend <> root / friend = root compares node identity, but both sides are bare node aliases. In a variable-length path this predicate lands in the recursive CTE's WHERE, where the aliases aren't in scope (the CTE's node columns are start_node/end_node). render_expr_to_sql_string renders a bare TableAlias as the literal alias name, emitting friend != root inside the CTE body → ClickHouse Code 47.

Fix

At the VLP where_predicate render seam (right after conversion to RenderExpr, before categorization), normalize a bare-node-identity comparison into property-access comparisons on the endpoints' schema-declared node_id column(s):

  • single column: a =/<> ba.col =/<> b.col
  • composite a = b(a.c1 = b.c1 AND a.c2 = b.c2 …)
  • composite a <> bNOT (a.c1 = b.c1 AND …)

This uses the real node_id (resolved from start_label/end_label + schema, both already in scope), not a hardcoded .id — correct on renamed and composite node ids per the axis-dispatch rule (CLAUDE.md §7). The resulting property-access form flows through the existing property + alias mapping. Tightly guarded: fires only when both operands are bare node aliases and both are the VLP's own endpoints; recurses through AND/OR/NOT; leaves the predicate untouched (loud fallback) on any unresolved label.

Validation

  • Schema-axis coverage: verified LDBC (id), renamed (User.node_id: user_id), composite (Account.node_id: [bank_id, account_number]) — each emits the correct column(s) and executes live with no Code 47.
  • IC9/IC5 execute end-to-end live against LDBC SF1 (IC9 friend/message rows ordered by date; IC5 forum post counts).
  • Full cargo test green (1738 lib + integration); ratchet green; fmt/clippy clean.
  • 5 new golden tests: bare <>, = nested in AND (sibling filter survives), renamed node_id, composite node_id, plus a strengthened ldbc_complex_9 guard.

Review response

An adversarial review caught that the first cut hardcoded .id (broken on renamed/composite schemas). The reworked commit resolves the real node_id instead, with dedicated axis tests. The review also flagged a pre-existing cyclic self-path leak (both-endpoint filter applied only in the base case) — confirmed independent of this fix (the .id property form shows the identical leak) and filed as #1103; LDBC IC9/IC5 use *1..2 and are unaffected.

Remaining under #1100

IS2 (compound-alias endpoint), IC10 (pattern predicate), IC3 (Code 62), BI12 (endpoint property projection) have distinct root causes and stay tracked in #1100.

🤖 Generated with Claude Code

A predicate like `WHERE friend <> root` / `friend = root` compares node
IDENTITY, but both sides are BARE node aliases (`RenderExpr::TableAlias`).
In a variable-length path this predicate is categorized to the recursive
CTE's WHERE, where the aliases are not in scope (its node columns are
`start_node`/`end_node`). `render_expr_to_sql_string` renders a bare
`TableAlias` as the literal alias name, emitting `friend != root` inside
the CTE body → ClickHouse Code 47.

The `.id` property-access form (`friend.id <> root.id`) already resolves
correctly through the existing property- and alias-mapping machinery
(→ `end_node.id != start_node.id`). So normalize the bare-node-identity
form into the `.id` form for the two VLP endpoint aliases, right after the
predicate is converted to RenderExpr and before categorization. `.id` is
Cypher's generic node-identity synonym, resolved schema-appropriately
downstream (renamed/composite ids included), so no schema lookup is needed
at this seam.

The normalization is tightly guarded: it fires only when BOTH operands are
bare node aliases AND both are the VLP's own endpoint aliases; it recurses
through AND/OR/NOT so a node-identity conjunct nested beside property
filters is reached without disturbing its siblings. Non-VLP node-identity
still flows through the outer-SELECT handler (to_sql_query.rs, #1076),
untouched.

Unblocks LDBC IC9 and IC5 end-to-end (both filter `WHERE NOT friend =
root` on a `KNOWS*1..2` VLP): verified executing live against LDBC SF1
(ClickHouse 26.7) — IC9 returns friend/message rows ordered by date, IC5
returns forum post counts. Combined with the merged re-match projection
fix (#1101), IC9/IC5 now run.

The remaining #1100 shapes (IS2 compound-alias endpoint, IC10 pattern
predicate, IC3 Code 62, BI12 endpoint property projection) have distinct
root causes and stay tracked in #1100.

Regression: full `cargo test` green (1738 lib + integration); ratchet
green; fmt/clippy clean. 3 new golden tests (bare identity `<>`, `=`
nested in AND with a surviving sibling filter) plus a strengthened
ldbc_complex_9 guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fleet-Machine: bronco
Fleet-Agent: cca-61a8aa18f5144c5d
Review found the first cut hardcoded `.id`, which is wrong on renamed
(`user_id`) and composite (`[bank_id, account_number]`) node ids — it
emitted `end_node.id = start_node.id` verbatim (Code 47 live) on those
schemas, violating the axis-dispatch rule (CLAUDE.md §7). It only worked
on LDBC because its node_id happens to be literally `id`.

Rework: resolve each endpoint's real node_id column(s) from the schema
(`node_schema_opt(label).node_id.id.columns()`) at the render seam, where
`start_label`/`end_label` and `schema` are already in scope, and build the
comparison against those columns:
  - single column: `a =/<> b` → `a.col =/<> b.col`
  - composite `a = b`  → `(a.c1 = b.c1 AND a.c2 = b.c2 …)`
  - composite `a <> b` → `NOT (a.c1 = b.c1 AND a.c2 = b.c2 …)`
On any unresolved label the predicate is left untouched (loud fallback,
never wrong SQL).

Verified across all three axes: LDBC (`id`), renamed (`user_id`), composite
(`[bank_id, account_number]`) — each emits the correct column(s) and
executes live (no Code 47). Added renamed + composite golden tests.

Also filed #1103: a pre-existing, separate defect this exposes — a
both-endpoint WHERE filter (`friend <> root`) is applied only in the VLP
base case, so cyclic self-paths leak at `*1..N` for N≥3. The property form
`friend.id <> root.id` shows the identical leak, so it predates this fix;
LDBC IC9/IC5 use `*1..2` and are unaffected (verified 0 self-rows).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fleet-Machine: bronco
Fleet-Agent: cca-61a8aa18f5144c5d
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant