Skip to content

Commit c4c0544

Browse files
committed
Fix MERGE INTO planner edge cases
1 parent c0c26cb commit c4c0544

13 files changed

Lines changed: 1005 additions & 56 deletions

File tree

datafusion/core/src/physical_planner.rs

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -971,14 +971,23 @@ impl DefaultPhysicalPlanner {
971971
table_name,
972972
target,
973973
op: WriteOp::MergeInto(merge_op),
974+
input,
974975
..
975976
}) => {
976-
let provider = source_as_provider(target)?;
977+
let provider = source_as_provider(target).map_err(|e| {
978+
e.context(format!("MERGE INTO operation on table '{table_name}'"))
979+
})?;
977980
let input_exec = children.one()?;
981+
let target_schema = DFSchema::try_from_qualified_schema(
982+
table_name.clone(),
983+
&target.schema(),
984+
)?;
985+
let merge_schema = Arc::new(target_schema.join(input.schema())?);
978986
provider
979987
.merge_into(
980988
session_state,
981989
input_exec,
990+
merge_schema,
982991
merge_op.on.clone(),
983992
merge_op.clauses.clone(),
984993
)
@@ -3398,6 +3407,7 @@ mod tests {
33983407
use datafusion_execution::TaskContext;
33993408
use datafusion_execution::runtime_env::RuntimeEnv;
34003409
use datafusion_expr::builder::subquery_alias;
3410+
use datafusion_expr::dml::MergeIntoClause;
34013411
use datafusion_expr::expr::AggregateFunctionParams;
34023412
use datafusion_expr::function::{AccumulatorArgs, StateFieldsArgs};
34033413
use datafusion_expr::{
@@ -3422,6 +3432,88 @@ mod tests {
34223432
.build()
34233433
}
34243434

3435+
#[derive(Debug)]
3436+
struct CaptureMergeProvider {
3437+
schema: SchemaRef,
3438+
captured: Mutex<Option<(DFSchemaRef, String, usize)>>,
3439+
}
3440+
3441+
#[async_trait]
3442+
impl TableProvider for CaptureMergeProvider {
3443+
fn schema(&self) -> SchemaRef {
3444+
Arc::clone(&self.schema)
3445+
}
3446+
3447+
fn table_type(&self) -> TableType {
3448+
TableType::Base
3449+
}
3450+
3451+
async fn scan(
3452+
&self,
3453+
_state: &dyn Session,
3454+
_projection: Option<&Vec<usize>>,
3455+
_filters: &[Expr],
3456+
_limit: Option<usize>,
3457+
) -> Result<Arc<dyn ExecutionPlan>> {
3458+
Ok(Arc::new(EmptyExec::new(Arc::clone(&self.schema))))
3459+
}
3460+
3461+
async fn merge_into(
3462+
&self,
3463+
state: &dyn Session,
3464+
source: Arc<dyn ExecutionPlan>,
3465+
merge_schema: DFSchemaRef,
3466+
on: Expr,
3467+
clauses: Vec<MergeIntoClause>,
3468+
) -> Result<Arc<dyn ExecutionPlan>> {
3469+
let physical_on = state.create_physical_expr(on, &merge_schema)?;
3470+
*self.captured.lock().await =
3471+
Some((merge_schema, format!("{physical_on:?}"), clauses.len()));
3472+
Ok(source)
3473+
}
3474+
}
3475+
3476+
#[tokio::test]
3477+
async fn merge_into_provider_receives_combined_logical_schema() -> Result<()> {
3478+
let schema = Arc::new(Schema::new(vec![
3479+
Field::new("id", DataType::Int32, false),
3480+
Field::new("val", DataType::Int32, true),
3481+
]));
3482+
let target = Arc::new(CaptureMergeProvider {
3483+
schema: Arc::clone(&schema),
3484+
captured: Mutex::new(None),
3485+
});
3486+
let source = Arc::new(MemTable::try_new(Arc::clone(&schema), vec![vec![]])?);
3487+
let ctx = SessionContext::new();
3488+
ctx.register_table("target", target.clone())?;
3489+
ctx.register_table("source", source)?;
3490+
3491+
ctx.sql(
3492+
"MERGE INTO target AS t USING source AS s ON t.id = s.id \
3493+
WHEN MATCHED AND t.val IS NULL THEN UPDATE SET val = s.val \
3494+
WHEN NOT MATCHED THEN INSERT (id, val) VALUES (s.id, s.val)",
3495+
)
3496+
.await?
3497+
.create_physical_plan()
3498+
.await?;
3499+
3500+
let captured = target.captured.lock().await;
3501+
let (merge_schema, physical_on, clause_count) =
3502+
captured.as_ref().expect("merge_into should be called");
3503+
assert_eq!(*clause_count, 2);
3504+
assert_eq!(
3505+
merge_schema.index_of_column(&Column::new(Some("target"), "id"))?,
3506+
0
3507+
);
3508+
assert_eq!(
3509+
merge_schema.index_of_column(&Column::new(Some("s"), "id"))?,
3510+
2
3511+
);
3512+
assert_contains!(physical_on, "index: 0");
3513+
assert_contains!(physical_on, "index: 2");
3514+
Ok(())
3515+
}
3516+
34253517
async fn plan(logical_plan: &LogicalPlan) -> Result<Arc<dyn ExecutionPlan>> {
34263518
let session_state = make_session_state();
34273519
// 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)