Skip to main content

iceberg/transaction/
row_delta.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::collections::HashMap;
19use std::sync::Arc;
20
21use async_trait::async_trait;
22use uuid::Uuid;
23
24use crate::error::Result;
25use crate::spec::{DataContentType, DataFile, ManifestEntry, ManifestFile, Operation};
26use crate::table::Table;
27use crate::transaction::snapshot::{
28    DefaultManifestProcess, SnapshotProduceOperation, SnapshotProducer,
29};
30use crate::transaction::{ActionCommit, TransactionAction};
31use crate::{Error, ErrorKind};
32
33/// RowDeltaAction is a transaction action for encoding row-level changes to a table.
34///
35/// This action supports:
36/// - Adding new data files
37/// - Adding delete files (both position and equality deletes)
38///
39/// This is the appropriate action to use for:
40/// - CDC (Change Data Capture) ingestion
41/// - Upsert operations
42/// - Row-level deletions
43///
44/// # Example
45/// ```ignore
46/// use iceberg::transaction::Transaction;
47///
48/// let tx = Transaction::new(&table);
49/// let action = tx.row_delta()
50///     .add_data_files(new_data_files)
51///     .add_delete_files(equality_delete_files);
52/// let tx = action.apply(tx).unwrap();
53/// let table = tx.commit(&catalog).await.unwrap();
54/// ```
55pub struct RowDeltaAction {
56    check_duplicate: bool,
57    // below are properties used to create SnapshotProducer when commit
58    commit_uuid: Option<Uuid>,
59    snapshot_properties: HashMap<String, String>,
60    added_data_files: Vec<DataFile>,
61    added_delete_files: Vec<DataFile>,
62}
63
64impl Default for RowDeltaAction {
65    fn default() -> Self {
66        Self::new()
67    }
68}
69
70impl RowDeltaAction {
71    /// Create a new row delta action. Equivalent to [`crate::transaction::Transaction::row_delta`].
72    pub fn new() -> Self {
73        Self {
74            check_duplicate: true,
75            commit_uuid: None,
76            snapshot_properties: HashMap::default(),
77            added_data_files: vec![],
78            added_delete_files: vec![],
79        }
80    }
81
82    /// Set whether to check duplicate files
83    pub fn with_check_duplicate(mut self, v: bool) -> Self {
84        self.check_duplicate = v;
85        self
86    }
87
88    /// Add data files to the snapshot.
89    pub fn add_data_files(mut self, data_files: impl IntoIterator<Item = DataFile>) -> Self {
90        self.added_data_files.extend(data_files);
91        self
92    }
93
94    /// Add delete files to the snapshot.
95    ///
96    /// Delete files can be either position deletes or equality deletes.
97    /// The content type of each file will be validated.
98    pub fn add_delete_files(mut self, delete_files: impl IntoIterator<Item = DataFile>) -> Self {
99        self.added_delete_files.extend(delete_files);
100        self
101    }
102
103    /// Set commit UUID for the snapshot.
104    pub fn set_commit_uuid(mut self, commit_uuid: Uuid) -> Self {
105        self.commit_uuid = Some(commit_uuid);
106        self
107    }
108
109    /// Set snapshot summary properties.
110    pub fn set_snapshot_properties(mut self, snapshot_properties: HashMap<String, String>) -> Self {
111        self.snapshot_properties = snapshot_properties;
112        self
113    }
114
115    /// Validate that delete files have appropriate content types
116    fn validate_delete_files(delete_files: &[DataFile]) -> Result<()> {
117        for delete_file in delete_files {
118            match delete_file.content_type() {
119                DataContentType::PositionDeletes | DataContentType::EqualityDeletes => {
120                    // Valid delete file types
121                }
122                DataContentType::Data => {
123                    return Err(Error::new(
124                        ErrorKind::DataInvalid,
125                        format!(
126                            "File {} has content type Data but was added as a delete file. Use add_data_files() instead.",
127                            delete_file.file_path()
128                        ),
129                    ));
130                }
131            }
132
133            // Additional validation for equality deletes
134            if delete_file.content_type() == DataContentType::EqualityDeletes
135                && delete_file.equality_ids().is_none()
136            {
137                return Err(Error::new(
138                    ErrorKind::DataInvalid,
139                    format!(
140                        "Equality delete file {} must have equality_ids set",
141                        delete_file.file_path()
142                    ),
143                ));
144            }
145        }
146        Ok(())
147    }
148}
149
150#[async_trait]
151impl TransactionAction for RowDeltaAction {
152    async fn commit(self: Arc<Self>, table: &Table) -> Result<ActionCommit> {
153        // Validate delete files have correct content types
154        Self::validate_delete_files(&self.added_delete_files)?;
155
156        let snapshot_producer = SnapshotProducer::new(
157            table,
158            self.commit_uuid.unwrap_or_else(Uuid::now_v7),
159            self.snapshot_properties.clone(),
160            self.added_data_files.clone(),
161            self.added_delete_files.clone(),
162        );
163
164        // Validate added data files (partition specs, etc.)
165        if !self.added_data_files.is_empty() {
166            snapshot_producer.validate_added_data_files(&self.added_data_files)?;
167        }
168
169        // Validate added delete files (partition specs, etc.)
170        if !self.added_delete_files.is_empty() {
171            snapshot_producer.validate_added_data_files(&self.added_delete_files)?;
172        }
173
174        // Check duplicate files
175        if self.check_duplicate {
176            snapshot_producer.validate_duplicate_files().await?;
177        }
178
179        snapshot_producer
180            .commit(RowDeltaOperation, DefaultManifestProcess)
181            .await
182    }
183}
184
185struct RowDeltaOperation;
186
187impl SnapshotProduceOperation for RowDeltaOperation {
188    fn operation(&self) -> Operation {
189        Operation::Append
190    }
191
192    async fn delete_entries(
193        &self,
194        _snapshot_produce: &SnapshotProducer<'_>,
195    ) -> Result<Vec<ManifestEntry>> {
196        Ok(vec![])
197    }
198
199    async fn existing_manifest(
200        &self,
201        snapshot_produce: &SnapshotProducer<'_>,
202    ) -> Result<Vec<ManifestFile>> {
203        let Some(snapshot) = snapshot_produce.table.metadata().current_snapshot() else {
204            return Ok(vec![]);
205        };
206
207        let manifest_list = snapshot_produce
208            .table
209            .manifest_list_reader(snapshot)
210            .load()
211            .await?;
212
213        // Include all existing manifests with added or existing files, plus delete-only
214        // manifests — dropping those would let removed files reappear as live data (#2148).
215        Ok(manifest_list
216            .entries()
217            .iter()
218            .filter(|entry| {
219                entry.has_added_files() || entry.has_existing_files() || entry.has_deleted_files()
220            })
221            .cloned()
222            .collect())
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use std::collections::HashMap;
229    use std::sync::Arc;
230
231    use crate::spec::{
232        DataContentType, DataFileBuilder, DataFileFormat, Literal, MAIN_BRANCH, SnapshotRef, Struct,
233    };
234    use crate::transaction::tests::make_v2_minimal_table;
235    use crate::transaction::{Transaction, TransactionAction};
236    use crate::{TableRequirement, TableUpdate};
237
238    #[tokio::test]
239    async fn test_row_delta_with_data_and_deletes() {
240        let table = make_v2_minimal_table();
241        let tx = Transaction::new(&table);
242
243        let data_file = DataFileBuilder::default()
244            .content(DataContentType::Data)
245            .file_path("test/data-1.parquet".to_string())
246            .file_format(DataFileFormat::Parquet)
247            .file_size_in_bytes(100)
248            .record_count(10)
249            .partition_spec_id(table.metadata().default_partition_spec_id())
250            .partition(Struct::from_iter([Some(Literal::long(100))]))
251            .build()
252            .unwrap();
253
254        let delete_file = DataFileBuilder::default()
255            .content(DataContentType::EqualityDeletes)
256            .file_path("test/delete-1.parquet".to_string())
257            .file_format(DataFileFormat::Parquet)
258            .file_size_in_bytes(50)
259            .record_count(5)
260            .partition_spec_id(table.metadata().default_partition_spec_id())
261            .partition(Struct::from_iter([Some(Literal::long(100))]))
262            .equality_ids(Some(vec![1])) // Assuming field id 1 is the key
263            .build()
264            .unwrap();
265
266        let action = tx
267            .row_delta()
268            .add_data_files(vec![data_file.clone()])
269            .add_delete_files(vec![delete_file.clone()]);
270
271        let mut action_commit = Arc::new(action).commit(&table).await.unwrap();
272        let updates = action_commit.take_updates();
273        let requirements = action_commit.take_requirements();
274
275        // Check updates and requirements
276        assert!(
277            matches!((&updates[0],&updates[1]), (TableUpdate::AddSnapshot { snapshot },TableUpdate::SetSnapshotRef { reference,ref_name }) if snapshot.snapshot_id() == reference.snapshot_id && ref_name == MAIN_BRANCH)
278        );
279        assert_eq!(
280            vec![
281                TableRequirement::UuidMatch {
282                    uuid: table.metadata().uuid()
283                },
284                TableRequirement::RefSnapshotIdMatch {
285                    r#ref: MAIN_BRANCH.to_string(),
286                    snapshot_id: table.metadata().current_snapshot_id
287                }
288            ],
289            requirements
290        );
291
292        // Check manifest list
293        let new_snapshot: SnapshotRef = if let TableUpdate::AddSnapshot { snapshot } = &updates[0] {
294            SnapshotRef::new(snapshot.clone())
295        } else {
296            unreachable!()
297        };
298        let manifest_list = table
299            .manifest_list_reader(&new_snapshot)
300            .load()
301            .await
302            .unwrap();
303
304        // Should have 2 manifests: one for data, one for deletes
305        assert_eq!(2, manifest_list.entries().len());
306    }
307
308    #[tokio::test]
309    async fn test_row_delta_rejects_data_file_as_delete() {
310        let table = make_v2_minimal_table();
311        let tx = Transaction::new(&table);
312
313        let data_file = DataFileBuilder::default()
314            .content(DataContentType::Data)
315            .file_path("test/data-1.parquet".to_string())
316            .file_format(DataFileFormat::Parquet)
317            .file_size_in_bytes(100)
318            .record_count(10)
319            .partition_spec_id(table.metadata().default_partition_spec_id())
320            .partition(Struct::from_iter([Some(Literal::long(100))]))
321            .build()
322            .unwrap();
323
324        // Try to add a data file as a delete file - should fail
325        let action = tx.row_delta().add_delete_files(vec![data_file]);
326
327        let result = Arc::new(action).commit(&table).await;
328        assert!(result.is_err());
329        let err = result.err().unwrap();
330        assert!(
331            err.to_string()
332                .contains("has content type Data but was added as a delete file")
333        );
334    }
335
336    #[tokio::test]
337    async fn test_row_delta_rejects_equality_delete_without_ids() {
338        let table = make_v2_minimal_table();
339        let tx = Transaction::new(&table);
340
341        let delete_file = DataFileBuilder::default()
342            .content(DataContentType::EqualityDeletes)
343            .file_path("test/delete-1.parquet".to_string())
344            .file_format(DataFileFormat::Parquet)
345            .file_size_in_bytes(50)
346            .record_count(5)
347            .partition_spec_id(table.metadata().default_partition_spec_id())
348            .partition(Struct::from_iter([Some(Literal::long(100))]))
349            // Missing equality_ids!
350            .build()
351            .unwrap();
352
353        let action = tx.row_delta().add_delete_files(vec![delete_file]);
354
355        let result = Arc::new(action).commit(&table).await;
356        assert!(result.is_err());
357        let err = result.err().unwrap();
358        assert!(err.to_string().contains("must have equality_ids set"));
359    }
360
361    #[tokio::test]
362    async fn test_row_delta_with_only_deletes() {
363        let table = make_v2_minimal_table();
364        let tx = Transaction::new(&table);
365
366        let delete_file = DataFileBuilder::default()
367            .content(DataContentType::PositionDeletes)
368            .file_path("test/delete-1.parquet".to_string())
369            .file_format(DataFileFormat::Parquet)
370            .file_size_in_bytes(50)
371            .record_count(5)
372            .partition_spec_id(table.metadata().default_partition_spec_id())
373            .partition(Struct::from_iter([Some(Literal::long(100))]))
374            .build()
375            .unwrap();
376
377        let action = tx.row_delta().add_delete_files(vec![delete_file]);
378
379        let result = Arc::new(action).commit(&table).await;
380        assert!(result.is_ok());
381    }
382
383    #[tokio::test]
384    async fn test_row_delta_with_snapshot_properties() {
385        let table = make_v2_minimal_table();
386        let tx = Transaction::new(&table);
387
388        let mut snapshot_properties = HashMap::new();
389        snapshot_properties.insert("custom-key".to_string(), "custom-value".to_string());
390
391        let data_file = DataFileBuilder::default()
392            .content(DataContentType::Data)
393            .file_path("test/data-1.parquet".to_string())
394            .file_format(DataFileFormat::Parquet)
395            .file_size_in_bytes(100)
396            .record_count(10)
397            .partition_spec_id(table.metadata().default_partition_spec_id())
398            .partition(Struct::from_iter([Some(Literal::long(100))]))
399            .build()
400            .unwrap();
401
402        let action = tx
403            .row_delta()
404            .set_snapshot_properties(snapshot_properties)
405            .add_data_files(vec![data_file]);
406
407        let mut action_commit = Arc::new(action).commit(&table).await.unwrap();
408        let updates = action_commit.take_updates();
409
410        let new_snapshot = if let TableUpdate::AddSnapshot { snapshot } = &updates[0] {
411            snapshot
412        } else {
413            unreachable!()
414        };
415        assert_eq!(
416            new_snapshot
417                .summary()
418                .additional_properties
419                .get("custom-key")
420                .unwrap(),
421            "custom-value"
422        );
423    }
424}