1use datafusion::arrow::error::ArrowError;
16use datafusion::common::SchemaError;
17use datafusion::error::DataFusionError;
18use datafusion::sql::sqlparser::parser::ParserError;
19
20pub mod matched {
28 pub const OPTIMIZER_RULE_FAILED: &str = "Optimizer rule";
32
33 pub const TOKENIZER_UNTERMINATED: &str = "Unterminated";
38
39 pub const TOKENIZER_EOF: &str = "EOF";
44
45 pub const TIMESTAMP_OVERFLOW: &str = "exceeds the representable i64 range";
51
52 pub const TIMESTAMP_NS: &str = "Timestamp(ns)";
54
55 pub const CSV_FILE_CONTEXT: &str = "Error when processing CSV file ";
60}
61
62pub 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
75const 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
81pub 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 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
134fn 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 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 #[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 assert_eq!(user_message(&err), "the cause");
254 }
255
256 #[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 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 #[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 #[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 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 #[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}