Skip to main content

ursa_engine/
error.rs

1//! Error messages for people, not programmers.
2//!
3//! DataFusion's `Display` for an error prefixes it with the variant it came from
4//! ("Error during planning: ", "Arrow error: ", "External error: ") and, for a
5//! SQL syntax error, prints the parser error's `Debug` form
6//! (`ParserError("…")`). [`user_message`] walks the error instead and keeps only
7//! what happened. It reads the error's variants, not its text.
8//!
9//! Where DataFusion offers no typed error, the engine matches its text. Every
10//! such string is in [`matched`], with a test that makes DataFusion (or
11//! sqlparser) produce the message itself and checks the string is still in it,
12//! so an upgrade that rewords one fails there rather than quietly showing the
13//! user DataFusion's raw message.
14
15use datafusion::arrow::error::ArrowError;
16use datafusion::common::SchemaError;
17use datafusion::error::DataFusionError;
18use datafusion::sql::sqlparser::parser::ParserError;
19
20/// The DataFusion and sqlparser messages the engine and the CLI recognize by
21/// their text, because no typed error says the same (#265).
22///
23/// Each is matched as a substring or a prefix, as its doc says. The tests
24/// below trigger each message in DataFusion or sqlparser directly, not through
25/// Ursa's wrappers, and check the text is still there: when an upgrade changes
26/// the wording, they fail and name the string to update.
27pub mod matched {
28    /// The start of the context DataFusion's optimizer puts on a rule's error
29    /// (`Optimizer rule '…' failed`). Which rule failed is DataFusion's
30    /// business: [`super::user_message`] drops it and keeps the cause.
31    pub const OPTIMIZER_RULE_FAILED: &str = "Optimizer rule";
32
33    /// The start of sqlparser's tokenizer error for a string literal (or a
34    /// dollar-quoted or encoded one) still open at the end of the input. The
35    /// shell reads the next line instead of reporting it (`ursa-cli`'s
36    /// `input.rs`).
37    pub const TOKENIZER_UNTERMINATED: &str = "Unterminated";
38
39    /// In sqlparser's tokenizer error for a quoted identifier or a block
40    /// comment still open at the end of the input (`… before EOF.`,
41    /// `Unexpected EOF while in a multi-line comment`). The shell reads the
42    /// next line instead (`ursa-cli`'s `input.rs`).
43    pub const TOKENIZER_EOF: &str = "EOF";
44
45    /// In arrow's cast error for a date or timestamp outside the range of a
46    /// nanosecond timestamp (`Cannot cast Date32 value -100000000 to
47    /// Timestamp(ns): converted value exceeds the representable i64 range`),
48    /// which `date_trunc` and other timestamp functions meet on an
49    /// out-of-range date. Matched together with [`TIMESTAMP_NS`].
50    pub const TIMESTAMP_OVERFLOW: &str = "exceeds the representable i64 range";
51
52    /// The nanosecond timestamp type, as arrow names it in a cast error.
53    pub const TIMESTAMP_NS: &str = "Timestamp(ns)";
54
55    /// The start of the context DataFusion puts on an error inferring a CSV
56    /// file's schema, followed by the file's object store location, which
57    /// has lost its leading `/` (`Users/me/x.csv`): [`super::user_message`]
58    /// puts it back (#327, N14).
59    pub const CSV_FILE_CONTEXT: &str = "Error when processing CSV file ";
60}
61
62/// `location`, an object store location, as the local path the user wrote
63/// when it is one (`Users/me/x.csv` is `/Users/me/x.csv` if that exists).
64pub fn shown_path(location: &str) -> String {
65    #[cfg(all(unix, not(target_arch = "wasm32")))]
66    if !location.starts_with('/') && !location.contains("://") {
67        let absolute = format!("/{location}");
68        if std::path::Path::new(&absolute).exists() {
69            return absolute;
70        }
71    }
72    location.to_string()
73}
74
75/// What to do about a date outside a nanosecond timestamp's range (#328).
76const TIMESTAMP_RANGE_HINT: &str = "a timestamp in nanoseconds holds only dates from 1677-09-21 \
77     to 2262-04-11, and date_trunc and the other timestamp functions convert to one. Filter \
78     the dates first (WHERE d BETWEEN DATE '1678-01-01' AND DATE '2262-04-11'), or use \
79     extract(year FROM d) or date_part instead of date_trunc";
80
81/// The message to show a user for `err`: what went wrong, without the names of
82/// the internal error types it passed through.
83pub fn user_message(err: &DataFusionError) -> String {
84    let message = message_of(err);
85    if message.contains(matched::TIMESTAMP_OVERFLOW) && message.contains(matched::TIMESTAMP_NS) {
86        return format!("{message}; {TIMESTAMP_RANGE_HINT}");
87    }
88    message
89}
90
91fn message_of(err: &DataFusionError) -> String {
92    match err {
93        DataFusionError::ArrowError(e, _) => arrow_message(e),
94        DataFusionError::ParquetError(e) => format!("Parquet: {e}"),
95        DataFusionError::ObjectStore(e) => e.to_string(),
96        DataFusionError::IoError(e) => e.to_string(),
97        DataFusionError::SQL(e, _) => parser_message(e),
98        DataFusionError::NotImplemented(m)
99        | DataFusionError::Plan(m)
100        | DataFusionError::Configuration(m)
101        | DataFusionError::Execution(m)
102        | DataFusionError::ResourcesExhausted(m)
103        | DataFusionError::Substrait(m)
104        | DataFusionError::Ffi(m) => m.clone(),
105        DataFusionError::Internal(m) => format!("internal error: {m}"),
106        DataFusionError::SchemaError(e, _) => schema_message(e),
107        DataFusionError::ExecutionJoin(e) => e.to_string(),
108        DataFusionError::External(e) => external_message(e.as_ref()),
109        // Which optimizer rule hit the error is DataFusion's business.
110        DataFusionError::Context(context, inner)
111            if context.starts_with(matched::OPTIMIZER_RULE_FAILED) =>
112        {
113            message_of(inner)
114        }
115        DataFusionError::Context(context, inner) => {
116            match context.strip_prefix(matched::CSV_FILE_CONTEXT) {
117                Some(location) => format!(
118                    "cannot read {}: {}",
119                    shown_path(location),
120                    message_of(inner)
121                ),
122                None => format!("{context}: {}", message_of(inner)),
123            }
124        }
125        DataFusionError::Diagnostic(_, inner) => message_of(inner),
126        DataFusionError::Collection(errs) => errs
127            .first()
128            .map(message_of)
129            .unwrap_or_else(|| "unknown error".to_string()),
130        DataFusionError::Shared(inner) => message_of(inner),
131    }
132}
133
134/// A schema error, with a hint for a name with a `-` in it: unquoted,
135/// `date-created` is `date - created`, so DataFusion reports `date` missing.
136fn schema_message(e: &SchemaError) -> String {
137    let message = e.to_string();
138    let SchemaError::FieldNotFound {
139        field,
140        valid_fields,
141    } = e
142    else {
143        return message;
144    };
145    // A valid name that the missing one is a `-`-separated part of.
146    let part_of =
147        |name: &str| name.split('-').count() > 1 && name.split('-').any(|p| p == field.name);
148    match valid_fields.iter().find(|c| part_of(&c.name)) {
149        Some(column) => {
150            let (first, rest) = message.split_once('\n').unwrap_or((&message, ""));
151            let mut out = format!(
152                "{first}\nA name that contains '-' must be quoted, or it is read as a \
153                 subtraction: write \"{}\"",
154                column.name.replace('"', "\"\"")
155            );
156            out.push_str(&format!(", not {}.", column.name));
157            if !rest.is_empty() {
158                out.push('\n');
159                out.push_str(rest);
160            }
161            out
162        }
163        None => message,
164    }
165}
166
167fn parser_message(e: &ParserError) -> String {
168    match e {
169        ParserError::TokenizerError(m) | ParserError::ParserError(m) => {
170            format!("syntax error: {m}")
171        }
172        ParserError::RecursionLimitExceeded => {
173            "syntax error: the statement is nested too deeply".to_string()
174        }
175    }
176}
177
178fn arrow_message(e: &ArrowError) -> String {
179    match e {
180        ArrowError::ExternalError(source) => external_message(source.as_ref()),
181        other => other.to_string(),
182    }
183}
184
185fn external_message(e: &(dyn std::error::Error + Send + Sync + 'static)) -> String {
186    if let Some(df) = e.downcast_ref::<DataFusionError>() {
187        message_of(df)
188    } else if let Some(arrow) = e.downcast_ref::<ArrowError>() {
189        arrow_message(arrow)
190    } else {
191        e.to_string()
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use std::sync::Arc;
198
199    use datafusion::common::tree_node::Transformed;
200    use datafusion::common::DFSchema;
201    use datafusion::logical_expr::{EmptyRelation, LogicalPlan};
202    use datafusion::optimizer::{Optimizer, OptimizerConfig, OptimizerContext, OptimizerRule};
203    use datafusion::prelude::SessionContext;
204    use datafusion::sql::sqlparser::dialect::GenericDialect;
205    use datafusion::sql::sqlparser::tokenizer::Tokenizer;
206
207    use super::*;
208
209    /// An optimizer rule that always fails.
210    #[derive(Debug)]
211    struct Failing;
212
213    impl OptimizerRule for Failing {
214        fn name(&self) -> &str {
215            "failing"
216        }
217
218        fn supports_rewrite(&self) -> bool {
219            true
220        }
221
222        fn rewrite(
223            &self,
224            _plan: LogicalPlan,
225            _config: &dyn OptimizerConfig,
226        ) -> datafusion::error::Result<Transformed<LogicalPlan>> {
227            Err(DataFusionError::Plan("the cause".into()))
228        }
229    }
230
231    #[test]
232    fn datafusion_still_labels_a_failed_optimizer_rule() {
233        let optimizer = Optimizer::with_rules(vec![Arc::new(Failing)]);
234        let config = OptimizerContext::new().with_skip_failing_rules(false);
235        let plan = LogicalPlan::EmptyRelation(EmptyRelation {
236            produce_one_row: false,
237            schema: Arc::new(DFSchema::empty()),
238        });
239        let err = optimizer
240            .optimize(plan, &config, |_, _| {})
241            .expect_err("the rule fails");
242        let DataFusionError::Context(label, _) = &err else {
243            panic!(
244                "DataFusion no longer wraps a failed optimizer rule's error in a context: {err:?}"
245            );
246        };
247        assert!(
248            label.starts_with(matched::OPTIMIZER_RULE_FAILED),
249            "DataFusion's label for a failed optimizer rule changed; update \
250             matched::OPTIMIZER_RULE_FAILED: {label}"
251        );
252        // And user_message keeps only the cause.
253        assert_eq!(user_message(&err), "the cause");
254    }
255
256    /// `user_message` reads a syntax error's variant, not DataFusion's
257    /// `ParserError("…")` text: DataFusion must still return the typed error.
258    #[tokio::test]
259    async fn datafusion_still_returns_syntax_errors_typed() {
260        let err = SessionContext::new()
261            .sql("SELEC 1")
262            .await
263            .expect_err("a syntax error");
264        assert!(
265            matches!(err.find_root(), DataFusionError::SQL(..)),
266            "DataFusion no longer returns a syntax error as DataFusionError::SQL: {err:?}"
267        );
268        let message = user_message(&err);
269        assert!(
270            message.starts_with("syntax error: ") && !message.contains("ParserError"),
271            "{message}"
272        );
273    }
274
275    /// sqlparser's tokenizer error for `sql`, which must fail.
276    fn tokenizer_error(sql: &str) -> String {
277        Tokenizer::new(&GenericDialect {}, sql)
278            .tokenize()
279            .expect_err("the tokenizer fails")
280            .message
281    }
282
283    #[test]
284    fn sqlparser_still_says_unterminated_and_eof() {
285        for sql in ["SELECT 'abc", "SELECT 'it''s"] {
286            let message = tokenizer_error(sql);
287            assert!(
288                message.starts_with(matched::TOKENIZER_UNTERMINATED),
289                "sqlparser's message for an open string changed; update \
290                 matched::TOKENIZER_UNTERMINATED: {sql}: {message}"
291            );
292        }
293        for sql in ["SELECT \"abc", "SELECT 1 /* a note"] {
294            let message = tokenizer_error(sql);
295            assert!(
296                message.contains(matched::TOKENIZER_EOF),
297                "sqlparser's message for an open quoted name or comment changed; update \
298                 matched::TOKENIZER_EOF: {sql}: {message}"
299            );
300        }
301    }
302
303    /// N14: a CSV file DataFusion cannot read is named by its path, with its
304    /// leading `/`, not by its object store location.
305    #[tokio::test]
306    async fn datafusion_still_names_a_bad_csv_file_by_its_location() {
307        let dir = tempfile::tempdir().unwrap();
308        let path = std::fs::canonicalize(dir.path()).unwrap().join("bad.csv");
309        std::fs::write(&path, "a,b\n1,2\n1,2,3\n").unwrap();
310        let ctx = SessionContext::new();
311        let err = ctx
312            .register_csv("t", path.to_str().unwrap(), Default::default())
313            .await
314            .unwrap_err();
315        let DataFusionError::Context(context, _) = &err else {
316            panic!("DataFusion no longer gives a bad CSV file context; update matched::CSV_FILE_CONTEXT: {err:?}");
317        };
318        assert!(
319            context.starts_with(matched::CSV_FILE_CONTEXT),
320            "DataFusion's context for a bad CSV file changed; update \
321             matched::CSV_FILE_CONTEXT: {context}"
322        );
323        let message = user_message(&err);
324        #[cfg(unix)]
325        assert!(
326            message.starts_with(&format!("cannot read {}: ", path.display())),
327            "{message}"
328        );
329        #[cfg(not(unix))]
330        assert!(message.starts_with("cannot read "), "{message}");
331    }
332
333    #[test]
334    fn strips_variant_prefixes() {
335        let err = DataFusionError::Plan("table 'x' not found".into());
336        assert_eq!(user_message(&err), "table 'x' not found");
337        let err = DataFusionError::SQL(
338            Box::new(ParserError::ParserError("Expected: an expression".into())),
339            None,
340        );
341        assert_eq!(user_message(&err), "syntax error: Expected: an expression");
342        let err = DataFusionError::External(Box::new(DataFusionError::Execution("boom".into())));
343        assert_eq!(user_message(&err), "boom");
344    }
345
346    /// #328: an unquoted `date-created` is `date - created`; say to quote it.
347    #[tokio::test]
348    async fn a_hyphenated_name_says_to_quote_it() {
349        let ctx = SessionContext::new();
350        let sql = "SELECT date-created FROM (SELECT 1 AS \"date-created\", 2 AS x) t";
351        let err = ctx.sql(sql).await.unwrap_err();
352        assert_eq!(
353            user_message(&err),
354            "No field named date.\n\
355             A name that contains '-' must be quoted, or it is read as a subtraction: write \
356             \"date-created\", not date-created.\n\
357             Valid fields are t.\"date-created\", t.x."
358        );
359        // A missing name that is not part of one with a `-` gets no hint.
360        let sql = "SELECT nope FROM (SELECT 1 AS \"date-created\") t";
361        let err = ctx.sql(sql).await.unwrap_err();
362        assert!(
363            !user_message(&err).contains("quoted"),
364            "{}",
365            user_message(&err)
366        );
367    }
368
369    /// #328: a date out of a nanosecond timestamp's range, in date_trunc,
370    /// says what to do instead (and arrow still words the error as
371    /// [`matched::TIMESTAMP_OVERFLOW`] and [`matched::TIMESTAMP_NS`] say).
372    #[tokio::test]
373    async fn an_out_of_range_date_in_date_trunc_suggests_extract() {
374        let ctx = SessionContext::new();
375        let err = ctx
376            .sql("SELECT date_trunc('year', CAST(-100000000 AS DATE))")
377            .await
378            .unwrap()
379            .collect()
380            .await
381            .unwrap_err();
382        let raw = message_of(&err);
383        assert!(
384            raw.contains(matched::TIMESTAMP_OVERFLOW) && raw.contains(matched::TIMESTAMP_NS),
385            "arrow's message for an out-of-range timestamp cast changed; update \
386             matched::TIMESTAMP_OVERFLOW and TIMESTAMP_NS: {raw}"
387        );
388        let message = user_message(&err);
389        assert!(
390            message.ends_with(&format!("; {TIMESTAMP_RANGE_HINT}")),
391            "{message}"
392        );
393        assert!(message.contains("extract(year FROM d)"), "{message}");
394    }
395}