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 5b248b70 fix(datafusion): truncate format tables instead of reporting 
success (#863)
5b248b70 is described below

commit 5b248b7016fbbea33f67adbb50e83c7d39d747d9
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Fri Sep 18 14:32:50 2026 +0800

    fix(datafusion): truncate format tables instead of reporting success (#863)
---
 .../datafusion/src/format_partition_analyze.rs     |  69 +-----
 .../datafusion/src/format_partition_ddl.rs         |  61 ++++-
 .../datafusion/src/format_table_truncate.rs        | 180 +++++++++++++++
 crates/integrations/datafusion/src/lib.rs          |   1 +
 crates/integrations/datafusion/src/sql_context.rs  |  11 +
 .../datafusion/tests/rest_format_partition_sql.rs  | 249 ++++++++++++++++++++-
 .../paimon/src/table/format_partition_truncate.rs  | 101 +++++++++
 crates/paimon/src/table/format_table_scan.rs       |  20 +-
 crates/paimon/src/table/mod.rs                     |   2 +
 crates/paimon/src/table/table_commit.rs            |  17 ++
 docs/src/sql.md                                    |  22 ++
 11 files changed, 662 insertions(+), 71 deletions(-)

diff --git a/crates/integrations/datafusion/src/format_partition_analyze.rs 
b/crates/integrations/datafusion/src/format_partition_analyze.rs
index 71a8c610..a3277d17 100644
--- a/crates/integrations/datafusion/src/format_partition_analyze.rs
+++ b/crates/integrations/datafusion/src/format_partition_analyze.rs
@@ -17,20 +17,16 @@
 
 //! ANALYZE TABLE for Format Tables with catalog-managed partitions.
 
-use std::collections::HashSet;
-
 use datafusion::error::{DataFusionError, Result as DFResult};
 use datafusion::prelude::DataFrame;
-use datafusion::sql::sqlparser::ast::{Analyze, Expr as SqlExpr};
+use datafusion::sql::sqlparser::ast::Analyze;
 use paimon::table::FormatTablePartitionStatsCollector;
 
 use crate::error::to_datafusion_error;
 use crate::format_partition_ddl::{
-    ensure_catalog_managed_format_table, has_custom_location, 
parse_format_partition_spec,
-};
-use crate::sql_context::{
-    normalize_schema_identifier, ok_result, partition_assignment, SQLContext,
+    ensure_catalog_managed_format_table, has_custom_location, 
leading_partition_prefix,
 };
+use crate::sql_context::{ok_result, SQLContext};
 
 /// `ANALYZE TABLE t [PARTITION (...)] COMPUTE STATISTICS [NOSCAN]` on a 
Format Table with
 /// catalog-managed partitions. Mirrors Java 
`PaimonAnalyzeFormatTablePartitionsCommand`.
@@ -68,9 +64,10 @@ pub(crate) async fn execute_analyze(
         .await
         .map_err(to_datafusion_error)?;
     ensure_catalog_managed_format_table(&table, "ANALYZE TABLE")?;
-    let prefix = analyze_partition_prefix(
+    let prefix = leading_partition_prefix(
         analyze.partitions.as_deref().unwrap_or_default(),
         &table,
+        "ANALYZE TABLE",
         enable_ident_normalization,
     )?;
 
@@ -138,59 +135,3 @@ fn format_table_statistics_parallelism(ctx: &SQLContext) 
-> usize {
         .map(|value| value.max(1) as usize)
         .unwrap_or(8)
 }
-
-/// The values an `ANALYZE ... PARTITION (...)` clause fixes, in partition-key 
order; valued
-/// columns must be a leading run of the keys, so `PARTITION (hour = '00')` is 
rejected.
-fn analyze_partition_prefix(
-    expressions: &[SqlExpr],
-    table: &paimon::Table,
-    enable_ident_normalization: bool,
-) -> DFResult<Vec<(String, String)>> {
-    let partition_keys = table.schema().partition_keys();
-    let mut named = HashSet::with_capacity(expressions.len());
-    let mut assignments = Vec::with_capacity(expressions.len());
-    for expression in expressions {
-        let column = match expression {
-            SqlExpr::Identifier(identifier) => {
-                normalize_schema_identifier(identifier, 
enable_ident_normalization)
-            }
-            other => {
-                let (column, _) = partition_assignment(other, 
enable_ident_normalization)?;
-                assignments.push(other.clone());
-                column
-            }
-        };
-        if !partition_keys.contains(&column) {
-            return Err(DataFusionError::Plan(format!(
-                "Column '{column}' is not a partition column"
-            )));
-        }
-        if !named.insert(column.clone()) {
-            return Err(DataFusionError::Plan(format!(
-                "Duplicate partition column '{column}'"
-            )));
-        }
-    }
-    let spec = parse_format_partition_spec(
-        &assignments,
-        table,
-        false,
-        Some("ANALYZE TABLE"),
-        enable_ident_normalization,
-    )?;
-    let leading = partition_keys
-        .iter()
-        .take_while(|key| spec.contains_key(key.as_str()))
-        .count();
-    if leading != spec.len() {
-        return Err(DataFusionError::Plan(format!(
-            "ANALYZE TABLE {} PARTITION must give values for a leading run of 
its partition \
-             columns {partition_keys:?}",
-            table.identifier().full_name()
-        )));
-    }
-    Ok(partition_keys[..leading]
-        .iter()
-        .map(|key| (key.clone(), spec[key].clone()))
-        .collect())
-}
diff --git a/crates/integrations/datafusion/src/format_partition_ddl.rs 
b/crates/integrations/datafusion/src/format_partition_ddl.rs
index 5c08a359..7d0c5bed 100644
--- a/crates/integrations/datafusion/src/format_partition_ddl.rs
+++ b/crates/integrations/datafusion/src/format_partition_ddl.rs
@@ -40,7 +40,9 @@ use paimon::table::{
 };
 
 use crate::error::to_datafusion_error;
-use crate::sql_context::{is_table_not_exist, ok_result, partition_assignment, 
SQLContext};
+use crate::sql_context::{
+    is_table_not_exist, normalize_schema_identifier, ok_result, 
partition_assignment, SQLContext,
+};
 
 #[derive(Debug)]
 pub(crate) struct ShowPartitionsStatement {
@@ -358,6 +360,63 @@ pub(crate) async fn drop_catalog_managed_partitions(
     ok_result(ctx.ctx())
 }
 
+/// The values a `PARTITION (...)` clause of ANALYZE or TRUNCATE fixes, in 
partition-key order.
+/// Valued columns must be a leading run of the keys, so `PARTITION (hour = 
'00')` is rejected.
+pub(crate) fn leading_partition_prefix(
+    expressions: &[SqlExpr],
+    table: &paimon::Table,
+    operation: &str,
+    enable_ident_normalization: bool,
+) -> DFResult<Vec<(String, String)>> {
+    let partition_keys = table.schema().partition_keys();
+    let mut named = HashSet::with_capacity(expressions.len());
+    let mut assignments = Vec::with_capacity(expressions.len());
+    for expression in expressions {
+        let column = match expression {
+            SqlExpr::Identifier(identifier) => {
+                normalize_schema_identifier(identifier, 
enable_ident_normalization)
+            }
+            other => {
+                let (column, _) = partition_assignment(other, 
enable_ident_normalization)?;
+                assignments.push(other.clone());
+                column
+            }
+        };
+        if !partition_keys.contains(&column) {
+            return Err(DataFusionError::Plan(format!(
+                "Column '{column}' is not a partition column"
+            )));
+        }
+        if !named.insert(column.clone()) {
+            return Err(DataFusionError::Plan(format!(
+                "Duplicate partition column '{column}'"
+            )));
+        }
+    }
+    let spec = parse_format_partition_spec(
+        &assignments,
+        table,
+        false,
+        Some(operation),
+        enable_ident_normalization,
+    )?;
+    let leading = partition_keys
+        .iter()
+        .take_while(|key| spec.contains_key(key.as_str()))
+        .count();
+    if leading != spec.len() {
+        return Err(DataFusionError::Plan(format!(
+            "{operation} {} PARTITION must give values for a leading run of 
its partition \
+             columns {partition_keys:?}",
+            table.identifier().full_name()
+        )));
+    }
+    Ok(partition_keys[..leading]
+        .iter()
+        .map(|key| (key.clone(), spec[key].clone()))
+        .collect())
+}
+
 /// Whether the catalog registered a partition at a location of its own rather 
than under the
 /// table directory.
 pub(crate) fn has_custom_location(partition: &paimon::spec::Partition) -> bool 
{
diff --git a/crates/integrations/datafusion/src/format_table_truncate.rs 
b/crates/integrations/datafusion/src/format_table_truncate.rs
new file mode 100644
index 00000000..19051239
--- /dev/null
+++ b/crates/integrations/datafusion/src/format_table_truncate.rs
@@ -0,0 +1,180 @@
+// 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.
+
+//! TRUNCATE TABLE for Format Tables.
+
+use std::collections::HashMap;
+
+use datafusion::error::{DataFusionError, Result as DFResult};
+use datafusion::prelude::DataFrame;
+use datafusion::sql::sqlparser::ast::Expr as SqlExpr;
+use paimon::catalog::{Catalog, Identifier};
+use paimon::spec::{Partition, PartitionStatistics};
+use paimon::table::{FormatTableTruncator, Table};
+
+use crate::error::to_datafusion_error;
+use crate::format_partition_ddl::{has_custom_location, 
leading_partition_prefix};
+use crate::sql_context::{ok_result, SQLContext};
+
+/// `TRUNCATE TABLE t [PARTITION (...)]` on a Format Table. Deletes data files 
only: directories and
+/// registrations stay. Mirrors Java `FormatTableCommit#truncateTable` and 
`#truncatePartitions`.
+pub(crate) async fn execute_truncate(
+    ctx: &SQLContext,
+    catalog: &dyn Catalog,
+    identifier: &Identifier,
+    table: &Table,
+    partitions: Option<&[SqlExpr]>,
+    enable_ident_normalization: bool,
+) -> DFResult<DataFrame> {
+    let truncator = FormatTableTruncator::new(table);
+    if table.schema().partition_keys().is_empty() {
+        if partitions.is_some() {
+            return Err(DataFusionError::Plan(format!(
+                "TRUNCATE TABLE ... PARTITION requires a partitioned table, 
but {} is not \
+                 partitioned",
+                identifier.full_name()
+            )));
+        }
+        truncator
+            .truncate_unpartitioned()
+            .await
+            .map_err(to_datafusion_error)?;
+        return ok_result(ctx.ctx());
+    }
+    let prefix = leading_partition_prefix(
+        partitions.unwrap_or_default(),
+        table,
+        "TRUNCATE TABLE",
+        enable_ident_normalization,
+    )?;
+    // Unlike ANALYZE, a bare `PARTITION (dt)` is not read as every value: 
that empties the table.
+    if partitions.is_some() && prefix.is_empty() {
+        return Err(DataFusionError::Plan(
+            "PARTITION clause requires at least one column = 
value".to_string(),
+        ));
+    }
+
+    // A table with catalog-managed partitions has the partitions its catalog 
holds; any other
+    // table has the partition directories below it, as its scan reads them.
+    let catalog_managed = table.has_catalog_managed_partitions();
+    let selected = if catalog_managed {
+        registered_partitions(catalog, identifier, table, &prefix).await?
+    } else {
+        truncator
+            .discover_partitions()
+            .await
+            .map_err(to_datafusion_error)?
+            .into_iter()
+            .filter(|spec| matches_prefix(spec, &prefix))
+            .collect()
+    };
+    if selected.is_empty() && !prefix.is_empty() {
+        return Err(DataFusionError::Plan(format!(
+            "Partition {prefix:?} does not exist in table {}",
+            identifier.full_name()
+        )));
+    }
+
+    let truncated_at = chrono::Utc::now().timestamp_millis();
+    let mut emptied = Vec::with_capacity(selected.len());
+    let mut failure = None;
+    for spec in selected {
+        match truncator.truncate_partition(&spec).await {
+            Ok(()) => emptied.push(spec),
+            Err(error) => {
+                failure = Some(error);
+                break;
+            }
+        }
+    }
+    // What was emptied is reported even when a later partition failed, so the 
catalog stops
+    // describing files that are gone; the deletion failure is the one 
returned.
+    if catalog_managed && !emptied.is_empty() {
+        let statistics = emptied
+            .iter()
+            .map(|spec| empty_statistics(spec, truncated_at))
+            .collect();
+        let reported = catalog
+            .create_partitions_with_statistics(identifier, emptied, true, 
Some(statistics), true)
+            .await;
+        if let (Err(error), None) = (reported, &failure) {
+            return Err(to_datafusion_error(error));
+        }
+    }
+    if let Some(error) = failure {
+        return Err(to_datafusion_error(error));
+    }
+    ok_result(ctx.ctx())
+}
+
+/// The registered partitions under `prefix`. Nothing is deleted when one of 
them sits at a custom
+/// location, which this client cannot reset to its default directory as Java 
does.
+async fn registered_partitions(
+    catalog: &dyn Catalog,
+    identifier: &Identifier,
+    table: &Table,
+    prefix: &[(String, String)],
+) -> DFResult<Vec<HashMap<String, String>>> {
+    let complete = prefix.len() == table.schema().partition_keys().len();
+    let partitions = if complete {
+        let spec = prefix.iter().cloned().collect::<HashMap<_, _>>();
+        catalog
+            .list_partitions_by_names(identifier, vec![spec])
+            .await
+    } else {
+        catalog.list_partitions(identifier).await
+    }
+    .map_err(to_datafusion_error)?
+    .into_iter()
+    .filter(|partition| matches_prefix(&partition.spec, prefix))
+    .collect::<Vec<_>>();
+
+    let custom_located = partitions
+        .iter()
+        .filter(|partition| has_custom_location(partition))
+        .map(|partition| &partition.spec)
+        .collect::<Vec<_>>();
+    if !custom_located.is_empty() {
+        return Err(DataFusionError::NotImplemented(format!(
+            "TRUNCATE TABLE cannot empty partitions with a custom location in 
Format Table {}: \
+             {custom_located:?}",
+            identifier.full_name()
+        )));
+    }
+    Ok(partitions
+        .into_iter()
+        .map(|partition| partition.spec)
+        .collect())
+}
+
+fn matches_prefix(spec: &HashMap<String, String>, prefix: &[(String, String)]) 
-> bool {
+    prefix
+        .iter()
+        .all(|(key, value)| spec.get(key) == Some(value))
+}
+
+/// What an emptied partition holds, dated to the truncation.
+fn empty_statistics(spec: &HashMap<String, String>, truncated_at: i64) -> 
PartitionStatistics {
+    PartitionStatistics {
+        spec: spec.clone(),
+        record_count: 0,
+        file_size_in_bytes: 0,
+        file_count: 0,
+        last_file_creation_time: truncated_at,
+        total_buckets: Partition::UNKNOWN_TOTAL_BUCKETS,
+    }
+}
diff --git a/crates/integrations/datafusion/src/lib.rs 
b/crates/integrations/datafusion/src/lib.rs
index ad1e8c32..95d1efc9 100644
--- a/crates/integrations/datafusion/src/lib.rs
+++ b/crates/integrations/datafusion/src/lib.rs
@@ -46,6 +46,7 @@ mod filter_pushdown;
 mod format_partition_analyze;
 mod format_partition_ddl;
 mod format_partition_repair;
+mod format_table_truncate;
 #[cfg(feature = "fulltext")]
 mod full_text_search;
 mod hybrid_search;
diff --git a/crates/integrations/datafusion/src/sql_context.rs 
b/crates/integrations/datafusion/src/sql_context.rs
index 41a425eb..aba77b15 100644
--- a/crates/integrations/datafusion/src/sql_context.rs
+++ b/crates/integrations/datafusion/src/sql_context.rs
@@ -1736,6 +1736,17 @@ impl SQLContext {
             }
             Err(e) => return Err(to_datafusion_error(e)),
         };
+        if CoreOptions::new(table.schema().options()).is_format_table() {
+            return crate::format_table_truncate::execute_truncate(
+                self,
+                catalog.as_ref(),
+                &identifier,
+                &table,
+                truncate.partitions.as_deref(),
+                enable_ident_normalization,
+            )
+            .await;
+        }
 
         let wb = table.new_write_builder();
         let commit = wb.try_new_commit().map_err(to_datafusion_error)?;
diff --git a/crates/integrations/datafusion/tests/rest_format_partition_sql.rs 
b/crates/integrations/datafusion/tests/rest_format_partition_sql.rs
index e07b1f4b..78c2cdc5 100644
--- a/crates/integrations/datafusion/tests/rest_format_partition_sql.rs
+++ b/crates/integrations/datafusion/tests/rest_format_partition_sql.rs
@@ -27,7 +27,7 @@ use std::sync::Arc;
 use arrow_array::{Int64Array, RecordBatch};
 use arrow_schema::{DataType as ArrowDataType, Field, Schema as ArrowSchema};
 use paimon::api::ConfigResponse;
-use paimon::catalog::RESTCatalog;
+use paimon::catalog::{Catalog, Identifier, RESTCatalog};
 use paimon::spec::{BigIntType, BooleanType, DataType, DateType, IntType, 
Schema, VarCharType};
 use paimon::{CatalogOptions, Options};
 use paimon_datafusion::SQLContext;
@@ -60,15 +60,19 @@ async fn setup_rest_table(temp_dir: &TempDir, schema: 
Schema) -> (RESTServer, SQ
     );
     server.set_table_external(DATABASE, TABLE, false);
 
+    let catalog = rest_catalog(&server).await;
+    let mut context = SQLContext::new();
+    context.register_catalog("paimon", catalog).await.unwrap();
+    (server, context)
+}
+
+async fn rest_catalog(server: &RESTServer) -> Arc<RESTCatalog> {
     let mut options = Options::new();
     options.set(CatalogOptions::URI, server.url().unwrap());
     options.set(CatalogOptions::WAREHOUSE, WAREHOUSE);
     options.set(CatalogOptions::TOKEN_PROVIDER, "bear");
     options.set(CatalogOptions::TOKEN, "test-token");
-    let catalog = Arc::new(RESTCatalog::new(options, true).await.unwrap());
-    let mut context = SQLContext::new();
-    context.register_catalog("paimon", catalog).await.unwrap();
-    (server, context)
+    Arc::new(RESTCatalog::new(options, true).await.unwrap())
 }
 
 fn format_table_schema(partition_columns: &[(&str, DataType)]) -> Schema {
@@ -931,6 +935,241 @@ async fn test_analyze_refuses_what_it_cannot_measure() {
     .await;
 }
 
+#[cfg(not(windows))]
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn 
test_truncate_table_empties_registered_partitions_and_keeps_them_registered() {
+    let (temp_dir, server, context) = dt_hh_table(&[("a", "00"), ("a", "01"), 
("b", "00")]).await;
+    let root = temp_dir.path();
+    write_ids(&root.join("dt=a/hh=00"), &[1, 2]);
+    write_ids(&root.join("dt=a/hh=01"), &[3]);
+    // A Java reader reads a file whatever its name ends with, so truncating 
deletes it too.
+    std::fs::write(root.join("dt=b/hh=00/part-00000"), b"data").unwrap();
+    // Neither another writer's staging output nor a directory awaiting MSCK 
REPAIR is table data.
+    write_ids_file(&root.join("dt=a/hh=00/_temporary/0/part-1.parquet"), &[8]);
+    write_ids(&root.join("dt=c/hh=00"), &[9]);
+    common::exec(
+        &context,
+        &format!("ANALYZE TABLE {TABLE_NAME} COMPUTE STATISTICS NOSCAN"),
+    )
+    .await;
+    assert_eq!(counts(&partition_statistics(&server)["dt=a/hh=00"]).1, 1);
+    let select = format!("SELECT id FROM {TABLE_NAME}");
+    assert_eq!(ids(&context, &select).await, [1, 2, 3]);
+
+    common::exec(&context, &format!("TRUNCATE TABLE {TABLE_NAME}")).await;
+
+    assert!(ids(&context, &select).await.is_empty());
+    assert!(!root.join("dt=a/hh=00/part-0.parquet").exists());
+    assert!(!root.join("dt=b/hh=00/part-00000").exists());
+    assert!(root.join("dt=a/hh=00/_temporary/0/part-1.parquet").exists());
+    assert!(root.join("dt=c/hh=00/part-0.parquet").exists());
+    assert_eq!(server.table_partition_specs(DATABASE, TABLE).len(), 3);
+    let emptied = partition_statistics(&server);
+    for name in ["dt=a/hh=00", "dt=a/hh=01", "dt=b/hh=00"] {
+        assert!(root.join(name).is_dir(), "{name}");
+        assert_eq!(counts(&emptied[name]), (0, 0), "{name}");
+        assert_eq!(emptied[name].file_size_in_bytes, 0, "{name}");
+        assert!(emptied[name].last_file_creation_time > 0, "{name}");
+    }
+    let calls = server.create_partitions_calls();
+    let (_, _, request) = calls.last().unwrap();
+    assert_eq!(request.replace_statistics, Some(true));
+}
+
+#[cfg(not(windows))]
+#[tokio::test]
+async fn 
test_truncate_partition_clause_empties_a_leading_run_of_partition_values() {
+    let (temp_dir, server, context) = dt_hh_table(&[("a", "00"), ("a", "01"), 
("b", "00")]).await;
+    let root = temp_dir.path();
+    for directory in ["dt=a/hh=00", "dt=a/hh=01", "dt=b/hh=00"] {
+        write_ids(&root.join(directory), &[1]);
+    }
+    common::exec(
+        &context,
+        &format!("ANALYZE TABLE {TABLE_NAME} COMPUTE STATISTICS NOSCAN"),
+    )
+    .await;
+    let has_data = || {
+        ["dt=a/hh=00", "dt=a/hh=01", "dt=b/hh=00"]
+            .map(|directory| 
root.join(directory).join("part-0.parquet").exists())
+    };
+
+    common::exec(
+        &context,
+        &format!("TRUNCATE TABLE {TABLE_NAME} PARTITION (dt = 'a', hh = 
'01')"),
+    )
+    .await;
+    assert_eq!(has_data(), [true, false, true]);
+    let measured = partition_statistics(&server);
+    assert_eq!(counts(&measured["dt=a/hh=00"]), (UNKNOWN, 1));
+    assert_eq!(counts(&measured["dt=a/hh=01"]), (0, 0));
+
+    common::exec(
+        &context,
+        &format!("TRUNCATE TABLE {TABLE_NAME} PARTITION (dt = 'a')"),
+    )
+    .await;
+    assert_eq!(has_data(), [false, false, true]);
+    assert_eq!(
+        counts(&partition_statistics(&server)["dt=b/hh=00"]),
+        (UNKNOWN, 1)
+    );
+
+    for (clause, message) in [
+        ("PARTITION (hh = '00')", "leading run"),
+        ("PARTITION (id = 1)", "not a partition column"),
+        ("PARTITION (dt = 'zzz')", "does not exist"),
+        ("PARTITION (dt = 'b', hh = '99')", "does not exist"),
+        ("PARTITION (dt, hh)", "at least one column = value"),
+    ] {
+        common::assert_sql_error(
+            &context,
+            &format!("TRUNCATE TABLE {TABLE_NAME} {clause}"),
+            message,
+        )
+        .await;
+    }
+    assert_eq!(has_data(), [false, false, true]);
+}
+
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_truncate_refuses_a_custom_location_before_deleting_anything() {
+    let temp_dir = tempfile::tempdir().unwrap();
+    let external_dir = tempfile::tempdir().unwrap();
+    let (server, context) =
+        setup_rest_table(&temp_dir, format_table_schema(&[("dt", 
varchar())])).await;
+    common::exec(
+        &context,
+        &format!("ALTER TABLE {TABLE_NAME} ADD PARTITION (dt = 'a') PARTITION 
(dt = 'b')"),
+    )
+    .await;
+    let root = temp_dir.path();
+    write_ids(&root.join("dt=a"), &[1]);
+    write_ids(&root.join("dt=b"), &[2]);
+    write_ids(external_dir.path(), &[3]);
+    server.set_table_partition_options(
+        DATABASE,
+        TABLE,
+        &spec(&[("dt", "b")]),
+        HashMap::from([(
+            "path".to_string(),
+            format!("file://{}", external_dir.path().display()),
+        )]),
+    );
+    let reports = server.create_partitions_calls().len();
+
+    common::assert_sql_error(
+        &context,
+        &format!("TRUNCATE TABLE {TABLE_NAME}"),
+        "custom location",
+    )
+    .await;
+    assert!(root.join("dt=a/part-0.parquet").exists());
+    assert!(root.join("dt=b/part-0.parquet").exists());
+    assert!(external_dir.path().join("part-0.parquet").exists());
+    assert_eq!(server.create_partitions_calls().len(), reports);
+
+    // A partition clause that leaves it out does not meet it.
+    common::exec(
+        &context,
+        &format!("TRUNCATE TABLE {TABLE_NAME} PARTITION (dt = 'a')"),
+    )
+    .await;
+    assert!(!root.join("dt=a/part-0.parquet").exists());
+    assert!(root.join("dt=b/part-0.parquet").exists());
+    assert!(external_dir.path().join("part-0.parquet").exists());
+}
+
+#[cfg(not(windows))]
+#[tokio::test]
+async fn 
test_truncate_a_format_table_that_discovers_partitions_from_directories() {
+    let temp_dir = tempfile::tempdir().unwrap();
+    let schema = Schema::builder()
+        .column("dt", varchar())
+        .column("id", DataType::BigInt(BigIntType::new()))
+        .partition_keys(["dt"])
+        .option("type", "format-table")
+        .option("file.format", "parquet")
+        .build()
+        .unwrap();
+    let (server, context) = setup_rest_table(&temp_dir, schema).await;
+    let root = temp_dir.path();
+    write_ids(&root.join("dt=a"), &[1]);
+    write_ids(&root.join("dt=b"), &[2]);
+    // Not a partition directory, so not part of the table.
+    write_ids(&root.join("tmp/unknown"), &[3]);
+
+    common::exec(
+        &context,
+        &format!("TRUNCATE TABLE {TABLE_NAME} PARTITION (dt = 'a')"),
+    )
+    .await;
+    assert!(!root.join("dt=a/part-0.parquet").exists());
+    assert!(root.join("dt=b/part-0.parquet").exists());
+    common::assert_sql_error(
+        &context,
+        &format!("TRUNCATE TABLE {TABLE_NAME} PARTITION (dt = 'zzz')"),
+        "does not exist",
+    )
+    .await;
+
+    common::exec(&context, &format!("TRUNCATE TABLE {TABLE_NAME}")).await;
+    assert!(!root.join("dt=b/part-0.parquet").exists());
+    assert!(root.join("dt=a").is_dir() && root.join("dt=b").is_dir());
+    assert!(root.join("tmp/unknown/part-0.parquet").exists());
+    assert!(server.create_partitions_calls().is_empty());
+}
+
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_truncate_an_unpartitioned_format_table() {
+    let temp_dir = tempfile::tempdir().unwrap();
+    let schema = Schema::builder()
+        .column("id", DataType::BigInt(BigIntType::new()))
+        .option("type", "format-table")
+        .option("file.format", "parquet")
+        .build()
+        .unwrap();
+    let (_server, context) = setup_rest_table(&temp_dir, schema).await;
+    let root = temp_dir.path();
+    write_ids(root, &[1]);
+    write_ids_file(&root.join("_temporary/0/part-1.parquet"), &[2]);
+
+    common::assert_sql_error(
+        &context,
+        &format!("TRUNCATE TABLE {TABLE_NAME} PARTITION (id = 1)"),
+        "not partitioned",
+    )
+    .await;
+    assert!(root.join("part-0.parquet").exists());
+
+    common::exec(&context, &format!("TRUNCATE TABLE {TABLE_NAME}")).await;
+    assert!(!root.join("part-0.parquet").exists());
+    assert!(root.join("_temporary/0/part-1.parquet").exists());
+}
+
+#[tokio::test]
+async fn test_a_snapshot_commit_refuses_to_truncate_a_format_table() {
+    let temp_dir = tempfile::tempdir().unwrap();
+    let (server, _context) =
+        setup_rest_table(&temp_dir, format_table_schema(&[("dt", 
varchar())])).await;
+    let catalog = rest_catalog(&server).await;
+    let table = catalog
+        .get_table(&Identifier::new(DATABASE, TABLE))
+        .await
+        .unwrap();
+
+    // Without snapshots such a commit finds nothing to delete, and used to 
report success.
+    let commit = paimon::table::WriteBuilder::new(&table).new_commit();
+    let error = commit.truncate_table().await.unwrap_err();
+    assert!(error.to_string().contains("snapshot commit"), "{error}");
+    assert!(commit
+        .truncate_partitions(vec![HashMap::from([("dt".to_string(), None)])])
+        .await
+        .is_err());
+}
+
 /// `SQLContext::sql` futures have to stay `Send` for callers that box or 
spawn them; this stops
 /// compiling when a stream over borrowed items anywhere below a statement 
takes that away.
 #[allow(dead_code)]
diff --git a/crates/paimon/src/table/format_partition_truncate.rs 
b/crates/paimon/src/table/format_partition_truncate.rs
new file mode 100644
index 00000000..f30a2ba8
--- /dev/null
+++ b/crates/paimon/src/table/format_partition_truncate.rs
@@ -0,0 +1,101 @@
+// 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.
+
+//! Deletes the data files of a Format Table for TRUNCATE TABLE.
+
+use std::collections::HashMap;
+
+use super::format_partition::FormatTablePartitionPaths;
+use super::format_table_scan::list_format_table_files;
+use super::Table;
+use crate::spec::CoreOptions;
+
+/// Empties a Format Table or its partitions, keeping every directory and 
catalog registration.
+/// Mirrors the file side of Java `FormatTableCommit#truncateTable` and 
`#truncatePartitions`.
+#[derive(Debug)]
+pub struct FormatTableTruncator<'a> {
+    table: &'a Table,
+    table_path: String,
+    partition_paths: FormatTablePartitionPaths,
+    default_partition_name: String,
+}
+
+impl<'a> FormatTableTruncator<'a> {
+    pub fn new(table: &'a Table) -> Self {
+        let options = CoreOptions::new(table.schema().options());
+        let table_path = options
+            .path()
+            .unwrap_or_else(|| table.location())
+            .trim_end_matches('/')
+            .to_string();
+        let partition_paths = FormatTablePartitionPaths::new(
+            table.schema().partition_keys().iter().cloned(),
+            options.format_table_partition_only_value_in_path(),
+        );
+        let default_partition_name = 
options.partition_default_name().to_string();
+        Self {
+            table,
+            table_path,
+            partition_paths,
+            default_partition_name,
+        }
+    }
+
+    /// Deletes the data files of an unpartitioned table.
+    pub async fn truncate_unpartitioned(&self) -> crate::Result<()> {
+        if !self.table.schema().partition_keys().is_empty() {
+            return Err(crate::Error::DataInvalid {
+                message: format!(
+                    "Format Table {} is partitioned, so it is truncated 
partition by partition",
+                    self.table.identifier().full_name()
+                ),
+                source: None,
+            });
+        }
+        self.delete_data_files(&self.table_path).await
+    }
+
+    /// Deletes the data files of one complete partition in its directory 
below the table.
+    pub async fn truncate_partition(&self, spec: &HashMap<String, String>) -> 
crate::Result<()> {
+        let relative_path = self.partition_paths.relative_path(spec)?;
+        self.delete_data_files(&format!("{}/{relative_path}", self.table_path))
+            .await
+    }
+
+    /// The complete partitions whose directories sit below the table 
directory, for a table that
+    /// does not keep its partitions in a catalog.
+    pub async fn discover_partitions(&self) -> 
crate::Result<Vec<HashMap<String, String>>> {
+        self.partition_paths
+            .discover(
+                self.table.file_io(),
+                &self.table_path,
+                &self.default_partition_name,
+            )
+            .await
+    }
+
+    /// Every non-hidden file below `directory`, whatever its extension: a 
Java reader reads those
+    /// too. Staging trees such as `_temporary` hold another writer's 
uncommitted output and stay.
+    async fn delete_data_files(&self, directory: &str) -> crate::Result<()> {
+        let file_io = self.table.file_io();
+        let files = list_format_table_files(file_io, directory, 0, 
None).await?;
+        for file in files.iter().filter(|file| !file.is_dir) {
+            file_io.delete_file(&file.path).await?;
+        }
+        Ok(())
+    }
+}
diff --git a/crates/paimon/src/table/format_table_scan.rs 
b/crates/paimon/src/table/format_table_scan.rs
index b002e88a..7297b1c5 100644
--- a/crates/paimon/src/table/format_table_scan.rs
+++ b/crates/paimon/src/table/format_table_scan.rs
@@ -457,6 +457,23 @@ pub(crate) async fn list_format_table_data_files(
     root: &str,
     partition_levels_below_root: usize,
     format_extension: &str,
+) -> crate::Result<Vec<crate::io::FileStatus>> {
+    list_format_table_files(
+        file_io,
+        root,
+        partition_levels_below_root,
+        Some(format_extension),
+    )
+    .await
+}
+
+/// As [`list_format_table_data_files`], with every non-hidden file when 
`format_extension` is
+/// `None`: the files Java treats as table data, whatever their names end with.
+pub(crate) async fn list_format_table_files(
+    file_io: &crate::io::FileIO,
+    root: &str,
+    partition_levels_below_root: usize,
+    format_extension: Option<&str>,
 ) -> crate::Result<Vec<crate::io::FileStatus>> {
     let statuses = match file_io.list_status_recursive_stream(root, 
None).await {
         Ok(listing) => collect_listing(listing).await?,
@@ -471,7 +488,8 @@ pub(crate) async fn list_format_table_data_files(
         }
         let is_data_file = 
split_parent_and_file(&status.path).is_some_and(|(_, file_name)| {
             is_format_table_data_file_name(file_name)
-                && file_name.to_ascii_lowercase().ends_with(format_extension)
+                && format_extension
+                    .is_none_or(|extension| 
file_name.to_ascii_lowercase().ends_with(extension))
         });
         if !is_data_file {
             continue;
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index f46c835f..caa9971a 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -45,6 +45,7 @@ mod de_vector_scan;
 mod dedicated_format_file_writer;
 mod format_partition;
 mod format_partition_stats;
+mod format_partition_truncate;
 mod format_read_builder;
 mod format_table_read;
 mod format_table_scan;
@@ -137,6 +138,7 @@ pub use format_partition::{
     format_partition_value, parse_format_partition_value, 
FormatTablePartitionPaths,
 };
 pub use format_partition_stats::FormatTablePartitionStatsCollector;
+pub use format_partition_truncate::FormatTableTruncator;
 #[cfg(feature = "fulltext")]
 pub use full_text_search_builder::FullTextSearchBuilder;
 use futures::stream::BoxStream;
diff --git a/crates/paimon/src/table/table_commit.rs 
b/crates/paimon/src/table/table_commit.rs
index a77267a7..2b747483 100644
--- a/crates/paimon/src/table/table_commit.rs
+++ b/crates/paimon/src/table/table_commit.rs
@@ -597,6 +597,7 @@ impl TableCommit {
     ) -> Result<()> {
         // A commit validates against the existing snapshot.
         
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
+        self.ensure_not_format_table()?;
         self.table.ensure_not_branch_reference_for_write()?;
 
         if partitions.is_empty() {
@@ -678,6 +679,7 @@ impl TableCommit {
     ) -> Result<()> {
         // A commit validates against the existing snapshot.
         
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
+        self.ensure_not_format_table()?;
         self.table.ensure_not_branch_reference_for_write()?;
 
         self.try_commit(
@@ -698,6 +700,21 @@ impl TableCommit {
         .await
     }
 
+    /// A Format Table has no snapshots, so an overwrite commit would find 
nothing to delete and
+    /// report success; see [`crate::table::FormatTableTruncator`].
+    fn ensure_not_format_table(&self) -> Result<()> {
+        if CoreOptions::new(self.table.schema().options()).is_format_table() {
+            return Err(crate::Error::Unsupported {
+                message: format!(
+                    "Format Table {} cannot be truncated through a snapshot 
commit; use \
+                     FormatTableTruncator or TRUNCATE TABLE in SQL",
+                    self.table.identifier().full_name()
+                ),
+            });
+        }
+        Ok(())
+    }
+
     /// Abort a prepared commit by deleting newly written data, changelog and 
index files.
     ///
     /// Deletion is best-effort and mirrors Python `FileStoreCommit.abort`: 
missing
diff --git a/docs/src/sql.md b/docs/src/sql.md
index 7d35a05a..f35345dd 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -1023,6 +1023,28 @@ listings and footer reads alike. Set it for the session:
 SET 'paimon.format-table.statistics.parallelism' = '16';
 ```
 
+### TRUNCATE TABLE
+
+```sql
+TRUNCATE TABLE paimon.my_db.events;
+TRUNCATE TABLE paimon.my_db.events PARTITION (dt = '2024-01-01');
+```
+
+Unlike the statements above, this works on any Format Table, including one 
that discovers
+its partitions from the directory layout. It deletes data files only: 
partition directories
+and catalog registrations stay. Every non-hidden file in a partition directory 
is deleted,
+whatever its extension, because other engines read those files too; staging 
entries such as
+`_temporary` are left alone.
+
+With catalog-managed partitions, the statement empties the registered 
partitions and
+reports zero statistics for each of them, replacing what the catalog holds. A 
directory
+nobody registered is not part of the table and is not touched. Without them, 
it empties the
+partition directories found below the table, and an unpartitioned table loses 
the data
+files in its table directory. `PARTITION (...)` must give values for a leading 
run of the
+partition keys, as in `ANALYZE TABLE`, but a column named without a value is 
rejected
+rather than read as every value. It is an error when no partition matches. A 
selected partition at a custom location fails the statement before
+anything is deleted.
+
 ## Procedures
 
 Use `CALL` to invoke built-in procedures. All procedures are under the `sys` 
namespace.

Reply via email to