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 152caa69 fix(table): refuse row-level DML on a format table (#866)
152caa69 is described below

commit 152caa697b902549c2ebe301005c841a75f72bd1
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Fri Sep 18 13:09:19 2026 +0800

    fix(table): refuse row-level DML on a format table (#866)
---
 .../datafusion/tests/rest_format_table_dml.rs      | 232 +++++++++++++++++++++
 crates/paimon/src/table/cow_writer.rs              |  12 ++
 docs/src/sql.md                                    |   3 +
 3 files changed, 247 insertions(+)

diff --git a/crates/integrations/datafusion/tests/rest_format_table_dml.rs 
b/crates/integrations/datafusion/tests/rest_format_table_dml.rs
new file mode 100644
index 00000000..4d08fa80
--- /dev/null
+++ b/crates/integrations/datafusion/tests/rest_format_table_dml.rs
@@ -0,0 +1,232 @@
+// 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.
+
+//! Row-level DML on a Format Table, which the Rust client cannot write.
+
+mod common;
+
+#[path = "../../../paimon/tests/mock_server.rs"]
+mod mock_server;
+
+use std::collections::HashMap;
+use std::path::Path;
+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::{Catalog, FileSystemCatalog, Identifier, RESTCatalog};
+use paimon::spec::{BigIntType, DataType, Schema, VarCharType};
+use paimon::{CatalogOptions, Options};
+use paimon_datafusion::SQLContext;
+use parquet::arrow::ArrowWriter;
+use tempfile::TempDir;
+
+use mock_server::start_mock_server;
+
+const DATABASE: &str = "default";
+const TABLE: &str = "events";
+const TABLE_NAME: &str = "paimon.default.events";
+const WAREHOUSE: &str = "test_warehouse";
+
+/// A Format Table with one file in each of `dt=a` and `dt=b`, whose 
partitions a mock REST
+/// catalog manages. Its data directory is the root of `temp_dir`.
+async fn catalog_managed_format_table(temp_dir: &TempDir) -> SQLContext {
+    let server = start_mock_server(
+        WAREHOUSE.to_string(),
+        temp_dir.path().to_string_lossy().into_owned(),
+        ConfigResponse::new(HashMap::from([(
+            CatalogOptions::PREFIX.to_string(),
+            "mock-test".to_string(),
+        )])),
+        vec![DATABASE.to_string()],
+    )
+    .await;
+    server.add_table_with_schema(
+        DATABASE,
+        TABLE,
+        format_table_schema(true),
+        &format!("file://{}", temp_dir.path().display()),
+    );
+    server.set_table_external(DATABASE, TABLE, false);
+    server.set_table_partitions(
+        DATABASE,
+        TABLE,
+        ["a", "b"]
+            .map(|value| HashMap::from([("dt".to_string(), 
value.to_string())]))
+            .to_vec(),
+    );
+    write_ids(&temp_dir.path().join("dt=a"), &[1, 2]);
+    write_ids(&temp_dir.path().join("dt=b"), &[3]);
+
+    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();
+    context
+}
+
+/// The same table with its partitions discovered from the directory layout, 
in a filesystem
+/// catalog. Its data directory is the table directory below the warehouse.
+async fn directory_partitioned_format_table(
+    temp_dir: &TempDir,
+) -> (std::path::PathBuf, SQLContext) {
+    let mut options = Options::new();
+    options.set(
+        CatalogOptions::WAREHOUSE,
+        format!("file:{}", temp_dir.path().display()),
+    );
+    let catalog = Arc::new(FileSystemCatalog::new(options).unwrap());
+    catalog
+        .create_database(DATABASE, false, Default::default())
+        .await
+        .unwrap();
+    catalog
+        .create_table(
+            &Identifier::new(DATABASE, TABLE),
+            format_table_schema(false),
+            false,
+        )
+        .await
+        .unwrap();
+    let table_dir = temp_dir.path().join(format!("{DATABASE}.db")).join(TABLE);
+    write_ids(&table_dir.join("dt=a"), &[1, 2]);
+    write_ids(&table_dir.join("dt=b"), &[3]);
+    let mut context = SQLContext::new();
+    context.register_catalog("paimon", catalog).await.unwrap();
+    (table_dir, context)
+}
+
+fn format_table_schema(catalog_managed: bool) -> Schema {
+    let mut builder = Schema::builder()
+        .column("dt", DataType::VarChar(VarCharType::new(255).unwrap()))
+        .column("id", DataType::BigInt(BigIntType::new()))
+        .partition_keys(["dt"])
+        .option("type", "format-table")
+        .option("file.format", "parquet");
+    if catalog_managed {
+        builder = builder.option("metastore.partitioned-table", "true");
+    }
+    builder.build().unwrap()
+}
+
+fn write_ids(directory: &Path, ids: &[i64]) {
+    std::fs::create_dir_all(directory).unwrap();
+    let schema = Arc::new(ArrowSchema::new(vec![Field::new(
+        "id",
+        ArrowDataType::Int64,
+        true,
+    )]));
+    let batch = RecordBatch::try_new(
+        Arc::clone(&schema),
+        vec![Arc::new(Int64Array::from(ids.to_vec()))],
+    )
+    .unwrap();
+    let file = 
std::fs::File::create(directory.join("part-0.parquet")).unwrap();
+    let mut writer = ArrowWriter::try_new(file, schema, None).unwrap();
+    writer.write(&batch).unwrap();
+    writer.close().unwrap();
+}
+
+/// Every file below `root`, so a statement that wrote one cannot pass as a 
statement that failed.
+fn files(root: &Path) -> Vec<String> {
+    let mut paths = Vec::new();
+    let mut frontier = vec![root.to_path_buf()];
+    while let Some(directory) = frontier.pop() {
+        for entry in std::fs::read_dir(&directory).unwrap() {
+            let path = entry.unwrap().path();
+            if path.is_dir() {
+                frontier.push(path);
+            } else {
+                paths.push(
+                    path.strip_prefix(root)
+                        .unwrap()
+                        .to_string_lossy()
+                        .into_owned(),
+                );
+            }
+        }
+    }
+    paths.sort();
+    paths
+}
+
+async fn ids(context: &SQLContext, table_name: &str) -> Vec<i64> {
+    let batches = context
+        .sql(&format!("SELECT id FROM {table_name}"))
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+    let mut values = Vec::new();
+    for batch in batches {
+        let column = batch
+            .column(0)
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+        values.extend(column.iter().flatten());
+    }
+    values.sort_unstable();
+    values
+}
+
+fn statements(table_name: &str) -> [String; 3] {
+    [
+        format!("DELETE FROM {table_name} WHERE id = 1"),
+        format!("UPDATE {table_name} SET id = 7 WHERE id = 1"),
+        format!(
+            "MERGE INTO {table_name} t USING (SELECT 1 AS id) s ON t.id = s.id 
\
+             WHEN MATCHED THEN UPDATE SET id = 7"
+        ),
+    ]
+}
+
+#[cfg(not(windows))]
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn test_row_level_dml_is_refused_on_a_catalog_managed_format_table() {
+    let temp_dir = tempfile::tempdir().unwrap();
+    let context = catalog_managed_format_table(&temp_dir).await;
+    let seeded = files(temp_dir.path());
+
+    for statement in statements(TABLE_NAME) {
+        // A rewrite used to leave Paimon bucket files behind, which the scan 
then read as data.
+        common::assert_sql_error(&context, &statement, "format table").await;
+        assert_eq!(files(temp_dir.path()), seeded, "{statement}");
+        assert_eq!(ids(&context, TABLE_NAME).await, [1, 2, 3], "{statement}");
+    }
+}
+
+#[cfg(not(windows))]
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn 
test_row_level_dml_is_refused_on_a_format_table_without_catalog_managed_partitions()
 {
+    let temp_dir = tempfile::tempdir().unwrap();
+    let (table_dir, context) = 
directory_partitioned_format_table(&temp_dir).await;
+    let table_name = format!("paimon.{DATABASE}.{TABLE}");
+    let seeded = files(&table_dir);
+
+    for statement in statements(&table_name) {
+        common::assert_sql_error(&context, &statement, "format table").await;
+        assert_eq!(files(&table_dir), seeded, "{statement}");
+        assert_eq!(ids(&context, &table_name).await, [1, 2, 3], "{statement}");
+    }
+}
diff --git a/crates/paimon/src/table/cow_writer.rs 
b/crates/paimon/src/table/cow_writer.rs
index d0913e26..6a07f49d 100644
--- a/crates/paimon/src/table/cow_writer.rs
+++ b/crates/paimon/src/table/cow_writer.rs
@@ -104,6 +104,18 @@ impl CopyOnWriteMergeWriter {
             });
         }
 
+        // A rewrite would write Paimon data files into the directories a 
Format Table reads, and
+        // the commit that should publish them has no snapshot to write.
+        if core_options.is_format_table() {
+            return Err(crate::Error::Unsupported {
+                message: format!(
+                    "Rewriting the files of format table {} is not supported 
by the Rust client \
+                     yet, so UPDATE, DELETE and MERGE INTO are refused",
+                    table.identifier().full_name()
+                ),
+            });
+        }
+
         let partition_keys = schema.partition_keys();
         let blob_descriptor_fields = core_options.blob_descriptor_fields();
         for col in &update_columns {
diff --git a/docs/src/sql.md b/docs/src/sql.md
index 76b56034..7d35a05a 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -913,6 +913,9 @@ A partition the catalog holds at a custom location (the 
partition option `path`)
 read from that location yet: a scan that reaches it fails rather than reading 
the table
 directory in its place.
 
+No statement of this client writes rows into a Format Table. `INSERT`, 
`UPDATE`, `DELETE`
+and `MERGE INTO` are all refused, whether or not the catalog manages the 
partitions.
+
 ### SHOW PARTITIONS
 
 ```sql

Reply via email to