This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new f282f586 [Core] Add predicate deletes to TableUpdate (#1105)
f282f586 is described below

commit f282f586d0be99938036dd02df028a8eeed7dbd9
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Oct 12 08:21:09 2026 +0800

    [Core] Add predicate deletes to TableUpdate (#1105)
---
 .../python/python/pypaimon_rust/datafusion.pyi     |   8 +
 bindings/python/src/write.rs                       |  38 ++
 bindings/python/tests/test_predicate_delete.py     |  68 +++
 crates/paimon/src/table/mod.rs                     |   2 +
 crates/paimon/src/table/snapshot_manifest.rs       |  49 ++
 crates/paimon/src/table/table_commit.rs            |  24 +-
 crates/paimon/src/table/table_update.rs            |  16 +
 crates/paimon/src/table/table_update_delete.rs     | 164 ++++++
 crates/paimon/tests/table_predicate_delete_test.rs | 573 +++++++++++++++++++++
 9 files changed, 924 insertions(+), 18 deletions(-)

diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi 
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index e0414953..b14bbe5b 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -345,6 +345,11 @@ class BatchTableUpdate:
         ...
     def with_update_type(self, update_cols: List[str]) -> "BatchTableUpdate": 
...
     def delete_by_row_id(self, row_ids: Sequence[int]) -> List[CommitMessage]: 
...
+    def delete_by_predicate(
+        self, predicate: Optional[Dict[str, Any]]
+    ) -> List[CommitMessage]:
+        """Prepare partition metadata deletes or matching row deletion vectors 
in core."""
+        ...
     def upsert_by_arrow_with_key(
         self, table: Union[pyarrow.Table, Sequence[pyarrow.RecordBatch]], 
upsert_keys: List[str]
     ) -> List[CommitMessage]:
@@ -399,6 +404,9 @@ class StreamTableUpdate:
     def delete_by_row_id(
         self, row_ids: Sequence[int], commit_identifier: int
     ) -> List[CommitMessage]: ...
+    def delete_by_predicate(
+        self, predicate: Optional[Dict[str, Any]], commit_identifier: int
+    ) -> List[CommitMessage]: ...
     def upsert_by_arrow_with_key(
         self, table: Union[pyarrow.Table, Sequence[pyarrow.RecordBatch]],
         upsert_keys: List[str], commit_identifier: int
diff --git a/bindings/python/src/write.rs b/bindings/python/src/write.rs
index fffe64b9..be06b18f 100644
--- a/bindings/python/src/write.rs
+++ b/bindings/python/src/write.rs
@@ -788,6 +788,24 @@ impl UpdateContext {
         ))
     }
 
+    fn delete_by_predicate(
+        &self,
+        py: Python<'_>,
+        predicate: Option<&Bound<'_, PyDict>>,
+    ) -> PyResult<Vec<PyCommitMessage>> {
+        let predicate = predicate
+            .map(|predicate| dict_to_table_predicate(predicate, 
self.table.schema(), true))
+            .transpose()?;
+        let messages = py
+            .detach(|| 
runtime().block_on(self.inner.delete_by_predicate(predicate)))
+            .map_err(to_py_err)?;
+        Ok(wrap_messages(
+            messages,
+            &self.table_location,
+            &self.commit_user,
+        ))
+    }
+
     fn delete_by_row_id(
         &self,
         py: Python<'_>,
@@ -1058,10 +1076,30 @@ impl PyStreamTableUpdate {
         let _ = commit_identifier;
         self.context.delete_by_row_id(py, row_ids)
     }
+
+    #[pyo3(signature = (predicate, commit_identifier))]
+    fn delete_by_predicate(
+        &self,
+        py: Python<'_>,
+        predicate: Option<&Bound<'_, PyDict>>,
+        commit_identifier: i64,
+    ) -> PyResult<Vec<PyCommitMessage>> {
+        let _ = commit_identifier;
+        self.context.delete_by_predicate(py, predicate)
+    }
 }
 
 #[pymethods]
 impl PyBatchTableUpdate {
+    #[pyo3(signature = (predicate))]
+    fn delete_by_predicate(
+        &self,
+        py: Python<'_>,
+        predicate: Option<&Bound<'_, PyDict>>,
+    ) -> PyResult<Vec<PyCommitMessage>> {
+        self.context.delete_by_predicate(py, predicate)
+    }
+
     fn _with_blob_uri_reader_factory(
         &mut self,
         py: Python<'_>,
diff --git a/bindings/python/tests/test_predicate_delete.py 
b/bindings/python/tests/test_predicate_delete.py
new file mode 100644
index 00000000..1e27fbcb
--- /dev/null
+++ b/bindings/python/tests/test_predicate_delete.py
@@ -0,0 +1,68 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import pyarrow as pa
+import pytest
+
+from pypaimon_rust.datafusion import PaimonCatalog, SQLContext
+
+
[email protected]('stream', [False, True])
[email protected]('partition_delete', [False, True])
+def test_predicate_delete_returns_messages_for_batch_and_stream(tmp_path, 
stream, partition_delete):
+    context = SQLContext()
+    context.register_catalog('paimon', {'warehouse': str(tmp_path)})
+    context.sql('CREATE SCHEMA paimon.deletes')
+    context.sql("""CREATE TABLE paimon.deletes.t (id INT, p STRING, v INT) 
PARTITIONED BY (p) WITH (
+        'row-tracking.enabled' = 'true', 'data-evolution.enabled' = 'true',
+        'deletion-vectors.enabled' = '%s')""" % ('false' if partition_delete 
else 'true'))
+    context.sql("INSERT INTO paimon.deletes.t (id, p, v) VALUES (1, 'a', 10), 
(2, 'a', 20), (3, 'b', 30)")
+    table = PaimonCatalog({'warehouse': str(tmp_path)}).get_table('deletes.t')
+    builder = 
(table.new_stream_write_builder().with_commit_user('predicate-stream') if stream
+               else table.new_batch_write_builder())
+    predicate = {'method': 'equal', 'field': 'p' if partition_delete else 'v',
+                 'literals': ['a' if partition_delete else 20]}
+    update = builder.new_update()
+    messages = (update.delete_by_predicate(predicate, 7) if stream
+                else update.delete_by_predicate(predicate))
+    assert messages and all(message.serialize() for message in messages)
+    # Preparation alone does not publish the delete.
+    assert pa.Table.from_batches(context.sql('SELECT id FROM 
paimon.deletes.t')).num_rows == 3
+    commit = builder.new_commit()
+    commit.commit(7, messages) if stream else commit.commit(messages)
+    assert pa.Table.from_batches(context.sql(
+        'SELECT id FROM paimon.deletes.t ORDER BY id'))['id'].to_pylist() == (
+        [3] if partition_delete else [1, 3])
+
+
+def 
test_predicate_delete_requires_explicit_selection_but_none_matches_all(tmp_path):
+    context = SQLContext()
+    context.register_catalog('paimon', {'warehouse': str(tmp_path)})
+    context.sql('CREATE SCHEMA paimon.deletes')
+    context.sql("""CREATE TABLE paimon.deletes.t (id INT) WITH (
+        'row-tracking.enabled' = 'true', 'data-evolution.enabled' = 'true',
+        'deletion-vectors.enabled' = 'true')""")
+    context.sql('INSERT INTO paimon.deletes.t (id) VALUES (1), (2)')
+    table = PaimonCatalog({'warehouse': str(tmp_path)}).get_table('deletes.t')
+    builder = table.new_batch_write_builder()
+    update = builder.new_update()
+    with pytest.raises(TypeError, match='predicate'):
+        update.delete_by_predicate()
+    messages = update.delete_by_predicate(None)
+    assert messages
+    builder.new_commit().commit(messages)
+    assert list(context.sql('SELECT id FROM paimon.deletes.t')) == []
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index cf1f164a..fe7f8ced 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -140,6 +140,7 @@ pub(crate) mod schema_manager;
 mod shard_table_updater;
 pub(crate) mod snapshot_commit;
 mod snapshot_manager;
+mod snapshot_manifest;
 mod sort_merge;
 mod sorted_global_index_build_builder;
 mod sorted_global_index_options;
@@ -155,6 +156,7 @@ mod table_read;
 mod table_scan;
 mod table_update;
 mod table_update_by_row_id;
+mod table_update_delete;
 mod table_update_predicate;
 mod table_upsert;
 pub(crate) mod table_write;
diff --git a/crates/paimon/src/table/snapshot_manifest.rs 
b/crates/paimon/src/table/snapshot_manifest.rs
new file mode 100644
index 00000000..79391880
--- /dev/null
+++ b/crates/paimon/src/table/snapshot_manifest.rs
@@ -0,0 +1,49 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use super::partition_filter::PartitionFilter;
+use super::SnapshotManager;
+use crate::spec::{merge_active_entries, Manifest, ManifestEntry, ManifestList, 
Snapshot};
+
+/// Read active metadata without read-side bucket, level, column, or index 
pruning.
+/// Partition mutations must also see pending files and dedicated column files.
+pub(super) async fn read_snapshot_entries(
+    manager: &SnapshotManager,
+    snapshot: &Snapshot,
+    partition_filter: Option<&PartitionFilter>,
+) -> crate::Result<Vec<ManifestEntry>> {
+    let file_io = manager.file_io();
+    let mut entries = Vec::new();
+    for list in [
+        snapshot.base_manifest_list(),
+        snapshot.delta_manifest_list(),
+    ] {
+        for manifest in ManifestList::read(file_io, 
&manager.manifest_path(list)).await? {
+            for entry in
+                Manifest::read(file_io, 
&manager.manifest_path(manifest.file_name())).await?
+            {
+                if let Some(filter) = partition_filter {
+                    if !filter.matches_entry(entry.partition())? {
+                        continue;
+                    }
+                }
+                entries.push(entry);
+            }
+        }
+    }
+    Ok(merge_active_entries(entries))
+}
diff --git a/crates/paimon/src/table/table_commit.rs 
b/crates/paimon/src/table/table_commit.rs
index d4d2dba8..317ac221 100644
--- a/crates/paimon/src/table/table_commit.rs
+++ b/crates/paimon/src/table/table_commit.rs
@@ -2270,24 +2270,12 @@ impl TableCommit {
         let Some(snap) = snapshot else {
             return Ok(vec![]);
         };
-        let file_io = self.snapshot_manager.file_io();
-        let manifest_dir = self.snapshot_manager.manifest_dir();
-        let mut entries = Vec::new();
-        for manifest_list in [snap.base_manifest_list(), 
snap.delta_manifest_list()] {
-            let manifest_list_path = format!("{manifest_dir}/{manifest_list}");
-            for manifest_file in ManifestList::read(file_io, 
&manifest_list_path).await? {
-                let manifest_path = format!("{manifest_dir}/{}", 
manifest_file.file_name());
-                for entry in Manifest::read(file_io, &manifest_path).await? {
-                    if let Some(filter) = partition_filter {
-                        if !filter.matches_entry(entry.partition())? {
-                            continue;
-                        }
-                    }
-                    entries.push(entry);
-                }
-            }
-        }
-        Ok(merge_active_entries(entries))
+        super::snapshot_manifest::read_snapshot_entries(
+            &self.snapshot_manager,
+            snap,
+            partition_filter,
+        )
+        .await
     }
 
     async fn scan_changed_partition_entries(
diff --git a/crates/paimon/src/table/table_update.rs 
b/crates/paimon/src/table/table_update.rs
index af9b283a..546dc8a2 100644
--- a/crates/paimon/src/table/table_update.rs
+++ b/crates/paimon/src/table/table_update.rs
@@ -288,6 +288,22 @@ impl TableUpdate {
         .await
     }
 
+    /// Delete rows matching a predicate against one captured snapshot.
+    ///
+    /// Partition-only predicates remove complete data and index metadata,
+    /// including pending files, for append and primary-key tables as well as
+    /// data-evolution tables, without requiring deletion vectors. All other
+    /// predicates (including `None`, which matches all rows) require a
+    /// data-evolution table with row tracking and deletion vectors.
+    /// Returned messages carry the snapshot baseline for conflict detection;
+    /// the caller publishes them through the matching builder's committer.
+    pub async fn delete_by_predicate(
+        &self,
+        predicate: Option<crate::spec::Predicate>,
+    ) -> crate::Result<Vec<CommitMessage>> {
+        super::table_update_delete::delete(&self.table, predicate).await
+    }
+
     /// Delete rows by row ID using deletion vectors.
     pub async fn delete_by_row_id(&self, row_ids: Vec<i64>) -> 
crate::Result<Vec<CommitMessage>> {
         if row_ids.is_empty() {
diff --git a/crates/paimon/src/table/table_update_delete.rs 
b/crates/paimon/src/table/table_update_delete.rs
new file mode 100644
index 00000000..84b3af40
--- /dev/null
+++ b/crates/paimon/src/table/table_update_delete.rs
@@ -0,0 +1,164 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Predicate deletes prepare changes against one captured snapshot.
+
+use std::collections::HashMap;
+
+use futures::TryStreamExt;
+use indexmap::IndexMap;
+
+use super::partition_filter::PartitionFilter;
+use super::{CommitMessage, DataEvolutionDeleteWriter, Table};
+use crate::spec::{
+    field_idx_to_partition_idx, CoreOptions, FileKind, IndexManifest, 
Predicate, Snapshot,
+};
+
+pub(super) async fn delete(
+    table: &Table,
+    predicate: Option<Predicate>,
+) -> crate::Result<Vec<CommitMessage>> {
+    super::write_builder::ensure_table_write_allowed(table)?;
+    let options = CoreOptions::new(table.schema().options());
+    options.ensure_read_authorized()?;
+    if let Some(predicate) = &predicate {
+        validate_predicate(predicate, 
&table.schema().fields_with_read_metadata())?;
+    }
+
+    let mapping =
+        field_idx_to_partition_idx(table.schema().fields(), 
table.schema().partition_keys());
+    let partition_predicate = predicate.as_ref().and_then(|predicate| {
+        (!table.schema().partition_keys().is_empty())
+            .then(|| predicate.remap_field_index(&mapping))
+            .flatten()
+    });
+    // Data predicates require deletion vectors even when the table is empty or
+    // no rows match. Partition-only deletes remove complete file/index 
metadata.
+    if partition_predicate.is_none() {
+        let _ = DataEvolutionDeleteWriter::new(table)?;
+    }
+    let Some(snapshot) = super::time_travel::resolve_snapshot(table).await? 
else {
+        return Ok(Vec::new());
+    };
+    if let Some(predicate) = partition_predicate {
+        return delete_partitions(table, &snapshot, predicate).await;
+    }
+
+    // FULL includes unindexed rows in PARTIAL/DETAIL search mode. Both the
+    // predicate reader and the DV writer must keep this exact snapshot.
+    let scan_table = table
+        .copy_with_options(HashMap::from([(
+            "scalar-index.search-mode".into(),
+            "FULL".into(),
+        )]))
+        .copy_with_pinned_snapshot(Some(&snapshot));
+    let mut builder = scan_table.new_read_builder();
+    builder.with_projection(&["_ROW_ID"])?;
+    if let Some(predicate) = predicate {
+        builder.with_filter(predicate);
+    }
+    let reader = builder.new_read()?;
+    let plan = builder.new_scan().with_scan_all_files().plan().await?;
+    let mut writer = DataEvolutionDeleteWriter::new(&scan_table)?;
+    let mut batches = reader.to_arrow(plan.splits())?;
+    while let Some(batch) = batches.try_next().await? {
+        writer.add_matched_batch(batch)?;
+    }
+    writer.prepare_commit().await
+}
+
+fn validate_predicate(
+    predicate: &Predicate,
+    fields: &[crate::spec::DataField],
+) -> crate::Result<()> {
+    match predicate {
+        Predicate::Leaf {
+            column,
+            index,
+            data_type,
+            ..
+        } => {
+            if !fields
+                .get(*index)
+                .is_some_and(|field| field.name() == column && 
field.data_type() == data_type)
+            {
+                return Err(crate::Error::DataInvalid {
+                    message: format!("Delete predicate field '{column}' does 
not match the table schema at index {index}"),
+                    source: None,
+                });
+            }
+        }
+        Predicate::And(children) | Predicate::Or(children) => {
+            for child in children {
+                validate_predicate(child, fields)?;
+            }
+        }
+        Predicate::Not(child) => validate_predicate(child, fields)?,
+        Predicate::AlwaysTrue | Predicate::AlwaysFalse => {}
+    }
+    Ok(())
+}
+
+async fn delete_partitions(
+    table: &Table,
+    snapshot: &Snapshot,
+    predicate: Predicate,
+) -> crate::Result<Vec<CommitMessage>> {
+    let partition_fields = table.schema().partition_fields();
+    let filter = PartitionFilter::from_predicate(predicate, &partition_fields);
+    let manager = table.snapshot_manager();
+    let mut messages = IndexMap::new();
+    for entry in
+        super::snapshot_manifest::read_snapshot_entries(&manager, snapshot, 
Some(&filter)).await?
+    {
+        partition_message(
+            &mut messages,
+            entry.partition(),
+            entry.bucket(),
+            snapshot.id(),
+        )
+        .deleted_files
+        .push(entry.file().clone());
+    }
+    if let Some(index_manifest) = snapshot.index_manifest() {
+        for entry in
+            IndexManifest::read(table.file_io(), 
&manager.manifest_path(index_manifest)).await?
+        {
+            if entry.kind == FileKind::Add && 
filter.matches_entry(&entry.partition)? {
+                partition_message(&mut messages, &entry.partition, 
entry.bucket, snapshot.id())
+                    .deleted_index_files
+                    .push(entry.index_file);
+            }
+        }
+    }
+    Ok(messages.into_values().collect())
+}
+
+fn partition_message<'a>(
+    messages: &'a mut IndexMap<(Vec<u8>, i32), CommitMessage>,
+    partition: &[u8],
+    bucket: i32,
+    snapshot_id: i64,
+) -> &'a mut CommitMessage {
+    messages
+        .entry((partition.to_vec(), bucket))
+        .or_insert_with(|| {
+            let mut message = CommitMessage::new(partition.to_vec(), bucket, 
Vec::new());
+            message.check_from_snapshot = Some(snapshot_id);
+            message
+        })
+}
diff --git a/crates/paimon/tests/table_predicate_delete_test.rs 
b/crates/paimon/tests/table_predicate_delete_test.rs
new file mode 100644
index 00000000..cc30f344
--- /dev/null
+++ b/crates/paimon/tests/table_predicate_delete_test.rs
@@ -0,0 +1,573 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+mod common;
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use arrow_array::{ArrayRef, Int32Array, Int64Array, RecordBatch, StringArray};
+use common::incremental_helpers::{memory_table, persist_table_schema, 
setup_dirs, write_batch};
+use futures::TryStreamExt;
+use paimon::spec::{
+    DataType, Datum, IndexManifest, IntType, Predicate, PredicateBuilder, 
Schema, TableSchema,
+    VarCharType,
+};
+use paimon::table::{CommitMessage, Table};
+
+async fn table(options: &[(&str, &str)]) -> Table {
+    table_with_primary_keys(options, false).await
+}
+
+async fn table_with_primary_keys(options: &[(&str, &str)], pk: bool) -> Table {
+    let mut schema = Schema::builder()
+        .column("id", DataType::Int(IntType::new()))
+        .column("p", DataType::VarChar(VarCharType::string_type()))
+        .column("v", DataType::Int(IntType::new()))
+        .column("q", DataType::Int(IntType::new()))
+        .partition_keys(["q", "p"])
+        .primary_key(if pk { vec!["id", "q", "p"] } else { vec![] })
+        .option("data-evolution.enabled", "true")
+        .option("row-tracking.enabled", "true")
+        .option("deletion-vectors.enabled", "true");
+    for (key, value) in options {
+        schema = schema.option(*key, *value);
+    }
+    let path = "memory:/predicate_delete";
+    let (io, table) = memory_table(path, TableSchema::new(0, 
&schema.build().unwrap()));
+    setup_dirs(&io, path).await;
+    persist_table_schema(&io, path, table.schema()).await;
+    table
+}
+
+fn batch(id: Vec<i32>, p: Vec<Option<&str>>, v: Vec<i32>, q: Vec<i32>) -> 
RecordBatch {
+    RecordBatch::try_from_iter([
+        ("id", Arc::new(Int32Array::from(id)) as ArrayRef),
+        ("p", Arc::new(StringArray::from(p)) as ArrayRef),
+        ("v", Arc::new(Int32Array::from(v)) as ArrayRef),
+        ("q", Arc::new(Int32Array::from(q)) as ArrayRef),
+    ])
+    .unwrap()
+}
+
+async fn seed(table: &Table) {
+    write_batch(
+        table,
+        &batch(
+            vec![0, 1, 2, 3],
+            vec![
+                Some("a"),
+                Some("a"),
+                Some("b"),
+                if table.schema().primary_keys().is_empty() {
+                    None
+                } else {
+                    Some("c")
+                },
+            ],
+            vec![10, 20, 30, 40],
+            vec![1, 1, 2, 2],
+        ),
+    )
+    .await;
+}
+
+async fn commit(table: &Table, messages: Vec<CommitMessage>) {
+    table
+        .new_write_builder()
+        .new_commit()
+        .commit(messages)
+        .await
+        .unwrap();
+}
+
+async fn ids(table: &Table) -> Vec<i32> {
+    // Ordinary FULL reads let the assertion include unindexed rows even if
+    // the operation under test was configured for PARTIAL/DETAIL search.
+    let table = table.copy_with_options(HashMap::from([(
+        "scalar-index.search-mode".into(),
+        "FULL".into(),
+    )]));
+    let mut builder = table.new_read_builder();
+    builder.with_projection(&["id"]).unwrap();
+    let plan = builder.new_scan().plan().await.unwrap();
+    let batches: Vec<RecordBatch> = builder
+        .new_read()
+        .unwrap()
+        .to_arrow(plan.splits())
+        .unwrap()
+        .try_collect()
+        .await
+        .unwrap();
+    let mut ids: Vec<_> = batches
+        .iter()
+        .flat_map(|batch| {
+            batch
+                .column(0)
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .unwrap()
+                .values()
+                .to_vec()
+        })
+        .collect();
+    ids.sort_unstable();
+    ids
+}
+
+async fn snapshot_id(table: &Table) -> i64 {
+    table
+        .snapshot_manager()
+        .get_latest_snapshot()
+        .await
+        .unwrap()
+        .unwrap()
+        .id()
+}
+
+async fn index_names(table: &Table) -> Vec<String> {
+    let snapshot = table
+        .snapshot_manager()
+        .get_latest_snapshot()
+        .await
+        .unwrap()
+        .unwrap();
+    let Some(name) = snapshot.index_manifest() else {
+        return Vec::new();
+    };
+    let mut names: Vec<_> = IndexManifest::read(
+        table.file_io(),
+        &table.snapshot_manager().manifest_path(name),
+    )
+    .await
+    .unwrap()
+    .into_iter()
+    .map(|entry| entry.index_file.file_name)
+    .collect();
+    names.sort();
+    names
+}
+
+#[tokio::test]
+async fn 
data_predicate_uses_latest_partial_column_values_and_existing_deletes() {
+    let table = table(&[("read.batch-size", "1")]).await;
+    seed(&table).await;
+    let fields = table.schema().fields_with_read_metadata();
+    let pb = PredicateBuilder::new(&fields);
+    let mut read = table.new_read_builder();
+    read.with_projection(&["id", "_ROW_ID"]).unwrap();
+    let plan = read.new_scan().plan().await.unwrap();
+    let batches: Vec<RecordBatch> = read
+        .new_read()
+        .unwrap()
+        .to_arrow(plan.splits())
+        .unwrap()
+        .try_collect()
+        .await
+        .unwrap();
+    let mut row_ids = HashMap::new();
+    for batch in batches {
+        let ids = batch
+            .column(0)
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .unwrap();
+        let rows = batch
+            .column(1)
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+        for row in 0..batch.num_rows() {
+            row_ids.insert(ids.value(row), rows.value(row));
+        }
+    }
+    let update = table.new_write_builder().new_update().unwrap();
+    let updates = RecordBatch::try_from_iter([
+        (
+            "_ROW_ID",
+            Arc::new(Int64Array::from(vec![row_ids[&0]])) as ArrayRef,
+        ),
+        ("v", Arc::new(Int32Array::from(vec![99])) as ArrayRef),
+    ])
+    .unwrap();
+    commit(
+        &table,
+        update
+            .update_by_arrow_with_row_id(vec![updates])
+            .await
+            .unwrap(),
+    )
+    .await;
+    commit(
+        &table,
+        update.delete_by_row_id(vec![row_ids[&1]]).await.unwrap(),
+    )
+    .await;
+    let baseline = snapshot_id(&table).await;
+    let messages = update
+        .delete_by_predicate(Some(pb.greater_than("v", 
Datum::Int(50)).unwrap()))
+        .await
+        .unwrap();
+    assert_eq!(messages.len(), 1);
+    assert_eq!(messages[0].check_from_snapshot, Some(baseline));
+    assert!(messages[0].deleted_files.is_empty());
+    assert_eq!(messages[0].new_index_files.len(), 1);
+    assert_eq!(messages[0].deleted_index_files.len(), 1);
+    assert_eq!(ids(&table).await, vec![0, 2, 3]);
+    commit(&table, messages).await;
+    assert_eq!(ids(&table).await, vec![2, 3]);
+    assert!(update
+        .delete_by_predicate(Some(pb.greater_than("v", 
Datum::Int(50)).unwrap()))
+        .await
+        .unwrap()
+        .is_empty());
+}
+
+#[tokio::test]
+async fn partition_predicates_remap_all_leaves_and_delete_without_dv() {
+    for (predicate, expected) in [
+        (0, vec![2]),
+        (1, vec![2, 3]),
+        (2, vec![0, 1, 2]),
+        (3, vec![0, 1]),
+    ] {
+        let table = table(&[("deletion-vectors.enabled", "false")]).await;
+        seed(&table).await;
+        let pb = PredicateBuilder::new(table.schema().fields());
+        let predicate = match predicate {
+            0 => Predicate::or(vec![
+                pb.equal("p", Datum::String("a".into())).unwrap(),
+                pb.is_null("p").unwrap(),
+            ]),
+            1 => Predicate::and(vec![
+                pb.equal("q", Datum::Int(1)).unwrap(),
+                pb.equal("p", Datum::String("a".into())).unwrap(),
+            ]),
+            2 => pb.is_null("p").unwrap(),
+            _ => Predicate::negate(pb.equal("q", Datum::Int(1)).unwrap()),
+        };
+        let baseline = snapshot_id(&table).await;
+        let messages = table
+            .new_write_builder()
+            .new_update()
+            .unwrap()
+            .delete_by_predicate(Some(predicate))
+            .await
+            .unwrap();
+        assert!(messages
+            .iter()
+            .all(|message| message.check_from_snapshot == Some(baseline)
+                && message.new_index_files.is_empty()
+                && !message.deleted_files.is_empty()));
+        assert_eq!(ids(&table).await, vec![0, 1, 2, 3]);
+        commit(&table, messages).await;
+        assert_eq!(ids(&table).await, expected);
+    }
+}
+
+#[tokio::test]
+async fn mixed_and_or_and_not_predicates_delete_rows_not_whole_partitions() {
+    for predicate_kind in 0..3 {
+        let table = table(&[]).await;
+        seed(&table).await;
+        let pb = PredicateBuilder::new(table.schema().fields());
+        let partition = pb.equal("p", Datum::String("a".into())).unwrap();
+        let data = pb.equal("v", Datum::Int(20)).unwrap();
+        let (predicate, expected) = match predicate_kind {
+            0 => (Predicate::and(vec![partition, data]), vec![0, 2, 3]),
+            1 => (
+                Predicate::or(vec![partition, pb.equal("v", 
Datum::Int(40)).unwrap()]),
+                vec![2],
+            ),
+            _ => (
+                Predicate::negate(Predicate::and(vec![partition, data])),
+                vec![1],
+            ),
+        };
+        let messages = table
+            .new_write_builder()
+            .new_update()
+            .unwrap()
+            .delete_by_predicate(Some(predicate))
+            .await
+            .unwrap();
+        assert!(messages
+            .iter()
+            .all(|message| message.deleted_files.is_empty()));
+        commit(&table, messages).await;
+        assert_eq!(ids(&table).await, expected);
+    }
+}
+
+#[tokio::test]
+async fn data_delete_requires_dv_before_empty_or_no_match_shortcuts() {
+    for seeded in [false, true] {
+        let table = table(&[("deletion-vectors.enabled", "false")]).await;
+        if seeded {
+            seed(&table).await;
+        }
+        let pb = PredicateBuilder::new(table.schema().fields());
+        for predicate in [
+            None,
+            Some(pb.equal("id", Datum::Int(999)).unwrap()),
+            Some(Predicate::and(vec![
+                pb.equal("p", Datum::String("a".into())).unwrap(),
+                pb.equal("id", Datum::Int(999)).unwrap(),
+            ])),
+        ] {
+            let error = table
+                .new_write_builder()
+                .new_update()
+                .unwrap()
+                .delete_by_predicate(predicate)
+                .await
+                .unwrap_err();
+            assert!(
+                error.to_string().contains("deletion-vectors.enabled"),
+                "{error}"
+            );
+        }
+    }
+}
+
+#[tokio::test]
+async fn empty_table_no_match_and_delete_all() {
+    let table = table(&[]).await;
+    let update = table.new_write_builder().new_update().unwrap();
+    assert!(update.delete_by_predicate(None).await.unwrap().is_empty());
+    seed(&table).await;
+    let pb = PredicateBuilder::new(table.schema().fields());
+    for predicate in [
+        pb.equal("id", Datum::Int(999)).unwrap(),
+        pb.equal("p", Datum::String("missing".into())).unwrap(),
+    ] {
+        assert!(update
+            .delete_by_predicate(Some(predicate))
+            .await
+            .unwrap()
+            .is_empty());
+    }
+    commit(&table, update.delete_by_predicate(None).await.unwrap()).await;
+    assert!(ids(&table).await.is_empty());
+    assert!(update.delete_by_predicate(None).await.unwrap().is_empty());
+}
+
+#[tokio::test]
+async fn 
partition_delete_removes_dv_and_global_indexes_and_retains_other_partition_indexes()
 {
+    let table = table(&[("global-index.enabled", "true")]).await;
+    seed(&table).await;
+    table
+        .copy_with_options(HashMap::from([(
+            "deletion-vectors.enabled".into(),
+            "false".into(),
+        )]))
+        .new_global_index_build_builder()
+        .with_index_column("v")
+        .with_index_type("bitmap")
+        .execute()
+        .await
+        .unwrap();
+    let pb = PredicateBuilder::new(table.schema().fields());
+    let update = table.new_write_builder().new_update().unwrap();
+    commit(
+        &table,
+        update
+            .delete_by_predicate(Some(pb.equal("id", Datum::Int(0)).unwrap()))
+            .await
+            .unwrap(),
+    )
+    .await;
+    let before = index_names(&table).await;
+    let messages = update
+        .delete_by_predicate(Some(pb.equal("p", 
Datum::String("a".into())).unwrap()))
+        .await
+        .unwrap();
+    let deleted: Vec<_> = messages
+        .iter()
+        .flat_map(|message| &message.deleted_index_files)
+        .map(|index| index.file_name.clone())
+        .collect();
+    assert_eq!(deleted.len(), 2);
+    commit(&table, messages).await;
+    let mut expected: Vec<_> = before
+        .into_iter()
+        .filter(|name| !deleted.contains(name))
+        .collect();
+    expected.sort();
+    assert_eq!(index_names(&table).await, expected);
+    assert_eq!(ids(&table).await, vec![2, 3]);
+}
+
+#[tokio::test]
+async fn 
data_predicate_delete_includes_unindexed_rows_in_partial_and_detail_modes() {
+    for mode in ["PARTIAL", "DETAIL"] {
+        let table = table(&[("global-index.enabled", "true")]).await;
+        seed(&table).await;
+        table
+            .copy_with_options(HashMap::from([(
+                "deletion-vectors.enabled".into(),
+                "false".into(),
+            )]))
+            .new_global_index_build_builder()
+            .with_index_column("v")
+            .with_index_type("bitmap")
+            .execute()
+            .await
+            .unwrap();
+        write_batch(&table, &batch(vec![4], vec![Some("a")], vec![20], 
vec![1])).await;
+        let table = table.copy_with_options(HashMap::from([(
+            "scalar-index.search-mode".into(),
+            mode.into(),
+        )]));
+        let pb = PredicateBuilder::new(table.schema().fields());
+        let messages = table
+            .new_write_builder()
+            .new_update()
+            .unwrap()
+            .delete_by_predicate(Some(pb.equal("v", Datum::Int(20)).unwrap()))
+            .await
+            .unwrap();
+        commit(&table, messages).await;
+        assert_eq!(ids(&table).await, vec![0, 2, 3]);
+    }
+}
+
+#[tokio::test]
+async fn partition_delete_preserves_concurrent_append_in_selected_partition() {
+    let table = table(&[("deletion-vectors.enabled", "false")]).await;
+    seed(&table).await;
+    let pb = PredicateBuilder::new(table.schema().fields());
+    let messages = table
+        .new_write_builder()
+        .new_update()
+        .unwrap()
+        .delete_by_predicate(Some(pb.equal("p", 
Datum::String("a".into())).unwrap()))
+        .await
+        .unwrap();
+    write_batch(&table, &batch(vec![4], vec![Some("a")], vec![50], 
vec![1])).await;
+    commit(&table, messages).await;
+    assert_eq!(ids(&table).await, vec![2, 3, 4]);
+}
+
+#[tokio::test]
+async fn row_delete_rejects_concurrent_dv_replacement() {
+    let table = table(&[]).await;
+    seed(&table).await;
+    let pb = PredicateBuilder::new(table.schema().fields());
+    let update = table.new_write_builder().new_update().unwrap();
+    let first = update
+        .delete_by_predicate(Some(pb.equal("id", Datum::Int(0)).unwrap()))
+        .await
+        .unwrap();
+    commit(
+        &table,
+        update
+            .delete_by_predicate(Some(pb.equal("id", Datum::Int(1)).unwrap()))
+            .await
+            .unwrap(),
+    )
+    .await;
+    let error = table
+        .new_write_builder()
+        .new_commit()
+        .commit(first)
+        .await
+        .unwrap_err();
+    assert!(
+        error.to_string().contains("Conflicting deletion vectors"),
+        "{error}"
+    );
+    assert_eq!(ids(&table).await, vec![0, 2, 3]);
+}
+
+#[tokio::test]
+async fn 
predicate_delete_rejects_time_travel_auth_and_inconsistent_field_indices() {
+    let table = table(&[]).await;
+    seed(&table).await;
+    let pb = PredicateBuilder::new(table.schema().fields());
+    let predicate = pb.equal("p", Datum::String("a".into())).unwrap();
+    let mut wrong = predicate.clone();
+    if let Predicate::Leaf { index, .. } = &mut wrong {
+        *index = 3;
+    }
+    let error = table
+        .new_write_builder()
+        .new_update()
+        .unwrap()
+        .delete_by_predicate(Some(wrong))
+        .await
+        .unwrap_err();
+    assert!(error
+        .to_string()
+        .contains("does not match the table schema"));
+    let historical =
+        table.copy_with_options(HashMap::from([("scan.snapshot-id".into(), 
"1".into())]));
+    assert!(historical.new_write_builder().new_update().is_err());
+    let auth = table.copy_with_options(HashMap::from([(
+        "query-auth.enabled".into(),
+        "true".into(),
+    )]));
+    let error = auth
+        .new_write_builder()
+        .new_update()
+        .unwrap()
+        .delete_by_predicate(Some(predicate))
+        .await
+        .unwrap_err();
+    assert!(error.to_string().contains("query-auth.enabled"));
+    assert_eq!(ids(&table).await, vec![0, 1, 2, 3]);
+}
+
+#[tokio::test]
+async fn 
partition_deletes_support_append_pk_and_pending_files_without_row_tracking() {
+    for bucket in ["-1", "1", "-2"] {
+        let table = table_with_primary_keys(
+            &[
+                ("data-evolution.enabled", "false"),
+                ("row-tracking.enabled", "false"),
+                ("deletion-vectors.enabled", "false"),
+                ("bucket", bucket),
+            ],
+            bucket != "-1",
+        )
+        .await;
+        seed(&table).await;
+        let pb = PredicateBuilder::new(table.schema().fields());
+        let messages = table
+            .new_write_builder()
+            .new_update()
+            .unwrap()
+            .delete_by_predicate(Some(pb.equal("p", 
Datum::String("a".into())).unwrap()))
+            .await
+            .unwrap();
+        assert_eq!(messages.len(), 1);
+        assert!(!messages[0].deleted_files.is_empty());
+        assert_eq!(messages[0].bucket, if bucket == "-2" { -2 } else { 0 });
+        commit(&table, messages).await;
+        if bucket != "-2" {
+            assert_eq!(ids(&table).await, vec![2, 3]);
+        } else {
+            let latest = table
+                .snapshot_manager()
+                .get_latest_snapshot()
+                .await
+                .unwrap()
+                .unwrap();
+            assert_eq!(latest.total_record_count(), Some(2));
+        }
+    }
+}

Reply via email to