Skip to main content

spatialrust_records/
schema.rs

1//! Schema identity, versioning, and compatibility.
2
3use spatialrust_core::{PointField, PointSchema};
4
5use crate::{RecordsError, RecordsResult};
6
7/// Stable schema family identifier independent of field order serialization.
8#[derive(Clone, Debug, PartialEq, Eq, Hash)]
9pub struct SchemaId(pub String);
10
11impl SchemaId {
12    /// Creates a schema identifier.
13    #[must_use]
14    pub fn new(value: impl Into<String>) -> Self {
15        Self(value.into())
16    }
17
18    /// Borrows the identifier string.
19    #[must_use]
20    pub fn as_str(&self) -> &str {
21        &self.0
22    }
23}
24
25impl From<&str> for SchemaId {
26    fn from(value: &str) -> Self {
27        Self(value.to_owned())
28    }
29}
30
31impl From<String> for SchemaId {
32    fn from(value: String) -> Self {
33        Self(value)
34    }
35}
36
37/// Semantic schema version used for evolution checks.
38#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
39pub struct SchemaVersion {
40    /// Breaking-change counter.
41    pub major: u32,
42    /// Compatible additive counter.
43    pub minor: u32,
44}
45
46impl SchemaVersion {
47    /// Creates a schema version.
48    #[must_use]
49    pub const fn new(major: u32, minor: u32) -> Self {
50        Self { major, minor }
51    }
52}
53
54impl std::fmt::Display for SchemaVersion {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        write!(f, "{}.{}", self.major, self.minor)
57    }
58}
59
60/// Named, versioned [`PointSchema`] contract.
61#[derive(Clone, Debug, PartialEq, Eq)]
62pub struct SchemaDescriptor {
63    /// Family id shared across compatible revisions.
64    pub id: SchemaId,
65    /// Revision selected for this descriptor.
66    pub version: SchemaVersion,
67    /// Concrete columns.
68    pub schema: PointSchema,
69}
70
71impl SchemaDescriptor {
72    /// Creates a validated schema descriptor.
73    pub fn try_new(
74        id: impl Into<SchemaId>,
75        version: SchemaVersion,
76        schema: PointSchema,
77    ) -> RecordsResult<Self> {
78        schema.validate_positions().map_err(RecordsError::from)?;
79        if schema.fields().is_empty() {
80            return Err(RecordsError::InvalidConfiguration("schema has no fields".into()));
81        }
82        Ok(Self { id: id.into(), version, schema })
83    }
84
85    /// Returns the point schema.
86    #[must_use]
87    pub fn point_schema(&self) -> &PointSchema {
88        &self.schema
89    }
90}
91
92/// High-level comparison result between two schemas.
93#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
94pub enum CompatVerdict {
95    /// Exact field set, order, dtypes, and semantics.
96    Identical,
97    /// Reader can consume a richer producer by dropping additive fields.
98    BackwardCompatible,
99    /// Reader can accept a poorer producer by filling defaults.
100    ForwardCompatible,
101    /// Major version or field contracts conflict.
102    Incompatible,
103}
104
105/// Detailed schema comparison report.
106#[derive(Clone, Debug, PartialEq, Eq)]
107pub struct SchemaCompatReport {
108    /// Overall verdict.
109    pub verdict: CompatVerdict,
110    /// Fields present only in the expected/target schema.
111    pub missing_in_actual: Vec<String>,
112    /// Fields present only in the actual/source schema.
113    pub extra_in_actual: Vec<String>,
114    /// Same name but mismatched dtype/semantic/components.
115    pub conflicting: Vec<String>,
116}
117
118/// Compares `actual` against `expected` using id/version plus field contracts.
119pub fn compare_schemas(
120    expected: &SchemaDescriptor,
121    actual: &SchemaDescriptor,
122) -> SchemaCompatReport {
123    if expected.id != actual.id {
124        return SchemaCompatReport {
125            verdict: CompatVerdict::Incompatible,
126            missing_in_actual: Vec::new(),
127            extra_in_actual: Vec::new(),
128            conflicting: vec![format!(
129                "schema id `{}` vs `{}`",
130                expected.id.as_str(),
131                actual.id.as_str()
132            )],
133        };
134    }
135    if expected.version.major != actual.version.major {
136        return SchemaCompatReport {
137            verdict: CompatVerdict::Incompatible,
138            missing_in_actual: Vec::new(),
139            extra_in_actual: Vec::new(),
140            conflicting: vec![format!(
141                "major version {} vs {}",
142                expected.version.major, actual.version.major
143            )],
144        };
145    }
146
147    let mut missing_in_actual = Vec::new();
148    let mut conflicting = Vec::new();
149    for field in expected.schema.fields() {
150        match actual.schema.fields().iter().find(|candidate| candidate.name == field.name) {
151            None => missing_in_actual.push(field.name.clone()),
152            Some(actual_field) if !fields_compatible(field, actual_field) => {
153                conflicting.push(field.name.clone());
154            }
155            Some(_) => {}
156        }
157    }
158    let mut extra_in_actual = Vec::new();
159    for field in actual.schema.fields() {
160        if !expected.schema.fields().iter().any(|candidate| candidate.name == field.name) {
161            extra_in_actual.push(field.name.clone());
162        }
163    }
164
165    let verdict = if missing_in_actual.is_empty()
166        && extra_in_actual.is_empty()
167        && conflicting.is_empty()
168        && expected.schema.fields() == actual.schema.fields()
169    {
170        CompatVerdict::Identical
171    } else if !conflicting.is_empty() {
172        CompatVerdict::Incompatible
173    } else if missing_in_actual.is_empty() && !extra_in_actual.is_empty() {
174        CompatVerdict::BackwardCompatible
175    } else if !missing_in_actual.is_empty() && extra_in_actual.is_empty() {
176        CompatVerdict::ForwardCompatible
177    } else if missing_in_actual.is_empty() && extra_in_actual.is_empty() {
178        // Same fields, possibly different order.
179        CompatVerdict::Identical
180    } else {
181        CompatVerdict::Incompatible
182    };
183
184    SchemaCompatReport { verdict, missing_in_actual, extra_in_actual, conflicting }
185}
186
187fn fields_compatible(expected: &PointField, actual: &PointField) -> bool {
188    expected.dtype == actual.dtype
189        && expected.semantic == actual.semantic
190        && expected.components == actual.components
191}
192
193#[cfg(test)]
194mod tests {
195    use super::{compare_schemas, CompatVerdict, SchemaDescriptor, SchemaVersion};
196    use spatialrust_core::{DType, FieldSemantic, PointField, StandardSchemas};
197
198    #[test]
199    fn additive_field_is_backward_compatible() {
200        let base = SchemaDescriptor::try_new(
201            "point",
202            SchemaVersion::new(1, 0),
203            StandardSchemas::point_xyz(),
204        )
205        .unwrap();
206        let richer = SchemaDescriptor::try_new(
207            "point",
208            SchemaVersion::new(1, 1),
209            StandardSchemas::point_xyzi(),
210        )
211        .unwrap();
212        let report = compare_schemas(&base, &richer);
213        assert_eq!(report.verdict, CompatVerdict::BackwardCompatible);
214        assert_eq!(report.extra_in_actual, vec!["intensity".to_owned()]);
215    }
216
217    #[test]
218    fn dtype_conflict_is_incompatible() {
219        let expected = SchemaDescriptor::try_new(
220            "point",
221            SchemaVersion::new(1, 0),
222            StandardSchemas::point_xyz(),
223        )
224        .unwrap();
225        let schema = StandardSchemas::point_xyz();
226        // Replace x with f64 for conflict.
227        let fields = schema.fields().to_vec();
228        let mut rebuilt = spatialrust_core::PointSchema::new();
229        for field in fields {
230            if field.name == "x" {
231                rebuilt = rebuilt.with_field(PointField::scalar(
232                    "x",
233                    FieldSemantic::PositionX,
234                    DType::F64,
235                ));
236            } else {
237                rebuilt = rebuilt.with_field(field);
238            }
239        }
240        let actual = SchemaDescriptor::try_new("point", SchemaVersion::new(1, 0), rebuilt).unwrap();
241        assert_eq!(compare_schemas(&expected, &actual).verdict, CompatVerdict::Incompatible);
242    }
243}