Skip to main content

laminar_connectors/schema/evolution/
mod.rs

1//! Schema evolution engine (F-SCHEMA-009).
2//!
3//! Provides:
4//!
5//! - [`SchemaEvolution`] — a general-purpose schema evolver that performs
6//!   name-based schema diffing, compatibility evaluation, and schema merging
7//! - [`SchemaEvolutionEngine`] — orchestrates the full evolution flow:
8//!   detect → diff → evaluate → apply → record
9//! - [`SchemaHistory`] — tracks per-source schema version history
10//! - [`is_safe_widening`] — determines whether a type can be safely widened
11#![allow(clippy::disallowed_types)] // cold path: schema management
12
13use std::collections::HashMap;
14use std::sync::Arc;
15use std::time::SystemTime;
16
17use arrow_schema::{DataType, Field, Schema, SchemaRef};
18
19use super::error::{SchemaError, SchemaResult};
20use super::traits::{ColumnProjection, CompatibilityMode, EvolutionVerdict, SchemaChange};
21
22// ── Safe widening rules ────────────────────────────────────────────
23
24/// Returns `true` if `from` can be safely widened to `to` without data loss.
25#[must_use]
26pub fn is_safe_widening(from: &DataType, to: &DataType) -> bool {
27    matches!(
28        (from, to),
29        (
30            DataType::Int8,
31            DataType::Int16 | DataType::Int32 | DataType::Int64
32        ) | (DataType::Int16, DataType::Int32 | DataType::Int64)
33            | (DataType::Int32, DataType::Int64 | DataType::Float64)
34            | (
35                DataType::UInt8,
36                DataType::UInt16 | DataType::UInt32 | DataType::UInt64
37            )
38            | (DataType::UInt16, DataType::UInt32 | DataType::UInt64)
39            | (DataType::UInt32, DataType::UInt64)
40            | (
41                DataType::Float16 | DataType::Int8 | DataType::Int16,
42                DataType::Float32 | DataType::Float64
43            )
44            | (DataType::Float32, DataType::Float64)
45            | (DataType::Utf8, DataType::LargeUtf8)
46            | (DataType::Binary, DataType::LargeBinary)
47    )
48}
49
50// ── SchemaEvolution ───────────────────────────────────────────
51
52/// A general-purpose schema evolver that uses name-based schema matching
53/// (suitable for JSON, CSV, Avro, etc.).
54///
55/// For field-ID-based sources (Iceberg, Parquet), a specialised
56/// implementation should be used instead.
57#[derive(Debug, Clone)]
58pub struct SchemaEvolution {
59    /// The compatibility mode for evaluating changes.
60    pub compatibility: CompatibilityMode,
61}
62
63impl SchemaEvolution {
64    /// Creates a new evolver with the given compatibility mode.
65    #[must_use]
66    pub fn new(compatibility: CompatibilityMode) -> Self {
67        Self { compatibility }
68    }
69
70    /// Computes the differences between two schemas.
71    #[must_use]
72    pub fn diff_schemas(&self, old: &SchemaRef, new: &SchemaRef) -> Vec<SchemaChange> {
73        diff_schemas_by_name(old, new)
74    }
75
76    /// Evaluates whether a set of schema changes is acceptable.
77    #[must_use]
78    pub fn evaluate_evolution(&self, changes: &[SchemaChange]) -> EvolutionVerdict {
79        evaluate_changes(changes, self.compatibility)
80    }
81
82    /// Applies schema changes, returning a column projection.
83    ///
84    /// # Errors
85    ///
86    /// Returns a schema error if the evolution cannot be applied.
87    pub fn apply_evolution(
88        &self,
89        old: &SchemaRef,
90        changes: &[SchemaChange],
91    ) -> SchemaResult<ColumnProjection> {
92        apply_changes(old, changes)
93    }
94}
95
96impl Default for SchemaEvolution {
97    fn default() -> Self {
98        Self {
99            compatibility: CompatibilityMode::None,
100        }
101    }
102}
103
104// ── Core diffing algorithm ─────────────────────────────────────────
105
106/// Computes the diff between two Arrow schemas using name-based matching.
107///
108/// Returns a list of [`SchemaChange`] entries describing all differences.
109/// Order: column removals, column additions, type changes, nullability changes.
110#[must_use]
111pub fn diff_schemas_by_name(old: &SchemaRef, new: &SchemaRef) -> Vec<SchemaChange> {
112    let mut changes = Vec::new();
113
114    let old_fields: HashMap<&str, &Field> = old
115        .fields()
116        .iter()
117        .map(|f| (f.name().as_str(), f.as_ref()))
118        .collect();
119
120    let new_fields: HashMap<&str, &Field> = new
121        .fields()
122        .iter()
123        .map(|f| (f.name().as_str(), f.as_ref()))
124        .collect();
125
126    // Detect removed columns (in old but not in new).
127    for field in old.fields() {
128        if !new_fields.contains_key(field.name().as_str()) {
129            changes.push(SchemaChange::ColumnRemoved {
130                name: field.name().clone(),
131            });
132        }
133    }
134
135    // Detect added columns (in new but not in old).
136    for field in new.fields() {
137        if !old_fields.contains_key(field.name().as_str()) {
138            changes.push(SchemaChange::ColumnAdded {
139                name: field.name().clone(),
140                data_type: field.data_type().clone(),
141                nullable: field.is_nullable(),
142            });
143        }
144    }
145
146    // Detect type and nullability changes.
147    for field in new.fields() {
148        if let Some(old_field) = old_fields.get(field.name().as_str()) {
149            if old_field.data_type() != field.data_type() {
150                changes.push(SchemaChange::TypeChanged {
151                    name: field.name().clone(),
152                    old_type: old_field.data_type().clone(),
153                    new_type: field.data_type().clone(),
154                });
155            }
156            if old_field.is_nullable() != field.is_nullable() {
157                changes.push(SchemaChange::NullabilityChanged {
158                    name: field.name().clone(),
159                    was_nullable: old_field.is_nullable(),
160                    now_nullable: field.is_nullable(),
161                });
162            }
163        }
164    }
165
166    changes
167}
168
169// ── Compatibility evaluation ───────────────────────────────────────
170
171/// Evaluates a set of schema changes against a compatibility mode.
172#[must_use]
173pub fn evaluate_changes(changes: &[SchemaChange], mode: CompatibilityMode) -> EvolutionVerdict {
174    if changes.is_empty() {
175        return EvolutionVerdict::Compatible;
176    }
177
178    // Mode::None — everything is allowed.
179    if mode == CompatibilityMode::None {
180        return EvolutionVerdict::Compatible;
181    }
182
183    let requires_backward = matches!(
184        mode,
185        CompatibilityMode::Backward
186            | CompatibilityMode::Full
187            | CompatibilityMode::BackwardTransitive
188            | CompatibilityMode::FullTransitive
189    );
190    let requires_forward = matches!(
191        mode,
192        CompatibilityMode::Forward
193            | CompatibilityMode::Full
194            | CompatibilityMode::ForwardTransitive
195            | CompatibilityMode::FullTransitive
196    );
197
198    let mut needs_migration = false;
199
200    for change in changes {
201        match change {
202            SchemaChange::ColumnAdded { nullable, .. } => {
203                // Backward: new schema must read old data.
204                // Adding a nullable column is backward-compatible (old data has null).
205                // Adding a non-nullable column breaks backward compatibility
206                // (old data has no value for this column).
207                if requires_backward && !nullable {
208                    return EvolutionVerdict::Incompatible(format!(
209                        "adding non-nullable column '{}' is not backward-compatible",
210                        change_name(change)
211                    ));
212                }
213            }
214            SchemaChange::ColumnRemoved { name } => {
215                // Backward: removing a column breaks backward compatibility
216                // (old data has the column, new reader doesn't expect it).
217                // Actually, backward means new reader reads old data — if the new
218                // schema drops a column, the new reader just ignores it. That's fine.
219                // Forward: old reader reads new data — new data is missing a column
220                // that old reader expects. That breaks forward compatibility.
221                if requires_forward {
222                    return EvolutionVerdict::Incompatible(format!(
223                        "removing column '{name}' is not forward-compatible"
224                    ));
225                }
226                needs_migration = true;
227            }
228            SchemaChange::TypeChanged {
229                name,
230                old_type,
231                new_type,
232            } => {
233                if is_safe_widening(old_type, new_type) {
234                    // Widening is backward-compatible (new reader can handle old
235                    // narrower data). But it's NOT forward-compatible (old reader
236                    // can't handle wider data).
237                    if requires_forward {
238                        return EvolutionVerdict::Incompatible(format!(
239                            "widening column '{name}' from {old_type:?} to {new_type:?} \
240                             is not forward-compatible"
241                        ));
242                    }
243                } else if is_safe_widening(new_type, old_type) {
244                    // Narrowing — forward-compatible but not backward.
245                    if requires_backward {
246                        return EvolutionVerdict::Incompatible(format!(
247                            "narrowing column '{name}' from {old_type:?} to {new_type:?} \
248                             is not backward-compatible"
249                        ));
250                    }
251                    needs_migration = true;
252                } else {
253                    // Unrelated types — always incompatible.
254                    return EvolutionVerdict::Incompatible(format!(
255                        "changing column '{name}' from {old_type:?} to {new_type:?} \
256                         is incompatible"
257                    ));
258                }
259            }
260            SchemaChange::NullabilityChanged {
261                name,
262                was_nullable,
263                now_nullable,
264            } => {
265                if *was_nullable && !now_nullable {
266                    // Making a column non-nullable: new reader rejects old nulls.
267                    if requires_backward {
268                        return EvolutionVerdict::Incompatible(format!(
269                            "making column '{name}' non-nullable is not backward-compatible"
270                        ));
271                    }
272                    needs_migration = true;
273                }
274                // Making a column nullable is always safe.
275            }
276            SchemaChange::ColumnRenamed { old_name, .. } => {
277                // Renames without field-IDs are always incompatible
278                // (name-based matching treats it as drop + add).
279                return EvolutionVerdict::Incompatible(format!(
280                    "renaming column '{old_name}' is not supported without field IDs"
281                ));
282            }
283        }
284    }
285
286    if needs_migration {
287        EvolutionVerdict::RequiresMigration
288    } else {
289        EvolutionVerdict::Compatible
290    }
291}
292
293fn change_name(change: &SchemaChange) -> &str {
294    match change {
295        SchemaChange::ColumnAdded { name, .. }
296        | SchemaChange::ColumnRemoved { name }
297        | SchemaChange::TypeChanged { name, .. }
298        | SchemaChange::NullabilityChanged { name, .. } => name,
299        SchemaChange::ColumnRenamed { old_name, .. } => old_name,
300    }
301}
302
303// ── Schema application ─────────────────────────────────────────────
304
305/// Applies a set of schema changes to produce a new schema and a column
306/// projection mapping old columns to new positions.
307///
308/// # Errors
309///
310/// Returns [`SchemaError`] if the changes reference columns that don't exist.
311pub fn apply_changes(old: &SchemaRef, changes: &[SchemaChange]) -> SchemaResult<ColumnProjection> {
312    let mut fields: Vec<Field> = old.fields().iter().map(|f| f.as_ref().clone()).collect();
313    let mut old_index_map: Vec<Option<usize>> = (0..fields.len()).map(Some).collect();
314
315    // Collect indices of columns to remove.
316    let mut remove_indices = Vec::new();
317
318    for change in changes {
319        match change {
320            SchemaChange::ColumnAdded {
321                name,
322                data_type,
323                nullable,
324            } => {
325                fields.push(Field::new(name, data_type.clone(), *nullable));
326                old_index_map.push(None); // New column — no old source.
327            }
328            SchemaChange::ColumnRemoved { name } => {
329                if let Some(idx) = fields.iter().position(|f| f.name() == name) {
330                    remove_indices.push(idx);
331                }
332            }
333            SchemaChange::TypeChanged { name, new_type, .. } => {
334                if let Some(field) = fields.iter_mut().find(|f| f.name() == name) {
335                    *field = Field::new(field.name(), new_type.clone(), field.is_nullable());
336                }
337            }
338            SchemaChange::NullabilityChanged {
339                name, now_nullable, ..
340            } => {
341                if let Some(field) = fields.iter_mut().find(|f| f.name() == name) {
342                    *field = Field::new(field.name(), field.data_type().clone(), *now_nullable);
343                }
344            }
345            SchemaChange::ColumnRenamed {
346                old_name, new_name, ..
347            } => {
348                if let Some(field) = fields.iter_mut().find(|f| f.name() == old_name) {
349                    *field = Field::new(new_name, field.data_type().clone(), field.is_nullable());
350                }
351            }
352        }
353    }
354
355    // Remove columns in reverse order to preserve indices.
356    remove_indices.sort_unstable();
357    remove_indices.dedup();
358    for &idx in remove_indices.iter().rev() {
359        fields.remove(idx);
360        old_index_map.remove(idx);
361    }
362
363    let new_schema = Arc::new(Schema::new(fields));
364
365    Ok(ColumnProjection {
366        mappings: old_index_map,
367        target_schema: new_schema,
368    })
369}
370
371// ── Schema history ─────────────────────────────────────────────────
372
373/// How a schema evolution was triggered.
374#[derive(Debug, Clone, PartialEq, Eq)]
375pub enum EvolutionTrigger {
376    /// User issued ALTER SOURCE DDL.
377    Ddl,
378    /// Schema registry detected a new schema version.
379    Registry {
380        /// The schema ID that triggered the change.
381        schema_id: u32,
382    },
383    /// User issued REFRESH SCHEMA.
384    ManualRefresh,
385}
386
387/// A record in the schema history.
388#[derive(Debug, Clone)]
389pub struct SchemaHistoryEntry {
390    /// Source name.
391    pub source_name: String,
392    /// Schema version (monotonically increasing per source).
393    pub version: u64,
394    /// The Arrow schema at this version.
395    pub schema: SchemaRef,
396    /// Changes from the previous version.
397    pub changes: Vec<SchemaChange>,
398    /// When this version was applied.
399    pub applied_at: SystemTime,
400    /// How the evolution was triggered.
401    pub trigger: EvolutionTrigger,
402}
403
404/// Tracks schema version history for all sources.
405#[derive(Debug, Default)]
406pub struct SchemaHistory {
407    entries: HashMap<String, Vec<SchemaHistoryEntry>>,
408}
409
410impl SchemaHistory {
411    /// Creates a new empty history.
412    #[must_use]
413    pub fn new() -> Self {
414        Self::default()
415    }
416
417    /// Records a new schema version for a source. Returns the version number.
418    pub fn record(
419        &mut self,
420        source_name: &str,
421        schema: SchemaRef,
422        changes: Vec<SchemaChange>,
423        trigger: EvolutionTrigger,
424    ) -> u64 {
425        let entries = self.entries.entry(source_name.to_string()).or_default();
426        let version = entries.last().map_or(1, |e| e.version + 1);
427        entries.push(SchemaHistoryEntry {
428            source_name: source_name.to_string(),
429            version,
430            schema,
431            changes,
432            applied_at: SystemTime::now(),
433            trigger,
434        });
435        version
436    }
437
438    /// Returns all versions for a source.
439    #[must_use]
440    pub fn versions(&self, source_name: &str) -> &[SchemaHistoryEntry] {
441        self.entries.get(source_name).map_or(&[], |v| v.as_slice())
442    }
443
444    /// Returns the latest version number for a source.
445    #[must_use]
446    pub fn latest_version(&self, source_name: &str) -> Option<u64> {
447        self.entries.get(source_name)?.last().map(|e| e.version)
448    }
449}
450
451// ── Schema Evolution Engine ────────────────────────────────────────
452
453/// Result of an evolution attempt.
454#[derive(Debug)]
455pub enum EvolutionResult {
456    /// No schema change detected.
457    NoChange,
458    /// Evolution applied successfully.
459    Applied {
460        /// The new schema.
461        new_schema: SchemaRef,
462        /// Column projection from old → new.
463        projection: ColumnProjection,
464        /// The new version number.
465        version: u64,
466        /// The individual changes.
467        changes: Vec<SchemaChange>,
468    },
469}
470
471/// Orchestrates the full schema evolution flow.
472///
473/// 1. Diff the current and proposed schemas
474/// 2. Evaluate compatibility
475/// 3. Apply changes and produce a new schema + projection
476/// 4. Record in schema history
477#[derive(Debug)]
478pub struct SchemaEvolutionEngine {
479    /// Per-source version history.
480    pub history: SchemaHistory,
481    /// Default compatibility mode.
482    pub default_compatibility: CompatibilityMode,
483}
484
485impl SchemaEvolutionEngine {
486    /// Creates a new engine with the given default compatibility mode.
487    #[must_use]
488    pub fn new(default_compatibility: CompatibilityMode) -> Self {
489        Self {
490            history: SchemaHistory::new(),
491            default_compatibility,
492        }
493    }
494
495    /// Executes the full evolution flow for a source.
496    ///
497    /// # Errors
498    ///
499    /// Returns [`SchemaError::EvolutionRejected`] if the changes are incompatible.
500    pub fn evolve(
501        &mut self,
502        source_name: &str,
503        evolvable: &SchemaEvolution,
504        current: &SchemaRef,
505        proposed: &SchemaRef,
506        trigger: EvolutionTrigger,
507    ) -> SchemaResult<EvolutionResult> {
508        let changes = evolvable.diff_schemas(current, proposed);
509
510        if changes.is_empty() {
511            return Ok(EvolutionResult::NoChange);
512        }
513
514        let verdict = evolvable.evaluate_evolution(&changes);
515
516        match verdict {
517            EvolutionVerdict::Compatible | EvolutionVerdict::RequiresMigration => {
518                let projection = evolvable.apply_evolution(current, &changes)?;
519                let version = self.history.record(
520                    source_name,
521                    projection.target_schema.clone(),
522                    changes.clone(),
523                    trigger,
524                );
525                Ok(EvolutionResult::Applied {
526                    new_schema: projection.target_schema.clone(),
527                    projection,
528                    version,
529                    changes,
530                })
531            }
532            EvolutionVerdict::Incompatible(reason) => Err(SchemaError::EvolutionRejected(reason)),
533        }
534    }
535}
536
537#[cfg(test)]
538mod tests;