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 4a947ecd Expand native table sources and nullable primary-key writes 
(#1155)
4a947ecd is described below

commit 4a947ecdf80ac6e856e0ab99a0963027099dc39e
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Oct 18 16:02:03 2026 +0800

    Expand native table sources and nullable primary-key writes (#1155)
---
 crates/paimon/src/catalog/filesystem.rs           | 130 +++++++-
 crates/paimon/src/spec/core_options.rs            | 128 +++++++-
 crates/paimon/src/spec/schema.rs                  | 228 +++++++++++++--
 crates/paimon/src/table/pk_vector_scan.rs         |  26 +-
 crates/paimon/src/table/snapshot_scan_tests.rs    | 101 +++++++
 crates/paimon/src/table/stream_table_scan.rs      |  19 ++
 crates/paimon/src/table/table_merge_input.rs      |  39 ++-
 crates/paimon/src/table/table_merge_into_tests.rs |  50 ++++
 crates/paimon/src/table/table_scan.rs             |  19 ++
 crates/paimon/tests/nullable_primary_key_test.rs  | 342 ++++++++++++++++++++++
 crates/paimon/tests/rest_catalog/merge_sources.rs | 285 ++++++++++++++++++
 crates/paimon/tests/rest_catalog_test.rs          |   4 +
 12 files changed, 1338 insertions(+), 33 deletions(-)

diff --git a/crates/paimon/src/catalog/filesystem.rs 
b/crates/paimon/src/catalog/filesystem.rs
index a957c500..0689545b 100644
--- a/crates/paimon/src/catalog/filesystem.rs
+++ b/crates/paimon/src/catalog/filesystem.rs
@@ -29,7 +29,7 @@ use crate::io::cache::{create_local_cache, LocalCache};
 use crate::io::FileIO;
 use crate::spec::{
     CoreOptions, Schema, Snapshot, TableSchema, TableType, 
INDEX_FILE_IN_DATA_FILE_DIR_OPTION,
-    TABLE_TYPE_OPTION,
+    PRIMARY_KEY_NULLABLE_OPTION, TABLE_TYPE_OPTION,
 };
 use crate::table::{ObjectTable, SchemaManager, Table};
 use async_trait::async_trait;
@@ -554,6 +554,26 @@ impl Catalog for FileSystemCatalog {
             })?;
 
         reject_immutable_option_changes(current.options(), &changes)?;
+        // Java permits changing the key policy before the first snapshot and
+        // normalizes the fields again. Once data exists the option is 
immutable.
+        // Avoid snapshot IO for unrelated schema changes or exact SET no-ops.
+        let changes_key_nullability = changes.iter().any(|change| match change 
{
+            crate::spec::SchemaChange::SetOption { key, value } => {
+                key == PRIMARY_KEY_NULLABLE_OPTION && 
current.options().get(key) != Some(value)
+            }
+            crate::spec::SchemaChange::RemoveOption { key } => key == 
PRIMARY_KEY_NULLABLE_OPTION,
+            _ => false,
+        });
+        if changes_key_nullability
+            && crate::table::SnapshotManager::new(self.file_io.clone(), 
table_path.clone())
+                .get_latest_snapshot()
+                .await?
+                .is_some()
+        {
+            return Err(Error::Unsupported {
+                message: format!("Change '{PRIMARY_KEY_NULLABLE_OPTION}' is 
not supported yet."),
+            });
+        }
 
         let new_schema = current
             .apply_changes(changes)
@@ -1086,6 +1106,114 @@ mod tests {
         catalog.get_table(&identifier).await.unwrap();
     }
 
+    #[tokio::test]
+    async fn nullable_key_policy_changes_only_before_first_snapshot() {
+        use crate::spec::{DataType, IntType, SchemaChange};
+
+        let catalog = create_memory_catalog();
+        catalog
+            .create_database("db", false, HashMap::new())
+            .await
+            .unwrap();
+        for initial in [None, Some("false"), Some("true")] {
+            let identifier = Identifier::new("db", 
initial.unwrap_or("default"));
+            let mut schema = Schema::builder()
+                .column("id", DataType::Int(IntType::new()))
+                .primary_key(["id"])
+                .option("bucket", "1");
+            if let Some(initial) = initial {
+                schema = schema.option("primary-key.nullable", initial);
+            }
+            catalog
+                .create_table(&identifier, schema.build().unwrap(), false)
+                .await
+                .unwrap();
+            // As in Java, an empty table can change and reset this option.
+            for value in ["true", "false", "true"] {
+                catalog
+                    .alter_table(
+                        &identifier,
+                        vec![SchemaChange::set_option(
+                            "primary-key.nullable".into(),
+                            value.into(),
+                        )],
+                        false,
+                    )
+                    .await
+                    .unwrap();
+                let table = catalog.get_table(&identifier).await.unwrap();
+                assert_eq!(
+                    table.schema().fields()[0].data_type().is_nullable(),
+                    value == "true"
+                );
+            }
+            catalog
+                .alter_table(
+                    &identifier,
+                    
vec![SchemaChange::remove_option("primary-key.nullable".into())],
+                    false,
+                )
+                .await
+                .unwrap();
+            let table = catalog.get_table(&identifier).await.unwrap();
+            assert!(!table.schema().fields()[0].data_type().is_nullable());
+            if let Some(initial) = initial {
+                catalog
+                    .alter_table(
+                        &identifier,
+                        vec![SchemaChange::set_option(
+                            "primary-key.nullable".into(),
+                            initial.into(),
+                        )],
+                        false,
+                    )
+                    .await
+                    .unwrap();
+            }
+            give_the_table_a_snapshot(&catalog, &identifier).await;
+            let before = catalog
+                .get_table(&identifier)
+                .await
+                .unwrap()
+                .schema()
+                .clone();
+            for value in ["false", "true", "TRUE"] {
+                let result = catalog
+                    .alter_table(
+                        &identifier,
+                        vec![SchemaChange::set_option(
+                            "primary-key.nullable".into(),
+                            value.into(),
+                        )],
+                        false,
+                    )
+                    .await;
+                if Some(value) == initial {
+                    result.unwrap();
+                } else {
+                    assert!(
+                        matches!(result, Err(Error::Unsupported { .. })),
+                        "{initial:?}/{value}: {result:?}"
+                    );
+                }
+            }
+            let result = catalog
+                .alter_table(
+                    &identifier,
+                    
vec![SchemaChange::remove_option("primary-key.nullable".into())],
+                    false,
+                )
+                .await;
+            assert!(
+                matches!(result, Err(Error::Unsupported { .. })),
+                "{initial:?}: {result:?}"
+            );
+            let after = catalog.get_table(&identifier).await.unwrap();
+            assert_eq!(after.schema().options(), before.options());
+            assert_eq!(after.schema().fields(), before.fields());
+        }
+    }
+
     /// Give the table a first snapshot, so a test can assert that a rejection 
does
     /// not depend on whether one exists.
     async fn give_the_table_a_snapshot(catalog: &FileSystemCatalog, 
identifier: &Identifier) {
diff --git a/crates/paimon/src/spec/core_options.rs 
b/crates/paimon/src/spec/core_options.rs
index 0b6c8b29..cba2dab6 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -58,6 +58,7 @@ const MAX_FORMAT_TABLE_SCAN_LIST_PARALLELISM: i64 = 1000;
 pub(crate) const BUCKET_KEY_OPTION: &str = "bucket-key";
 const BUCKET_FUNCTION_TYPE_OPTION: &str = "bucket-function.type";
 const BUCKET_OPTION: &str = "bucket";
+pub(crate) const PRIMARY_KEY_NULLABLE_OPTION: &str = "primary-key.nullable";
 const DEFAULT_BUCKET: i32 = -1;
 /// Postpone bucket mode: data is written to `bucket-postpone` directory
 /// and is invisible to readers until compaction assigns real bucket numbers.
@@ -527,9 +528,10 @@ impl<'a> CoreOptions<'a> {
     /// they surface as `Error::Unsupported` (mapped to `NotImplementedError` 
at
     /// the Python boundary). Explicit `scan.mode=from-snapshot` /
     /// `from-timestamp` are the modes Java's `CoreOptions.setDefaultValues()`
-    /// writes next to the corresponding selector, so they are accepted when
-    /// that selector is present (the batch-read semantics are identical to
-    /// leaving the mode at `default`); an explicit mode without its selector
+    /// writes next to the corresponding selector. `latest` / `latest-full`
+    /// read the latest full state in batch mode; `from-snapshot-full` reads
+    /// the selected full snapshot, as Java's batch starting scanners do.
+    /// An explicit point-in-time mode without its selector
     /// is malformed input (`Error::DataInvalid`), mirroring Java's
     /// `SchemaValidation`. Incremental timestamp windows reuse the batch
     /// Delta/Changelog scanner; snapshot/tag ranges remain unimplemented here.
@@ -564,6 +566,15 @@ impl<'a> CoreOptions<'a> {
             }
         }
         if let Some(mode) = self.options.get(SCAN_MODE_OPTION) {
+            if self.is_format_table()
+                && ["latest", "latest-full", "from-snapshot-full"]
+                    .iter()
+                    .any(|value| mode.eq_ignore_ascii_case(value))
+            {
+                return Err(crate::Error::Unsupported {
+                    message: "Full-snapshot startup modes require a Paimon 
table".into(),
+                });
+            }
             if incremental
                 && !mode.eq_ignore_ascii_case("default")
                 && !mode.eq_ignore_ascii_case("incremental")
@@ -577,6 +588,10 @@ impl<'a> CoreOptions<'a> {
             }
             let selector_keys: &[&str] = if 
mode.eq_ignore_ascii_case("default") {
                 return Ok(());
+            } else if mode.eq_ignore_ascii_case("latest")
+                || mode.eq_ignore_ascii_case("latest-full")
+            {
+                &[]
             } else if mode.eq_ignore_ascii_case("incremental") {
                 if incremental {
                     return Ok(());
@@ -594,6 +609,8 @@ impl<'a> CoreOptions<'a> {
                 ]
             } else if mode.eq_ignore_ascii_case("from-timestamp") {
                 &[SCAN_TIMESTAMP_MILLIS_OPTION, SCAN_TIMESTAMP_OPTION]
+            } else if mode.eq_ignore_ascii_case("from-snapshot-full") {
+                &[SCAN_SNAPSHOT_ID_OPTION]
             } else {
                 return Err(crate::Error::Unsupported {
                     message: format!(
@@ -601,9 +618,10 @@ impl<'a> CoreOptions<'a> {
                     ),
                 });
             };
-            if !selector_keys
-                .iter()
-                .any(|key| self.options.contains_key(*key))
+            if !selector_keys.is_empty()
+                && !selector_keys
+                    .iter()
+                    .any(|key| self.options.contains_key(*key))
             {
                 return Err(crate::Error::DataInvalid {
                     message: format!(
@@ -613,6 +631,18 @@ impl<'a> CoreOptions<'a> {
                     source: None,
                 });
             }
+            if self
+                .configured_time_travel_selectors()
+                .iter()
+                .any(|key| !selector_keys.contains(key))
+                || self.options.contains_key("scan.file-creation-time-millis")
+                || self.options.contains_key("scan.creation-time-millis")
+            {
+                return Err(crate::Error::DataInvalid {
+                    message: format!("scan.mode={mode} cannot be used with the 
configured point-in-time scan options"),
+                    source: None,
+                });
+            }
         }
         Ok(())
     }
@@ -782,6 +812,14 @@ impl<'a> CoreOptions<'a> {
         self.options.get(ROWKIND_FIELD_OPTION).map(String::as_str)
     }
 
+    /// Whether primary keys allow null values. Null keys use null-safe 
equality
+    /// when records are merged. Disabled by default, as in Java.
+    pub fn primary_key_nullable(&self) -> bool {
+        self.options
+            .get(PRIMARY_KEY_NULLABLE_OPTION)
+            .is_some_and(|v| v.eq_ignore_ascii_case("true"))
+    }
+
     /// Whether to ignore delete records (and all retracts when used by 
`RowKindFilter`).
     pub fn ignore_delete(&self) -> bool {
         for key in
@@ -3947,7 +3985,7 @@ mod tests {
         let ok = HashMap::from([("scan.mode".to_string(), 
"default".to_string())]);
         assert!(CoreOptions::new(&ok).validate_scan_options().is_ok());
         // unimplemented modes Unsupported
-        for mode in ["compacted-full", "latest", "latest-full"] {
+        for mode in ["compacted-full", "latest-delta", "unknown"] {
             let bad = HashMap::from([("scan.mode".to_string(), 
mode.to_string())]);
             let err = 
CoreOptions::new(&bad).validate_scan_options().unwrap_err();
             assert!(
@@ -3957,6 +3995,82 @@ mod tests {
         }
     }
 
+    #[test]
+    fn test_full_startup_modes_validate_batch_options() {
+        for mode in ["latest", "latest-full", "LATEST-FULL"] {
+            let options = HashMap::from([("scan.mode".into(), mode.into())]);
+            assert!(
+                CoreOptions::new(&options).validate_scan_options().is_ok(),
+                "{mode}"
+            );
+        }
+        let options = HashMap::from([
+            ("scan.mode".into(), "from-snapshot-full".into()),
+            ("scan.snapshot-id".into(), "1".into()),
+        ]);
+        assert!(CoreOptions::new(&options).validate_scan_options().is_ok());
+    }
+
+    #[test]
+    fn test_full_startup_modes_reject_conflicting_selectors() {
+        for mode in ["latest", "latest-full", "from-snapshot-full"] {
+            for key in [
+                "scan.version",
+                "scan.snapshot-id",
+                "scan.tag-name",
+                "scan.watermark",
+                "scan.timestamp",
+                "scan.timestamp-millis",
+                "scan.file-creation-time-millis",
+                "scan.creation-time-millis",
+                "incremental-between-timestamp",
+            ] {
+                if mode == "from-snapshot-full" && key == "scan.snapshot-id" {
+                    continue;
+                }
+                let mut options =
+                    HashMap::from([("scan.mode".into(), mode.into()), 
(key.into(), "1".into())]);
+                if mode == "from-snapshot-full" {
+                    options.insert("scan.snapshot-id".into(), "1".into());
+                }
+                assert!(
+                    matches!(
+                        CoreOptions::new(&options).validate_scan_options(),
+                        Err(crate::Error::DataInvalid { .. })
+                    ),
+                    "{mode} / {key}"
+                );
+            }
+        }
+        for selector in [None, Some("scan.tag-name"), Some("scan.version")] {
+            let mut options = HashMap::from([("scan.mode".into(), 
"from-snapshot-full".into())]);
+            if let Some(selector) = selector {
+                options.insert(selector.into(), "1".into());
+            }
+            assert!(matches!(
+                CoreOptions::new(&options).validate_scan_options(),
+                Err(crate::Error::DataInvalid { .. })
+            ));
+        }
+    }
+
+    #[test]
+    fn test_full_startup_modes_do_not_enable_format_table_snapshots() {
+        for mode in ["latest", "latest-full", "from-snapshot-full"] {
+            let mut options = HashMap::from([
+                ("type".into(), "format-table".into()),
+                ("scan.mode".into(), mode.into()),
+            ]);
+            if mode == "from-snapshot-full" {
+                options.insert("scan.snapshot-id".into(), "1".into());
+            }
+            assert!(matches!(
+                CoreOptions::new(&options).validate_scan_options(),
+                Err(crate::Error::Unsupported { .. })
+            ));
+        }
+    }
+
     #[test]
     fn test_validate_scan_options_explicit_mode_with_matching_selector() {
         // Java's CoreOptions.setDefaultValues() writes scan.mode=from-snapshot
diff --git a/crates/paimon/src/spec/schema.rs b/crates/paimon/src/spec/schema.rs
index 8970213b..9e54e08d 100644
--- a/crates/paimon/src/spec/schema.rs
+++ b/crates/paimon/src/spec/schema.rs
@@ -19,8 +19,9 @@ use crate::spec::core_options::{
     first_row_supports_changelog_producer, ChangelogProducer, CoreOptions, 
MergeEngine,
     BLOB_DESCRIPTOR_FIELD_FALLBACK, BLOB_DESCRIPTOR_FIELD_OPTION, 
BLOB_FIELD_OPTION,
     BLOB_VIEW_FIELD_OPTION, BUCKET_KEY_OPTION, CHANGELOG_PRODUCER_OPTION,
-    INDEX_FILE_IN_DATA_FILE_DIR_OPTION, POSTPONE_BUCKET, 
QUERY_AUTH_ENABLED_OPTION,
-    SEQUENCE_FIELD_OPTION, TABLE_READ_SEQUENCE_NUMBER_ENABLED_OPTION, 
TABLE_TYPE_OPTION,
+    INDEX_FILE_IN_DATA_FILE_DIR_OPTION, POSTPONE_BUCKET, 
PRIMARY_KEY_NULLABLE_OPTION,
+    QUERY_AUTH_ENABLED_OPTION, SEQUENCE_FIELD_OPTION, 
TABLE_READ_SEQUENCE_NUMBER_ENABLED_OPTION,
+    TABLE_TYPE_OPTION,
 };
 use crate::spec::types::{ArrayType, DataType, MapType, MultisetType, RowType, 
VarCharType};
 use crate::spec::{
@@ -245,6 +246,7 @@ impl TableSchema {
         for key in [
             INDEX_FILE_IN_DATA_FILE_DIR_OPTION,
             "data-file.path-directory",
+            PRIMARY_KEY_NULLABLE_OPTION,
         ] {
             match self.options.get(key) {
                 Some(stored) => extra.insert(key.to_string(), stored.clone()),
@@ -351,6 +353,8 @@ impl TableSchema {
     /// Column errors ([`crate::Error::ColumnNotExist`] /
     /// [`crate::Error::ColumnAlreadyExist`]) are returned with an empty table
     /// name; the calling catalog fills in the table's full name.
+    /// The catalog also checks snapshot-dependent immutable options before
+    /// applying changes; this schema transformation has no snapshot context.
     pub fn apply_changes(&self, changes: Vec<crate::spec::SchemaChange>) -> 
crate::Result<Self> {
         use crate::spec::SchemaChange;
 
@@ -680,8 +684,8 @@ impl TableSchema {
                     new_nullability,
                 } => {
                     let name = top_level_field(&field_names)?;
-                    // Primary keys are normalized to NOT NULL at create time;
-                    // a nullable key column would break key/bucket semantics.
+                    // Key nullability is selected at create time by the 
immutable
+                    // primary-key.nullable option, not by ALTER COLUMN.
                     if new_nullability && 
new_schema.primary_keys.iter().any(|k| k == name) {
                         return Err(crate::Error::Unsupported {
                             message: "Cannot change nullability of primary 
key".to_string(),
@@ -700,8 +704,9 @@ impl TableSchema {
                         disable_null_to_not_null,
                     )?;
                     let nt = 
old.data_type().copy_with_nullable(new_nullability)?;
-                    fields[idx] = DataField::new(old.id(), 
old.name().to_string(), nt)
-                        .with_description(old.description().map(|s| 
s.to_string()));
+                    let mut field = old.clone();
+                    field.typ = nt;
+                    fields[idx] = field;
                 }
                 SchemaChange::UpdateColumnComment {
                     field_names,
@@ -721,6 +726,13 @@ impl TableSchema {
             }
         }
 
+        // Java rebuilds Schema after ALTER, reapplying the key option even if
+        // an individual column change requested a different nullability.
+        Schema::normalize_primary_key_nullability(
+            &mut fields,
+            &new_schema.primary_keys,
+            new_schema.core_options().primary_key_nullable(),
+        )?;
         new_schema.fields = fields;
         new_schema.highest_field_id =
             
highest_field_id.max(Self::current_highest_field_id(&new_schema.fields));
@@ -1377,6 +1389,13 @@ impl Schema {
     ) -> crate::Result<()> {
         // Create and alter share this, so an unparsable type never lands.
         CoreOptions::new(options).table_type()?;
+        if primary_keys.is_empty() && 
CoreOptions::new(options).primary_key_nullable() {
+            return Err(crate::Error::ConfigInvalid {
+                message: format!(
+                    "Option '{PRIMARY_KEY_NULLABLE_OPTION}' can only be 
enabled for a table with primary keys."
+                ),
+            });
+        }
         validate_no_reserved_field_names(fields)?;
         Self::validate_key_field_types(fields, partition_keys, primary_keys, 
options)?;
         Self::validate_bucket_count(options)?;
@@ -1521,7 +1540,7 @@ impl Schema {
     }
 
     /// Normalize fields: validate duplicate/subset checks, promote configured
-    /// binary BLOB fields, and make primary key columns non-nullable.
+    /// binary BLOB fields, and apply the configured primary key nullability.
     /// Corresponds to Java `normalizeFields`.
     fn normalize_fields(
         fields: &[DataField],
@@ -1535,7 +1554,8 @@ impl Schema {
         Self::validate_primary_keys(&field_names, primary_keys)?;
         Self::validate_primary_keys_not_partition_only(partition_keys, 
primary_keys)?;
 
-        let blob_field_names = CoreOptions::new(options).blob_fields();
+        let core_options = CoreOptions::new(options);
+        let blob_field_names = core_options.blob_fields();
         for name in &blob_field_names {
             if !field_names.iter().any(|field| field == name) {
                 return Err(crate::Error::ConfigInvalid {
@@ -1548,25 +1568,42 @@ impl Schema {
             return Ok(fields.to_vec());
         }
 
-        let pk_set: HashSet<&str> = 
primary_keys.iter().map(String::as_str).collect();
         let mut new_fields = Vec::with_capacity(fields.len());
         for f in fields {
-            let mut data_type = if blob_field_names.contains(f.name()) {
+            let data_type = if blob_field_names.contains(f.name()) {
                 normalize_blob_field_type(f.name(), f.data_type().clone())?
             } else {
                 f.data_type().clone()
             };
-            if pk_set.contains(f.name()) && data_type.is_nullable() {
-                data_type = data_type.copy_with_nullable(false)?;
-            }
-            new_fields.push(
-                DataField::new(f.id(), f.name().to_string(), data_type)
-                    .with_description(f.description().map(|s| s.to_string())),
-            );
+            let mut field = f.clone();
+            field.typ = data_type;
+            new_fields.push(field);
         }
+        Self::normalize_primary_key_nullability(
+            &mut new_fields,
+            primary_keys,
+            core_options.primary_key_nullable(),
+        )?;
         Ok(new_fields)
     }
 
+    fn normalize_primary_key_nullability(
+        fields: &mut [DataField],
+        primary_keys: &[String],
+        nullable: bool,
+    ) -> crate::Result<()> {
+        if primary_keys.is_empty() {
+            return Ok(());
+        }
+        let primary_keys: HashSet<&str> = 
primary_keys.iter().map(String::as_str).collect();
+        for field in fields {
+            if primary_keys.contains(field.name()) && field.typ.is_nullable() 
!= nullable {
+                field.typ = field.typ.copy_with_nullable(nullable)?;
+            }
+        }
+        Ok(())
+    }
+
     /// Table columns must not contain duplicate field names.
     fn validate_no_duplicate_fields(field_names: &[String]) -> 
crate::Result<()> {
         let duplicates = Self::duplicate_fields(field_names);
@@ -2561,7 +2598,8 @@ impl SchemaBuilder {
         self
     }
 
-    /// Set primary key columns. They must not be nullable.
+    /// Set primary key columns. Their nullability follows 
`primary-key.nullable`
+    /// (false by default), regardless of the declared column nullability.
     pub fn primary_key(mut self, names: impl IntoIterator<Item = impl 
Into<String>>) -> Self {
         self.primary_keys = names.into_iter().map(Into::into).collect();
         self
@@ -2860,6 +2898,160 @@ mod tests {
         }
     }
 
+    #[test]
+    fn nullable_primary_key_option_normalizes_declared_not_null_keys() {
+        let schema = Schema::builder()
+            .column("id", DataType::Int(IntType::with_nullable(false)))
+            .column("value", DataType::Int(IntType::new()))
+            .primary_key(["id"])
+            .option("bucket", "1")
+            .option("primary-key.nullable", "true")
+            .build()
+            .unwrap();
+        assert!(schema.fields()[0].data_type().is_nullable());
+    }
+
+    #[test]
+    fn primary_key_normalization_preserves_field_metadata() {
+        for nullable in [false, true] {
+            let fields = vec![
+                DataField::new(
+                    7,
+                    "id".into(),
+                    DataType::Int(IntType::with_nullable(!nullable)),
+                )
+                .with_description(Some("identifier".into()))
+                .with_default_value(Some("42".into())),
+                DataField::new(11, "value".into(), 
DataType::Int(IntType::new()))
+                    .with_default_value(Some("1".into())),
+            ];
+            let schema = Schema::new(
+                fields.clone(),
+                vec![],
+                vec!["id".into()],
+                HashMap::from([(PRIMARY_KEY_NULLABLE_OPTION.into(), 
nullable.to_string())]),
+                None,
+            )
+            .unwrap();
+            let mut expected = fields;
+            expected[0].typ = DataType::Int(IntType::with_nullable(nullable));
+            assert_eq!(schema.fields(), expected.as_slice());
+            let stored = TableSchema::new(0, 
&schema).copy_with_options(HashMap::from([(
+                "alter-column-null-to-not-null.disabled".into(),
+                "false".into(),
+            )]));
+            let changed = stored
+                
.apply_changes(vec![crate::spec::SchemaChange::UpdateColumnNullability {
+                    field_names: vec!["id".into()],
+                    new_nullability: false,
+                }])
+                .unwrap();
+            assert_eq!(changed.fields(), expected.as_slice());
+        }
+    }
+
+    #[test]
+    fn nullable_primary_keys_include_partition_columns() {
+        let schema = Schema::builder()
+            .column("pt", DataType::Int(IntType::with_nullable(false)))
+            .column("id", DataType::Int(IntType::with_nullable(false)))
+            .column("value", DataType::Int(IntType::with_nullable(false)))
+            .primary_key(["pt", "id"])
+            .partition_keys(["pt"])
+            .option(PRIMARY_KEY_NULLABLE_OPTION, "TRUE")
+            .build()
+            .unwrap();
+        assert!(schema.fields()[0].data_type().is_nullable());
+        assert!(schema.fields()[1].data_type().is_nullable());
+        assert!(!schema.fields()[2].data_type().is_nullable());
+    }
+
+    #[test]
+    fn nullable_primary_key_option_requires_primary_keys() {
+        let error = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .option(PRIMARY_KEY_NULLABLE_OPTION, "true")
+            .build()
+            .unwrap_err();
+        assert!(error
+            .to_string()
+            .contains("can only be enabled for a table with primary keys"));
+    }
+
+    #[test]
+    fn dynamic_options_preserve_primary_key_nullability() {
+        for nullable in [false, true] {
+            let schema = Schema::builder()
+                .column("id", DataType::Int(IntType::new()))
+                .primary_key(["id"])
+                .option(PRIMARY_KEY_NULLABLE_OPTION, nullable.to_string())
+                .build()
+                .unwrap();
+            let stored = TableSchema::new(0, &schema);
+            let copied = stored.copy_with_options(HashMap::from([(
+                PRIMARY_KEY_NULLABLE_OPTION.into(),
+                (!nullable).to_string(),
+            )]));
+            assert_eq!(copied.core_options().primary_key_nullable(), nullable);
+            assert_eq!(copied.fields(), stored.fields());
+            let replaced = 
stored.copy_with_replaced_dynamic_options(HashMap::new());
+            assert_eq!(replaced.core_options().primary_key_nullable(), 
nullable);
+        }
+    }
+
+    #[test]
+    fn nullable_key_option_wins_over_alter_column_not_null() {
+        let schema = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .column("value", DataType::Int(IntType::new()))
+            .primary_key(["id"])
+            .option(PRIMARY_KEY_NULLABLE_OPTION, "true")
+            .option("alter-column-null-to-not-null.disabled", "false")
+            .build()
+            .unwrap();
+        let stored = TableSchema::new(0, &schema);
+        let changed = stored
+            
.apply_changes(vec![crate::spec::SchemaChange::UpdateColumnNullability {
+                field_names: vec!["id".into()],
+                new_nullability: false,
+            }])
+            .unwrap();
+        // Java reconstructs Schema after ALTER and reapplies the key option.
+        assert!(changed.fields()[0].data_type().is_nullable());
+        assert!(changed.core_options().primary_key_nullable());
+    }
+
+    #[test]
+    fn schema_changes_normalize_primary_key_nullability_option() {
+        use crate::spec::SchemaChange;
+
+        for nullable in [false, true] {
+            let schema = Schema::builder()
+                .column("id", DataType::Int(IntType::new()))
+                .primary_key(["id"])
+                .option(PRIMARY_KEY_NULLABLE_OPTION, nullable.to_string())
+                .build()
+                .unwrap();
+            let stored = TableSchema::new(0, &schema);
+            let changed = stored
+                .apply_changes(vec![SchemaChange::SetOption {
+                    key: PRIMARY_KEY_NULLABLE_OPTION.into(),
+                    value: (!nullable).to_string(),
+                }])
+                .unwrap();
+            assert_eq!(changed.core_options().primary_key_nullable(), 
!nullable);
+            assert_eq!(changed.fields()[0].data_type().is_nullable(), 
!nullable);
+            assert_eq!(stored.fields()[0].data_type().is_nullable(), nullable);
+            let removed = stored
+                .apply_changes(vec![SchemaChange::RemoveOption {
+                    key: PRIMARY_KEY_NULLABLE_OPTION.into(),
+                }])
+                .unwrap();
+            assert!(!removed.core_options().primary_key_nullable());
+            assert!(!removed.fields()[0].data_type().is_nullable());
+        }
+    }
+
     #[test]
     fn test_apply_changes_rejects_invalid_read_batch_size() {
         let schema = Schema::builder()
diff --git a/crates/paimon/src/table/pk_vector_scan.rs 
b/crates/paimon/src/table/pk_vector_scan.rs
index 0f6ad524..8d3d033a 100644
--- a/crates/paimon/src/table/pk_vector_scan.rs
+++ b/crates/paimon/src/table/pk_vector_scan.rs
@@ -1187,7 +1187,7 @@ mod tests {
         async fn 
plan_validates_original_scan_mode_and_rejects_nonempty_incremental_search() {
             let (_tmp, table) = build_pruning_test_table(false).await;
             for extra in [
-                HashMap::from([("scan.mode".to_string(), 
"latest-full".to_string())]),
+                HashMap::from([("scan.mode".to_string(), 
"compacted-full".to_string())]),
                 HashMap::from([(
                     "incremental-between-timestamp".to_string(),
                     "0,9999999999999".to_string(),
@@ -1221,6 +1221,30 @@ mod tests {
             .is_empty());
         }
 
+        #[tokio::test]
+        async fn full_startup_modes_preserve_vector_scan_snapshot() {
+            let (_tmp, table) = build_pruning_test_table(false).await;
+            let snapshot = 
table.resolve_read_snapshot().await.unwrap().unwrap();
+            for mode in ["latest", "latest-full", "from-snapshot-full"] {
+                let mut options = HashMap::from([("scan.mode".into(), 
mode.into())]);
+                if mode == "from-snapshot-full" {
+                    options.insert("scan.snapshot-id".into(), 
snapshot.id().to_string());
+                }
+                let view = table.copy_with_options(options);
+                let plan = PkVectorScan::new(
+                    &view,
+                    prune_vector_field_id(&view),
+                    PRUNE_INDEX_TYPE.to_string(),
+                    None,
+                )
+                .plan()
+                .await
+                .unwrap();
+                assert_eq!(plan.snapshot_id, snapshot.id());
+                assert!(!plan.splits.is_empty());
+            }
+        }
+
         #[tokio::test]
         async fn 
plan_uses_complete_retained_tag_metadata_and_explicit_empty_view() {
             let (_tmp, table) = build_pruning_test_table(false).await;
diff --git a/crates/paimon/src/table/snapshot_scan_tests.rs 
b/crates/paimon/src/table/snapshot_scan_tests.rs
index 50910988..c535dc2a 100644
--- a/crates/paimon/src/table/snapshot_scan_tests.rs
+++ b/crates/paimon/src/table/snapshot_scan_tests.rs
@@ -174,6 +174,107 @@ async fn 
explicit_snapshot_overrides_startup_options_without_mutating_table() {
     assert_eq!(rows(&selected, &batch).await, vec![(1, 10)]);
 }
 
+#[tokio::test]
+async fn full_startup_modes_read_complete_batch_snapshots() {
+    for engine in [
+        "append",
+        "evolution",
+        "deduplicate",
+        "first-row",
+        "partial-update",
+        "aggregation",
+    ] {
+        let table = table(&format!("startup_modes_{engine}"), engine, "none", 
1).await;
+        write_batch(&table, &make_batch(vec![1, 2], vec![10, 20])).await;
+        write_batch(&table, &make_batch(vec![1, 3], vec![11, 30])).await;
+        let expected = match engine {
+            "append" | "evolution" => vec![(1, 10), (1, 11), (2, 20), (3, 30)],
+            // Ordinary first-row scans skip uncompacted L0 files.
+            "first-row" => vec![],
+            "aggregation" => vec![(1, 21), (2, 20), (3, 30)],
+            _ => vec![(1, 11), (2, 20), (3, 30)],
+        };
+        for mode in ["latest", "latest-full", "from-snapshot-full"] {
+            let mut options = HashMap::from([("scan.mode".into(), 
mode.into())]);
+            if mode == "from-snapshot-full" {
+                options.insert("scan.snapshot-id".into(), "1".into());
+            }
+            let selected = table.copy_with_options(options.clone());
+            let plan = 
selected.new_read_builder().new_scan().plan().await.unwrap();
+            assert_eq!(
+                plan.snapshot_id(),
+                Some(if mode == "from-snapshot-full" { 1 } else { 2 })
+            );
+            assert!(plan.splits().iter().all(|split| !split.is_streaming()));
+            let want = if engine == "first-row" {
+                vec![]
+            } else if mode == "from-snapshot-full" {
+                vec![(1, 10), (2, 20)]
+            } else {
+                expected.clone()
+            };
+            assert_eq!(rows(&selected, &plan).await, want, "{engine}/{mode}");
+            assert_eq!(
+                selected.schema().options().get("scan.mode"),
+                options.get("scan.mode")
+            );
+            let mut builder = selected.new_read_builder();
+            builder.with_projection(&["value"]).unwrap();
+            builder.with_filter(
+                crate::spec::PredicateBuilder::new(selected.schema().fields())
+                    .equal("id", crate::spec::Datum::Int(2))
+                    .unwrap(),
+            );
+            builder.with_limit(1);
+            let plan = builder.new_scan().plan().await.unwrap();
+            let batches: Vec<RecordBatch> = builder
+                .new_read()
+                .unwrap()
+                .to_arrow(plan.splits())
+                .unwrap()
+                .try_collect()
+                .await
+                .unwrap();
+            assert_eq!(
+                batches.iter().map(RecordBatch::num_rows).sum::<usize>(),
+                usize::from(engine != "first-row")
+            );
+            assert!(batches.iter().all(|batch| batch.num_columns() == 1));
+        }
+    }
+}
+
+#[tokio::test]
+async fn full_startup_modes_empty_tables_and_stream_boundaries() {
+    let table = table("startup_modes_empty", "append", "none", 1).await;
+    for mode in ["latest", "latest-full"] {
+        let selected = 
table.copy_with_options(HashMap::from([("scan.mode".into(), mode.into())]));
+        let plan = 
selected.new_read_builder().new_scan().plan().await.unwrap();
+        assert_eq!(plan.snapshot_id(), None);
+        assert!(plan.splits().is_empty());
+    }
+    for mode in ["latest", "from-snapshot-full"] {
+        let selected = 
table.copy_with_options(HashMap::from([("scan.mode".into(), mode.into())]));
+        assert!(matches!(
+            selected.new_read_builder().new_stream_scan(),
+            Err(paimon::Error::Unsupported { .. })
+        ));
+    }
+    let selected = table.copy_with_options(HashMap::from([
+        ("scan.mode".into(), "from-snapshot-full".into()),
+        ("scan.snapshot-id".into(), "1".into()),
+    ]));
+    assert!(selected.new_read_builder().new_scan().plan().await.is_err());
+    let selected =
+        table.copy_with_options(HashMap::from([("scan.mode".into(), 
"latest-full".into())]));
+    let mut stream = selected.new_read_builder().new_stream_scan().unwrap();
+    assert!(stream.plan().await.unwrap().is_none());
+    write_batch(&table, &make_batch(vec![1], vec![10])).await;
+    let plan = stream.plan().await.unwrap().unwrap();
+    assert_eq!(rows(&selected, &plan).await, vec![(1, 10)]);
+    assert_eq!(stream.checkpoint(), Some(2));
+}
+
 #[tokio::test]
 async fn changelog_reads_overwrite_while_batch_range_still_skips_it() {
     let table = table("overwrite", "deduplicate", "input", 1).await;
diff --git a/crates/paimon/src/table/stream_table_scan.rs 
b/crates/paimon/src/table/stream_table_scan.rs
index 96cadba6..2052d3ba 100644
--- a/crates/paimon/src/table/stream_table_scan.rs
+++ b/crates/paimon/src/table/stream_table_scan.rs
@@ -44,6 +44,25 @@ impl StreamTableScan {
     pub(super) fn new(scan: TableScan<'_>) -> Result<Self> {
         let mut scan = scan.into_owned_paimon_scan()?;
         let options = 
CoreOptions::new(scan.snapshot_table().schema().options());
+        // These aliases are supported by the batch starting scanner. A
+        // continuous reader needs a different starting position: latest must
+        // skip the initial state, and from-snapshot-full must start at that
+        // snapshot. Keep rejecting them until those stream policies exist.
+        if scan
+            .snapshot_table()
+            .schema()
+            .options()
+            .get("scan.mode")
+            .is_some_and(|mode| {
+                mode.eq_ignore_ascii_case("latest")
+                    || mode.eq_ignore_ascii_case("from-snapshot-full")
+            })
+        {
+            return Err(Error::Unsupported {
+                message: "Stream scans do not yet support scan.mode=latest or 
from-snapshot-full"
+                    .into(),
+            });
+        }
         let producer = options.try_changelog_producer()?;
         let follow_up_mode = if producer == ChangelogProducer::None {
             SnapshotScanMode::Delta
diff --git a/crates/paimon/src/table/table_merge_input.rs 
b/crates/paimon/src/table/table_merge_input.rs
index 7512718b..2283a23f 100644
--- a/crates/paimon/src/table/table_merge_input.rs
+++ b/crates/paimon/src/table/table_merge_input.rs
@@ -118,23 +118,50 @@ impl MergeInput {
         } else {
             super::time_travel::resolve_snapshot(table).await?
         };
-        // Pin the source independently, keeping branch/time-travel resolution
-        // and the caller's resolved schema. Include L0 as Python 
plan_for_write.
-        let table = if incremental {
+        // Pin the source independently, preserving its resolved read schema.
+        // Authorized sources keep their original catalog identity and plan the
+        // selected snapshot explicitly below. Include L0 as plan_for_write.
+        let query_auth = table.schema().core_options().query_auth_enabled();
+        let table = if incremental || query_auth {
             table.clone()
         } else {
             table.copy_with_pinned_snapshot(snapshot.as_ref())
         };
         let mut builder = table.new_read_builder();
         builder.with_projection(&names)?;
-        let plan = if incremental || snapshot.is_some() {
-            builder.new_scan().with_scan_all_files().plan().await?
+        let scan = if query_auth {
+            // Python materializes the entire source before matching. Ask for
+            // all source columns even when this MERGE only needs its keys,
+            // otherwise an unused column's mask/denial could be bypassed.
+            table
+                .new_read_builder()
+                .new_scan()
+                .authorize_merge_source()
+                .await?
+        } else {
+            builder.new_scan()
+        };
+        let plan = if query_auth && !incremental {
+            if let Some(snapshot) = snapshot {
+                scan.plan_selected_snapshot(snapshot, 
super::table_scan::SnapshotScanMode::All)
+                    .await?
+            } else {
+                super::source::Plan::new(Vec::new())
+            }
+        } else if incremental || snapshot.is_some() {
+            scan.with_scan_all_files().plan().await?
         } else {
             // Run the normal reader's policy checks even with no snapshot, but
             // do not observe files from a commit arriving after resolution.
             super::source::Plan::new(Vec::new())
         };
-        let mut stream = builder.new_read()?.to_arrow(plan.splits())?;
+        let read = builder.new_read()?;
+        if query_auth && plan.splits().is_empty() {
+            // Empty input carries no splits on which to stamp the live grant.
+            // Authorization above still runs for empty tables/windows.
+            return Self::new(vec![RecordBatch::new_empty(projected)]);
+        }
+        let mut stream = read.to_arrow(plan.splits())?;
         let mut batches = Vec::new();
         while let Some(batch) = stream.try_next().await? {
             if batch.num_rows() > 0 {
diff --git a/crates/paimon/src/table/table_merge_into_tests.rs 
b/crates/paimon/src/table/table_merge_into_tests.rs
index 2c520d21..6de570e2 100644
--- a/crates/paimon/src/table/table_merge_into_tests.rs
+++ b/crates/paimon/src/table/table_merge_into_tests.rs
@@ -703,6 +703,56 @@ async fn 
core_merge_empty_table_source_keeps_no_match_assignments_lazy() {
     assert_eq!(rows(&target).await, vec![(Some(1), Some(10))]);
 }
 
+#[tokio::test]
+async fn core_merge_full_startup_modes_select_source_snapshot() {
+    for mode in ["latest", "latest-full", "from-snapshot-full"] {
+        let target = table_at(&format!("target_full_{mode}"));
+        seed(&target, &batch(vec![Some(1)], vec![Some(10)])).await;
+        let source = table_at(&format!("source_full_{mode}"));
+        seed(&source, &batch(vec![Some(1)], vec![Some(11)])).await;
+        seed(&source, &batch(vec![Some(2)], vec![Some(22)])).await;
+        let mut options = 
std::collections::HashMap::from([("scan.mode".into(), mode.into())]);
+        if mode == "from-snapshot-full" {
+            options.insert("scan.snapshot-id".into(), "1".into());
+        }
+        let selected = source.copy_with_options(options);
+        let messages = target
+            .new_write_builder()
+            .new_update()
+            .unwrap()
+            .merge_into(
+                MergeSource::Table(Arc::new(selected)),
+                vec![("id".into(), "id".into())],
+                vec![update(None)],
+                vec![WhenNotMatched {
+                    condition: None,
+                    assignments: vec![
+                        ("id".into(), 
MergeAssignment::SourceColumn("id".into())),
+                        (
+                            "value".into(),
+                            MergeAssignment::SourceColumn("value".into()),
+                        ),
+                    ],
+                }],
+            )
+            .await
+            .unwrap();
+        TableCommit::new(target.clone(), "merge".into())
+            .commit(messages)
+            .await
+            .unwrap();
+        let mut expected = vec![(Some(1), Some(11))];
+        if mode != "from-snapshot-full" {
+            expected.push((Some(2), Some(22)));
+        }
+        assert_eq!(rows(&target).await, expected, "{mode}");
+        assert_eq!(
+            rows(&source).await,
+            vec![(Some(1), Some(11)), (Some(2), Some(22))]
+        );
+    }
+}
+
 #[tokio::test]
 async fn core_merge_empty_table_source_obeys_reader_authorization() {
     let target = table();
diff --git a/crates/paimon/src/table/table_scan.rs 
b/crates/paimon/src/table/table_scan.rs
index 4e89f05d..9e60f035 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -1144,6 +1144,25 @@ impl<'a> TableScan<'a> {
         }
     }
 
+    /// MERGE table sources follow plan_for_write: authorize the whole table,
+    /// then include L0 only when no row filter or column mask was returned.
+    /// Keep this scope internal; public metadata scans must still fail closed.
+    pub(super) async fn authorize_merge_source(&self) -> crate::Result<Self> {
+        let scan = self.authorize_snapshot_read().await?;
+        if let TableScanKind::Paimon(source) = &scan.0 {
+            if source
+                .query_auth_grant
+                .as_ref()
+                .is_some_and(|grant| !grant.is_unrestricted())
+            {
+                return Err(super::query_auth::unsupported(
+                    "MERGE table sources require unrestricted query 
authorization",
+                ));
+            }
+        }
+        Ok(scan)
+    }
+
     pub(super) fn validate_snapshot_read(&self) -> crate::Result<()> {
         let TableScanKind::Paimon(scan) = &self.0 else {
             unreachable!("snapshot scans only contain Paimon tables")
diff --git a/crates/paimon/tests/nullable_primary_key_test.rs 
b/crates/paimon/tests/nullable_primary_key_test.rs
new file mode 100644
index 00000000..cb69bf50
--- /dev/null
+++ b/crates/paimon/tests/nullable_primary_key_test.rs
@@ -0,0 +1,342 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+mod common;
+
+use arrow_array::{Array, ArrayRef, Int32Array, Int8Array, RecordBatch};
+use common::incremental_helpers::{memory_table, persist_table_schema, 
setup_dirs, write_batch};
+use futures::TryStreamExt;
+use paimon::spec::{DataType, IntType, Predicate, PredicateBuilder, Schema, 
TableSchema};
+use paimon::table::{MergeAssignment, MergeSource, Table, WhenMatched, 
WhenNotMatched};
+use std::collections::HashMap;
+use std::sync::Arc;
+
+type Row = (Option<i32>, Option<i32>, Option<i32>);
+
+async fn table(name: &str, options: &[(&str, &str)], primary_key: bool) -> 
Table {
+    let mut schema = Schema::builder()
+        // Engines may declare keys NOT NULL before applying the table option.
+        .column("pt", DataType::Int(IntType::with_nullable(!primary_key)))
+        .column("id", DataType::Int(IntType::with_nullable(!primary_key)))
+        .column("value", DataType::Int(IntType::new()));
+    if primary_key {
+        schema = schema
+            .primary_key(["pt", "id"])
+            .partition_keys(["pt"])
+            .option("primary-key.nullable", "true");
+    } else {
+        schema = schema
+            .option("data-evolution.enabled", "true")
+            .option("row-tracking.enabled", "true")
+            .option("deletion-vectors.enabled", "true");
+    }
+    for (key, value) in options {
+        schema = schema.option(*key, *value);
+    }
+    if options
+        .iter()
+        .any(|(key, value)| *key == "merge-engine" && *value == "aggregation")
+    {
+        schema = schema.option("fields.value.aggregate-function", "sum");
+    }
+    let path = format!("memory:/nullable/{name}");
+    let (io, table) = memory_table(&path, TableSchema::new(0, 
&schema.build().unwrap()));
+    setup_dirs(&io, &path).await;
+    persist_table_schema(&io, &path, table.schema()).await;
+    table
+}
+
+fn batch(rows: &[Row]) -> RecordBatch {
+    RecordBatch::try_from_iter(["pt", "id", 
"value"].into_iter().enumerate().map(
+        |(column, name)| {
+            let values: Int32Array = rows
+                .iter()
+                .map(|row| match column {
+                    0 => row.0,
+                    1 => row.1,
+                    _ => row.2,
+                })
+                .collect();
+            (name, Arc::new(values) as ArrayRef)
+        },
+    ))
+    .unwrap()
+}
+
+async fn read(table: &Table, predicate: Option<Predicate>) -> Vec<Row> {
+    let mut builder = table.new_read_builder();
+    if let Some(predicate) = predicate {
+        builder.with_filter(predicate);
+    }
+    // First-row source reads include L0, as Python plan_for_write does.
+    let plan = builder
+        .new_scan()
+        .with_scan_all_files()
+        .plan()
+        .await
+        .unwrap();
+    let batches: Vec<RecordBatch> = builder
+        .new_read()
+        .unwrap()
+        .to_arrow(plan.splits())
+        .unwrap()
+        .try_collect()
+        .await
+        .unwrap();
+    let mut rows = vec![];
+    for batch in batches {
+        let columns: Vec<_> = batch
+            .columns()
+            .iter()
+            .map(|array| array.as_any().downcast_ref::<Int32Array>().unwrap())
+            .collect();
+        for row in 0..batch.num_rows() {
+            let get =
+                |column: usize| (!columns[column].is_null(row)).then(|| 
columns[column].value(row));
+            rows.push((get(0), get(1), get(2)));
+        }
+    }
+    rows.sort_unstable();
+    rows
+}
+
+#[tokio::test]
+async fn 
nullable_composite_keys_merge_across_writer_restarts_and_bucket_modes() {
+    for bucket in ["1", "3", "-1"] {
+        for engine in ["deduplicate", "partial-update", "aggregation", 
"first-row"] {
+            let table = table(
+                &format!("{bucket}-{engine}"),
+                &[("bucket", bucket), ("merge-engine", engine)],
+                true,
+            )
+            .await;
+            let first = [
+                (None, None, Some(10)),
+                (Some(1), None, Some(100)),
+                (Some(1), Some(0), Some(1)),
+            ];
+            write_batch(&table, &batch(&first)).await;
+            // A new writer must restore dynamic bucket routing for null keys.
+            write_batch(
+                &table,
+                &batch(&[
+                    (None, None, Some(20)),
+                    (Some(1), None, Some(200)),
+                    (None, Some(0), Some(2)),
+                ]),
+            )
+            .await;
+            let values = match engine {
+                "aggregation" => (30, 300),
+                "first-row" => (10, 100),
+                _ => (20, 200),
+            };
+            let expected = vec![
+                (None, None, Some(values.0)),
+                (None, Some(0), Some(2)),
+                (Some(1), None, Some(values.1)),
+                (Some(1), Some(0), Some(1)),
+            ];
+            assert_eq!(read(&table, None).await, expected, 
"{bucket}/{engine}");
+            let pb = PredicateBuilder::new(table.schema().fields());
+            assert_eq!(
+                read(&table, Some(pb.is_null("id").unwrap())).await,
+                vec![expected[0], expected[2]],
+                "{bucket}/{engine}"
+            );
+            assert_eq!(
+                read(&table, Some(pb.is_null("pt").unwrap())).await,
+                expected[..2],
+                "{bucket}/{engine}"
+            );
+            let first_view = table.copy_with_options(HashMap::from([
+                ("scan.mode".into(), "from-snapshot-full".into()),
+                ("scan.snapshot-id".into(), "1".into()),
+            ]));
+            assert_eq!(read(&first_view, None).await, first);
+        }
+    }
+}
+
+#[tokio::test]
+async fn null_key_restores_its_nonzero_dynamic_bucket() {
+    let table = table(
+        "null-nonzero-bucket",
+        &[("bucket", "-1"), ("dynamic-bucket.target-row-num", "1")],
+        true,
+    )
+    .await;
+    write_batch(
+        &table,
+        &batch(&[(None, Some(0), Some(100)), (None, None, Some(10))]),
+    )
+    .await;
+    for value in [20, 30] {
+        let builder = table.new_write_builder();
+        let mut writer = builder.new_write().unwrap();
+        writer
+            .write_arrow_batch(&batch(&[(None, None, Some(value))]))
+            .await
+            .unwrap();
+        let messages = writer.prepare_commit().await.unwrap();
+        assert_eq!(messages.len(), 1);
+        assert_eq!(messages[0].bucket, 1);
+        builder.new_commit().commit(messages).await.unwrap();
+    }
+    assert_eq!(
+        read(&table, None).await,
+        vec![(None, None, Some(30)), (None, Some(0), Some(100))]
+    );
+}
+
+#[tokio::test]
+async fn null_key_retract_does_not_delete_zero_or_other_partition_keys() {
+    for bucket in ["3", "-1"] {
+        let table = table(&format!("delete-{bucket}"), &[("bucket", bucket)], 
true).await;
+        write_batch(
+            &table,
+            &batch(&[
+                (None, None, Some(10)),
+                (None, Some(0), Some(20)),
+                (Some(1), None, Some(30)),
+            ]),
+        )
+        .await;
+        let mut retract = batch(&[(None, None, Some(10))]);
+        let mut fields = retract.schema().fields().to_vec();
+        fields.push(Arc::new(arrow_schema::Field::new(
+            "_VALUE_KIND",
+            arrow_schema::DataType::Int8,
+            false,
+        )));
+        let mut columns = retract.columns().to_vec();
+        columns.push(Arc::new(Int8Array::from(vec![3])));
+        retract =
+            RecordBatch::try_new(Arc::new(arrow_schema::Schema::new(fields)), 
columns).unwrap();
+        write_batch(&table, &retract).await;
+        assert_eq!(
+            read(&table, None).await,
+            vec![(None, Some(0), Some(20)), (Some(1), None, Some(30))]
+        );
+        write_batch(&table, &batch(&[(None, None, Some(40))])).await;
+        assert_eq!(
+            read(&table, None).await,
+            vec![
+                (None, None, Some(40)),
+                (None, Some(0), Some(20)),
+                (Some(1), None, Some(30)),
+            ]
+        );
+    }
+}
+
+#[tokio::test]
+async fn nullable_primary_key_merge_source_keeps_sql_null_join_semantics() {
+    for engine in ["deduplicate", "partial-update", "aggregation", 
"first-row"] {
+        let source = table(
+            &format!("source-{engine}"),
+            &[("bucket", "-1"), ("merge-engine", engine)],
+            true,
+        )
+        .await;
+        write_batch(
+            &source,
+            &batch(&[
+                (None, None, Some(10)),
+                (None, Some(1), Some(11)),
+                (Some(1), None, Some(12)),
+                (Some(1), Some(1), Some(100)),
+            ]),
+        )
+        .await;
+        write_batch(
+            &source,
+            &batch(&[
+                (None, None, Some(20)),
+                (None, Some(1), Some(21)),
+                (Some(1), None, Some(22)),
+                (Some(1), Some(1), Some(200)),
+            ]),
+        )
+        .await;
+        let target = table(&format!("target-{engine}"), &[], false).await;
+        write_batch(
+            &target,
+            &batch(&[
+                (None, None, Some(-1)),
+                (None, Some(1), Some(-3)),
+                (Some(1), None, Some(-4)),
+                (Some(1), Some(1), Some(-2)),
+            ]),
+        )
+        .await;
+        let builder = target.new_write_builder();
+        let messages = builder
+            .new_update()
+            .unwrap()
+            .merge_into(
+                MergeSource::Table(Arc::new(source.clone())),
+                vec![("pt".into(), "pt".into()), ("id".into(), "id".into())],
+                vec![WhenMatched {
+                    condition: None,
+                    delete: false,
+                    assignments: vec![(
+                        "value".into(),
+                        MergeAssignment::SourceColumn("value".into()),
+                    )],
+                }],
+                vec![WhenNotMatched {
+                    condition: None,
+                    assignments: ["pt", "id", "value"]
+                        .into_iter()
+                        .map(|name| (name.into(), 
MergeAssignment::SourceColumn(name.into())))
+                        .collect(),
+                }],
+            )
+            .await
+            .unwrap();
+        builder.new_commit().commit(messages).await.unwrap();
+        let values = match engine {
+            "aggregation" => (30, 32, 34, 300),
+            "first-row" => (10, 11, 12, 100),
+            _ => (20, 21, 22, 200),
+        };
+        // PK merging is null-safe, but MERGE's SQL equality must not match 
nulls.
+        assert_eq!(
+            read(&target, None).await,
+            vec![
+                (None, None, Some(-1)),
+                (None, None, Some(values.0)),
+                (None, Some(1), Some(-3)),
+                (None, Some(1), Some(values.1)),
+                (Some(1), None, Some(-4)),
+                (Some(1), None, Some(values.2)),
+                (Some(1), Some(1), Some(values.3)),
+            ]
+        );
+        assert_eq!(
+            source
+                .snapshot_manager()
+                .get_latest_snapshot()
+                .await
+                .unwrap()
+                .unwrap()
+                .id(),
+            2
+        );
+    }
+}
diff --git a/crates/paimon/tests/rest_catalog/merge_sources.rs 
b/crates/paimon/tests/rest_catalog/merge_sources.rs
new file mode 100644
index 00000000..1fcf5bb1
--- /dev/null
+++ b/crates/paimon/tests/rest_catalog/merge_sources.rs
@@ -0,0 +1,285 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use super::*;
+use paimon::table::{MergeAssignment, MergeSource, TableCommit, WhenMatched, 
WhenNotMatched};
+
+fn target() -> Table {
+    Table::new(
+        paimon::io::FileIOBuilder::new("memory").build().unwrap(),
+        Identifier::new("default", "merge_target"),
+        "memory:/merge_target".into(),
+        paimon::spec::TableSchema::new(
+            0,
+            &people(&[
+                ("data-evolution.enabled", "true"),
+                ("row-tracking.enabled", "true"),
+                ("deletion-vectors.enabled", "true"),
+            ]),
+        ),
+        None,
+    )
+}
+
+async fn merge(target: &Table, source: &Table, delete: bool) -> 
paimon::Result<()> {
+    let messages = target
+        .new_write_builder()
+        .new_update()?
+        .merge_into(
+            MergeSource::Table(Arc::new(source.clone())),
+            vec![("id".into(), "id".into())],
+            vec![WhenMatched {
+                condition: None,
+                delete,
+                assignments: if delete {
+                    Vec::new()
+                } else {
+                    vec![("name".into(), 
MergeAssignment::SourceColumn("name".into()))]
+                },
+            }],
+            if delete {
+                Vec::new()
+            } else {
+                vec![WhenNotMatched {
+                    condition: None,
+                    assignments: vec![
+                        ("id".into(), 
MergeAssignment::SourceColumn("id".into())),
+                        ("name".into(), 
MergeAssignment::SourceColumn("name".into())),
+                    ],
+                }]
+            },
+        )
+        .await?;
+    TableCommit::new(target.clone(), "authorized-merge".into())
+        .commit(messages)
+        .await?;
+    Ok(())
+}
+
+#[tokio::test]
+async fn unrestricted_source_supports_merge_and_keeps_metadata_scans_closed() {
+    let g = written("merge_source", people, vec![people_rows(1..=2)]).await;
+    g.set_rules(&[], &[]);
+    let target = target();
+    write_batch(&target, people_batch(&[(1, "old"), (3, "keep")]), 
"seed").await;
+    merge(&target, &g.table, false).await.unwrap();
+    assert_eq!(
+        id_names(&read_all(&target.new_read_builder()).await.unwrap()),
+        [
+            (1, Some("alice".into())),
+            (2, Some("bob".into())),
+            (3, Some("keep".into())),
+        ]
+    );
+    assert_eq!(g.ctx.server.auth_selects("default", "merge_source"), [None]);
+    // An unrestricted data-read grant must not expose a public metadata 
bypass.
+    assert!(g
+        .table
+        .new_read_builder()
+        .new_scan()
+        .with_scan_all_files()
+        .plan()
+        .await
+        .is_err());
+}
+
+#[tokio::test]
+async fn 
unrestricted_source_authorizes_empty_tables_and_keeps_historical_guard() {
+    let g = written(
+        "merge_history",
+        people,
+        vec![people_rows(1..=1), people_rows(2..=2)],
+    )
+    .await;
+    g.set_rules(&[], &[]);
+    let snapshot = g.table.snapshot_manager().get_snapshot(1).await.unwrap();
+    for view in [
+        g.table.copy_with_pinned_snapshot(None),
+        g.table.copy_with_pinned_snapshot(Some(&snapshot)),
+    ] {
+        let target = target();
+        write_batch(&target, people_batch(&[(1, "old")]), "seed").await;
+        let error = merge(&target, &view, false).await.unwrap_err();
+        assert!(error.to_string().contains("time-travelled"), "{error}");
+        assert_eq!(
+            id_names(&read_all(&target.new_read_builder()).await.unwrap()),
+            [(1, Some("old".into()))]
+        );
+    }
+    let empty = written("merge_empty", people, vec![]).await;
+    empty.set_rules(&[], &[]);
+    let target = target();
+    write_batch(&target, people_batch(&[(1, "old")]), "seed").await;
+    merge(&target, &empty.table, true).await.unwrap();
+    assert_eq!(
+        ids(&read_all(&target.new_read_builder()).await.unwrap()),
+        [1]
+    );
+    assert_eq!(
+        empty.ctx.server.auth_selects("default", "merge_empty"),
+        [None]
+    );
+}
+
+#[tokio::test]
+async fn 
merge_rejects_restricted_sources_even_for_unused_fields_or_empty_views() {
+    for empty in [false, true] {
+        let source = if empty {
+            // The empty branch here is a genuinely empty current table, not
+            // a historical handle whose authorization is separately refused.
+            written("merge_rules_empty", people, vec![]).await
+        } else {
+            written("merge_rules_full", people, vec![people_rows(1..=2)]).await
+        };
+        let target = target();
+        write_batch(&target, people_batch(&[(1, "old")]), "seed").await;
+        for mask in [false, true] {
+            if mask {
+                source.set_rules(&[], &[("name", upper(1, "name"))]);
+            } else {
+                source.set_rules(&[int_leaf(0, "id", "GREATER_THAN", 1)], &[]);
+            }
+            // DELETE matches only `id`: an unused column's mask still makes 
the
+            // source unsuitable for Python's existing plan_for_write contract.
+            let error = merge(&target, &source.table, true).await.unwrap_err();
+            assert!(error.to_string().contains("unrestricted"), "{error}");
+            assert_eq!(
+                ids(&read_all(&target.new_read_builder()).await.unwrap()),
+                [1]
+            );
+            assert_eq!(
+                target
+                    .snapshot_manager()
+                    .get_latest_snapshot_id()
+                    .await
+                    .unwrap(),
+                Some(1)
+            );
+        }
+        assert!(source
+            .ctx
+            .server
+            .auth_selects("default", source.identifier.object())
+            .iter()
+            .all(Option::is_none));
+    }
+}
+
+#[tokio::test]
+async fn merge_authorizes_all_source_columns_and_refreshes_each_operation() {
+    let g = written("merge_columns", people, vec![people_rows(1..=2)]).await;
+    g.set_rules(&[], &[]);
+    let target = target();
+    write_batch(&target, people_batch(&[(1, "old")]), "seed").await;
+    g.ctx
+        .server
+        .set_column_auth("default", "merge_columns", vec!["id".into()]);
+    // Key-only matching must not reduce a table source to a different select.
+    assert_forbidden(merge(&target, &g.table, true).await.unwrap_err());
+    assert_eq!(
+        ids(&read_all(&target.new_read_builder()).await.unwrap()),
+        [1]
+    );
+    g.ctx
+        .server
+        .set_column_auth("default", "merge_columns", vec!["id".into(), 
"name".into()]);
+    merge(&target, &g.table, false).await.unwrap();
+    g.set_rules(&[int_leaf(0, "id", "GREATER_THAN", 1)], &[]);
+    let error = merge(&target, &g.table, true).await.unwrap_err();
+    assert!(error.to_string().contains("unrestricted"), "{error}");
+    assert_eq!(
+        ids(&read_all(&target.new_read_builder()).await.unwrap()),
+        [1, 2]
+    );
+}
+
+#[tokio::test]
+async fn unrestricted_pk_source_merges_versions_including_level_zero() {
+    let g = written(
+        "merge_pk_source",
+        |options| {
+            let mut builder = Schema::builder()
+                .column("id", DataType::Int(IntType::new()))
+                .column("name", 
DataType::VarChar(VarCharType::new(255).unwrap()))
+                .primary_key(["id"])
+                .option("bucket", "1");
+            for (key, value) in options {
+                builder = builder.option(*key, *value);
+            }
+            builder.build().unwrap()
+        },
+        vec![people_batch(&[(1, "old"), (2, "bob")]), people_rows(1..=1)],
+    )
+    .await;
+    g.set_rules(&[], &[]);
+    let target = target();
+    write_batch(&target, people_batch(&[(1, "target")]), "seed").await;
+    merge(&target, &g.table, false).await.unwrap();
+    assert_eq!(
+        id_names(&read_all(&target.new_read_builder()).await.unwrap()),
+        [(1, Some("alice".into())), (2, Some("bob".into()))]
+    );
+}
+
+#[tokio::test]
+async fn 
unrestricted_de_and_timestamp_window_sources_preserve_reader_semantics() {
+    for data_evolution in [false, true] {
+        let g = written(
+            "merge_window_source",
+            |options| {
+                if data_evolution {
+                    people(
+                        &[
+                            options,
+                            &[
+                                ("data-evolution.enabled", "true"),
+                                ("row-tracking.enabled", "true"),
+                                ("deletion-vectors.enabled", "true"),
+                            ],
+                        ]
+                        .concat(),
+                    )
+                } else {
+                    people(options)
+                }
+            },
+            vec![people_rows(1..=1), people_rows(2..=2)],
+        )
+        .await;
+        g.set_rules(&[], &[]);
+        let source = if data_evolution {
+            g.table.clone()
+        } else {
+            g.table.copy_with_options(HashMap::from([(
+                "incremental-between-timestamp".into(),
+                "0,9223372036854775807".into(),
+            )]))
+        };
+        let target = target();
+        write_batch(&target, people_batch(&[(1, "old")]), "seed").await;
+        merge(&target, &source, false).await.unwrap();
+        assert_eq!(
+            id_names(&read_all(&target.new_read_builder()).await.unwrap()),
+            [(1, Some("alice".into())), (2, Some("bob".into()))]
+        );
+        // Combined timestamp planning shares the same live grant.
+        assert_eq!(
+            g.ctx.server.auth_selects("default", "merge_window_source"),
+            [None]
+        );
+    }
+}
diff --git a/crates/paimon/tests/rest_catalog_test.rs 
b/crates/paimon/tests/rest_catalog_test.rs
index d51532cd..8f0e95b8 100644
--- a/crates/paimon/tests/rest_catalog_test.rs
+++ b/crates/paimon/tests/rest_catalog_test.rs
@@ -5820,3 +5820,7 @@ async fn test_query_auth_explicit_partition_selector() {
 #[cfg(not(windows))]
 #[path = "rest_catalog/authorized_snapshot_scans.rs"]
 mod authorized_snapshot_scans;
+
+#[cfg(not(windows))]
+#[path = "rest_catalog/merge_sources.rs"]
+mod merge_sources;

Reply via email to