Skip to content

Commit 3a76cd3

Browse files
committed
Consolidate MERGE INTO regression tests
1 parent 3b9ebdb commit 3a76cd3

3 files changed

Lines changed: 82 additions & 176 deletions

File tree

datafusion/core/src/physical_planner.rs

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3475,10 +3475,8 @@ mod tests {
34753475

34763476
#[tokio::test]
34773477
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-
]));
3478+
let schema =
3479+
Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
34823480
let target = Arc::new(CaptureMergeProvider {
34833481
schema: Arc::clone(&schema),
34843482
captured: Mutex::new(None),
@@ -3490,8 +3488,7 @@ mod tests {
34903488

34913489
ctx.sql(
34923490
"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)",
3491+
WHEN MATCHED AND t.id > s.id THEN DELETE",
34953492
)
34963493
.await?
34973494
.create_physical_plan()
@@ -3500,17 +3497,17 @@ mod tests {
35003497
let captured = target.captured.lock().await;
35013498
let (merge_schema, physical_on, clause_count) =
35023499
captured.as_ref().expect("merge_into should be called");
3503-
assert_eq!(*clause_count, 2);
3500+
assert_eq!(*clause_count, 1);
35043501
assert_eq!(
35053502
merge_schema.index_of_column(&Column::new(Some("target"), "id"))?,
35063503
0
35073504
);
35083505
assert_eq!(
35093506
merge_schema.index_of_column(&Column::new(Some("s"), "id"))?,
3510-
2
3507+
1
35113508
);
35123509
assert_contains!(physical_on, "index: 0");
3513-
assert_contains!(physical_on, "index: 2");
3510+
assert_contains!(physical_on, "index: 1");
35143511
Ok(())
35153512
}
35163513

datafusion/core/tests/sql/sql_api.rs

Lines changed: 51 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -208,67 +208,64 @@ async fn ddl_can_not_be_planned_by_session_state() {
208208
);
209209
}
210210

211-
#[tokio::test]
212-
async fn merge_into_rejects_source_alias_colliding_with_target_name() {
213-
// Regression test: `MERGE INTO target AS t USING source AS target ...`
214-
// aliases the source to the target table's real name. Both `id` columns
215-
// are identically named. Before the fix, canonicalizing the aliased target
216-
// reference `t.id` to `target.id` collapsed it onto the source's
217-
// `target.id`, so `t.id = target.id` silently became `target.id = target.id`
218-
// (a comparison of the source column with itself). The planner must reject
219-
// this collision instead of changing the meaning of the condition.
211+
async fn merge_into_context() -> SessionContext {
220212
let ctx = SessionContext::new();
221-
ctx.sql("CREATE TABLE target (id INT, val INT)")
213+
ctx.sql("CREATE TABLE target (id INT)").await.unwrap();
214+
ctx.sql("CREATE TABLE source (id INT)").await.unwrap();
215+
ctx
216+
}
217+
218+
async fn assert_merge_sql_error(ctx: &SessionContext, sql: &str, expected: &str) {
219+
let err = ctx.sql(sql).await.unwrap_err();
220+
assert_contains!(err.strip_backtrace(), expected);
221+
}
222+
223+
async fn assert_merge_physical_error(ctx: &SessionContext, sql: &str, expected: &str) {
224+
let err = ctx
225+
.sql(sql)
222226
.await
223-
.unwrap();
224-
ctx.sql("CREATE TABLE source (id INT, val INT)")
227+
.unwrap()
228+
.create_physical_plan()
225229
.await
226-
.unwrap();
230+
.unwrap_err();
231+
assert_contains!(err.strip_backtrace(), expected);
232+
}
233+
234+
#[tokio::test]
235+
async fn merge_into_rejects_source_alias_colliding_with_target_name() {
236+
// Canonicalizing `t.id` to `target.id` must not collapse it onto a source
237+
// that also uses `target` as its qualifier.
238+
let ctx = merge_into_context().await;
227239

228240
for target_ref in ["target", "public.target", "datafusion.public.target"] {
229-
let err = ctx
230-
.sql(&format!(
241+
assert_merge_sql_error(
242+
&ctx,
243+
&format!(
231244
"MERGE INTO {target_ref} AS t USING source AS target \
232245
ON t.id = target.id WHEN MATCHED THEN DELETE"
233-
))
234-
.await
235-
.unwrap_err();
236-
237-
assert_contains!(
238-
err.strip_backtrace(),
246+
),
239247
&format!(
240248
"MERGE source may not use the target table name '{target_ref}' \
241249
as a qualifier"
242-
)
243-
);
250+
),
251+
)
252+
.await;
244253
}
245254
}
246255

247256
#[tokio::test]
248257
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-
257-
let err = ctx
258-
.sql(
259-
"MERGE INTO target AS t USING source AS s \
260-
ON EXISTS (SELECT 1 FROM source AS x WHERE x.id = t.id) \
261-
WHEN MATCHED THEN DELETE",
262-
)
263-
.await
264-
.unwrap_err();
265-
assert_contains!(
266-
err.strip_backtrace(),
267-
"MERGE subqueries correlated to target alias 't' are not supported"
268-
);
258+
let ctx = merge_into_context().await;
259+
assert_merge_sql_error(
260+
&ctx,
261+
"MERGE INTO target AS t USING source AS s \
262+
ON EXISTS (SELECT 1 FROM source AS x WHERE x.id = t.id) \
263+
WHEN MATCHED THEN DELETE",
264+
"MERGE subqueries correlated to target alias 't' are not supported",
265+
)
266+
.await;
269267

270-
// Source correlation and uncorrelated set-comparison subqueries do not
271-
// require target-alias canonicalization and remain supported through
268+
// Source-correlated and uncorrelated subqueries remain supported through
272269
// logical optimization.
273270
for sql in [
274271
"MERGE INTO target AS t USING source AS s \
@@ -278,29 +275,14 @@ async fn merge_into_rejects_subqueries_correlated_to_target_alias() {
278275
ON t.id = ANY (SELECT id FROM source) \
279276
WHEN MATCHED THEN DELETE",
280277
] {
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-
);
278+
assert_merge_physical_error(&ctx, sql, "MERGE INTO not supported for Base table")
279+
.await;
292280
}
293281
}
294282

295283
#[tokio::test]
296284
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();
285+
let ctx = merge_into_context().await;
304286

305287
for (sql, expected) in [
306288
(
@@ -312,31 +294,14 @@ async fn merge_into_requires_boolean_conditions() {
312294
WHEN MATCHED AND 1 THEN DELETE",
313295
"MERGE WHEN condition must be boolean type, but got Int64",
314296
),
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(
297+
(
328298
"MERGE INTO target USING source ON NULL \
329299
WHEN MATCHED AND NULL THEN DELETE",
330-
)
331-
.await
332-
.unwrap()
333-
.create_physical_plan()
334-
.await
335-
.unwrap_err();
336-
assert_contains!(
337-
err.strip_backtrace(),
338-
"MERGE INTO not supported for Base table"
339-
);
300+
"MERGE INTO not supported for Base table",
301+
),
302+
] {
303+
assert_merge_physical_error(&ctx, sql, expected).await;
304+
}
340305
}
341306

342307
#[tokio::test]

datafusion/sql/tests/sql_integration.rs

Lines changed: 25 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -3601,51 +3601,37 @@ fn select_groupby_orderby_aggregate_on_non_selected_column_original_issue() {
36013601
}
36023602

36033603
#[test]
3604-
fn plan_merge_into_canonicalizes_target_alias() {
3605-
// The target alias `t` must be rewritten to the real table name `j1` in the
3606-
// stored plan, while the source alias `s` is preserved. This keeps the plan
3607-
// independent of the SQL alias so analyzer passes and proto deserialization
3608-
// can rebuild the target schema from the table name alone.
3609-
let sql = "MERGE INTO j1 AS t USING j2 AS s ON t.j1_id = s.j2_id \
3610-
WHEN MATCHED THEN UPDATE SET j1_string = s.j2_string \
3611-
WHEN NOT MATCHED THEN INSERT (j1_id, j1_string) VALUES (s.j2_id, s.j2_string)";
3612-
let plan = logical_plan(sql).unwrap();
3604+
fn plan_merge_into_canonicalizes_qualifiers_and_preserves_quoted_columns() {
3605+
let plan = logical_plan(
3606+
"MERGE INTO person_quoted_cols AS t USING j2 AS s ON t.id = s.j2_id \
3607+
WHEN MATCHED THEN UPDATE SET \"First Name\" = s.j2_string \
3608+
WHEN NOT MATCHED THEN INSERT (id, \"Age\") VALUES (s.j2_id, 42)",
3609+
)
3610+
.unwrap();
36133611
let LogicalPlan::Dml(dml) = &plan else {
36143612
panic!("expected Dml, got {plan:?}");
36153613
};
36163614
let datafusion_expr::WriteOp::MergeInto(merge_op) = &dml.op else {
36173615
panic!("expected MergeInto, got {:?}", dml.op);
36183616
};
3619-
// `t.j1_id` -> `j1.j1_id`, `s.j2_id` left untouched.
3620-
assert_eq!(merge_op.on.to_string(), "j1.j1_id = s.j2_id");
3621-
// Source-side column references remain qualified with the source alias.
3622-
let exprs: Vec<String> = merge_op.exprs().iter().map(|e| e.to_string()).collect();
3623-
assert_eq!(
3624-
exprs,
3625-
vec![
3626-
"j1.j1_id = s.j2_id".to_string(),
3627-
"s.j2_string".to_string(),
3628-
"s.j2_id".to_string(),
3629-
"s.j2_string".to_string(),
3630-
]
3631-
);
3632-
}
36333617

3634-
#[test]
3635-
fn plan_merge_into_rejects_source_alias_colliding_with_target_name() {
3636-
// Aliasing the source to the target table's real name (`USING j2 AS j1`,
3637-
// where `j1` is the target) would make canonicalizing the target alias
3638-
// `t` to `j1` collide with the source qualifier, silently collapsing the
3639-
// two namespaces. The planner must reject this rather than misresolve.
3640-
let sql = "MERGE INTO j1 AS t USING j2 AS j1 ON t.j1_id = j1.j2_id \
3641-
WHEN MATCHED THEN DELETE";
3642-
let err = logical_plan(sql).unwrap_err();
3643-
assert!(
3644-
err.strip_backtrace().contains(
3645-
"MERGE source may not use the target table name 'j1' as a qualifier"
3646-
),
3647-
"unexpected error: {err}"
3648-
);
3618+
assert_eq!(merge_op.on.to_string(), "person_quoted_cols.id = s.j2_id");
3619+
3620+
let datafusion_expr::dml::MergeIntoAction::Update(assignments) =
3621+
&merge_op.clauses[0].action
3622+
else {
3623+
panic!("expected UPDATE");
3624+
};
3625+
assert_eq!(assignments[0].0, "First Name");
3626+
assert_eq!(assignments[0].1.to_string(), "s.j2_string");
3627+
3628+
let datafusion_expr::dml::MergeIntoAction::Insert { columns, values } =
3629+
&merge_op.clauses[1].action
3630+
else {
3631+
panic!("expected INSERT");
3632+
};
3633+
assert_eq!(columns, &["id".to_string(), "Age".to_string()]);
3634+
assert_eq!(values[0].to_string(), "s.j2_id");
36493635
}
36503636

36513637
#[rstest]
@@ -3665,18 +3651,6 @@ fn plan_merge_into_rejects_source_alias_colliding_with_target_name() {
36653651
VALUES (j2.j2_id, j2.j2_string) WHERE false",
36663652
"MERGE INSERT WHERE predicates are not supported"
36673653
)]
3668-
fn plan_merge_into_rejects_unsupported_action_predicates(
3669-
#[case] sql: &str,
3670-
#[case] expected: &str,
3671-
) {
3672-
let err = logical_plan(sql).unwrap_err();
3673-
assert!(
3674-
err.strip_backtrace().contains(expected),
3675-
"unexpected error: {err}"
3676-
);
3677-
}
3678-
3679-
#[rstest]
36803654
#[case(
36813655
"MERGE INTO j1 USING j2 ON j1.j1_id = j2.j2_id \
36823656
WHEN MATCHED THEN UPDATE SET j1_string = 'a', j1_string = 'b'",
@@ -3713,7 +3687,7 @@ fn plan_merge_into_rejects_unsupported_action_predicates(
37133687
"MERGE INTO j1 AS t(a) USING j2 ON true WHEN MATCHED THEN DELETE",
37143688
"MERGE target alias column lists are not supported"
37153689
)]
3716-
fn plan_merge_into_validates_action_targets_and_structure(
3690+
fn plan_merge_into_rejects_invalid_actions_and_structure(
37173691
#[case] sql: &str,
37183692
#[case] expected: &str,
37193693
) {
@@ -3724,36 +3698,6 @@ fn plan_merge_into_validates_action_targets_and_structure(
37243698
);
37253699
}
37263700

3727-
#[test]
3728-
fn plan_merge_into_preserves_quoted_action_columns() {
3729-
let plan = logical_plan(
3730-
"MERGE INTO person_quoted_cols AS t USING j2 AS s ON t.id = s.j2_id \
3731-
WHEN MATCHED THEN UPDATE SET \"First Name\" = s.j2_string \
3732-
WHEN NOT MATCHED THEN INSERT (id, \"Age\") VALUES (s.j2_id, 42)",
3733-
)
3734-
.unwrap();
3735-
let LogicalPlan::Dml(dml) = plan else {
3736-
panic!("expected Dml");
3737-
};
3738-
let datafusion_expr::WriteOp::MergeInto(merge_op) = dml.op else {
3739-
panic!("expected MergeInto");
3740-
};
3741-
3742-
let datafusion_expr::dml::MergeIntoAction::Update(assignments) =
3743-
&merge_op.clauses[0].action
3744-
else {
3745-
panic!("expected UPDATE");
3746-
};
3747-
assert_eq!(assignments[0].0, "First Name");
3748-
3749-
let datafusion_expr::dml::MergeIntoAction::Insert { columns, .. } =
3750-
&merge_op.clauses[1].action
3751-
else {
3752-
panic!("expected INSERT");
3753-
};
3754-
assert_eq!(columns, &["id".to_string(), "Age".to_string()]);
3755-
}
3756-
37573701
fn logical_plan(sql: &str) -> Result<LogicalPlan> {
37583702
logical_plan_with_options(sql, ParserOptions::default())
37593703
}

0 commit comments

Comments
 (0)