Skip to content

Commit 9f59bfb

Browse files
def-claude
andcommitted
expr: preserve operand errors in non-strict AND/OR reduce
Closes: CLU-137 Fixes two user-visible bugs in how `reduce` treats an erroring operand of a non-strict AND/OR: * A materialized view or query whose `mz_now()` temporal filter sits under an OR of ANDs, e.g. WHERE (a AND ... AND mz_now() < t) OR (b AND ... AND mz_now() < t) could panic the compute worker (or fail to plan) with "Unsupported temporal predicate". The shared `mz_now() < t` conjunct was left buried inside the OR instead of being factored out, so temporal-filter extraction failed. * A query that should short-circuit, such as `WHERE col AND (1/0 = 1)`, could spuriously fail at runtime (e.g. "division by zero") even on rows where `col` is false. AND/OR are non-strict: `false AND <error>` is `false` and `true OR <error>` is `true`, so such rows must be filtered, not errored. Mechanism: the generic variadic fold in `reduce` replaced a call with any operand's literal error unconditionally, which is wrong for a non-strict function. Guard error-propagation with `propagates_nulls()`, mirroring the null fold directly above. Keeping the erroring operand then reaches `undistribute_and_or`, which recombines operands across AND/OR's short-circuit boundary. That only preserves error semantics for operands common to every disjunct, so skip undistribution otherwise. A shared temporal predicate (`mz_now() < t`, whose cast can error) is common to every disjunct, so it is still factored out and stays extractable, unlike the reverted #37049's blunt `could_error()` skip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 25802a5 commit 9f59bfb

4 files changed

Lines changed: 189 additions & 3 deletions

File tree

src/expr/src/scalar.rs

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -849,6 +849,49 @@ impl MirScalarExpr {
849849
}
850850
}
851851

852+
/// Whether [`Self::undistribute_and_or`] can rewrite `self` without changing
853+
/// its error semantics.
854+
///
855+
/// Undistribution recombines the operands *not* common to every disjunct into
856+
/// a new AND/OR, changing the short-circuit context they evaluate in. Only a
857+
/// dominating operand (`false`/`true`), not a NULL one, absorbs another
858+
/// operand's error, so recombining a could-error operand can change whether a
859+
/// row errors (e.g. `a OR (a AND <err>)` raises for a NULL `a`, but
860+
/// undistributes to `a`). Operands common to every disjunct are factored out
861+
/// unchanged and stay sound, which is what lets a shared temporal filter like
862+
/// `mz_now() < <expr>` (whose cast can error) be factored out, as the renderer
863+
/// requires.
864+
fn undistribution_preserves_errors(&self) -> bool {
865+
let MirScalarExpr::CallVariadic {
866+
func: func @ (VariadicFunc::And(_) | VariadicFunc::Or(_)),
867+
exprs,
868+
} = self
869+
else {
870+
return true;
871+
};
872+
// Fast path for the common case: with no erroring operand there is
873+
// nothing to preserve, so skip the per-operand bookkeeping below.
874+
// `could_error` recurses, so this also covers nested operands.
875+
if !exprs.iter().any(|o| o.could_error()) {
876+
return true;
877+
}
878+
let inner = func.switch_and_or();
879+
// The operands of a disjunct (a non-`inner` disjunct is its own singleton).
880+
let operands = |o: &MirScalarExpr| match o {
881+
MirScalarExpr::CallVariadic { func, exprs } if *func == inner => exprs.clone(),
882+
_ => vec![o.clone()],
883+
};
884+
let common = exprs
885+
.iter()
886+
.map(|o| BTreeSet::from_iter(operands(o)))
887+
.reduce(|a, b| &a & &b)
888+
.unwrap_or_default();
889+
exprs
890+
.iter()
891+
.flat_map(operands)
892+
.all(|op| !op.could_error() || common.contains(&op))
893+
}
894+
852895
/// AND/OR undistribution (factoring out) to apply at each `MirScalarExpr`.
853896
///
854897
/// This method attempts to apply one of the [distribution laws][distributivity]
@@ -922,6 +965,12 @@ impl MirScalarExpr {
922965
while old_self != *self {
923966
old_self = self.clone();
924967
self.reduce_and_canonicalize_and_or(); // We don't want to deal with 1-arg AND/OR at the top
968+
969+
// Skip undistribution when it would not preserve error semantics.
970+
if !self.undistribution_preserves_errors() {
971+
return;
972+
}
973+
925974
if let MirScalarExpr::CallVariadic {
926975
exprs: outer_operands,
927976
func: outer_func @ (VariadicFunc::Or(_) | VariadicFunc::And(_)),
@@ -2475,6 +2524,78 @@ mod tests {
24752524
use super::*;
24762525
use crate::scalar::func::variadic::Coalesce;
24772526

2527+
#[mz_ore::test]
2528+
fn test_reduce_and_or_preserves_operand_errors() {
2529+
// #37049: AND/OR are non-strict, so a `false`/`true` operand absorbs an
2530+
// erroring operand at runtime. `reduce` must not fold or absorb an
2531+
// erroring operand into an error the original never raises.
2532+
let bool_typ = ReprScalarType::Bool;
2533+
let types = vec![bool_typ.clone().nullable(true)];
2534+
let err = || MirScalarExpr::literal(Err(EvalError::DivisionByZero), bool_typ.clone());
2535+
let col = || MirScalarExpr::column(0);
2536+
let arena = RowArena::new();
2537+
2538+
// `x AND <err>` must not fold to the error: `false AND <err>` is `false`.
2539+
let mut and = col().and(err());
2540+
and.reduce(&types);
2541+
assert!(!and.is_literal_err(), "{and:?}");
2542+
assert_eq!(and.eval(&[Datum::False], &arena), Ok(Datum::False));
2543+
2544+
// `a OR (a AND <err>)` must not absorb to `a`: a NULL `a` surfaces the
2545+
// error (`NULL OR (NULL AND <err>)` = `<err>`).
2546+
let mut or = col().or(col().and(err()));
2547+
or.reduce(&types);
2548+
assert_ne!(or, col(), "{or:?}");
2549+
assert_eq!(or.eval(&[Datum::True], &arena), Ok(Datum::True));
2550+
assert_eq!(or.eval(&[Datum::False], &arena), Ok(Datum::False));
2551+
assert_eq!(
2552+
or.eval(&[Datum::Null], &arena),
2553+
Err(EvalError::DivisionByZero)
2554+
);
2555+
}
2556+
2557+
#[mz_ore::test]
2558+
fn test_reduce_and_or_undistributes_common_error() {
2559+
// CLU-137: undistribution must still factor out a could-error operand
2560+
// common to every disjunct (e.g. a temporal `mz_now() < <expr>`), so it
2561+
// becomes a standalone conjunct the renderer can extract. The reverted
2562+
// #37049 guard skipped undistribution for any could-error expression,
2563+
// burying such predicates inside the OR.
2564+
let bool_typ = ReprScalarType::Bool;
2565+
let types = vec![
2566+
bool_typ.clone().nullable(true),
2567+
bool_typ.clone().nullable(true),
2568+
];
2569+
let err = || MirScalarExpr::literal(Err(EvalError::DivisionByZero), bool_typ.clone());
2570+
let arena = RowArena::new();
2571+
2572+
// `(a AND <err>) OR (b AND <err>)` --> `<err> AND (a OR b)`.
2573+
let original = MirScalarExpr::column(0)
2574+
.and(err())
2575+
.or(MirScalarExpr::column(1).and(err()));
2576+
let mut reduced = original.clone();
2577+
reduced.reduce(&types);
2578+
assert!(
2579+
matches!(
2580+
&reduced,
2581+
MirScalarExpr::CallVariadic {
2582+
func: VariadicFunc::And(_),
2583+
..
2584+
}
2585+
),
2586+
"common error operand not factored out: {reduced:?}"
2587+
);
2588+
// ...and error semantics are preserved over every assignment.
2589+
for a in [Datum::True, Datum::False, Datum::Null] {
2590+
for b in [Datum::True, Datum::False, Datum::Null] {
2591+
assert_eq!(
2592+
reduced.eval(&[a, b], &arena),
2593+
original.eval(&[a, b], &arena)
2594+
);
2595+
}
2596+
}
2597+
}
2598+
24782599
#[mz_ore::test]
24792600
#[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
24802601
fn test_reduce() {

src/expr/src/scalar/reduce/variadic.rs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,20 @@ pub(super) fn reduce_call_variadic(
5151
*e = MirScalarExpr::literal_null(e.typ(column_types).scalar_type);
5252
return;
5353
}
54-
if let Some(err) = exprs.iter().find_map(|x| x.as_literal_err()) {
55-
*e = MirScalarExpr::literal(Err(err.clone()), e.typ(column_types).scalar_type);
56-
return;
54+
// Fold to an operand's error under the same condition as the null fold
55+
// above: only when the function is strict, so any operand error makes the
56+
// whole call error. `propagates_nulls()` is the proxy. It is false for the
57+
// non-strict variadics, which must be excluded: AND/OR (a `false`/`true`
58+
// operand absorbs an erroring operand at runtime, e.g. `false AND <error>` is
59+
// `false`) and ErrorIfNull (its message arg is evaluated only when the first
60+
// arg is null). It is also false for the strict `greatest`/`least`, so we
61+
// miss their fold, but that is harmless: their eager eval raises the error at
62+
// runtime regardless. (Coalesce, also non-strict, bailed out above.)
63+
if func.propagates_nulls() {
64+
if let Some(err) = exprs.iter().find_map(|x| x.as_literal_err()) {
65+
*e = MirScalarExpr::literal(Err(err.clone()), e.typ(column_types).scalar_type);
66+
return;
67+
}
5768
}
5869

5970
// Per-function dispatch. Arms are mutually exclusive on discriminant; the

test/sqllogictest/error_semantics.slt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,16 @@ select coalesce(a, (select a/b from test)) from test;
9393

9494
query error Evaluation error: division by zero
9595
select *, case when a = 5 then (select a/b from test) else 7 end from test;
96+
97+
# Regression for #37049. AND/OR are non-strict: a `false`/`true` operand absorbs
98+
# an erroring operand at runtime (`false AND <error>` is `false`), so `reduce`
99+
# must not fold `x AND <error>` / `x OR <error>` to the error. Before the fix
100+
# these spuriously errored with division by zero (test.a = 1).
101+
query I
102+
select a from test where (a = 2) and (1/0 = 1);
103+
----
104+
105+
query I
106+
select a from test where (a = 1) or (1/0 = 1);
107+
----
108+
1

test/sqllogictest/temporal.slt

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,3 +440,44 @@ FROM (SELECT CAST('62143-12-31' AS date) + '200000 YEARS' AS c2
440440
FROM (SELECT
441441
FROM (SELECT FROM t) AS subq_0) AS subq_1
442442
WHERE pg_catalog."current_timestamp"() = pg_catalog.pg_postmaster_start_time()) AS subq_2;
443+
444+
# Regression test for https://linear.app/materializeinc/issue/CLU-137
445+
#
446+
# A temporal predicate (`mz_now() < ...`) shared across both branches of a
447+
# disjunction of conjunctions must be factored out of the disjunction, so that
448+
# it becomes a standalone conjunct the renderer can extract as a temporal
449+
# bound. The factoring is done by `undistribute_and_or` during predicate
450+
# canonicalization. The error-propagation fix in #37049 had skipped
451+
# undistribution for any expression that could error, and `mz_now()` always
452+
# counts as could-error, so the temporal predicate stayed buried inside the OR
453+
# and the renderer panicked with "Unsupported temporal predicate".
454+
455+
# A constant collection (`VALUES`) is readable at any logical time, so the MV
456+
# can be queried `AS OF` a small timestamp, like the `valid` MV above.
457+
statement ok
458+
CREATE VIEW clu137 (active, confirmed, enabled, label, valid_until) AS VALUES
459+
(true, true, true, CAST(NULL AS text), 10),
460+
(true, true, true, '', 20),
461+
(true, true, true, 'x', 30),
462+
(false, true, true, NULL, 40);
463+
464+
statement ok
465+
CREATE MATERIALIZED VIEW clu137_mv AS
466+
SELECT valid_until FROM clu137
467+
WHERE (active AND confirmed AND enabled AND label IS NULL AND mz_now() < valid_until)
468+
OR (active AND confirmed AND enabled AND label = '' AND mz_now() < valid_until);
469+
470+
query I rowsort
471+
SELECT * FROM clu137_mv AS OF 5;
472+
----
473+
10
474+
20
475+
476+
query I rowsort
477+
SELECT * FROM clu137_mv AS OF 15;
478+
----
479+
20
480+
481+
query I rowsort
482+
SELECT * FROM clu137_mv AS OF 25;
483+
----

0 commit comments

Comments
 (0)