Skip to main content

reifydb_core/encoded/schema/
evolution.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (c) 2025 ReifyDB
3
4//! Schema evolution and resolution utilities.
5//!
6//! SchemaResolver handles reading data written with one schema
7//! using a different (but compatible) target schema.
8
9use reifydb_type::value::constraint::TypeConstraint;
10
11use crate::encoded::schema::Schema;
12
13/// Describes how to map a source field to a target field during schema evolution.
14#[derive(Debug, Clone)]
15pub enum FieldMapping {
16	/// Field exists in both schemas at the given source index
17	Direct {
18		source_index: usize,
19	},
20	/// Field is new in target schema, use default value
21	UseDefault,
22	/// Field was removed (source has it, target doesn't) - skip during read
23	Removed,
24}
25
26/// Resolves differences between source and target schemas.
27///
28/// Used when reading data that was written with an older schema version
29/// using a newer schema, or vice versa.
30#[derive(Debug)]
31pub struct SchemaResolver {
32	/// The schema the data was written with
33	source: Schema,
34	/// The schema we want to read as
35	target: Schema,
36	/// Mapping from target field index to source field
37	mappings: Vec<FieldMapping>,
38}
39
40impl SchemaResolver {
41	/// Create a resolver to read data from source schema as target schema.
42	///
43	/// Returns None if the schemas are incompatible (e.g., type mismatch
44	/// on same-named field without valid widening path).
45	pub fn new(source: Schema, target: Schema) -> Option<Self> {
46		// If fingerprints match, no resolution needed - schemas are identical
47		if source.fingerprint() == target.fingerprint() {
48			return Some(Self {
49				mappings: (0..target.field_count())
50					.map(|i| FieldMapping::Direct {
51						source_index: i,
52					})
53					.collect(),
54				source,
55				target,
56			});
57		}
58
59		let mut mappings = Vec::with_capacity(target.field_count());
60
61		for target_field in target.fields() {
62			if let Some((source_idx, source_field)) =
63				source.fields().iter().enumerate().find(|(_, f)| f.name == target_field.name)
64			{
65				// Field exists in both - check type compatibility
66				if !Self::types_compatible(&source_field.constraint, &target_field.constraint) {
67					return None; // Incompatible types
68				}
69				mappings.push(FieldMapping::Direct {
70					source_index: source_idx,
71				});
72			} else {
73				// Field only in target - needs default
74				mappings.push(FieldMapping::UseDefault);
75			}
76		}
77
78		Some(Self {
79			source,
80			target,
81			mappings,
82		})
83	}
84
85	/// Check if source constraint can be read as target constraint.
86	/// For now, just compares base types - constraint widening could be added later.
87	fn types_compatible(source: &TypeConstraint, target: &TypeConstraint) -> bool {
88		let source_type = source.get_type();
89		let target_type = target.get_type();
90
91		if source_type == target_type {
92			return true;
93		}
94
95		// Type widening would go here
96		// For now, only identical types are compatible
97		false
98	}
99
100	/// Get the source schema
101	pub fn source(&self) -> &Schema {
102		&self.source
103	}
104
105	/// Get the target schema
106	pub fn target(&self) -> &Schema {
107		&self.target
108	}
109
110	/// Get the field mappings
111	pub fn mappings(&self) -> &[FieldMapping] {
112		&self.mappings
113	}
114
115	/// Check if this is an identity mapping (source == target)
116	pub fn is_identity(&self) -> bool {
117		self.source.fingerprint() == self.target.fingerprint()
118	}
119}
120
121#[cfg(test)]
122mod tests {
123	use reifydb_type::value::r#type::Type;
124
125	use super::*;
126	use crate::encoded::schema::SchemaField;
127
128	#[test]
129	fn test_resolver_identity() {
130		let fields =
131			vec![SchemaField::unconstrained("a", Type::Int4), SchemaField::unconstrained("b", Type::Utf8)];
132
133		let schema = Schema::new(fields);
134		let resolver = SchemaResolver::new(schema.clone(), schema.clone()).unwrap();
135
136		assert!(resolver.is_identity());
137		assert_eq!(resolver.mappings().len(), 2);
138	}
139
140	#[test]
141	fn test_resolver_added_field() {
142		let source_fields = vec![SchemaField::unconstrained("a", Type::Int4)];
143
144		let target_fields = vec![
145			SchemaField::unconstrained("a", Type::Int4),
146			SchemaField::unconstrained("b", Type::Utf8), // new field
147		];
148
149		let source = Schema::new(source_fields);
150		let target = Schema::new(target_fields);
151
152		let resolver = SchemaResolver::new(source, target).unwrap();
153
154		assert!(!resolver.is_identity());
155		assert!(matches!(
156			resolver.mappings()[0],
157			FieldMapping::Direct {
158				source_index: 0
159			}
160		));
161		assert!(matches!(resolver.mappings()[1], FieldMapping::UseDefault));
162	}
163
164	#[test]
165	fn test_resolver_incompatible_types() {
166		let source_fields = vec![SchemaField::unconstrained("a", Type::Int4)];
167		let target_fields = vec![SchemaField::unconstrained("a", Type::Utf8)]; // type changed
168
169		let source = Schema::new(source_fields);
170		let target = Schema::new(target_fields);
171
172		// Should return None due to incompatible types
173		assert!(SchemaResolver::new(source, target).is_none());
174	}
175}