1use spatialrust_core::{PointField, PointSchema};
4
5use crate::{RecordsError, RecordsResult};
6
7#[derive(Clone, Debug, PartialEq, Eq, Hash)]
9pub struct SchemaId(pub String);
10
11impl SchemaId {
12 #[must_use]
14 pub fn new(value: impl Into<String>) -> Self {
15 Self(value.into())
16 }
17
18 #[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#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
39pub struct SchemaVersion {
40 pub major: u32,
42 pub minor: u32,
44}
45
46impl SchemaVersion {
47 #[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#[derive(Clone, Debug, PartialEq, Eq)]
62pub struct SchemaDescriptor {
63 pub id: SchemaId,
65 pub version: SchemaVersion,
67 pub schema: PointSchema,
69}
70
71impl SchemaDescriptor {
72 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 #[must_use]
87 pub fn point_schema(&self) -> &PointSchema {
88 &self.schema
89 }
90}
91
92#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
94pub enum CompatVerdict {
95 Identical,
97 BackwardCompatible,
99 ForwardCompatible,
101 Incompatible,
103}
104
105#[derive(Clone, Debug, PartialEq, Eq)]
107pub struct SchemaCompatReport {
108 pub verdict: CompatVerdict,
110 pub missing_in_actual: Vec<String>,
112 pub extra_in_actual: Vec<String>,
114 pub conflicting: Vec<String>,
116}
117
118pub 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 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 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}