Skip to content

Commit dba0424

Browse files
genezhangclaude
andcommitted
Merge branch 'fix/479-optional-match-join-planning-family'
Post-WITH OPTIONAL MATCH predicate handling: pure-anchor WHERE conjuncts now move into the LEFT JOIN ON (safe for any LEFT JOIN, previously only optional-alias conjuncts were moved); and a general-purpose predicate walker bug that silently dropped IS NULL operators and corrupted NOT(...) OR ... into NOT(...) AND ... is fixed at its root cause (broader than originally filed — reproduces on plain non-WITH cross-alias predicates too). Three related issues (#478, #461, #479) are deferred with "KNOWN BROKEN" characterization tests rather than risking another naive fix that's worse than the original bug (this exact family already burned that once, per #479's own ground-truth experiment) — they need structural planner/join-extraction work, precisely characterized for a future slice. Fixes #472 Fixes #473 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 parents eb2a670 + 92f0160 commit dba0424

3 files changed

Lines changed: 368 additions & 45 deletions

File tree

src/query_planner/analyzer/filter_tagging.rs

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1707,18 +1707,28 @@ impl FilterTagging {
17071707
let single_table =
17081708
Self::get_table_alias_if_single_table_condition(&op_app.operands[0], false);
17091709

1710-
if single_table.is_none() {
1711-
// Cross-table condition under NOT (e.g., NOT (p.id = friend.id))
1712-
// Keep it intact - don't recurse into operands
1713-
// Just extract property projections for column selection
1710+
// #473: a single-table NOT nested inside an OR (`in_or`) must NOT be
1711+
// extracted as its own standalone filter — doing so silently splits the
1712+
// OR into two independently-placed conjuncts (effectively an AND),
1713+
// changing the query's boolean semantics (e.g.
1714+
// `NOT(o.amount > 5) OR c.id > 101` was rendered as if it were
1715+
// `NOT(o.amount > 5) AND c.id > 101`, dropping matches the OR should
1716+
// have kept). Only extract when NOT is single-table AND not nested in
1717+
// an OR — mirrors the general single-table extraction guard below
1718+
// (`!new_in_or && ... && is_single_table_condition`).
1719+
if single_table.is_none() || in_or {
1720+
// Cross-table condition under NOT (e.g., NOT (p.id = friend.id)),
1721+
// or a single-table NOT that lives inside an OR: keep it intact -
1722+
// don't recurse into operands. Just extract property projections
1723+
// for column selection.
17141724
let mut temp_props = Vec::new();
17151725
Self::collect_property_accesses(&op_app.operands[0], &mut temp_props);
17161726
extracted_projections.extend(temp_props);
17171727

17181728
// Return the entire NOT expression as-is for global WHERE clause
17191729
return Some(LogicalExpr::OperatorApplicationExp(op_app));
17201730
} else {
1721-
// Single-table NOT (e.g., NOT p.active)
1731+
// Single-table NOT (e.g., NOT p.active), NOT inside an OR
17221732
// Extract it as a complete filter to the table
17231733
// Collect projections from the operand
17241734
let mut temp_props = Vec::new();
@@ -1843,8 +1853,19 @@ impl FilterTagging {
18431853
extracted_projections.append(&mut temp_prop_acc);
18441854
}
18451855

1846-
// If after processing there is only one operand left and it is not unary then collapse the operator application.
1847-
if op_app.operands.len() == 1 && op_app.operator != Operator::Not {
1856+
// If after processing there is only one operand left, collapse the operator
1857+
// application — but ONLY for the variadic boolean combinators (And/Or) whose
1858+
// operand count can genuinely shrink from extraction (e.g. `a AND b` becomes
1859+
// just `b` once `a` is extracted as its own filter). #473: this must NOT fire
1860+
// for operators that are inherently single-operand (Not, IsNull, IsNotNull,
1861+
// Distinct) — those always have exactly one operand from the start, and
1862+
// "collapsing" them silently drops the operator itself (e.g.
1863+
// `o.amount IS NULL` was rendered as bare `o.amount`, evaluating a Float
1864+
// column as a boolean). The old `operator != Operator::Not` check excluded
1865+
// only Not, missing IsNull/IsNotNull/Distinct.
1866+
if op_app.operands.len() == 1
1867+
&& matches!(op_app.operator, Operator::And | Operator::Or)
1868+
{
18481869
log::warn!(
18491870
"process_expr: Collapsing operator {:?} with single operand: {:?}",
18501871
op_app.operator,

src/render_plan/plan_builder_utils.rs

Lines changed: 41 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -13076,53 +13076,56 @@ pub(crate) fn build_chained_with_match_cte_plan(
1307613076
);
1307713077
}
1307813078

13079-
// #462 GAP 1: cross-alias / OR conjuncts were routed to the
13080-
// outer WHERE (`render_plan.filters`) by
13081-
// `collect_graphrel_predicates` — wrong for OPTIONAL MATCH, as
13082-
// the outer WHERE drops the NULL-extended no-match anchor rows.
13083-
// Move every already-resolved filter conjunct that references
13084-
// the optional node alias into the LEFT JOIN's ON condition
13085-
// (anchor `c` references were already resolved to CTE columns,
13086-
// e.g. `c.p1_c_customer_id`, when the filter was rendered).
13087-
// Pure-anchor conjuncts stay in the outer WHERE.
13079+
// #462/#472: every conjunct in `render_plan.filters` at this
13080+
// point belongs EXCLUSIVELY to this post-WITH OPTIONAL MATCH's
13081+
// own WHERE (the tight `post_with_optional_restructure` guard
13082+
// above ensures a single-branch optional pattern; any WHERE
13083+
// attached to the WITH projection itself was already folded
13084+
// into the CTE body and is not visible here). `
13085+
// collect_graphrel_predicates` routed the whole thing to the
13086+
// outer WHERE — wrong for OPTIONAL MATCH, since the outer WHERE
13087+
// drops the NULL-extended no-match anchor rows. Move EVERY
13088+
// conjunct into the LEFT JOIN's ON condition, including
13089+
// pure-anchor ones (#472): for a LEFT JOIN a false ON condition
13090+
// just NULL-extends the row rather than dropping it, so folding
13091+
// the whole predicate into ON is always safe and never changes
13092+
// which rows are kept vs. dropped when the FROM side is the
13093+
// anchor CTE. (Anchor `c` references were already resolved to
13094+
// CTE columns, e.g. `c.p1_c_customer_id`, when the filter was
13095+
// rendered.) Nothing is left behind in the outer WHERE for this
13096+
// segment.
1308813097
let mut extra_on_conditions: Vec<OperatorApplication> = Vec::new();
1308913098
if let Some(filter_expr) = render_plan.filters.0.take() {
13090-
let mut kept: Vec<RenderExpr> = Vec::new();
1309113099
for conj in split_render_and_conjuncts(filter_expr) {
13092-
if super::expression_utils::references_alias(
13093-
&conj,
13094-
&optional_from_alias,
13095-
) {
13096-
match conj {
13097-
RenderExpr::OperatorApplicationExp(op) => {
13098-
extra_on_conditions.push(op);
13099-
}
13100-
// A boolean conjunct that is not an operator
13101-
// application (e.g. a bare scalar-fn predicate)
13102-
// cannot be expressed as a joining_on
13103-
// `OperatorApplication`. Rather than silently
13104-
// leave it in the outer WHERE (wrong semantics)
13105-
// or drop it, refuse with a clean error (#462).
13106-
other => {
13107-
return Err(RenderBuildError::InvalidRenderPlan(format!(
13108-
"post-WITH OPTIONAL MATCH WHERE conjunct referencing \
13109-
optional alias '{}' is not an operator application and \
13110-
cannot be moved into the LEFT JOIN ON condition; \
13111-
refusing to place it in the outer WHERE (would drop \
13112-
NULL-extended rows): {:?}",
13113-
optional_from_alias, other
13114-
)));
13115-
}
13100+
match conj {
13101+
RenderExpr::OperatorApplicationExp(op) => {
13102+
extra_on_conditions.push(op);
13103+
}
13104+
// A boolean conjunct that is not an operator
13105+
// application (e.g. a bare scalar-fn predicate)
13106+
// cannot be expressed as a joining_on
13107+
// `OperatorApplication`. Rather than silently leave
13108+
// it in the outer WHERE (wrong semantics) or drop
13109+
// it, refuse with a clean error (#462/#472).
13110+
other => {
13111+
return Err(RenderBuildError::InvalidRenderPlan(format!(
13112+
"post-WITH OPTIONAL MATCH WHERE conjunct is not an \
13113+
operator application and cannot be moved into the \
13114+
LEFT JOIN ON condition for alias '{}'; refusing to \
13115+
place it in the outer WHERE (would drop NULL-extended \
13116+
rows): {:?}",
13117+
optional_from_alias, other
13118+
)));
1311613119
}
13117-
} else {
13118-
kept.push(conj);
1311913120
}
1312013121
}
13121-
render_plan.filters.0 = combine_render_exprs_with_and(kept);
13122+
// No `kept` remainder: the whole post-OPTIONAL WHERE moves
13123+
// into the ON condition, so `render_plan.filters` stays
13124+
// cleared (no outer WHERE for this segment).
1312213125
}
1312313126
if !extra_on_conditions.is_empty() {
1312413127
log::info!(
13125-
"🔧 build_chained_with_match_cte_plan: #462 moved {} cross-alias/OR WHERE conjunct(s) into LEFT JOIN ON for alias '{}'",
13128+
"🔧 build_chained_with_match_cte_plan: #462/#472 moved {} WHERE conjunct(s) (incl. pure-anchor) into LEFT JOIN ON for alias '{}'",
1312613129
extra_on_conditions.len(),
1312713130
optional_from_alias
1312813131
);

0 commit comments

Comments
 (0)