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 1ca84c0d feat(index): support dry run when dropping global indexes 
(#1078)
1ca84c0d is described below

commit 1ca84c0d069f80c2f6715722993d61c29d359c92
Author: QuakeWang <[email protected]>
AuthorDate: Thu Oct 8 20:40:32 2026 +0800

    feat(index): support dry run when dropping global indexes (#1078)
---
 crates/integrations/datafusion/src/procedures.rs   |  22 ++-
 crates/integrations/datafusion/tests/procedures.rs | 141 ++++++++++++++-
 .../paimon/src/table/global_index_drop_builder.rs  | 189 +++++++++++++++++++--
 docs/src/sql.md                                    |  19 +++
 4 files changed, 351 insertions(+), 20 deletions(-)

diff --git a/crates/integrations/datafusion/src/procedures.rs 
b/crates/integrations/datafusion/src/procedures.rs
index 84f014c7..18416eec 100644
--- a/crates/integrations/datafusion/src/procedures.rs
+++ b/crates/integrations/datafusion/src/procedures.rs
@@ -176,8 +176,8 @@ fn declared_parameters(proc_name: &str) -> Option<&'static 
[&'static str]> {
         "create_tag_from_timestamp" => &["table", "tag", "timestamp"],
         "rename_branch" => &["table", "from_branch", "to_branch"],
         "create_global_index" => &["table", "index_column", "index_type", 
"options"],
-        // `partitions`/`dry_run` are declared but not yet implemented; they 
still reach
-        // their own "not supported yet" error rather than being reported as 
unknown.
+        // `partitions` is declared but not yet implemented; it still reaches 
its
+        // own "not supported yet" error rather than being reported as unknown.
         "drop_global_index" => &[
             "table",
             "index_column",
@@ -810,16 +810,22 @@ async fn proc_drop_global_index(
             "drop_global_index partitions are not supported yet".to_string(),
         ));
     }
-    if args.contains_key("dry_run") {
-        return Err(DataFusionError::NotImplemented(
-            "drop_global_index dry_run is not supported yet".to_string(),
-        ));
-    }
+    let dry_run = bool_arg(args, "dry_run")?;
 
     let mut builder = table.new_global_index_drop_builder();
     builder.with_index_column(index_column);
     builder.with_index_type(index_type);
-    builder.execute().await.map_err(to_datafusion_error)?;
+    builder.with_dry_run(dry_run);
+    let dropped = builder.execute().await.map_err(to_datafusion_error)?;
+    if dry_run {
+        return utf8_result(
+            ctx,
+            &[("result", false)],
+            vec![vec![Some(format!(
+                "Would drop {dropped} global index file(s)"
+            ))]],
+        );
+    }
     ok_result(ctx)
 }
 
diff --git a/crates/integrations/datafusion/tests/procedures.rs 
b/crates/integrations/datafusion/tests/procedures.rs
index f2b21646..d89c4547 100644
--- a/crates/integrations/datafusion/tests/procedures.rs
+++ b/crates/integrations/datafusion/tests/procedures.rs
@@ -19,12 +19,14 @@ mod common;
 
 use common::{
     assert_sql_error, collect_id_name, create_sql_context, create_test_env, 
exec, row_count,
-    setup_sql_context,
+    setup_sql_context, string_value,
 };
 use datafusion::arrow::array::{Array, Int64Array, StringArray};
 use paimon::catalog::Identifier;
 use paimon::table::BranchManager;
 use paimon::Catalog;
+use std::collections::BTreeMap;
+use std::path::{Path, PathBuf};
 
 async fn setup_table_with_snapshots() -> (tempfile::TempDir, 
paimon_datafusion::SQLContext) {
     let (tmp, sql_context) = setup_sql_context().await;
@@ -1009,6 +1011,143 @@ async fn 
test_create_full_text_global_index_rejects_non_string_column() {
     .await;
 }
 
+fn table_files(root: &Path) -> BTreeMap<PathBuf, Vec<u8>> {
+    let mut files = BTreeMap::new();
+    for entry in std::fs::read_dir(root).unwrap() {
+        let path = entry.unwrap().path();
+        if path.is_dir() {
+            files.extend(table_files(&path));
+        } else {
+            let content = std::fs::read(&path).unwrap();
+            files.insert(path, content);
+        }
+    }
+    files
+}
+
+#[tokio::test]
+async fn test_drop_global_index_dry_run_then_drop() {
+    for (table_name, dry_run, drop_arg) in [
+        ("dry_run_false", "true", ", dry_run => false"),
+        ("dry_run_default", "' TrUe '", ""),
+    ] {
+        let (tmp, sql_context) = 
setup_btree_global_index_table(table_name).await;
+        exec(
+            &sql_context,
+            &format!(
+                "INSERT INTO paimon.test_db.{table_name} (id, name) VALUES (1, 
'alice'), (2, 'bob')"
+            ),
+        )
+        .await;
+        exec(
+            &sql_context,
+            &format!(
+                "CALL sys.create_global_index(table => 'test_db.{table_name}', 
index_column => 'id')"
+            ),
+        )
+        .await;
+        let index_sql = format!("SELECT * FROM 
paimon.test_db.`{table_name}$table_indexes`");
+        let matched = row_count(&sql_context, &index_sql).await;
+        assert!(matched > 0);
+        let before = table_files(tmp.path());
+
+        let result = sql_context
+            .sql(&format!(
+                "CALL sys.drop_global_index(table => 'test_db.{table_name}', 
index_column => 'id', dry_run => {dry_run})"
+            ))
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+        assert_eq!(
+            string_value(result[0].column_by_name("result").unwrap().as_ref(), 
0),
+            format!("Would drop {matched} global index file(s)")
+        );
+        assert_eq!(row_count(&sql_context, &index_sql).await, matched);
+        assert_eq!(table_files(tmp.path()), before);
+
+        let result = sql_context
+            .sql(&format!(
+                "CALL sys.drop_global_index(table => 'test_db.{table_name}', 
index_column => 'id'{drop_arg})"
+            ))
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+        assert_eq!(
+            string_value(result[0].column_by_name("result").unwrap().as_ref(), 
0),
+            "OK"
+        );
+        assert_eq!(row_count(&sql_context, &index_sql).await, 0);
+        assert_ne!(table_files(tmp.path()), before);
+    }
+}
+
+#[tokio::test]
+async fn test_drop_global_index_dry_run_without_match() {
+    let (tmp, sql_context) = 
setup_btree_global_index_table("dry_run_empty").await;
+    for insert_data in [false, true] {
+        if insert_data {
+            exec(
+                &sql_context,
+                "INSERT INTO paimon.test_db.dry_run_empty (id, name) VALUES 
(1, 'alice')",
+            )
+            .await;
+        }
+        let before = table_files(tmp.path());
+        let result = sql_context
+            .sql("CALL sys.drop_global_index(table => 'test_db.dry_run_empty', 
index_column => 'id', dry_run => true)")
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+        assert_eq!(
+            string_value(result[0].column(0).as_ref(), 0),
+            "Would drop 0 global index file(s)"
+        );
+        assert_eq!(table_files(tmp.path()), before);
+    }
+}
+
+#[tokio::test]
+async fn test_drop_global_index_dry_run_rejects_invalid_args() {
+    let (tmp, sql_context) = 
setup_btree_global_index_table("dry_run_invalid").await;
+    exec(
+        &sql_context,
+        "INSERT INTO paimon.test_db.dry_run_invalid (id, name) VALUES (1, 
'alice')",
+    )
+    .await;
+    exec(
+        &sql_context,
+        "CALL sys.create_global_index(table => 'test_db.dry_run_invalid', 
index_column => 'id')",
+    )
+    .await;
+    let before = table_files(tmp.path());
+    for (args, expected_error) in [
+        ("dry_run => 'maybe'", "Invalid dry_run"),
+        ("dry_run => 1", "Invalid dry_run"),
+        ("dry_run => ''", "Invalid dry_run"),
+        ("dry_run => NULL", "Unsupported argument value"),
+        (
+            "dry_run => true, partitions => 'pt=p1'",
+            "drop_global_index partitions are not supported yet",
+        ),
+    ] {
+        assert_sql_error(
+            &sql_context,
+            &format!(
+                "CALL sys.drop_global_index(table => 
'test_db.dry_run_invalid', index_column => 'id', {args})"
+            ),
+            expected_error,
+        )
+        .await;
+        assert_eq!(table_files(tmp.path()), before);
+    }
+}
+
 #[tokio::test]
 async fn test_drop_global_index_removes_btree_and_reads_fallback() {
     let (_tmp, sql_context) = 
setup_btree_global_index_table("btree_drop").await;
diff --git a/crates/paimon/src/table/global_index_drop_builder.rs 
b/crates/paimon/src/table/global_index_drop_builder.rs
index 3a21f2f2..155a708e 100644
--- a/crates/paimon/src/table/global_index_drop_builder.rs
+++ b/crates/paimon/src/table/global_index_drop_builder.rs
@@ -29,6 +29,7 @@ pub struct GlobalIndexDropBuilder<'a> {
     table: &'a Table,
     index_column: Option<IndexColumns>,
     index_type: String,
+    dry_run: bool,
 }
 
 impl<'a> GlobalIndexDropBuilder<'a> {
@@ -37,6 +38,7 @@ impl<'a> GlobalIndexDropBuilder<'a> {
             table,
             index_column: None,
             index_type: BTREE_GLOBAL_INDEX_TYPE.to_string(),
+            dry_run: false,
         }
     }
 
@@ -58,6 +60,12 @@ impl<'a> GlobalIndexDropBuilder<'a> {
         self
     }
 
+    /// Return the matched index file count without committing when enabled.
+    pub fn with_dry_run(&mut self, dry_run: bool) -> &mut Self {
+        self.dry_run = dry_run;
+        self
+    }
+
     pub async fn execute(&self) -> Result<usize> {
         // Dropping an index reads the index manifest.
         
crate::spec::CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
@@ -135,8 +143,8 @@ impl<'a> GlobalIndexDropBuilder<'a> {
                 .or_default()
                 .push(entry.index_file);
         }
-        if dropped == 0 {
-            return Ok(0);
+        if self.dry_run || dropped == 0 {
+            return Ok(dropped);
         }
 
         let mut groups = deletions_by_partition_bucket
@@ -182,6 +190,7 @@ mod tests {
     use crate::table::TableCommit;
     use chrono::{DateTime, Utc};
     use indexmap::IndexMap;
+    use std::collections::BTreeMap;
 
     #[tokio::test]
     async fn test_query_auth_table_refuses_index_builds_and_drops() {
@@ -195,15 +204,18 @@ mod tests {
             );
         };
 
-        refused(
-            table
-                .new_global_index_drop_builder()
-                .with_index_type("btree")
-                .execute()
-                .await
-                .unwrap_err(),
-            "dropping a global index",
-        );
+        for dry_run in [false, true] {
+            refused(
+                table
+                    .new_global_index_drop_builder()
+                    .with_index_type("btree")
+                    .with_dry_run(dry_run)
+                    .execute()
+                    .await
+                    .unwrap_err(),
+                "dropping a global index",
+            );
+        }
         refused(
             table
                 .new_btree_global_index_build_builder()
@@ -365,6 +377,161 @@ mod tests {
         .unwrap()
     }
 
+    async fn table_files(table: &Table) -> BTreeMap<String, Vec<u8>> {
+        let mut files = BTreeMap::new();
+        for status in table
+            .file_io()
+            .list_status_recursive(&format!("{}/", table.location()))
+            .await
+            .unwrap()
+        {
+            let content = table
+                .file_io()
+                .new_input(&status.path)
+                .unwrap()
+                .read()
+                .await
+                .unwrap();
+            files.insert(status.path, content.to_vec());
+        }
+        files
+    }
+
+    #[tokio::test]
+    async fn test_drop_global_index_dry_run_preserves_files() {
+        for index_type in [
+            "btree",
+            "bitmap",
+            "multivalue",
+            "fm",
+            "full-text",
+            "lumina",
+            "ivf-flat",
+            "ivf-pq",
+            "ivf-sq",
+            "ivf-rq",
+            "diskann",
+        ] {
+            let table = test_table(&format!("memory:/dry_run_{index_type}"));
+            setup_dirs(&table).await;
+            let mut message = CommitMessage::new(vec![], 0, 
vec![data_file("data.parquet")]);
+            message.new_index_files = vec![
+                global_index_file(index_type, "id-0.index", 0, 0, 9),
+                global_index_file(index_type, "id-1.index", 0, 10, 19),
+                global_index_file(index_type, "name.index", 1, 0, 19),
+                hash_index_file("hash.index"),
+            ];
+            TableCommit::new(table.clone(), "test-user".into())
+                .commit(vec![message])
+                .await
+                .unwrap();
+            let before = table_files(&table).await;
+
+            let mut builder = table.new_global_index_drop_builder();
+            builder
+                .with_index_column("id")
+                .with_index_type(index_type)
+                .with_dry_run(true);
+            assert_eq!(builder.execute().await.unwrap(), 2, "{index_type}");
+            assert_eq!(table_files(&table).await, before, "{index_type}");
+            assert_eq!(latest_index_entries(&table).await.len(), 4);
+
+            builder.with_dry_run(false);
+            assert_eq!(builder.execute().await.unwrap(), 2, "{index_type}");
+            assert_eq!(latest_index_entries(&table).await.len(), 2);
+        }
+    }
+
+    #[tokio::test]
+    async fn test_drop_global_index_dry_run_matches_full_column_list() {
+        let table = test_table("memory:/dry_run_composite");
+        setup_dirs(&table).await;
+        let mut composite = global_index_file("btree", "id-name.index", 0, 0, 
9);
+        composite
+            .global_index_meta
+            .as_mut()
+            .unwrap()
+            .extra_field_ids = Some(vec![1]);
+        let mut reversed = global_index_file("btree", "name-id.index", 1, 0, 
9);
+        reversed.global_index_meta.as_mut().unwrap().extra_field_ids = 
Some(vec![0]);
+        let mut message = CommitMessage::new(vec![], 0, 
vec![data_file("data.parquet")]);
+        message.new_index_files = vec![
+            global_index_file("btree", "id.index", 0, 0, 9),
+            composite,
+            reversed,
+            global_index_file("bitmap", "bitmap-id.index", 0, 0, 9),
+        ];
+        TableCommit::new(table.clone(), "test-user".into())
+            .commit(vec![message])
+            .await
+            .unwrap();
+        let before = table_files(&table).await;
+
+        for columns in ["id", "id,name", "name,id"] {
+            assert_eq!(
+                table
+                    .new_global_index_drop_builder()
+                    .with_index_column(columns)
+                    .with_dry_run(true)
+                    .execute()
+                    .await
+                    .unwrap(),
+                1,
+                "{columns}"
+            );
+        }
+        assert_eq!(table_files(&table).await, before);
+    }
+
+    #[tokio::test]
+    async fn test_drop_global_index_dry_run_without_match() {
+        for (case, index_files) in [
+            ("no_snapshot", None),
+            ("no_index_manifest", Some(vec![])),
+            (
+                "no_match",
+                Some(vec![global_index_file("btree", "name.index", 1, 0, 9)]),
+            ),
+        ] {
+            let table = test_table(&format!("memory:/dry_run_{case}"));
+            setup_dirs(&table).await;
+            if let Some(index_files) = index_files {
+                let mut message = CommitMessage::new(vec![], 0, 
vec![data_file("data.parquet")]);
+                message.new_index_files = index_files;
+                TableCommit::new(table.clone(), "test-user".into())
+                    .commit(vec![message])
+                    .await
+                    .unwrap();
+            }
+            let before = table_files(&table).await;
+            assert_eq!(
+                table
+                    .new_global_index_drop_builder()
+                    .with_index_column("id")
+                    .with_dry_run(true)
+                    .execute()
+                    .await
+                    .unwrap(),
+                0,
+                "{case}"
+            );
+            assert_eq!(table_files(&table).await, before, "{case}");
+        }
+    }
+
+    #[tokio::test]
+    async fn test_drop_global_index_dry_run_validates_columns() {
+        let table = test_table("memory:/dry_run_invalid_columns");
+        for column in [None, Some(""), Some(" "), Some(", ,"), 
Some("missing")] {
+            let mut builder = table.new_global_index_drop_builder();
+            builder.with_dry_run(true);
+            if let Some(column) = column {
+                builder.with_index_column(column);
+            }
+            assert!(builder.execute().await.is_err(), "{column:?}");
+        }
+    }
+
     #[tokio::test]
     async fn test_drop_btree_global_index_only_removes_target_entries() {
         let table = test_table("memory:/test_drop_btree_global_index");
diff --git a/docs/src/sql.md b/docs/src/sql.md
index a37f1774..5bcd622f 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -1412,6 +1412,25 @@ To drop a composite BTree definition, pass the same 
ordered `index_column`
 list used to build it, such as `'category,item_number'`. Scalar indexes and
 definitions with a different column order remain available.
 
+Set `dry_run => true` to return the number of matching index files without
+committing changes to the table:
+
+```sql
+CALL sys.drop_global_index(
+  table => 'paimon.my_db.my_table',
+  index_column => 'id',
+  index_type => 'btree',
+  dry_run => true
+);
+```
+
+The `result` column contains `Would drop N global index file(s)`, including
+when `N` is zero. `dry_run` defaults to `false`; an explicit `false` or an
+omitted argument performs the drop and returns `OK`. Other values are rejected.
+The preview uses the latest snapshot at the time of the call; concurrent 
changes
+can affect the number of files matched by a later drop. Reads retain the usual
+local-cache behavior. Partition filtering is not supported by this procedure.
+
 ### create_lumina_index
 
 Build and commit a Lumina global vector index for a table column:

Reply via email to