Skip to content

Commit fb21a80

Browse files
authored
Normalize wildcard EXCLUDE targets during SQL planning (#115)
1 parent c2412a0 commit fb21a80

5 files changed

Lines changed: 93 additions & 32 deletions

File tree

‎datafusion/expr/src/utils.rs‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -367,18 +367,18 @@ fn get_excluded_columns(
367367
};
368368
idents.extend(exclude_owned.iter());
369369
}
370-
// Excluded columns should be unique
370+
// Lookup uses the resolved name, so quoting differences cannot distinguish duplicates.
371371
let n_elem = idents.len();
372-
let unique_idents = idents.into_iter().collect::<HashSet<_>>();
373-
// If HashSet size, and vector length are different, this means that some of the excluded columns
374-
// are not unique. In this case return error.
375-
if n_elem != unique_idents.len() {
372+
let unique_names = idents
373+
.into_iter()
374+
.map(|ident| ident.value.as_str())
375+
.collect::<HashSet<_>>();
376+
if n_elem != unique_names.len() {
376377
return plan_err!("EXCLUDE or EXCEPT contains duplicate column names");
377378
}
378379

379380
let mut result = vec![];
380-
for ident in unique_idents.into_iter() {
381-
let col_name = ident.value.as_str();
381+
for col_name in unique_names {
382382
let (qualifier, field) = schema.qualified_field_with_name(qualifier, col_name)?;
383383
result.push(Column::from((qualifier, field)));
384384
}

‎datafusion/sql/src/expr/function.rs‎

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -40,25 +40,6 @@ use sqlparser::ast::{
4040
WindowType,
4141
};
4242

43-
fn function_wildcard_options(
44-
options: WildcardAdditionalOptions,
45-
) -> Result<Box<WildcardOptions>> {
46-
if options.opt_alias.is_some() {
47-
return not_impl_err!("wildcard function argument with AS alias");
48-
}
49-
if options.opt_replace.is_some() {
50-
return not_impl_err!("wildcard function argument with REPLACE");
51-
}
52-
53-
Ok(Box::new(WildcardOptions {
54-
ilike: options.opt_ilike,
55-
exclude: options.opt_exclude,
56-
except: options.opt_except,
57-
replace: None,
58-
rename: options.opt_rename,
59-
}))
60-
}
61-
6243
/// Suggest a valid function based on an invalid input function name
6344
///
6445
/// Returns `None` if no valid matches are found. This happens when there are no
@@ -247,6 +228,28 @@ impl FunctionArgs {
247228
type WithinGroupExtraction = (Vec<SortExpr>, Vec<Expr>, Vec<Option<ArgumentName>>);
248229

249230
impl<S: ContextProvider> SqlToRel<'_, S> {
231+
fn function_wildcard_options(
232+
&self,
233+
options: WildcardAdditionalOptions,
234+
) -> Result<Box<WildcardOptions>> {
235+
if options.opt_alias.is_some() {
236+
return not_impl_err!("wildcard function argument with AS alias");
237+
}
238+
if options.opt_replace.is_some() {
239+
return not_impl_err!("wildcard function argument with REPLACE");
240+
}
241+
242+
Ok(Box::new(WildcardOptions {
243+
ilike: options.opt_ilike,
244+
exclude: options
245+
.opt_exclude
246+
.map(|exclude| self.normalize_wildcard_exclude(exclude)),
247+
except: options.opt_except,
248+
replace: None,
249+
rename: options.opt_rename,
250+
}))
251+
}
252+
250253
pub(super) fn sql_function_to_expr(
251254
&self,
252255
function: SQLFunction,
@@ -1109,7 +1112,7 @@ impl<S: ContextProvider> SqlToRel<'_, S> {
11091112
#[expect(deprecated)]
11101113
let expr = Expr::Wildcard {
11111114
qualifier: None,
1112-
options: function_wildcard_options(options)?,
1115+
options: self.function_wildcard_options(options)?,
11131116
};
11141117
Ok((expr, None))
11151118
}
@@ -1140,7 +1143,7 @@ impl<S: ContextProvider> SqlToRel<'_, S> {
11401143
#[expect(deprecated)]
11411144
let expr = Expr::Wildcard {
11421145
qualifier: qualifier.into(),
1143-
options: function_wildcard_options(options)?,
1146+
options: self.function_wildcard_options(options)?,
11441147
};
11451148
Ok((expr, None))
11461149
}

‎datafusion/sql/src/select.rs‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,9 @@ use datafusion_expr::{
5454

5555
use indexmap::IndexMap;
5656
use sqlparser::ast::{
57-
Distinct, Expr as SQLExpr, GroupByExpr, NamedWindowExpr, OrderBy, RenameSelectItem,
58-
SelectItemQualifiedWildcardKind, WildcardAdditionalOptions, WindowType,
59-
visit_expressions_mut,
57+
Distinct, ExcludeSelectItem, Expr as SQLExpr, GroupByExpr, NamedWindowExpr,
58+
ObjectNamePart, OrderBy, RenameSelectItem, SelectItemQualifiedWildcardKind,
59+
WildcardAdditionalOptions, WindowType, visit_expressions_mut,
6060
};
6161
use sqlparser::ast::{NamedWindowDefinition, Select, SelectItem, TableWithJoins};
6262

@@ -1246,6 +1246,22 @@ impl<S: ContextProvider> SqlToRel<'_, S> {
12461246
}
12471247
}
12481248

1249+
pub(crate) fn normalize_wildcard_exclude(
1250+
&self,
1251+
mut exclude: ExcludeSelectItem,
1252+
) -> ExcludeSelectItem {
1253+
let names = match &mut exclude {
1254+
ExcludeSelectItem::Single(name) => std::slice::from_mut(name),
1255+
ExcludeSelectItem::Multiple(names) => names,
1256+
};
1257+
for name in names {
1258+
if let [ObjectNamePart::Identifier(ident)] = name.0.as_mut_slice() {
1259+
ident.value = self.ident_normalizer.normalize(ident.clone());
1260+
}
1261+
}
1262+
exclude
1263+
}
1264+
12491265
/// If there is a REPLACE statement in the projected expression in the form of
12501266
/// "REPLACE (some_column_within_an_expr AS some_column)", we should plan the
12511267
/// replace expressions first.
@@ -1256,6 +1272,9 @@ impl<S: ContextProvider> SqlToRel<'_, S> {
12561272
planner_context: &mut PlannerContext,
12571273
options: WildcardAdditionalOptions,
12581274
) -> Result<WildcardOptions> {
1275+
let exclude = options
1276+
.opt_exclude
1277+
.map(|exclude| self.normalize_wildcard_exclude(exclude));
12591278
let rename = options.opt_rename.map(|mut rename| {
12601279
let items = match &mut rename {
12611280
RenameSelectItem::Single(item) => std::slice::from_mut(item),
@@ -1269,7 +1288,7 @@ impl<S: ContextProvider> SqlToRel<'_, S> {
12691288
});
12701289
let planned_option = WildcardOptions {
12711290
ilike: options.opt_ilike,
1272-
exclude: options.opt_exclude,
1291+
exclude,
12731292
except: options.opt_except,
12741293
replace: None,
12751294
rename,

‎datafusion/sql/tests/sql_integration.rs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2477,6 +2477,27 @@ fn scalar_expr_planner_receives_wildcard_options() {
24772477
);
24782478
}
24792479

2480+
#[test]
2481+
fn scalar_expr_planner_normalizes_wildcard_exclude() {
2482+
let state = mock_session_state().with_expr_planner(Arc::new(ScalarWildcardPlanner));
2483+
let plan = logical_plan_from_state(
2484+
"SELECT concat(* EXCLUDE FIRST_NAME) FROM person AS p",
2485+
&GenericDialect {},
2486+
ParserOptions::default(),
2487+
state,
2488+
)
2489+
.unwrap();
2490+
2491+
assert_snapshot!(
2492+
plan,
2493+
@r"
2494+
Projection: concat(p.last_name, p.state)
2495+
SubqueryAlias: p
2496+
TableScan: person
2497+
"
2498+
);
2499+
}
2500+
24802501
#[test]
24812502
fn scalar_expr_planner_applies_wildcard_ilike() {
24822503
let state = mock_session_state().with_expr_planner(Arc::new(ScalarWildcardPlanner));

‎datafusion/sqllogictest/test_files/select.slt‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1289,6 +1289,24 @@ LIMIT 5
12891289
100 1000
12901290
200 2000
12911291

1292+
# Unquoted EXCLUDE targets follow the same normalization as input columns.
1293+
query III
1294+
SELECT * EXCLUDE B FROM table1 ORDER BY a
1295+
----
1296+
1 100 1000
1297+
2 200 2000
1298+
1299+
# Quoted targets preserve case and match quoted aliases.
1300+
query III
1301+
SELECT * EXCLUDE "B" FROM (SELECT a, b AS "B", c, d FROM table1) ORDER BY a
1302+
----
1303+
1 100 1000
1304+
2 200 2000
1305+
1306+
# Quoted and unquoted spellings that resolve to one field are duplicates.
1307+
statement error DataFusion error: Error during planning: EXCLUDE or EXCEPT contains duplicate column names
1308+
SELECT * EXCLUDE (B, "b") FROM table1
1309+
12921310
# when wildcard is prepended with table name, exclude should still work
12931311
# below query should emit all the columns except a and b
12941312
query II

0 commit comments

Comments
 (0)