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::error::DataFusionError;
17use datafusion::sql::sqlparser::parser::ParserError;
18
19/// The DataFusion and sqlparser messages the engine and the CLI recognize by
20/// their text, because no typed error says the same (#265).
21///
22/// Each is matched as a substring or a prefix, as its doc says. The tests
23/// below trigger each message in DataFusion or sqlparser directly, not through
24/// Ursa's wrappers, and check the text is still there: when an upgrade changes
25/// the wording, they fail and name the string to update.
26pub mod matched {
27    /// The start of the context DataFusion's optimizer puts on a rule's error
28    /// (`Optimizer rule '…' failed`). Which rule failed is DataFusion's
29    /// business: [`super::user_message`] drops it and keeps the cause.
30    pub const OPTIMIZER_RULE_FAILED: &str = "Optimizer rule";
31
32    /// The start of sqlparser's tokenizer error for a string literal (or a
33    /// dollar-quoted or encoded one) still open at the end of the input. The
34    /// shell reads the next line instead of reporting it (`ursa-cli`'s
35    /// `input.rs`).
36    pub const TOKENIZER_UNTERMINATED: &str = "Unterminated";
37
38    /// In sqlparser's tokenizer error for a quoted identifier or a block
39    /// comment still open at the end of the input (`… before EOF.`,
40    /// `Unexpected EOF while in a multi-line comment`). The shell reads the
41    /// next line instead (`ursa-cli`'s `input.rs`).
42    pub const TOKENIZER_EOF: &str = "EOF";
43}
44
45/// The message to show a user for `err`: what went wrong, without the names of
46/// the internal error types it passed through.
47pub fn user_message(err: &DataFusionError) -> String {
48    match err {
49        DataFusionError::ArrowError(e, _) => arrow_message(e),
50        DataFusionError::ParquetError(e) => format!("Parquet: {e}"),
51        DataFusionError::ObjectStore(e) => e.to_string(),
52        DataFusionError::IoError(e) => e.to_string(),
53        DataFusionError::SQL(e, _) => parser_message(e),
54        DataFusionError::NotImplemented(m)
55        | DataFusionError::Plan(m)
56        | DataFusionError::Configuration(m)
57        | DataFusionError::Execution(m)
58        | DataFusionError::ResourcesExhausted(m)
59        | DataFusionError::Substrait(m)
60        | DataFusionError::Ffi(m) => m.clone(),
61        DataFusionError::Internal(m) => format!("internal error: {m}"),
62        DataFusionError::SchemaError(e, _) => e.to_string(),
63        DataFusionError::ExecutionJoin(e) => e.to_string(),
64        DataFusionError::External(e) => external_message(e.as_ref()),
65        // Which optimizer rule hit the error is DataFusion's business.
66        DataFusionError::Context(context, inner)
67            if context.starts_with(matched::OPTIMIZER_RULE_FAILED) =>
68        {
69            user_message(inner)
70        }
71        DataFusionError::Context(context, inner) => {
72            format!("{context}: {}", user_message(inner))
73        }
74        DataFusionError::Diagnostic(_, inner) => user_message(inner),
75        DataFusionError::Collection(errs) => errs
76            .first()
77            .map(user_message)
78            .unwrap_or_else(|| "unknown error".to_string()),
79        DataFusionError::Shared(inner) => user_message(inner),
80    }
81}
82
83fn parser_message(e: &ParserError) -> String {
84    match e {
85        ParserError::TokenizerError(m) | ParserError::ParserError(m) => {
86            format!("syntax error: {m}")
87        }
88        ParserError::RecursionLimitExceeded => {
89            "syntax error: the statement is nested too deeply".to_string()
90        }
91    }
92}
93
94fn arrow_message(e: &ArrowError) -> String {
95    match e {
96        ArrowError::ExternalError(source) => external_message(source.as_ref()),
97        other => other.to_string(),
98    }
99}
100
101fn external_message(e: &(dyn std::error::Error + Send + Sync + 'static)) -> String {
102    if let Some(df) = e.downcast_ref::<DataFusionError>() {
103        user_message(df)
104    } else if let Some(arrow) = e.downcast_ref::<ArrowError>() {
105        arrow_message(arrow)
106    } else {
107        e.to_string()
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use std::sync::Arc;
114
115    use datafusion::common::tree_node::Transformed;
116    use datafusion::common::DFSchema;
117    use datafusion::logical_expr::{EmptyRelation, LogicalPlan};
118    use datafusion::optimizer::{Optimizer, OptimizerConfig, OptimizerContext, OptimizerRule};
119    use datafusion::prelude::SessionContext;
120    use datafusion::sql::sqlparser::dialect::GenericDialect;
121    use datafusion::sql::sqlparser::tokenizer::Tokenizer;
122
123    use super::*;
124
125    /// An optimizer rule that always fails.
126    #[derive(Debug)]
127    struct Failing;
128
129    impl OptimizerRule for Failing {
130        fn name(&self) -> &str {
131            "failing"
132        }
133
134        fn supports_rewrite(&self) -> bool {
135            true
136        }
137
138        fn rewrite(
139            &self,
140            _plan: LogicalPlan,
141            _config: &dyn OptimizerConfig,
142        ) -> datafusion::error::Result<Transformed<LogicalPlan>> {
143            Err(DataFusionError::Plan("the cause".into()))
144        }
145    }
146
147    #[test]
148    fn datafusion_still_labels_a_failed_optimizer_rule() {
149        let optimizer = Optimizer::with_rules(vec![Arc::new(Failing)]);
150        let config = OptimizerContext::new().with_skip_failing_rules(false);
151        let plan = LogicalPlan::EmptyRelation(EmptyRelation {
152            produce_one_row: false,
153            schema: Arc::new(DFSchema::empty()),
154        });
155        let err = optimizer
156            .optimize(plan, &config, |_, _| {})
157            .expect_err("the rule fails");
158        let DataFusionError::Context(label, _) = &err else {
159            panic!(
160                "DataFusion no longer wraps a failed optimizer rule's error in a context: {err:?}"
161            );
162        };
163        assert!(
164            label.starts_with(matched::OPTIMIZER_RULE_FAILED),
165            "DataFusion's label for a failed optimizer rule changed; update \
166             matched::OPTIMIZER_RULE_FAILED: {label}"
167        );
168        // And user_message keeps only the cause.
169        assert_eq!(user_message(&err), "the cause");
170    }
171
172    /// `user_message` reads a syntax error's variant, not DataFusion's
173    /// `ParserError("…")` text: DataFusion must still return the typed error.
174    #[tokio::test]
175    async fn datafusion_still_returns_syntax_errors_typed() {
176        let err = SessionContext::new()
177            .sql("SELEC 1")
178            .await
179            .expect_err("a syntax error");
180        assert!(
181            matches!(err.find_root(), DataFusionError::SQL(..)),
182            "DataFusion no longer returns a syntax error as DataFusionError::SQL: {err:?}"
183        );
184        let message = user_message(&err);
185        assert!(
186            message.starts_with("syntax error: ") && !message.contains("ParserError"),
187            "{message}"
188        );
189    }
190
191    /// sqlparser's tokenizer error for `sql`, which must fail.
192    fn tokenizer_error(sql: &str) -> String {
193        Tokenizer::new(&GenericDialect {}, sql)
194            .tokenize()
195            .expect_err("the tokenizer fails")
196            .message
197    }
198
199    #[test]
200    fn sqlparser_still_says_unterminated_and_eof() {
201        for sql in ["SELECT 'abc", "SELECT 'it''s"] {
202            let message = tokenizer_error(sql);
203            assert!(
204                message.starts_with(matched::TOKENIZER_UNTERMINATED),
205                "sqlparser's message for an open string changed; update \
206                 matched::TOKENIZER_UNTERMINATED: {sql}: {message}"
207            );
208        }
209        for sql in ["SELECT \"abc", "SELECT 1 /* a note"] {
210            let message = tokenizer_error(sql);
211            assert!(
212                message.contains(matched::TOKENIZER_EOF),
213                "sqlparser's message for an open quoted name or comment changed; update \
214                 matched::TOKENIZER_EOF: {sql}: {message}"
215            );
216        }
217    }
218
219    #[test]
220    fn strips_variant_prefixes() {
221        let err = DataFusionError::Plan("table 'x' not found".into());
222        assert_eq!(user_message(&err), "table 'x' not found");
223        let err = DataFusionError::SQL(
224            Box::new(ParserError::ParserError("Expected: an expression".into())),
225            None,
226        );
227        assert_eq!(user_message(&err), "syntax error: Expected: an expression");
228        let err = DataFusionError::External(Box::new(DataFusionError::Execution("boom".into())));
229        assert_eq!(user_message(&err), "boom");
230    }
231}