laminar_connectors/schema/evolution/
mod.rs1#![allow(clippy::disallowed_types)] use 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#[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#[derive(Debug, Clone)]
58pub struct SchemaEvolution {
59 pub compatibility: CompatibilityMode,
61}
62
63impl SchemaEvolution {
64 #[must_use]
66 pub fn new(compatibility: CompatibilityMode) -> Self {
67 Self { compatibility }
68 }
69
70 #[must_use]
72 pub fn diff_schemas(&self, old: &SchemaRef, new: &SchemaRef) -> Vec<SchemaChange> {
73 diff_schemas_by_name(old, new)
74 }
75
76 #[must_use]
78 pub fn evaluate_evolution(&self, changes: &[SchemaChange]) -> EvolutionVerdict {
79 evaluate_changes(changes, self.compatibility)
80 }
81
82 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#[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 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 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 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#[must_use]
173pub fn evaluate_changes(changes: &[SchemaChange], mode: CompatibilityMode) -> EvolutionVerdict {
174 if changes.is_empty() {
175 return EvolutionVerdict::Compatible;
176 }
177
178 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 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 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 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 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 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 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 }
276 SchemaChange::ColumnRenamed { old_name, .. } => {
277 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
303pub 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 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); }
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_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#[derive(Debug, Clone, PartialEq, Eq)]
375pub enum EvolutionTrigger {
376 Ddl,
378 Registry {
380 schema_id: u32,
382 },
383 ManualRefresh,
385}
386
387#[derive(Debug, Clone)]
389pub struct SchemaHistoryEntry {
390 pub source_name: String,
392 pub version: u64,
394 pub schema: SchemaRef,
396 pub changes: Vec<SchemaChange>,
398 pub applied_at: SystemTime,
400 pub trigger: EvolutionTrigger,
402}
403
404#[derive(Debug, Default)]
406pub struct SchemaHistory {
407 entries: HashMap<String, Vec<SchemaHistoryEntry>>,
408}
409
410impl SchemaHistory {
411 #[must_use]
413 pub fn new() -> Self {
414 Self::default()
415 }
416
417 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 #[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 #[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#[derive(Debug)]
455pub enum EvolutionResult {
456 NoChange,
458 Applied {
460 new_schema: SchemaRef,
462 projection: ColumnProjection,
464 version: u64,
466 changes: Vec<SchemaChange>,
468 },
469}
470
471#[derive(Debug)]
478pub struct SchemaEvolutionEngine {
479 pub history: SchemaHistory,
481 pub default_compatibility: CompatibilityMode,
483}
484
485impl SchemaEvolutionEngine {
486 #[must_use]
488 pub fn new(default_compatibility: CompatibilityMode) -> Self {
489 Self {
490 history: SchemaHistory::new(),
491 default_compatibility,
492 }
493 }
494
495 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;