blob: 777b62f3c1be3ab8c081fac96b3ad1b64c0d2dd0 [file]
// 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 crate::spec::core_options::{
first_row_supports_changelog_producer, CoreOptions, BUCKET_KEY_OPTION, SEQUENCE_FIELD_OPTION,
};
use crate::spec::types::{ArrayType, DataType, MapType, MultisetType, RowType};
use crate::spec::{
remove_field_scoped_options, rename_field_scoped_options, AggregationConfig, ColumnMove,
ColumnMoveType, PartialUpdateConfig,
};
use serde::{Deserialize, Serialize};
use serde_with::serde_as;
use std::collections::{HashMap, HashSet};
/// The table schema for paimon table.
///
/// Impl References: <https://github.com/apache/paimon/blob/release-0.8.2/paimon-core/src/main/java/org/apache/paimon/schema/TableSchema.java#L47>
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TableSchema {
/// version of schema for paimon
version: i32,
id: i64,
fields: Vec<DataField>,
highest_field_id: i32,
partition_keys: Vec<String>,
primary_keys: Vec<String>,
options: HashMap<String, String>,
comment: Option<String>,
time_millis: i64,
}
impl TableSchema {
pub const CURRENT_VERSION: i32 = 3;
/// Create a TableSchema from a Schema with the given ID.
///
/// Reference: <https://github.com/apache/paimon/blob/release-0.8.2/paimon-core/src/main/java/org/apache/paimon/schema/TableSchema.java#L373>
pub fn new(id: i64, schema: &Schema) -> Self {
let fields = schema.fields().to_vec();
let highest_field_id = Self::current_highest_field_id(&fields);
Self {
version: Self::CURRENT_VERSION,
id,
fields,
highest_field_id,
partition_keys: schema.partition_keys().to_vec(),
primary_keys: schema.primary_keys().to_vec(),
options: schema.options().clone(),
comment: schema.comment().map(|s| s.to_string()),
time_millis: chrono::Utc::now().timestamp_millis(),
}
}
/// Get the highest field ID from a list of fields, including fields nested
/// inside row types (mirrors Java `RowType.currentHighestFieldId`).
pub fn current_highest_field_id(fields: &[DataField]) -> i32 {
fields
.iter()
.map(|f| f.id().max(highest_nested_field_id(f.data_type())))
.max()
.unwrap_or(-1)
}
pub fn version(&self) -> i32 {
self.version
}
pub fn id(&self) -> i64 {
self.id
}
pub fn fields(&self) -> &[DataField] {
&self.fields
}
pub fn highest_field_id(&self) -> i32 {
self.highest_field_id
}
pub fn partition_keys(&self) -> &[String] {
&self.partition_keys
}
pub fn partition_fields(&self) -> Vec<DataField> {
self.partition_keys
.iter()
.filter_map(|key| self.fields.iter().find(|f| f.name() == key).cloned())
.collect()
}
pub fn primary_keys(&self) -> &[String] {
&self.primary_keys
}
/// Primary keys with partition columns removed.
///
/// Within a single partition the partition columns are constant, so they
/// are redundant in the KV key. Java Paimon calls these "trimmed primary keys".
pub fn trimmed_primary_keys(&self) -> Vec<String> {
if self.partition_keys.is_empty() {
return self.primary_keys.clone();
}
let partition_set: HashSet<&str> = self.partition_keys.iter().map(String::as_str).collect();
self.primary_keys
.iter()
.filter(|pk| !partition_set.contains(pk.as_str()))
.cloned()
.collect()
}
pub fn options(&self) -> &HashMap<String, String> {
&self.options
}
/// Create a copy of this schema with extra options merged in.
pub fn copy_with_options(&self, extra: HashMap<String, String>) -> Self {
let mut new_schema = self.clone();
new_schema.options.extend(extra);
new_schema
}
/// Create a copy of this schema with the options replaced entirely,
/// keeping id, fields, keys, comment, and timestamps.
///
/// Corresponds to Java `TableSchema.copy(Map<String, String> newOptions)`,
/// which constructs a new schema with the given options rather than
/// merging them.
pub fn copy_with_replaced_options(&self, options: HashMap<String, String>) -> Self {
let mut new_schema = self.clone();
new_schema.options = options;
new_schema
}
/// Apply a list of schema changes and return a new schema with incremented ID.
///
/// Column-level changes operate on **top-level** columns only: a
/// `field_names` path with more than one element (a nested struct field) is
/// rejected with [`crate::Error::Unsupported`].
///
/// 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.
pub fn apply_changes(&self, changes: Vec<crate::spec::SchemaChange>) -> crate::Result<Self> {
use crate::spec::SchemaChange;
// Column errors carry no table name here; the catalog layer fills it in.
let full_name = "";
// Both flags are read from the pre-alter options, mirroring Java
// `SchemaManager.applySchemaChanges`.
let disable_null_to_not_null = self
.options
.get(crate::spec::DISABLE_ALTER_COLUMN_NULL_TO_NOT_NULL_OPTION)
.map(|v| v == "true")
.unwrap_or(true);
let allow_explicit_cast = self
.options
.get(crate::spec::DISABLE_EXPLICIT_TYPE_CASTING_OPTION)
.map(|v| v != "true")
.unwrap_or(true);
let mut new_schema = self.clone();
new_schema.id += 1;
new_schema.time_millis = chrono::Utc::now().timestamp_millis();
// Operate on an owned field list, then write it back.
let mut fields = std::mem::take(&mut new_schema.fields);
let mut highest_field_id = new_schema.highest_field_id;
for change in changes {
match change {
SchemaChange::SetOption { key, value } => {
new_schema.options.insert(key, value);
}
SchemaChange::RemoveOption { key } => {
new_schema.options.remove(&key);
}
SchemaChange::UpdateComment { comment } => {
new_schema.comment = comment;
}
SchemaChange::AddColumn {
field_names,
data_type,
comment,
column_move,
} => {
let name = top_level_field(&field_names)?;
if field_index(&fields, name).is_some() {
return Err(crate::Error::ColumnAlreadyExist {
full_name: full_name.to_string(),
column: name.to_string(),
});
}
// Mirrors Java: an added column has no value for existing
// rows, so it must be nullable.
if !data_type.is_nullable() {
return Err(crate::Error::ConfigInvalid {
message: format!("Column {name} cannot specify NOT NULL."),
});
}
highest_field_id += 1;
let id = highest_field_id;
let data_type = reassign_field_ids(data_type, &mut highest_field_id);
let field =
DataField::new(id, name.to_string(), data_type).with_description(comment);
insert_field_with_move(&mut fields, field, column_move.as_ref(), full_name)?;
}
SchemaChange::RenameColumn {
field_names,
new_name,
} => {
let name = top_level_field(&field_names)?;
// Existing partition data is laid out with the old key name
// in paths and metadata; renaming would break resolution.
if new_schema.partition_keys.iter().any(|k| k == name) {
return Err(crate::Error::Unsupported {
message: format!("Cannot rename partition column: [{name}]"),
});
}
let idx =
field_index(&fields, name).ok_or_else(|| crate::Error::ColumnNotExist {
full_name: full_name.to_string(),
column: name.to_string(),
})?;
if new_name != name && field_index(&fields, &new_name).is_some() {
return Err(crate::Error::ColumnAlreadyExist {
full_name: full_name.to_string(),
column: new_name,
});
}
fields[idx] = fields[idx].clone().with_name(new_name.clone());
rename_in_keys(&mut new_schema.primary_keys, name, &new_name);
rename_in_option_list(
&mut new_schema.options,
BUCKET_KEY_OPTION,
name,
&new_name,
);
rename_in_option_list(
&mut new_schema.options,
SEQUENCE_FIELD_OPTION,
name,
&new_name,
);
// Field-scoped aggregation options encode the column in the
// key (`fields.<col>.aggregate-function` / `.list-agg-delimiter`),
// so they must be rewritten too, mirroring Java
// `SchemaManager.applyRenameColumnsToOptions`.
rename_field_scoped_options(&mut new_schema.options, name, &new_name);
}
SchemaChange::DropColumn { field_names } => {
let name = top_level_field(&field_names)?;
let idx =
field_index(&fields, name).ok_or_else(|| crate::Error::ColumnNotExist {
full_name: full_name.to_string(),
column: name.to_string(),
})?;
if new_schema.partition_keys.iter().any(|k| k == name)
|| new_schema.primary_keys.iter().any(|k| k == name)
{
return Err(crate::Error::Unsupported {
message: format!(
"Cannot drop partition or primary key column '{name}' of table {full_name}"
),
});
}
// 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
// fall back to bucket 0), so reject it instead.
{
let core_options = CoreOptions::new(&new_schema.options);
if core_options
.bucket_key()
.is_some_and(|keys| keys.iter().any(|k| k == name))
{
return Err(crate::Error::Unsupported {
message: format!(
"Cannot drop column '{name}' referenced by '{BUCKET_KEY_OPTION}'"
),
});
}
if core_options.sequence_fields().contains(&name) {
return Err(crate::Error::Unsupported {
message: format!(
"Cannot drop column '{name}' referenced by '{SEQUENCE_FIELD_OPTION}'"
),
});
}
}
if fields.len() == 1 {
return Err(crate::Error::Unsupported {
message: "Cannot drop all fields in table".to_string(),
});
}
fields.remove(idx);
// Drop the column's field-scoped aggregation options so no
// orphaned `fields.<col>.*` keys remain (which would otherwise
// fail the aggregation re-validation below).
remove_field_scoped_options(&mut new_schema.options, name);
}
SchemaChange::UpdateColumnType {
field_names,
new_data_type,
keep_nullability,
} => {
let name = top_level_field(&field_names)?;
// Existing partitions, bucket assignment, and key encoding
// were all written with the old key type.
if new_schema.partition_keys.iter().any(|k| k == name) {
return Err(crate::Error::Unsupported {
message: format!("Cannot update partition column: [{name}]"),
});
}
if new_schema.primary_keys.iter().any(|k| k == name) {
return Err(crate::Error::Unsupported {
message: "Cannot update primary key".to_string(),
});
}
let idx =
field_index(&fields, name).ok_or_else(|| crate::Error::ColumnNotExist {
full_name: full_name.to_string(),
column: name.to_string(),
})?;
let old = &fields[idx];
// 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() {
return Err(crate::Error::Unsupported {
message: format!(
"Cannot change column type involving BLOB: [{name}] {:?} -> {new_data_type:?}",
old.data_type()
),
});
}
let target = if keep_nullability {
new_data_type.copy_with_nullable(old.data_type().is_nullable())?
} else {
assert_nullability_change(
old.data_type().is_nullable(),
new_data_type.is_nullable(),
name,
disable_null_to_not_null,
)?;
new_data_type
};
// Existing data files keep the old schema; the read path
// casts old columns to the new type, so the change must be
// both a supported Paimon cast and executable by arrow.
let arrow_castable = arrow_cast::can_cast_types(
&crate::arrow::paimon_type_to_arrow(old.data_type())?,
&crate::arrow::paimon_type_to_arrow(&target)?,
);
if !crate::spec::supports_cast(old.data_type(), &target, allow_explicit_cast)
|| !arrow_castable
{
return Err(crate::Error::Unsupported {
message: format!(
"Column type {name}[{:?}] cannot be converted to {target:?} without losing information.",
old.data_type()
),
});
}
fields[idx] = DataField::new(old.id(), old.name().to_string(), target)
.with_description(old.description().map(|s| s.to_string()));
}
SchemaChange::UpdateColumnNullability {
field_names,
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.
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(),
});
}
let idx =
field_index(&fields, name).ok_or_else(|| crate::Error::ColumnNotExist {
full_name: full_name.to_string(),
column: name.to_string(),
})?;
let old = &fields[idx];
assert_nullability_change(
old.data_type().is_nullable(),
new_nullability,
name,
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()));
}
SchemaChange::UpdateColumnComment {
field_names,
new_comment,
} => {
let name = top_level_field(&field_names)?;
let idx =
field_index(&fields, name).ok_or_else(|| crate::Error::ColumnNotExist {
full_name: full_name.to_string(),
column: name.to_string(),
})?;
fields[idx] = fields[idx].clone().with_description(Some(new_comment));
}
SchemaChange::UpdateColumnPosition { column_move } => {
apply_move(&mut fields, &column_move, full_name)?;
}
}
}
new_schema.fields = fields;
new_schema.highest_field_id =
highest_field_id.max(Self::current_highest_field_id(&new_schema.fields));
// Re-run create-time validations on the final schema, mirroring Java
// `SchemaValidation.validateTableSchema` after applying changes.
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,
)?;
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)?;
Ok(new_schema)
}
pub fn comment(&self) -> Option<&str> {
self.comment.as_deref()
}
pub fn time_millis(&self) -> i64 {
self.time_millis
}
/// Compute the effective bucket key columns.
///
/// Priority: explicit `bucket-key` option > primary keys > all non-partition fields.
pub fn bucket_keys(&self) -> Vec<String> {
let core_options = CoreOptions::new(&self.options);
if let Some(keys) = core_options.bucket_key() {
return keys;
}
if !self.primary_keys.is_empty() {
return self.trimmed_primary_keys();
}
let partition_set: HashSet<&str> = self.partition_keys.iter().map(String::as_str).collect();
self.fields
.iter()
.filter(|f| !partition_set.contains(f.name()))
.map(|f| f.name().to_string())
.collect()
}
}
/// Extract the single top-level column name from a `field_names` path.
///
/// Nested struct field paths (length > 1) are not yet supported.
fn top_level_field(field_names: &[String]) -> crate::Result<&str> {
match field_names {
[name] => Ok(name.as_str()),
[] => Err(crate::Error::ConfigInvalid {
message: "Schema change has empty fieldNames".to_string(),
}),
_ => Err(crate::Error::Unsupported {
message: format!("Altering nested struct fields is not supported yet: {field_names:?}"),
}),
}
}
/// Index of the field with the given name, if any.
fn field_index(fields: &[DataField], name: &str) -> Option<usize> {
fields.iter().position(|f| f.name() == name)
}
/// Mirrors Java `SchemaManager.assertNullabilityChange`: converting a nullable
/// column to NOT NULL is rejected unless explicitly enabled, because existing
/// rows may already contain NULLs.
fn assert_nullability_change(
old_nullable: bool,
new_nullable: bool,
field_name: &str,
disable_null_to_not_null: bool,
) -> crate::Result<()> {
if disable_null_to_not_null && old_nullable && !new_nullable {
return Err(crate::Error::Unsupported {
message: format!(
"Cannot update column type from nullable to non nullable for {field_name}. \
You can set table configuration option 'alter-column-null-to-not-null.disabled' = 'false' \
to allow converting null columns to not null"
),
});
}
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() {
if key == old {
*key = new.to_string();
}
}
}
/// Rename a column inside a comma-separated column-list option (`bucket-key`,
/// `sequence.field`), if the option is set and references the column.
///
/// Mirrors Java `SchemaManager.applyRenameColumnsToOptions`.
fn rename_in_option_list(
options: &mut HashMap<String, String>,
option_key: &str,
old: &str,
new: &str,
) {
let Some(value) = options.get(option_key) else {
return;
};
let renamed = value
.split(',')
.map(|col| if col == old { new } else { col })
.collect::<Vec<_>>()
.join(",");
options.insert(option_key.to_string(), renamed);
}
/// The highest field ID nested inside a data type, or -1 if it contains none.
fn highest_nested_field_id(data_type: &DataType) -> i32 {
match data_type {
DataType::Array(t) => highest_nested_field_id(t.element_type()),
DataType::Multiset(t) => highest_nested_field_id(t.element_type()),
DataType::Map(t) => {
highest_nested_field_id(t.key_type()).max(highest_nested_field_id(t.value_type()))
}
DataType::Row(t) => t
.fields()
.iter()
.map(|f| f.id().max(highest_nested_field_id(f.data_type())))
.max()
.unwrap_or(-1),
_ => -1,
}
}
/// Reassign the IDs of all row fields nested inside a data type from the
/// table-wide highest field ID, so they cannot collide with existing fields.
///
/// Mirrors Java `ReassignFieldId`: IDs nested inside a field's type are
/// assigned before the field's own ID.
fn reassign_field_ids(data_type: DataType, next_id: &mut i32) -> DataType {
let nullable = data_type.is_nullable();
match data_type {
DataType::Array(t) => DataType::Array(ArrayType::with_nullable(
nullable,
reassign_field_ids(t.element_type().clone(), next_id),
)),
DataType::Multiset(t) => DataType::Multiset(MultisetType::with_nullable(
nullable,
reassign_field_ids(t.element_type().clone(), next_id),
)),
DataType::Map(t) => DataType::Map(MapType::with_nullable(
nullable,
reassign_field_ids(t.key_type().clone(), next_id),
reassign_field_ids(t.value_type().clone(), next_id),
)),
DataType::Row(t) => {
let fields = t
.fields()
.iter()
.map(|f| {
let typ = reassign_field_ids(f.data_type().clone(), next_id);
*next_id += 1;
DataField::new(*next_id, f.name().to_string(), typ)
.with_description(f.description().map(|s| s.to_string()))
})
.collect();
DataType::Row(RowType::with_nullable(nullable, fields))
}
other => other,
}
}
/// Insert a brand-new field according to an optional move (used by `AddColumn`).
fn insert_field_with_move(
fields: &mut Vec<DataField>,
field: DataField,
column_move: Option<&ColumnMove>,
full_name: &str,
) -> crate::Result<()> {
let Some(mv) = column_move else {
fields.push(field);
return Ok(());
};
match mv.move_type() {
ColumnMoveType::FIRST => fields.insert(0, field),
ColumnMoveType::LAST => fields.push(field),
ColumnMoveType::AFTER | ColumnMoveType::BEFORE => {
let reference = move_reference(mv)?;
let ref_idx =
field_index(fields, reference).ok_or_else(|| crate::Error::ColumnNotExist {
full_name: full_name.to_string(),
column: reference.to_string(),
})?;
let at = match mv.move_type() {
ColumnMoveType::AFTER => ref_idx + 1,
_ => ref_idx,
};
fields.insert(at, field);
}
}
Ok(())
}
/// Move an existing field to a new position (used by `UpdateColumnPosition`).
///
/// Mirrors Java `SchemaManager.applyMove`: remove the field first, then resolve
/// the reference index in the reduced list so the offset is already adjusted.
fn apply_move(fields: &mut Vec<DataField>, mv: &ColumnMove, full_name: &str) -> crate::Result<()> {
let idx = field_index(fields, mv.field_name()).ok_or_else(|| crate::Error::ColumnNotExist {
full_name: full_name.to_string(),
column: mv.field_name().to_string(),
})?;
let field = fields.remove(idx);
match mv.move_type() {
ColumnMoveType::FIRST => fields.insert(0, field),
ColumnMoveType::LAST => fields.push(field),
ColumnMoveType::AFTER | ColumnMoveType::BEFORE => {
let reference = move_reference(mv)?;
let ref_idx =
field_index(fields, reference).ok_or_else(|| crate::Error::ColumnNotExist {
full_name: full_name.to_string(),
column: reference.to_string(),
})?;
let at = match mv.move_type() {
ColumnMoveType::AFTER => ref_idx + 1,
_ => ref_idx,
};
fields.insert(at, field);
}
}
Ok(())
}
/// The reference (anchor) field name required by `AFTER`/`BEFORE` moves.
fn move_reference(mv: &ColumnMove) -> crate::Result<&str> {
mv.reference_field_name()
.ok_or_else(|| crate::Error::ConfigInvalid {
message: format!(
"Move of type {:?} requires a reference field name",
mv.move_type()
),
})
}
pub const ROW_ID_FIELD_NAME: &str = "_ROW_ID";
pub const ROW_ID_FIELD_ID: i32 = i32::MAX - 5;
pub const SEQUENCE_NUMBER_FIELD_NAME: &str = "_SEQUENCE_NUMBER";
/// Must match Java Paimon's `SpecialFields.SEQUENCE_NUMBER` (Integer.MAX_VALUE - 1).
pub const SEQUENCE_NUMBER_FIELD_ID: i32 = i32::MAX - 1;
pub const VALUE_KIND_FIELD_NAME: &str = "_VALUE_KIND";
/// Must match Java Paimon's `SpecialFields.VALUE_KIND` (Integer.MAX_VALUE - 2).
pub const VALUE_KIND_FIELD_ID: i32 = i32::MAX - 2;
/// Data field for paimon table.
///
/// Impl Reference: <https://github.com/apache/paimon/blob/release-0.8.2/paimon-common/src/main/java/org/apache/paimon/types/DataField.java#L40>
#[serde_as]
#[derive(Debug, Clone, PartialEq, Hash, Eq, Deserialize, Serialize)]
pub struct DataField {
id: i32,
name: String,
#[serde(rename = "type")]
typ: DataType,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
}
impl DataField {
pub fn new(id: i32, name: String, typ: DataType) -> Self {
Self {
id,
name,
typ,
description: None,
}
}
pub fn id(&self) -> i32 {
self.id
}
pub fn name(&self) -> &str {
&self.name
}
pub fn data_type(&self) -> &DataType {
&self.typ
}
pub fn description(&self) -> Option<&str> {
self.description.as_deref()
}
pub fn with_id(mut self, new_id: i32) -> Self {
self.id = new_id;
self
}
pub fn with_name(mut self, new_name: String) -> Self {
self.name = new_name;
self
}
pub fn with_description(mut self, new_description: Option<String>) -> Self {
self.description = new_description;
self
}
}
pub fn escape_identifier(identifier: &str) -> String {
identifier.replace('"', "\"\"")
}
pub fn escape_single_quotes(text: &str) -> String {
text.replace('\'', "''")
}
// ======================= Schema (DDL) ===============================
/// Option key for primary key in table options (same as [CoreOptions.PRIMARY_KEY](https://github.com/apache/paimon/blob/release-1.3/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java)).
pub const PRIMARY_KEY_OPTION: &str = "primary-key";
/// Option key for partition in table options (same as [CoreOptions.PARTITION](https://github.com/apache/paimon/blob/release-1.3/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java)).
pub const PARTITION_OPTION: &str = "partition";
const MERGE_ENGINE_OPTION: &str = "merge-engine";
/// Schema of a table (logical DDL schema).
///
/// Corresponds to [org.apache.paimon.schema.Schema](https://github.com/apache/paimon/blob/release-1.3/paimon-api/src/main/java/org/apache/paimon/schema/Schema.java).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Schema {
fields: Vec<DataField>,
partition_keys: Vec<String>,
primary_keys: Vec<String>,
options: HashMap<String, String>,
comment: Option<String>,
}
impl Schema {
/// Build a schema with validation. Normalizes partition/primary keys from options if present.
fn new(
fields: Vec<DataField>,
partition_keys: Vec<String>,
primary_keys: Vec<String>,
mut options: HashMap<String, String>,
comment: Option<String>,
) -> crate::Result<Self> {
let primary_keys = Self::normalize_primary_keys(&primary_keys, &mut options)?;
let partition_keys = Self::normalize_partition_keys(&partition_keys, &mut options)?;
let fields = Self::normalize_fields(&fields, &partition_keys, &primary_keys)?;
Self::validate_key_field_types(&fields, &primary_keys, &options)?;
Self::validate_blob_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)?;
Ok(Self {
fields,
partition_keys,
primary_keys,
options,
comment,
})
}
/// Normalize primary keys: optionally take from table options (`primary-key`), remove from options.
/// Corresponds to Java `normalizePrimaryKeys`.
fn normalize_primary_keys(
primary_keys: &[String],
options: &mut HashMap<String, String>,
) -> crate::Result<Vec<String>> {
if let Some(pk) = options.remove(PRIMARY_KEY_OPTION) {
if !primary_keys.is_empty() {
return Err(crate::Error::ConfigInvalid {
message: "Cannot define primary key on DDL and table options at the same time."
.to_string(),
});
}
return Ok(pk
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect());
}
Ok(primary_keys.to_vec())
}
/// Normalize partition keys: optionally take from table options (`partition`), remove from options.
/// Corresponds to Java `normalizePartitionKeys`.
fn normalize_partition_keys(
partition_keys: &[String],
options: &mut HashMap<String, String>,
) -> crate::Result<Vec<String>> {
if let Some(part) = options.remove(PARTITION_OPTION) {
if !partition_keys.is_empty() {
return Err(crate::Error::ConfigInvalid {
message: "Cannot define partition on DDL and table options at the same time."
.to_string(),
});
}
return Ok(part
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect());
}
Ok(partition_keys.to_vec())
}
/// Normalize fields: validate (duplicate/subset checks) and make primary key columns non-nullable.
/// Corresponds to Java `normalizeFields`.
fn normalize_fields(
fields: &[DataField],
partition_keys: &[String],
primary_keys: &[String],
) -> crate::Result<Vec<DataField>> {
let field_names: Vec<String> = fields.iter().map(|f| f.name().to_string()).collect();
Self::validate_no_duplicate_fields(&field_names)?;
Self::validate_partition_keys(&field_names, partition_keys)?;
Self::validate_primary_keys(&field_names, primary_keys)?;
Self::validate_primary_keys_not_partition_only(partition_keys, primary_keys)?;
if primary_keys.is_empty() {
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 {
if pk_set.contains(f.name()) && f.data_type().is_nullable() {
new_fields.push(
DataField::new(
f.id(),
f.name().to_string(),
f.data_type().copy_with_nullable(false)?,
)
.with_description(f.description().map(|s| s.to_string())),
);
} else {
new_fields.push(f.clone());
}
}
Ok(new_fields)
}
/// 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);
if duplicates.is_empty() {
Ok(())
} else {
Err(crate::Error::ConfigInvalid {
message: format!(
"Table column {field_names:?} must not contain duplicate fields. Found: {duplicates:?}"
),
})
}
}
/// Partition key constraint must not contain duplicates; all partition keys must be in table columns.
fn validate_partition_keys(
field_names: &[String],
partition_keys: &[String],
) -> crate::Result<()> {
let all_names: HashSet<&str> = field_names.iter().map(String::as_str).collect();
let duplicates = Self::duplicate_fields(partition_keys);
if !duplicates.is_empty() {
return Err(crate::Error::ConfigInvalid {
message: format!(
"Partition key constraint {partition_keys:?} must not contain duplicate columns. Found: {duplicates:?}"
),
});
}
if !partition_keys
.iter()
.all(|k| all_names.contains(k.as_str()))
{
return Err(crate::Error::ConfigInvalid {
message: format!(
"Table column {field_names:?} should include all partition fields {partition_keys:?}"
),
});
}
Ok(())
}
/// Primary key constraint must not contain duplicates; all primary keys must be in table columns.
fn validate_primary_keys(field_names: &[String], primary_keys: &[String]) -> crate::Result<()> {
if primary_keys.is_empty() {
return Ok(());
}
let all_names: HashSet<&str> = field_names.iter().map(String::as_str).collect();
let duplicates = Self::duplicate_fields(primary_keys);
if !duplicates.is_empty() {
return Err(crate::Error::ConfigInvalid {
message: format!(
"Primary key constraint {primary_keys:?} must not contain duplicate columns. Found: {duplicates:?}"
),
});
}
if !primary_keys.iter().all(|k| all_names.contains(k.as_str())) {
return Err(crate::Error::ConfigInvalid {
message: format!(
"Table column {field_names:?} should include all primary key constraint {primary_keys:?}"
),
});
}
Ok(())
}
fn validate_primary_keys_not_partition_only(
partition_keys: &[String],
primary_keys: &[String],
) -> crate::Result<()> {
if primary_keys.is_empty() || partition_keys.is_empty() {
return Ok(());
}
let partition_set: HashSet<&str> = partition_keys.iter().map(String::as_str).collect();
if primary_keys
.iter()
.all(|pk| partition_set.contains(pk.as_str()))
{
return Err(crate::Error::ConfigInvalid {
message: format!(
"Primary key constraint {primary_keys:?} should not be same with partition fields {partition_keys:?}, this will result in only one record in a partition"
),
});
}
Ok(())
}
/// Reject types that cannot serve as a key (primary key or explicit
/// `bucket-key`). Currently only `VECTOR` is rejected here: it is densely
/// stored and has no key ordering, so it cannot be used as a key column.
fn validate_key_field_types(
fields: &[DataField],
primary_keys: &[String],
options: &HashMap<String, String>,
) -> crate::Result<()> {
let reject = |key_kind: &str, name: &str| -> crate::Result<()> {
let field = fields.iter().find(|f| f.name() == name);
if let Some(field) = field {
if matches!(field.data_type(), DataType::Vector(_)) {
return Err(crate::Error::ConfigInvalid {
message: format!(
"The VECTOR type of {key_kind} field '{name}' is unsupported."
),
});
}
}
Ok(())
};
for pk in primary_keys {
reject("primary key", pk)?;
}
if let Some(bucket_keys) = CoreOptions::new(options).bucket_key() {
for bk in &bucket_keys {
reject("bucket key", bk)?;
}
}
Ok(())
}
fn validate_blob_fields(
fields: &[DataField],
partition_keys: &[String],
options: &HashMap<String, String>,
) -> crate::Result<()> {
let blob_field_names = Self::top_level_blob_field_names(fields);
if blob_field_names.is_empty() {
return Ok(());
}
let core_options = CoreOptions::new(options);
if !core_options.data_evolution_enabled() {
return Err(crate::Error::ConfigInvalid {
message: "Data evolution config must enabled for table with BLOB type column."
.to_string(),
});
}
if fields.len() == blob_field_names.len() {
return Err(crate::Error::ConfigInvalid {
message: "Table with BLOB type column must have other normal columns.".to_string(),
});
}
let partition_key_set: HashSet<&str> = partition_keys.iter().map(String::as_str).collect();
if blob_field_names
.iter()
.any(|name| partition_key_set.contains(name))
{
return Err(crate::Error::ConfigInvalid {
message: "The BLOB type column can not be part of partition keys.".to_string(),
});
}
Ok(())
}
fn validate_first_row_changelog_producer(
options: &HashMap<String, String>,
) -> crate::Result<()> {
if !options
.get(MERGE_ENGINE_OPTION)
.is_some_and(|value| value.eq_ignore_ascii_case("first-row"))
{
return Ok(());
}
let changelog_producer = CoreOptions::new(options)
.try_changelog_producer()
.map_err(Self::options_error_to_config_invalid)?;
if first_row_supports_changelog_producer(changelog_producer) {
return Ok(());
}
Err(crate::Error::ConfigInvalid {
message: format!(
"merge-engine=first-row only supports changelog-producer=none or lookup, but found changelog-producer={}",
changelog_producer.as_str()
),
})
}
fn options_error_to_config_invalid(error: crate::Error) -> crate::Error {
match error {
crate::Error::Unsupported { message } => crate::Error::ConfigInvalid { message },
other => crate::Error::ConfigInvalid {
message: other.to_string(),
},
}
}
/// Returns top-level Blob field names for create-time Blob contract checks.
fn top_level_blob_field_names(fields: &[DataField]) -> Vec<&str> {
fields
.iter()
.filter_map(|field| match field.data_type() {
DataType::Blob(_) => Some(field.name()),
_ => None,
})
.collect()
}
/// Returns the set of names that appear more than once.
pub fn duplicate_fields(names: &[String]) -> HashSet<String> {
let mut seen = HashMap::new();
for n in names {
*seen.entry(n.clone()).or_insert(0) += 1;
}
seen.into_iter()
.filter(|(_, count)| *count > 1)
.map(|(name, _)| name)
.collect()
}
/// Row type with these fields (nullable = false for table row).
pub fn row_type(&self) -> RowType {
RowType::with_nullable(false, self.fields.clone())
}
pub fn fields(&self) -> &[DataField] {
&self.fields
}
pub fn partition_keys(&self) -> &[String] {
&self.partition_keys
}
pub fn primary_keys(&self) -> &[String] {
&self.primary_keys
}
/// Primary keys with partition columns removed.
///
/// Within a single partition the partition columns are constant, so they
/// are redundant in the KV key. Java Paimon calls these "trimmed primary keys".
pub fn trimmed_primary_keys(&self) -> Vec<String> {
if self.partition_keys.is_empty() {
return self.primary_keys.clone();
}
let partition_set: HashSet<&str> = self.partition_keys.iter().map(String::as_str).collect();
self.primary_keys
.iter()
.filter(|pk| !partition_set.contains(pk.as_str()))
.cloned()
.collect()
}
pub fn options(&self) -> &HashMap<String, String> {
&self.options
}
pub fn comment(&self) -> Option<&str> {
self.comment.as_deref()
}
/// Create a new schema with the same keys/options/comment but different row type
pub fn copy(&self, row_type: RowType) -> crate::Result<Self> {
Self::new(
row_type.fields().to_vec(),
self.partition_keys.clone(),
self.primary_keys.clone(),
self.options.clone(),
self.comment.clone(),
)
}
/// Create a new builder for configuring a schema.
pub fn builder() -> SchemaBuilder {
SchemaBuilder::new()
}
}
/// Builder for [`Schema`].
pub struct SchemaBuilder {
columns: Vec<DataField>,
partition_keys: Vec<String>,
primary_keys: Vec<String>,
options: HashMap<String, String>,
comment: Option<String>,
next_field_id: i32,
}
impl SchemaBuilder {
pub fn new() -> Self {
Self {
columns: Vec::new(),
partition_keys: Vec::new(),
primary_keys: Vec::new(),
options: HashMap::new(),
comment: None,
next_field_id: 0,
}
}
/// Add a column (name, data type).
pub fn column(self, column_name: impl Into<String>, data_type: DataType) -> Self {
self.column_with_description(column_name, data_type, None)
}
/// Add a column with optional description.
pub fn column_with_description(
mut self,
column_name: impl Into<String>,
data_type: DataType,
description: Option<String>,
) -> Self {
let name = column_name.into();
let id = self.next_field_id;
self.next_field_id += 1;
let data_type = Self::assign_nested_field_ids(data_type, &mut self.next_field_id);
self.columns
.push(DataField::new(id, name, data_type).with_description(description));
self
}
/// Set partition keys.
pub fn partition_keys(mut self, names: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.partition_keys = names.into_iter().map(Into::into).collect();
self
}
/// Set primary key columns. They must not be nullable.
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
}
/// Set table options (merged with existing).
pub fn options(mut self, opts: impl IntoIterator<Item = (String, String)>) -> Self {
self.options.extend(opts);
self
}
/// Set a single option.
pub fn option(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.options.insert(key.into(), value.into());
self
}
/// Set table comment.
pub fn comment(mut self, comment: Option<String>) -> Self {
self.comment = comment;
self
}
/// Build the schema (validates and normalizes).
pub fn build(self) -> crate::Result<Schema> {
Schema::new(
self.columns,
self.partition_keys,
self.primary_keys,
self.options,
self.comment,
)
}
/// Recursively assign field IDs to nested fields in complex types.
fn assign_nested_field_ids(data_type: DataType, next_id: &mut i32) -> DataType {
let nullable = data_type.is_nullable();
match data_type {
DataType::Row(row) => {
let fields = row
.fields()
.iter()
.map(|f| {
let id = *next_id;
*next_id += 1;
let typ = Self::assign_nested_field_ids(f.data_type().clone(), next_id);
DataField::new(id, f.name().to_string(), typ)
})
.collect();
DataType::Row(RowType::with_nullable(nullable, fields))
}
DataType::Array(arr) => {
let element = Self::assign_nested_field_ids(arr.element_type().clone(), next_id);
DataType::Array(ArrayType::with_nullable(nullable, element))
}
DataType::Map(map) => {
let key = Self::assign_nested_field_ids(map.key_type().clone(), next_id);
let value = Self::assign_nested_field_ids(map.value_type().clone(), next_id);
DataType::Map(MapType::with_nullable(nullable, key, value))
}
DataType::Multiset(ms) => {
let element = Self::assign_nested_field_ids(ms.element_type().clone(), next_id);
DataType::Multiset(MultisetType::with_nullable(nullable, element))
}
other => other,
}
}
}
impl Default for SchemaBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use crate::spec::{BlobType, FloatType, IntType, VarCharType, VectorType};
use super::*;
#[test]
fn test_create_data_field() {
let id = 1;
let name = "field1".to_string();
let typ = DataType::Int(IntType::new());
let description = "test description".to_string();
let data_field = DataField::new(id, name.clone(), typ.clone())
.with_description(Some(description.clone()));
assert_eq!(data_field.id(), id);
assert_eq!(data_field.name(), name);
assert_eq!(data_field.data_type(), &typ);
assert_eq!(data_field.description(), Some(description).as_deref());
}
#[test]
fn test_current_highest_field_id_includes_nested_fields() {
let fields = vec![
DataField::new(0, "id".to_string(), DataType::Int(IntType::new())),
DataField::new(
1,
"s".to_string(),
DataType::Row(RowType::new(vec![DataField::new(
7,
"a".to_string(),
DataType::Array(ArrayType::new(DataType::Row(RowType::new(vec![
DataField::new(9, "b".to_string(), DataType::Int(IntType::new())),
])))),
)])),
),
];
assert_eq!(TableSchema::current_highest_field_id(&fields), 9);
}
#[test]
fn test_new_id() {
let d_type = DataType::Int(IntType::new());
let new_data_field = DataField::new(1, "field1".to_string(), d_type.clone()).with_id(2);
assert_eq!(new_data_field.id(), 2);
assert_eq!(new_data_field.name(), "field1");
assert_eq!(new_data_field.data_type(), &d_type);
assert_eq!(new_data_field.description(), None);
}
#[test]
fn test_new_name() {
let d_type = DataType::Int(IntType::new());
let new_data_field =
DataField::new(1, "field1".to_string(), d_type.clone()).with_name("field2".to_string());
assert_eq!(new_data_field.id(), 1);
assert_eq!(new_data_field.name(), "field2");
assert_eq!(new_data_field.data_type(), &d_type);
assert_eq!(new_data_field.description(), None);
}
#[test]
fn test_new_description() {
let d_type = DataType::Int(IntType::new());
let new_data_field = DataField::new(1, "field1".to_string(), d_type.clone())
.with_description(Some("new description".to_string()));
assert_eq!(new_data_field.id(), 1);
assert_eq!(new_data_field.name(), "field1");
assert_eq!(new_data_field.data_type(), &d_type);
assert_eq!(new_data_field.description(), Some("new description"));
}
#[test]
fn test_escape_identifier() {
let escaped_identifier = escape_identifier("\"identifier\"");
assert_eq!(escaped_identifier, "\"\"identifier\"\"");
}
#[test]
fn test_escape_single_quotes() {
let escaped_text = escape_single_quotes("text with 'single' quotes");
assert_eq!(escaped_text, "text with ''single'' quotes");
}
#[test]
fn test_schema_builder_build() {
let schema = Schema::builder()
.column("id", DataType::Int(IntType::with_nullable(true)))
.column("name", DataType::Int(IntType::new()))
.primary_key(["id"])
.option("k", "v")
.comment(Some("table comment".into()))
.build()
.unwrap();
assert_eq!(schema.fields().len(), 2);
assert_eq!(schema.primary_keys(), &["id"]);
assert_eq!(schema.options().get("k"), Some(&"v".to_string()));
assert_eq!(schema.comment(), Some("table comment"));
let id_field = schema.fields().iter().find(|f| f.name() == "id").unwrap();
assert!(
!id_field.data_type().is_nullable(),
"primary key column should be normalized to NOT NULL"
);
}
#[test]
fn test_copy_with_replaced_options() {
let schema = Schema::builder()
.column("id", DataType::Int(IntType::new()))
.primary_key(["id"])
.option("old-key", "old-value")
.comment(Some("c".into()))
.build()
.unwrap();
let table_schema = TableSchema::new(3, &schema);
let mut new_options = HashMap::new();
new_options.insert("new-key".to_string(), "new-value".to_string());
let copied = table_schema.copy_with_replaced_options(new_options);
// Options are replaced entirely, not merged.
assert_eq!(copied.options().get("old-key"), None);
assert_eq!(
copied.options().get("new-key"),
Some(&"new-value".to_string())
);
// Everything else is preserved.
assert_eq!(copied.id(), table_schema.id());
assert_eq!(copied.fields(), table_schema.fields());
assert_eq!(copied.primary_keys(), table_schema.primary_keys());
assert_eq!(copied.comment(), table_schema.comment());
assert_eq!(copied.time_millis(), table_schema.time_millis());
}
#[test]
fn test_schema_validation() {
// Duplicate field names
let res = Schema::builder()
.column("a", DataType::Int(IntType::new()))
.column("b", DataType::Int(IntType::new()))
.column("a", DataType::Int(IntType::new()))
.build();
assert!(res.is_err(), "duplicate field names should be rejected");
// Duplicate partition keys
let res = Schema::builder()
.column("a", DataType::Int(IntType::new()))
.column("b", DataType::Int(IntType::new()))
.partition_keys(["a", "a"])
.build();
assert!(res.is_err(), "duplicate partition keys should be rejected");
// Partition key not in fields
let res = Schema::builder()
.column("a", DataType::Int(IntType::new()))
.column("b", DataType::Int(IntType::new()))
.partition_keys(["c"])
.build();
assert!(
res.is_err(),
"partition key not in columns should be rejected"
);
// Duplicate primary keys
let res = Schema::builder()
.column("a", DataType::Int(IntType::with_nullable(false)))
.column("b", DataType::Int(IntType::new()))
.primary_key(["a", "a"])
.build();
assert!(res.is_err(), "duplicate primary keys should be rejected");
// Primary key not in fields
let res = Schema::builder()
.column("a", DataType::Int(IntType::with_nullable(false)))
.column("b", DataType::Int(IntType::new()))
.primary_key(["c"])
.build();
assert!(
res.is_err(),
"primary key not in columns should be rejected"
);
// Primary key cannot be fully covered by partition keys.
let res = Schema::builder()
.column("a", DataType::Int(IntType::with_nullable(false)))
.column("b", DataType::Int(IntType::new()))
.partition_keys(["a", "b"])
.primary_key(["a"])
.build();
assert!(
matches!(res, Err(crate::Error::ConfigInvalid { message }) if message.contains("only one record in a partition")),
"primary key fully covered by partition keys should be rejected"
);
// primary-key in options and DDL at same time
let res = Schema::builder()
.column("a", DataType::Int(IntType::with_nullable(false)))
.column("b", DataType::Int(IntType::new()))
.primary_key(["a"])
.option(PRIMARY_KEY_OPTION, "a")
.build();
assert!(
res.is_err(),
"primary key defined in both DDL and options should be rejected"
);
// partition in options and DDL at same time
let res = Schema::builder()
.column("a", DataType::Int(IntType::new()))
.column("b", DataType::Int(IntType::new()))
.partition_keys(["a"])
.option(PARTITION_OPTION, "a")
.build();
assert!(
res.is_err(),
"partition defined in both DDL and options should be rejected"
);
// Valid: partition keys and primary key subset of fields
let schema = Schema::builder()
.column("a", DataType::Int(IntType::with_nullable(false)))
.column("b", DataType::Int(IntType::new()))
.column("c", DataType::Int(IntType::new()))
.partition_keys(["a"])
.primary_key(["a", "b"])
.build()
.unwrap();
assert_eq!(schema.partition_keys(), &["a"]);
assert_eq!(schema.primary_keys(), &["a", "b"]);
}
#[test]
fn test_blob_schema_validation_requires_data_evolution() {
let err = Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column("payload", DataType::Blob(BlobType::new()))
.build()
.unwrap_err();
assert!(
matches!(err, crate::Error::ConfigInvalid { message } if message.contains("Data evolution config must enabled")),
"blob columns should require data-evolution.enabled"
);
}
#[test]
fn test_blob_schema_validation_rejects_all_blob_columns() {
let err = Schema::builder()
.column("payload", DataType::Blob(BlobType::new()))
.option("data-evolution.enabled", "true")
.build()
.unwrap_err();
assert!(
matches!(err, crate::Error::ConfigInvalid { message } if message.contains("must have other normal columns")),
"blob-only tables should be rejected"
);
}
#[test]
fn test_blob_schema_validation_rejects_blob_partition_keys() {
let err = Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column("payload", DataType::Blob(BlobType::new()))
.partition_keys(["payload"])
.option("data-evolution.enabled", "true")
.build()
.unwrap_err();
assert!(
matches!(err, crate::Error::ConfigInvalid { message } if message.contains("can not be part of partition keys")),
"blob columns should be rejected as partition keys during schema validation"
);
}
#[test]
fn test_blob_schema_validation_accepts_valid_blob_table() {
let schema = Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column("payload", DataType::Blob(BlobType::new()))
.option("data-evolution.enabled", "true")
.build()
.unwrap();
assert_eq!(schema.fields().len(), 2);
}
#[test]
fn test_partial_update_schema_validation_rejects_unsupported_options() {
for (key, value) in [
("ignore-delete", "true"),
("fields.value.sequence-group", "g1"),
("fields.default-aggregate-function", "last_non_null"),
] {
let err = Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column("value", DataType::Int(IntType::new()))
.primary_key(["id"])
.option("merge-engine", "partial-update")
.option(key, value)
.build()
.unwrap_err();
assert!(
matches!(err, crate::Error::ConfigInvalid { ref message } if message.contains(key)),
"partial-update create-time validation should reject '{key}', got {err:?}"
);
}
}
#[test]
fn test_aggregation_schema_validation_accepts_basic_options() {
let schema = Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column("value", DataType::Int(IntType::new()))
.column("tags", DataType::VarChar(VarCharType::string_type()))
.primary_key(["id"])
.option("merge-engine", "aggregation")
.option("fields.value.aggregate-function", "sum")
.option("fields.tags.aggregate-function", "listagg")
.option("fields.tags.list-agg-delimiter", ";")
.option("fields.default-aggregate-function", "last_non_null_value")
.build()
.unwrap();
assert_eq!(schema.fields().len(), 3);
}
#[test]
fn test_aggregation_schema_validation_rejects_unsupported_options() {
for (key, value) in [
("ignore-delete", "true"),
("aggregation.remove-record-on-delete", "true"),
("fields.value.ignore-retract", "true"),
("fields.value.distinct", "true"),
("fields.value.sequence-group", "g1"),
("fields.value.nested-key", "id"),
("fields.value.count-limit", "10"),
] {
let err = Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column("value", DataType::Int(IntType::new()))
.primary_key(["id"])
.option("merge-engine", "aggregation")
.option(key, value)
.build()
.unwrap_err();
assert!(
matches!(err, crate::Error::ConfigInvalid { ref message } if message.contains(key)),
"aggregation create-time validation should reject '{key}', got {err:?}"
);
}
}
#[test]
fn test_aggregation_schema_validation_rejects_unknown_field() {
let err = Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column("amount", DataType::Int(IntType::new()))
.primary_key(["id"])
.option("merge-engine", "aggregation")
// typo: `amout` instead of `amount`
.option("fields.amout.aggregate-function", "sum")
.build()
.unwrap_err();
assert!(
matches!(err, crate::Error::ConfigInvalid { ref message }
if message.contains("amout") && message.contains("amount")),
"expected unknown-field rejection at CREATE TABLE, got {err:?}"
);
}
#[test]
fn test_aggregation_schema_validation_rejects_unknown_function() {
let err = Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column("amount", DataType::Int(IntType::new()))
.primary_key(["id"])
.option("merge-engine", "aggregation")
.option("fields.amount.aggregate-function", "sume")
.build()
.unwrap_err();
assert!(
matches!(err, crate::Error::ConfigInvalid { ref message }
if message.contains("sume")),
"expected unknown-function rejection at CREATE TABLE, got {err:?}"
);
}
#[test]
fn test_aggregation_schema_validation_rejects_incompatible_function_type() {
let err = Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column("tag", DataType::VarChar(VarCharType::new(255).unwrap()))
.primary_key(["id"])
.option("merge-engine", "aggregation")
// sum on a VarChar column
.option("fields.tag.aggregate-function", "sum")
.build()
.unwrap_err();
assert!(
matches!(err, crate::Error::ConfigInvalid { ref message }
if message.contains("sum") && message.contains("tag")),
"expected incompatible-type rejection at CREATE TABLE, got {err:?}"
);
}
#[test]
fn test_first_row_schema_validation_accepts_supported_changelog_producers() {
for producer in ["none", "lookup"] {
Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column("value", DataType::Int(IntType::new()))
.primary_key(["id"])
.option("merge-engine", "first-row")
.option("changelog-producer", producer)
.build()
.unwrap();
}
}
#[test]
fn test_first_row_schema_validation_rejects_incompatible_changelog_producers() {
for producer in ["input", "full-compaction"] {
let err = Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column("value", DataType::Int(IntType::new()))
.primary_key(["id"])
.option("merge-engine", "first-row")
.option("changelog-producer", producer)
.build()
.unwrap_err();
assert!(
matches!(err, crate::Error::ConfigInvalid { ref message }
if message.contains("merge-engine=first-row")
&& message.contains("changelog-producer")
&& message.contains(producer)),
"first-row should reject changelog-producer={producer}, got {err:?}"
);
}
}
#[test]
fn test_first_row_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("merge-engine", "first-row")
.option("changelog-producer", "lookup")
.build()
.unwrap(),
);
for producer in ["input", "full-compaction"] {
let err = table_schema
.apply_changes(vec![crate::spec::SchemaChange::set_option(
"changelog-producer".to_string(),
producer.to_string(),
)])
.unwrap_err();
assert!(
matches!(err, crate::Error::ConfigInvalid { ref message }
if message.contains("merge-engine=first-row")
&& message.contains("changelog-producer")
&& message.contains(producer)),
"first-row alter should reject changelog-producer={producer}, got {err:?}"
);
}
}
#[test]
fn test_first_row_apply_changes_validates_final_options() {
let table_schema = TableSchema::new(
0,
&Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column("value", DataType::Int(IntType::new()))
.primary_key(["id"])
.option("changelog-producer", "input")
.build()
.unwrap(),
);
let err = table_schema
.apply_changes(vec![crate::spec::SchemaChange::set_option(
"merge-engine".to_string(),
"first-row".to_string(),
)])
.unwrap_err();
assert!(
matches!(err, crate::Error::ConfigInvalid { ref message }
if message.contains("merge-engine=first-row")
&& message.contains("changelog-producer")
&& message.contains("input")),
"first-row alter should reject incompatible final options, got {err:?}"
);
let new_schema = table_schema
.apply_changes(vec![
crate::spec::SchemaChange::set_option(
"merge-engine".to_string(),
"first-row".to_string(),
),
crate::spec::SchemaChange::set_option(
"changelog-producer".to_string(),
"lookup".to_string(),
),
])
.unwrap();
assert_eq!(
new_schema.options().get("merge-engine").map(String::as_str),
Some("first-row")
);
assert_eq!(
new_schema
.options()
.get("changelog-producer")
.map(String::as_str),
Some("lookup")
);
}
fn cast_test_schema(options: &[(&str, &str)]) -> TableSchema {
let mut builder = Schema::builder()
.column("a", DataType::Int(IntType::new()))
.column("b", DataType::BigInt(crate::spec::BigIntType::new()))
.column(
"d",
DataType::Timestamp(crate::spec::TimestampType::new(3).unwrap()),
);
for (key, value) in options {
builder = builder.option(*key, *value);
}
TableSchema::new(0, &builder.build().unwrap())
}
#[test]
fn test_apply_changes_update_column_type_cast_compatibility() {
let table_schema = cast_test_schema(&[]);
// Implicit widening.
let new_schema = table_schema
.apply_changes(vec![crate::spec::SchemaChange::update_column_type(
"a".to_string(),
DataType::BigInt(crate::spec::BigIntType::new()),
)])
.unwrap();
assert!(matches!(
new_schema.fields()[0].data_type(),
DataType::BigInt(_)
));
// Narrowing is an explicit cast, allowed by default.
let new_schema = table_schema
.apply_changes(vec![crate::spec::SchemaChange::update_column_type(
"b".to_string(),
DataType::Int(IntType::new()),
)])
.unwrap();
assert!(matches!(
new_schema.fields()[1].data_type(),
DataType::Int(_)
));
// Unsupported conversions are rejected before committing the schema.
for new_type in [
DataType::Array(ArrayType::new(DataType::Int(IntType::new()))),
DataType::Boolean(crate::spec::BooleanType::new()),
] {
let err = table_schema
.apply_changes(vec![crate::spec::SchemaChange::update_column_type(
"d".to_string(),
new_type,
)])
.unwrap_err();
assert!(
matches!(err, crate::Error::Unsupported { ref message }
if message.contains("cannot be converted") && message.contains('d')),
"expected cast rejection, got {err:?}"
);
}
}
#[test]
fn test_apply_changes_update_column_type_respects_disable_explicit_casting() {
let table_schema = cast_test_schema(&[("disable-explicit-type-casting", "true")]);
let err = table_schema
.apply_changes(vec![crate::spec::SchemaChange::update_column_type(
"b".to_string(),
DataType::Int(IntType::new()),
)])
.unwrap_err();
assert!(
matches!(err, crate::Error::Unsupported { ref message }
if message.contains("cannot be converted")),
"narrowing should be rejected when explicit casting is disabled, got {err:?}"
);
// Implicit widening is still allowed.
table_schema
.apply_changes(vec![crate::spec::SchemaChange::update_column_type(
"a".to_string(),
DataType::BigInt(crate::spec::BigIntType::new()),
)])
.unwrap();
}
#[test]
fn test_apply_changes_update_column_type_rejects_blob() {
let table_schema = TableSchema::new(
0,
&Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column("payload", DataType::Blob(BlobType::new()))
.option("data-evolution.enabled", "true")
.build()
.unwrap(),
);
for (column, new_type) in [
(
"payload",
DataType::VarChar(crate::spec::VarCharType::new(10).unwrap()),
),
("id", DataType::Blob(BlobType::new())),
] {
let err = table_schema
.apply_changes(vec![crate::spec::SchemaChange::update_column_type(
column.to_string(),
new_type,
)])
.unwrap_err();
assert!(
matches!(err, crate::Error::Unsupported { ref message }
if message.contains("involving BLOB") && message.contains(column)),
"expected BLOB type-change rejection for {column}, got {err:?}"
);
}
}
#[test]
fn test_apply_changes_nullable_to_not_null_guard() {
let table_schema = cast_test_schema(&[]);
let not_null_int = DataType::Int(IntType::new())
.copy_with_nullable(false)
.unwrap();
// Both nullability change paths are rejected by default.
let changes: Vec<crate::spec::SchemaChange> = vec![
crate::spec::SchemaChange::update_column_nullability("a".to_string(), false),
crate::spec::SchemaChange::update_column_type("a".to_string(), not_null_int.clone()),
];
for change in changes {
let err = table_schema.apply_changes(vec![change]).unwrap_err();
assert!(
matches!(err, crate::Error::Unsupported { ref message }
if message.contains("nullable to non nullable")),
"expected null-to-not-null rejection, got {err:?}"
);
}
// Allowed when explicitly enabled via table option.
let table_schema = cast_test_schema(&[("alter-column-null-to-not-null.disabled", "false")]);
let new_schema = table_schema
.apply_changes(vec![crate::spec::SchemaChange::update_column_nullability(
"a".to_string(),
false,
)])
.unwrap();
assert!(!new_schema.fields()[0].data_type().is_nullable());
let new_schema = table_schema
.apply_changes(vec![crate::spec::SchemaChange::update_column_type(
"a".to_string(),
not_null_int,
)])
.unwrap();
assert!(!new_schema.fields()[0].data_type().is_nullable());
}
#[test]
fn test_apply_changes_revalidates_blob_fields() {
let table_schema = TableSchema::new(
0,
&Schema::builder()
.column("id", DataType::Int(IntType::new()))
.build()
.unwrap(),
);
let err = table_schema
.apply_changes(vec![crate::spec::SchemaChange::add_column(
"payload".to_string(),
DataType::Blob(BlobType::new()),
)])
.unwrap_err();
assert!(
matches!(err, crate::Error::ConfigInvalid { ref message }
if message.contains("Data evolution config must enabled")),
"adding a BLOB column without data-evolution.enabled should fail, got {err:?}"
);
// Enabling data evolution in the same alter makes the final schema valid.
let new_schema = table_schema
.apply_changes(vec![
crate::spec::SchemaChange::set_option(
"data-evolution.enabled".to_string(),
"true".to_string(),
),
crate::spec::SchemaChange::add_column(
"payload".to_string(),
DataType::Blob(BlobType::new()),
),
])
.unwrap();
assert_eq!(new_schema.fields().len(), 2);
}
fn vector_4f() -> DataType {
DataType::Vector(VectorType::try_new(true, 4, DataType::Float(FloatType::new())).unwrap())
}
#[test]
fn test_vector_rejected_as_primary_key() {
let err = Schema::builder()
.column("id", vector_4f())
.column("name", DataType::Int(IntType::new()))
.primary_key(["id"])
.build()
.unwrap_err();
assert!(
matches!(err, crate::Error::ConfigInvalid { ref message }
if message.contains("primary key") && message.contains("VECTOR")),
"VECTOR primary key should be rejected, got {err:?}"
);
}
#[test]
fn test_vector_rejected_as_explicit_bucket_key() {
let err = Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column("embedding", vector_4f())
.option(BUCKET_KEY_OPTION, "embedding")
.build()
.unwrap_err();
assert!(
matches!(err, crate::Error::ConfigInvalid { ref message }
if message.contains("bucket key") && message.contains("VECTOR")),
"VECTOR explicit bucket key should be rejected, got {err:?}"
);
}
#[test]
fn test_vector_allowed_as_non_key_column() {
// A VECTOR column that is not a key (no pk, no explicit bucket-key) must
// build fine — the implicit "all non-partition fields" bucket-key fallback
// must NOT reject it.
let schema = Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column("embedding", vector_4f())
.build()
.unwrap();
assert_eq!(schema.fields().len(), 2);
}
#[test]
fn test_apply_changes_revalidates_vector_bucket_key() {
let table_schema = TableSchema::new(
0,
&Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column("embedding", vector_4f())
.build()
.unwrap(),
);
let err = table_schema
.apply_changes(vec![crate::spec::SchemaChange::set_option(
BUCKET_KEY_OPTION.to_string(),
"embedding".to_string(),
)])
.unwrap_err();
assert!(
matches!(err, crate::Error::ConfigInvalid { ref message }
if message.contains("bucket key") && message.contains("VECTOR")),
"altering to a VECTOR bucket key should fail, got {err:?}"
);
}
#[test]
fn test_apply_changes_revalidates_partial_update_options() {
let table_schema = TableSchema::new(
0,
&Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column("value", DataType::Int(IntType::new()))
.primary_key(["id"])
.option("merge-engine", "partial-update")
.build()
.unwrap(),
);
let err = table_schema
.apply_changes(vec![crate::spec::SchemaChange::set_option(
"fields.value.sequence-group".to_string(),
"value".to_string(),
)])
.unwrap_err();
assert!(
matches!(err, crate::Error::ConfigInvalid { ref message }
if message.contains("partial-update") && message.contains("sequence-group")),
"unsupported partial-update option should be rejected on alter, got {err:?}"
);
}
#[test]
fn test_aggregation_apply_changes_rejects_unknown_field() {
let table_schema = TableSchema::new(
0,
&Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column("value", DataType::Int(IntType::new()))
.primary_key(["id"])
.option("merge-engine", "aggregation")
.option("fields.value.aggregate-function", "sum")
.build()
.unwrap(),
);
let err = table_schema
.apply_changes(vec![crate::spec::SchemaChange::set_option(
"fields.valuee.aggregate-function".to_string(),
"sum".to_string(),
)])
.unwrap_err();
assert!(
matches!(err, crate::Error::ConfigInvalid { ref message }
if message.contains("is not declared")
&& message.contains("valuee")),
"aggregation alter should reject typo'd column, got {err:?}"
);
}
#[test]
fn test_partial_update_apply_changes_rejects_unsupported_option() {
let table_schema = TableSchema::new(
0,
&Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column("value", DataType::Int(IntType::new()))
.primary_key(["id"])
.option("merge-engine", "partial-update")
.build()
.unwrap(),
);
let err = table_schema
.apply_changes(vec![crate::spec::SchemaChange::set_option(
"ignore-delete".to_string(),
"true".to_string(),
)])
.unwrap_err();
assert!(
matches!(err, crate::Error::ConfigInvalid { ref message }
if message.contains("merge-engine=partial-update")
&& message.contains("ignore-delete")),
"partial-update alter should reject unsupported option, got {err:?}"
);
}
#[test]
fn test_aggregation_apply_changes_accepts_valid_option() {
let table_schema = TableSchema::new(
0,
&Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column("value", DataType::Int(IntType::new()))
.primary_key(["id"])
.option("merge-engine", "aggregation")
.option("fields.value.aggregate-function", "sum")
.build()
.unwrap(),
);
let new_schema = table_schema
.apply_changes(vec![crate::spec::SchemaChange::set_option(
"fields.value.aggregate-function".to_string(),
"max".to_string(),
)])
.unwrap();
assert_eq!(
new_schema
.options()
.get("fields.value.aggregate-function")
.map(String::as_str),
Some("max")
);
}
#[test]
fn test_aggregation_apply_changes_rejects_sequence_field_function() {
let table_schema = TableSchema::new(
0,
&Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column("seq", DataType::Int(IntType::new()))
.column("value", DataType::Int(IntType::new()))
.primary_key(["id"])
.option("merge-engine", "aggregation")
.option("sequence.field", "seq")
.option("fields.value.aggregate-function", "sum")
.build()
.unwrap(),
);
let err = table_schema
.apply_changes(vec![crate::spec::SchemaChange::set_option(
"fields.seq.aggregate-function".to_string(),
"sum".to_string(),
)])
.unwrap_err();
assert!(
matches!(err, crate::Error::ConfigInvalid { ref message }
if message.contains("sequence field") && message.contains("seq")),
"aggregation alter should reject sequence-field aggregate function, got {err:?}"
);
}
#[test]
fn test_rename_column_rewrites_field_scoped_agg_options() {
let table_schema = TableSchema::new(
0,
&Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column("tag", DataType::VarChar(VarCharType::string_type()))
.primary_key(["id"])
.option("merge-engine", "aggregation")
.option("fields.tag.aggregate-function", "listagg")
.option("fields.tag.list-agg-delimiter", ";")
.build()
.unwrap(),
);
let new_schema = table_schema
.apply_changes(vec![crate::spec::SchemaChange::rename_column(
"tag".to_string(),
"label".to_string(),
)])
.unwrap();
// Field-scoped option keys follow the column to its new name.
assert_eq!(
new_schema
.options()
.get("fields.label.aggregate-function")
.map(String::as_str),
Some("listagg")
);
assert_eq!(
new_schema
.options()
.get("fields.label.list-agg-delimiter")
.map(String::as_str),
Some(";")
);
// The old keys are gone.
assert_eq!(
new_schema.options().get("fields.tag.aggregate-function"),
None
);
assert_eq!(
new_schema.options().get("fields.tag.list-agg-delimiter"),
None
);
}
#[test]
fn test_drop_column_referenced_by_bucket_key_rejected() {
let table_schema = TableSchema::new(
0,
&Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column("name", DataType::VarChar(VarCharType::string_type()))
.option("bucket", "4")
.option("bucket-key", "name")
.build()
.unwrap(),
);
let err = table_schema
.apply_changes(vec![crate::spec::SchemaChange::drop_column(
"name".to_string(),
)])
.unwrap_err();
assert!(
matches!(err, crate::Error::Unsupported { ref message }
if message.contains("bucket-key") && message.contains("name")),
"drop of a bucket-key column should be rejected, got {err:?}"
);
}
#[test]
fn test_drop_column_referenced_by_sequence_field_rejected() {
let table_schema = TableSchema::new(
0,
&Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column("ts", DataType::Int(IntType::new()))
.column("value", DataType::Int(IntType::new()))
.primary_key(["id"])
.option("sequence.field", "ts")
.build()
.unwrap(),
);
let err = table_schema
.apply_changes(vec![crate::spec::SchemaChange::drop_column(
"ts".to_string(),
)])
.unwrap_err();
assert!(
matches!(err, crate::Error::Unsupported { ref message }
if message.contains("sequence.field") && message.contains("ts")),
"drop of a sequence.field column should be rejected, got {err:?}"
);
}
#[test]
fn test_drop_column_removes_field_scoped_agg_options() {
let table_schema = TableSchema::new(
0,
&Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column("value", DataType::Int(IntType::new()))
.column("tag", DataType::VarChar(VarCharType::string_type()))
.primary_key(["id"])
.option("merge-engine", "aggregation")
.option("fields.value.aggregate-function", "sum")
.option("fields.tag.aggregate-function", "listagg")
.option("fields.tag.list-agg-delimiter", ";")
.build()
.unwrap(),
);
let new_schema = table_schema
.apply_changes(vec![crate::spec::SchemaChange::drop_column(
"tag".to_string(),
)])
.unwrap();
// The dropped column's field-scoped options are removed...
assert_eq!(
new_schema.options().get("fields.tag.aggregate-function"),
None
);
assert_eq!(
new_schema.options().get("fields.tag.list-agg-delimiter"),
None
);
// ...while the surviving column's option is untouched.
assert_eq!(
new_schema
.options()
.get("fields.value.aggregate-function")
.map(String::as_str),
Some("sum")
);
assert!(new_schema.fields().iter().all(|f| f.name() != "tag"));
}
#[test]
fn test_schema_builder_column_row_type() {
let row_type = RowType::new(vec![DataField::new(
0,
"nested".into(),
DataType::Int(IntType::new()),
)]);
let schema = Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column("payload", DataType::Row(row_type))
.build()
.unwrap();
assert_eq!(schema.fields().len(), 2);
// id gets field_id=0, payload gets field_id=1, nested gets field_id=2
assert_eq!(schema.fields()[0].id(), 0);
assert_eq!(schema.fields()[1].id(), 1);
if let DataType::Row(row) = schema.fields()[1].data_type() {
assert_eq!(row.fields().len(), 1);
assert_eq!(row.fields()[0].id(), 2);
assert_eq!(row.fields()[0].name(), "nested");
} else {
panic!("expected Row type");
}
}
}