1use std::collections::{BTreeMap, BTreeSet};
4use std::error::Error;
5use std::fmt;
6
7use serde::Serialize;
8use type_bridge_contract::capability::{CapabilityId, CapabilitySet};
9use type_bridge_contract::id::{FunctionId, TypeKind};
10use type_bridge_contract::schema::{
11 AnnotationFact, AnnotationKindId, AnnotationSubjectId, FunctionFact, FunctionReturnElement,
12 FunctionReturnMode, RelatesFact, SchemaAnnotationValue, SchemaDelta, SchemaFact, SchemaFactId,
13 SchemaOperation, SchemaOperationKind, TypeReference,
14};
15use type_bridge_contract::value::{CanonicalValue, ValueTypeTag};
16
17use type_bridge_schema::SafetyClass;
18
19use crate::profile::{classify_operation_transition, typedb_3_12_1_profile};
20use crate::{SchemaLoweringProfileFingerprint, SchemaLoweringProfileId, profile_fingerprint};
21
22const CODE_PROFILE_MISMATCH: &str = "schema_lowering_profile_mismatch";
23const CODE_CAPABILITY_MISMATCH: &str = "schema_lowering_capability_mismatch";
24const CODE_CONTEXT_MISMATCH: &str = "schema_lowering_fact_context_mismatch";
25const CODE_REQUIRES_ASSERTION: &str = "schema_lowering_requires_assertion";
26const CODE_REQUIRES_BACKFILL: &str = "schema_lowering_requires_backfill";
27const CODE_DESTRUCTIVE: &str = "schema_lowering_destructive";
28const CODE_OPAQUE: &str = "schema_lowering_opaque";
29const CODE_UNSUPPORTED: &str = "schema_lowering_unsupported";
30const CODE_INVALID_TRANSITION: &str = "schema_lowering_invalid_transition";
31const CODE_RENDER_CONTEXT: &str = "schema_lowering_render_context_missing";
32
33#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
35pub struct SchemaLoweringDiagnostic {
36 code: &'static str,
37 message: &'static str,
38 operation_index: Option<usize>,
39 safety: Option<SafetyClass>,
40 missing_capabilities: Vec<CapabilityId>,
41}
42
43impl SchemaLoweringDiagnostic {
44 fn new(code: &'static str, message: &'static str) -> Self {
45 Self {
46 code,
47 message,
48 operation_index: None,
49 safety: None,
50 missing_capabilities: Vec::new(),
51 }
52 }
53
54 fn at_operation(mut self, operation_index: usize) -> Self {
55 self.operation_index = Some(operation_index);
56 self
57 }
58
59 fn with_safety(mut self, safety: SafetyClass) -> Self {
60 self.safety = Some(safety);
61 self
62 }
63
64 fn with_missing_capabilities(mut self, missing: Vec<CapabilityId>) -> Self {
65 self.missing_capabilities = missing;
66 self
67 }
68
69 pub const fn code(&self) -> &'static str {
71 self.code
72 }
73
74 pub const fn message(&self) -> &'static str {
76 self.message
77 }
78
79 pub const fn operation_index(&self) -> Option<usize> {
81 self.operation_index
82 }
83
84 pub const fn safety(&self) -> Option<SafetyClass> {
86 self.safety
87 }
88
89 pub fn missing_capabilities(&self) -> &[CapabilityId] {
91 &self.missing_capabilities
92 }
93}
94
95impl fmt::Display for SchemaLoweringDiagnostic {
96 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
97 write!(formatter, "{}: {}", self.code, self.message)
98 }
99}
100
101impl Error for SchemaLoweringDiagnostic {}
102
103#[derive(Clone, Debug, Eq, PartialEq)]
105pub struct SchemaFactCatalog(BTreeMap<SchemaFactId, SchemaFact>);
106
107impl SchemaFactCatalog {
108 pub fn new(
110 facts: impl IntoIterator<Item = SchemaFact>,
111 ) -> Result<Self, SchemaLoweringDiagnostic> {
112 let mut catalog = BTreeMap::new();
113 for fact in facts {
114 let id = fact.id();
115 if catalog.insert(id, fact).is_some() {
116 return Err(SchemaLoweringDiagnostic::new(
117 CODE_CONTEXT_MISMATCH,
118 "schema fact catalog contains a duplicate identity",
119 ));
120 }
121 }
122 Ok(Self(catalog))
123 }
124
125 pub fn empty() -> Self {
127 Self(BTreeMap::new())
128 }
129
130 pub fn get(&self, id: &SchemaFactId) -> Option<&SchemaFact> {
132 self.0.get(id)
133 }
134
135 pub fn iter(&self) -> impl ExactSizeIterator<Item = (&SchemaFactId, &SchemaFact)> {
137 self.0.iter()
138 }
139
140 fn matches_selection(
141 &self,
142 selection: &type_bridge_contract::schema::ManagedFactSelection,
143 ) -> bool {
144 self.0.len() == selection.len()
145 && self
146 .0
147 .keys()
148 .zip(selection.iter())
149 .all(|(left, right)| left == right)
150 }
151}
152
153#[derive(Clone, Debug, Eq, PartialEq)]
155pub struct SchemaLoweringBinding {
156 profile_id: SchemaLoweringProfileId,
157 profile_fingerprint: SchemaLoweringProfileFingerprint,
158 available_capabilities: CapabilitySet,
159}
160
161impl SchemaLoweringBinding {
162 pub fn new(
164 profile_id: SchemaLoweringProfileId,
165 profile_fingerprint: SchemaLoweringProfileFingerprint,
166 available_capabilities: CapabilitySet,
167 ) -> Result<Self, SchemaLoweringDiagnostic> {
168 let profile = typedb_3_12_1_profile();
169 if profile_id != profile.id || profile_fingerprint != crate::profile_fingerprint() {
170 return Err(SchemaLoweringDiagnostic::new(
171 CODE_PROFILE_MISMATCH,
172 "schema lowering profile identity or fingerprint does not match the compiled registry",
173 ));
174 }
175 Ok(Self {
176 profile_id,
177 profile_fingerprint,
178 available_capabilities,
179 })
180 }
181
182 pub fn current(
184 available_capabilities: CapabilitySet,
185 ) -> Result<Self, SchemaLoweringDiagnostic> {
186 Self::new(
187 typedb_3_12_1_profile().id.clone(),
188 profile_fingerprint(),
189 available_capabilities,
190 )
191 }
192
193 pub const fn available_capabilities(&self) -> &CapabilitySet {
195 &self.available_capabilities
196 }
197
198 pub const fn profile_id(&self) -> &SchemaLoweringProfileId {
200 &self.profile_id
201 }
202
203 pub const fn profile_fingerprint(&self) -> &SchemaLoweringProfileFingerprint {
205 &self.profile_fingerprint
206 }
207}
208
209#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
211#[serde(rename_all = "snake_case")]
212pub enum TypeQlVerb {
213 Define,
215 Undefine,
217 Redefine,
219}
220
221impl TypeQlVerb {
222 fn as_str(self) -> &'static str {
223 match self {
224 Self::Define => "define",
225 Self::Undefine => "undefine",
226 Self::Redefine => "redefine",
227 }
228 }
229}
230
231#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
233pub struct TypeQlStatement {
234 verb: TypeQlVerb,
235 query: String,
236}
237
238impl TypeQlStatement {
239 fn new(verb: TypeQlVerb, body: String) -> Self {
240 Self {
241 verb,
242 query: format!("{}\n{body}", verb.as_str()),
243 }
244 }
245
246 pub const fn verb(&self) -> TypeQlVerb {
248 self.verb
249 }
250
251 pub fn query(&self) -> &str {
253 &self.query
254 }
255}
256
257#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
259#[serde(rename_all = "snake_case")]
260pub enum StatementOperationKind {
261 Define,
263 Redefine,
265 Undefine,
267}
268
269impl From<SchemaOperationKind> for StatementOperationKind {
270 fn from(value: SchemaOperationKind) -> Self {
271 match value {
272 SchemaOperationKind::Define => Self::Define,
273 SchemaOperationKind::Redefine => Self::Redefine,
274 SchemaOperationKind::Undefine => Self::Undefine,
275 }
276 }
277}
278
279#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
281pub struct StatementUnit {
282 operation_index: usize,
283 operation_kind: StatementOperationKind,
284 safety: SafetyClass,
285 atomic: bool,
286 affected_ids: Vec<SchemaFactId>,
287 required_capabilities: CapabilitySet,
288 statements: Vec<TypeQlStatement>,
289}
290
291impl StatementUnit {
292 pub const fn operation_index(&self) -> usize {
294 self.operation_index
295 }
296
297 pub const fn operation_kind(&self) -> StatementOperationKind {
299 self.operation_kind
300 }
301
302 pub const fn safety(&self) -> SafetyClass {
304 self.safety
305 }
306
307 pub const fn atomic(&self) -> bool {
309 self.atomic
310 }
311
312 pub fn affected_ids(&self) -> &[SchemaFactId] {
314 &self.affected_ids
315 }
316
317 pub const fn required_capabilities(&self) -> &CapabilitySet {
319 &self.required_capabilities
320 }
321
322 pub fn statements(&self) -> &[TypeQlStatement] {
324 &self.statements
325 }
326}
327
328#[derive(Clone, Debug, Eq, PartialEq)]
330pub struct SchemaLoweringPlan {
331 delta: SchemaDelta,
332 profile_id: SchemaLoweringProfileId,
333 profile_fingerprint: SchemaLoweringProfileFingerprint,
334 units: Vec<StatementUnit>,
335}
336
337impl SchemaLoweringPlan {
338 pub const fn delta(&self) -> &SchemaDelta {
340 &self.delta
341 }
342
343 pub const fn profile_id(&self) -> &SchemaLoweringProfileId {
345 &self.profile_id
346 }
347
348 pub const fn profile_fingerprint(&self) -> &SchemaLoweringProfileFingerprint {
350 &self.profile_fingerprint
351 }
352
353 pub fn units(&self) -> &[StatementUnit] {
355 &self.units
356 }
357}
358
359pub fn lower_schema_delta(
361 delta: &SchemaDelta,
362 source_facts: &SchemaFactCatalog,
363 target_facts: &SchemaFactCatalog,
364 binding: &SchemaLoweringBinding,
365) -> Result<SchemaLoweringPlan, SchemaLoweringDiagnostic> {
366 lower_schema_delta_with_verified_assertions(
367 delta,
368 source_facts,
369 target_facts,
370 binding,
371 &[],
372 false,
373 )
374}
375
376pub(crate) fn lower_schema_delta_with_verified_assertions(
377 delta: &SchemaDelta,
378 source_facts: &SchemaFactCatalog,
379 target_facts: &SchemaFactCatalog,
380 binding: &SchemaLoweringBinding,
381 discharged_operation_indices: &[usize],
382 destructive_approved: bool,
383) -> Result<SchemaLoweringPlan, SchemaLoweringDiagnostic> {
384 if !source_facts.matches_selection(delta.source().selection())
385 || !target_facts.matches_selection(delta.target().selection())
386 {
387 return Err(SchemaLoweringDiagnostic::new(
388 CODE_CONTEXT_MISMATCH,
389 "source or target fact catalog does not match the delta managed selection",
390 ));
391 }
392 if discharged_operation_indices
393 .windows(2)
394 .any(|pair| pair[0] >= pair[1])
395 || discharged_operation_indices
396 .last()
397 .is_some_and(|index| *index >= delta.operations().len())
398 {
399 return Err(SchemaLoweringDiagnostic::new(
400 CODE_CONTEXT_MISMATCH,
401 "verified discharged operation indices are not canonical for this delta",
402 ));
403 }
404 let deleted_attributes = delta
410 .operations()
411 .iter()
412 .filter_map(|operation| match operation.undefined_fact() {
413 Some(SchemaFact::Type(fact)) if fact.id().kind() == TypeKind::Attribute => {
414 Some(fact.id().label().as_str().to_owned())
415 }
416 _ => None,
417 })
418 .collect::<BTreeSet<_>>();
419 let units = delta
420 .operations()
421 .iter()
422 .enumerate()
423 .map(|(index, operation)| {
424 let mut unit = lower_operation(
425 index,
426 operation,
427 source_facts,
428 target_facts,
429 binding,
430 discharged_operation_indices.binary_search(&index).is_ok(),
431 destructive_approved,
432 )?;
433 if matches!(
434 operation.undefined_fact(),
435 Some(SchemaFact::Value(fact))
436 if deleted_attributes.contains(fact.id().attribute().label().as_str())
437 ) {
438 unit.statements.clear();
439 }
440 Ok(unit)
441 })
442 .collect::<Result<Vec<_>, _>>()?;
443 Ok(SchemaLoweringPlan {
444 delta: delta.clone(),
445 profile_id: binding.profile_id.clone(),
446 profile_fingerprint: binding.profile_fingerprint.clone(),
447 units,
448 })
449}
450
451fn lower_operation(
452 operation_index: usize,
453 operation: &SchemaOperation,
454 source_facts: &SchemaFactCatalog,
455 target_facts: &SchemaFactCatalog,
456 binding: &SchemaLoweringBinding,
457 verifier_resolved: bool,
458 destructive_approved: bool,
459) -> Result<StatementUnit, SchemaLoweringDiagnostic> {
460 let classification = classify_operation_transition(operation).map_err(|error| {
461 SchemaLoweringDiagnostic::new(CODE_INVALID_TRANSITION, error.message())
462 .at_operation(operation_index)
463 })?;
464 let missing = classification
465 .required_capabilities
466 .iter()
467 .filter(|capability| !binding.available_capabilities.contains(capability))
468 .cloned()
469 .collect::<Vec<_>>();
470 if !missing.is_empty() {
471 return Err(SchemaLoweringDiagnostic::new(
472 CODE_CAPABILITY_MISMATCH,
473 "provider capabilities do not satisfy the schema lowering unit",
474 )
475 .at_operation(operation_index)
476 .with_missing_capabilities(missing));
477 }
478 gate_safety(
479 operation_index,
480 classification.safety,
481 verifier_resolved,
482 destructive_approved,
483 )?;
484 let statements = render_operation(operation_index, operation, source_facts, target_facts)?;
485 Ok(StatementUnit {
486 operation_index,
487 operation_kind: operation.kind().into(),
488 safety: classification.safety,
489 atomic: classification.atomic,
490 affected_ids: operation.affected_ids(),
491 required_capabilities: classification.required_capabilities,
492 statements,
493 })
494}
495
496fn gate_safety(
497 operation_index: usize,
498 safety: SafetyClass,
499 verifier_resolved: bool,
500 destructive_approved: bool,
501) -> Result<(), SchemaLoweringDiagnostic> {
502 if verifier_resolved
503 && !matches!(
504 safety,
505 SafetyClass::Conditional | SafetyClass::BackfillRequired
506 )
507 {
508 return Err(SchemaLoweringDiagnostic::new(
509 CODE_INVALID_TRANSITION,
510 "verifier resolution targets an operation outside the resolvable safety classes",
511 )
512 .at_operation(operation_index)
513 .with_safety(safety));
514 }
515 let (code, message) = match safety {
516 SafetyClass::FormalOnly | SafetyClass::SchemaMetadata | SafetyClass::Additive => {
517 return Ok(());
518 }
519 SafetyClass::Conditional if verifier_resolved => return Ok(()),
520 SafetyClass::Conditional => (
521 CODE_REQUIRES_ASSERTION,
522 "schema transition requires an explicit data assertion",
523 ),
524 SafetyClass::BackfillRequired if verifier_resolved => return Ok(()),
525 SafetyClass::BackfillRequired => (
526 CODE_REQUIRES_BACKFILL,
527 "schema transition requires an explicit backfill plan",
528 ),
529 SafetyClass::Destructive if destructive_approved => return Ok(()),
530 SafetyClass::Destructive => (
531 CODE_DESTRUCTIVE,
532 "destructive schema transition requires an identity-bound approval",
533 ),
534 SafetyClass::Opaque => (
535 CODE_OPAQUE,
536 "opaque schema transition requires explicit operator intent",
537 ),
538 SafetyClass::Unsupported => (
539 CODE_UNSUPPORTED,
540 "schema transition is unsupported by the TypeDB 3.12.1 lowering profile",
541 ),
542 };
543 Err(SchemaLoweringDiagnostic::new(code, message)
544 .at_operation(operation_index)
545 .with_safety(safety))
546}
547
548#[derive(Debug)]
549struct RenderFailure {
550 code: &'static str,
551 message: &'static str,
552}
553
554fn render_operation(
555 operation_index: usize,
556 operation: &SchemaOperation,
557 source_facts: &SchemaFactCatalog,
558 target_facts: &SchemaFactCatalog,
559) -> Result<Vec<TypeQlStatement>, SchemaLoweringDiagnostic> {
560 render_operation_inner(operation, source_facts, target_facts).map_err(|failure| {
561 SchemaLoweringDiagnostic::new(failure.code, failure.message).at_operation(operation_index)
562 })
563}
564
565fn render_operation_inner(
566 operation: &SchemaOperation,
567 source_facts: &SchemaFactCatalog,
568 target_facts: &SchemaFactCatalog,
569) -> Result<Vec<TypeQlStatement>, RenderFailure> {
570 match operation.kind() {
571 SchemaOperationKind::Define => {
572 let facts = operation.defined_facts().expect("define exposes facts");
573 let function_ids = facts
574 .iter()
575 .filter_map(|fact| match fact {
576 SchemaFact::Function(function) => Some(function.id().clone()),
577 _ => None,
578 })
579 .collect::<BTreeSet<_>>();
580 let mut function_annotations = BTreeMap::<FunctionId, Vec<&AnnotationFact>>::new();
581 for fact in facts {
582 if let SchemaFact::Annotation(annotation) = fact
583 && let AnnotationSubjectId::Function(function) = annotation.id().subject()
584 && function_ids.contains(function)
585 {
586 function_annotations
587 .entry(function.clone())
588 .or_default()
589 .push(annotation);
590 }
591 }
592 let mut bodies = Vec::new();
593 for fact in facts {
594 match fact {
595 SchemaFact::Annotation(annotation) if matches!(annotation.id().subject(), AnnotationSubjectId::Function(id) if function_ids.contains(id)) =>
596 {}
597 SchemaFact::Function(function) => bodies.push(render_function(
598 function,
599 function_annotations
600 .get(function.id())
601 .map(Vec::as_slice)
602 .unwrap_or_default(),
603 )?),
604 _ => bodies.push(render_definition(fact, target_facts, true)?),
605 }
606 }
607 Ok(vec![TypeQlStatement::new(
608 TypeQlVerb::Define,
609 bodies.join("\n"),
610 )])
611 }
612 SchemaOperationKind::Undefine => Ok(vec![TypeQlStatement::new(
613 TypeQlVerb::Undefine,
614 render_undefinition(
615 operation.undefined_fact().expect("undefine exposes fact"),
616 source_facts,
617 )?,
618 )]),
619 SchemaOperationKind::Redefine => {
620 let expected = operation
621 .expected_fact()
622 .expect("redefine exposes expected");
623 let replacement = operation
624 .replacement_fact()
625 .expect("redefine exposes replacement");
626 if let (SchemaFact::Annotation(old), SchemaFact::Annotation(new)) =
627 (expected, replacement)
628 && matches!(old.id().subject(), AnnotationSubjectId::Sub(_))
629 && matches!(
630 old.id().kind(),
631 AnnotationKindId::Doc | AnnotationKindId::Meta(_)
632 )
633 {
634 return Ok(vec![
635 TypeQlStatement::new(
636 TypeQlVerb::Undefine,
637 render_annotation_undefinition(old, source_facts)?,
638 ),
639 TypeQlStatement::new(
640 TypeQlVerb::Define,
641 render_annotation_definition(new, target_facts, false)?,
642 ),
643 ]);
644 }
645 if let (SchemaFact::Relates(old), SchemaFact::Relates(new)) = (expected, replacement) {
646 return match (old.specializes(), new.specializes()) {
647 (None, Some(_)) => Ok(vec![TypeQlStatement::new(
648 TypeQlVerb::Define,
649 render_relates(new),
650 )]),
651 (Some(old_parent), None) => Ok(vec![TypeQlStatement::new(
652 TypeQlVerb::Undefine,
653 format!(
654 "as {} from {} relates {};",
655 old_parent.label().as_str(),
656 old.id().relation().label().as_str(),
657 old.id().role().label().as_str()
658 ),
659 )]),
660 (Some(_), Some(_)) => Ok(vec![TypeQlStatement::new(
661 TypeQlVerb::Redefine,
662 render_relates(new),
663 )]),
664 (None, None) => Err(RenderFailure {
665 code: CODE_INVALID_TRANSITION,
666 message: "relates redefinition does not change specialization",
667 }),
668 };
669 }
670 Ok(vec![TypeQlStatement::new(
671 TypeQlVerb::Redefine,
672 render_definition(replacement, target_facts, false)?,
673 )])
674 }
675 }
676}
677
678fn render_definition(
679 fact: &SchemaFact,
680 catalog: &SchemaFactCatalog,
681 defining: bool,
682) -> Result<String, RenderFailure> {
683 match fact {
684 SchemaFact::Type(fact) => Ok(format!(
685 "{} {};",
686 type_kind(fact.id().kind()),
687 fact.id().label().as_str()
688 )),
689 SchemaFact::Sub(fact) => Ok(format!(
690 "{} sub {};",
691 fact.id().subtype().label().as_str(),
692 fact.id().supertype().label().as_str()
693 )),
694 SchemaFact::Value(fact) => Ok(format!(
695 "{} value {};",
696 fact.id().attribute().label().as_str(),
697 value_type(fact.value_type())
698 )),
699 SchemaFact::Owns(fact) => Ok(format!(
700 "{} owns {};",
701 fact.id().owner().label().as_str(),
702 fact.id().attribute().label().as_str()
703 )),
704 SchemaFact::Relates(fact) => Ok(render_relates(fact)),
705 SchemaFact::Plays(fact) => Ok(format!(
706 "{} plays {}:{};",
707 fact.id().player().label().as_str(),
708 fact.id().role().declaring_relation().as_str(),
709 fact.id().role().label().as_str()
710 )),
711 SchemaFact::Annotation(fact) => render_annotation_definition(fact, catalog, defining),
712 SchemaFact::Function(fact) => render_function(fact, &[]),
713 SchemaFact::Struct(_) => Err(RenderFailure {
714 code: CODE_UNSUPPORTED,
715 message: "TypeDB 3.12.1 does not admit the pinned struct transition grammar",
716 }),
717 }
718}
719
720fn render_undefinition(
721 fact: &SchemaFact,
722 catalog: &SchemaFactCatalog,
723) -> Result<String, RenderFailure> {
724 match fact {
725 SchemaFact::Type(fact) => Ok(format!("{};", fact.id().label().as_str())),
728 SchemaFact::Sub(fact) => Ok(format!(
729 "sub {} from {};",
730 fact.id().supertype().label().as_str(),
731 fact.id().subtype().label().as_str()
732 )),
733 SchemaFact::Value(fact) => Ok(format!(
734 "value {} from {};",
735 value_type(fact.value_type()),
736 fact.id().attribute().label().as_str()
737 )),
738 SchemaFact::Owns(fact) => Ok(format!(
739 "owns {} from {};",
740 fact.id().attribute().label().as_str(),
741 fact.id().owner().label().as_str()
742 )),
743 SchemaFact::Relates(fact) => Ok(format!(
744 "relates {} from {};",
745 fact.id().role().label().as_str(),
746 fact.id().relation().label().as_str()
747 )),
748 SchemaFact::Plays(fact) => Ok(format!(
749 "plays {}:{} from {};",
750 fact.id().role().declaring_relation().as_str(),
751 fact.id().role().label().as_str(),
752 fact.id().player().label().as_str()
753 )),
754 SchemaFact::Annotation(fact) => render_annotation_undefinition(fact, catalog),
755 SchemaFact::Function(fact) => Ok(format!("fun {};", fact.id().label().as_str())),
756 SchemaFact::Struct(_) => Err(RenderFailure {
757 code: CODE_UNSUPPORTED,
758 message: "TypeDB 3.12.1 does not admit the pinned struct transition grammar",
759 }),
760 }
761}
762
763fn render_relates(fact: &RelatesFact) -> String {
764 let specializes = fact
765 .specializes()
766 .map(|role| format!(" as {}", role.label().as_str()))
767 .unwrap_or_default();
768 format!(
769 "{} relates {}{};",
770 fact.id().relation().label().as_str(),
771 fact.id().role().label().as_str(),
772 specializes
773 )
774}
775
776fn render_annotation_definition(
777 annotation: &AnnotationFact,
778 catalog: &SchemaFactCatalog,
779 defining: bool,
780) -> Result<String, RenderFailure> {
781 reject_distinct_lowering(annotation.id().kind())?;
782 let subject = render_annotation_subject(annotation.id().subject(), catalog, defining)?;
783 Ok(format!("{subject} {};", render_annotation(annotation)))
784}
785
786fn render_annotation_undefinition(
787 annotation: &AnnotationFact,
788 catalog: &SchemaFactCatalog,
789) -> Result<String, RenderFailure> {
790 reject_distinct_lowering(annotation.id().kind())?;
791 let subject = render_annotation_subject(annotation.id().subject(), catalog, false)?;
792 Ok(format!(
793 "{} from {subject};",
794 render_annotation_selector(annotation.id().kind())
795 ))
796}
797
798fn reject_distinct_lowering(kind: &AnnotationKindId) -> Result<(), RenderFailure> {
799 if kind == &AnnotationKindId::Distinct {
800 return Err(RenderFailure {
801 code: CODE_UNSUPPORTED,
802 message: "schema transition is unsupported by the TypeDB 3.12.1 lowering profile",
803 });
804 }
805 Ok(())
806}
807
808fn render_annotation_subject(
809 subject: &AnnotationSubjectId,
810 catalog: &SchemaFactCatalog,
811 defining: bool,
812) -> Result<String, RenderFailure> {
813 match subject {
814 AnnotationSubjectId::Type(id) if defining => {
815 Ok(format!("{} {}", type_kind(id.kind()), id.label().as_str()))
816 }
817 AnnotationSubjectId::Type(id) => Ok(id.label().as_str().to_owned()),
818 AnnotationSubjectId::Sub(id) => Ok(format!(
819 "{} sub {}",
820 id.subtype().label().as_str(),
821 id.supertype().label().as_str()
822 )),
823 AnnotationSubjectId::Value(id) => {
824 let fact_id = SchemaFactId::Value(id.clone());
825 let Some(SchemaFact::Value(value)) = catalog.get(&fact_id) else {
826 return Err(RenderFailure {
827 code: CODE_RENDER_CONTEXT,
828 message: "value annotation rendering requires its exact value fact payload",
829 });
830 };
831 Ok(format!(
832 "{} value {}",
833 id.attribute().label().as_str(),
834 value_type(value.value_type())
835 ))
836 }
837 AnnotationSubjectId::Owns(id) => Ok(format!(
838 "{} owns {}",
839 id.owner().label().as_str(),
840 id.attribute().label().as_str()
841 )),
842 AnnotationSubjectId::Relates(id) => Ok(format!(
843 "{} relates {}",
844 id.relation().label().as_str(),
845 id.role().label().as_str()
846 )),
847 AnnotationSubjectId::Plays(id) => Ok(format!(
848 "{} plays {}:{}",
849 id.player().label().as_str(),
850 id.role().declaring_relation().as_str(),
851 id.role().label().as_str()
852 )),
853 AnnotationSubjectId::Function(_) => Err(RenderFailure {
854 code: CODE_UNSUPPORTED,
855 message: "persistent function annotations are unsupported; fold metadata into function definition",
856 }),
857 }
858}
859
860fn render_annotation(annotation: &AnnotationFact) -> String {
861 match (annotation.id().kind(), annotation.value()) {
862 (AnnotationKindId::Abstract, SchemaAnnotationValue::Presence) => "@abstract".into(),
863 (AnnotationKindId::Independent, SchemaAnnotationValue::Presence) => "@independent".into(),
864 (AnnotationKindId::Key, SchemaAnnotationValue::Presence) => "@key".into(),
865 (AnnotationKindId::Unique, SchemaAnnotationValue::Presence) => "@unique".into(),
866 (AnnotationKindId::Distinct, SchemaAnnotationValue::Presence) => {
867 unreachable!("distinct lowering rejects before annotation rendering")
868 }
869 (AnnotationKindId::Card, SchemaAnnotationValue::Cardinality(cardinality)) => format!(
870 "@card({}..{})",
871 (*cardinality).min(),
872 (*cardinality)
873 .max()
874 .map(|value| value.to_string())
875 .unwrap_or_default()
876 ),
877 (AnnotationKindId::Regex, SchemaAnnotationValue::Regex(regex)) => {
878 format!("@regex({})", quote(regex.as_str()))
879 }
880 (AnnotationKindId::Range, SchemaAnnotationValue::Range(range)) => format!(
881 "@range({}..{})",
882 range.lower().map(render_value).unwrap_or_default(),
883 range.upper().map(render_value).unwrap_or_default()
884 ),
885 (AnnotationKindId::Values, SchemaAnnotationValue::Values(values)) => format!(
886 "@values({})",
887 values
888 .iter()
889 .map(render_value)
890 .collect::<Vec<_>>()
891 .join(", ")
892 ),
893 (AnnotationKindId::Doc, SchemaAnnotationValue::Doc(doc)) => {
894 format!("@doc({})", quote(doc.as_str()))
895 }
896 (AnnotationKindId::Meta(key), SchemaAnnotationValue::Meta(value)) => {
897 format!("@meta({}, {})", quote(key.as_str()), render_value(value))
898 }
899 _ => unreachable!("annotation constructors preserve kind-safe payloads"),
900 }
901}
902
903fn render_annotation_selector(kind: &AnnotationKindId) -> String {
904 match kind {
905 AnnotationKindId::Abstract => "@abstract".into(),
906 AnnotationKindId::Independent => "@independent".into(),
907 AnnotationKindId::Key => "@key".into(),
908 AnnotationKindId::Unique => "@unique".into(),
909 AnnotationKindId::Distinct => {
910 unreachable!("distinct lowering rejects before annotation selector rendering")
911 }
912 AnnotationKindId::Card => "@card".into(),
913 AnnotationKindId::Regex => "@regex".into(),
914 AnnotationKindId::Range => "@range".into(),
915 AnnotationKindId::Values => "@values".into(),
916 AnnotationKindId::Doc => "@doc".into(),
917 AnnotationKindId::Meta(key) => format!("@meta({})", quote(key.as_str())),
918 }
919}
920
921fn render_function(
922 function: &FunctionFact,
923 annotations: &[&AnnotationFact],
924) -> Result<String, RenderFailure> {
925 let parameters = function
926 .signature()
927 .parameters()
928 .iter()
929 .map(|parameter| {
930 format!(
931 "${}: {}",
932 parameter.name().as_str(),
933 render_type_reference(parameter.type_ref())
934 )
935 })
936 .collect::<Vec<_>>()
937 .join(", ");
938 let returns = match function.signature().returns() {
939 FunctionReturnMode::Scalar(element) => render_return_element(element),
940 FunctionReturnMode::Tuple(elements) => format!(
941 "({})",
942 elements
943 .iter()
944 .map(render_return_element)
945 .collect::<Vec<_>>()
946 .join(", ")
947 ),
948 FunctionReturnMode::Stream(elements) => format!(
949 "{{ {} }}",
950 elements
951 .iter()
952 .map(render_return_element)
953 .collect::<Vec<_>>()
954 .join(", ")
955 ),
956 };
957 let mut rendered_annotations = annotations
958 .iter()
959 .map(|annotation| render_annotation(annotation))
960 .collect::<Vec<_>>();
961 rendered_annotations.sort();
962 let suffix = if rendered_annotations.is_empty() {
963 String::new()
964 } else {
965 format!(" {}", rendered_annotations.join(" "))
966 };
967 Ok(format!(
968 "fun {}({parameters}) -> {returns}{suffix}:\n{}",
969 function.id().label().as_str(),
970 function.body().text()
971 ))
972}
973
974fn render_return_element(element: &FunctionReturnElement) -> String {
975 format!(
976 "{}{}",
977 render_type_reference(element.type_ref()),
978 if element.optional() { "?" } else { "" }
979 )
980}
981
982fn render_type_reference(reference: &TypeReference) -> String {
983 match reference {
984 TypeReference::Value(value) => value_type(*value).into(),
985 TypeReference::Schema(label) => label.as_str().into(),
986 }
987}
988
989fn render_value(value: &CanonicalValue) -> String {
990 match value {
991 CanonicalValue::String(value) => quote(value.as_str()),
992 CanonicalValue::Long(value) => value.to_string(),
993 CanonicalValue::Double(value) => format!("{:?}", value.get()),
994 CanonicalValue::Boolean(value) => value.to_string(),
995 CanonicalValue::Date(value) => value.to_string(),
996 CanonicalValue::DateTime(value) => value.to_string(),
997 CanonicalValue::DateTimeTz(value) => value.to_string(),
998 CanonicalValue::Decimal(value) => format!("{}dec", value.as_str()),
999 CanonicalValue::Duration(value) => value.to_string(),
1000 }
1001}
1002
1003fn quote(value: &str) -> String {
1004 serde_json::to_string(value).expect("strings always serialize")
1005}
1006
1007fn type_kind(kind: TypeKind) -> &'static str {
1008 match kind {
1009 TypeKind::Entity => "entity",
1010 TypeKind::Relation => "relation",
1011 TypeKind::Attribute => "attribute",
1012 TypeKind::Struct => "struct",
1013 }
1014}
1015
1016fn value_type(value: ValueTypeTag) -> &'static str {
1017 match value.as_str() {
1018 "long" => "integer",
1019 "datetime_tz" => "datetime-tz",
1020 other => other,
1021 }
1022}
1023
1024#[cfg(test)]
1025mod tests {
1026 use super::*;
1027 use type_bridge_contract::id::{AttributeId, Label, RoleId, StructId, TypeId};
1028 use type_bridge_contract::schema::{
1029 AnnotationFactId, CanonicalValueRange, CanonicalValueSet, DocText, FunctionBody,
1030 FunctionParameter, FunctionSignature, OwnsFact, OwnsFactId, PlaysFact, PlaysFactId,
1031 RegexPattern, RelatesFactId, SchemaOperation, StructFact, StructField, SubFact, SubFactId,
1032 TypeFact, ValueFact, ValueFactId,
1033 };
1034 use type_bridge_contract::value::{CanonicalString, Cardinality};
1035
1036 fn type_id(kind: TypeKind, label: &str) -> TypeId {
1037 TypeId::new(kind, label).unwrap()
1038 }
1039
1040 fn attribute_id(label: &str) -> AttributeId {
1041 AttributeId::new(label).unwrap()
1042 }
1043
1044 fn role_id(relation: &str, role: &str) -> RoleId {
1045 RoleId::new(relation, role).unwrap()
1046 }
1047
1048 fn value_fact(label: &str, value_type: ValueTypeTag) -> SchemaFact {
1049 SchemaFact::Value(ValueFact::new(
1050 ValueFactId::new(attribute_id(label)),
1051 value_type,
1052 ))
1053 }
1054
1055 fn annotation(
1056 subject: AnnotationSubjectId,
1057 kind: AnnotationKindId,
1058 value: SchemaAnnotationValue,
1059 ) -> SchemaFact {
1060 SchemaFact::Annotation(
1061 AnnotationFact::new(AnnotationFactId::new(subject, kind), value).unwrap(),
1062 )
1063 }
1064
1065 fn function(body: &str) -> SchemaFact {
1066 SchemaFact::Function(FunctionFact::new(
1067 FunctionId::new("answer").unwrap(),
1068 FunctionSignature::new(
1069 vec![FunctionParameter::new(
1070 Label::new("seed").unwrap(),
1071 TypeReference::Value(ValueTypeTag::Long),
1072 )],
1073 FunctionReturnMode::scalar(FunctionReturnElement::new(
1074 TypeReference::Value(ValueTypeTag::Long),
1075 false,
1076 )),
1077 )
1078 .unwrap(),
1079 FunctionBody::new(body).unwrap(),
1080 ))
1081 }
1082
1083 fn full_binding() -> SchemaLoweringBinding {
1084 SchemaLoweringBinding::current(typedb_3_12_1_profile().required_capabilities.clone())
1085 .unwrap()
1086 }
1087
1088 fn dump(
1089 name: &str,
1090 operation: SchemaOperation,
1091 source: &SchemaFactCatalog,
1092 target: &SchemaFactCatalog,
1093 output: &mut String,
1094 ) {
1095 output.push_str("## ");
1096 output.push_str(name);
1097 output.push('\n');
1098 for (index, statement) in render_operation_inner(&operation, source, target)
1099 .unwrap()
1100 .iter()
1101 .enumerate()
1102 {
1103 if index != 0 {
1104 output.push_str("-- atomic-next --\n");
1105 }
1106 output.push_str(statement.query());
1107 output.push('\n');
1108 }
1109 }
1110
1111 #[test]
1112 fn supported_renderer_matches_exhaustive_golden() {
1113 let empty = SchemaFactCatalog::empty();
1114 let person = type_id(TypeKind::Entity, "person");
1115 let employee = type_id(TypeKind::Entity, "employee");
1116 let name = attribute_id("name");
1117 let owns_id = OwnsFactId::new(person.clone(), name.clone()).unwrap();
1118 let relation = type_id(TypeKind::Relation, "friendship");
1119 let role = role_id("friendship", "friend");
1120 let relates_id = RelatesFactId::new(relation.clone(), role.clone()).unwrap();
1121 let child_relation = type_id(TypeKind::Relation, "child-relation");
1122 let child_role = role_id("child-relation", "child-role");
1123 let child_relates = RelatesFactId::new(child_relation.clone(), child_role.clone()).unwrap();
1124 let parent_a = role_id("parent-relation", "parent-role-a");
1125 let parent_b = role_id("parent-relation", "parent-role-b");
1126 let plays_id = PlaysFactId::new(person.clone(), role.clone()).unwrap();
1127 let sub_id = SubFactId::new(employee.clone(), person.clone()).unwrap();
1128 let string_value = value_fact("name", ValueTypeTag::String);
1129 let integer_value = value_fact("name", ValueTypeTag::Long);
1130 let string_catalog = SchemaFactCatalog::new([string_value.clone()]).unwrap();
1131 let integer_catalog = SchemaFactCatalog::new([integer_value.clone()]).unwrap();
1132 let mut output = String::new();
1133
1134 let type_fact = SchemaFact::Type(TypeFact::new(person.clone()).unwrap());
1135 dump(
1136 "type-define",
1137 SchemaOperation::define(vec![type_fact.clone()]).unwrap(),
1138 &empty,
1139 &empty,
1140 &mut output,
1141 );
1142 dump(
1143 "type-undefine",
1144 SchemaOperation::undefine(type_fact),
1145 &empty,
1146 &empty,
1147 &mut output,
1148 );
1149 let sub_fact = SchemaFact::Sub(SubFact::new(sub_id.clone()));
1150 dump(
1151 "sub-define",
1152 SchemaOperation::define(vec![sub_fact.clone()]).unwrap(),
1153 &empty,
1154 &empty,
1155 &mut output,
1156 );
1157 dump(
1158 "sub-undefine",
1159 SchemaOperation::undefine(sub_fact),
1160 &empty,
1161 &empty,
1162 &mut output,
1163 );
1164 dump(
1165 "value-define",
1166 SchemaOperation::define(vec![string_value.clone()]).unwrap(),
1167 &empty,
1168 &string_catalog,
1169 &mut output,
1170 );
1171 dump(
1172 "value-redefine",
1173 SchemaOperation::redefine(string_value.clone(), integer_value.clone()).unwrap(),
1174 &string_catalog,
1175 &integer_catalog,
1176 &mut output,
1177 );
1178 dump(
1179 "value-undefine",
1180 SchemaOperation::undefine(string_value.clone()),
1181 &string_catalog,
1182 &empty,
1183 &mut output,
1184 );
1185 let owns = SchemaFact::Owns(OwnsFact::new(owns_id.clone()));
1186 dump(
1187 "owns-define",
1188 SchemaOperation::define(vec![owns.clone()]).unwrap(),
1189 &empty,
1190 &empty,
1191 &mut output,
1192 );
1193 dump(
1194 "owns-undefine",
1195 SchemaOperation::undefine(owns),
1196 &empty,
1197 &empty,
1198 &mut output,
1199 );
1200 let relates = SchemaFact::Relates(RelatesFact::new(relates_id, None).unwrap());
1201 dump(
1202 "relates-define",
1203 SchemaOperation::define(vec![relates.clone()]).unwrap(),
1204 &empty,
1205 &empty,
1206 &mut output,
1207 );
1208 dump(
1209 "relates-undefine",
1210 SchemaOperation::undefine(relates),
1211 &empty,
1212 &empty,
1213 &mut output,
1214 );
1215 let specialization_a = SchemaFact::Relates(
1216 RelatesFact::new(child_relates.clone(), Some(parent_a.clone())).unwrap(),
1217 );
1218 let specialization_b = SchemaFact::Relates(
1219 RelatesFact::new(child_relates.clone(), Some(parent_b.clone())).unwrap(),
1220 );
1221 let unspecialized = SchemaFact::Relates(RelatesFact::new(child_relates, None).unwrap());
1222 dump(
1223 "specialization-define",
1224 SchemaOperation::redefine(unspecialized.clone(), specialization_a.clone()).unwrap(),
1225 &empty,
1226 &empty,
1227 &mut output,
1228 );
1229 dump(
1230 "specialization-redefine",
1231 SchemaOperation::redefine(specialization_a.clone(), specialization_b.clone()).unwrap(),
1232 &empty,
1233 &empty,
1234 &mut output,
1235 );
1236 dump(
1237 "specialization-undefine",
1238 SchemaOperation::redefine(specialization_b, unspecialized).unwrap(),
1239 &empty,
1240 &empty,
1241 &mut output,
1242 );
1243 let plays = SchemaFact::Plays(PlaysFact::new(plays_id));
1244 dump(
1245 "plays-define",
1246 SchemaOperation::define(vec![plays.clone()]).unwrap(),
1247 &empty,
1248 &empty,
1249 &mut output,
1250 );
1251 dump(
1252 "plays-undefine",
1253 SchemaOperation::undefine(plays),
1254 &empty,
1255 &empty,
1256 &mut output,
1257 );
1258
1259 let doc_old = annotation(
1260 AnnotationSubjectId::Type(person.clone()),
1261 AnnotationKindId::Doc,
1262 SchemaAnnotationValue::Doc(DocText::new("line\n\"quoted\"").unwrap()),
1263 );
1264 let doc_new = annotation(
1265 AnnotationSubjectId::Type(person.clone()),
1266 AnnotationKindId::Doc,
1267 SchemaAnnotationValue::Doc(DocText::new("changed").unwrap()),
1268 );
1269 dump(
1270 "doc-define",
1271 SchemaOperation::define(vec![doc_old.clone()]).unwrap(),
1272 &empty,
1273 &empty,
1274 &mut output,
1275 );
1276 dump(
1277 "doc-redefine",
1278 SchemaOperation::redefine(doc_old.clone(), doc_new).unwrap(),
1279 &empty,
1280 &empty,
1281 &mut output,
1282 );
1283 dump(
1284 "doc-undefine",
1285 SchemaOperation::undefine(doc_old),
1286 &empty,
1287 &empty,
1288 &mut output,
1289 );
1290 let regex_old = annotation(
1291 AnnotationSubjectId::Value(ValueFactId::new(name.clone())),
1292 AnnotationKindId::Regex,
1293 SchemaAnnotationValue::Regex(RegexPattern::new("^a+\\\\d$").unwrap()),
1294 );
1295 let regex_new = annotation(
1296 AnnotationSubjectId::Value(ValueFactId::new(name.clone())),
1297 AnnotationKindId::Regex,
1298 SchemaAnnotationValue::Regex(RegexPattern::new("^b+$").unwrap()),
1299 );
1300 dump(
1301 "regex-redefine",
1302 SchemaOperation::redefine(regex_old, regex_new).unwrap(),
1303 &string_catalog,
1304 &string_catalog,
1305 &mut output,
1306 );
1307 let card_old = annotation(
1308 AnnotationSubjectId::Owns(owns_id.clone()),
1309 AnnotationKindId::Card,
1310 SchemaAnnotationValue::Cardinality(Cardinality::new(0, Some(1)).unwrap()),
1311 );
1312 let card_new = annotation(
1313 AnnotationSubjectId::Owns(owns_id),
1314 AnnotationKindId::Card,
1315 SchemaAnnotationValue::Cardinality(Cardinality::new(0, Some(2)).unwrap()),
1316 );
1317 dump(
1318 "card-redefine",
1319 SchemaOperation::redefine(card_old, card_new).unwrap(),
1320 &empty,
1321 &empty,
1322 &mut output,
1323 );
1324 let range = annotation(
1325 AnnotationSubjectId::Value(ValueFactId::new(name.clone())),
1326 AnnotationKindId::Range,
1327 SchemaAnnotationValue::Range(
1328 CanonicalValueRange::new(
1329 Some(CanonicalValue::Long(1)),
1330 Some(CanonicalValue::Long(10)),
1331 )
1332 .unwrap(),
1333 ),
1334 );
1335 dump(
1336 "range-define",
1337 SchemaOperation::define(vec![range]).unwrap(),
1338 &empty,
1339 &string_catalog,
1340 &mut output,
1341 );
1342 let values = annotation(
1343 AnnotationSubjectId::Value(ValueFactId::new(name)),
1344 AnnotationKindId::Values,
1345 SchemaAnnotationValue::Values(
1346 CanonicalValueSet::new([CanonicalValue::Long(2), CanonicalValue::Long(1)]).unwrap(),
1347 ),
1348 );
1349 dump(
1350 "values-define",
1351 SchemaOperation::define(vec![values]).unwrap(),
1352 &empty,
1353 &integer_catalog,
1354 &mut output,
1355 );
1356 let meta_old = annotation(
1357 AnnotationSubjectId::Sub(sub_id.clone()),
1358 AnnotationKindId::meta("owner").unwrap(),
1359 SchemaAnnotationValue::Meta(CanonicalValue::String(
1360 CanonicalString::new("old").unwrap(),
1361 )),
1362 );
1363 let meta_new = annotation(
1364 AnnotationSubjectId::Sub(sub_id),
1365 AnnotationKindId::meta("owner").unwrap(),
1366 SchemaAnnotationValue::Meta(CanonicalValue::String(
1367 CanonicalString::new("new").unwrap(),
1368 )),
1369 );
1370 dump(
1371 "sub-meta-fallback",
1372 SchemaOperation::redefine(meta_old.clone(), meta_new).unwrap(),
1373 &empty,
1374 &empty,
1375 &mut output,
1376 );
1377 dump(
1378 "meta-keyed-undefine",
1379 SchemaOperation::undefine(meta_old),
1380 &empty,
1381 &empty,
1382 &mut output,
1383 );
1384
1385 let function_fact = function("match\n let $value = $seed;\nreturn first $value;");
1386 let function_doc = annotation(
1387 AnnotationSubjectId::Function(FunctionId::new("answer").unwrap()),
1388 AnnotationKindId::Doc,
1389 SchemaAnnotationValue::Doc(DocText::new("answer docs").unwrap()),
1390 );
1391 let function_meta = annotation(
1392 AnnotationSubjectId::Function(FunctionId::new("answer").unwrap()),
1393 AnnotationKindId::meta("owner").unwrap(),
1394 SchemaAnnotationValue::Meta(CanonicalValue::String(
1395 CanonicalString::new("core").unwrap(),
1396 )),
1397 );
1398 dump(
1399 "function-define-with-metadata",
1400 SchemaOperation::define(vec![function_meta, function_fact.clone(), function_doc])
1401 .unwrap(),
1402 &empty,
1403 &empty,
1404 &mut output,
1405 );
1406 let changed_function = function("match\n let $value = 2;\nreturn first $value;");
1407 dump(
1408 "function-redefine",
1409 SchemaOperation::redefine(function_fact.clone(), changed_function).unwrap(),
1410 &empty,
1411 &empty,
1412 &mut output,
1413 );
1414 dump(
1415 "function-undefine",
1416 SchemaOperation::undefine(function_fact),
1417 &empty,
1418 &empty,
1419 &mut output,
1420 );
1421
1422 assert_eq!(
1423 output,
1424 include_str!("../tests/fixtures/lowering-supported-v1.txt")
1425 );
1426 }
1427
1428 #[test]
1429 fn safety_profile_and_capability_rejections_match_golden() {
1430 let empty = SchemaFactCatalog::empty();
1431 let full = full_binding();
1432 let person = type_id(TypeKind::Entity, "person");
1433 let employee = type_id(TypeKind::Entity, "employee");
1434 let sub = SchemaFact::Sub(SubFact::new(
1435 SubFactId::new(employee, person.clone()).unwrap(),
1436 ));
1437 let name = attribute_id("name");
1438 let owns_id = OwnsFactId::new(person.clone(), name).unwrap();
1439 let key = annotation(
1440 AnnotationSubjectId::Owns(owns_id),
1441 AnnotationKindId::Key,
1442 SchemaAnnotationValue::Presence,
1443 );
1444 let type_fact = SchemaFact::Type(TypeFact::new(person).unwrap());
1445 let function_old = function("match\n let $value = 1;\nreturn first $value;");
1446 let function_new = function("match\n let $value = 2;\nreturn first $value;");
1447 let struct_fact = SchemaFact::Struct(
1448 StructFact::new(
1449 StructId::new("record").unwrap(),
1450 vec![StructField::new(
1451 Label::new("field").unwrap(),
1452 ValueTypeTag::Long,
1453 false,
1454 )],
1455 )
1456 .unwrap(),
1457 );
1458 let persistent_function_doc = annotation(
1459 AnnotationSubjectId::Function(FunctionId::new("answer").unwrap()),
1460 AnnotationKindId::Doc,
1461 SchemaAnnotationValue::Doc(DocText::new("docs").unwrap()),
1462 );
1463 let cases = [
1464 (
1465 "conditional",
1466 SchemaOperation::define(vec![sub]).unwrap(),
1467 &full,
1468 ),
1469 (
1470 "backfill",
1471 SchemaOperation::define(vec![key]).unwrap(),
1472 &full,
1473 ),
1474 (
1475 "destructive",
1476 SchemaOperation::undefine(type_fact.clone()),
1477 &full,
1478 ),
1479 (
1480 "opaque",
1481 SchemaOperation::redefine(function_old, function_new).unwrap(),
1482 &full,
1483 ),
1484 (
1485 "struct-unsupported",
1486 SchemaOperation::define(vec![struct_fact]).unwrap(),
1487 &full,
1488 ),
1489 (
1490 "persistent-function-metadata",
1491 SchemaOperation::define(vec![persistent_function_doc]).unwrap(),
1492 &full,
1493 ),
1494 ];
1495 let mut output = String::new();
1496 for (name, operation, binding) in cases {
1497 let error =
1498 lower_operation(0, &operation, &empty, &empty, binding, false, false).unwrap_err();
1499 output.push_str(name);
1500 output.push('|');
1501 output.push_str(error.code());
1502 output.push('\n');
1503 }
1504 let no_capabilities = SchemaLoweringBinding::current(CapabilitySet::new()).unwrap();
1505 let capability_error = lower_operation(
1506 0,
1507 &SchemaOperation::define(vec![type_fact]).unwrap(),
1508 &empty,
1509 &empty,
1510 &no_capabilities,
1511 false,
1512 false,
1513 )
1514 .unwrap_err();
1515 output.push_str("capability|");
1516 output.push_str(capability_error.code());
1517 output.push('\n');
1518 let profile_error = SchemaLoweringBinding::new(
1519 SchemaLoweringProfileId::typedb_3_12_1(),
1520 SchemaLoweringProfileFingerprint::compute(b"wrong profile bytes"),
1521 CapabilitySet::new(),
1522 )
1523 .unwrap_err();
1524 output.push_str("profile|");
1525 output.push_str(profile_error.code());
1526 output.push('\n');
1527 assert_eq!(
1528 output,
1529 include_str!("../tests/fixtures/lowering-rejections-v1.txt")
1530 );
1531 }
1532
1533 #[test]
1534 fn distinct_migration_lowering_is_explicitly_unsupported() {
1535 let person = type_id(TypeKind::Entity, "ordered-person");
1536 let owns = OwnsFactId::new(person, attribute_id("ordered-name")).unwrap();
1537 let distinct = annotation(
1538 AnnotationSubjectId::Owns(owns),
1539 AnnotationKindId::Distinct,
1540 SchemaAnnotationValue::Presence,
1541 );
1542 let operations = [
1543 SchemaOperation::define(vec![distinct.clone()]).unwrap(),
1544 SchemaOperation::undefine(distinct),
1545 ];
1546
1547 for (index, operation) in operations.into_iter().enumerate() {
1548 let error = lower_operation(
1549 index,
1550 &operation,
1551 &SchemaFactCatalog::empty(),
1552 &SchemaFactCatalog::empty(),
1553 &full_binding(),
1554 false,
1555 false,
1556 )
1557 .unwrap_err();
1558 assert_eq!(error.code(), CODE_UNSUPPORTED);
1559 assert_eq!(
1560 error.message(),
1561 "schema transition is unsupported by the TypeDB 3.12.1 lowering profile",
1562 );
1563 assert_eq!(error.operation_index(), Some(index));
1564 assert_eq!(error.safety(), Some(SafetyClass::Unsupported));
1565 assert!(error.missing_capabilities().is_empty());
1566
1567 let render_error = render_operation(
1568 index,
1569 &operation,
1570 &SchemaFactCatalog::empty(),
1571 &SchemaFactCatalog::empty(),
1572 )
1573 .unwrap_err();
1574 assert_eq!(render_error.code(), CODE_UNSUPPORTED);
1575 assert_eq!(render_error.operation_index(), Some(index));
1576 }
1577 }
1578
1579 #[test]
1580 fn equal_default_cardinality_is_formal_only_and_lowers() {
1581 let person = type_id(TypeKind::Entity, "person");
1582 let owns = OwnsFactId::new(person, attribute_id("name")).unwrap();
1583 let card = annotation(
1584 AnnotationSubjectId::Owns(owns),
1585 AnnotationKindId::Card,
1586 SchemaAnnotationValue::Cardinality(Cardinality::new(0, Some(1)).unwrap()),
1587 );
1588 let unit = lower_operation(
1589 0,
1590 &SchemaOperation::undefine(card),
1591 &SchemaFactCatalog::empty(),
1592 &SchemaFactCatalog::empty(),
1593 &full_binding(),
1594 false,
1595 false,
1596 )
1597 .unwrap();
1598 assert_eq!(unit.safety(), SafetyClass::FormalOnly);
1599 assert_eq!(
1600 unit.statements()[0].query(),
1601 "undefine\n@card from person owns name;"
1602 );
1603 }
1604
1605 #[test]
1606 fn safety_gate_matches_exhaustive_golden() {
1607 let mut output = String::new();
1608 for safety in SafetyClass::ALL {
1609 let name = match safety {
1610 SafetyClass::FormalOnly => "formal_only",
1611 SafetyClass::SchemaMetadata => "schema_metadata",
1612 SafetyClass::Additive => "additive",
1613 SafetyClass::Conditional => "conditional",
1614 SafetyClass::BackfillRequired => "backfill_required",
1615 SafetyClass::Destructive => "destructive",
1616 SafetyClass::Opaque => "opaque",
1617 SafetyClass::Unsupported => "unsupported",
1618 };
1619 output.push_str(name);
1620 match gate_safety(7, safety, false, false) {
1621 Ok(()) => output.push_str("|accepted\n"),
1622 Err(error) => {
1623 assert_eq!(error.operation_index(), Some(7));
1624 assert_eq!(error.safety(), Some(safety));
1625 output.push('|');
1626 output.push_str(error.code());
1627 output.push('|');
1628 output.push_str(error.message());
1629 output.push('\n');
1630 }
1631 }
1632 }
1633 assert_eq!(
1634 output,
1635 include_str!("../tests/fixtures/lowering-safety-gate-v1.txt")
1636 );
1637 }
1638
1639 #[test]
1640 fn destructive_gate_opens_only_under_approval() {
1641 assert!(gate_safety(0, SafetyClass::Destructive, false, true).is_ok());
1642 assert!(gate_safety(0, SafetyClass::Opaque, false, true).is_err());
1644 assert!(gate_safety(0, SafetyClass::BackfillRequired, false, true).is_err());
1645 assert!(gate_safety(0, SafetyClass::Unsupported, false, true).is_err());
1646 assert!(gate_safety(0, SafetyClass::Conditional, false, true).is_err());
1647 }
1648
1649 #[test]
1650 fn backfill_gate_requires_verifier_discharge_and_retains_raw_unit_safety() {
1651 let person = type_id(TypeKind::Entity, "person");
1652 let owns = OwnsFactId::new(person, attribute_id("name")).unwrap();
1653 let owns_fact = SchemaFact::Owns(OwnsFact::new(owns.clone()));
1654 let target = SchemaFactCatalog::new([owns_fact]).unwrap();
1655 let key = annotation(
1656 AnnotationSubjectId::Owns(owns),
1657 AnnotationKindId::Key,
1658 SchemaAnnotationValue::Presence,
1659 );
1660 let operation = SchemaOperation::define(vec![key]).unwrap();
1661
1662 let unverified = lower_operation(
1663 0,
1664 &operation,
1665 &SchemaFactCatalog::empty(),
1666 &target,
1667 &full_binding(),
1668 false,
1669 false,
1670 )
1671 .expect_err("an unverified backfill operation must stay closed");
1672 assert_eq!(unverified.code(), CODE_REQUIRES_BACKFILL);
1673
1674 let verified = lower_operation(
1675 0,
1676 &operation,
1677 &SchemaFactCatalog::empty(),
1678 &target,
1679 &full_binding(),
1680 true,
1681 false,
1682 )
1683 .expect("exact verifier discharge admits the operation");
1684 assert_eq!(verified.safety(), SafetyClass::BackfillRequired);
1685
1686 for safety in [
1687 SafetyClass::FormalOnly,
1688 SafetyClass::SchemaMetadata,
1689 SafetyClass::Additive,
1690 SafetyClass::Destructive,
1691 SafetyClass::Opaque,
1692 SafetyClass::Unsupported,
1693 ] {
1694 assert!(gate_safety(0, safety, true, false).is_err());
1695 }
1696 }
1697}