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 d57cac1  feat: support rowkind.field write semantics (#507)
d57cac1 is described below

commit d57cac12301cf8f269cd85e1fb9d883712a95797
Author: Pandas <[email protected]>
AuthorDate: Mon Jul 13 13:09:55 2026 +0800

    feat: support rowkind.field write semantics (#507)
---
 crates/paimon/src/spec/core_options.rs        |  45 ++++++
 crates/paimon/src/spec/mod.rs                 |   3 +
 crates/paimon/src/spec/row_kind.rs            |  83 ++++++++++
 crates/paimon/src/spec/row_kind_filter.rs     |  79 +++++++++
 crates/paimon/src/spec/schema.rs              | 160 ++++++++++++++++++-
 crates/paimon/src/table/mod.rs                |   1 +
 crates/paimon/src/table/row_kind_generator.rs | 105 ++++++++++++
 crates/paimon/src/table/table_write.rs        | 117 ++++++++++++--
 crates/paimon/tests/common/rowkind_helpers.rs | 206 ++++++++++++++++++++++++
 crates/paimon/tests/rowkind_field_test.rs     | 222 ++++++++++++++++++++++++++
 10 files changed, 1006 insertions(+), 15 deletions(-)

diff --git a/crates/paimon/src/spec/core_options.rs 
b/crates/paimon/src/spec/core_options.rs
index b77ab95..1e1df3e 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -71,6 +71,13 @@ pub(crate) const 
DISABLE_ALTER_COLUMN_NULL_TO_NOT_NULL_OPTION: &str =
 const MERGE_ENGINE_OPTION: &str = "merge-engine";
 const CHANGELOG_PRODUCER_OPTION: &str = "changelog-producer";
 const ROWKIND_FIELD_OPTION: &str = "rowkind.field";
+const IGNORE_DELETE_OPTION: &str = "ignore-delete";
+const IGNORE_UPDATE_BEFORE_OPTION: &str = "ignore-update-before";
+const IGNORE_DELETE_FALLBACK_KEYS: &[&str] = &[
+    "first-row.ignore-delete",
+    "deduplicate.ignore-delete",
+    "partial-update.ignore-delete",
+];
 const DEFAULT_COMMIT_MAX_RETRIES: u32 = 10;
 const DEFAULT_COMMIT_TIMEOUT_MS: u64 = 120_000;
 const DEFAULT_COMMIT_MIN_RETRY_WAIT_MS: u64 = 1_000;
@@ -384,6 +391,25 @@ impl<'a> CoreOptions<'a> {
         self.options.get(ROWKIND_FIELD_OPTION).map(String::as_str)
     }
 
+    /// Whether to ignore delete records (and all retracts when used by 
`RowKindFilter`).
+    pub fn ignore_delete(&self) -> bool {
+        for key in
+            
std::iter::once(IGNORE_DELETE_OPTION).chain(IGNORE_DELETE_FALLBACK_KEYS.iter().copied())
+        {
+            if let Some(v) = self.options.get(key) {
+                return v.eq_ignore_ascii_case("true");
+            }
+        }
+        false
+    }
+
+    /// Whether to ignore update-before records at write time.
+    pub fn ignore_update_before(&self) -> bool {
+        self.options
+            .get(IGNORE_UPDATE_BEFORE_OPTION)
+            .is_some_and(|v| v.eq_ignore_ascii_case("true"))
+    }
+
     pub fn data_evolution_enabled(&self) -> bool {
         self.options
             .get(DATA_EVOLUTION_ENABLED_OPTION)
@@ -1637,4 +1663,23 @@ mod tests {
         let options = HashMap::from([(SCAN_SNAPSHOT_ID_OPTION.to_string(), 
"1".to_string())]);
         assert!(CoreOptions::new(&options).validate_scan_options().is_ok());
     }
+
+    #[test]
+    fn ignore_delete_reads_primary_and_fallback_keys() {
+        let fallback =
+            HashMap::from([("deduplicate.ignore-delete".to_string(), 
"true".to_string())]);
+        let opts = CoreOptions::new(&fallback);
+        assert!(opts.ignore_delete());
+
+        let primary = HashMap::from([("ignore-delete".to_string(), 
"true".to_string())]);
+        let opts = CoreOptions::new(&primary);
+        assert!(opts.ignore_delete());
+    }
+
+    #[test]
+    fn ignore_update_before_defaults_false() {
+        let options = HashMap::new();
+        let opts = CoreOptions::new(&options);
+        assert!(!opts.ignore_update_before());
+    }
 }
diff --git a/crates/paimon/src/spec/mod.rs b/crates/paimon/src/spec/mod.rs
index 4614e89..13f9c3d 100644
--- a/crates/paimon/src/spec/mod.rs
+++ b/crates/paimon/src/spec/mod.rs
@@ -110,3 +110,6 @@ pub use partition_statistics::PartitionStatistics;
 
 mod row_kind;
 pub use row_kind::RowKind;
+
+mod row_kind_filter;
+pub(crate) use row_kind_filter::RowKindFilter;
diff --git a/crates/paimon/src/spec/row_kind.rs 
b/crates/paimon/src/spec/row_kind.rs
index 1e3ee5d..d6b8965 100644
--- a/crates/paimon/src/spec/row_kind.rs
+++ b/crates/paimon/src/spec/row_kind.rs
@@ -48,4 +48,87 @@ impl RowKind {
     pub fn is_add(&self) -> bool {
         matches!(self, RowKind::Insert | RowKind::UpdateAfter)
     }
+
+    /// Byte value for serialization, matching Java `toByteValue()`.
+    pub fn to_value(self) -> i8 {
+        self as i8
+    }
+
+    /// Short string form: `"+I"`, `"-U"`, `"+U"`, `"-D"`.
+    pub fn short_string(self) -> &'static str {
+        match self {
+            RowKind::Insert => "+I",
+            RowKind::UpdateBefore => "-U",
+            RowKind::UpdateAfter => "+U",
+            RowKind::Delete => "-D",
+        }
+    }
+
+    /// Parse from short string; case-insensitive (Java 
`RowKind.fromShortString`).
+    pub fn from_short_string(value: &str) -> crate::Result<Self> {
+        if value.len() != 2 {
+            return Err(crate::Error::DataInvalid {
+                message: format!("Unsupported short string '{value}' for row 
kind"),
+                source: None,
+            });
+        }
+        let bytes = value.as_bytes();
+        let sign = bytes[0].to_ascii_uppercase();
+        let letter = bytes[1].to_ascii_uppercase();
+        match (sign, letter) {
+            (b'+', b'I') => Ok(RowKind::Insert),
+            (b'-', b'U') => Ok(RowKind::UpdateBefore),
+            (b'+', b'U') => Ok(RowKind::UpdateAfter),
+            (b'-', b'D') => Ok(RowKind::Delete),
+            _ => Err(crate::Error::DataInvalid {
+                message: format!("Unsupported short string '{value}' for row 
kind"),
+                source: None,
+            }),
+        }
+    }
+
+    /// Whether this is `UPDATE_BEFORE` or `DELETE` (Java `isRetract()`).
+    pub fn is_retract(self) -> bool {
+        matches!(self, RowKind::UpdateBefore | RowKind::Delete)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::RowKind;
+
+    #[test]
+    fn from_short_string_accepts_case_insensitive_tokens() {
+        assert_eq!(RowKind::from_short_string("+i").unwrap(), RowKind::Insert);
+        assert_eq!(
+            RowKind::from_short_string("-u").unwrap(),
+            RowKind::UpdateBefore
+        );
+        assert_eq!(
+            RowKind::from_short_string("+U").unwrap(),
+            RowKind::UpdateAfter
+        );
+        assert_eq!(RowKind::from_short_string("-D").unwrap(), RowKind::Delete);
+    }
+
+    #[test]
+    fn from_short_string_rejects_invalid() {
+        assert!(RowKind::from_short_string("INSERT").is_err());
+        assert!(RowKind::from_short_string("").is_err());
+    }
+
+    #[test]
+    fn short_string_round_trip() {
+        for kind in [
+            RowKind::Insert,
+            RowKind::UpdateBefore,
+            RowKind::UpdateAfter,
+            RowKind::Delete,
+        ] {
+            assert_eq!(
+                RowKind::from_short_string(kind.short_string()).unwrap(),
+                kind
+            );
+        }
+    }
 }
diff --git a/crates/paimon/src/spec/row_kind_filter.rs 
b/crates/paimon/src/spec/row_kind_filter.rs
new file mode 100644
index 0000000..78364cc
--- /dev/null
+++ b/crates/paimon/src/spec/row_kind_filter.rs
@@ -0,0 +1,79 @@
+// 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.
+
+//! Filter row kinds at write time (`ignore-delete`, `ignore-update-before`).
+//!
+//! Reference: Java `org.apache.paimon.utils.RowKindFilter`.
+
+use crate::spec::{CoreOptions, RowKind};
+
+pub struct RowKindFilter {
+    ignore_all_retracts: bool,
+    ignore_update_before: bool,
+}
+
+impl RowKindFilter {
+    pub fn of(options: CoreOptions<'_>) -> Option<Self> {
+        let ignore_all_retracts = options.ignore_delete();
+        let ignore_update_before = options.ignore_update_before();
+        if !ignore_all_retracts && !ignore_update_before {
+            return None;
+        }
+        Some(Self {
+            ignore_all_retracts,
+            ignore_update_before,
+        })
+    }
+
+    pub fn test(&self, row_kind: RowKind) -> bool {
+        match row_kind {
+            RowKind::Delete if self.ignore_all_retracts => false,
+            RowKind::UpdateBefore if self.ignore_update_before || 
self.ignore_all_retracts => false,
+            _ => true,
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::collections::HashMap;
+
+    #[test]
+    fn ignore_delete_drops_delete_and_update_before() {
+        let filter = RowKindFilter::of(CoreOptions::new(&HashMap::from([(
+            "ignore-delete".to_string(),
+            "true".to_string(),
+        )])))
+        .unwrap();
+        assert!(!filter.test(RowKind::Delete));
+        assert!(!filter.test(RowKind::UpdateBefore));
+        assert!(filter.test(RowKind::Insert));
+    }
+
+    #[test]
+    fn ignore_update_before_drops_only_update_before() {
+        let filter = RowKindFilter::of(CoreOptions::new(&HashMap::from([(
+            "ignore-update-before".to_string(),
+            "true".to_string(),
+        )])))
+        .unwrap();
+        assert!(filter.test(RowKind::Delete));
+        assert!(!filter.test(RowKind::UpdateBefore));
+        assert!(filter.test(RowKind::Insert));
+    }
+}
diff --git a/crates/paimon/src/spec/schema.rs b/crates/paimon/src/spec/schema.rs
index 626889e..c00d6de 100644
--- a/crates/paimon/src/spec/schema.rs
+++ b/crates/paimon/src/spec/schema.rs
@@ -16,11 +16,11 @@
 // under the License.
 
 use crate::spec::core_options::{
-    first_row_supports_changelog_producer, CoreOptions, 
BLOB_DESCRIPTOR_FIELD_OPTION,
-    BLOB_FIELD_OPTION, BLOB_VIEW_FIELD_OPTION, BUCKET_KEY_OPTION, 
QUERY_AUTH_ENABLED_OPTION,
-    SEQUENCE_FIELD_OPTION,
+    first_row_supports_changelog_producer, ChangelogProducer, CoreOptions, 
MergeEngine,
+    BLOB_DESCRIPTOR_FIELD_OPTION, BLOB_FIELD_OPTION, BLOB_VIEW_FIELD_OPTION, 
BUCKET_KEY_OPTION,
+    QUERY_AUTH_ENABLED_OPTION, SEQUENCE_FIELD_OPTION,
 };
-use crate::spec::types::{ArrayType, DataType, MapType, MultisetType, RowType};
+use crate::spec::types::{ArrayType, DataType, MapType, MultisetType, RowType, 
VarCharType};
 use crate::spec::{
     remove_field_scoped_options, rename_field_scoped_options, 
AggregationConfig, BlobType,
     ColumnMove, ColumnMoveType, PartialUpdateConfig,
@@ -470,6 +470,11 @@ impl TableSchema {
         AggregationConfig::new(&new_schema.options)
             .validate_create_mode(&new_schema.primary_keys, 
&new_schema.fields)?;
         Schema::validate_first_row_changelog_producer(&new_schema.options)?;
+        Schema::validate_rowkind_field(
+            &new_schema.options,
+            &new_schema.primary_keys,
+            &new_schema.fields,
+        )?;
         Ok(new_schema)
     }
 
@@ -910,6 +915,7 @@ impl Schema {
         
PartialUpdateConfig::new(&options).validate_create_mode(!primary_keys.is_empty())?;
         AggregationConfig::new(&options).validate_create_mode(&primary_keys, 
&fields)?;
         Self::validate_first_row_changelog_producer(&options)?;
+        Self::validate_rowkind_field(&options, &primary_keys, &fields)?;
 
         Ok(Self {
             fields,
@@ -1270,6 +1276,60 @@ impl Schema {
         })
     }
 
+    fn validate_rowkind_field(
+        options: &HashMap<String, String>,
+        primary_keys: &[String],
+        fields: &[DataField],
+    ) -> crate::Result<()> {
+        let core = CoreOptions::new(options);
+        let Some(field_name) = core.rowkind_field() else {
+            return Ok(());
+        };
+
+        if primary_keys.is_empty() {
+            return Err(crate::Error::ConfigInvalid {
+                message: "rowkind.field requires a primary-key 
table".to_string(),
+            });
+        }
+
+        let merge_engine = core
+            .merge_engine()
+            .map_err(Self::options_error_to_config_invalid)?;
+        if merge_engine != MergeEngine::Deduplicate {
+            return Err(crate::Error::ConfigInvalid {
+                message: "rowkind.field only supports 
merge-engine=deduplicate".to_string(),
+            });
+        }
+
+        let producer = core
+            .try_changelog_producer()
+            .map_err(Self::options_error_to_config_invalid)?;
+        if producer == ChangelogProducer::Input {
+            return Err(crate::Error::ConfigInvalid {
+                message: "rowkind.field cannot be used with 
changelog-producer=input".to_string(),
+            });
+        }
+
+        let Some(field) = fields.iter().find(|f| f.name() == field_name) else {
+            return Err(crate::Error::ConfigInvalid {
+                message: format!("Rowkind field '{field_name}' can not be 
found in table schema."),
+            });
+        };
+
+        if !matches!(
+            field.data_type(),
+            DataType::VarChar(v) if v.length() == VarCharType::MAX_LENGTH
+        ) {
+            return Err(crate::Error::ConfigInvalid {
+                message: format!(
+                    "rowkind.field '{field_name}' must be STRING (unbounded 
VARCHAR) type"
+                ),
+            });
+        }
+
+        Ok(())
+    }
+
     fn options_error_to_config_invalid(error: crate::Error) -> crate::Error {
         match error {
             crate::Error::Unsupported { message } => 
crate::Error::ConfigInvalid { message },
@@ -2849,4 +2909,96 @@ mod tests {
             panic!("expected Row type");
         }
     }
+
+    #[test]
+    fn rowkind_field_requires_primary_key_table() {
+        let err = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .column("op", DataType::VarChar(VarCharType::string_type()))
+            .option("rowkind.field", "op")
+            .build()
+            .unwrap_err();
+        assert!(
+            matches!(err, crate::Error::ConfigInvalid { ref message } if 
message.contains("rowkind.field")),
+            "got {err:?}"
+        );
+    }
+
+    #[test]
+    fn rowkind_field_rejects_non_deduplicate_merge_engine() {
+        let err = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .column("op", DataType::VarChar(VarCharType::string_type()))
+            .primary_key(["id"])
+            .option("merge-engine", "partial-update")
+            .option("rowkind.field", "op")
+            .build()
+            .unwrap_err();
+        assert!(
+            matches!(err, crate::Error::ConfigInvalid { ref message }
+                if message.contains("deduplicate")),
+            "got {err:?}"
+        );
+    }
+
+    #[test]
+    fn rowkind_field_rejects_changelog_producer_input() {
+        let err = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .column("op", DataType::VarChar(VarCharType::string_type()))
+            .primary_key(["id"])
+            .option("changelog-producer", "input")
+            .option("rowkind.field", "op")
+            .build()
+            .unwrap_err();
+        assert!(
+            matches!(err, crate::Error::ConfigInvalid { ref message }
+                if message.contains("changelog-producer=input")),
+            "got {err:?}"
+        );
+    }
+
+    #[test]
+    fn rowkind_field_rejects_non_string_field() {
+        let err = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .column("op", DataType::Int(IntType::new()))
+            .primary_key(["id"])
+            .option("rowkind.field", "op")
+            .build()
+            .unwrap_err();
+        assert!(
+            matches!(err, crate::Error::ConfigInvalid { ref message }
+                if message.contains("STRING")),
+            "got {err:?}"
+        );
+    }
+
+    #[test]
+    fn rowkind_field_rejects_missing_field() {
+        let err = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .column("value", DataType::Int(IntType::new()))
+            .primary_key(["id"])
+            .option("rowkind.field", "op")
+            .build()
+            .unwrap_err();
+        assert!(
+            matches!(err, crate::Error::ConfigInvalid { ref message }
+                if message.contains("can not be found")),
+            "got {err:?}"
+        );
+    }
+
+    #[test]
+    fn rowkind_field_accepts_valid_deduplicate_table() {
+        Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .column("value", DataType::Int(IntType::new()))
+            .column("op", DataType::VarChar(VarCharType::string_type()))
+            .primary_key(["id"])
+            .option("rowkind.field", "op")
+            .build()
+            .unwrap();
+    }
 }
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index e454e98..5a7030a 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -60,6 +60,7 @@ mod read_builder;
 pub mod referenced_files;
 pub(crate) mod rest_env;
 pub(crate) mod row_id_predicate;
+mod row_kind_generator;
 mod scan_trace;
 pub(crate) mod schema_manager;
 pub(crate) mod snapshot_commit;
diff --git a/crates/paimon/src/table/row_kind_generator.rs 
b/crates/paimon/src/table/row_kind_generator.rs
new file mode 100644
index 0000000..07e8985
--- /dev/null
+++ b/crates/paimon/src/table/row_kind_generator.rs
@@ -0,0 +1,105 @@
+// 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.
+
+//! Parse row kind from a user STRING column (`rowkind.field` table option).
+//!
+//! Reference: Java `org.apache.paimon.table.sink.RowKindGenerator`.
+
+use arrow_array::{Array, RecordBatch, StringArray};
+
+use crate::spec::{RowKind, TableSchema};
+
+pub struct RowKindGenerator {
+    index: usize,
+}
+
+impl RowKindGenerator {
+    pub fn create(schema: &TableSchema, field_name: &str) -> 
crate::Result<Self> {
+        let index = schema
+            .fields()
+            .iter()
+            .position(|f| f.name() == field_name)
+            .ok_or_else(|| crate::Error::DataInvalid {
+                message: format!(
+                    "Can not find rowkind {field_name} in table schema: {:?}",
+                    schema.fields().iter().map(|f| 
f.name()).collect::<Vec<_>>()
+                ),
+                source: None,
+            })?;
+        Ok(Self { index })
+    }
+
+    pub fn generate(&self, batch: &RecordBatch, row: usize) -> 
crate::Result<RowKind> {
+        let col = batch.column(self.index);
+        if col.is_null(row) {
+            return Err(crate::Error::DataInvalid {
+                message: "Row kind cannot be null.".to_string(),
+                source: None,
+            });
+        }
+        let strings = col.as_any().downcast_ref::<StringArray>().ok_or_else(|| 
{
+            crate::Error::DataInvalid {
+                message: "rowkind.field column must be 
Utf8/String".to_string(),
+                source: None,
+            }
+        })?;
+        RowKind::from_short_string(strings.value(row))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use std::sync::Arc;
+
+    use arrow_array::{RecordBatch, StringArray};
+    use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema 
as ArrowSchema};
+
+    use crate::spec::{DataType, IntType, RowKind, Schema, TableSchema, 
VarCharType};
+
+    use super::RowKindGenerator;
+
+    fn test_schema(op_col: &str) -> TableSchema {
+        TableSchema::new(
+            0,
+            &Schema::builder()
+                .column("id", DataType::Int(IntType::new()))
+                .column(op_col, DataType::VarChar(VarCharType::string_type()))
+                .primary_key(["id"])
+                .option("rowkind.field", op_col)
+                .build()
+                .unwrap(),
+        )
+    }
+
+    #[test]
+    fn generate_parses_short_string_column() {
+        let schema = test_schema("op");
+        let gen = RowKindGenerator::create(&schema, "op").unwrap();
+        let batch = RecordBatch::try_new(
+            Arc::new(ArrowSchema::new(vec![
+                Arc::new(ArrowField::new("id", ArrowDataType::Int32, false)),
+                Arc::new(ArrowField::new("op", ArrowDataType::Utf8, false)),
+            ])),
+            vec![
+                Arc::new(arrow_array::Int32Array::from(vec![1])),
+                Arc::new(StringArray::from(vec!["-D"])),
+            ],
+        )
+        .unwrap();
+        assert_eq!(gen.generate(&batch, 0).unwrap(), RowKind::Delete);
+    }
+}
diff --git a/crates/paimon/src/table/table_write.rs 
b/crates/paimon/src/table/table_write.rs
index 034e634..375dfa3 100644
--- a/crates/paimon/src/table/table_write.rs
+++ b/crates/paimon/src/table/table_write.rs
@@ -24,7 +24,8 @@ use crate::arrow::build_target_arrow_schema;
 use crate::spec::PartitionComputer;
 use crate::spec::{
     first_row_supports_changelog_producer, BinaryRow, ChangelogProducer, 
CoreOptions, DataField,
-    DataType, MergeEngine, EMPTY_SERIALIZED_ROW, POSTPONE_BUCKET,
+    DataType, MergeEngine, RowKindFilter, EMPTY_SERIALIZED_ROW, 
POSTPONE_BUCKET,
+    VALUE_KIND_FIELD_NAME,
 };
 use crate::table::bucket_assigner::{BucketAssignerEnum, PartitionBucketKey};
 use crate::table::bucket_assigner_constant::ConstantBucketAssigner;
@@ -39,6 +40,7 @@ use crate::table::kv_file_writer::{KeyValueFileWriter, 
KeyValueWriteConfig};
 use crate::table::partition_filter::PartitionFilter;
 use crate::table::postpone_file_writer::{PostponeFileWriter, 
PostponeWriteConfig};
 use crate::table::prepared_files::PreparedFiles;
+use crate::table::row_kind_generator::RowKindGenerator;
 use crate::table::{SnapshotManager, Table, TableScan};
 use crate::Result;
 use arrow_array::RecordBatch;
@@ -121,6 +123,8 @@ pub struct TableWrite {
     vector_file_format: Option<String>,
     /// Whether the table has VECTOR fields requiring dedicated vector files.
     has_dedicated_vector_fields: bool,
+    row_kind_generator: Option<RowKindGenerator>,
+    row_kind_filter: Option<RowKindFilter>,
 }
 
 impl TableWrite {
@@ -278,11 +282,13 @@ impl TableWrite {
             });
         }
 
-        if has_primary_keys && core_options.rowkind_field().is_some() {
-            return Err(crate::Error::Unsupported {
-                message: "KeyValueFileWriter does not support 
rowkind.field".to_string(),
-            });
-        }
+        let row_kind_generator = match core_options.rowkind_field() {
+            Some(field_name) if has_primary_keys => {
+                Some(RowKindGenerator::create(schema, field_name)?)
+            }
+            _ => None,
+        };
+        let row_kind_filter = RowKindFilter::of(core_options);
 
         let target_bucket_row_number = 
core_options.dynamic_bucket_target_row_num();
         let bucket_function_type = core_options.bucket_function_type()?;
@@ -366,6 +372,8 @@ impl TableWrite {
             has_blob_fields,
             vector_file_format,
             has_dedicated_vector_fields,
+            row_kind_generator,
+            row_kind_filter,
         })
     }
 
@@ -430,7 +438,12 @@ impl TableWrite {
             return Ok(());
         }
 
-        let grouped = self.divide_by_partition_bucket(batch).await?;
+        let batch = self.enrich_rowkind_batch(batch)?;
+        if batch.num_rows() == 0 {
+            return Ok(());
+        }
+
+        let grouped = self.divide_by_partition_bucket(&batch).await?;
         for ((partition_bytes, bucket), sub_batch) in grouped {
             self.write_bucket(partition_bytes, bucket, sub_batch)
                 .await?;
@@ -475,16 +488,20 @@ impl TableWrite {
         }
 
         let mut result = Vec::with_capacity(groups.len());
+        let batch_has_value_kind = batch
+            .schema()
+            .column_with_name(VALUE_KIND_FIELD_NAME)
+            .is_some();
         // Cross-partition writers must always include _VALUE_KIND to keep the
         // Arrow schema stable across batches (some batches may have deletes,
         // others may not — KeyValueFileWriter's concat_batches requires a
         // consistent schema).
-        let needs_value_kind =
-            matches!(self.bucket_assigner, 
BucketAssignerEnum::CrossPartition(_))
-                || !output.deletes.is_empty();
+        let needs_value_kind = batch_has_value_kind
+            || matches!(self.bucket_assigner, 
BucketAssignerEnum::CrossPartition(_))
+            || !output.deletes.is_empty();
         for (key, row_indices) in groups {
             let sub_batch = Self::take_rows(batch, &row_indices)?;
-            let sub_batch = if needs_value_kind {
+            let sub_batch = if needs_value_kind && !batch_has_value_kind {
                 Self::add_value_kind_column(&sub_batch, 0)?
             } else {
                 sub_batch
@@ -557,6 +574,84 @@ impl TableWrite {
         })
     }
 
+    fn enrich_rowkind_batch(&self, batch: &RecordBatch) -> Result<RecordBatch> 
{
+        let Some(generator) = &self.row_kind_generator else {
+            return Ok(batch.clone());
+        };
+        if batch
+            .schema()
+            .column_with_name(VALUE_KIND_FIELD_NAME)
+            .is_some()
+        {
+            return Err(crate::Error::DataInvalid {
+                message: "batch must not contain _VALUE_KIND when 
rowkind.field is configured"
+                    .to_string(),
+                source: None,
+            });
+        }
+
+        let mut keep_rows = Vec::new();
+        let mut kinds = Vec::new();
+        for row in 0..batch.num_rows() {
+            let kind = generator.generate(batch, row)?;
+            if let Some(filter) = &self.row_kind_filter {
+                if !filter.test(kind) {
+                    continue;
+                }
+            }
+            keep_rows.push(row);
+            kinds.push(kind.to_value());
+        }
+        if keep_rows.is_empty() {
+            return Ok(RecordBatch::new_empty(batch.schema()));
+        }
+        let filtered = Self::take_rows(batch, &keep_rows)?;
+        Self::add_per_row_value_kind_column(&filtered, kinds)
+    }
+
+    fn add_per_row_value_kind_column(
+        batch: &RecordBatch,
+        value_kinds: Vec<i8>,
+    ) -> Result<RecordBatch> {
+        use arrow_array::Int8Array;
+        use arrow_schema::{DataType as ArrowDataType, Field as ArrowField};
+
+        if batch.num_rows() != value_kinds.len() {
+            return Err(crate::Error::DataInvalid {
+                message: "rowkind batch row count must match value kind array 
length".to_string(),
+                source: None,
+            });
+        }
+        if batch
+            .schema()
+            .column_with_name(VALUE_KIND_FIELD_NAME)
+            .is_some()
+        {
+            return Err(crate::Error::DataInvalid {
+                message: "batch already contains _VALUE_KIND 
column".to_string(),
+                source: None,
+            });
+        }
+
+        let vk_array = Arc::new(Int8Array::from(value_kinds));
+        let vk_field = Arc::new(ArrowField::new(
+            VALUE_KIND_FIELD_NAME,
+            ArrowDataType::Int8,
+            false,
+        ));
+
+        let mut fields = batch.schema().fields().to_vec();
+        let mut columns: Vec<Arc<dyn arrow_array::Array>> = 
batch.columns().to_vec();
+        fields.push(vk_field);
+        columns.push(vk_array);
+
+        let schema = Arc::new(arrow_schema::Schema::new(fields));
+        RecordBatch::try_new(schema, columns).map_err(|e| 
crate::Error::DataInvalid {
+            message: format!("Failed to add per-row _VALUE_KIND column: {e}"),
+            source: None,
+        })
+    }
+
     /// Write a batch directly to the writer for the given (partition, bucket).
     async fn write_bucket(
         &mut self,
diff --git a/crates/paimon/tests/common/rowkind_helpers.rs 
b/crates/paimon/tests/common/rowkind_helpers.rs
new file mode 100644
index 0000000..4ff3c3a
--- /dev/null
+++ b/crates/paimon/tests/common/rowkind_helpers.rs
@@ -0,0 +1,206 @@
+// 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.
+
+//! Minimal helpers for `rowkind.field` integration tests (no compact APIs).
+
+#![allow(dead_code)]
+
+use arrow_array::{Array, Int32Array, Int8Array, RecordBatch, StringArray};
+use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema as 
ArrowSchema};
+use futures::StreamExt;
+use paimon::catalog::Identifier;
+use paimon::io::FileIOBuilder;
+use paimon::spec::{DataType, IntType, Schema, TableSchema, VarCharType, 
VALUE_KIND_FIELD_NAME};
+use paimon::table::Table;
+use std::collections::BTreeMap;
+use std::sync::Arc;
+
+pub async fn setup_dirs(file_io: &paimon::io::FileIO, table_path: &str) {
+    file_io
+        .mkdirs(&format!("{table_path}/schema"))
+        .await
+        .unwrap();
+    file_io
+        .mkdirs(&format!("{table_path}/snapshot"))
+        .await
+        .unwrap();
+}
+
+pub async fn persist_table_schema(
+    file_io: &paimon::io::FileIO,
+    table_path: &str,
+    schema: &TableSchema,
+) {
+    use bytes::Bytes;
+
+    let path = format!("{table_path}/schema/schema-{}", schema.id());
+    let json = serde_json::to_vec(schema).unwrap();
+    file_io
+        .new_output(&path)
+        .unwrap()
+        .write(Bytes::from(json))
+        .await
+        .unwrap();
+}
+
+pub fn rowkind_field_schema(kind_col: &str, options: &[(&str, &str)]) -> 
TableSchema {
+    let mut builder = Schema::builder()
+        .column("id", DataType::Int(IntType::new()))
+        .column("value", DataType::Int(IntType::new()))
+        .column(kind_col, DataType::VarChar(VarCharType::string_type()))
+        .primary_key(["id"])
+        .option("bucket", "1")
+        .option("rowkind.field", kind_col);
+    for (k, v) in options {
+        builder = builder.option(*k, *v);
+    }
+    TableSchema::new(0, &builder.build().unwrap())
+}
+
+pub fn memory_table(path: &str, schema: TableSchema) -> (paimon::io::FileIO, 
Table) {
+    let file_io = FileIOBuilder::new("memory").build().unwrap();
+    let table = Table::new(
+        file_io.clone(),
+        Identifier::new("default", "rowkind_test"),
+        path.to_string(),
+        schema,
+        None,
+    );
+    (file_io, table)
+}
+
+pub fn make_batch_with_rowkind(
+    ids: Vec<i32>,
+    values: Vec<i32>,
+    kinds: Vec<&str>,
+    kind_col: &str,
+) -> RecordBatch {
+    let schema = Arc::new(ArrowSchema::new(vec![
+        ArrowField::new("id", ArrowDataType::Int32, false),
+        ArrowField::new("value", ArrowDataType::Int32, false),
+        ArrowField::new(kind_col, ArrowDataType::Utf8, false),
+    ]));
+    RecordBatch::try_new(
+        schema,
+        vec![
+            Arc::new(Int32Array::from(ids)),
+            Arc::new(Int32Array::from(values)),
+            Arc::new(StringArray::from(kinds)),
+        ],
+    )
+    .unwrap()
+}
+
+pub fn make_batch_with_rowkind_and_value_kind(
+    ids: Vec<i32>,
+    values: Vec<i32>,
+    kinds: Vec<&str>,
+    kind_col: &str,
+) -> RecordBatch {
+    let schema = Arc::new(ArrowSchema::new(vec![
+        ArrowField::new("id", ArrowDataType::Int32, false),
+        ArrowField::new("value", ArrowDataType::Int32, false),
+        ArrowField::new(kind_col, ArrowDataType::Utf8, false),
+        ArrowField::new(VALUE_KIND_FIELD_NAME, ArrowDataType::Int8, false),
+    ]));
+    let n = kinds.len();
+    RecordBatch::try_new(
+        schema,
+        vec![
+            Arc::new(Int32Array::from(ids)),
+            Arc::new(Int32Array::from(values)),
+            Arc::new(StringArray::from(kinds)),
+            Arc::new(Int8Array::from(vec![0i8; n])),
+        ],
+    )
+    .unwrap()
+}
+
+pub async fn write_batch(table: &Table, batch: &RecordBatch) {
+    let builder = table.new_write_builder();
+    let mut w = builder.new_write().unwrap();
+    w.write_arrow_batch(batch).await.unwrap();
+    let msgs = w.prepare_commit().await.unwrap();
+    builder.new_commit().commit(msgs).await.unwrap();
+}
+
+pub async fn write_batch_expect_err(table: &Table, batch: &RecordBatch) -> 
paimon::Error {
+    let builder = table.new_write_builder();
+    let mut w = builder.new_write().unwrap();
+    w.write_arrow_batch(batch).await.unwrap_err()
+}
+
+pub async fn scan_id_values(table: &Table) -> BTreeMap<i32, i32> {
+    let scan = table.new_read_builder().new_scan();
+    let plan = scan.plan().await.unwrap();
+    let read = table.new_read_builder().new_read().unwrap();
+    let mut stream = read.to_arrow(plan.splits()).unwrap();
+    let mut out = BTreeMap::new();
+    while let Some(batch) = stream.next().await {
+        let batch = batch.unwrap();
+        let ids = batch
+            .column_by_name("id")
+            .unwrap()
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .unwrap();
+        let values = batch
+            .column_by_name("value")
+            .unwrap()
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .unwrap();
+        for i in 0..ids.len() {
+            out.insert(ids.value(i), values.value(i));
+        }
+    }
+    out
+}
+
+pub async fn scan_pk_value_kind(table: &Table, kind_col: &str) -> Vec<(i32, 
i32, String)> {
+    let scan = table.new_read_builder().new_scan();
+    let plan = scan.plan().await.unwrap();
+    let read = table.new_read_builder().new_read().unwrap();
+    let mut stream = read.to_arrow(plan.splits()).unwrap();
+    let mut out = Vec::new();
+    while let Some(batch) = stream.next().await {
+        let batch = batch.unwrap();
+        let ids = batch
+            .column_by_name("id")
+            .unwrap()
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .unwrap();
+        let values = batch
+            .column_by_name("value")
+            .unwrap()
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .unwrap();
+        let kinds = batch
+            .column_by_name(kind_col)
+            .unwrap()
+            .as_any()
+            .downcast_ref::<StringArray>()
+            .unwrap();
+        for i in 0..ids.len() {
+            out.push((ids.value(i), values.value(i), 
kinds.value(i).to_string()));
+        }
+    }
+    out.sort_unstable();
+    out
+}
diff --git a/crates/paimon/tests/rowkind_field_test.rs 
b/crates/paimon/tests/rowkind_field_test.rs
new file mode 100644
index 0000000..c7f9f08
--- /dev/null
+++ b/crates/paimon/tests/rowkind_field_test.rs
@@ -0,0 +1,222 @@
+// 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.
+
+//! Integration tests for `rowkind.field` (mirrors Java 
`BatchFileStoreITCase`).
+
+#[path = "common/rowkind_helpers.rs"]
+mod rowkind_helpers;
+
+use rowkind_helpers::{
+    make_batch_with_rowkind, make_batch_with_rowkind_and_value_kind, 
memory_table,
+    persist_table_schema, rowkind_field_schema, scan_id_values, 
scan_pk_value_kind, setup_dirs,
+    write_batch, write_batch_expect_err,
+};
+use std::collections::HashMap;
+
+fn table_with_options(
+    table: &paimon::table::Table,
+    options: HashMap<String, String>,
+) -> paimon::table::Table {
+    table.copy_with_options(options)
+}
+
+#[tokio::test]
+async fn rowkind_field_insert_then_delete() {
+    let table_path = "memory:/rowkind_field/insert_delete";
+    let schema = rowkind_field_schema("rf", &[]);
+    let (file_io, table) = memory_table(table_path, schema);
+    setup_dirs(&file_io, table_path).await;
+    persist_table_schema(&file_io, table_path, table.schema()).await;
+
+    write_batch(
+        &table,
+        &make_batch_with_rowkind(vec![1], vec![1], vec!["+I"], "rf"),
+    )
+    .await;
+    assert_eq!(
+        scan_pk_value_kind(&table, "rf").await,
+        vec![(1, 1, "+I".to_string())]
+    );
+
+    write_batch(
+        &table,
+        &make_batch_with_rowkind(vec![1], vec![2], vec!["-D"], "rf"),
+    )
+    .await;
+    assert!(scan_id_values(&table).await.is_empty());
+}
+
+#[tokio::test]
+async fn rowkind_field_update_tokens() {
+    let table_path = "memory:/rowkind_field/update_tokens";
+    let schema = rowkind_field_schema("rf", &[]);
+    let (file_io, table) = memory_table(table_path, schema);
+    setup_dirs(&file_io, table_path).await;
+    persist_table_schema(&file_io, table_path, table.schema()).await;
+
+    write_batch(
+        &table,
+        &make_batch_with_rowkind(vec![1], vec![1], vec!["+I"], "rf"),
+    )
+    .await;
+    write_batch(
+        &table,
+        &make_batch_with_rowkind(vec![1], vec![1], vec!["-U"], "rf"),
+    )
+    .await;
+    assert!(scan_id_values(&table).await.is_empty());
+
+    write_batch(
+        &table,
+        &make_batch_with_rowkind(vec![1], vec![10], vec!["+U"], "rf"),
+    )
+    .await;
+    assert_eq!(
+        scan_pk_value_kind(&table, "rf").await,
+        vec![(1, 10, "+U".to_string())]
+    );
+}
+
+#[tokio::test]
+async fn rowkind_field_ignore_delete() {
+    let table_path = "memory:/rowkind_field/ignore_delete";
+    let schema = rowkind_field_schema("kind", &[("ignore-delete", "true")]);
+    let (file_io, table) = memory_table(table_path, schema);
+    setup_dirs(&file_io, table_path).await;
+    persist_table_schema(&file_io, table_path, table.schema()).await;
+
+    write_batch(
+        &table,
+        &make_batch_with_rowkind(vec![1], vec![10], vec!["+I"], "kind"),
+    )
+    .await;
+    assert_eq!(
+        scan_pk_value_kind(&table, "kind").await,
+        vec![(1, 10, "+I".to_string())]
+    );
+
+    write_batch(
+        &table,
+        &make_batch_with_rowkind(vec![1], vec![10], vec!["-D"], "kind"),
+    )
+    .await;
+    assert_eq!(
+        scan_pk_value_kind(&table, "kind").await,
+        vec![(1, 10, "+I".to_string())]
+    );
+
+    write_batch(
+        &table,
+        &make_batch_with_rowkind(vec![1], vec![20], vec!["+I"], "kind"),
+    )
+    .await;
+    assert_eq!(
+        scan_pk_value_kind(&table, "kind").await,
+        vec![(1, 20, "+I".to_string())]
+    );
+}
+
+#[tokio::test]
+async fn rowkind_field_ignore_update_before() {
+    let table_path = "memory:/rowkind_field/ignore_update_before";
+    let schema = rowkind_field_schema("kind", &[]);
+    let (file_io, table) = memory_table(table_path, schema);
+    setup_dirs(&file_io, table_path).await;
+    persist_table_schema(&file_io, table_path, table.schema()).await;
+
+    write_batch(
+        &table,
+        &make_batch_with_rowkind(vec![1, 2], vec![10, 20], vec!["+I", "+I"], 
"kind"),
+    )
+    .await;
+    write_batch(
+        &table,
+        &make_batch_with_rowkind(vec![2], vec![20], vec!["-U"], "kind"),
+    )
+    .await;
+    assert_eq!(
+        scan_pk_value_kind(&table, "kind").await,
+        vec![(1, 10, "+I".to_string())]
+    );
+
+    let table = table_with_options(
+        &table,
+        HashMap::from([("ignore-update-before".to_string(), 
"true".to_string())]),
+    );
+    assert_eq!(
+        scan_pk_value_kind(&table, "kind").await,
+        vec![(1, 10, "+I".to_string())],
+        "after option change before filtered -U"
+    );
+
+    write_batch(
+        &table,
+        &make_batch_with_rowkind(vec![1], vec![10], vec!["-U"], "kind"),
+    )
+    .await;
+    assert_eq!(
+        scan_pk_value_kind(&table, "kind").await,
+        vec![(1, 10, "+I".to_string())]
+    );
+
+    write_batch(
+        &table,
+        &make_batch_with_rowkind(vec![1], vec![10], vec!["-D"], "kind"),
+    )
+    .await;
+    assert!(scan_id_values(&table).await.is_empty());
+}
+
+#[tokio::test]
+async fn rowkind_field_rejects_illegal_token() {
+    let table_path = "memory:/rowkind_field/illegal_token";
+    let schema = rowkind_field_schema("rf", &[]);
+    let (file_io, table) = memory_table(table_path, schema);
+    setup_dirs(&file_io, table_path).await;
+    persist_table_schema(&file_io, table_path, table.schema()).await;
+
+    let err = write_batch_expect_err(
+        &table,
+        &make_batch_with_rowkind(vec![1], vec![1], vec!["INSERT"], "rf"),
+    )
+    .await;
+    assert!(
+        matches!(err, paimon::Error::DataInvalid { ref message, .. }
+            if message.contains("Unsupported short string")),
+        "got {err:?}"
+    );
+}
+
+#[tokio::test]
+async fn rowkind_field_rejects_value_kind_conflict() {
+    let table_path = "memory:/rowkind_field/value_kind_conflict";
+    let schema = rowkind_field_schema("rf", &[]);
+    let (file_io, table) = memory_table(table_path, schema);
+    setup_dirs(&file_io, table_path).await;
+    persist_table_schema(&file_io, table_path, table.schema()).await;
+
+    let err = write_batch_expect_err(
+        &table,
+        &make_batch_with_rowkind_and_value_kind(vec![1], vec![1], vec!["+I"], 
"rf"),
+    )
+    .await;
+    assert!(
+        matches!(err, paimon::Error::DataInvalid { ref message, .. }
+            if message.contains("_VALUE_KIND")),
+        "got {err:?}"
+    );
+}


Reply via email to