1use std::collections::BTreeSet;
4
5use serde::Serialize;
6use type_bridge_contract::id::FunctionId;
7use type_bridge_contract::schema::{
8 AnnotationFact, AnnotationKindId, AnnotationSubjectId, SchemaAnnotationValue, SchemaDelta,
9 SchemaFact, SchemaFactId, SchemaOperation, SchemaOperationKind,
10};
11
12#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
14#[serde(rename_all = "snake_case")]
15pub enum SafetyClass {
16 FormalOnly,
18 SchemaMetadata,
20 Additive,
22 Conditional,
24 BackfillRequired,
26 Destructive,
28 Opaque,
30 Unsupported,
32}
33
34impl SafetyClass {
35 pub const ALL: [Self; 8] = [
37 Self::FormalOnly,
38 Self::SchemaMetadata,
39 Self::Additive,
40 Self::Conditional,
41 Self::BackfillRequired,
42 Self::Destructive,
43 Self::Opaque,
44 Self::Unsupported,
45 ];
46}
47
48pub type DeltaSafety = SafetyClass;
50
51#[derive(Clone, Copy, Debug, Eq, PartialEq)]
53pub enum SafetyClassificationError {
54 UnchangedRelatesSpecialization,
56 RedefinitionCategoryChanged,
58}
59
60impl SafetyClassificationError {
61 pub const fn message(self) -> &'static str {
63 match self {
64 Self::UnchangedRelatesSpecialization => {
65 "relates redefinition does not change specialization or collection mode"
66 }
67 Self::RedefinitionCategoryChanged => "schema redefinition changed fact category",
68 }
69 }
70}
71
72pub fn classify_operation_safety(
74 operation: &SchemaOperation,
75) -> Result<SafetyClass, SafetyClassificationError> {
76 let mut safety = SafetyClass::FormalOnly;
77 match operation.kind() {
78 SchemaOperationKind::Define => {
79 let facts = operation.defined_facts().expect("define exposes facts");
80 let functions = facts
81 .iter()
82 .filter_map(|fact| match fact {
83 SchemaFact::Function(function) => Some(function.id().clone()),
84 _ => None,
85 })
86 .collect::<BTreeSet<FunctionId>>();
87 for fact in facts {
88 safety = safety.max(classify_defined_fact(fact, &functions));
89 }
90 }
91 SchemaOperationKind::Redefine => {
92 safety = classify_redefinition(
93 operation
94 .expected_fact()
95 .expect("redefine exposes expected fact"),
96 operation
97 .replacement_fact()
98 .expect("redefine exposes replacement fact"),
99 )?;
100 }
101 SchemaOperationKind::Undefine => {
102 safety =
103 classify_undefined_fact(operation.undefined_fact().expect("undefine exposes fact"));
104 }
105 }
106 Ok(safety)
107}
108
109fn classify_defined_fact(fact: &SchemaFact, functions: &BTreeSet<FunctionId>) -> SafetyClass {
110 match fact {
111 SchemaFact::Relates(relates) if relates.specializes().is_some() => SafetyClass::Conditional,
112 SchemaFact::Annotation(annotation) => {
113 if let AnnotationSubjectId::Function(function) = annotation.id().subject()
114 && functions.contains(function)
115 && matches!(
116 annotation.id().kind(),
117 AnnotationKindId::Doc | AnnotationKindId::Meta(_)
118 )
119 {
120 SafetyClass::SchemaMetadata
121 } else {
122 classify_annotation(annotation, AnnotationTransition::Add, None)
123 }
124 }
125 _ => classify_fact(fact, FactTransition::Define),
126 }
127}
128
129fn classify_undefined_fact(fact: &SchemaFact) -> SafetyClass {
130 match fact {
131 SchemaFact::Annotation(annotation) => {
132 classify_annotation(annotation, AnnotationTransition::Remove, None)
133 }
134 SchemaFact::Relates(relates) if relates.specializes().is_some() => SafetyClass::Conditional,
135 _ => classify_fact(fact, FactTransition::Undefine),
136 }
137}
138
139fn classify_redefinition(
140 expected: &SchemaFact,
141 replacement: &SchemaFact,
142) -> Result<SafetyClass, SafetyClassificationError> {
143 match (expected, replacement) {
144 (SchemaFact::Relates(old), SchemaFact::Relates(new)) => {
145 if old.collection_mode() != new.collection_mode() {
146 Ok(SafetyClass::Unsupported)
147 } else if old.specializes() != new.specializes() {
148 Ok(SafetyClass::Conditional)
149 } else {
150 Err(SafetyClassificationError::UnchangedRelatesSpecialization)
151 }
152 }
153 (SchemaFact::Annotation(old), SchemaFact::Annotation(new)) => Ok(classify_annotation(
154 new,
155 AnnotationTransition::Change,
156 Some(old),
157 )),
158 (left, right) if std::mem::discriminant(left) == std::mem::discriminant(right) => {
159 Ok(classify_fact(right, FactTransition::Redefine))
160 }
161 _ => Err(SafetyClassificationError::RedefinitionCategoryChanged),
162 }
163}
164
165#[derive(Clone, Copy)]
166enum FactTransition {
167 Define,
168 Undefine,
169 Redefine,
170}
171
172fn classify_fact(fact: &SchemaFact, transition: FactTransition) -> SafetyClass {
173 use FactTransition::{Define, Redefine, Undefine};
174 use SafetyClass::{Additive, Conditional, Destructive, Opaque, Unsupported};
175
176 match (fact, transition) {
177 (SchemaFact::Type(_), Define) => Additive,
178 (SchemaFact::Type(_), Undefine) => Destructive,
179 (SchemaFact::Type(_), Redefine) => Unsupported,
180 (SchemaFact::Sub(_), Define | Redefine) => Conditional,
181 (SchemaFact::Sub(_), Undefine) => Destructive,
182 (SchemaFact::Value(_), Define) => Additive,
183 (SchemaFact::Value(_), Undefine | Redefine) => Destructive,
184 (SchemaFact::Owns(_) | SchemaFact::Relates(_) | SchemaFact::Plays(_), Define) => Additive,
185 (SchemaFact::Owns(_) | SchemaFact::Relates(_) | SchemaFact::Plays(_), Undefine) => {
186 Destructive
187 }
188 (SchemaFact::Owns(_) | SchemaFact::Relates(_) | SchemaFact::Plays(_), Redefine) => {
189 Unsupported
190 }
191 (SchemaFact::Function(_), Define) => Additive,
192 (SchemaFact::Function(_), Undefine) => Destructive,
193 (SchemaFact::Function(_), Redefine) => Opaque,
194 (SchemaFact::Struct(_), _) => Unsupported,
195 (SchemaFact::Annotation(_), _) => {
196 unreachable!("annotations use the annotation classifier")
197 }
198 }
199}
200
201#[derive(Clone, Copy)]
202enum AnnotationTransition {
203 Add,
204 Change,
205 Remove,
206}
207
208fn classify_annotation(
209 annotation: &AnnotationFact,
210 transition: AnnotationTransition,
211 expected: Option<&AnnotationFact>,
212) -> SafetyClass {
213 let subject = annotation.id().subject();
214 let kind = annotation.id().kind();
215 if !annotation_supported(subject, kind) {
216 return SafetyClass::Unsupported;
217 }
218 if matches!(subject, AnnotationSubjectId::Sub(_))
219 || matches!(kind, AnnotationKindId::Doc | AnnotationKindId::Meta(_))
220 {
221 return SafetyClass::SchemaMetadata;
222 }
223
224 let mut safety = match kind {
225 AnnotationKindId::Abstract => match transition {
226 AnnotationTransition::Add => SafetyClass::Conditional,
227 AnnotationTransition::Remove => SafetyClass::Additive,
228 AnnotationTransition::Change => SafetyClass::Unsupported,
229 },
230 AnnotationKindId::Independent => match transition {
231 AnnotationTransition::Add => SafetyClass::Additive,
232 AnnotationTransition::Remove => SafetyClass::Destructive,
233 AnnotationTransition::Change => SafetyClass::Unsupported,
234 },
235 AnnotationKindId::Key | AnnotationKindId::Unique => match transition {
236 AnnotationTransition::Add => SafetyClass::BackfillRequired,
237 AnnotationTransition::Remove => SafetyClass::Additive,
238 AnnotationTransition::Change => SafetyClass::Unsupported,
239 },
240 AnnotationKindId::Distinct => SafetyClass::Unsupported,
241 AnnotationKindId::Card => SafetyClass::Conditional,
242 AnnotationKindId::Regex | AnnotationKindId::Range | AnnotationKindId::Values => {
243 match transition {
244 AnnotationTransition::Add | AnnotationTransition::Change => {
245 SafetyClass::Conditional
246 }
247 AnnotationTransition::Remove => SafetyClass::Additive,
248 }
249 }
250 AnnotationKindId::Doc | AnnotationKindId::Meta(_) => {
251 unreachable!("metadata annotations returned above")
252 }
253 };
254
255 if matches!(kind, AnnotationKindId::Card)
256 && let Some(target) = annotation_cardinality(annotation)
257 && let Some(default) = default_cardinality(subject)
258 {
259 let (from, to) = match transition {
260 AnnotationTransition::Add => (default, target),
261 AnnotationTransition::Change => {
262 let Some(source) = expected.and_then(annotation_cardinality) else {
263 return safety;
264 };
265 (source, target)
266 }
267 AnnotationTransition::Remove => (target, default),
268 };
269 safety = cardinality_transition_safety(from, to);
270 }
271 safety
272}
273
274fn annotation_supported(subject: &AnnotationSubjectId, kind: &AnnotationKindId) -> bool {
275 match subject {
276 AnnotationSubjectId::Type(_) => matches!(
277 kind,
278 AnnotationKindId::Abstract
279 | AnnotationKindId::Independent
280 | AnnotationKindId::Doc
281 | AnnotationKindId::Meta(_)
282 ),
283 AnnotationSubjectId::Sub(_) => {
284 matches!(kind, AnnotationKindId::Doc | AnnotationKindId::Meta(_))
285 }
286 AnnotationSubjectId::Value(_) => matches!(
287 kind,
288 AnnotationKindId::Regex
289 | AnnotationKindId::Range
290 | AnnotationKindId::Values
291 | AnnotationKindId::Doc
292 | AnnotationKindId::Meta(_)
293 ),
294 AnnotationSubjectId::Owns(_) => matches!(
295 kind,
296 AnnotationKindId::Key
297 | AnnotationKindId::Unique
298 | AnnotationKindId::Distinct
299 | AnnotationKindId::Card
300 | AnnotationKindId::Regex
301 | AnnotationKindId::Range
302 | AnnotationKindId::Values
303 | AnnotationKindId::Doc
304 | AnnotationKindId::Meta(_)
305 ),
306 AnnotationSubjectId::Relates(_) => matches!(
307 kind,
308 AnnotationKindId::Abstract
309 | AnnotationKindId::Distinct
310 | AnnotationKindId::Card
311 | AnnotationKindId::Doc
312 | AnnotationKindId::Meta(_)
313 ),
314 AnnotationSubjectId::Plays(_) => matches!(
315 kind,
316 AnnotationKindId::Card | AnnotationKindId::Doc | AnnotationKindId::Meta(_)
317 ),
318 AnnotationSubjectId::Function(_) => false,
319 }
320}
321
322fn annotation_cardinality(annotation: &AnnotationFact) -> Option<(u64, Option<u64>)> {
323 match annotation.value() {
324 SchemaAnnotationValue::Cardinality(cardinality) => {
325 Some(((*cardinality).min(), (*cardinality).max()))
326 }
327 _ => None,
328 }
329}
330
331fn default_cardinality(subject: &AnnotationSubjectId) -> Option<(u64, Option<u64>)> {
332 match subject {
333 AnnotationSubjectId::Owns(_) | AnnotationSubjectId::Relates(_) => Some((0, Some(1))),
334 AnnotationSubjectId::Plays(_) => Some((0, None)),
335 _ => None,
336 }
337}
338
339fn cardinality_transition_safety(from: (u64, Option<u64>), to: (u64, Option<u64>)) -> SafetyClass {
340 if from == to {
341 SafetyClass::FormalOnly
342 } else if interval_contains(to, from) {
343 SafetyClass::Additive
344 } else if interval_contains(from, to) {
345 SafetyClass::BackfillRequired
346 } else {
347 SafetyClass::Conditional
348 }
349}
350
351fn interval_contains(outer: (u64, Option<u64>), inner: (u64, Option<u64>)) -> bool {
352 outer.0 <= inner.0
353 && match (outer.1, inner.1) {
354 (None, _) => true,
355 (Some(_), None) => false,
356 (Some(outer), Some(inner)) => outer >= inner,
357 }
358}
359
360#[derive(Debug, Clone, PartialEq, Eq)]
362pub struct DeltaSafetyReason {
363 operation_index: usize,
364 fact_id: SchemaFactId,
365 classification: DeltaSafety,
366}
367
368impl DeltaSafetyReason {
369 #[must_use]
371 pub const fn operation_index(&self) -> usize {
372 self.operation_index
373 }
374
375 #[must_use]
377 pub const fn fact_id(&self) -> &SchemaFactId {
378 &self.fact_id
379 }
380
381 #[must_use]
383 pub const fn classification(&self) -> DeltaSafety {
384 self.classification
385 }
386}
387
388#[derive(Debug, Clone, PartialEq, Eq)]
390pub struct DeltaSafetyReport {
391 classification: DeltaSafety,
392 reasons: Vec<DeltaSafetyReason>,
393}
394
395impl DeltaSafetyReport {
396 #[must_use]
398 pub const fn classification(&self) -> DeltaSafety {
399 self.classification
400 }
401
402 #[must_use]
404 pub fn reasons(&self) -> &[DeltaSafetyReason] {
405 &self.reasons
406 }
407}
408
409#[must_use]
411pub fn classify_schema_operation_safety(operation: &SchemaOperation) -> DeltaSafety {
412 classify_operation_safety(operation).unwrap_or(DeltaSafety::Unsupported)
413}
414
415#[must_use]
417pub fn classify_delta_safety(delta: &SchemaDelta) -> DeltaSafetyReport {
418 let mut reasons = Vec::new();
419 for (operation_index, operation) in delta.operations().iter().enumerate() {
420 let classification = classify_schema_operation_safety(operation);
421 for fact_id in operation.affected_ids() {
422 reasons.push(DeltaSafetyReason {
423 operation_index,
424 fact_id,
425 classification,
426 });
427 }
428 }
429 let classification = reasons
430 .iter()
431 .map(DeltaSafetyReason::classification)
432 .max()
433 .unwrap_or(DeltaSafety::FormalOnly);
434 DeltaSafetyReport {
435 classification,
436 reasons,
437 }
438}