Skip to main content

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}