Skip to content

Commit 8de4f28

Browse files
committed
Fix MERGE INTO planner edge cases
1 parent e3b27a5 commit 8de4f28

13 files changed

Lines changed: 1005 additions & 56 deletions

File tree

datafusion/catalog/src/table.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ use crate::session::Session;
2424
use arrow::datatypes::SchemaRef;
2525
use async_trait::async_trait;
2626
use datafusion_common::{Constraints, Statistics, not_impl_err};
27-
use datafusion_common::{Result, internal_err};
27+
use datafusion_common::{DFSchemaRef, Result, internal_err};
2828
use datafusion_expr::Expr;
2929
use datafusion_expr::statistics::StatisticsRequest;
3030

@@ -383,6 +383,10 @@ pub trait TableProvider: Any + Debug + Sync + Send {
383383
/// Merge rows from a source into this table.
384384
///
385385
/// The `source` is an [`ExecutionPlan`] representing the USING clause.
386+
/// The `merge_schema` contains the target columns followed by the source
387+
/// columns, preserving their logical qualifiers. Providers can use this
388+
/// schema to resolve the logical expressions against the combined rows
389+
/// they construct while executing the merge.
386390
/// The `on` condition is the join predicate from the ON clause.
387391
/// The `clauses` describe the WHEN MATCHED / WHEN NOT MATCHED actions.
388392
///
@@ -391,6 +395,7 @@ pub trait TableProvider: Any + Debug + Sync + Send {
391395
&self,
392396
_state: &dyn Session,
393397
_source: Arc<dyn ExecutionPlan>,
398+
_merge_schema: DFSchemaRef,
394399
_on: Expr,
395400
_clauses: Vec<MergeIntoClause>,
396401
) -> Result<Arc<dyn ExecutionPlan>> {

datafusion/core/src/physical_planner.rs

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -931,14 +931,23 @@ impl DefaultPhysicalPlanner {
931931
table_name,
932932
target,
933933
op: WriteOp::MergeInto(merge_op),
934+
input,
934935
..
935936
}) => {
936-
let provider = source_as_provider(target)?;
937+
let provider = source_as_provider(target).map_err(|e| {
938+
e.context(format!("MERGE INTO operation on table '{table_name}'"))
939+
})?;
937940
let input_exec = children.one()?;
941+
let target_schema = DFSchema::try_from_qualified_schema(
942+
table_name.clone(),
943+
&target.schema(),
944+
)?;
945+
let merge_schema = Arc::new(target_schema.join(input.schema())?);
938946
provider
939947
.merge_into(
940948
session_state,
941949
input_exec,
950+
merge_schema,
942951
merge_op.on.clone(),
943952
merge_op.clauses.clone(),
944953
)
@@ -3262,6 +3271,7 @@ mod tests {
32623271
use datafusion_execution::TaskContext;
32633272
use datafusion_execution::runtime_env::RuntimeEnv;
32643273
use datafusion_expr::builder::subquery_alias;
3274+
use datafusion_expr::dml::MergeIntoClause;
32653275
use datafusion_expr::expr::AggregateFunctionParams;
32663276
use datafusion_expr::function::{AccumulatorArgs, StateFieldsArgs};
32673277
use datafusion_expr::{
@@ -3285,6 +3295,88 @@ mod tests {
32853295
.build()
32863296
}
32873297

3298+
#[derive(Debug)]
3299+
struct CaptureMergeProvider {
3300+
schema: SchemaRef,
3301+
captured: Mutex<Option<(DFSchemaRef, String, usize)>>,
3302+
}
3303+
3304+
#[async_trait]
3305+
impl TableProvider for CaptureMergeProvider {
3306+
fn schema(&self) -> SchemaRef {
3307+
Arc::clone(&self.schema)
3308+
}
3309+
3310+
fn table_type(&self) -> TableType {
3311+
TableType::Base
3312+
}
3313+
3314+
async fn scan(
3315+
&self,
3316+
_state: &dyn Session,
3317+
_projection: Option<&Vec<usize>>,
3318+
_filters: &[Expr],
3319+
_limit: Option<usize>,
3320+
) -> Result<Arc<dyn ExecutionPlan>> {
3321+
Ok(Arc::new(EmptyExec::new(Arc::clone(&self.schema))))
3322+
}
3323+
3324+
async fn merge_into(
3325+
&self,
3326+
state: &dyn Session,
3327+
source: Arc<dyn ExecutionPlan>,
3328+
merge_schema: DFSchemaRef,
3329+
on: Expr,
3330+
clauses: Vec<MergeIntoClause>,
3331+
) -> Result<Arc<dyn ExecutionPlan>> {
3332+
let physical_on = state.create_physical_expr(on, &merge_schema)?;
3333+
*self.captured.lock().await =
3334+
Some((merge_schema, format!("{physical_on:?}"), clauses.len()));
3335+
Ok(source)
3336+
}
3337+
}
3338+
3339+
#[tokio::test]
3340+
async fn merge_into_provider_receives_combined_logical_schema() -> Result<()> {
3341+
let schema = Arc::new(Schema::new(vec![
3342+
Field::new("id", DataType::Int32, false),
3343+
Field::new("val", DataType::Int32, true),
3344+
]));
3345+
let target = Arc::new(CaptureMergeProvider {
3346+
schema: Arc::clone(&schema),
3347+
captured: Mutex::new(None),
3348+
});
3349+
let source = Arc::new(MemTable::try_new(Arc::clone(&schema), vec![vec![]])?);
3350+
let ctx = SessionContext::new();
3351+
ctx.register_table("target", target.clone())?;
3352+
ctx.register_table("source", source)?;
3353+
3354+
ctx.sql(
3355+
"MERGE INTO target AS t USING source AS s ON t.id = s.id \
3356+
WHEN MATCHED AND t.val IS NULL THEN UPDATE SET val = s.val \
3357+
WHEN NOT MATCHED THEN INSERT (id, val) VALUES (s.id, s.val)",
3358+
)
3359+
.await?
3360+
.create_physical_plan()
3361+
.await?;
3362+
3363+
let captured = target.captured.lock().await;
3364+
let (merge_schema, physical_on, clause_count) =
3365+
captured.as_ref().expect("merge_into should be called");
3366+
assert_eq!(*clause_count, 2);
3367+
assert_eq!(
3368+
merge_schema.index_of_column(&Column::new(Some("target"), "id"))?,
3369+
0
3370+
);
3371+
assert_eq!(
3372+
merge_schema.index_of_column(&Column::new(Some("s"), "id"))?,
3373+
2
3374+
);
3375+
assert_contains!(physical_on, "index: 0");
3376+
assert_contains!(physical_on, "index: 2");
3377+
Ok(())
3378+
}
3379+
32883380
async fn plan(logical_plan: &LogicalPlan) -> Result<Arc<dyn ExecutionPlan>> {
32893381
let session_state = make_session_state();
32903382
// optimize the logical plan

datafusion/core/tests/sql/sql_api.rs

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,17 +225,117 @@ async fn merge_into_rejects_source_alias_colliding_with_target_name() {
225225
.await
226226
.unwrap();
227227

228+
for target_ref in ["target", "public.target", "datafusion.public.target"] {
229+
let err = ctx
230+
.sql(&format!(
231+
"MERGE INTO {target_ref} AS t USING source AS target \
232+
ON t.id = target.id WHEN MATCHED THEN DELETE"
233+
))
234+
.await
235+
.unwrap_err();
236+
237+
assert_contains!(
238+
err.strip_backtrace(),
239+
&format!(
240+
"MERGE source may not use the target table name '{target_ref}' \
241+
as a qualifier"
242+
)
243+
);
244+
}
245+
}
246+
247+
#[tokio::test]
248+
async fn merge_into_rejects_subqueries_correlated_to_target_alias() {
249+
let ctx = SessionContext::new();
250+
ctx.sql("CREATE TABLE target (id INT, val INT)")
251+
.await
252+
.unwrap();
253+
ctx.sql("CREATE TABLE source (id INT, val INT)")
254+
.await
255+
.unwrap();
256+
228257
let err = ctx
229258
.sql(
230-
"MERGE INTO target AS t USING source AS target ON t.id = target.id \
259+
"MERGE INTO target AS t USING source AS s \
260+
ON EXISTS (SELECT 1 FROM source AS x WHERE x.id = t.id) \
231261
WHEN MATCHED THEN DELETE",
232262
)
233263
.await
234264
.unwrap_err();
265+
assert_contains!(
266+
err.strip_backtrace(),
267+
"MERGE subqueries correlated to target alias 't' are not supported"
268+
);
269+
270+
// Source correlation and uncorrelated set-comparison subqueries do not
271+
// require target-alias canonicalization and remain supported through
272+
// logical optimization.
273+
for sql in [
274+
"MERGE INTO target AS t USING source AS s \
275+
ON EXISTS (SELECT 1 FROM source AS x WHERE x.id = s.id) \
276+
WHEN MATCHED THEN DELETE",
277+
"MERGE INTO target AS t USING source AS s \
278+
ON t.id = ANY (SELECT id FROM source) \
279+
WHEN MATCHED THEN DELETE",
280+
] {
281+
let err = ctx
282+
.sql(sql)
283+
.await
284+
.unwrap()
285+
.create_physical_plan()
286+
.await
287+
.unwrap_err();
288+
assert_contains!(
289+
err.strip_backtrace(),
290+
"MERGE INTO not supported for Base table"
291+
);
292+
}
293+
}
235294

295+
#[tokio::test]
296+
async fn merge_into_requires_boolean_conditions() {
297+
let ctx = SessionContext::new();
298+
ctx.sql("CREATE TABLE target (id INT, val INT)")
299+
.await
300+
.unwrap();
301+
ctx.sql("CREATE TABLE source (id INT, val INT)")
302+
.await
303+
.unwrap();
304+
305+
for (sql, expected) in [
306+
(
307+
"MERGE INTO target USING source ON 1 WHEN MATCHED THEN DELETE",
308+
"MERGE ON condition must be boolean type, but got Int64",
309+
),
310+
(
311+
"MERGE INTO target USING source ON true \
312+
WHEN MATCHED AND 1 THEN DELETE",
313+
"MERGE WHEN condition must be boolean type, but got Int64",
314+
),
315+
] {
316+
let err = ctx
317+
.sql(sql)
318+
.await
319+
.unwrap()
320+
.create_physical_plan()
321+
.await
322+
.unwrap_err();
323+
assert_contains!(err.strip_backtrace(), expected);
324+
}
325+
326+
let err = ctx
327+
.sql(
328+
"MERGE INTO target USING source ON NULL \
329+
WHEN MATCHED AND NULL THEN DELETE",
330+
)
331+
.await
332+
.unwrap()
333+
.create_physical_plan()
334+
.await
335+
.unwrap_err();
236336
assert_contains!(
237337
err.strip_backtrace(),
238-
"MERGE source may not use the target table name 'target' as a qualifier"
338+
"MERGE INTO not supported for Base table"
239339
);
240340
}
241341

datafusion/expr/src/logical_plan/invariants.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ use datafusion_common::{
2121
};
2222

2323
use crate::{
24-
Aggregate, Expr, Filter, Join, JoinType, LogicalPlan, Window,
24+
Aggregate, DmlStatement, Expr, Filter, Join, JoinType, LogicalPlan, Window, WriteOp,
2525
expr::{Exists, InSubquery, SetComparison},
2626
expr_rewriter::strip_outer_reference,
2727
utils::{collect_subquery_cols, split_conjunction},
@@ -253,7 +253,11 @@ pub fn check_subquery_expr(
253253
| LogicalPlan::TableScan(_)
254254
| LogicalPlan::Window(_)
255255
| LogicalPlan::Aggregate(_)
256-
| LogicalPlan::Join(_) => Ok(()),
256+
| LogicalPlan::Join(_)
257+
| LogicalPlan::Dml(DmlStatement {
258+
op: WriteOp::MergeInto(_),
259+
..
260+
}) => Ok(()),
257261
_ => plan_err!(
258262
"In/Exist/SetComparison subquery can only be used in \
259263
Projection, Filter, TableScan, Window functions, Aggregate and Join plan nodes, \

0 commit comments

Comments
 (0)