Skip to main content

mz_expr/scalar/func/impls/
try_cast.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10use std::fmt;
11use std::sync::atomic::{AtomicBool, Ordering};
12
13use mz_repr::{Datum, RowArena, SqlColumnType};
14use serde::{Deserialize, Serialize};
15
16use crate::scalar::func::{LazyUnaryFunc, UnaryFunc};
17use crate::{Eval, EvalError, MirScalarExpr};
18
19/// Wraps a cast function so that its errors become `NULL`.
20///
21/// The argument is evaluated first, and its errors propagate. Only errors
22/// raised by `inner` itself are swallowed. Because the wrapper is strict in its
23/// argument and never errors, it is safe for the optimizer to treat it like
24/// any other infallible unary function.
25///
26/// The planner builds wrappers only through [`UnaryFunc::try_cast_if_fallible`],
27/// which leaves an infallible function unwrapped, so a planner-built `inner`
28/// can always error. A deserialized wrapper may hold an infallible `inner`.
29/// The forced-nullable output is then conservative but still correct.
30#[derive(
31    Ord,
32    PartialOrd,
33    Clone,
34    Debug,
35    Eq,
36    PartialEq,
37    Serialize,
38    Deserialize,
39    Hash
40)]
41pub struct TryCast<E = MirScalarExpr> {
42    inner: Box<UnaryFunc<E>>,
43}
44
45impl<E> TryCast<E> {
46    /// The wrapped function.
47    pub fn inner(&self) -> &UnaryFunc<E> {
48        &self.inner
49    }
50}
51
52/// Evaluates to the first column of the row it is given.
53///
54/// `TryCast::eval` needs to hand an already evaluated datum to `inner`, but
55/// `LazyUnaryFunc::eval` takes the argument as an `&'a impl Eval` and returns
56/// a `Datum<'a>`, so a value constructed inside `eval` cannot serve as the
57/// argument. Instead the datum becomes a one-column row and this `'static`
58/// reader stands in for the argument, mirroring how the element casts of
59/// `CastList1ToList2` evaluate against a one-column row.
60struct FirstColumn;
61
62static FIRST_COLUMN: FirstColumn = FirstColumn;
63
64impl Eval for FirstColumn {
65    fn eval<'a>(
66        &'a self,
67        datums: &[Datum<'a>],
68        _temp_storage: &'a RowArena,
69    ) -> Result<Datum<'a>, EvalError> {
70        Ok(datums[0])
71    }
72
73    fn could_error(&self) -> bool {
74        false
75    }
76}
77
78impl<E: Eval> LazyUnaryFunc for TryCast<E> {
79    fn eval<'a>(
80        &'a self,
81        datums: &[Datum<'a>],
82        temp_storage: &'a RowArena,
83        a: &'a impl Eval,
84    ) -> Result<Datum<'a>, EvalError> {
85        let a = a.eval(datums, temp_storage)?;
86        match self.inner.eval(&[a], temp_storage, &FIRST_COLUMN) {
87            Ok(datum) => Ok(datum),
88            // An internal error means the planner handed `inner` a datum of
89            // the wrong kind. No cast reports it for bad user data, so it is
90            // a planner bug, and swallowing it silently would hide that. Soft
91            // assertions are off in production, where the row becomes NULL
92            // like any other failure and the error is logged instead. This
93            // runs per row, so one planner bug over a large collection would
94            // flood the logs and Sentry. The log fires once per process.
95            Err(EvalError::Internal(message)) => {
96                static REPORTED: AtomicBool = AtomicBool::new(false);
97                if mz_ore::assert::soft_assertions_enabled()
98                    || !REPORTED.swap(true, Ordering::Relaxed)
99                {
100                    mz_ore::soft_panic_or_log!(
101                        "TRY_CAST swallowed an internal error from {}: {message}",
102                        self.inner
103                    );
104                }
105                Ok(Datum::Null)
106            }
107            Err(_) => Ok(Datum::Null),
108        }
109    }
110
111    fn output_sql_type(&self, input_type: SqlColumnType) -> SqlColumnType {
112        self.inner.output_sql_type(input_type).nullable(true)
113    }
114
115    fn propagates_nulls(&self) -> bool {
116        self.inner.propagates_nulls()
117    }
118
119    fn introduces_nulls(&self) -> bool {
120        true
121    }
122
123    fn could_error(&self) -> bool {
124        false
125    }
126
127    fn preserves_uniqueness(&self) -> bool {
128        // Every failing input maps to the same NULL.
129        //
130        // If there was somehow a _unique_ failing input
131        // and no other value produce NULL, this could be true.
132        // It's not clear that situation ever arises.
133        false
134    }
135
136    fn inverse(&self) -> Option<crate::UnaryFunc> {
137        None
138    }
139
140    fn is_monotone(&self) -> bool {
141        // The failing inputs, which may sit anywhere in the domain, map to
142        // NULL, so the wrapper is not monotone even when `inner` is.
143        //
144        // If the domain of failures was somehow monotonic with respect to NULL,
145        // this could be true.
146        false
147    }
148
149    fn is_eliminable_cast(&self) -> bool {
150        false
151    }
152}
153
154impl<E: Eval> UnaryFunc<E> {
155    /// Wraps `inner` in [`TryCast`] if it could error, so that its errors
156    /// become NULL. A function that cannot error has nothing to fall back
157    /// from and is returned as is, keeping its nullability.
158    pub fn try_cast_if_fallible(inner: impl Into<UnaryFunc<E>>) -> UnaryFunc<E> {
159        let inner = inner.into();
160        if inner.could_error() {
161            UnaryFunc::TryCast(TryCast {
162                inner: Box::new(inner),
163            })
164        } else {
165            inner
166        }
167    }
168}
169
170impl<E> TryCast<E> {
171    /// Rebuilds this function with any expressions stored in `inner` converted
172    /// to `E2`.
173    pub fn try_map_expr<'a, E2: TryFrom<&'a E>>(&'a self) -> Result<TryCast<E2>, E2::Error> {
174        Ok(TryCast {
175            inner: Box::new(self.inner.try_map_expr()?),
176        })
177    }
178
179    /// Rebuilds this function with any expressions stored in `inner` converted
180    /// to `E2`.
181    pub fn map_expr<'a, E2: From<&'a E>>(&'a self) -> TryCast<E2> {
182        TryCast {
183            inner: Box::new(self.inner.map_expr()),
184        }
185    }
186}
187
188impl<E> fmt::Display for TryCast<E> {
189    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
190        // The brackets attach the guard to the function rather than to its
191        // argument: `try_cast[f](x)` catches errors from `f`, not from `x`.
192        write!(f, "try_cast[{}]", self.inner)
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use mz_repr::{Datum, ReprScalarType, RowArena};
199
200    use crate::scalar::func::{CastInt64ToInt32, CastStringToInt32, CastStringToInt64};
201    use crate::{Eval, EvalError, MirScalarExpr, UnaryFunc};
202
203    fn try_cast(inner: impl Into<UnaryFunc>) -> UnaryFunc {
204        let func = UnaryFunc::try_cast_if_fallible(inner.into());
205        assert!(
206            matches!(func, UnaryFunc::TryCast(_)),
207            "test needs a fallible cast"
208        );
209        func
210    }
211
212    fn eval(expr: &MirScalarExpr) -> Result<Datum<'_>, EvalError> {
213        // The expressions under test are closed, so an empty row suffices.
214        let arena = Box::leak(Box::new(RowArena::new()));
215        expr.eval(&[], arena)
216    }
217
218    fn text(s: &str) -> MirScalarExpr {
219        MirScalarExpr::literal_ok(Datum::String(s), ReprScalarType::String)
220    }
221
222    #[mz_ore::test]
223    fn cast_error_becomes_null() {
224        let expr = text("abc").call_unary(try_cast(CastStringToInt32));
225        assert_eq!(eval(&expr), Ok(Datum::Null));
226    }
227
228    #[mz_ore::test]
229    fn success_agrees_with_strict_cast() {
230        let lenient = text("42").call_unary(try_cast(CastStringToInt32));
231        let strict = text("42").call_unary(CastStringToInt32);
232        assert_eq!(eval(&lenient), Ok(Datum::Int32(42)));
233        assert_eq!(eval(&lenient), eval(&strict));
234    }
235
236    #[mz_ore::test]
237    fn null_argument_stays_null() {
238        let expr = MirScalarExpr::literal_null(ReprScalarType::String)
239            .call_unary(try_cast(CastStringToInt32));
240        assert_eq!(eval(&expr), Ok(Datum::Null));
241    }
242
243    #[mz_ore::test]
244    fn argument_error_propagates() {
245        let expr = MirScalarExpr::literal(Err(EvalError::DivisionByZero), ReprScalarType::String)
246            .call_unary(try_cast(CastStringToInt32));
247        assert_eq!(eval(&expr), Err(EvalError::DivisionByZero));
248    }
249
250    /// A two-stage chain, text to int8 to int4, wrapped per stage as the
251    /// planner does. A failure at either stage yields NULL because the later
252    /// stage propagates the NULL the earlier one produced.
253    #[mz_ore::test]
254    fn chain_nulls_at_either_stage() {
255        let chain = |s: &str| {
256            text(s)
257                .call_unary(try_cast(CastStringToInt64))
258                .call_unary(try_cast(CastInt64ToInt32))
259        };
260        assert_eq!(eval(&chain("abc")), Ok(Datum::Null), "fails at stage one");
261        assert_eq!(
262            eval(&chain("3000000000")),
263            Ok(Datum::Null),
264            "fails at stage two"
265        );
266        assert_eq!(eval(&chain("5")), Ok(Datum::Int32(5)));
267    }
268
269    /// A datum of the wrong kind reaches the inner cast only through a
270    /// planner bug, which the wrapper reports under soft assertions and hides
271    /// as NULL otherwise.
272    #[mz_ore::test]
273    fn internal_error_is_a_soft_panic() {
274        let expr = MirScalarExpr::literal_ok(Datum::Int64(7), ReprScalarType::Int64)
275            .call_unary(try_cast(CastStringToInt32));
276        let outcome = mz_ore::panic::catch_unwind(|| eval(&expr));
277        if mz_ore::assert::soft_assertions_enabled() {
278            assert!(outcome.is_err(), "soft assertion should have fired");
279        } else {
280            assert_eq!(outcome.expect("no panic"), Ok(Datum::Null));
281        }
282    }
283
284    #[mz_ore::test]
285    fn metadata() {
286        let func = try_cast(CastStringToInt32);
287        assert!(!func.could_error());
288        assert!(func.introduces_nulls());
289        assert!(func.propagates_nulls());
290        assert!(!func.is_monotone());
291        assert!(!func.preserves_uniqueness());
292        assert_eq!(func.inverse(), None);
293        let input = mz_repr::SqlScalarType::String.nullable(false);
294        assert!(func.output_sql_type(input).nullable);
295        assert_eq!(func.to_string(), "try_cast[text_to_integer]");
296    }
297}