Skip to content

Commit 1d5bfeb

Browse files
Adding schema evolution for write use case
1 parent 789f9c6 commit 1d5bfeb

1 file changed

Lines changed: 39 additions & 25 deletions

File tree

src/metadata_writer_sqlite.rs

Lines changed: 39 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -402,38 +402,52 @@ impl MetadataWriter for SqliteMetadataWriter {
402402
.map(|row| {
403403
let name: String = row.try_get(0).unwrap_or_default();
404404
let col_type: String = row.try_get(1).unwrap_or_default();
405-
let nullable: bool = row.try_get::<Option<bool>, _>(2).ok().flatten().unwrap_or(true);
405+
let nullable: bool = row
406+
.try_get::<Option<bool>, _>(2)
407+
.ok()
408+
.flatten()
409+
.unwrap_or(true);
406410
(name, col_type, nullable)
407411
})
408412
.collect();
409413

410-
// For append mode (replace=false), validate schema compatibility
414+
// For append mode (replace=false), validate schema compatibility with evolution rules:
415+
// - Allowed: add nullable columns, remove columns, reorder columns
416+
// - Disallowed: add non-nullable columns, type changes for existing columns
411417
if !replace && !existing_columns.is_empty() {
412-
if existing_columns.len() != columns.len() {
413-
return Err(crate::error::DuckLakeError::InvalidConfig(format!(
414-
"Schema mismatch on append: existing table has {} columns, but write has {} columns",
415-
existing_columns.len(),
416-
columns.len()
417-
)));
418-
}
419-
420-
for (i, ((existing_name, existing_type, _existing_nullable), new_col)) in
421-
existing_columns.iter().zip(columns.iter()).enumerate()
422-
{
423-
if existing_name != &new_col.name {
424-
return Err(crate::error::DuckLakeError::InvalidConfig(format!(
425-
"Schema mismatch on append: column {} is '{}' in existing table but '{}' in write",
426-
i, existing_name, new_col.name
427-
)));
428-
}
429-
if existing_type != &new_col.ducklake_type {
430-
return Err(crate::error::DuckLakeError::InvalidConfig(format!(
431-
"Schema mismatch on append: column '{}' has type '{}' in existing table but '{}' in write",
432-
existing_name, existing_type, new_col.ducklake_type
433-
)));
418+
use std::collections::HashMap;
419+
420+
// Build map of existing columns: name -> (type, nullable)
421+
let existing_map: HashMap<&str, (&str, bool)> = existing_columns
422+
.iter()
423+
.map(|(name, col_type, nullable)| {
424+
(name.as_str(), (col_type.as_str(), *nullable))
425+
})
426+
.collect();
427+
428+
for new_col in columns.iter() {
429+
if let Some((existing_type, _existing_nullable)) =
430+
existing_map.get(new_col.name.as_str())
431+
{
432+
// Column exists - check type matches
433+
if *existing_type != new_col.ducklake_type {
434+
return Err(crate::error::DuckLakeError::InvalidConfig(format!(
435+
"Schema evolution error: column '{}' has type '{}' in existing table but '{}' in new schema. Type changes are not allowed.",
436+
new_col.name, existing_type, new_col.ducklake_type
437+
)));
438+
}
439+
// Note: We allow nullable changes (strict -> nullable is safe for reads)
440+
} else {
441+
// New column - must be nullable
442+
if !new_col.is_nullable {
443+
return Err(crate::error::DuckLakeError::InvalidConfig(format!(
444+
"Schema evolution error: new column '{}' must be nullable. Adding non-nullable columns is not allowed.",
445+
new_col.name
446+
)));
447+
}
434448
}
435-
// Note: We allow nullable changes (strict -> nullable is safe, nullable -> strict requires validation)
436449
}
450+
// Columns in existing but not in new schema are implicitly removed - this is allowed
437451
}
438452

439453
sqlx::query(

0 commit comments

Comments
 (0)