Skip to content

Commit ff1fbd4

Browse files
committed
Add MERGE INTO types to datafusion-expr
Add new types to support MERGE INTO DML statements: - MergeIntoOp: Carries ON condition and WHEN clauses - MergeIntoClause: Single WHEN clause (kind, predicate, action) - MergeIntoClauseKind: Matched/NotMatched/NotMatchedByTarget/NotMatchedBySource - MergeIntoAction: Update/Insert/Delete actions Add WriteOp::MergeInto variant to WriteOp enum. Wire MergeInto through datafusion-proto: extend the DmlNode message with a MERGE_INTO type tag and a MergeIntoOpNode payload field, plus matching serializers in to_proto.rs and from_proto.rs. The tag-only conversion From<dml_node::Type> for WriteOp cannot reconstruct the MergeInto payload, so callers must use the new fallible parse_write_op helper. Document that MergeIntoClauseKind::NotMatched and NotMatchedByTarget are semantically equivalent and must be treated identically downstream. Add unit tests in datafusion-expr and proto round-trip tests in datafusion-proto.
1 parent c134a84 commit ff1fbd4

9 files changed

Lines changed: 1424 additions & 11 deletions

File tree

datafusion/expr/src/logical_plan/dml.rs

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ use arrow::datatypes::{DataType, Field, Schema};
2525
use datafusion_common::file_options::file_type::FileType;
2626
use datafusion_common::{DFSchemaRef, TableReference};
2727

28-
use crate::{LogicalPlan, TableSource};
28+
use crate::{Expr, LogicalPlan, TableSource};
2929

3030
/// Operator that copies the contents of a database to file(s)
3131
#[derive(Clone)]
@@ -239,6 +239,8 @@ pub enum WriteOp {
239239
Ctas,
240240
/// `TRUNCATE` operation
241241
Truncate,
242+
/// `MERGE INTO` operation
243+
MergeInto(MergeIntoOp),
242244
}
243245

244246
impl WriteOp {
@@ -250,6 +252,7 @@ impl WriteOp {
250252
WriteOp::Update => "Update",
251253
WriteOp::Ctas => "Ctas",
252254
WriteOp::Truncate => "Truncate",
255+
WriteOp::MergeInto(_) => "MergeInto",
253256
}
254257
}
255258
}
@@ -291,10 +294,94 @@ impl Display for InsertOp {
291294
}
292295
}
293296

297+
/// Describes a MERGE INTO operation's parameters.
298+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Hash)]
299+
pub struct MergeIntoOp {
300+
/// The join condition from `ON <expr>`.
301+
pub on: Expr,
302+
/// The WHEN clauses, in the order they appeared in the SQL.
303+
pub clauses: Vec<MergeIntoClause>,
304+
}
305+
306+
/// A single WHEN clause within a MERGE INTO statement.
307+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Hash)]
308+
pub struct MergeIntoClause {
309+
/// Whether this fires on matched or unmatched rows.
310+
pub kind: MergeIntoClauseKind,
311+
/// Optional additional predicate (`AND <expr>`).
312+
pub predicate: Option<Expr>,
313+
/// The action to take.
314+
pub action: MergeIntoAction,
315+
}
316+
317+
/// Which rows a MERGE WHEN clause applies to.
318+
///
319+
/// Mirrors `sqlparser::ast::MergeClauseKind` so that the SQL spelling is
320+
/// preserved through the logical plan.
321+
///
322+
/// **Note on `NotMatched` vs `NotMatchedByTarget`:** these two variants are
323+
/// semantically identical — both describe a source row that has no matching
324+
/// target row. `NotMatched` is the SQL standard short form (used by
325+
/// Snowflake, Postgres, SQL Server); `NotMatchedByTarget` is BigQuery's
326+
/// explicit form added for symmetry with `NotMatchedBySource`. Downstream
327+
/// consumers (planners, table providers, optimizers) MUST treat the two
328+
/// variants identically.
329+
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Hash)]
330+
pub enum MergeIntoClauseKind {
331+
/// `WHEN MATCHED`
332+
Matched,
333+
/// `WHEN NOT MATCHED` — see type-level note for the equivalence with
334+
/// [`NotMatchedByTarget`](Self::NotMatchedByTarget).
335+
NotMatched,
336+
/// `WHEN NOT MATCHED BY TARGET` — see type-level note for the
337+
/// equivalence with [`NotMatched`](Self::NotMatched).
338+
NotMatchedByTarget,
339+
/// `WHEN NOT MATCHED BY SOURCE`
340+
NotMatchedBySource,
341+
}
342+
343+
/// The action for a single WHEN clause.
344+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Hash)]
345+
pub enum MergeIntoAction {
346+
/// `UPDATE SET col1 = expr1, col2 = expr2, ...`, stored as
347+
/// `(column_name, value_expr)` pairs.
348+
Update(Vec<(String, Expr)>),
349+
/// `INSERT (col1, col2, ...) VALUES (expr1, expr2, ...)`. `columns` may
350+
/// be empty, meaning all columns.
351+
Insert {
352+
columns: Vec<String>,
353+
values: Vec<Expr>,
354+
},
355+
Delete,
356+
}
357+
294358
fn make_count_schema() -> DFSchemaRef {
295359
Arc::new(
296360
Schema::new(vec![Field::new("count", DataType::UInt64, false)])
297361
.try_into()
298362
.unwrap(),
299363
)
300364
}
365+
366+
#[cfg(test)]
367+
mod tests {
368+
use super::*;
369+
use crate::{col, lit};
370+
371+
#[test]
372+
fn write_op_merge_into_name_and_display() {
373+
let op = WriteOp::MergeInto(MergeIntoOp {
374+
on: col("id").eq(col("source_id")),
375+
clauses: vec![MergeIntoClause {
376+
kind: MergeIntoClauseKind::Matched,
377+
predicate: Some(col("qty").gt(lit(0_i64))),
378+
action: MergeIntoAction::Update(vec![(
379+
"qty".to_string(),
380+
col("source_qty"),
381+
)]),
382+
}],
383+
});
384+
assert_eq!(op.name(), "MergeInto");
385+
assert_eq!(format!("{op}"), "MergeInto");
386+
}
387+
}

datafusion/expr/src/logical_plan/mod.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@ pub use ddl::{
3636
CreateFunctionBody, CreateIndex, CreateMemoryTable, CreateView, DdlStatement,
3737
DropCatalogSchema, DropFunction, DropTable, DropView, OperateFunctionArg,
3838
};
39-
pub use dml::{DmlStatement, WriteOp};
39+
pub use dml::{
40+
DmlStatement, MergeIntoAction, MergeIntoClause, MergeIntoClauseKind, MergeIntoOp,
41+
WriteOp,
42+
};
4043
pub use plan::{
4144
Aggregate, Analyze, ColumnUnnestList, DescribeTable, Distinct, DistinctOn,
4245
EmptyRelation, Explain, ExplainOption, Extension, FetchType, Filter, Join,

datafusion/proto/proto/datafusion.proto

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,13 +299,63 @@ message DmlNode{
299299
INSERT_OVERWRITE = 4;
300300
INSERT_REPLACE = 5;
301301
TRUNCATE = 6;
302+
MERGE_INTO = 7;
302303
}
303304
Type dml_type = 1;
304305
LogicalPlanNode input = 2;
305306
TableReference table_name = 3;
306307
LogicalPlanNode target = 5;
308+
// Populated only when dml_type == MERGE_INTO.
309+
MergeIntoOpNode merge_into = 6;
307310
}
308311

312+
// Carries the ON condition and WHEN clauses of a MERGE INTO operation.
313+
message MergeIntoOpNode {
314+
LogicalExprNode on = 1;
315+
repeated MergeIntoClauseNode clauses = 2;
316+
}
317+
318+
// A single WHEN clause within a MERGE INTO statement.
319+
message MergeIntoClauseNode {
320+
enum Kind {
321+
MATCHED = 0;
322+
NOT_MATCHED = 1;
323+
NOT_MATCHED_BY_TARGET = 2;
324+
NOT_MATCHED_BY_SOURCE = 3;
325+
}
326+
Kind kind = 1;
327+
// Optional `AND <expr>` predicate. Absent when the clause has no predicate.
328+
LogicalExprNode predicate = 2;
329+
MergeIntoActionNode action = 3;
330+
}
331+
332+
// The action for a single WHEN clause.
333+
message MergeIntoActionNode {
334+
oneof action {
335+
MergeUpdateAction update = 1;
336+
MergeInsertAction insert = 2;
337+
MergeDeleteAction delete = 3;
338+
}
339+
}
340+
341+
message MergeUpdateAction {
342+
repeated MergeAssignment assignments = 1;
343+
}
344+
345+
message MergeAssignment {
346+
string column = 1;
347+
LogicalExprNode value = 2;
348+
}
349+
350+
message MergeInsertAction {
351+
// May be empty (meaning all columns).
352+
repeated string columns = 1;
353+
// One expression per inserted column.
354+
repeated LogicalExprNode values = 2;
355+
}
356+
357+
message MergeDeleteAction {}
358+
309359
message UnnestNode {
310360
LogicalPlanNode input = 1;
311361
repeated datafusion_common.Column exec_columns = 2;

0 commit comments

Comments
 (0)