Skip to content

Commit 6900981

Browse files
committed
fix: UNIQUE constraint with NULLs incorrectly collapses GROUP BY groups
1 parent 29f1acd commit 6900981

6 files changed

Lines changed: 203 additions & 37 deletions

File tree

datafusion/common/src/functional_dependencies.rs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,6 @@ pub fn aggregate_functional_dependencies(
422422
) -> FunctionalDependencies {
423423
let mut aggregate_func_dependencies = vec![];
424424
let aggr_input_fields = aggr_input_schema.field_names();
425-
let aggr_fields = aggr_schema.fields();
426425
// Association covers the whole table:
427426
let target_indices = (0..aggr_schema.fields().len()).collect::<Vec<_>>();
428427
// Get functional dependencies of the schema:
@@ -484,9 +483,12 @@ pub fn aggregate_functional_dependencies(
484483
if !group_by_expr_names.is_empty() {
485484
let count = group_by_expr_names.len();
486485
let source_indices = (0..count).collect::<Vec<_>>();
487-
let nullable = source_indices
488-
.iter()
489-
.any(|idx| aggr_fields[*idx].is_nullable());
486+
// Aggregation with GROUP BY always produces unique output rows for
487+
// each distinct combination of GROUP BY keys. The nullable flag is
488+
// set to false here so that subsequent expansion (e.g. a second
489+
// GROUP BY on the aggregate output) is never blocked by source
490+
// field nullability.
491+
let nullable = false;
490492
// If GROUP BY expressions do not already act as a determinant:
491493
if !aggregate_func_dependencies.iter().any(|item| {
492494
// If `item.source_indices` is a subset of GROUP BY expressions, we shouldn't add
@@ -565,9 +567,17 @@ pub fn get_required_group_by_exprs_indices(
565567
for FunctionalDependence {
566568
source_indices,
567569
target_indices,
570+
nullable,
568571
..
569572
} in &dependencies.deps
570573
{
574+
// Skip nullable dependencies: UNIQUE constraints allow NULL values,
575+
// and NULLs are not considered equal in SQL, so two rows with NULL in
576+
// the source key are NOT in the same group. We therefore cannot use a
577+
// nullable FD to eliminate other GROUP BY columns.
578+
if *nullable {
579+
continue;
580+
}
571581
if source_indices
572582
.iter()
573583
.all(|source_idx| groupby_expr_indices.contains(source_idx))

datafusion/expr/src/logical_plan/builder.rs

Lines changed: 63 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,9 @@ use datafusion_common::display::ToStringifiedPlan;
5454
use datafusion_common::file_options::file_type::FileType;
5555
use datafusion_common::metadata::FieldMetadata;
5656
use datafusion_common::{
57-
Column, Constraints, DFSchema, DFSchemaRef, NullEquality, Result, ScalarValue,
58-
TableReference, ToDFSchema, UnnestOptions, exec_err,
59-
get_target_functional_dependencies, internal_datafusion_err, plan_datafusion_err,
60-
plan_err,
57+
Column, Constraints, DFSchema, DFSchemaRef, Dependency, NullEquality, Result, ScalarValue,
58+
TableReference, ToDFSchema, UnnestOptions, exec_err, internal_datafusion_err,
59+
plan_datafusion_err, plan_err,
6160
};
6261
use datafusion_expr_common::type_coercion::binary::type_union_resolution;
6362

@@ -1853,15 +1852,40 @@ pub fn add_group_by_exprs_from_dependencies(
18531852
.map(|e| e.schema_name().to_string())
18541853
.collect::<Vec<_>>();
18551854

1856-
if let Some(target_indices) =
1857-
get_target_functional_dependencies(schema, &group_by_field_names)
1858-
{
1859-
for idx in target_indices {
1860-
let expr = Expr::Column(Column::from(schema.qualified_field(idx)));
1861-
let expr_name = expr.schema_name().to_string();
1862-
if !group_by_field_names.contains(&expr_name) {
1863-
group_by_field_names.push(expr_name);
1864-
group_expr.push(expr);
1855+
let field_names = schema.field_names();
1856+
for dependence in schema.functional_dependencies().iter() {
1857+
// SQL UNIQUE constraints (mode == Single) allow multiple NULLs, so a
1858+
// nullable UNIQUE key on a nullable source column cannot safely be
1859+
// used to synthesize additional GROUP BY keys. Doing so could split
1860+
// NULL groups and change aggregate results.
1861+
// Join-derived dependencies (mode == Multi) represent downgraded PKs
1862+
// from outer joins and are safe to expand even when nullable.
1863+
if dependence.nullable
1864+
&& dependence.mode == Dependency::Single
1865+
&& dependence
1866+
.source_indices
1867+
.iter()
1868+
.any(|&source_idx| schema.field(source_idx).is_nullable())
1869+
{
1870+
continue;
1871+
}
1872+
1873+
let source_key_names = dependence
1874+
.source_indices
1875+
.iter()
1876+
.map(|idx| &field_names[*idx])
1877+
.collect::<Vec<_>>();
1878+
if source_key_names
1879+
.iter()
1880+
.all(|source_key_name| group_by_field_names.contains(source_key_name))
1881+
{
1882+
for idx in &dependence.target_indices {
1883+
let expr = Expr::Column(Column::from(schema.qualified_field(*idx)));
1884+
let expr_name = expr.schema_name().to_string();
1885+
if !group_by_field_names.contains(&expr_name) {
1886+
group_by_field_names.push(expr_name);
1887+
group_expr.push(expr);
1888+
}
18651889
}
18661890
}
18671891
}
@@ -2887,6 +2911,32 @@ mod tests {
28872911
Ok(())
28882912
}
28892913

2914+
#[test]
2915+
fn plan_builder_aggregate_does_not_expand_nullable_unique_group_by_exprs()
2916+
-> Result<()> {
2917+
let schema = Schema::new(vec![
2918+
Field::new("id", DataType::Int32, true),
2919+
Field::new("state", DataType::Utf8, false),
2920+
Field::new("salary", DataType::Int32, false),
2921+
]);
2922+
let constraints = Constraints::new_unverified(vec![Constraint::Unique(vec![0])]);
2923+
let table_source = table_source_with_constraints(&schema, constraints);
2924+
2925+
let options =
2926+
LogicalPlanBuilderOptions::new().with_add_implicit_group_by_exprs(true);
2927+
let plan = LogicalPlanBuilder::scan("employee_csv", table_source, None)?
2928+
.with_options(options)
2929+
.aggregate(vec![col("id")], vec![sum(col("salary"))])?
2930+
.build()?;
2931+
2932+
assert_snapshot!(plan, @r"
2933+
Aggregate: groupBy=[[employee_csv.id]], aggr=[[sum(employee_csv.salary)]]
2934+
TableScan: employee_csv
2935+
");
2936+
2937+
Ok(())
2938+
}
2939+
28902940
#[test]
28912941
fn test_join_metadata() -> Result<()> {
28922942
let left_schema = DFSchema::new_with_metadata(

datafusion/expr/src/logical_plan/plan.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,6 @@ impl LogicalPlan {
351351
LogicalPlan::Ddl(ddl) => ddl.schema(),
352352
LogicalPlan::Unnest(Unnest { schema, .. }) => schema,
353353
LogicalPlan::RecursiveQuery(RecursiveQuery { static_term, .. }) => {
354-
// we take the schema of the static term as the schema of the entire recursive query
355354
static_term.schema()
356355
}
357356
}
@@ -2402,6 +2401,10 @@ impl SubqueryAlias {
24022401
// no field must share the same column name as this would lead to ambiguity when referencing
24032402
// columns in parent logical nodes.
24042403

2404+
// Capture whether the input is a RecursiveQuery before `plan` may be
2405+
// rebound to a wrapping Projection below.
2406+
let is_recursive_query = matches!(plan.as_ref(), LogicalPlan::RecursiveQuery(_));
2407+
24052408
// Compute unique aliases, if any, for each column of the input's schema.
24062409
let aliases = unique_field_aliases(plan.schema().fields());
24072410
let is_projection_needed = aliases.iter().any(Option::is_some);
@@ -2431,7 +2434,14 @@ impl SubqueryAlias {
24312434
// Requalify fields with the new `alias`.
24322435
let fields = plan.schema().fields().clone();
24332436
let meta_data = plan.schema().metadata().clone();
2434-
let func_dependencies = plan.schema().functional_dependencies().clone();
2437+
// Recursive queries do not expose the anchor's functional dependencies to
2438+
// the outer schema — the recursive term can produce rows that violate
2439+
// those dependencies, so they are intentionally dropped here.
2440+
let func_dependencies = if is_recursive_query {
2441+
FunctionalDependencies::empty()
2442+
} else {
2443+
plan.schema().functional_dependencies().clone()
2444+
};
24352445

24362446
let schema = DFSchema::from_unqualified_fields(fields, meta_data)?;
24372447
let schema = schema.as_arrow();

datafusion/expr/src/logical_plan/tree_node.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,7 @@ impl TreeNode for LogicalPlan {
329329
static_term,
330330
recursive_term,
331331
is_distinct,
332+
..
332333
}) => (static_term, recursive_term).map_elements(f)?.update_data(
333334
|(static_term, recursive_term)| {
334335
LogicalPlan::RecursiveQuery(RecursiveQuery {

datafusion/sql/src/select.rs

Lines changed: 78 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,16 @@ use crate::utils::{
2929

3030
use datafusion_common::error::DataFusionErrorBuilder;
3131
use datafusion_common::tree_node::{TreeNode, TreeNodeRecursion};
32-
use datafusion_common::{Column, DFSchema, DFSchemaRef, Result, not_impl_err, plan_err};
32+
use datafusion_common::{Column, DFSchema, DFSchemaRef, Dependency, Result, not_impl_err, plan_err};
3333
use datafusion_common::{RecursionUnnestOption, UnnestOptions};
3434
use datafusion_expr::expr::{PlannedReplaceSelectItem, WildcardOptions};
3535
use datafusion_expr::expr_rewriter::{
3636
normalize_col, normalize_col_with_schemas_and_ambiguity_check, normalize_sorts,
3737
};
3838
use datafusion_expr::select_expr::SelectExpr;
3939
use datafusion_expr::utils::{
40-
expr_as_column_expr, expr_to_columns, find_aggregate_exprs, find_window_exprs,
40+
expr_as_column_expr, expr_to_columns, find_aggregate_exprs,
41+
find_window_exprs,
4142
};
4243
use datafusion_expr::{
4344
Aggregate, Expr, Filter, GroupingSet, LogicalPlan, LogicalPlanBuilder,
@@ -966,12 +967,15 @@ impl<S: ContextProvider> SqlToRel<'_, S> {
966967
group_by_exprs: &[Expr],
967968
aggr_exprs: &[Expr],
968969
) -> Result<AggregatePlanResult> {
970+
let group_by_exprs = self.add_required_group_by_exprs_from_dependencies(
971+
input,
972+
group_by_exprs,
973+
)?;
974+
969975
// create the aggregate plan
970-
let options =
971-
LogicalPlanBuilderOptions::new().with_add_implicit_group_by_exprs(true);
972976
let plan = LogicalPlanBuilder::from(input.clone())
973-
.with_options(options)
974-
.aggregate(group_by_exprs.to_vec(), aggr_exprs.to_vec())?
977+
.with_options(LogicalPlanBuilderOptions::new())
978+
.aggregate(group_by_exprs.clone(), aggr_exprs.to_vec())?
975979
.build()?;
976980
let group_by_exprs = if let LogicalPlan::Aggregate(agg) = &plan {
977981
&agg.group_expr
@@ -1138,6 +1142,74 @@ impl<S: ContextProvider> SqlToRel<'_, S> {
11381142
})
11391143
}
11401144

1145+
/// Expand `group_by_exprs` with additional columns that are functionally
1146+
/// determined by the current GROUP BY keys, using the functional
1147+
/// dependencies recorded on the input schema.
1148+
///
1149+
/// A dependency is skipped when **all** of the following are true:
1150+
/// - `nullable = true` — the constraint allows NULLs (i.e. SQL `UNIQUE`,
1151+
/// not `PRIMARY KEY`)
1152+
/// - `mode == Single` — the dependency comes from a single-table scan,
1153+
/// not from a join. Join-derived dependencies (`mode == Multi`) are
1154+
/// downgraded PKs and remain safe to expand.
1155+
/// - At least one source field is nullable in the schema.
1156+
///
1157+
/// Together these conditions identify nullable `UNIQUE` keys on nullable
1158+
/// columns, where multiple NULLs are permitted and `key → other_cols`
1159+
/// does not hold for the NULL group. Expanding such a key would split
1160+
/// the NULL group and produce incorrect aggregates.
1161+
fn add_required_group_by_exprs_from_dependencies(
1162+
&self,
1163+
input: &LogicalPlan,
1164+
group_by_exprs: &[Expr],
1165+
) -> Result<Vec<Expr>> {
1166+
let schema = input.schema();
1167+
let field_names = schema.field_names();
1168+
let mut result = group_by_exprs.to_vec();
1169+
let mut group_by_field_names: Vec<String> =
1170+
result.iter().map(|e| e.schema_name().to_string()).collect();
1171+
1172+
for dependence in schema.functional_dependencies().iter() {
1173+
// SQL UNIQUE constraints (mode == Single) allow multiple NULLs,
1174+
// so a nullable UNIQUE key on a nullable source column cannot
1175+
// safely synthesise additional GROUP BY keys — it could split
1176+
// NULL groups and change aggregate results.
1177+
// Join-derived dependencies (mode == Multi) represent downgraded
1178+
// PKs from outer joins and are safe to expand even when nullable.
1179+
if dependence.nullable
1180+
&& dependence.mode == Dependency::Single
1181+
&& dependence
1182+
.source_indices
1183+
.iter()
1184+
.any(|&source_idx| schema.field(source_idx).is_nullable())
1185+
{
1186+
continue;
1187+
}
1188+
1189+
let source_key_names = dependence
1190+
.source_indices
1191+
.iter()
1192+
.map(|idx| &field_names[*idx])
1193+
.collect::<Vec<_>>();
1194+
if source_key_names
1195+
.iter()
1196+
.all(|n| group_by_field_names.contains(n))
1197+
{
1198+
for idx in &dependence.target_indices {
1199+
let expr =
1200+
Expr::Column(Column::from(schema.qualified_field(*idx)));
1201+
let expr_name = expr.schema_name().to_string();
1202+
if !group_by_field_names.contains(&expr_name) {
1203+
group_by_field_names.push(expr_name);
1204+
result.push(expr);
1205+
}
1206+
}
1207+
}
1208+
}
1209+
1210+
Ok(result)
1211+
}
1212+
11411213
// If the projection is done over a named window, that window
11421214
// name must be defined. Otherwise, it gives an error.
11431215
fn match_window_definitions(

datafusion/sqllogictest/test_files/group_by.slt

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3565,33 +3565,27 @@ SELECT r.sn, r.amount, SUM(r.amount)
35653565
GROUP BY r.sn
35663566
ORDER BY r.sn
35673567

3568-
# left semi join should propagate constraint of left side as is.
3569-
query IRR
3568+
# left semi join with a nullable UNIQUE key cannot safely propagate the
3569+
# constraint for expansion, because UNIQUE allows multiple NULLs.
3570+
statement error DataFusion error: Error during planning: Column in SELECT must be in GROUP BY or an aggregate function: While expanding wildcard, column "l\.amount" must appear in the GROUP BY clause or must be part of an aggregate function, currently only "l\.sn, sum\(l\.amount\)" appears in the SELECT clause satisfies this requirement
35703571
SELECT l.sn, l.amount, SUM(l.amount)
35713572
FROM (SELECT *
35723573
FROM sales_global_with_unique as l
35733574
LEFT SEMI JOIN sales_global_with_unique as r
35743575
ON l.amount >= r.amount + 10)
35753576
GROUP BY l.sn
35763577
ORDER BY l.sn
3577-
----
3578-
1 50 50
3579-
2 75 75
3580-
3 200 200
3581-
4 100 100
3582-
NULL 100 100
35833578

3584-
# Similarly, left anti join should propagate constraint of left side as is.
3585-
query IRR
3579+
# Similarly, left anti join with a nullable UNIQUE key cannot safely propagate
3580+
# the constraint for expansion.
3581+
statement error DataFusion error: Error during planning: Column in SELECT must be in GROUP BY or an aggregate function: While expanding wildcard, column "l\.amount" must appear in the GROUP BY clause or must be part of an aggregate function, currently only "l\.sn, sum\(l\.amount\)" appears in the SELECT clause satisfies this requirement
35863582
SELECT l.sn, l.amount, SUM(l.amount)
35873583
FROM (SELECT *
35883584
FROM sales_global_with_unique as l
35893585
LEFT ANTI JOIN sales_global_with_unique as r
35903586
ON l.amount >= r.amount + 10)
35913587
GROUP BY l.sn
35923588
ORDER BY l.sn
3593-
----
3594-
0 30 30
35953589

35963590
# Should support grouping by list column
35973591
query ?I
@@ -5641,3 +5635,32 @@ set datafusion.execution.target_partitions = 4;
56415635

56425636
statement count 0
56435637
drop table t;
5638+
5639+
# Test that GROUP BY with a UNIQUE constraint does not incorrectly collapse
5640+
# NULL rows. UNIQUE allows multiple NULLs (NULLs are not equal in SQL), so
5641+
# a UNIQUE column cannot be used to eliminate other GROUP BY columns.
5642+
# Regression test for https://github.com/apache/datafusion/issues/21507
5643+
5644+
statement ok
5645+
CREATE TABLE t_unique_null(a INT, b INT, c INT, UNIQUE(a));
5646+
5647+
statement ok
5648+
INSERT INTO t_unique_null VALUES (1, 10, 100), (NULL, 20, 200), (NULL, 30, 300);
5649+
5650+
# The two NULL rows must stay in separate groups (grouped by b as well).
5651+
query II rowsort
5652+
SELECT a, SUM(c) AS total FROM t_unique_null GROUP BY a, b;
5653+
----
5654+
1 100
5655+
NULL 200
5656+
NULL 300
5657+
5658+
# GROUP BY on the UNIQUE column alone must still merge the NULL rows into one group.
5659+
query II rowsort
5660+
SELECT a, SUM(c) AS total FROM t_unique_null GROUP BY a;
5661+
----
5662+
1 100
5663+
NULL 500
5664+
5665+
statement ok
5666+
DROP TABLE t_unique_null;

0 commit comments

Comments
 (0)