datafusion_expr/logical_plan/dml.rs
1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements. See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership. The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License. You may obtain a copy of the License at
8//
9// http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied. See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use std::cmp::Ordering;
19use std::collections::HashMap;
20use std::fmt::{self, Debug, Display, Formatter};
21use std::hash::{Hash, Hasher};
22use std::sync::Arc;
23
24use arrow::datatypes::{DataType, Field, Schema};
25use datafusion_common::file_options::file_type::FileType;
26use datafusion_common::{DFSchemaRef, Result, TableReference, internal_err};
27
28use crate::{Expr, LogicalPlan, TableSource};
29
30/// Operator that copies the contents of a database to file(s)
31#[derive(Clone)]
32pub struct CopyTo {
33 /// The relation that determines the tuples to write to the output file(s)
34 pub input: Arc<LogicalPlan>,
35 /// The location to write the file(s)
36 pub output_url: String,
37 /// Determines which, if any, columns should be used for hive-style partitioned writes
38 pub partition_by: Vec<String>,
39 /// File type trait
40 pub file_type: Arc<dyn FileType>,
41 /// SQL Options that can affect the formats
42 pub options: HashMap<String, String>,
43 /// The schema of the output (a single column "count")
44 pub output_schema: DFSchemaRef,
45}
46
47impl Debug for CopyTo {
48 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
49 f.debug_struct("CopyTo")
50 .field("input", &self.input)
51 .field("output_url", &self.output_url)
52 .field("partition_by", &self.partition_by)
53 .field("file_type", &"...")
54 .field("options", &self.options)
55 .field("output_schema", &self.output_schema)
56 .finish_non_exhaustive()
57 }
58}
59
60// Implement PartialEq manually
61impl PartialEq for CopyTo {
62 fn eq(&self, other: &Self) -> bool {
63 self.input == other.input && self.output_url == other.output_url
64 }
65}
66
67// Implement Eq (no need for additional logic over PartialEq)
68impl Eq for CopyTo {}
69
70// Manual implementation needed because of `file_type` and `options` fields.
71// Comparison excludes these field.
72impl PartialOrd for CopyTo {
73 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
74 match self.input.partial_cmp(&other.input) {
75 Some(Ordering::Equal) => match self.output_url.partial_cmp(&other.output_url)
76 {
77 Some(Ordering::Equal) => {
78 self.partition_by.partial_cmp(&other.partition_by)
79 }
80 cmp => cmp,
81 },
82 cmp => cmp,
83 }
84 // TODO (https://github.com/apache/datafusion/issues/17477) avoid recomparing all fields
85 .filter(|cmp| *cmp != Ordering::Equal || self == other)
86 }
87}
88
89// Implement Hash manually
90impl Hash for CopyTo {
91 fn hash<H: Hasher>(&self, state: &mut H) {
92 self.input.hash(state);
93 self.output_url.hash(state);
94 }
95}
96
97impl CopyTo {
98 pub fn new(
99 input: Arc<LogicalPlan>,
100 output_url: String,
101 partition_by: Vec<String>,
102 file_type: Arc<dyn FileType>,
103 options: HashMap<String, String>,
104 ) -> Self {
105 Self {
106 input,
107 output_url,
108 partition_by,
109 file_type,
110 options,
111 // The output schema is always a single column "count" with the number of rows copied
112 output_schema: make_count_schema(),
113 }
114 }
115}
116
117/// Modifies the content of a database
118///
119/// This operator is used to perform DML operations such as INSERT, DELETE,
120/// UPDATE, and CTAS (CREATE TABLE AS SELECT).
121///
122/// * `INSERT` - Appends new rows to the existing table. Calls
123/// [`TableProvider::insert_into`]
124///
125/// * `DELETE` - Removes rows from the table. Calls [`TableProvider::delete_from`]
126///
127/// * `UPDATE` - Modifies existing rows in the table. Calls [`TableProvider::update`]
128///
129/// * `CREATE TABLE AS SELECT` - Creates a new table and populates it with data
130/// from a query. This is similar to the `INSERT` operation, but it creates a new
131/// table instead of modifying an existing one.
132///
133/// Note that the structure is adapted from substrait WriteRel)
134///
135/// [`TableProvider`]: https://docs.rs/datafusion/latest/datafusion/datasource/trait.TableProvider.html
136/// [`TableProvider::insert_into`]: https://docs.rs/datafusion/latest/datafusion/datasource/trait.TableProvider.html#method.insert_into
137/// [`TableProvider::delete_from`]: https://docs.rs/datafusion/latest/datafusion/datasource/trait.TableProvider.html#method.delete_from
138/// [`TableProvider::update`]: https://docs.rs/datafusion/latest/datafusion/datasource/trait.TableProvider.html#method.update
139#[derive(Clone)]
140pub struct DmlStatement {
141 /// The table name
142 pub table_name: TableReference,
143 /// this is target table to insert into
144 pub target: Arc<dyn TableSource>,
145 /// The type of operation to perform
146 pub op: WriteOp,
147 /// The relation that determines the tuples to add/remove/modify the schema must match with table_schema
148 pub input: Arc<LogicalPlan>,
149 /// The schema of the output relation
150 pub output_schema: DFSchemaRef,
151}
152impl Eq for DmlStatement {}
153impl Hash for DmlStatement {
154 fn hash<H: Hasher>(&self, state: &mut H) {
155 self.table_name.hash(state);
156 self.target.schema().hash(state);
157 self.op.hash(state);
158 self.input.hash(state);
159 self.output_schema.hash(state);
160 }
161}
162
163impl PartialEq for DmlStatement {
164 fn eq(&self, other: &Self) -> bool {
165 self.table_name == other.table_name
166 && self.target.schema() == other.target.schema()
167 && self.op == other.op
168 && self.input == other.input
169 && self.output_schema == other.output_schema
170 }
171}
172
173impl Debug for DmlStatement {
174 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
175 f.debug_struct("DmlStatement")
176 .field("table_name", &self.table_name)
177 .field("target", &"...")
178 .field("target_schema", &self.target.schema())
179 .field("op", &self.op)
180 .field("input", &self.input)
181 .field("output_schema", &self.output_schema)
182 .finish()
183 }
184}
185
186impl DmlStatement {
187 /// Creates a new DML statement with the output schema set to a single `count` column.
188 pub fn new(
189 table_name: TableReference,
190 target: Arc<dyn TableSource>,
191 op: WriteOp,
192 input: Arc<LogicalPlan>,
193 ) -> Self {
194 Self {
195 table_name,
196 target,
197 op,
198 input,
199
200 // The output schema is always a single column with the number of rows affected
201 output_schema: make_count_schema(),
202 }
203 }
204
205 /// Return a descriptive name of this [`DmlStatement`]
206 pub fn name(&self) -> &str {
207 self.op.name()
208 }
209}
210
211// Manual implementation needed because of `table_schema` and `output_schema` fields.
212// Comparison excludes these fields.
213impl PartialOrd for DmlStatement {
214 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
215 match self.table_name.partial_cmp(&other.table_name) {
216 Some(Ordering::Equal) => match self.op.partial_cmp(&other.op) {
217 Some(Ordering::Equal) => self.input.partial_cmp(&other.input),
218 cmp => cmp,
219 },
220 cmp => cmp,
221 }
222 // TODO (https://github.com/apache/datafusion/issues/17477) avoid recomparing all fields
223 .filter(|cmp| *cmp != Ordering::Equal || self == other)
224 }
225}
226
227/// The type of DML operation to perform.
228///
229/// See [`DmlStatement`] for more details.
230///
231/// Marked `#[non_exhaustive]` so adding new variants in future releases is
232/// not a SemVer break for downstream matchers.
233#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Hash)]
234#[non_exhaustive]
235pub enum WriteOp {
236 /// `INSERT INTO` operation
237 Insert(InsertOp),
238 /// `DELETE` operation
239 Delete,
240 /// `UPDATE` operation
241 Update,
242 /// `CREATE TABLE AS SELECT` operation
243 Ctas,
244 /// `TRUNCATE` operation
245 Truncate,
246 /// `MERGE INTO` operation
247 MergeInto(Box<MergeIntoOp>),
248}
249
250impl WriteOp {
251 /// Return a descriptive name of this [`WriteOp`]
252 pub fn name(&self) -> &str {
253 match self {
254 WriteOp::Insert(insert) => insert.name(),
255 WriteOp::Delete => "Delete",
256 WriteOp::Update => "Update",
257 WriteOp::Ctas => "Ctas",
258 WriteOp::Truncate => "Truncate",
259 WriteOp::MergeInto(_) => "MergeInto",
260 }
261 }
262}
263
264impl Display for WriteOp {
265 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
266 write!(f, "{}", self.name())
267 }
268}
269
270#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Hash)]
271pub enum InsertOp {
272 /// Appends new rows to the existing table without modifying any
273 /// existing rows. This corresponds to the SQL `INSERT INTO` query.
274 Append,
275 /// Overwrites all existing rows in the table with the new rows.
276 /// This corresponds to the SQL `INSERT OVERWRITE` query.
277 Overwrite,
278 /// If any existing rows collides with the inserted rows (typically based
279 /// on a unique key or primary key), those existing rows are replaced.
280 /// This corresponds to the SQL `REPLACE INTO` query and its equivalents.
281 Replace,
282}
283
284impl InsertOp {
285 /// Return a descriptive name of this [`InsertOp`]
286 pub fn name(&self) -> &str {
287 match self {
288 InsertOp::Append => "Insert Into",
289 InsertOp::Overwrite => "Insert Overwrite",
290 InsertOp::Replace => "Replace Into",
291 }
292 }
293}
294
295impl Display for InsertOp {
296 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
297 write!(f, "{}", self.name())
298 }
299}
300
301/// Describes a MERGE INTO operation's parameters.
302#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Hash)]
303pub struct MergeIntoOp {
304 /// The join condition from `ON <expr>`.
305 pub on: Expr,
306 /// The WHEN clauses, in the order they appeared in the SQL.
307 pub clauses: Vec<MergeIntoClause>,
308}
309
310impl MergeIntoOp {
311 /// Count of top-level [`Expr`]s owned by this operation (no allocation).
312 ///
313 /// Matches the length of [`Self::exprs`] and the `exprs` vec consumed by
314 /// [`Self::with_new_exprs`].
315 fn expr_count(&self) -> usize {
316 1 + self
317 .clauses
318 .iter()
319 .map(|c| {
320 c.predicate.is_some() as usize
321 + match &c.action {
322 MergeIntoAction::Update(a) => a.len(),
323 MergeIntoAction::Insert { values, .. } => values.len(),
324 MergeIntoAction::Delete => 0,
325 }
326 })
327 .sum::<usize>()
328 }
329
330 /// Top-level [`Expr`]s in stable order: `on`, then per-clause predicate
331 /// (if any) and action value expressions.
332 pub fn exprs(&self) -> Vec<&Expr> {
333 let mut out = Vec::with_capacity(self.expr_count());
334 out.push(&self.on);
335 for clause in &self.clauses {
336 if let Some(predicate) = &clause.predicate {
337 out.push(predicate);
338 }
339 match &clause.action {
340 MergeIntoAction::Update(assignments) => {
341 out.extend(assignments.iter().map(|(_, value)| value));
342 }
343 MergeIntoAction::Insert { values, .. } => {
344 out.extend(values.iter());
345 }
346 MergeIntoAction::Delete => {}
347 }
348 }
349 out
350 }
351
352 /// Rebuild this `MergeIntoOp` from a flat vector of new expressions, in
353 /// the same order produced by [`Self::exprs`]. The clause kinds, action
354 /// kinds, column lists, and presence/absence of each predicate are
355 /// preserved from `self`.
356 pub fn with_new_exprs(&self, exprs: Vec<Expr>) -> Result<Self> {
357 let expected = self.expr_count();
358 if exprs.len() != expected {
359 return internal_err!(
360 "MergeIntoOp::with_new_exprs expected {expected} expressions, got {}",
361 exprs.len()
362 );
363 }
364 let mut iter = exprs.into_iter();
365 let on = iter.next().expect("non-empty by length check");
366 let clauses = self
367 .clauses
368 .iter()
369 .map(|clause| {
370 let predicate = clause
371 .predicate
372 .is_some()
373 .then(|| iter.next().expect("non-empty by length check"));
374 let action = match &clause.action {
375 MergeIntoAction::Update(assignments) => {
376 let assignments = assignments
377 .iter()
378 .map(|(name, _)| {
379 (
380 name.clone(),
381 iter.next().expect("non-empty by length check"),
382 )
383 })
384 .collect();
385 MergeIntoAction::Update(assignments)
386 }
387 MergeIntoAction::Insert { columns, values } => {
388 let values = values
389 .iter()
390 .map(|_| iter.next().expect("non-empty by length check"))
391 .collect();
392 MergeIntoAction::Insert {
393 columns: columns.clone(),
394 values,
395 }
396 }
397 MergeIntoAction::Delete => MergeIntoAction::Delete,
398 };
399 MergeIntoClause {
400 kind: clause.kind,
401 predicate,
402 action,
403 }
404 })
405 .collect();
406 Ok(Self { on, clauses })
407 }
408}
409
410/// A single WHEN clause within a MERGE INTO statement.
411#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Hash)]
412pub struct MergeIntoClause {
413 /// Whether this fires on matched or unmatched rows.
414 pub kind: MergeIntoClauseKind,
415 /// Optional additional predicate (`AND <expr>`).
416 pub predicate: Option<Expr>,
417 /// The action to take.
418 pub action: MergeIntoAction,
419}
420
421/// Which rows a MERGE WHEN clause applies to.
422///
423/// Mirrors `sqlparser::ast::MergeClauseKind` so that the SQL spelling is
424/// preserved through the logical plan.
425///
426/// **Note on `NotMatched` vs `NotMatchedByTarget`:** these two variants are
427/// semantically identical — both describe a source row that has no matching
428/// target row. `NotMatched` is the SQL standard short form (used by
429/// Snowflake, Postgres, SQL Server); `NotMatchedByTarget` is BigQuery's
430/// explicit form added for symmetry with `NotMatchedBySource`. Downstream
431/// consumers (planners, table providers, optimizers) MUST treat the two
432/// variants identically.
433#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Hash)]
434pub enum MergeIntoClauseKind {
435 /// `WHEN MATCHED`
436 Matched,
437 /// `WHEN NOT MATCHED` — see type-level note for the equivalence with
438 /// [`NotMatchedByTarget`](Self::NotMatchedByTarget).
439 NotMatched,
440 /// `WHEN NOT MATCHED BY TARGET` — see type-level note for the
441 /// equivalence with [`NotMatched`](Self::NotMatched).
442 NotMatchedByTarget,
443 /// `WHEN NOT MATCHED BY SOURCE`
444 NotMatchedBySource,
445}
446
447impl MergeIntoClauseKind {
448 /// True if this clause fires on a source row that has no matching target
449 /// row. Returns `true` for both [`NotMatched`](Self::NotMatched) and
450 /// [`NotMatchedByTarget`](Self::NotMatchedByTarget) (see the type-level
451 /// note explaining why those two variants are semantically identical).
452 ///
453 /// Prefer this predicate over hand-written `matches!` arms so the
454 /// `NotMatched`/`NotMatchedByTarget` equivalence is enforced in one place.
455 pub fn is_not_matched_by_target(&self) -> bool {
456 matches!(self, Self::NotMatched | Self::NotMatchedByTarget)
457 }
458
459 /// Collapse the SQL-spelling variants into the canonical three semantic
460 /// categories: [`Matched`](Self::Matched),
461 /// [`NotMatchedByTarget`](Self::NotMatchedByTarget) (covering both
462 /// "NOT MATCHED" spellings), and
463 /// [`NotMatchedBySource`](Self::NotMatchedBySource).
464 ///
465 /// Use this in downstream `match` expressions when the SQL spelling
466 /// distinction does not matter — e.g. in planners, optimizers, or
467 /// table-provider dispatch.
468 pub fn canonical(self) -> Self {
469 match self {
470 Self::NotMatched => Self::NotMatchedByTarget,
471 other => other,
472 }
473 }
474}
475
476/// The action for a single WHEN clause.
477#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Hash)]
478pub enum MergeIntoAction {
479 /// `UPDATE SET col1 = expr1, col2 = expr2, ...`, stored as
480 /// `(column_name, value_expr)` pairs.
481 Update(Vec<(String, Expr)>),
482 /// `INSERT (col1, col2, ...) VALUES (expr1, expr2, ...)`. `columns` may
483 /// be empty, meaning all columns.
484 Insert {
485 columns: Vec<String>,
486 values: Vec<Expr>,
487 },
488 Delete,
489}
490
491fn make_count_schema() -> DFSchemaRef {
492 Arc::new(
493 Schema::new(vec![Field::new("count", DataType::UInt64, false)])
494 .try_into()
495 .unwrap(),
496 )
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502 use crate::{col, lit};
503
504 #[test]
505 fn write_op_merge_into_name_and_display() {
506 let op = WriteOp::MergeInto(Box::new(MergeIntoOp {
507 on: col("id").eq(col("source_id")),
508 clauses: vec![MergeIntoClause {
509 kind: MergeIntoClauseKind::Matched,
510 predicate: Some(col("qty").gt(lit(0_i64))),
511 action: MergeIntoAction::Update(vec![(
512 "qty".to_string(),
513 col("source_qty"),
514 )]),
515 }],
516 }));
517 assert_eq!(op.name(), "MergeInto");
518 assert_eq!(format!("{op}"), "MergeInto");
519 }
520
521 #[test]
522 fn merge_into_clause_kind_is_not_matched_by_target() {
523 assert!(!MergeIntoClauseKind::Matched.is_not_matched_by_target());
524 assert!(MergeIntoClauseKind::NotMatched.is_not_matched_by_target());
525 assert!(MergeIntoClauseKind::NotMatchedByTarget.is_not_matched_by_target());
526 assert!(!MergeIntoClauseKind::NotMatchedBySource.is_not_matched_by_target());
527 }
528
529 #[test]
530 fn merge_into_clause_kind_canonical_collapses_not_matched() {
531 assert_eq!(
532 MergeIntoClauseKind::NotMatched.canonical(),
533 MergeIntoClauseKind::NotMatchedByTarget
534 );
535 assert_eq!(
536 MergeIntoClauseKind::NotMatchedByTarget.canonical(),
537 MergeIntoClauseKind::NotMatchedByTarget
538 );
539 assert_eq!(
540 MergeIntoClauseKind::Matched.canonical(),
541 MergeIntoClauseKind::Matched
542 );
543 assert_eq!(
544 MergeIntoClauseKind::NotMatchedBySource.canonical(),
545 MergeIntoClauseKind::NotMatchedBySource
546 );
547 }
548
549 #[test]
550 fn merge_into_op_exprs_round_trip() {
551 let op = MergeIntoOp {
552 on: col("id").eq(col("source_id")),
553 clauses: vec![
554 MergeIntoClause {
555 kind: MergeIntoClauseKind::Matched,
556 predicate: Some(col("qty").gt(lit(0_i64))),
557 action: MergeIntoAction::Update(vec![
558 ("qty".to_string(), col("source_qty")),
559 ("price".to_string(), col("source_price")),
560 ]),
561 },
562 MergeIntoClause {
563 kind: MergeIntoClauseKind::NotMatched,
564 predicate: None,
565 action: MergeIntoAction::Insert {
566 columns: vec!["id".to_string(), "qty".to_string()],
567 values: vec![col("source_id"), col("source_qty")],
568 },
569 },
570 MergeIntoClause {
571 kind: MergeIntoClauseKind::NotMatchedBySource,
572 predicate: Some(col("active").eq(lit(true))),
573 action: MergeIntoAction::Delete,
574 },
575 ],
576 };
577 let exprs = op.exprs();
578 assert_eq!(exprs.len(), 7);
579
580 let owned: Vec<Expr> = exprs.into_iter().cloned().collect();
581 let rebuilt = op.with_new_exprs(owned).unwrap();
582 assert_eq!(op, rebuilt);
583 }
584
585 #[test]
586 fn merge_into_op_with_new_exprs_length_mismatch() {
587 let op = MergeIntoOp {
588 on: col("id").eq(col("source_id")),
589 clauses: vec![],
590 };
591 let err = op.with_new_exprs(vec![]).unwrap_err();
592 assert!(
593 err.to_string().contains("expected 1 expressions, got 0"),
594 "unexpected error: {err}"
595 );
596 }
597}