use crate::types::{arrow_to_ducklake_type, ducklake_to_arrow_type};
use crate::{DuckLakeError, Result};
use arrow::datatypes::DataType;
use std::collections::{HashMap, HashSet};
pub const MAX_NAME_LENGTH: usize = 1024;
pub fn validate_name(name: &str, kind: &str) -> Result<()> {
if name.trim().is_empty() {
return Err(DuckLakeError::InvalidConfig(format!(
"{kind} name cannot be empty or whitespace-only"
)));
}
if let Some(pos) = name.find(|c: char| c.is_ascii_control()) {
let byte = name.as_bytes()[pos];
return Err(DuckLakeError::InvalidConfig(format!(
"{kind} name contains control character 0x{byte:02X} at position {pos}"
)));
}
if name.len() > MAX_NAME_LENGTH {
return Err(DuckLakeError::InvalidConfig(format!(
"{kind} name exceeds maximum length of {MAX_NAME_LENGTH} characters (got {})",
name.len()
)));
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WriteMode {
Replace,
Append,
}
pub(crate) fn table_write_changes(
table_id: i64,
mode: WriteMode,
has_deletes: bool,
replaced_existing_data: bool,
) -> String {
match (mode, has_deletes, replaced_existing_data) {
(WriteMode::Append, false, _) | (WriteMode::Replace, false, false) => {
format!("inserted_into_table:{table_id}")
},
(WriteMode::Append | WriteMode::Replace, true, _) | (WriteMode::Replace, false, true) => {
format!("deleted_from_table:{table_id},inserted_into_table:{table_id}")
},
}
}
pub(crate) fn quote_snapshot_name(name: &str) -> String {
format!("\"{}\"", name.replace('"', "\"\""))
}
pub(crate) fn quote_snapshot_table(schema_name: &str, table_name: &str) -> String {
format!(
"{}.{}",
quote_snapshot_name(schema_name),
quote_snapshot_name(table_name)
)
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SnapshotCommitMetadata {
author: Option<String>,
message: Option<String>,
extra_info: Option<String>,
}
impl SnapshotCommitMetadata {
#[must_use]
pub const fn new() -> Self {
Self {
author: None,
message: None,
extra_info: None,
}
}
#[must_use]
pub fn with_author(mut self, author: impl Into<String>) -> Self {
self.author = Some(author.into());
self
}
#[must_use]
pub fn with_message(mut self, message: impl Into<String>) -> Self {
self.message = Some(message.into());
self
}
#[must_use]
pub fn with_extra_info(mut self, extra_info: impl Into<String>) -> Self {
self.extra_info = Some(extra_info.into());
self
}
#[must_use]
pub fn author(&self) -> Option<&str> {
self.author.as_deref()
}
#[must_use]
pub fn message(&self) -> Option<&str> {
self.message.as_deref()
}
#[must_use]
pub fn extra_info(&self) -> Option<&str> {
self.extra_info.as_deref()
}
fn ensure_supported_by_default(&self) -> Result<()> {
if self.author.is_some() || self.message.is_some() || self.extra_info.is_some() {
return Err(DuckLakeError::InvalidConfig(
"commit metadata is not supported by this metadata writer".to_string(),
));
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct ColumnDef {
pub(crate) name: String,
pub(crate) ducklake_type: String,
pub(crate) is_nullable: bool,
pub(crate) data_type: DataType,
}
impl ColumnDef {
pub fn name(&self) -> &str {
&self.name
}
pub fn ducklake_type(&self) -> &str {
&self.ducklake_type
}
pub fn is_nullable(&self) -> bool {
self.is_nullable
}
pub fn new(
name: impl Into<String>,
ducklake_type: impl Into<String>,
is_nullable: bool,
) -> Result<Self> {
let name = name.into();
validate_name(&name, "Column")?;
let ducklake_type = ducklake_type.into();
let data_type = ducklake_to_arrow_type(&ducklake_type)?;
Ok(Self {
name,
ducklake_type,
is_nullable,
data_type,
})
}
pub fn from_arrow(
name: impl Into<String>,
data_type: &DataType,
is_nullable: bool,
) -> Result<Self> {
let name = name.into();
validate_name(&name, "Column")?;
let ducklake_type = arrow_to_ducklake_type(data_type)?;
Ok(Self {
name,
ducklake_type,
is_nullable,
data_type: data_type.clone(),
})
}
}
#[derive(Debug, Clone)]
pub(crate) struct CatalogColumnDef {
pub name: String,
pub ducklake_type: String,
pub logical_type: String,
pub is_nullable: bool,
pub parent_index: Option<usize>,
}
#[derive(Debug, Clone)]
pub(crate) struct ExistingCatalogColumn {
pub column_id: i64,
pub name: String,
pub ducklake_type: String,
pub parent_column: Option<i64>,
}
pub(crate) fn catalog_column_defs(columns: &[ColumnDef]) -> Result<Vec<CatalogColumnDef>> {
let mut result = Vec::new();
for column in columns {
append_column_def(
&column.name,
&column.data_type,
column.is_nullable,
None,
&mut result,
)?;
}
Ok(result)
}
fn append_column_def(
name: &str,
data_type: &DataType,
is_nullable: bool,
parent_index: Option<usize>,
result: &mut Vec<CatalogColumnDef>,
) -> Result<()> {
let index = result.len();
result.push(CatalogColumnDef {
name: name.to_string(),
ducklake_type: catalog_type_name(data_type)?,
logical_type: arrow_to_ducklake_type(data_type)?,
is_nullable,
parent_index,
});
match data_type {
DataType::List(field) | DataType::LargeList(field) | DataType::FixedSizeList(field, _) => {
append_column_def(
"element",
field.data_type(),
field.is_nullable(),
Some(index),
result,
)
},
DataType::Struct(fields) => {
for field in fields {
append_column_def(
field.name(),
field.data_type(),
field.is_nullable(),
Some(index),
result,
)?;
}
Ok(())
},
DataType::Map(entries, _) => {
let DataType::Struct(fields) = entries.data_type() else {
return Err(DuckLakeError::UnsupportedType(
"Arrow map entries must be a struct".to_string(),
));
};
let [key, value] = fields.as_ref() else {
return Err(DuckLakeError::UnsupportedType(
"Arrow maps must have key and value fields".to_string(),
));
};
append_column_def("key", key.data_type(), false, Some(index), result)?;
append_column_def(
"value",
value.data_type(),
value.is_nullable(),
Some(index),
result,
)
},
_ => Ok(()),
}
}
pub(crate) fn catalog_column_type_equal(existing_type: &str, proposed: &CatalogColumnDef) -> bool {
existing_type.eq_ignore_ascii_case(&proposed.ducklake_type)
|| crate::types::types_equal_canonical(existing_type, &proposed.ducklake_type)
|| catalog_column_type_requires_migration(existing_type, proposed)
}
pub(crate) fn catalog_column_type_requires_migration(
existing_type: &str,
proposed: &CatalogColumnDef,
) -> bool {
proposed.ducklake_type == "list"
&& existing_type
.trim()
.to_ascii_lowercase()
.starts_with("list<")
&& crate::types::types_equal_canonical(existing_type, &proposed.logical_type)
}
fn catalog_type_name(data_type: &DataType) -> Result<String> {
match data_type {
DataType::List(_) | DataType::LargeList(_) | DataType::FixedSizeList(_, _) => {
Ok("list".to_string())
},
DataType::Struct(_) => Ok("struct".to_string()),
DataType::Map(_, _) => Ok("map".to_string()),
_ => arrow_to_ducklake_type(data_type),
}
}
pub(crate) fn top_level_column_ids(
columns: &[CatalogColumnDef],
field_ids: &[i64],
) -> Result<Vec<i64>> {
if columns.len() != field_ids.len() {
return Err(DuckLakeError::Internal(format!(
"catalog column count {} does not match field id count {}",
columns.len(),
field_ids.len()
)));
}
Ok(columns
.iter()
.zip(field_ids)
.filter_map(|(column, field_id)| column.parent_index.is_none().then_some(*field_id))
.collect())
}
pub(crate) fn assign_column_ids(
proposed: &[CatalogColumnDef],
existing: &[ExistingCatalogColumn],
fresh_ids: &[i64],
) -> Result<Vec<i64>> {
if proposed.len() != fresh_ids.len() {
return Err(DuckLakeError::Internal(format!(
"proposed column count {} does not match reserved id count {}",
proposed.len(),
fresh_ids.len()
)));
}
let mut existing_paths: HashMap<i64, Vec<String>> = HashMap::new();
let mut unresolved = existing.iter().collect::<Vec<_>>();
while !unresolved.is_empty() {
let before = unresolved.len();
unresolved.retain(|column| {
let parent_path = match column.parent_column {
Some(parent_id) => match existing_paths.get(&parent_id) {
Some(path) => path.clone(),
None => return true,
},
None => Vec::new(),
};
let mut path = parent_path;
path.push(column.name.clone());
existing_paths.insert(column.column_id, path);
false
});
if unresolved.len() == before {
return Err(DuckLakeError::InvalidConfig(
"Catalog contains an orphaned or cyclic nested column".to_string(),
));
}
}
if existing_paths.len() != existing.len() {
return Err(DuckLakeError::InvalidConfig(
"Catalog contains duplicate column ids".to_string(),
));
}
let mut existing_by_path = HashMap::new();
for column in existing {
let path = existing_paths
.get(&column.column_id)
.expect("every existing column path was resolved")
.clone();
if existing_by_path.insert(path, column.column_id).is_some() {
return Err(DuckLakeError::InvalidConfig(
"Catalog contains duplicate nested column paths".to_string(),
));
}
}
let mut proposed_paths: Vec<Vec<String>> = Vec::with_capacity(proposed.len());
let mut proposed_path_set = HashSet::with_capacity(proposed.len());
for column in proposed {
let mut path = column
.parent_index
.map(|parent_index| proposed_paths[parent_index].clone())
.unwrap_or_default();
path.push(column.name.clone());
if !proposed_path_set.insert(path.clone()) {
return Err(DuckLakeError::InvalidConfig(
"Proposed schema contains duplicate nested column paths".to_string(),
));
}
proposed_paths.push(path);
}
Ok(proposed_paths
.iter()
.zip(fresh_ids)
.map(|(path, fresh_id)| existing_by_path.get(path).copied().unwrap_or(*fresh_id))
.collect())
}
pub(crate) fn catalog_columns_differ(
existing: &[ExistingCatalogColumn],
existing_nullability: &[bool],
proposed: &[CatalogColumnDef],
field_ids: &[i64],
) -> bool {
if existing.len() != proposed.len()
|| existing.len() != existing_nullability.len()
|| proposed.len() != field_ids.len()
{
return true;
}
existing
.iter()
.zip(existing_nullability)
.zip(proposed.iter().zip(field_ids))
.any(|((existing, existing_nullable), (proposed, field_id))| {
let parent_id = proposed.parent_index.map(|index| field_ids[index]);
let same_type = catalog_column_type_equal(&existing.ducklake_type, proposed)
|| crate::types::is_promotable(&proposed.ducklake_type, &existing.ducklake_type);
existing.column_id != *field_id
|| existing.name != proposed.name
|| !same_type
|| *existing_nullable != proposed.is_nullable
|| existing.parent_column != parent_id
})
}
#[cfg(test)]
pub(crate) fn columns_differ(existing: &[(String, String, bool)], proposed: &[ColumnDef]) -> bool {
if existing.len() != proposed.len() {
return true;
}
for ((ex_name, ex_type, ex_nullable), new_col) in existing.iter().zip(proposed.iter()) {
if ex_name != &new_col.name {
return true;
}
let same_type = crate::types::types_equal_canonical(ex_type, &new_col.ducklake_type)
|| crate::types::is_promotable(&new_col.ducklake_type, ex_type);
if !same_type {
return true;
}
if *ex_nullable != new_col.is_nullable {
return true;
}
}
false
}
#[derive(Debug, Clone, PartialEq)]
pub struct ColumnStat {
pub column_id: i64,
pub min_value: Option<String>,
pub max_value: Option<String>,
pub null_count: Option<i64>,
pub value_count: Option<i64>,
pub contains_nan: Option<bool>,
pub column_size_bytes: Option<i64>,
}
#[derive(Debug, Clone)]
pub struct DataFileInfo {
pub path: String,
pub path_is_relative: bool,
pub file_size_bytes: i64,
pub footer_size: Option<i64>,
pub record_count: i64,
pub column_stats: Vec<ColumnStat>,
pub partition_id: Option<i64>,
pub partition_values: Vec<(i32, Option<String>)>,
}
impl DataFileInfo {
pub fn new(path: impl Into<String>, file_size_bytes: i64, record_count: i64) -> Self {
assert!(
record_count >= 0,
"record_count must be non-negative, got {}",
record_count
);
Self {
path: path.into(),
path_is_relative: true,
file_size_bytes,
footer_size: None,
record_count,
column_stats: Vec::new(),
partition_id: None,
partition_values: Vec::new(),
}
}
pub fn with_footer_size(mut self, footer_size: i64) -> Self {
self.footer_size = Some(footer_size);
self
}
pub fn with_column_stats(mut self, column_stats: Vec<ColumnStat>) -> Self {
self.column_stats = column_stats;
self
}
pub fn with_partition(
mut self,
partition_id: i64,
partition_values: Vec<(i32, Option<String>)>,
) -> Self {
self.partition_id = Some(partition_id);
self.partition_values = partition_values;
self
}
pub fn with_absolute_path(mut self) -> Self {
self.path_is_relative = false;
self
}
}
pub(crate) fn enforce_partition_fence(
table_id: i64,
live_partition_id: Option<i64>,
file: &DataFileInfo,
) -> Result<()> {
match file.partition_id {
Some(pid) if live_partition_id != Some(pid) => Err(DuckLakeError::Conflict(format!(
"partition spec (partition_id {pid}) for table {table_id} was changed by a concurrent \
SET/RESET PARTITIONED BY during this commit; re-open the catalog and retry"
))),
None if file.record_count > 0 && live_partition_id.is_some() => {
Err(DuckLakeError::Conflict(format!(
"table {table_id} gained a partition spec (concurrent SET PARTITIONED BY) after this \
unpartitioned write was planned; re-open the catalog and retry"
)))
},
_ => Ok(()),
}
}
pub(crate) fn validate_promoted_partition_values(
table_id: i64,
transforms: &[String],
key_column_types: &[Option<DataType>],
file: &DataFileInfo,
) -> Result<()> {
use crate::partition::PartitionTransform;
if file.partition_values.len() != transforms.len() {
return Err(DuckLakeError::InvalidConfig(format!(
"promoted file for table {table_id} carries {} partition value(s) but the table's \
live partition spec has {} key(s)",
file.partition_values.len(),
transforms.len()
)));
}
let mut seen = vec![false; transforms.len()];
for (key_index, value) in &file.partition_values {
let index = usize::try_from(*key_index).ok().filter(|i| *i < seen.len());
let Some(index) = index else {
return Err(DuckLakeError::InvalidConfig(format!(
"promoted file for table {table_id} has partition_key_index {key_index}, outside \
the live spec's 0..{} keys",
transforms.len()
)));
};
if seen[index] {
return Err(DuckLakeError::InvalidConfig(format!(
"promoted file for table {table_id} repeats partition_key_index {key_index}"
)));
}
seen[index] = true;
let transform = PartitionTransform::parse(&transforms[index]);
let column_type = key_column_types
.get(index)
.and_then(|t| t.clone())
.unwrap_or(DataType::Utf8);
if !transform.value_is_well_formed(value.as_deref(), &column_type) {
return Err(DuckLakeError::InvalidConfig(format!(
"promoted file for table {table_id} has partition value {value:?} for key \
{key_index} with transform '{}' on a {column_type} column; the value is not \
valid for that key",
transform.to_catalog_string()
)));
}
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct DeleteFileInfo {
pub path: String,
pub path_is_relative: bool,
pub file_size_bytes: i64,
pub footer_size: Option<i64>,
pub delete_count: i64,
}
impl DeleteFileInfo {
pub fn new(path: impl Into<String>, file_size_bytes: i64, delete_count: i64) -> Self {
assert!(
delete_count >= 0,
"delete_count must be non-negative, got {delete_count}"
);
Self {
path: path.into(),
path_is_relative: true,
file_size_bytes,
footer_size: None,
delete_count,
}
}
pub fn with_footer_size(mut self, footer_size: i64) -> Self {
self.footer_size = Some(footer_size);
self
}
pub fn with_absolute_path(mut self) -> Self {
self.path_is_relative = false;
self
}
}
#[derive(Debug, Clone)]
pub struct DeleteFileEntry {
pub data_file_id: i64,
pub expected_prev_delete_file: Option<i64>,
pub delete: DeleteFileInfo,
}
pub(crate) fn validate_delete_entries(mode: WriteMode, deletes: &[DeleteFileEntry]) -> Result<()> {
if deletes.is_empty() {
return Ok(());
}
if mode == WriteMode::Replace {
return Err(DuckLakeError::InvalidConfig(
"register_data_file_with_deletes: positional deletes require WriteMode::Append; \
Replace retires the data files the deletes target"
.to_string(),
));
}
let mut seen = std::collections::HashSet::with_capacity(deletes.len());
for entry in deletes {
if !seen.insert(entry.data_file_id) {
return Err(DuckLakeError::InvalidConfig(format!(
"register_data_file_with_deletes: duplicate delete entry for data file {}; \
each entry must target a distinct data file",
entry.data_file_id
)));
}
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct CompactionSourceFile {
pub data_file_id: i64,
pub delete_file_id: Option<i64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceRetirement {
Remove,
Retire,
}
#[derive(Debug, Clone)]
pub struct CompactionOutputFile {
pub file: DataFileInfo,
pub partial_max: Option<i64>,
pub begin_snapshot: Option<i64>,
}
#[derive(Debug)]
pub struct WriteResult {
pub snapshot_id: i64,
pub table_id: i64,
pub schema_id: i64,
pub files_written: usize,
pub records_written: i64,
}
#[derive(Debug, Clone, Copy)]
pub struct CommitIds {
pub snapshot_id: i64,
pub schema_id: i64,
pub table_id: i64,
}
#[derive(Debug)]
pub struct WriteSetupResult {
pub snapshot_id: i64,
pub base_snapshot_id: i64,
pub schema_id: i64,
pub table_id: i64,
pub column_ids: Vec<i64>,
pub field_ids: Vec<i64>,
}
pub trait MetadataWriter: Send + Sync + std::fmt::Debug {
fn create_snapshot(&self) -> Result<i64>;
fn get_or_create_schema(
&self,
name: &str,
path: Option<&str>,
snapshot_id: i64,
) -> Result<(i64, bool)>;
fn get_or_create_table(
&self,
schema_id: i64,
name: &str,
path: Option<&str>,
snapshot_id: i64,
) -> Result<(i64, bool)>;
fn set_columns(
&self,
table_id: i64,
columns: &[ColumnDef],
snapshot_id: i64,
) -> Result<Vec<i64>>;
fn promote_column_type(
&self,
_table_id: i64,
_column_name: &str,
_new_ducklake_type: &str,
) -> Result<i64> {
Err(DuckLakeError::InvalidConfig(
"promote_column_type is not supported on this metadata backend".to_string(),
))
}
fn set_partition_spec(
&self,
_table_id: i64,
_columns: &[(String, crate::partition::PartitionTransform)],
) -> Result<i64> {
Err(DuckLakeError::InvalidConfig(
"SET PARTITIONED BY is not supported on this metadata backend".to_string(),
))
}
fn live_partition_spec(
&self,
_table_id: i64,
) -> Result<Option<crate::partition::PartitionSpec>> {
Ok(None)
}
fn reset_partition_spec(&self, _table_id: i64) -> Result<i64> {
Err(DuckLakeError::InvalidConfig(
"RESET PARTITIONED BY is not supported on this metadata backend".to_string(),
))
}
fn live_sort_spec(&self, _table_id: i64) -> Result<Option<crate::sort::SortSpec>> {
Ok(None)
}
fn set_sort_spec(&self, _table_id: i64, _fields: &[crate::sort::SortField]) -> Result<i64> {
Err(DuckLakeError::InvalidConfig(
"SET SORTED BY is not supported on this metadata backend".to_string(),
))
}
fn reset_sort_spec(&self, _table_id: i64) -> Result<i64> {
Err(DuckLakeError::InvalidConfig(
"RESET SORTED BY is not supported on this metadata backend".to_string(),
))
}
#[allow(clippy::too_many_arguments)]
fn register_data_file(
&self,
table_id: i64,
schema_name: &str,
table_name: &str,
snapshot_id: i64,
file: &DataFileInfo,
mode: WriteMode,
base_snapshot: i64,
columns: &[ColumnDef],
column_ids: &[i64],
) -> Result<CommitIds>;
#[allow(clippy::too_many_arguments)]
fn register_data_files(
&self,
table_id: i64,
schema_name: &str,
table_name: &str,
snapshot_id: i64,
files: &[DataFileInfo],
mode: WriteMode,
base_snapshot: i64,
columns: &[ColumnDef],
column_ids: &[i64],
) -> Result<CommitIds> {
match files {
[file] => self.register_data_file(
table_id,
schema_name,
table_name,
snapshot_id,
file,
mode,
base_snapshot,
columns,
column_ids,
),
_ => Err(DuckLakeError::InvalidConfig(
"register_data_files (atomic multi-file / partitioned write) is not \
supported on this metadata backend"
.to_string(),
)),
}
}
#[allow(clippy::too_many_arguments)]
fn register_data_file_with_commit_metadata(
&self,
table_id: i64,
schema_name: &str,
table_name: &str,
snapshot_id: i64,
file: &DataFileInfo,
mode: WriteMode,
base_snapshot: i64,
columns: &[ColumnDef],
column_ids: &[i64],
commit_metadata: &SnapshotCommitMetadata,
expected_base_snapshot_id: Option<i64>,
) -> Result<CommitIds> {
if expected_base_snapshot_id.is_some() {
return Err(DuckLakeError::InvalidConfig(
"conditional writes are not supported by this metadata writer".to_string(),
));
}
commit_metadata.ensure_supported_by_default()?;
self.register_data_file(
table_id,
schema_name,
table_name,
snapshot_id,
file,
mode,
base_snapshot,
columns,
column_ids,
)
}
#[allow(clippy::too_many_arguments)]
fn register_data_files_with_commit_metadata(
&self,
table_id: i64,
schema_name: &str,
table_name: &str,
snapshot_id: i64,
files: &[DataFileInfo],
mode: WriteMode,
base_snapshot: i64,
columns: &[ColumnDef],
column_ids: &[i64],
commit_metadata: &SnapshotCommitMetadata,
expected_base_snapshot_id: Option<i64>,
) -> Result<CommitIds> {
if expected_base_snapshot_id.is_some() {
return Err(DuckLakeError::InvalidConfig(
"conditional multi-file writes are not supported by this metadata writer"
.to_string(),
));
}
commit_metadata.ensure_supported_by_default()?;
self.register_data_files(
table_id,
schema_name,
table_name,
snapshot_id,
files,
mode,
base_snapshot,
columns,
column_ids,
)
}
#[allow(clippy::too_many_arguments)]
fn set_delete_file(
&self,
_table_id: i64,
_schema_name: &str,
_table_name: &str,
_snapshot_id: i64,
_data_file_id: i64,
_expected_prev_delete_file: Option<i64>,
_base_snapshot: i64,
_delete: &DeleteFileInfo,
) -> Result<CommitIds> {
Err(DuckLakeError::InvalidConfig(
"set_delete_file is not supported by this metadata writer".to_string(),
))
}
#[allow(clippy::too_many_arguments)]
fn register_data_file_with_deletes(
&self,
_table_id: i64,
_schema_name: &str,
_table_name: &str,
_snapshot_id: i64,
_file: &DataFileInfo,
_deletes: &[DeleteFileEntry],
_mode: WriteMode,
_base_snapshot: i64,
_columns: &[ColumnDef],
_column_ids: &[i64],
) -> Result<CommitIds> {
Err(DuckLakeError::InvalidConfig(
"register_data_file_with_deletes is not supported by this metadata writer".to_string(),
))
}
#[allow(clippy::too_many_arguments)]
fn register_data_file_with_deletes_and_commit_metadata(
&self,
table_id: i64,
schema_name: &str,
table_name: &str,
snapshot_id: i64,
file: &DataFileInfo,
deletes: &[DeleteFileEntry],
mode: WriteMode,
base_snapshot: i64,
columns: &[ColumnDef],
column_ids: &[i64],
commit_metadata: &SnapshotCommitMetadata,
expected_base_snapshot_id: Option<i64>,
) -> Result<CommitIds> {
if expected_base_snapshot_id.is_some() {
return Err(DuckLakeError::InvalidConfig(
"conditional combined writes are not supported by this metadata writer".to_string(),
));
}
commit_metadata.ensure_supported_by_default()?;
self.register_data_file_with_deletes(
table_id,
schema_name,
table_name,
snapshot_id,
file,
deletes,
mode,
base_snapshot,
columns,
column_ids,
)
}
#[allow(clippy::too_many_arguments)]
fn register_data_files_with_deletes(
&self,
_table_id: i64,
_schema_name: &str,
_table_name: &str,
_snapshot_id: i64,
_files: &[DataFileInfo],
_deletes: &[DeleteFileEntry],
_mode: WriteMode,
_base_snapshot: i64,
_columns: &[ColumnDef],
_column_ids: &[i64],
) -> Result<CommitIds> {
Err(DuckLakeError::InvalidConfig(
"register_data_files_with_deletes is not supported by this metadata writer".to_string(),
))
}
#[allow(clippy::too_many_arguments)]
fn register_data_files_with_deletes_and_commit_metadata(
&self,
table_id: i64,
schema_name: &str,
table_name: &str,
snapshot_id: i64,
files: &[DataFileInfo],
deletes: &[DeleteFileEntry],
mode: WriteMode,
base_snapshot: i64,
columns: &[ColumnDef],
column_ids: &[i64],
commit_metadata: &SnapshotCommitMetadata,
expected_base_snapshot_id: Option<i64>,
) -> Result<CommitIds> {
if expected_base_snapshot_id.is_some() {
return Err(DuckLakeError::InvalidConfig(
"conditional combined multi-file writes are not supported by this metadata writer"
.to_string(),
));
}
commit_metadata.ensure_supported_by_default()?;
self.register_data_files_with_deletes(
table_id,
schema_name,
table_name,
snapshot_id,
files,
deletes,
mode,
base_snapshot,
columns,
column_ids,
)
}
fn commit_positional_deletes(
&self,
_table_id: i64,
_schema_name: &str,
_table_name: &str,
_base_snapshot: i64,
_deletes: &[DeleteFileEntry],
) -> Result<CommitIds> {
Err(DuckLakeError::InvalidConfig(
"positional DELETE is not supported on this metadata backend".to_string(),
))
}
fn commit_compaction(
&self,
_table_id: i64,
_base_snapshot: i64,
_sources: &[CompactionSourceFile],
_outputs: &[CompactionOutputFile],
_retirement: SourceRetirement,
) -> Result<CommitIds> {
Err(DuckLakeError::InvalidConfig(
"compaction is not supported on this metadata backend".to_string(),
))
}
fn commit_truncate(
&self,
_table_id: i64,
_schema_name: &str,
_table_name: &str,
_base_snapshot: i64,
) -> Result<u64> {
Err(DuckLakeError::InvalidConfig(
"DELETE (truncate) is not supported on this metadata backend".to_string(),
))
}
fn retire_appends_since(&self, _table_id: i64, _base_snapshot: i64) -> Result<Option<i64>> {
Err(DuckLakeError::InvalidConfig(
"retire_appends_since is not supported on this metadata backend".to_string(),
))
}
#[allow(clippy::too_many_arguments)]
fn register_existing_data_file(
&self,
_schema_name: &str,
_table_name: &str,
_columns: &[ColumnDef],
_column_ids: &[i64],
_file: &DataFileInfo,
_mode: WriteMode,
) -> Result<CommitIds> {
Err(DuckLakeError::InvalidConfig(
"register_existing_data_file is not supported by this metadata writer".to_string(),
))
}
#[allow(clippy::too_many_arguments)]
fn publish_snapshot(
&self,
_table_id: i64,
_schema_name: &str,
_table_name: &str,
_snapshot_id: i64,
_mode: WriteMode,
_base_snapshot: i64,
_columns: &[ColumnDef],
_column_ids: &[i64],
) -> Result<CommitIds> {
Ok(CommitIds {
snapshot_id: _snapshot_id,
schema_id: 0,
table_id: _table_id,
})
}
fn end_table_files(&self, table_id: i64, snapshot_id: i64) -> Result<u64>;
fn get_data_path(&self) -> Result<String>;
fn set_data_path(&self, path: &str) -> Result<()>;
fn initialize_schema(&self) -> Result<()>;
fn begin_write_transaction(
&self,
schema_name: &str,
table_name: &str,
columns: &[ColumnDef],
mode: WriteMode,
) -> Result<WriteSetupResult>;
fn catalog_id(&self) -> Option<i64> {
None
}
fn supports_update(&self) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::DuckLakeError;
use arrow::datatypes::Field;
use std::sync::Arc;
fn promoted(values: Vec<(i32, Option<String>)>) -> DataFileInfo {
DataFileInfo::new("f.parquet", 1024, 10).with_partition(7, values)
}
fn utf8_types(n: usize) -> Vec<Option<DataType>> {
vec![Some(DataType::Utf8); n]
}
#[test]
fn promoted_values_must_match_the_live_key_count() {
let transforms = vec!["identity".to_string(), "year".to_string()];
let err = validate_promoted_partition_values(
1,
&transforms,
&utf8_types(transforms.len()),
&promoted(vec![(0, Some("us".into()))]),
)
.unwrap_err();
assert!(matches!(err, DuckLakeError::InvalidConfig(_)), "got {err}");
assert!(
validate_promoted_partition_values(
1,
&transforms,
&utf8_types(transforms.len()),
&promoted(vec![(0, Some("us".into())), (1, Some("2024".into()))]),
)
.is_ok()
);
}
#[test]
fn promoted_values_reject_duplicate_or_out_of_range_key_index() {
let transforms = vec!["identity".to_string(), "year".to_string()];
assert!(
validate_promoted_partition_values(
1,
&transforms,
&utf8_types(transforms.len()),
&promoted(vec![(0, Some("us".into())), (0, Some("eu".into()))]),
)
.is_err()
);
assert!(
validate_promoted_partition_values(
1,
&transforms,
&utf8_types(transforms.len()),
&promoted(vec![(0, Some("us".into())), (5, Some("2024".into()))]),
)
.is_err()
);
}
#[test]
fn promoted_bucket_values_must_be_in_range() {
let transforms = vec!["bucket(8)".to_string()];
assert!(
validate_promoted_partition_values(
1,
&transforms,
&utf8_types(transforms.len()),
&promoted(vec![(0, Some("3".into()))])
)
.is_ok()
);
for bad in ["8", "-1", "abc"] {
assert!(
validate_promoted_partition_values(
1,
&transforms,
&utf8_types(transforms.len()),
&promoted(vec![(0, Some(bad.to_string()))]),
)
.is_err(),
"bucket value {bad} must be rejected"
);
}
assert!(
validate_promoted_partition_values(
1,
&transforms,
&utf8_types(transforms.len()),
&promoted(vec![(0, None)])
)
.is_ok()
);
}
#[test]
fn promoted_identity_value_must_cast_to_the_key_column_type() {
let transforms = vec!["identity".to_string()];
let int_key = vec![Some(DataType::Int32)];
let err = validate_promoted_partition_values(
1,
&transforms,
&int_key,
&promoted(vec![(0, Some("abc".into()))]),
)
.unwrap_err();
assert!(matches!(err, DuckLakeError::InvalidConfig(_)), "got {err}");
for value in [Some("42".to_string()), None] {
assert!(
validate_promoted_partition_values(
1,
&transforms,
&int_key,
&promoted(vec![(0, value.clone())]),
)
.is_ok(),
"value {value:?} must be accepted for an Int32 identity key"
);
}
}
#[test]
fn promoted_temporal_value_must_parse_as_an_integer_and_no_more() {
let transforms = vec!["month".to_string()];
let date_key = vec![Some(DataType::Date32)];
assert!(
validate_promoted_partition_values(
1,
&transforms,
&date_key,
&promoted(vec![(0, Some("2024-06".into()))]),
)
.is_err(),
"a non-integer month value must be rejected"
);
for accepted in ["6", "13", "0"] {
assert!(
validate_promoted_partition_values(
1,
&transforms,
&date_key,
&promoted(vec![(0, Some(accepted.to_string()))]),
)
.is_ok(),
"month value {accepted} must be accepted (official does not range-check)"
);
}
}
#[test]
fn promoted_null_value_is_legal_for_identity() {
let transforms = vec!["identity".to_string()];
assert!(
validate_promoted_partition_values(
1,
&transforms,
&utf8_types(transforms.len()),
&promoted(vec![(0, None)])
)
.is_ok()
);
}
#[test]
fn fence_exempts_empty_file_but_rejects_rows_without_partition() {
let empty = DataFileInfo::new("empty.parquet", 0, 0);
assert!(enforce_partition_fence(1, Some(7), &empty).is_ok());
let rows = DataFileInfo::new("f.parquet", 1024, 10);
assert!(matches!(
enforce_partition_fence(1, Some(7), &rows),
Err(DuckLakeError::Conflict(_))
));
assert!(enforce_partition_fence(1, None, &rows).is_ok());
assert!(matches!(
enforce_partition_fence(1, Some(9), &promoted(vec![(0, Some("us".into()))])),
Err(DuckLakeError::Conflict(_))
));
}
#[test]
fn test_column_def_new() {
let col = ColumnDef::new("test_col", "int32", true).unwrap();
assert_eq!(col.name, "test_col");
assert_eq!(col.ducklake_type, "int32");
assert!(col.is_nullable);
}
#[test]
fn test_column_def_new_valid_types() {
assert!(ColumnDef::new("a", "int32", true).is_ok());
assert!(ColumnDef::new("b", "varchar", false).is_ok());
assert!(ColumnDef::new("c", "boolean", true).is_ok());
assert!(ColumnDef::new("d", "float64", true).is_ok());
assert!(ColumnDef::new("e", "decimal(10,2)", true).is_ok());
assert!(ColumnDef::new("f", "timestamp", true).is_ok());
assert!(ColumnDef::new("g", "date", true).is_ok());
assert!(ColumnDef::new("h", "bigint", true).is_ok());
assert!(ColumnDef::new("i", "text", true).is_ok());
}
#[test]
fn test_column_def_new_invalid_type_rejected() {
let result = ColumnDef::new("col", "not_a_type", true);
assert!(result.is_err());
match result {
Err(DuckLakeError::UnsupportedType(msg)) => {
assert_eq!(msg, "not_a_type");
},
other => panic!("Expected UnsupportedType error, got {:?}", other),
}
}
#[test]
fn test_column_def_new_empty_type_rejected() {
let result = ColumnDef::new("col", "", true);
assert!(result.is_err());
match result {
Err(DuckLakeError::UnsupportedType(_)) => {},
other => panic!("Expected UnsupportedType error, got {:?}", other),
}
}
#[test]
fn test_column_def_from_arrow() {
let col = ColumnDef::from_arrow("id", &DataType::Int64, false).unwrap();
assert_eq!(col.name, "id");
assert_eq!(col.ducklake_type, "int64");
assert!(!col.is_nullable);
}
#[test]
fn test_catalog_column_defs_use_depth_first_preorder() {
let levels = DataType::List(Arc::new(Field::new(
"item",
DataType::Struct(
vec![
Arc::new(Field::new("price", DataType::Decimal128(38, 16), false)),
Arc::new(Field::new(
"tags",
DataType::List(Arc::new(Field::new("item", DataType::Utf8, true))),
true,
)),
]
.into(),
),
false,
)));
let attrs = DataType::Map(
Arc::new(Field::new(
"entries",
DataType::Struct(
vec![
Arc::new(Field::new("key", DataType::Utf8, false)),
Arc::new(Field::new(
"value",
DataType::Struct(
vec![Arc::new(Field::new("active", DataType::Boolean, false))]
.into(),
),
true,
)),
]
.into(),
),
false,
)),
false,
);
let columns = vec![
ColumnDef::from_arrow("id", &DataType::Int32, false).unwrap(),
ColumnDef::from_arrow("levels", &levels, false).unwrap(),
ColumnDef::from_arrow("attrs", &attrs, true).unwrap(),
];
let definitions = catalog_column_defs(&columns).unwrap();
let actual = definitions
.iter()
.map(|column| {
(
column.name.as_str(),
column.ducklake_type.as_str(),
column.is_nullable,
column.parent_index,
)
})
.collect::<Vec<_>>();
assert_eq!(
actual,
vec![
("id", "int32", false, None),
("levels", "list", false, None),
("element", "struct", false, Some(1)),
("price", "decimal(38, 16)", false, Some(2)),
("tags", "list", true, Some(2)),
("element", "varchar", true, Some(4)),
("attrs", "map", true, None),
("key", "varchar", false, Some(6)),
("value", "struct", true, Some(6)),
("active", "boolean", false, Some(8)),
]
);
assert_eq!(
top_level_column_ids(&definitions, &[10, 11, 12, 13, 14, 15, 16, 17, 18, 19]).unwrap(),
vec![10, 11, 16]
);
}
#[test]
fn test_assign_column_ids_rejects_duplicate_paths() {
let columns = vec![
ColumnDef::from_arrow("value", &DataType::Int32, false).unwrap(),
ColumnDef::from_arrow("value", &DataType::Int64, false).unwrap(),
];
let definitions = catalog_column_defs(&columns).unwrap();
assert!(assign_column_ids(&definitions, &[], &[10, 11]).is_err());
}
#[test]
fn catalog_type_name_returns_unsupported_scalar_error() {
let error =
catalog_type_name(&DataType::Duration(arrow::datatypes::TimeUnit::Second)).unwrap_err();
assert!(matches!(error, DuckLakeError::UnsupportedType(_)));
}
#[test]
fn test_catalog_columns_differ_accepts_nested_type_alias() {
let columns = vec![
ColumnDef::from_arrow(
"values",
&DataType::List(Arc::new(Field::new("item", DataType::Int64, true))),
false,
)
.unwrap(),
];
let definitions = catalog_column_defs(&columns).unwrap();
let existing = vec![
ExistingCatalogColumn {
column_id: 10,
name: "values".into(),
ducklake_type: "list".into(),
parent_column: None,
},
ExistingCatalogColumn {
column_id: 11,
name: "element".into(),
ducklake_type: "bigint".into(),
parent_column: Some(10),
},
];
assert!(!catalog_columns_differ(
&existing,
&[false, true],
&definitions,
&[10, 11],
));
}
#[test]
fn legacy_list_type_matches_only_the_same_recursive_type() {
let columns = vec![
ColumnDef::from_arrow(
"values",
&DataType::List(Arc::new(Field::new("item", DataType::Float32, true))),
true,
)
.unwrap(),
];
let definitions = catalog_column_defs(&columns).unwrap();
assert!(catalog_column_type_equal("list<float32>", &definitions[0]));
assert!(catalog_column_type_requires_migration(
"list<float32>",
&definitions[0]
));
assert!(!catalog_column_type_equal("list<int32>", &definitions[0]));
}
#[test]
fn test_data_file_info_new() {
let file = DataFileInfo::new("test.parquet", 1024, 100);
assert_eq!(file.path, "test.parquet");
assert!(file.path_is_relative);
assert_eq!(file.file_size_bytes, 1024);
assert_eq!(file.record_count, 100);
assert!(file.footer_size.is_none());
}
#[test]
fn test_data_file_info_with_footer_size() {
let file = DataFileInfo::new("test.parquet", 1024, 100).with_footer_size(256);
assert_eq!(file.footer_size, Some(256));
}
#[test]
fn test_data_file_info_with_absolute_path() {
let file = DataFileInfo::new("/absolute/path.parquet", 1024, 100).with_absolute_path();
assert!(!file.path_is_relative);
}
#[test]
fn test_column_def_empty_name_rejected() {
let result = ColumnDef::new("", "int32", true);
assert!(result.is_err());
match result {
Err(DuckLakeError::InvalidConfig(msg)) => {
assert!(msg.contains("empty"), "Expected 'empty' in: {msg}");
},
other => panic!("Expected InvalidConfig, got {:?}", other),
}
}
#[test]
fn test_column_def_control_char_name_rejected() {
let result = ColumnDef::new("col\0name", "int32", true);
assert!(result.is_err());
match result {
Err(DuckLakeError::InvalidConfig(msg)) => {
assert!(
msg.contains("control character"),
"Expected 'control character' in: {msg}"
);
},
other => panic!("Expected InvalidConfig, got {:?}", other),
}
}
#[test]
fn test_column_def_from_arrow_empty_name_rejected() {
let result = ColumnDef::from_arrow("", &DataType::Int64, false);
assert!(result.is_err());
match result {
Err(DuckLakeError::InvalidConfig(msg)) => {
assert!(msg.contains("empty"), "Expected 'empty' in: {msg}");
},
other => panic!("Expected InvalidConfig, got {:?}", other),
}
}
#[test]
fn test_column_def_from_arrow_control_char_rejected() {
let result = ColumnDef::from_arrow("col\nnewline", &DataType::Int64, false);
assert!(result.is_err());
match result {
Err(DuckLakeError::InvalidConfig(msg)) => {
assert!(
msg.contains("control character"),
"Expected 'control character' in: {msg}"
);
},
other => panic!("Expected InvalidConfig, got {:?}", other),
}
}
#[test]
fn test_validate_name_valid() {
assert!(validate_name("users", "Table").is_ok());
assert!(validate_name("my_column", "Column").is_ok());
assert!(validate_name("Schema123", "Schema").is_ok());
assert!(validate_name("a", "Column").is_ok());
}
#[test]
fn test_validate_name_empty() {
let result = validate_name("", "Table");
assert!(result.is_err());
let result = validate_name(" ", "Table");
assert!(result.is_err());
}
#[test]
fn test_validate_name_control_chars() {
assert!(validate_name("col\0", "Column").is_err());
assert!(validate_name("col\n", "Column").is_err());
assert!(validate_name("col\t", "Column").is_err());
assert!(validate_name("col\x7F", "Column").is_err());
}
#[test]
fn test_validate_name_length_limit() {
let at_limit = "a".repeat(MAX_NAME_LENGTH);
assert!(validate_name(&at_limit, "Table").is_ok());
let over_limit = "a".repeat(MAX_NAME_LENGTH + 1);
assert!(validate_name(&over_limit, "Table").is_err());
}
#[test]
fn test_column_def_long_name_rejected() {
let long_name = "x".repeat(MAX_NAME_LENGTH + 1);
let result = ColumnDef::new(long_name, "int32", true);
assert!(result.is_err());
match result {
Err(DuckLakeError::InvalidConfig(msg)) => {
assert!(
msg.contains("exceeds maximum length"),
"Expected 'exceeds maximum length' in: {msg}"
);
},
other => panic!("Expected InvalidConfig, got {:?}", other),
}
}
#[test]
fn test_data_file_info_zero_record_count() {
let file = DataFileInfo::new("empty.parquet", 0, 0);
assert_eq!(file.record_count, 0);
}
#[test]
#[should_panic(expected = "record_count must be non-negative")]
fn test_data_file_info_negative_record_count_panics() {
DataFileInfo::new("test.parquet", 1024, -1);
}
}