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    /// In the error `ListingTableConfig::infer_options` returns when a
28    /// location lists no file (an internal error). Ursa says "no file found
29    /// at '…'" instead (`file_table.rs`).
30    pub const NO_FILES_FOR_TABLE: &str = "No files for table";
31
32    /// In the error `ListingTableConfig::infer_options` returns for a file
33    /// whose extension names no format it knows. Ursa lists the formats it
34    /// reads instead (`file_table.rs`).
35    pub const NO_FILE_FORMAT_FOR_EXTENSION: &str = "No file_format found with extension";
36
37    /// The start of the context DataFusion's optimizer puts on a rule's error
38    /// (`Optimizer rule '…' failed`). Which rule failed is DataFusion's
39    /// business: [`super::user_message`] drops it and keeps the cause.
40    pub const OPTIMIZER_RULE_FAILED: &str = "Optimizer rule";
41
42    /// The start of sqlparser's tokenizer error for a string literal (or a
43    /// dollar-quoted or encoded one) still open at the end of the input. The
44    /// shell reads the next line instead of reporting it (`ursa-cli`'s
45    /// `input.rs`).
46    pub const TOKENIZER_UNTERMINATED: &str = "Unterminated";
47
48    /// In sqlparser's tokenizer error for a quoted identifier or a block
49    /// comment still open at the end of the input (`… before EOF.`,
50    /// `Unexpected EOF while in a multi-line comment`). The shell reads the
51    /// next line instead (`ursa-cli`'s `input.rs`).
52    pub const TOKENIZER_EOF: &str = "EOF";
53}
54
55/// The message to show a user for `err`: what went wrong, without the names of
56/// the internal error types it passed through.
57pub fn user_message(err: &DataFusionError) -> String {
58    match err {
59        DataFusionError::ArrowError(e, _) => arrow_message(e),
60        DataFusionError::ParquetError(e) => format!("Parquet: {e}"),
61        DataFusionError::ObjectStore(e) => e.to_string(),
62        DataFusionError::IoError(e) => e.to_string(),
63        DataFusionError::SQL(e, _) => parser_message(e),
64        DataFusionError::NotImplemented(m)
65        | DataFusionError::Plan(m)
66        | DataFusionError::Configuration(m)
67        | DataFusionError::Execution(m)
68        | DataFusionError::ResourcesExhausted(m)
69        | DataFusionError::Substrait(m)
70        | DataFusionError::Ffi(m) => m.clone(),
71        DataFusionError::Internal(m) => format!("internal error: {m}"),
72        DataFusionError::SchemaError(e, _) => e.to_string(),
73        DataFusionError::ExecutionJoin(e) => e.to_string(),
74        DataFusionError::External(e) => external_message(e.as_ref()),
75        // Which optimizer rule hit the error is DataFusion's business.
76        DataFusionError::Context(context, inner)
77            if context.starts_with(matched::OPTIMIZER_RULE_FAILED) =>
78        {
79            user_message(inner)
80        }
81        DataFusionError::Context(context, inner) => {
82            format!("{context}: {}", user_message(inner))
83        }
84        DataFusionError::Diagnostic(_, inner) => user_message(inner),
85        DataFusionError::Collection(errs) => errs
86            .first()
87            .map(user_message)
88            .unwrap_or_else(|| "unknown error".to_string()),
89        DataFusionError::Shared(inner) => user_message(inner),
90    }
91}
92
93fn parser_message(e: &ParserError) -> String {
94    match e {
95        ParserError::TokenizerError(m) | ParserError::ParserError(m) => {
96            format!("syntax error: {m}")
97        }
98        ParserError::RecursionLimitExceeded => {
99            "syntax error: the statement is nested too deeply".to_string()
100        }
101    }
102}
103
104fn arrow_message(e: &ArrowError) -> String {
105    match e {
106        ArrowError::ExternalError(source) => external_message(source.as_ref()),
107        other => other.to_string(),
108    }
109}
110
111fn external_message(e: &(dyn std::error::Error + Send + Sync + 'static)) -> String {
112    if let Some(df) = e.downcast_ref::<DataFusionError>() {
113        user_message(df)
114    } else if let Some(arrow) = e.downcast_ref::<ArrowError>() {
115        arrow_message(arrow)
116    } else {
117        e.to_string()
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use std::sync::Arc;
124
125    use datafusion::common::tree_node::Transformed;
126    use datafusion::common::DFSchema;
127    use datafusion::datasource::listing::{
128        ListingTableConfig, ListingTableConfigExt, ListingTableUrl,
129    };
130    use datafusion::logical_expr::{EmptyRelation, LogicalPlan};
131    use datafusion::optimizer::{Optimizer, OptimizerConfig, OptimizerContext, OptimizerRule};
132    use datafusion::prelude::SessionContext;
133    use datafusion::sql::sqlparser::dialect::GenericDialect;
134    use datafusion::sql::sqlparser::tokenizer::Tokenizer;
135
136    use super::*;
137
138    /// What `ListingTableConfig::infer_options` says about `location`, which
139    /// must fail: the first step of opening a file as a table, as
140    /// `file_table.rs` does.
141    async fn infer_options_error(location: &std::path::Path) -> String {
142        let ctx = SessionContext::new();
143        let url = ListingTableUrl::parse(location.to_str().unwrap()).unwrap();
144        let err = ListingTableConfig::new(url)
145            .infer_options(&ctx.state())
146            .await
147            .expect_err("infer_options fails");
148        err.to_string()
149    }
150
151    #[tokio::test]
152    async fn datafusion_still_says_no_files_for_table() {
153        let dir = tempfile::tempdir().unwrap();
154        let message = infer_options_error(dir.path()).await;
155        assert!(
156            message.contains(matched::NO_FILES_FOR_TABLE),
157            "DataFusion's message for a location with no file changed; update \
158             matched::NO_FILES_FOR_TABLE: {message}"
159        );
160    }
161
162    #[tokio::test]
163    async fn datafusion_still_says_no_file_format_for_extension() {
164        let dir = tempfile::tempdir().unwrap();
165        let file = dir.path().join("notes.txt");
166        std::fs::write(&file, "hello\n").unwrap();
167        let message = infer_options_error(&file).await;
168        assert!(
169            message.contains(matched::NO_FILE_FORMAT_FOR_EXTENSION),
170            "DataFusion's message for an unknown extension changed; update \
171             matched::NO_FILE_FORMAT_FOR_EXTENSION: {message}"
172        );
173    }
174
175    /// An optimizer rule that always fails.
176    #[derive(Debug)]
177    struct Failing;
178
179    impl OptimizerRule for Failing {
180        fn name(&self) -> &str {
181            "failing"
182        }
183
184        fn supports_rewrite(&self) -> bool {
185            true
186        }
187
188        fn rewrite(
189            &self,
190            _plan: LogicalPlan,
191            _config: &dyn OptimizerConfig,
192        ) -> datafusion::error::Result<Transformed<LogicalPlan>> {
193            Err(DataFusionError::Plan("the cause".into()))
194        }
195    }
196
197    #[test]
198    fn datafusion_still_labels_a_failed_optimizer_rule() {
199        let optimizer = Optimizer::with_rules(vec![Arc::new(Failing)]);
200        let config = OptimizerContext::new().with_skip_failing_rules(false);
201        let plan = LogicalPlan::EmptyRelation(EmptyRelation {
202            produce_one_row: false,
203            schema: Arc::new(DFSchema::empty()),
204        });
205        let err = optimizer
206            .optimize(plan, &config, |_, _| {})
207            .expect_err("the rule fails");
208        let DataFusionError::Context(label, _) = &err else {
209            panic!(
210                "DataFusion no longer wraps a failed optimizer rule's error in a context: {err:?}"
211            );
212        };
213        assert!(
214            label.starts_with(matched::OPTIMIZER_RULE_FAILED),
215            "DataFusion's label for a failed optimizer rule changed; update \
216             matched::OPTIMIZER_RULE_FAILED: {label}"
217        );
218        // And user_message keeps only the cause.
219        assert_eq!(user_message(&err), "the cause");
220    }
221
222    /// `user_message` reads a syntax error's variant, not DataFusion's
223    /// `ParserError("…")` text: DataFusion must still return the typed error.
224    #[tokio::test]
225    async fn datafusion_still_returns_syntax_errors_typed() {
226        let err = SessionContext::new()
227            .sql("SELEC 1")
228            .await
229            .expect_err("a syntax error");
230        assert!(
231            matches!(err.find_root(), DataFusionError::SQL(..)),
232            "DataFusion no longer returns a syntax error as DataFusionError::SQL: {err:?}"
233        );
234        let message = user_message(&err);
235        assert!(
236            message.starts_with("syntax error: ") && !message.contains("ParserError"),
237            "{message}"
238        );
239    }
240
241    /// sqlparser's tokenizer error for `sql`, which must fail.
242    fn tokenizer_error(sql: &str) -> String {
243        Tokenizer::new(&GenericDialect {}, sql)
244            .tokenize()
245            .expect_err("the tokenizer fails")
246            .message
247    }
248
249    #[test]
250    fn sqlparser_still_says_unterminated_and_eof() {
251        for sql in ["SELECT 'abc", "SELECT 'it''s"] {
252            let message = tokenizer_error(sql);
253            assert!(
254                message.starts_with(matched::TOKENIZER_UNTERMINATED),
255                "sqlparser's message for an open string changed; update \
256                 matched::TOKENIZER_UNTERMINATED: {sql}: {message}"
257            );
258        }
259        for sql in ["SELECT \"abc", "SELECT 1 /* a note"] {
260            let message = tokenizer_error(sql);
261            assert!(
262                message.contains(matched::TOKENIZER_EOF),
263                "sqlparser's message for an open quoted name or comment changed; update \
264                 matched::TOKENIZER_EOF: {sql}: {message}"
265            );
266        }
267    }
268
269    #[test]
270    fn strips_variant_prefixes() {
271        let err = DataFusionError::Plan("table 'x' not found".into());
272        assert_eq!(user_message(&err), "table 'x' not found");
273        let err = DataFusionError::SQL(
274            Box::new(ParserError::ParserError("Expected: an expression".into())),
275            None,
276        );
277        assert_eq!(user_message(&err), "syntax error: Expected: an expression");
278        let err = DataFusionError::External(Box::new(DataFusionError::Execution("boom".into())));
279        assert_eq!(user_message(&err), "boom");
280    }
281}