Skip to content

fix(planner): distinct column names for unaliased complex expressions - #350

Open
temporaryfix wants to merge 2 commits into
GrafeoDB:mainfrom
temporaryfix:fix/expression-to-string-collisions
Open

fix(planner): distinct column names for unaliased complex expressions#350
temporaryfix wants to merge 2 commits into
GrafeoDB:mainfrom
temporaryfix:fix/expression-to-string-collisions

Conversation

@temporaryfix

@temporaryfix temporaryfix commented May 17, 2026

Copy link
Copy Markdown
Contributor

Summary

expression_to_string — the source of column names when a RETURN/WITH/Project item has no explicit alias — collapsed Binary/Unary/Case/Labels/Type/Id/Slice/List/Map/subquery shapes to the literal string "expr" via a catch-all _ arm. Two such items in one clause silently produced two columns sharing a name, so result-by-name lookups in external clients (Python bindings, MCP, anything using result.columns as a dict key) would shadow.

The internal planner is mostly insulated: ORDER BY/GROUP BY/DISTINCT resolution goes through resolved_column_name rather than the user-visible string, so this isn't a correctness bug in query execution. It is, however, a real bug at the client API boundary: result-set tooling and dict-style accessors over the column list see collisions for any unaliased complex expression.

What changes

Every LogicalExpression variant now produces a distinct, Cypher-style rendering. Examples:

Expression Before After
n.a + n.b "expr" "(n.a + n.b)"
NOT n.active "expr" "(NOT n.active)"
id(a) "id(...)" "id(a)"
count(*) "count(...)" "count(*)"
labels(n) "labels(...)" "labels(n)"
text_score(s.body, $q) "text_score(...)" "text_score(s.body, $q)"
CASE n.tier ... END "expr" "case"
EXISTS { ... } "expr" "exists"

Heavy expressions (Case, subqueries, comprehensions, Reduce) collapse to short generic labels ("case", "exists", "count", "subquery", "reduce", "list_comprehension", "pattern_comprehension", predicate kind). Two unaliased instances of the same kind in one RETURN still collide; the right answer there is to alias them — documented in the function's doc comment.

The _ catch-all arm is dropped from the three matches (LogicalExpression, BinaryOp, UnaryOp) so future variants force an explicit handler at compile time. Matches the codebase's existing convert_binary_op convention ("for forward compatibility" = exhaustive match, not a _ arm).

A new binary_op_symbol helper sits next to expression_to_string and maps BinaryOp variants to their Cypher symbols (+, <>, STARTS WITH, etc.).

Breaking change

This is a user-visible change. Callers matching column names by literal string against the old "expr"/"name(...)" outputs need to either update the checks or alias the expressions explicitly in the query.

The version bump implication is for the maintainer to decide — CHANGELOG note added under [Unreleased] § Changed.

Test plan

  • cargo test -p grafeo-engine --no-default-features --features "lpg gql ai parallel" --test expression_and_projection — 68 tests pass (65 existing + 3 new).
  • Three regression tests added:
    • return_two_unaliased_binary_expressions_have_distinct_column_names — verified fails pre-fix with ["expr", "expr"].
    • return_two_unaliased_id_calls_have_distinct_column_names — verified fails pre-fix with ["id(...)", "id(...)"].
    • return_mixed_scalar_intrinsics_have_distinct_column_names — already passed pre-fix because Cypher emits these as FunctionCall; retained as forward-regression guard for the Labels/Type/Id LogicalExpression variants.
  • Repository-wide grep for consumers of "expr"/"id(...)"/"labels(...)"/"count(...)" literal strings — zero internal hits beyond the new tests.

Notes for the reviewer

`expression_to_string` is the source of column names when a Return/Project
item has no explicit alias (via `output_column_name`'s fallback). It used
to collapse every Binary/Unary/Case/Labels/Type/Id/Slice/List/Map/subquery
shape to the literal string "expr" via a catch-all `_` arm. Two such
items in one clause silently produced two columns sharing a name —
result-by-name lookups would shadow, and ORDER BY's pre-Return-alias
prelude in `plan_sort` would resolve to whichever index was inserted
last.

This change covers every `LogicalExpression` variant explicitly with a
Cypher-like rendering:

- Binary: `(left <op> right)` via a new `binary_op_symbol` helper.
- Unary: `(<op> operand)` (NOT, -, IS NULL, IS NOT NULL).
- FunctionCall: `name(arg, arg, ...)` instead of `name(...)`.
- Parameter, Labels, Type, Id: matching Cypher syntax.
- SliceAccess, List, Map, MapProjection: Cypher-like literals.
- Heavy expressions (Case, subqueries, comprehensions, Reduce): short
  generic labels (`case`, `exists`, `count`, `subquery`, `reduce`,
  `list_comprehension`, `pattern_comprehension`, predicate kind). Two
  unaliased instances of the same kind in one RETURN still collide;
  the right answer there is to alias them, per the doc comment.

Drops the `_` catch-all so future `LogicalExpression`/`BinaryOp`/`UnaryOp`
variants force an explicit handler at compile time (matches the codebase's
existing `convert_binary_op` convention — "for forward compatibility"
means no catch-all, not a `_` arm).

Removes the now-unused `expression_to_string` re-export in
`lpg/project.rs` (the helper is reached through `output_column_name`).

Regression tests in `expression_and_projection.rs`:

- `return_two_unaliased_binary_expressions_have_distinct_column_names`
  — verified fails pre-fix with `["expr", "expr"]`.
- `return_two_unaliased_id_calls_have_distinct_column_names`
  — verified fails pre-fix with `["id(...)", "id(...)"]`.
- `return_mixed_scalar_intrinsics_have_distinct_column_names`
  — already passed pre-fix because Cypher emits these as FunctionCall;
  retained as forward-regression guard.
The expression_to_string fix is a user-visible change for callers
matching unaliased column names by string. Document under Unreleased
so the next release notes pick it up.
@temporaryfix
temporaryfix requested a review from StevenBtw as a code owner May 17, 2026 16:00

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 3 files

Re-trigger cubic

@codecov

codecov Bot commented May 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 35.52632% with 49 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/grafeo-engine/src/query/planner/common.rs 35.52% 49 Missing ⚠️

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented May 17, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 70 untouched benchmarks


Comparing temporaryfix:fix/expression-to-string-collisions (ab0f102) with main (4ebae02)

Open in CodSpeed

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