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 a6d37a9e fix(spec): validate final schema consistently (#660)
a6d37a9e is described below
commit a6d37a9e027250ae1a238fa35f5679d0a2a2029d
Author: QuakeWang <[email protected]>
AuthorDate: Tue Aug 4 13:34:32 2026 +0800
fix(spec): validate final schema consistently (#660)
---
crates/paimon/src/spec/core_options.rs | 36 +-
crates/paimon/src/spec/schema.rs | 928 ++++++++++++++++++++-
.../paimon/src/table/full_text_search_builder.rs | 1 +
crates/paimon/src/table/vector_search_builder.rs | 57 +-
4 files changed, 944 insertions(+), 78 deletions(-)
diff --git a/crates/paimon/src/spec/core_options.rs
b/crates/paimon/src/spec/core_options.rs
index dfb64911..e33cd09b 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -1227,8 +1227,8 @@ impl<'a> CoreOptions<'a> {
self.options.contains_key(PK_VECTOR_INDEX_COLUMNS_OPTION)
}
- /// The configured PK-vector index columns, split on ',' and trimmed.
Errors when
- /// the key is present but resolves to no non-blank column.
+ /// The configured PK-vector index columns, split on ',' and trimmed.
Blank tokens
+ /// are preserved so schema validation can reject malformed column lists.
pub fn primary_key_vector_index_columns(&self) ->
crate::Result<Vec<String>> {
let raw = self
.options
@@ -1236,17 +1236,7 @@ impl<'a> CoreOptions<'a> {
.ok_or_else(|| crate::Error::ConfigInvalid {
message: "pk-vector.index.columns is not set".to_string(),
})?;
- let columns: Vec<String> = raw
- .split(',')
- .map(|c| c.trim().to_string())
- .filter(|c| !c.is_empty())
- .collect();
- if columns.is_empty() {
- return Err(crate::Error::ConfigInvalid {
- message: "pk-vector.index.columns is set but names no
column".to_string(),
- });
- }
- Ok(columns)
+ Ok(raw.split(',').map(|c| c.trim().to_string()).collect())
}
/// The single PK-vector index column. The first release supports exactly
one.
@@ -2396,11 +2386,21 @@ mod tests {
}
#[test]
- fn test_pk_vector_empty_columns_errors() {
- let opts = HashMap::from([("pk-vector.index.columns".to_string(), " ,
".to_string())]);
- let co = CoreOptions::new(&opts);
- assert!(co.primary_key_vector_index_enabled()); // key present
- assert!(co.primary_key_vector_index_columns().is_err());
+ fn test_pk_vector_index_columns_preserve_blank_tokens() {
+ for (value, expected) in [
+ ("embedding,", vec!["embedding", ""]),
+ (",embedding", vec!["", "embedding"]),
+ ("embedding,,", vec!["embedding", "", ""]),
+ (" ", vec![""]),
+ ] {
+ let opts = HashMap::from([("pk-vector.index.columns".to_string(),
value.to_string())]);
+ assert_eq!(
+ CoreOptions::new(&opts)
+ .primary_key_vector_index_columns()
+ .unwrap(),
+ expected
+ );
+ }
}
#[test]
diff --git a/crates/paimon/src/spec/schema.rs b/crates/paimon/src/spec/schema.rs
index cc3bb46d..ce7d1c40 100644
--- a/crates/paimon/src/spec/schema.rs
+++ b/crates/paimon/src/spec/schema.rs
@@ -18,7 +18,7 @@
use crate::spec::core_options::{
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,
+ POSTPONE_BUCKET, QUERY_AUTH_ENABLED_OPTION, SEQUENCE_FIELD_OPTION,
};
use crate::spec::types::{ArrayType, DataType, MapType, MultisetType, RowType,
VarCharType};
use crate::spec::{
@@ -268,6 +268,20 @@ impl TableSchema {
.get(crate::spec::DISABLE_EXPLICIT_TYPE_CASTING_OPTION)
.map(|v| v != "true")
.unwrap_or(true);
+ // Capture stable IDs before applying changes so removing the option or
+ // renaming its column cannot bypass historical bucket-key protection.
+ let old_bucket_key_field_ids: HashSet<i32> = self
+ .core_options()
+ .bucket_key()
+ .into_iter()
+ .flatten()
+ .filter_map(|name| {
+ self.fields
+ .iter()
+ .find(|field| field.name() == name)
+ .map(DataField::id)
+ })
+ .collect();
let mut new_schema = self.clone();
new_schema.id += 1;
@@ -338,6 +352,11 @@ impl TableSchema {
full_name: full_name.to_string(),
column: name.to_string(),
})?;
+ if fields[idx].data_type().is_blob_file_field() {
+ return Err(crate::Error::Unsupported {
+ message: format!("Cannot rename BLOB column:
[{name}]"),
+ });
+ }
if new_name != name && field_index(&fields,
&new_name).is_some() {
return Err(crate::Error::ColumnAlreadyExist {
full_name: full_name.to_string(),
@@ -382,6 +401,11 @@ impl TableSchema {
});
}
assert_not_updating_primary_key_index_column(&self.options, name, "drop")?;
+ assert_not_updating_bucket_key_column(
+ &old_bucket_key_field_ids,
+ &fields[idx],
+ "drop",
+ )?;
// Dropping a column referenced by `bucket-key` /
`sequence.field`
// would silently break bucket assignment / sequence
ordering on
// existing data (e.g. `bucket_key_indices` becomes empty
and writes
@@ -446,10 +470,15 @@ impl TableSchema {
column: name.to_string(),
})?;
let old = &fields[idx];
+ assert_not_updating_bucket_key_column(
+ &old_bucket_key_field_ids,
+ old,
+ "update type of",
+ )?;
// Mirrors Java `assertNotChangingBlobColumnType`: BLOB
// columns use a dedicated storage layout that other types
// cannot be converted to or from.
- if old.data_type().is_blob_type() ||
new_data_type.is_blob_type() {
+ if old.data_type().is_blob_file_field() ||
new_data_type.is_blob_file_field() {
return Err(crate::Error::Unsupported {
message: format!(
"Cannot change column type involving BLOB:
[{name}] {:?} -> {new_data_type:?}",
@@ -547,41 +576,12 @@ impl TableSchema {
});
}
- // Re-run create-time validations on the final schema, mirroring Java
- // `SchemaValidation.validateTableSchema` after applying changes.
- validate_no_reserved_field_names(&new_schema.fields)?;
- Schema::validate_key_field_types(
- &new_schema.fields,
- &new_schema.primary_keys,
- &new_schema.options,
- )?;
- Schema::validate_blob_fields(
- &new_schema.fields,
- &new_schema.partition_keys,
- &new_schema.options,
- )?;
- Schema::validate_vector_store_fields(
+ Schema::validate_final_schema(
&new_schema.fields,
&new_schema.partition_keys,
- &new_schema.options,
- )?;
- PartialUpdateConfig::new(&new_schema.options)
- .validate_create_mode(!new_schema.primary_keys.is_empty())?;
- 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,
- )?;
- Schema::validate_bucket_keys(
&new_schema.options,
- &new_schema.fields,
- &new_schema.partition_keys,
- &new_schema.primary_keys,
)?;
- Schema::validate_read_batch_size(&new_schema.options)?;
Ok(new_schema)
}
@@ -712,6 +712,19 @@ fn assert_not_updating_primary_key_index_column(
Ok(())
}
+fn assert_not_updating_bucket_key_column(
+ old_bucket_key_field_ids: &HashSet<i32>,
+ field: &DataField,
+ operation: &str,
+) -> crate::Result<()> {
+ if old_bucket_key_field_ids.contains(&field.id()) {
+ return Err(crate::Error::Unsupported {
+ message: format!("Cannot {operation} bucket-key column: [{}]",
field.name()),
+ });
+ }
+ Ok(())
+}
+
/// Rename a key in a partition/primary key list, if present.
fn rename_in_keys(keys: &mut [String], old: &str, new: &str) {
for key in keys.iter_mut() {
@@ -1101,16 +1114,7 @@ impl Schema {
let partition_keys = Self::normalize_partition_keys(&partition_keys,
&mut options)?;
Self::normalize_blob_comment_directives(&mut fields, &mut options)?;
let fields = Self::normalize_fields(&fields, &partition_keys,
&primary_keys, &options)?;
- validate_no_reserved_field_names(&fields)?;
- Self::validate_key_field_types(&fields, &primary_keys, &options)?;
- Self::validate_blob_fields(&fields, &partition_keys, &options)?;
- Self::validate_vector_store_fields(&fields, &partition_keys,
&options)?;
-
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)?;
- Self::validate_bucket_keys(&options, &fields, &partition_keys,
&primary_keys)?;
- Self::validate_read_batch_size(&options)?;
+ Self::validate_final_schema(&fields, &partition_keys, &primary_keys,
&options)?;
Ok(Self {
fields,
@@ -1121,6 +1125,28 @@ impl Schema {
})
}
+ fn validate_final_schema(
+ fields: &[DataField],
+ partition_keys: &[String],
+ primary_keys: &[String],
+ options: &HashMap<String, String>,
+ ) -> crate::Result<()> {
+ validate_no_reserved_field_names(fields)?;
+ Self::validate_key_field_types(fields, primary_keys, options)?;
+ Self::validate_blob_fields(fields, partition_keys, options)?;
+ Self::validate_vector_store_fields(fields, partition_keys, options)?;
+
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)?;
+ Self::validate_deletion_vectors(options)?;
+ Self::validate_bucket_keys(options, fields, partition_keys,
primary_keys)?;
+ Self::validate_read_batch_size(options)?;
+ Self::validate_primary_key_vector_index(fields, primary_keys,
options)?;
+ Self::validate_primary_key_full_text_index(fields, primary_keys,
options)?;
+ Ok(())
+ }
+
/// Normalize primary keys: optionally take from table options
(`primary-key`), remove from options.
/// Corresponds to Java `normalizePrimaryKeys`.
fn normalize_primary_keys(
@@ -1471,6 +1497,35 @@ impl Schema {
})
}
+ fn validate_deletion_vectors(options: &HashMap<String, String>) ->
crate::Result<()> {
+ let core = CoreOptions::new(options);
+ if !core.deletion_vectors_enabled() {
+ return Ok(());
+ }
+
+ let changelog_producer = core
+ .try_changelog_producer()
+ .map_err(Self::options_error_to_config_invalid)?;
+ if !matches!(
+ changelog_producer,
+ ChangelogProducer::None | ChangelogProducer::Input |
ChangelogProducer::Lookup
+ ) {
+ return Err(crate::Error::ConfigInvalid {
+ message: "Deletion vectors mode is only supported for
NONE/INPUT/LOOKUP changelog producer now.".to_string(),
+ });
+ }
+
+ let merge_engine = core
+ .merge_engine()
+ .map_err(Self::options_error_to_config_invalid)?;
+ if merge_engine == MergeEngine::FirstRow {
+ return Err(crate::Error::ConfigInvalid {
+ message: "First row merge engine does not need deletion
vectors because there is no deletion of old data in this merge
engine.".to_string(),
+ });
+ }
+ Ok(())
+ }
+
fn validate_rowkind_field(
options: &HashMap<String, String>,
primary_keys: &[String],
@@ -1592,6 +1647,146 @@ impl Schema {
.map_err(Self::options_error_to_config_invalid)
}
+ fn validate_primary_key_vector_index(
+ fields: &[DataField],
+ primary_keys: &[String],
+ options: &HashMap<String, String>,
+ ) -> crate::Result<()> {
+ let core = CoreOptions::new(options);
+ if !core.primary_key_vector_index_enabled() {
+ return Ok(());
+ }
+
+ let column = core.primary_key_vector_index_column()?;
+ if column.is_empty() {
+ return Err(crate::Error::ConfigInvalid {
+ message: "pk-vector.index.columns must name a non-empty
column".to_string(),
+ });
+ }
+ core.primary_key_vector_index_type(&column)?;
+ Self::validate_primary_key_index_prerequisites("vector", primary_keys,
&core)?;
+
+ let field = fields
+ .iter()
+ .find(|field| field.name() == column)
+ .ok_or_else(|| crate::Error::ConfigInvalid {
+ message: format!(
+ "pk-vector.index.columns entry '{column}' must reference
an existing column."
+ ),
+ })?;
+ let supported_type = match field.data_type() {
+ DataType::Vector(vector) => matches!(vector.element_type(),
DataType::Float(_)),
+ DataType::Array(array) => matches!(array.element_type(),
DataType::Float(_)),
+ _ => false,
+ };
+ if !supported_type {
+ return Err(crate::Error::ConfigInvalid {
+ message: format!(
+ "pk-vector.index.columns entry '{column}' must reference
an ARRAY<FLOAT> or VECTOR<FLOAT> column."
+ ),
+ });
+ }
+
+ core.primary_key_vector_distance_metric(&column)
+ .map(|_| ())
+ .map_err(Self::options_error_to_config_invalid)
+ }
+
+ fn validate_primary_key_full_text_index(
+ fields: &[DataField],
+ primary_keys: &[String],
+ options: &HashMap<String, String>,
+ ) -> crate::Result<()> {
+ let core = CoreOptions::new(options);
+ if !core.primary_key_full_text_index_enabled() {
+ return Ok(());
+ }
+
+ let columns = core.primary_key_full_text_index_columns();
+ if columns.len() != 1 {
+ return Err(crate::Error::ConfigInvalid {
+ message: format!(
+ "pk-full-text.index.columns must name exactly one column,
got {}",
+ columns.len()
+ ),
+ });
+ }
+ let column = &columns[0];
+ if column.is_empty() {
+ return Err(crate::Error::ConfigInvalid {
+ message: "pk-full-text.index.columns must name a non-empty
column".to_string(),
+ });
+ }
+
+ Self::validate_primary_key_index_prerequisites("full-text",
primary_keys, &core)?;
+ if core.primary_key_vector_index_enabled()
+ && core.primary_key_vector_index_column()? == *column
+ {
+ return Err(crate::Error::ConfigInvalid {
+ message: format!(
+ "Primary-key vector and full-text indexes cannot reference
the same column: '{column}'."
+ ),
+ });
+ }
+
+ let field = fields
+ .iter()
+ .find(|field| field.name() == column)
+ .ok_or_else(|| crate::Error::ConfigInvalid {
+ message: format!(
+ "pk-full-text.index.columns entry '{column}' must
reference an existing column."
+ ),
+ })?;
+ if !matches!(field.data_type(), DataType::Char(_) |
DataType::VarChar(_)) {
+ return Err(crate::Error::ConfigInvalid {
+ message: format!(
+ "pk-full-text.index.columns entry '{column}' must
reference a CHAR or VARCHAR column."
+ ),
+ });
+ }
+ Ok(())
+ }
+
+ fn validate_primary_key_index_prerequisites(
+ index_name: &str,
+ primary_keys: &[String],
+ core: &CoreOptions<'_>,
+ ) -> crate::Result<()> {
+ if primary_keys.is_empty() {
+ return Err(crate::Error::ConfigInvalid {
+ message: format!("Primary-key {index_name} index requires a
primary-key table."),
+ });
+ }
+
+ let merge_engine = core
+ .merge_engine()
+ .map_err(Self::options_error_to_config_invalid)?;
+ if merge_engine != MergeEngine::FirstRow &&
!core.deletion_vectors_enabled() {
+ return Err(crate::Error::ConfigInvalid {
+ message: format!(
+ "Primary-key {index_name} index requires
deletion-vectors.enabled = true."
+ ),
+ });
+ }
+ if core.deletion_vectors_enabled() &&
core.deletion_vectors_merge_on_read() {
+ return Err(crate::Error::ConfigInvalid {
+ message: format!(
+ "Primary-key {index_name} index requires
deletion-vectors.merge-on-read = false."
+ ),
+ });
+ }
+
+ let bucket = core.bucket();
+ if bucket <= 0 && bucket != POSTPONE_BUCKET {
+ return Err(crate::Error::ConfigInvalid {
+ message: format!(
+ "Primary-key {index_name} index requires fixed or postpone
bucket mode (bucket > 0 or bucket = -2), but bucket is {bucket}."
+ ),
+ });
+ }
+ Ok(())
+ }
+
/// Returns top-level Blob field names for create-time Blob contract
checks.
fn top_level_blob_field_names(fields: &[DataField]) -> Vec<&str> {
fields
@@ -1811,10 +2006,101 @@ impl Default for SchemaBuilder {
#[cfg(test)]
mod tests {
- use crate::spec::{BlobType, FloatType, IntType, VarCharType, VectorType};
+ use crate::spec::{BlobType, CharType, FloatType, IntType, VarCharType,
VectorType};
use super::*;
+ fn build_index_test_schema(
+ columns: Vec<(&str, DataType)>,
+ primary_keys: &[&str],
+ options: &[(&str, &str)],
+ ) -> crate::Result<Schema> {
+ let mut builder = Schema::builder();
+ for (name, data_type) in columns {
+ builder = builder.column(name, data_type);
+ }
+ if !primary_keys.is_empty() {
+ builder = builder.primary_key(primary_keys.iter().copied());
+ }
+ for (key, value) in options {
+ builder = builder.option(*key, *value);
+ }
+ builder.build()
+ }
+
+ fn assert_config_invalid<T: std::fmt::Debug>(result: crate::Result<T>,
expected_message: &str) {
+ let error = result.unwrap_err();
+ assert!(
+ matches!(&error, crate::Error::ConfigInvalid { message }
+ if message.contains(expected_message)),
+ "expected ConfigInvalid containing '{expected_message}', got
{error:?}"
+ );
+ }
+
+ fn build_vector_index_test_schema(
+ data_type: DataType,
+ primary_keys: &[&str],
+ configure: impl FnOnce(&mut HashMap<String, String>),
+ ) -> crate::Result<Schema> {
+ let mut options = HashMap::from([
+ ("bucket".to_string(), "1".to_string()),
+ ("deletion-vectors.enabled".to_string(), "true".to_string()),
+ (
+ "pk-vector.index.columns".to_string(),
+ "embedding".to_string(),
+ ),
+ (
+ "fields.embedding.pk-vector.index.type".to_string(),
+ "ivf-flat".to_string(),
+ ),
+ (
+ "fields.embedding.pk-vector.distance.metric".to_string(),
+ "l2".to_string(),
+ ),
+ ]);
+ configure(&mut options);
+ let option_refs: Vec<(&str, &str)> = options
+ .iter()
+ .map(|(key, value)| (key.as_str(), value.as_str()))
+ .collect();
+ build_index_test_schema(
+ vec![
+ ("id", DataType::Int(IntType::new())),
+ ("embedding", data_type),
+ ],
+ primary_keys,
+ &option_refs,
+ )
+ }
+
+ fn build_full_text_index_test_schema(
+ data_type: DataType,
+ primary_keys: &[&str],
+ configure: impl FnOnce(&mut HashMap<String, String>),
+ ) -> crate::Result<Schema> {
+ let mut options = HashMap::from([
+ ("bucket".to_string(), "1".to_string()),
+ ("deletion-vectors.enabled".to_string(), "true".to_string()),
+ (
+ "pk-full-text.index.columns".to_string(),
+ "content".to_string(),
+ ),
+ ]);
+ configure(&mut options);
+ let option_refs: Vec<(&str, &str)> = options
+ .iter()
+ .map(|(key, value)| (key.as_str(), value.as_str()))
+ .collect();
+ build_index_test_schema(
+ vec![
+ ("id", DataType::Int(IntType::new())),
+ ("content", data_type),
+ ],
+ primary_keys,
+ &option_refs,
+ )
+ }
+
#[test]
fn test_create_data_field() {
let id = 1;
@@ -2597,6 +2883,66 @@ mod tests {
);
}
+ #[test]
+ fn
test_deletion_vector_schema_validation_accepts_supported_changelog_producers() {
+ for producer in ["none", "input", "lookup"] {
+ Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("value", DataType::Int(IntType::new()))
+ .primary_key(["id"])
+ .option("deletion-vectors.enabled", "true")
+ .option("changelog-producer", producer)
+ .build()
+ .unwrap();
+ }
+ }
+
+ #[test]
+ fn
test_deletion_vector_schema_validation_rejects_incompatible_changelog_producers()
{
+ for (producer, expected_message) in [
+ ("full-compaction", "NONE/INPUT/LOOKUP"),
+ ("unknown", "Unsupported changelog-producer"),
+ ] {
+ assert_config_invalid(
+ Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("value", DataType::Int(IntType::new()))
+ .primary_key(["id"])
+ .option("deletion-vectors.enabled", "true")
+ .option("changelog-producer", producer)
+ .build(),
+ expected_message,
+ );
+ }
+ }
+
+ #[test]
+ fn
test_deletion_vector_apply_changes_rejects_incompatible_changelog_producers() {
+ let table_schema = TableSchema::new(
+ 0,
+ &Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("value", DataType::Int(IntType::new()))
+ .primary_key(["id"])
+ .option("deletion-vectors.enabled", "true")
+ .build()
+ .unwrap(),
+ );
+
+ for (producer, expected_message) in [
+ ("full-compaction", "NONE/INPUT/LOOKUP"),
+ ("unknown", "Unsupported changelog-producer"),
+ ] {
+ assert_config_invalid(
+
table_schema.apply_changes(vec![crate::spec::SchemaChange::set_option(
+ "changelog-producer".to_string(),
+ producer.to_string(),
+ )]),
+ expected_message,
+ );
+ }
+ }
+
fn cast_test_schema(options: &[(&str, &str)]) -> TableSchema {
let mut builder = Schema::builder()
.column("a", DataType::Int(IntType::new()))
@@ -2690,6 +3036,10 @@ mod tests {
&Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column("payload", DataType::Blob(BlobType::new()))
+ .column(
+ "payloads",
+
DataType::Array(ArrayType::new(DataType::Blob(BlobType::new()))),
+ )
.option("data-evolution.enabled", "true")
.build()
.unwrap(),
@@ -2701,6 +3051,16 @@ mod tests {
DataType::VarChar(crate::spec::VarCharType::new(10).unwrap()),
),
("id", DataType::Blob(BlobType::new())),
+ (
+ "payloads",
+ DataType::Array(ArrayType::new(DataType::VarBinary(
+ crate::spec::VarBinaryType::new(10).unwrap(),
+ ))),
+ ),
+ (
+ "id",
+
DataType::Array(ArrayType::new(DataType::Blob(BlobType::new()))),
+ ),
] {
let err = table_schema
.apply_changes(vec![crate::spec::SchemaChange::update_column_type(
@@ -2716,6 +3076,37 @@ mod tests {
}
}
+ #[test]
+ fn test_apply_changes_rejects_blob_column_rename() {
+ let table_schema = TableSchema::new(
+ 0,
+ &Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("payload", DataType::Blob(BlobType::new()))
+ .column(
+ "payloads",
+
DataType::Array(ArrayType::new(DataType::Blob(BlobType::new()))),
+ )
+ .option("data-evolution.enabled", "true")
+ .build()
+ .unwrap(),
+ );
+
+ for column in ["payload", "payloads"] {
+ let err = table_schema
+ .apply_changes(vec![crate::spec::SchemaChange::rename_column(
+ column.to_string(),
+ format!("renamed_{column}"),
+ )])
+ .unwrap_err();
+ assert!(
+ matches!(err, crate::Error::Unsupported { ref message }
+ if message == &format!("Cannot rename BLOB column:
[{column}]")),
+ "expected BLOB rename rejection for {column}, got {err:?}"
+ );
+ }
+ }
+
#[test]
fn test_apply_changes_nullable_to_not_null_guard() {
let table_schema = cast_test_schema(&[]);
@@ -3500,6 +3891,386 @@ mod tests {
);
}
+ #[test]
+ fn test_create_schema_validates_primary_key_vector_index() {
+ for vector_type in [
+ vector_4f(),
+ DataType::Array(ArrayType::new(DataType::Float(FloatType::new()))),
+ ] {
+ build_vector_index_test_schema(vector_type, &["id"], |_|
{}).unwrap();
+ }
+
+ build_vector_index_test_schema(vector_4f(), &["id"], |options| {
+ options.insert("bucket".to_string(), "-2".to_string());
+ options.insert("merge-engine".to_string(),
"first-row".to_string());
+ options.remove("deletion-vectors.enabled");
+ })
+ .unwrap();
+
+ assert_config_invalid(
+ build_vector_index_test_schema(vector_4f(), &["id"], |options| {
+ options.insert("merge-engine".to_string(),
"first-row".to_string());
+ }),
+ "does not need deletion vectors",
+ );
+
+ assert_config_invalid(
+ build_vector_index_test_schema(vector_4f(), &[], |_| {}),
+ "primary-key table",
+ );
+ assert_config_invalid(
+ build_vector_index_test_schema(vector_4f(), &["id"], |options| {
+ options.insert("pk-vector.index.columns".to_string(), "
".to_string());
+ }),
+ "non-empty column",
+ );
+ for columns in ["embedding,", ",embedding", "embedding,,",
"embedding,other"] {
+ assert_config_invalid(
+ build_vector_index_test_schema(vector_4f(), &["id"], |options|
{
+ options.insert("pk-vector.index.columns".to_string(),
columns.to_string());
+ }),
+ "exactly one",
+ );
+ }
+ assert_config_invalid(
+ build_vector_index_test_schema(vector_4f(), &["id"], |options| {
+ options.insert("pk-vector.index.columns".to_string(),
"missing".to_string());
+ options.insert(
+ "fields.missing.pk-vector.index.type".to_string(),
+ "ivf-flat".to_string(),
+ );
+ }),
+ "existing column",
+ );
+ assert_config_invalid(
+ build_vector_index_test_schema(
+ DataType::Array(ArrayType::new(DataType::Int(IntType::new()))),
+ &["id"],
+ |_| {},
+ ),
+ "ARRAY<FLOAT> or VECTOR<FLOAT>",
+ );
+ assert_config_invalid(
+ build_vector_index_test_schema(vector_4f(), &["id"], |options| {
+ options.remove("fields.embedding.pk-vector.index.type");
+ }),
+ "index.type is required",
+ );
+ assert_config_invalid(
+ build_vector_index_test_schema(vector_4f(), &["id"], |options| {
+ options.insert(
+ "fields.embedding.pk-vector.distance.metric".to_string(),
+ "manhattan".to_string(),
+ );
+ }),
+ "unsupported vector distance metric",
+ );
+ assert_config_invalid(
+ build_vector_index_test_schema(vector_4f(), &["id"], |options| {
+ options.insert("bucket".to_string(), "-1".to_string());
+ }),
+ "fixed or postpone bucket",
+ );
+ assert_config_invalid(
+ build_vector_index_test_schema(vector_4f(), &["id"], |options| {
+ options.remove("deletion-vectors.enabled");
+ }),
+ "deletion-vectors.enabled",
+ );
+ assert_config_invalid(
+ build_vector_index_test_schema(vector_4f(), &["id"], |options| {
+ options.insert(
+ "deletion-vectors.merge-on-read".to_string(),
+ "true".to_string(),
+ );
+ }),
+ "merge-on-read = false",
+ );
+ }
+
+ #[test]
+ fn test_apply_changes_validates_primary_key_vector_index() {
+ let table_schema = TableSchema::new(
+ 0,
+ &build_index_test_schema(
+ vec![
+ ("id", DataType::Int(IntType::new())),
+ ("embedding", vector_4f()),
+ ],
+ &["id"],
+ &[("bucket", "1")],
+ )
+ .unwrap(),
+ );
+
+ table_schema
+ .apply_changes(vec![
+ crate::spec::SchemaChange::set_option(
+ "deletion-vectors.enabled".to_string(),
+ "true".to_string(),
+ ),
+ crate::spec::SchemaChange::set_option(
+ "pk-vector.index.columns".to_string(),
+ "embedding".to_string(),
+ ),
+ crate::spec::SchemaChange::set_option(
+ "fields.embedding.pk-vector.index.type".to_string(),
+ "ivf-flat".to_string(),
+ ),
+ ])
+ .unwrap();
+
+ assert_config_invalid(
+ table_schema.apply_changes(vec![
+ crate::spec::SchemaChange::set_option(
+ "pk-vector.index.columns".to_string(),
+ "embedding".to_string(),
+ ),
+ crate::spec::SchemaChange::set_option(
+ "fields.embedding.pk-vector.index.type".to_string(),
+ "ivf-flat".to_string(),
+ ),
+ ]),
+ "deletion-vectors.enabled",
+ );
+
+ assert_config_invalid(
+ table_schema.apply_changes(vec![
+ crate::spec::SchemaChange::set_option(
+ "merge-engine".to_string(),
+ "first-row".to_string(),
+ ),
+ crate::spec::SchemaChange::set_option(
+ "deletion-vectors.enabled".to_string(),
+ "true".to_string(),
+ ),
+ crate::spec::SchemaChange::set_option(
+ "pk-vector.index.columns".to_string(),
+ "embedding".to_string(),
+ ),
+ crate::spec::SchemaChange::set_option(
+ "fields.embedding.pk-vector.index.type".to_string(),
+ "ivf-flat".to_string(),
+ ),
+ ]),
+ "does not need deletion vectors",
+ );
+
+ for (columns, expected_message) in [
+ (" ", "non-empty column"),
+ ("embedding,", "exactly one"),
+ (",embedding", "exactly one"),
+ ("embedding,,", "exactly one"),
+ ] {
+ assert_config_invalid(
+ table_schema.apply_changes(vec![
+ crate::spec::SchemaChange::set_option(
+ "deletion-vectors.enabled".to_string(),
+ "true".to_string(),
+ ),
+ crate::spec::SchemaChange::set_option(
+ "pk-vector.index.columns".to_string(),
+ columns.to_string(),
+ ),
+ crate::spec::SchemaChange::set_option(
+ "fields.embedding.pk-vector.index.type".to_string(),
+ "ivf-flat".to_string(),
+ ),
+ ]),
+ expected_message,
+ );
+ }
+ }
+
+ #[test]
+ fn test_create_schema_validates_primary_key_full_text_index() {
+ for text_type in [
+ DataType::Char(CharType::new(32).unwrap()),
+ DataType::VarChar(VarCharType::string_type()),
+ ] {
+ build_full_text_index_test_schema(text_type, &["id"], |_|
{}).unwrap();
+ }
+
+ build_full_text_index_test_schema(
+ DataType::VarChar(VarCharType::string_type()),
+ &["id"],
+ |options| {
+ options.insert("bucket".to_string(), "-2".to_string());
+ options.insert("merge-engine".to_string(),
"first-row".to_string());
+ options.remove("deletion-vectors.enabled");
+ },
+ )
+ .unwrap();
+
+ assert_config_invalid(
+ build_full_text_index_test_schema(
+ DataType::VarChar(VarCharType::string_type()),
+ &["id"],
+ |options| {
+ options.insert("merge-engine".to_string(),
"first-row".to_string());
+ },
+ ),
+ "does not need deletion vectors",
+ );
+
+ assert_config_invalid(
+ build_full_text_index_test_schema(
+ DataType::VarChar(VarCharType::string_type()),
+ &[],
+ |_| {},
+ ),
+ "primary-key table",
+ );
+ assert_config_invalid(
+ build_full_text_index_test_schema(
+ DataType::VarChar(VarCharType::string_type()),
+ &["id"],
+ |options| {
+ options.insert("pk-full-text.index.columns".to_string(), "
".to_string());
+ },
+ ),
+ "non-empty column",
+ );
+ assert_config_invalid(
+ build_full_text_index_test_schema(
+ DataType::VarChar(VarCharType::string_type()),
+ &["id"],
+ |options| {
+ options.insert(
+ "pk-full-text.index.columns".to_string(),
+ "content,other".to_string(),
+ );
+ },
+ ),
+ "exactly one",
+ );
+ assert_config_invalid(
+ build_full_text_index_test_schema(
+ DataType::VarChar(VarCharType::string_type()),
+ &["id"],
+ |options| {
+ options.insert(
+ "pk-full-text.index.columns".to_string(),
+ "missing".to_string(),
+ );
+ },
+ ),
+ "existing column",
+ );
+ assert_config_invalid(
+ build_full_text_index_test_schema(DataType::Int(IntType::new()),
&["id"], |_| {}),
+ "CHAR or VARCHAR",
+ );
+ assert_config_invalid(
+ build_full_text_index_test_schema(
+ DataType::VarChar(VarCharType::string_type()),
+ &["id"],
+ |options| {
+ options.insert("bucket".to_string(), "-1".to_string());
+ },
+ ),
+ "fixed or postpone bucket",
+ );
+ assert_config_invalid(
+ build_full_text_index_test_schema(
+ DataType::VarChar(VarCharType::string_type()),
+ &["id"],
+ |options| {
+ options.remove("deletion-vectors.enabled");
+ },
+ ),
+ "deletion-vectors.enabled",
+ );
+ assert_config_invalid(
+ build_full_text_index_test_schema(
+ DataType::VarChar(VarCharType::string_type()),
+ &["id"],
+ |options| {
+ options.insert(
+ "deletion-vectors.merge-on-read".to_string(),
+ "true".to_string(),
+ );
+ },
+ ),
+ "merge-on-read = false",
+ );
+ assert_config_invalid(
+ build_index_test_schema(
+ vec![
+ ("id", DataType::Int(IntType::new())),
+ (
+ "embedding",
+
DataType::Array(ArrayType::new(DataType::Float(FloatType::new()))),
+ ),
+ ],
+ &["id"],
+ &[
+ ("bucket", "1"),
+ ("deletion-vectors.enabled", "true"),
+ ("pk-vector.index.columns", "embedding"),
+ ("fields.embedding.pk-vector.index.type", "ivf-flat"),
+ ("pk-full-text.index.columns", "embedding"),
+ ],
+ ),
+ "cannot reference the same column",
+ );
+ }
+
+ #[test]
+ fn test_apply_changes_validates_primary_key_full_text_index() {
+ let table_schema = TableSchema::new(
+ 0,
+ &build_index_test_schema(
+ vec![
+ ("id", DataType::Int(IntType::new())),
+ ("content", DataType::VarChar(VarCharType::string_type())),
+ ],
+ &["id"],
+ &[("bucket", "1")],
+ )
+ .unwrap(),
+ );
+
+ table_schema
+ .apply_changes(vec![
+ crate::spec::SchemaChange::set_option(
+ "deletion-vectors.enabled".to_string(),
+ "true".to_string(),
+ ),
+ crate::spec::SchemaChange::set_option(
+ "pk-full-text.index.columns".to_string(),
+ "content".to_string(),
+ ),
+ ])
+ .unwrap();
+
+ assert_config_invalid(
+
table_schema.apply_changes(vec![crate::spec::SchemaChange::set_option(
+ "pk-full-text.index.columns".to_string(),
+ "content".to_string(),
+ )]),
+ "deletion-vectors.enabled",
+ );
+
+ assert_config_invalid(
+ table_schema.apply_changes(vec![
+ crate::spec::SchemaChange::set_option(
+ "merge-engine".to_string(),
+ "first-row".to_string(),
+ ),
+ crate::spec::SchemaChange::set_option(
+ "deletion-vectors.enabled".to_string(),
+ "true".to_string(),
+ ),
+ crate::spec::SchemaChange::set_option(
+ "pk-full-text.index.columns".to_string(),
+ "content".to_string(),
+ ),
+ ]),
+ "does not need deletion vectors",
+ );
+ }
+
fn assert_primary_key_index_column_changes_rejected(
table_schema: &TableSchema,
column_name: &str,
@@ -3587,13 +4358,28 @@ mod tests {
.option("bucket", "1")
.option("deletion-vectors.enabled", "true")
.option("pk-vector.index.columns", "embedding")
- .option("fields.embedding.pk-vector.index.type", "ivf_flat")
+ .option("fields.embedding.pk-vector.index.type", "ivf-flat")
.option("fields.embedding.pk-vector.distance.metric", "l2")
.build()
.unwrap(),
);
assert_primary_key_index_column_changes_rejected(&table_schema,
"embedding", vector_type);
+
+ let err = table_schema
+ .apply_changes(vec![
+
crate::spec::SchemaChange::remove_option("pk-vector.index.columns".to_string()),
+ crate::spec::SchemaChange::rename_column(
+ "embedding".to_string(),
+ "renamed_embedding".to_string(),
+ ),
+ ])
+ .unwrap_err();
+ assert!(matches!(
+ err,
+ crate::Error::Unsupported { ref message }
+ if message == "Cannot rename primary-key index column:
[embedding]"
+ ));
}
#[test]
@@ -3849,6 +4635,60 @@ mod tests {
);
}
+ #[test]
+ fn test_bucket_key_history_rejects_destructive_column_changes() {
+ let table_schema = TableSchema::new(
+ 0,
+ &Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("bucket_col", DataType::Int(IntType::new()))
+ .column("value", DataType::Int(IntType::new()))
+ .option("bucket", "4")
+ .option("bucket-key", "bucket_col")
+ .build()
+ .unwrap(),
+ );
+
+ for changes in [
+ vec![crate::spec::SchemaChange::update_column_type(
+ "bucket_col".to_string(),
+ DataType::BigInt(crate::spec::BigIntType::new()),
+ )],
+ vec![
+
crate::spec::SchemaChange::remove_option(BUCKET_KEY_OPTION.to_string()),
+
crate::spec::SchemaChange::drop_column("bucket_col".to_string()),
+ ],
+ vec![
+ crate::spec::SchemaChange::rename_column(
+ "bucket_col".to_string(),
+ "renamed_bucket_col".to_string(),
+ ),
+ crate::spec::SchemaChange::update_column_type(
+ "renamed_bucket_col".to_string(),
+ DataType::BigInt(crate::spec::BigIntType::new()),
+ ),
+ ],
+ ] {
+ let err = table_schema.apply_changes(changes).unwrap_err();
+ assert!(
+ matches!(err, crate::Error::Unsupported { ref message }
+ if message.contains("bucket-key column") &&
message.contains("bucket_col")),
+ "expected historical bucket-key protection, got {err:?}"
+ );
+ }
+
+ let updated = table_schema
+ .apply_changes(vec![crate::spec::SchemaChange::update_column_type(
+ "value".to_string(),
+ DataType::BigInt(crate::spec::BigIntType::new()),
+ )])
+ .unwrap();
+ assert!(matches!(
+ updated.fields()[2].data_type(),
+ DataType::BigInt(_)
+ ));
+ }
+
#[test]
fn test_drop_column_referenced_by_sequence_field_rejected() {
let table_schema = TableSchema::new(
diff --git a/crates/paimon/src/table/full_text_search_builder.rs
b/crates/paimon/src/table/full_text_search_builder.rs
index 702c3295..b9e458e8 100644
--- a/crates/paimon/src/table/full_text_search_builder.rs
+++ b/crates/paimon/src/table/full_text_search_builder.rs
@@ -1341,6 +1341,7 @@ mod tests {
.column("body", DataType::VarChar(VarCharType::string_type()))
.primary_key(["id"])
.option("bucket", "1")
+ .option("deletion-vectors.enabled", "true")
.option("pk-full-text.index.columns", "body");
for (k, v) in extra {
builder = builder.option(*k, *v);
diff --git a/crates/paimon/src/table/vector_search_builder.rs
b/crates/paimon/src/table/vector_search_builder.rs
index 281f77bb..d882a551 100644
--- a/crates/paimon/src/table/vector_search_builder.rs
+++ b/crates/paimon/src/table/vector_search_builder.rs
@@ -4493,15 +4493,26 @@ mod tests {
"embedding",
DataType::Array(ArrayType::new(DataType::Float(FloatType::new()))),
);
- for (k, v) in options {
- builder = builder.option(*k, *v);
+ if options
+ .iter()
+ .any(|(key, _)| *key == "pk-vector.index.columns")
+ {
+ builder = builder.primary_key(["id"]).option("bucket", "1");
}
let schema = builder.build().unwrap();
+ // Runtime validation must remain defensive for schemas committed by
old
+ // or external writers, including malformed configurations which the
+ // current Schema builder rejects at commit time.
+ let runtime_options = options
+ .iter()
+ .map(|(key, value)| ((*key).to_string(), (*value).to_string()))
+ .collect();
+ let table_schema = TableSchema::new(0,
&schema).copy_with_options(runtime_options);
Table::new(
FileIOBuilder::new("memory").build().unwrap(),
Identifier::new("default", "pk_vector_test"),
"memory:/pk_vector_test".to_string(),
- TableSchema::new(0, &schema),
+ table_schema,
None,
)
}
@@ -4649,6 +4660,7 @@ mod tests {
)
.primary_key(["id"])
.option("bucket", "1")
+ .option("deletion-vectors.enabled", "true")
.option("pk-vector.index.columns", "embedding")
.option("fields.embedding.pk-vector.index.type",
IVF_FLAT_IDENTIFIER)
.option("fields.embedding.pk-vector.distance.metric", "l2")
@@ -5135,29 +5147,39 @@ mod tests {
async fn
execute_read_partition_only_filter_without_deletion_vectors_passes_guard() {
use crate::spec::VarCharType;
- // Partitioned PK-vector table, deletion vectors OFF (default).
- let mut builder = Schema::builder()
+ // Partitioned PK-vector table from old metadata, deletion vectors OFF.
+ let schema = Schema::builder()
.column("dt", DataType::VarChar(VarCharType::string_type()))
.column("id", DataType::Int(IntType::new()))
.column(
"embedding",
DataType::Array(ArrayType::new(DataType::Float(FloatType::new()))),
)
- .partition_keys(["dt"]);
- for (k, v) in [
- ("pk-vector.index.columns", "embedding"),
- ("fields.embedding.pk-vector.index.type", IVF_FLAT_IDENTIFIER),
- ("fields.embedding.pk-vector.distance.metric", "l2"),
- ("fields.embedding.dimension", "4"),
- ] {
- builder = builder.option(k, v);
- }
- let schema = builder.build().unwrap();
+ .partition_keys(["dt"])
+ .primary_key(["id"])
+ .option("bucket", "1")
+ .build()
+ .unwrap();
+ let table_schema = TableSchema::new(0,
&schema).copy_with_options(HashMap::from([
+ (
+ "pk-vector.index.columns".to_string(),
+ "embedding".to_string(),
+ ),
+ (
+ "fields.embedding.pk-vector.index.type".to_string(),
+ IVF_FLAT_IDENTIFIER.to_string(),
+ ),
+ (
+ "fields.embedding.pk-vector.distance.metric".to_string(),
+ "l2".to_string(),
+ ),
+ ("fields.embedding.dimension".to_string(), "4".to_string()),
+ ]));
let table = Table::new(
FileIOBuilder::new("memory").build().unwrap(),
Identifier::new("default", "pk_vector_partitioned"),
"memory:/pk_vector_partitioned".to_string(),
- TableSchema::new(0, &schema),
+ table_schema,
None,
);
@@ -5773,6 +5795,9 @@ mod tests {
DataType::Array(ArrayType::new(DataType::Float(FloatType::new()))),
)
.column(reserved, DataType::Int(IntType::new()))
+ .primary_key(["id"])
+ .option("bucket", "1")
+ .option("deletion-vectors.enabled", "true")
.option("pk-vector.index.columns", "embedding")
.option("fields.embedding.pk-vector.index.type",
IVF_FLAT_IDENTIFIER)
.option("fields.embedding.pk-vector.distance.metric", "l2")