Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add related source code locations to errors #13664

Merged
merged 39 commits into from
Feb 1, 2025
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
13ad9db
Reset branch to no changes other than SqlRel
eliaperantoni Jan 16, 2025
4603b35
feat: improve `Diagnostic` ergonomics
eliaperantoni Jan 16, 2025
86e1722
feat: 'table not found' diagnostic in `SqlToRel`
eliaperantoni Jan 16, 2025
c3532c1
feat: unresolved fields
eliaperantoni Jan 17, 2025
91f4e3c
feat: union with different number of columns
eliaperantoni Jan 17, 2025
85db8af
feat: `Spans`
eliaperantoni Jan 23, 2025
3f4ddd2
feat: adjust field not found error
eliaperantoni Jan 23, 2025
b4df859
feat: incompatible types in binary expr
eliaperantoni Jan 23, 2025
e4652d7
fix: tests not compiling
eliaperantoni Jan 23, 2025
d2e70ad
feat: ambiguous column
eliaperantoni Jan 23, 2025
e44cb9f
feat: possible references
eliaperantoni Jan 23, 2025
31772ed
feat: group by
eliaperantoni Jan 23, 2025
c15d8f5
chore: fmt
eliaperantoni Jan 23, 2025
09d7065
feat: relay `Spans` in `create_col_from_scalar_expr`
eliaperantoni Jan 23, 2025
1d5c647
chore: cargo check
eliaperantoni Jan 23, 2025
5fdc10e
chore: cargo fmt
eliaperantoni Jan 23, 2025
f63a493
Merge branch 'main' into eper/diagnostics
eliaperantoni Jan 23, 2025
6f6722d
feat: diagnostic can only be either error or warning
eliaperantoni Jan 24, 2025
9a8ef78
feat: simplify to one `Diagnostic` per error
eliaperantoni Jan 24, 2025
fd46dce
test: add tests
eliaperantoni Jan 24, 2025
360ac20
chore: add license headers
eliaperantoni Jan 24, 2025
3dd66fc
chore: update CLI lockfile
eliaperantoni Jan 24, 2025
13c4c9d
chore: taplo format
eliaperantoni Jan 24, 2025
fe74dce
fix: doctest
eliaperantoni Jan 24, 2025
829430a
chore: configs.md
eliaperantoni Jan 24, 2025
2772c6b
fix: test
eliaperantoni Jan 24, 2025
1b35207
fix: clippy, by removing smallvec
eliaperantoni Jan 24, 2025
243b788
fix: update slt
eliaperantoni Jan 24, 2025
e98694b
Merge branch 'main' into eper/diagnostics
eliaperantoni Jan 24, 2025
e847477
feat: support all binary expressions
eliaperantoni Jan 24, 2025
60a9f8b
refactor: move diagnostic tests to datafusion-sql
eliaperantoni Jan 27, 2025
f178db0
chore: format
eliaperantoni Jan 27, 2025
1308b85
Merge branch 'main' into eper/diagnostics
eliaperantoni Jan 27, 2025
51dd141
fix: tests
eliaperantoni Jan 27, 2025
b5e326c
feat: pr feedback
eliaperantoni Jan 28, 2025
1cd0f3e
fix: ci checks
eliaperantoni Jan 28, 2025
20e9f61
Merge remote-tracking branch 'apache/main' into eper/diagnostics
alamb Jan 29, 2025
e1e6ac5
Merge remote-tracking branch 'apache/main' into eper/diagnostics
alamb Feb 1, 2025
7265bd2
Merge remote-tracking branch 'apache/main' into eper/diagnostics
alamb Feb 1, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions datafusion/common/src/diagnostic.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
use sqlparser::tokenizer::Span;

#[derive(Debug, Clone)]
pub struct Diagnostic {
pub entries: Vec<DiagnosticEntry>,
}

#[derive(Debug, Clone)]
pub struct DiagnosticEntry {
pub span: Span,
pub message: String,
pub kind: DiagnosticEntryKind,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiagnosticEntryKind {
Error,
Warning,
Note,
Help,
}

impl Diagnostic {
pub fn new() -> Self {
Default::default()
}
}

impl Default for Diagnostic {
fn default() -> Self {
Diagnostic {
entries: Vec::new(),
}
}
}

impl FromIterator<DiagnosticEntry> for Diagnostic {
fn from_iter<T: IntoIterator<Item = DiagnosticEntry>>(iter: T) -> Self {
Diagnostic {
entries: iter.into_iter().collect(),
}
}
}

macro_rules! with_kind {
($name:ident, $kind:expr) => {
pub fn $name(mut self, message: impl Into<String>, span: Span) -> Self {
let entry = DiagnosticEntry {
span,
message: message.into(),
kind: $kind,
};
self.entries.push(entry);
self
}
};
}

impl Diagnostic {
with_kind!(with_error, DiagnosticEntryKind::Error);
with_kind!(with_warning, DiagnosticEntryKind::Warning);
with_kind!(with_note, DiagnosticEntryKind::Note);
with_kind!(with_help, DiagnosticEntryKind::Help);
}

impl DiagnosticEntry {
pub fn new(
message: impl Into<String>,
kind: DiagnosticEntryKind,
span: Span,
) -> Self {
DiagnosticEntry {
span,
message: message.into(),
kind,
}
}

pub fn new_without_span(
message: impl Into<String>,
kind: DiagnosticEntryKind,
) -> Self {
Self::new(message, kind, Span::empty())
}
}
57 changes: 56 additions & 1 deletion datafusion/common/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ use std::result;
use std::sync::Arc;

use crate::utils::quote_identifier;
use crate::{Column, DFSchema, TableReference};
use crate::{Column, DFSchema, Diagnostic, TableReference};
#[cfg(feature = "avro")]
use apache_avro::Error as AvroError;
use arrow::error::ArrowError;
Expand Down Expand Up @@ -131,6 +131,11 @@ pub enum DataFusionError {
/// Errors from either mapping LogicalPlans to/from Substrait plans
/// or serializing/deserializing protobytes to Substrait plans
Substrait(String),
/// Error wrapped together with additional contextual information intended
/// for end users, to help them understand what went wrong by providing
/// human-readable messages, and locations in the source query that relate
/// to the error in some way.
Diagnostic(Diagnostic, Box<DataFusionError>),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an interesting idea -- one way to think about this is that it adds additional structured information to DataFusionError::Context

If we went with the DataFusion::Diagnostic approach, do you think we would be able to deprecate / remove DataFusionError::Context in a future release?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! Yes, I think DataFusion::Diagnostic can convey a superset of the information that DataFusion::Context can. Any wrapping such as:

let error = DataFusionError::Context(message, Box::new(error));

can be converted to:

let error = DataFusionError::Diagnostic(
	Diagnostic {
		entries: vec![
			DiagnosticEntry {
				span: Span::empty(),
				kind: DiagnosticEntryKind::Error,
				message,
			}
		]
	},
	Box::new(error)
);

And of course, we can provide a DataFusionError::with_simple_diagnostic function to avoid the boilerplate. At that point, DataFusion::Context could be removed.

This also enables progressively adding Span information to what was previously simply a string message.

}

#[macro_export]
Expand Down Expand Up @@ -328,6 +333,7 @@ impl Error for DataFusionError {
DataFusionError::External(e) => Some(e.as_ref()),
DataFusionError::Context(_, e) => Some(e.as_ref()),
DataFusionError::Substrait(_) => None,
DataFusionError::Diagnostic(_, e) => Some(e.as_ref()),
}
}
}
Expand Down Expand Up @@ -441,6 +447,7 @@ impl DataFusionError {
DataFusionError::External(_) => "External error: ",
DataFusionError::Context(_, _) => "",
DataFusionError::Substrait(_) => "Substrait error: ",
DataFusionError::Diagnostic(_, _) => "",
}
}

Expand Down Expand Up @@ -481,8 +488,56 @@ impl DataFusionError {
Cow::Owned(format!("{desc}\ncaused by\n{}", *err))
}
DataFusionError::Substrait(ref desc) => Cow::Owned(desc.to_string()),
DataFusionError::Diagnostic(_, ref err) => Cow::Owned(err.to_string()),
}
}

/// Wraps the error with contextual information intended for end users
pub fn with_diagnostic(self, diagnostic: Diagnostic) -> Self {
Self::Diagnostic(diagnostic, Box::new(self))
}

/// Wraps the error with contextual information intended for end users.
/// Takes a function that inspects the error and returns the diagnostic to
/// wrap it with.
pub fn with_diagnostic_fn<F: FnOnce(&DataFusionError) -> Diagnostic>(
self,
f: F,
) -> Self {
let diagnostic = f(&self);
self.with_diagnostic(diagnostic)
}

pub fn get_diagnostics(&self) -> impl Iterator<Item = &Diagnostic> + '_ {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor -- I think a more consistent API would be to call this

Suggested change
pub fn get_diagnostics(&self) -> impl Iterator<Item = &Diagnostic> + '_ {
pub fn diagnostics(&self) -> impl Iterator<Item = &Diagnostic> + '_ {

struct DiagnosticsIterator<'a> {
head: &'a DataFusionError,
}

impl<'a> Iterator for DiagnosticsIterator<'a> {
type Item = &'a Diagnostic;

fn next(&mut self) -> Option<Self::Item> {
loop {
if let DataFusionError::Diagnostic(diagnostics, source) = self.head {
self.head = source.as_ref();
return Some(diagnostics);
}

if let Some(source) = self
.head
.source()
.and_then(|source| source.downcast_ref::<DataFusionError>())
{
self.head = source;
} else {
return None;
}
}
}
}

DiagnosticsIterator { head: self }
}
}

/// Unwrap an `Option` if possible. Otherwise return an `DataFusionError::Internal`.
Expand Down
2 changes: 2 additions & 0 deletions datafusion/common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ pub mod test_util;
pub mod tree_node;
pub mod types;
pub mod utils;
pub mod diagnostic;

/// Reexport arrow crate
pub use arrow;
Expand Down Expand Up @@ -76,6 +77,7 @@ pub use stats::{ColumnStatistics, Statistics};
pub use table_reference::{ResolvedTableReference, TableReference};
pub use unnest::{RecursionUnnestOption, UnnestOptions};
pub use utils::project_schema;
pub use diagnostic::{Diagnostic, DiagnosticEntry, DiagnosticEntryKind};

// These are hidden from docs purely to avoid polluting the public view of what this crate exports.
// These are just re-exports of macros by the same name, which gets around the 'cannot refer to
Expand Down
129 changes: 127 additions & 2 deletions datafusion/sql/src/expr/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,21 @@
// specific language governing permissions and limitations
// under the License.

use std::ops::ControlFlow;

use arrow_schema::DataType;
use arrow_schema::TimeUnit;
use datafusion_common::Column;
use datafusion_common::DataFusionError;
use datafusion_common::Diagnostic;
use datafusion_common::SchemaError;
use datafusion_expr::planner::{
PlannerResult, RawBinaryExpr, RawDictionaryExpr, RawFieldAccessExpr,
};
use datafusion_expr::utils::find_column_exprs;
use sqlparser::ast::Spanned;
use sqlparser::ast::Visit;
use sqlparser::ast::Visitor;
use sqlparser::ast::{
BinaryOperator, CastFormat, CastKind, DataType as SQLDataType, DictionaryField,
Expr as SQLExpr, ExprWithAlias as SQLExprWithAlias, MapEntry, StructField, Subscript,
Expand All @@ -36,7 +46,9 @@ use datafusion_expr::{
lit, Between, BinaryExpr, Cast, Expr, ExprSchemable, GetFieldAccess, Like, Literal,
Operator, TryCast,
};
use sqlparser::tokenizer::Span;

use crate::planner::IdentNormalizer;
use crate::planner::{ContextProvider, PlannerContext, SqlToRel};

mod binary_op;
Expand Down Expand Up @@ -165,13 +177,126 @@ impl<S: ContextProvider> SqlToRel<'_, S> {
schema: &DFSchema,
planner_context: &mut PlannerContext,
) -> Result<Expr> {
let mut expr = self.sql_expr_to_logical_expr(sql, schema, planner_context)?;
// The location of the original SQL expression in the source code
let mut expr =
self.sql_expr_to_logical_expr(sql.clone(), schema, planner_context)?;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having to copy the entire AST just to get the span information on error is non ideal (we are trying to keep planning reasonably faster)

I understand that Expr doesn't have the Span (and adding it is quite potentially intrusive). However, I wonder if you have considered using PlannerContext?

Specifically, since the PlannerContext is already threaded through most/all planning methods, if you could add the "current span" on the PlannerContext, you would have the necessary span information when you generated an error.

#[derive(Debug, Clone)]
pub struct PlannerContext {
...
  /// the current span of the expression or statement being planned
  /// Note not all statements have span information yet
  /// see <https://github.com/apache/datafusion-sqlparser-rs/issues/1548>
  current_span: Option<Span>,
...
}

Then rather than calling sql_expr_to_logical_expr twice, you could have the error generated in sql_expr_to_logical_expr include the span information.

The key would to manage setting/restoring the spans during the planing process. Maybe it could be something like

...
// set the `current_span` field in the planner context
// if sql has a span, otherwise use the existing span
let planner_context = planner_context.with_span(&sql);
self.sql_expr_to_logical_expr(sql.clone(), schema, planner_context)?;
...

Copy link
Contributor Author

@eliaperantoni eliaperantoni Jan 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a very interesting idea. I see a few issues with it though:

What if sql is a compound expression like a + b but only b is unresolved? Ideally, I would want sql_expr_to_logical_expr to be able to return diagnostics that highlight b only. But how does it do that if it only know the Span of the entire sql expression?

What if a diagnostic needs to highlight various parts of a query. e.g. if a non-aggregated column is missing from the GROUP BY clause, a good diagnostic would highlight both the column and the GROUP BY clause. But planner context has only one Span, and which is the "current span" in this case?

I thought that I could solve the above issues by putting a HashMap<String, Vec<Span>> in PlannerContext. The key would be to label different parts of the query, e.g. group_by_clause, set_operator, left_side_of_expr, etc.. So that I could do something like:

error.with_diagnostic(Diagnostic::new().with_error(
    "column missing from GROUP BY",
    planner_context.span("expr").last(), // Highlight the column
).with_note(
    "GROUP BY is here",
    planner_context.span("group_by").last(), // Highlight the GROUP BY
))

The Vec<_> is just to have stacks, so that in something like SELECT ... UNION (SELECT ... UNION SELECT ...) the set_operator key could point to the outer UNION, then the inner one, then restore the outer one when we're done planning the query in parentheses.

But then I encountered another issue:

Some functions where PlannerContext is threaded through (e.g. sql_expr_to_logical_expr and sql_expr_to_logical_expr_internal) take &mut PlannerContext. What if I need to add new spans?

For example, in the body of sql_expr_to_logical_expr I would like to do:

image

But, what if sql_expr_to_logical_expr_internal needs to mutate the given &mut PlannerContext? I can't simply clone PlannerContext and add a new span because I then break the ability to mutate downstream.

Then it seems like a pattern like:

pattern_context.with_span("expr", sql_expr.span(), move |planner_context| {
    let expr = self.sql_expr_to_logical_expr_internal(
        *sql_expr,
        schema,
        planner_context,
    )?;
});

might be necessary, to add a Span to the given PlannerContext but also remove it. Idk if that's less invasive than adding Span to Expr though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also it's validate_schema_satisfies_exprs that throws the error, not sql_expr_to_logical_expr. And in that place I have access to the SQL expression, but the problem still is that I don't know which subexpression was the problem

eliaperantoni marked this conversation as resolved.
Show resolved Hide resolved
expr = self.rewrite_partial_qualifier(expr, schema);
self.validate_schema_satisfies_exprs(schema, std::slice::from_ref(&expr))?;
let validation_result =
self.validate_schema_satisfies_exprs(schema, std::slice::from_ref(&expr));

// Must do it here because `validate_schema_satisfies_exprs` doesn't
// have access to the original SQL expression from the parser
validation_result.map_err(|err| {
if let DataFusionError::SchemaError(
SchemaError::FieldNotFound {
field,
valid_fields: _,
},
_,
) = &err
{
let diagnostic = self.get_field_not_found_diagnostic(
&sql,
field,
schema,
planner_context,
);
err.with_diagnostic(diagnostic)
} else {
err
}
})?;

let (expr, _) = expr.infer_placeholder_types(schema)?;
Ok(expr)
}

/// Given an unresolved field in an expression, returns a [`Diagnostic`]
fn get_field_not_found_diagnostic(
&self,
expr: &SQLExpr,
unresolved_field: &Column,
schema: &DFSchema,
planner_context: &mut PlannerContext,
) -> Diagnostic {
// Given a SQL expression like SELECT color, megabytes FROM fruit, where
// we assume that the 'megabytes' column doesn't exist in table 'fruit',
// we find that the logical expression Expr::Column(Column('megabytes'))
// is unresolved. Though, because we don't store `sqlparser::Span` in
// `datafusion::Expr`, we have no simple way to find where 'megabytes'
// appears in the query.
//
// Instead, we have to walk down the tree of subexpressions
// `sqlparser::Expr` rooted in the parameter `expr: sqlparser::Expr` to
// find a node that matches the unresolved one.
struct UnresolvedFieldFinder<'a, S: ContextProvider> {
unresolved_field: &'a Column,
sql_to_rel: &'a SqlToRel<'a, S>,
schema: &'a DFSchema,
planner_context: &'a mut PlannerContext,
}
impl<S: ContextProvider> Visitor for UnresolvedFieldFinder<'_, S> {
type Break = Span;

fn post_visit_expr(
&mut self,
sql_expr: &SQLExpr,
) -> ControlFlow<Self::Break> {
let (logical_expr, span) = match sql_expr {
SQLExpr::Identifier(ident) => (
self.sql_to_rel
.sql_identifier_to_expr(
ident.clone(),
self.schema,
self.planner_context,
)
.ok(),
sql_expr.span(),
),
SQLExpr::CompoundIdentifier(idents) => (
self.sql_to_rel
.sql_compound_identifier_to_expr(
idents.clone(),
self.schema,
self.planner_context,
)
.ok(),
sql_expr.span(),
),
_ => (None, Span::empty()),
};
match logical_expr {
Some(Expr::Column(col)) if &col == self.unresolved_field => {
ControlFlow::Break(span)
}
_ => ControlFlow::Continue(()),
}
}
}
let mut visitor = UnresolvedFieldFinder {
unresolved_field: &unresolved_field,
sql_to_rel: self,
schema,
planner_context,
};
let span = match expr.visit(&mut visitor) {
ControlFlow::Break(span) => Some(span),
ControlFlow::Continue(_) => None,
};

if let Some(relation) = &unresolved_field.relation {
Diagnostic::new().with_error(
format!("column '{}' not found in '{}'", &unresolved_field.name, relation.to_string()),
span.unwrap_or(Span::empty()),
)
} else {
Diagnostic::new().with_error(
format!("column '{}' not found", &unresolved_field.name),
span.unwrap_or(Span::empty()),
)
}
}

/// Rewrite aliases which are not-complete (e.g. ones that only include only table qualifier in a schema.table qualified relation)
fn rewrite_partial_qualifier(&self, expr: Expr, schema: &DFSchema) -> Expr {
match expr {
Expand Down
4 changes: 2 additions & 2 deletions datafusion/sql/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,10 +284,10 @@ impl<'a> DFParser<'a> {
dialect: &'a dyn Dialect,
) -> Result<Self, ParserError> {
let mut tokenizer = Tokenizer::new(dialect, sql);
let tokens = tokenizer.tokenize()?;
let tokens = tokenizer.tokenize_with_location()?;

Ok(DFParser {
parser: Parser::new(dialect).with_tokens(tokens),
parser: Parser::new(dialect).with_tokens_with_locations(tokens),
})
}

Expand Down
Loading
Loading