use crate::arrow::datatypes::{DataType, Field, SchemaRef};
use crate::error::{Error, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SchemaChange {
Added {
field: Field,
safe: bool,
},
Dropped {
name: String,
safe: bool,
},
Retyped {
name: String,
from: DataType,
to: DataType,
safe: bool,
},
Nullability {
name: String,
safe: bool,
},
}
impl SchemaChange {
pub fn is_safe(&self) -> bool {
match self {
Self::Added { safe, .. } => *safe,
Self::Dropped { safe, .. } => *safe,
Self::Retyped { safe, .. } => *safe,
Self::Nullability { safe, .. } => *safe,
}
}
pub fn describe(&self) -> String {
match self {
Self::Added { field, safe: true } => {
format!(
"column {:?} added (nullable — historical files read as null)",
field.name()
)
}
Self::Added { field, safe: false } => format!(
"column {:?} added as NOT NULL — every existing row would violate it; \
declare it nullable, or rewrite history out of band first",
field.name()
),
Self::Dropped { name, safe: true } => {
format!("column {name:?} no longer declared (retained for time travel)")
}
Self::Dropped { name, safe: false } => format!(
"column {name:?} is NOT NULL in the table and is no longer declared — a \
required field cannot be left out of a write, and if it was an identity \
column the merge key has just narrowed: two readings the wider key kept \
apart would now compete, and one would supersede the other. Restore the \
declaration, or create a new table"
),
Self::Retyped {
name,
from,
to,
safe: true,
} => format!("column {name:?} promoted {from:?} -> {to:?}"),
Self::Retyped {
name,
from,
to,
safe: false,
} => format!(
"column {name:?} changed {from:?} -> {to:?}, which Iceberg cannot promote; \
stored values would be reinterpreted rather than converted"
),
Self::Nullability { name, safe: true } => {
format!("column {name:?} widened to nullable")
}
Self::Nullability { name, safe: false } => format!(
"column {name:?} narrowed to NOT NULL, but stored rows may already hold nulls"
),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Compatibility {
pub changes: Vec<SchemaChange>,
}
impl Compatibility {
pub fn is_identical(&self) -> bool {
self.changes.is_empty()
}
pub fn is_safe(&self) -> bool {
self.changes.iter().all(SchemaChange::is_safe)
}
pub fn unsafe_changes(&self) -> Vec<&SchemaChange> {
self.changes.iter().filter(|c| !c.is_safe()).collect()
}
pub fn require_safe(&self, table: &str) -> Result<()> {
let unsafe_changes = self.unsafe_changes();
if unsafe_changes.is_empty() {
return Ok(());
}
Err(Error::Quarantined {
table: table.to_string(),
detail: unsafe_changes
.iter()
.map(|c| c.describe())
.collect::<Vec<_>>()
.join("; "),
})
}
}
pub fn compare(configured: &SchemaRef, stored: &SchemaRef) -> Compatibility {
let mut changes = Vec::new();
for field in configured.fields() {
match stored.field_with_name(field.name()) {
Err(_) => changes.push(SchemaChange::Added {
field: field.as_ref().clone(),
safe: field.is_nullable(),
}),
Ok(existing) => {
if existing.data_type() != field.data_type() {
changes.push(SchemaChange::Retyped {
name: field.name().clone(),
from: existing.data_type().clone(),
to: field.data_type().clone(),
safe: is_promotable(existing.data_type(), field.data_type()),
});
} else if existing.is_nullable() != field.is_nullable() {
changes.push(SchemaChange::Nullability {
name: field.name().clone(),
safe: field.is_nullable(),
});
}
}
}
}
for field in stored.fields() {
if configured.field_with_name(field.name()).is_err() {
changes.push(SchemaChange::Dropped {
name: field.name().clone(),
safe: field.is_nullable(),
});
}
}
Compatibility { changes }
}
fn is_promotable(from: &DataType, to: &DataType) -> bool {
match (from, to) {
(a, b) if a == b => true,
(DataType::Int32, DataType::Int64) => true,
(DataType::Float32, DataType::Float64) => true,
(
DataType::Decimal128(from_precision, from_scale),
DataType::Decimal128(to_precision, to_scale),
) => from_scale == to_scale && to_precision >= from_precision,
(DataType::Timestamp(a, Some(x)), DataType::Timestamp(b, Some(y))) => {
a == b && is_utc(x) && is_utc(y)
}
_ => false,
}
}
fn is_utc(tz: &str) -> bool {
matches!(tz, "UTC" | "utc" | "+00:00" | "Z" | "z" | "GMT" | "Etc/UTC")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::arrow::datatypes::Schema;
use std::sync::Arc;
fn schema(fields: Vec<Field>) -> SchemaRef {
Arc::new(Schema::new(fields))
}
fn base() -> SchemaRef {
schema(vec![
Field::new("malo_id", DataType::Utf8, false),
Field::new("value", DataType::Decimal128(18, 6), false),
])
}
#[test]
fn identical_schemas_have_nothing_to_report() {
let c = compare(&base(), &base());
assert!(c.is_identical());
assert!(c.is_safe());
assert!(c.require_safe("readings").is_ok());
}
#[test]
fn a_nullable_addition_is_safe() {
let configured = schema(vec![
Field::new("malo_id", DataType::Utf8, false),
Field::new("value", DataType::Decimal128(18, 6), false),
Field::new("bilanzkreis", DataType::Utf8, true),
]);
let c = compare(&configured, &base());
assert_eq!(c.changes.len(), 1);
assert!(c.is_safe());
assert!(c.require_safe("readings").is_ok());
}
#[test]
fn a_non_nullable_addition_quarantines() {
let configured = schema(vec![
Field::new("malo_id", DataType::Utf8, false),
Field::new("value", DataType::Decimal128(18, 6), false),
Field::new("tenant", DataType::Utf8, false),
]);
let c = compare(&configured, &base());
assert!(!c.is_safe());
let err = c.require_safe("readings").unwrap_err();
assert!(matches!(err, Error::Quarantined { .. }));
assert!(err.to_string().contains("tenant"), "{err}");
}
#[test]
fn a_dropped_nullable_column_is_safe_because_iceberg_keeps_it() {
let stored = schema(vec![
Field::new("malo_id", DataType::Utf8, false),
Field::new("bilanzkreis", DataType::Utf8, true),
]);
let configured = schema(vec![Field::new("malo_id", DataType::Utf8, false)]);
let c = compare(&configured, &stored);
assert_eq!(
c.changes,
vec![SchemaChange::Dropped {
name: "bilanzkreis".into(),
safe: true,
}]
);
assert!(c.is_safe());
}
#[test]
fn dropping_a_required_column_quarantines() {
let stored = schema(vec![
Field::new("malo_id", DataType::Utf8, false),
Field::new("value", DataType::Decimal128(18, 6), false),
Field::new("tenant", DataType::Utf8, false),
]);
let c = compare(&base(), &stored);
assert_eq!(
c.changes,
vec![SchemaChange::Dropped {
name: "tenant".into(),
safe: false,
}]
);
assert!(!c.is_safe());
let err = c.require_safe("readings").unwrap_err();
assert!(matches!(err, Error::Quarantined { .. }));
let msg = err.to_string();
assert!(msg.contains("tenant"), "{msg}");
assert!(
msg.contains("merge key"),
"the message must name the consequence, not just the column: {msg}"
);
}
#[test]
fn widening_decimal_precision_is_promotable() {
let configured = schema(vec![
Field::new("malo_id", DataType::Utf8, false),
Field::new("value", DataType::Decimal128(20, 6), false),
]);
assert!(compare(&configured, &base()).is_safe());
}
#[test]
fn changing_decimal_scale_quarantines() {
let configured = schema(vec![
Field::new("malo_id", DataType::Utf8, false),
Field::new("value", DataType::Decimal128(18, 3), false),
]);
let c = compare(&configured, &base());
assert!(!c.is_safe());
assert!(c.require_safe("readings").is_err());
}
#[test]
fn narrowing_decimal_precision_quarantines() {
let configured = schema(vec![
Field::new("malo_id", DataType::Utf8, false),
Field::new("value", DataType::Decimal128(9, 6), false),
]);
assert!(!compare(&configured, &base()).is_safe());
}
#[test]
fn retyping_a_string_to_a_number_quarantines() {
let configured = schema(vec![
Field::new("malo_id", DataType::Int64, false),
Field::new("value", DataType::Decimal128(18, 6), false),
]);
assert!(!compare(&configured, &base()).is_safe());
}
#[test]
fn widening_to_nullable_is_safe_and_narrowing_is_not() {
let widened = schema(vec![
Field::new("malo_id", DataType::Utf8, true),
Field::new("value", DataType::Decimal128(18, 6), false),
]);
assert!(compare(&widened, &base()).is_safe());
assert!(!compare(&base(), &widened).is_safe());
}
#[test]
fn the_two_utc_spellings_are_not_a_retype() {
let ours = schema(vec![Field::new(
"from",
DataType::Timestamp(
crate::arrow::datatypes::TimeUnit::Microsecond,
Some("UTC".into()),
),
false,
)]);
let theirs = schema(vec![Field::new(
"from",
DataType::Timestamp(
crate::arrow::datatypes::TimeUnit::Microsecond,
Some("+00:00".into()),
),
false,
)]);
assert!(compare(&ours, &theirs).is_safe());
assert!(compare(&ours, &theirs).is_identical() || compare(&ours, &theirs).is_safe());
}
#[test]
fn a_changed_timestamp_unit_is_not_promotable() {
use crate::arrow::datatypes::TimeUnit;
let micros = schema(vec![Field::new(
"from",
DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())),
false,
)]);
let nanos = schema(vec![Field::new(
"from",
DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into())),
false,
)]);
assert!(!compare(&nanos, µs).is_safe());
}
#[test]
fn the_quarantine_message_names_every_offending_column() {
let configured = schema(vec![
Field::new("malo_id", DataType::Utf8, false),
Field::new("value", DataType::Decimal128(18, 3), false),
Field::new("tenant", DataType::Utf8, false),
]);
let err = compare(&configured, &base())
.require_safe("readings")
.unwrap_err()
.to_string();
assert!(err.contains("value"), "{err}");
assert!(err.contains("tenant"), "{err}");
}
}