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,
214 Undefine,
215 Redefine,
216}
217
218impl TypeQlVerb {
219 fn as_str(self) -> &'static str {
220 match self {
221 Self::Define => "define",
222 Self::Undefine => "undefine",
223 Self::Redefine => "redefine",
224 }
225 }
226}
227
228#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
230pub struct TypeQlStatement {
231 verb: TypeQlVerb,
232 query: String,
233}
234
235impl TypeQlStatement {
236 fn new(verb: TypeQlVerb, body: String) -> Self {
237 Self {
238 verb,
239 query: format!("{}\n{body}", verb.as_str()),
240 }
241 }
242
243 pub const fn verb(&self) -> TypeQlVerb {
245 self.verb
246 }
247
248 pub fn query(&self) -> &str {
250 &self.query
251 }
252}
253
254#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
256#[serde(rename_all = "snake_case")]
257pub enum StatementOperationKind {
258 Define,
259 Redefine,
260 Undefine,
261}
262
263impl From<SchemaOperationKind> for StatementOperationKind {
264 fn from(value: SchemaOperationKind) -> Self {
265 match value {
266 SchemaOperationKind::Define => Self::Define,
267 SchemaOperationKind::Redefine => Self::Redefine,
268 SchemaOperationKind::Undefine => Self::Undefine,
269 }
270 }
271}
272
273#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
275pub struct StatementUnit {
276 operation_index: usize,
277 operation_kind: StatementOperationKind,
278 safety: SafetyClass,
279 atomic: bool,
280 affected_ids: Vec<SchemaFactId>,
281 required_capabilities: CapabilitySet,
282 statements: Vec<TypeQlStatement>,
283}
284
285impl StatementUnit {
286 pub const fn operation_index(&self) -> usize {
287 self.operation_index
288 }
289
290 pub const fn operation_kind(&self) -> StatementOperationKind {
291 self.operation_kind
292 }
293
294 pub const fn safety(&self) -> SafetyClass {
295 self.safety
296 }
297
298 pub const fn atomic(&self) -> bool {
299 self.atomic
300 }
301
302 pub fn affected_ids(&self) -> &[SchemaFactId] {
303 &self.affected_ids
304 }
305
306 pub const fn required_capabilities(&self) -> &CapabilitySet {
307 &self.required_capabilities
308 }
309
310 pub fn statements(&self) -> &[TypeQlStatement] {
311 &self.statements
312 }
313}
314
315#[derive(Clone, Debug, Eq, PartialEq)]
317pub struct SchemaLoweringPlan {
318 delta: SchemaDelta,
319 profile_id: SchemaLoweringProfileId,
320 profile_fingerprint: SchemaLoweringProfileFingerprint,
321 units: Vec<StatementUnit>,
322}
323
324impl SchemaLoweringPlan {
325 pub const fn delta(&self) -> &SchemaDelta {
326 &self.delta
327 }
328
329 pub const fn profile_id(&self) -> &SchemaLoweringProfileId {
330 &self.profile_id
331 }
332
333 pub const fn profile_fingerprint(&self) -> &SchemaLoweringProfileFingerprint {
334 &self.profile_fingerprint
335 }
336
337 pub fn units(&self) -> &[StatementUnit] {
338 &self.units
339 }
340}
341
342pub fn lower_schema_delta(
344 delta: &SchemaDelta,
345 source_facts: &SchemaFactCatalog,
346 target_facts: &SchemaFactCatalog,
347 binding: &SchemaLoweringBinding,
348) -> Result<SchemaLoweringPlan, SchemaLoweringDiagnostic> {
349 lower_schema_delta_with_verified_assertions(
350 delta,
351 source_facts,
352 target_facts,
353 binding,
354 &[],
355 false,
356 )
357}
358
359pub(crate) fn lower_schema_delta_with_verified_assertions(
360 delta: &SchemaDelta,
361 source_facts: &SchemaFactCatalog,
362 target_facts: &SchemaFactCatalog,
363 binding: &SchemaLoweringBinding,
364 conditional_operation_indices: &[usize],
365 destructive_approved: bool,
366) -> Result<SchemaLoweringPlan, SchemaLoweringDiagnostic> {
367 if !source_facts.matches_selection(delta.source().selection())
368 || !target_facts.matches_selection(delta.target().selection())
369 {
370 return Err(SchemaLoweringDiagnostic::new(
371 CODE_CONTEXT_MISMATCH,
372 "source or target fact catalog does not match the delta managed selection",
373 ));
374 }
375 if conditional_operation_indices
376 .windows(2)
377 .any(|pair| pair[0] >= pair[1])
378 || conditional_operation_indices
379 .last()
380 .is_some_and(|index| *index >= delta.operations().len())
381 {
382 return Err(SchemaLoweringDiagnostic::new(
383 CODE_CONTEXT_MISMATCH,
384 "verified conditional operation indices are not canonical for this delta",
385 ));
386 }
387 let units = delta
388 .operations()
389 .iter()
390 .enumerate()
391 .map(|(index, operation)| {
392 lower_operation(
393 index,
394 operation,
395 source_facts,
396 target_facts,
397 binding,
398 conditional_operation_indices.binary_search(&index).is_ok(),
399 destructive_approved,
400 )
401 })
402 .collect::<Result<Vec<_>, _>>()?;
403 Ok(SchemaLoweringPlan {
404 delta: delta.clone(),
405 profile_id: binding.profile_id.clone(),
406 profile_fingerprint: binding.profile_fingerprint.clone(),
407 units,
408 })
409}
410
411fn lower_operation(
412 operation_index: usize,
413 operation: &SchemaOperation,
414 source_facts: &SchemaFactCatalog,
415 target_facts: &SchemaFactCatalog,
416 binding: &SchemaLoweringBinding,
417 conditional_resolved: bool,
418 destructive_approved: bool,
419) -> Result<StatementUnit, SchemaLoweringDiagnostic> {
420 let classification = classify_operation_transition(operation).map_err(|error| {
421 SchemaLoweringDiagnostic::new(CODE_INVALID_TRANSITION, error.message())
422 .at_operation(operation_index)
423 })?;
424 let missing = classification
425 .required_capabilities
426 .iter()
427 .filter(|capability| !binding.available_capabilities.contains(capability))
428 .cloned()
429 .collect::<Vec<_>>();
430 if !missing.is_empty() {
431 return Err(SchemaLoweringDiagnostic::new(
432 CODE_CAPABILITY_MISMATCH,
433 "provider capabilities do not satisfy the schema lowering unit",
434 )
435 .at_operation(operation_index)
436 .with_missing_capabilities(missing));
437 }
438 gate_safety(
439 operation_index,
440 classification.safety,
441 conditional_resolved,
442 destructive_approved,
443 )?;
444 let statements = render_operation(operation_index, operation, source_facts, target_facts)?;
445 Ok(StatementUnit {
446 operation_index,
447 operation_kind: operation.kind().into(),
448 safety: classification.safety,
449 atomic: classification.atomic,
450 affected_ids: operation.affected_ids(),
451 required_capabilities: classification.required_capabilities,
452 statements,
453 })
454}
455
456fn gate_safety(
457 operation_index: usize,
458 safety: SafetyClass,
459 conditional_resolved: bool,
460 destructive_approved: bool,
461) -> Result<(), SchemaLoweringDiagnostic> {
462 if conditional_resolved && safety != SafetyClass::Conditional {
463 return Err(SchemaLoweringDiagnostic::new(
464 CODE_INVALID_TRANSITION,
465 "assertion coverage targets a non-conditional schema operation",
466 )
467 .at_operation(operation_index)
468 .with_safety(safety));
469 }
470 let (code, message) = match safety {
471 SafetyClass::FormalOnly | SafetyClass::SchemaMetadata | SafetyClass::Additive => {
472 return Ok(());
473 }
474 SafetyClass::Conditional if conditional_resolved => return Ok(()),
475 SafetyClass::Conditional => (
476 CODE_REQUIRES_ASSERTION,
477 "schema transition requires an explicit data assertion",
478 ),
479 SafetyClass::BackfillRequired => (
480 CODE_REQUIRES_BACKFILL,
481 "schema transition requires an explicit backfill plan",
482 ),
483 SafetyClass::Destructive if destructive_approved => return Ok(()),
484 SafetyClass::Destructive => (
485 CODE_DESTRUCTIVE,
486 "destructive schema transition requires an identity-bound approval",
487 ),
488 SafetyClass::Opaque => (
489 CODE_OPAQUE,
490 "opaque schema transition requires explicit operator intent",
491 ),
492 SafetyClass::Unsupported => (
493 CODE_UNSUPPORTED,
494 "schema transition is unsupported by the TypeDB 3.12.1 lowering profile",
495 ),
496 };
497 Err(SchemaLoweringDiagnostic::new(code, message)
498 .at_operation(operation_index)
499 .with_safety(safety))
500}
501
502#[derive(Debug)]
503struct RenderFailure {
504 code: &'static str,
505 message: &'static str,
506}
507
508fn render_operation(
509 operation_index: usize,
510 operation: &SchemaOperation,
511 source_facts: &SchemaFactCatalog,
512 target_facts: &SchemaFactCatalog,
513) -> Result<Vec<TypeQlStatement>, SchemaLoweringDiagnostic> {
514 render_operation_inner(operation, source_facts, target_facts).map_err(|failure| {
515 SchemaLoweringDiagnostic::new(failure.code, failure.message).at_operation(operation_index)
516 })
517}
518
519fn render_operation_inner(
520 operation: &SchemaOperation,
521 source_facts: &SchemaFactCatalog,
522 target_facts: &SchemaFactCatalog,
523) -> Result<Vec<TypeQlStatement>, RenderFailure> {
524 match operation.kind() {
525 SchemaOperationKind::Define => {
526 let facts = operation.defined_facts().expect("define exposes facts");
527 let function_ids = facts
528 .iter()
529 .filter_map(|fact| match fact {
530 SchemaFact::Function(function) => Some(function.id().clone()),
531 _ => None,
532 })
533 .collect::<BTreeSet<_>>();
534 let mut function_annotations = BTreeMap::<FunctionId, Vec<&AnnotationFact>>::new();
535 for fact in facts {
536 if let SchemaFact::Annotation(annotation) = fact
537 && let AnnotationSubjectId::Function(function) = annotation.id().subject()
538 && function_ids.contains(function)
539 {
540 function_annotations
541 .entry(function.clone())
542 .or_default()
543 .push(annotation);
544 }
545 }
546 let mut bodies = Vec::new();
547 for fact in facts {
548 match fact {
549 SchemaFact::Annotation(annotation) if matches!(annotation.id().subject(), AnnotationSubjectId::Function(id) if function_ids.contains(id)) =>
550 {}
551 SchemaFact::Function(function) => bodies.push(render_function(
552 function,
553 function_annotations
554 .get(function.id())
555 .map(Vec::as_slice)
556 .unwrap_or_default(),
557 )?),
558 _ => bodies.push(render_definition(fact, target_facts, true)?),
559 }
560 }
561 Ok(vec![TypeQlStatement::new(
562 TypeQlVerb::Define,
563 bodies.join("\n"),
564 )])
565 }
566 SchemaOperationKind::Undefine => Ok(vec![TypeQlStatement::new(
567 TypeQlVerb::Undefine,
568 render_undefinition(
569 operation.undefined_fact().expect("undefine exposes fact"),
570 source_facts,
571 )?,
572 )]),
573 SchemaOperationKind::Redefine => {
574 let expected = operation
575 .expected_fact()
576 .expect("redefine exposes expected");
577 let replacement = operation
578 .replacement_fact()
579 .expect("redefine exposes replacement");
580 if let (SchemaFact::Annotation(old), SchemaFact::Annotation(new)) =
581 (expected, replacement)
582 && matches!(old.id().subject(), AnnotationSubjectId::Sub(_))
583 && matches!(
584 old.id().kind(),
585 AnnotationKindId::Doc | AnnotationKindId::Meta(_)
586 )
587 {
588 return Ok(vec![
589 TypeQlStatement::new(
590 TypeQlVerb::Undefine,
591 render_annotation_undefinition(old, source_facts)?,
592 ),
593 TypeQlStatement::new(
594 TypeQlVerb::Define,
595 render_annotation_definition(new, target_facts, false)?,
596 ),
597 ]);
598 }
599 if let (SchemaFact::Relates(old), SchemaFact::Relates(new)) = (expected, replacement) {
600 return match (old.specializes(), new.specializes()) {
601 (None, Some(_)) => Ok(vec![TypeQlStatement::new(
602 TypeQlVerb::Define,
603 render_relates(new),
604 )]),
605 (Some(old_parent), None) => Ok(vec![TypeQlStatement::new(
606 TypeQlVerb::Undefine,
607 format!(
608 "as {} from {} relates {};",
609 old_parent.label().as_str(),
610 old.id().relation().label().as_str(),
611 old.id().role().label().as_str()
612 ),
613 )]),
614 (Some(_), Some(_)) => Ok(vec![TypeQlStatement::new(
615 TypeQlVerb::Redefine,
616 render_relates(new),
617 )]),
618 (None, None) => Err(RenderFailure {
619 code: CODE_INVALID_TRANSITION,
620 message: "relates redefinition does not change specialization",
621 }),
622 };
623 }
624 Ok(vec![TypeQlStatement::new(
625 TypeQlVerb::Redefine,
626 render_definition(replacement, target_facts, false)?,
627 )])
628 }
629 }
630}
631
632fn render_definition(
633 fact: &SchemaFact,
634 catalog: &SchemaFactCatalog,
635 defining: bool,
636) -> Result<String, RenderFailure> {
637 match fact {
638 SchemaFact::Type(fact) => Ok(format!(
639 "{} {};",
640 type_kind(fact.id().kind()),
641 fact.id().label().as_str()
642 )),
643 SchemaFact::Sub(fact) => Ok(format!(
644 "{} sub {};",
645 fact.id().subtype().label().as_str(),
646 fact.id().supertype().label().as_str()
647 )),
648 SchemaFact::Value(fact) => Ok(format!(
649 "{} value {};",
650 fact.id().attribute().label().as_str(),
651 value_type(fact.value_type())
652 )),
653 SchemaFact::Owns(fact) => Ok(format!(
654 "{} owns {};",
655 fact.id().owner().label().as_str(),
656 fact.id().attribute().label().as_str()
657 )),
658 SchemaFact::Relates(fact) => Ok(render_relates(fact)),
659 SchemaFact::Plays(fact) => Ok(format!(
660 "{} plays {}:{};",
661 fact.id().player().label().as_str(),
662 fact.id().role().declaring_relation().as_str(),
663 fact.id().role().label().as_str()
664 )),
665 SchemaFact::Annotation(fact) => render_annotation_definition(fact, catalog, defining),
666 SchemaFact::Function(fact) => render_function(fact, &[]),
667 SchemaFact::Struct(_) => Err(RenderFailure {
668 code: CODE_UNSUPPORTED,
669 message: "TypeDB 3.12.1 does not admit the pinned struct transition grammar",
670 }),
671 }
672}
673
674fn render_undefinition(
675 fact: &SchemaFact,
676 catalog: &SchemaFactCatalog,
677) -> Result<String, RenderFailure> {
678 match fact {
679 SchemaFact::Type(fact) => Ok(format!("{};", fact.id().label().as_str())),
682 SchemaFact::Sub(fact) => Ok(format!(
683 "sub {} from {};",
684 fact.id().supertype().label().as_str(),
685 fact.id().subtype().label().as_str()
686 )),
687 SchemaFact::Value(fact) => Ok(format!(
688 "value {} from {};",
689 value_type(fact.value_type()),
690 fact.id().attribute().label().as_str()
691 )),
692 SchemaFact::Owns(fact) => Ok(format!(
693 "owns {} from {};",
694 fact.id().attribute().label().as_str(),
695 fact.id().owner().label().as_str()
696 )),
697 SchemaFact::Relates(fact) => Ok(format!(
698 "relates {} from {};",
699 fact.id().role().label().as_str(),
700 fact.id().relation().label().as_str()
701 )),
702 SchemaFact::Plays(fact) => Ok(format!(
703 "plays {}:{} from {};",
704 fact.id().role().declaring_relation().as_str(),
705 fact.id().role().label().as_str(),
706 fact.id().player().label().as_str()
707 )),
708 SchemaFact::Annotation(fact) => render_annotation_undefinition(fact, catalog),
709 SchemaFact::Function(fact) => Ok(format!("fun {};", fact.id().label().as_str())),
710 SchemaFact::Struct(_) => Err(RenderFailure {
711 code: CODE_UNSUPPORTED,
712 message: "TypeDB 3.12.1 does not admit the pinned struct transition grammar",
713 }),
714 }
715}
716
717fn render_relates(fact: &RelatesFact) -> String {
718 let specializes = fact
719 .specializes()
720 .map(|role| format!(" as {}", role.label().as_str()))
721 .unwrap_or_default();
722 format!(
723 "{} relates {}{};",
724 fact.id().relation().label().as_str(),
725 fact.id().role().label().as_str(),
726 specializes
727 )
728}
729
730fn render_annotation_definition(
731 annotation: &AnnotationFact,
732 catalog: &SchemaFactCatalog,
733 defining: bool,
734) -> Result<String, RenderFailure> {
735 let subject = render_annotation_subject(annotation.id().subject(), catalog, defining)?;
736 Ok(format!("{subject} {};", render_annotation(annotation)))
737}
738
739fn render_annotation_undefinition(
740 annotation: &AnnotationFact,
741 catalog: &SchemaFactCatalog,
742) -> Result<String, RenderFailure> {
743 let subject = render_annotation_subject(annotation.id().subject(), catalog, false)?;
744 Ok(format!(
745 "{} from {subject};",
746 render_annotation_selector(annotation.id().kind())
747 ))
748}
749
750fn render_annotation_subject(
751 subject: &AnnotationSubjectId,
752 catalog: &SchemaFactCatalog,
753 defining: bool,
754) -> Result<String, RenderFailure> {
755 match subject {
756 AnnotationSubjectId::Type(id) if defining => {
757 Ok(format!("{} {}", type_kind(id.kind()), id.label().as_str()))
758 }
759 AnnotationSubjectId::Type(id) => Ok(id.label().as_str().to_owned()),
760 AnnotationSubjectId::Sub(id) => Ok(format!(
761 "{} sub {}",
762 id.subtype().label().as_str(),
763 id.supertype().label().as_str()
764 )),
765 AnnotationSubjectId::Value(id) => {
766 let fact_id = SchemaFactId::Value(id.clone());
767 let Some(SchemaFact::Value(value)) = catalog.get(&fact_id) else {
768 return Err(RenderFailure {
769 code: CODE_RENDER_CONTEXT,
770 message: "value annotation rendering requires its exact value fact payload",
771 });
772 };
773 Ok(format!(
774 "{} value {}",
775 id.attribute().label().as_str(),
776 value_type(value.value_type())
777 ))
778 }
779 AnnotationSubjectId::Owns(id) => Ok(format!(
780 "{} owns {}",
781 id.owner().label().as_str(),
782 id.attribute().label().as_str()
783 )),
784 AnnotationSubjectId::Relates(id) => Ok(format!(
785 "{} relates {}",
786 id.relation().label().as_str(),
787 id.role().label().as_str()
788 )),
789 AnnotationSubjectId::Plays(id) => Ok(format!(
790 "{} plays {}:{}",
791 id.player().label().as_str(),
792 id.role().declaring_relation().as_str(),
793 id.role().label().as_str()
794 )),
795 AnnotationSubjectId::Function(_) => Err(RenderFailure {
796 code: CODE_UNSUPPORTED,
797 message: "persistent function annotations are unsupported; fold metadata into function definition",
798 }),
799 }
800}
801
802fn render_annotation(annotation: &AnnotationFact) -> String {
803 match (annotation.id().kind(), annotation.value()) {
804 (AnnotationKindId::Abstract, SchemaAnnotationValue::Presence) => "@abstract".into(),
805 (AnnotationKindId::Independent, SchemaAnnotationValue::Presence) => "@independent".into(),
806 (AnnotationKindId::Key, SchemaAnnotationValue::Presence) => "@key".into(),
807 (AnnotationKindId::Unique, SchemaAnnotationValue::Presence) => "@unique".into(),
808 (AnnotationKindId::Card, SchemaAnnotationValue::Cardinality(cardinality)) => format!(
809 "@card({}..{})",
810 (*cardinality).min(),
811 (*cardinality)
812 .max()
813 .map(|value| value.to_string())
814 .unwrap_or_default()
815 ),
816 (AnnotationKindId::Regex, SchemaAnnotationValue::Regex(regex)) => {
817 format!("@regex({})", quote(regex.as_str()))
818 }
819 (AnnotationKindId::Range, SchemaAnnotationValue::Range(range)) => format!(
820 "@range({}..{})",
821 range.lower().map(render_value).unwrap_or_default(),
822 range.upper().map(render_value).unwrap_or_default()
823 ),
824 (AnnotationKindId::Values, SchemaAnnotationValue::Values(values)) => format!(
825 "@values({})",
826 values
827 .iter()
828 .map(render_value)
829 .collect::<Vec<_>>()
830 .join(", ")
831 ),
832 (AnnotationKindId::Doc, SchemaAnnotationValue::Doc(doc)) => {
833 format!("@doc({})", quote(doc.as_str()))
834 }
835 (AnnotationKindId::Meta(key), SchemaAnnotationValue::Meta(value)) => {
836 format!("@meta({}, {})", quote(key.as_str()), render_value(value))
837 }
838 _ => unreachable!("annotation constructors preserve kind-safe payloads"),
839 }
840}
841
842fn render_annotation_selector(kind: &AnnotationKindId) -> String {
843 match kind {
844 AnnotationKindId::Abstract => "@abstract".into(),
845 AnnotationKindId::Independent => "@independent".into(),
846 AnnotationKindId::Key => "@key".into(),
847 AnnotationKindId::Unique => "@unique".into(),
848 AnnotationKindId::Card => "@card".into(),
849 AnnotationKindId::Regex => "@regex".into(),
850 AnnotationKindId::Range => "@range".into(),
851 AnnotationKindId::Values => "@values".into(),
852 AnnotationKindId::Doc => "@doc".into(),
853 AnnotationKindId::Meta(key) => format!("@meta({})", quote(key.as_str())),
854 }
855}
856
857fn render_function(
858 function: &FunctionFact,
859 annotations: &[&AnnotationFact],
860) -> Result<String, RenderFailure> {
861 let parameters = function
862 .signature()
863 .parameters()
864 .iter()
865 .map(|parameter| {
866 format!(
867 "${}: {}",
868 parameter.name().as_str(),
869 render_type_reference(parameter.type_ref())
870 )
871 })
872 .collect::<Vec<_>>()
873 .join(", ");
874 let returns = match function.signature().returns() {
875 FunctionReturnMode::Scalar(element) => render_return_element(element),
876 FunctionReturnMode::Tuple(elements) => format!(
877 "({})",
878 elements
879 .iter()
880 .map(render_return_element)
881 .collect::<Vec<_>>()
882 .join(", ")
883 ),
884 FunctionReturnMode::Stream(elements) => format!(
885 "{{ {} }}",
886 elements
887 .iter()
888 .map(render_return_element)
889 .collect::<Vec<_>>()
890 .join(", ")
891 ),
892 };
893 let mut rendered_annotations = annotations
894 .iter()
895 .map(|annotation| render_annotation(annotation))
896 .collect::<Vec<_>>();
897 rendered_annotations.sort();
898 let suffix = if rendered_annotations.is_empty() {
899 String::new()
900 } else {
901 format!(" {}", rendered_annotations.join(" "))
902 };
903 Ok(format!(
904 "fun {}({parameters}) -> {returns}{suffix}:\n{}",
905 function.id().label().as_str(),
906 function.body().text()
907 ))
908}
909
910fn render_return_element(element: &FunctionReturnElement) -> String {
911 format!(
912 "{}{}",
913 render_type_reference(element.type_ref()),
914 if element.optional() { "?" } else { "" }
915 )
916}
917
918fn render_type_reference(reference: &TypeReference) -> String {
919 match reference {
920 TypeReference::Value(value) => value_type(*value).into(),
921 TypeReference::Schema(label) => label.as_str().into(),
922 }
923}
924
925fn render_value(value: &CanonicalValue) -> String {
926 match value {
927 CanonicalValue::String(value) => quote(value.as_str()),
928 CanonicalValue::Long(value) => value.to_string(),
929 CanonicalValue::Double(value) => format!("{:?}", value.get()),
930 CanonicalValue::Boolean(value) => value.to_string(),
931 CanonicalValue::Date(value) => value.to_string(),
932 CanonicalValue::DateTime(value) => value.to_string(),
933 CanonicalValue::DateTimeTz(value) => value.to_string(),
934 CanonicalValue::Decimal(value) => format!("{}dec", value.as_str()),
935 CanonicalValue::Duration(value) => value.to_string(),
936 }
937}
938
939fn quote(value: &str) -> String {
940 serde_json::to_string(value).expect("strings always serialize")
941}
942
943fn type_kind(kind: TypeKind) -> &'static str {
944 match kind {
945 TypeKind::Entity => "entity",
946 TypeKind::Relation => "relation",
947 TypeKind::Attribute => "attribute",
948 TypeKind::Struct => "struct",
949 }
950}
951
952fn value_type(value: ValueTypeTag) -> &'static str {
953 match value.as_str() {
954 "long" => "integer",
955 "datetime_tz" => "datetime-tz",
956 other => other,
957 }
958}
959
960#[cfg(test)]
961mod tests {
962 use super::*;
963 use type_bridge_contract::id::{AttributeId, Label, RoleId, StructId, TypeId};
964 use type_bridge_contract::schema::{
965 AnnotationFactId, CanonicalValueRange, CanonicalValueSet, DocText, FunctionBody,
966 FunctionParameter, FunctionSignature, OwnsFact, OwnsFactId, PlaysFact, PlaysFactId,
967 RegexPattern, RelatesFactId, SchemaOperation, StructFact, StructField, SubFact, SubFactId,
968 TypeFact, ValueFact, ValueFactId,
969 };
970 use type_bridge_contract::value::{CanonicalString, Cardinality};
971
972 fn type_id(kind: TypeKind, label: &str) -> TypeId {
973 TypeId::new(kind, label).unwrap()
974 }
975
976 fn attribute_id(label: &str) -> AttributeId {
977 AttributeId::new(label).unwrap()
978 }
979
980 fn role_id(relation: &str, role: &str) -> RoleId {
981 RoleId::new(relation, role).unwrap()
982 }
983
984 fn value_fact(label: &str, value_type: ValueTypeTag) -> SchemaFact {
985 SchemaFact::Value(ValueFact::new(
986 ValueFactId::new(attribute_id(label)),
987 value_type,
988 ))
989 }
990
991 fn annotation(
992 subject: AnnotationSubjectId,
993 kind: AnnotationKindId,
994 value: SchemaAnnotationValue,
995 ) -> SchemaFact {
996 SchemaFact::Annotation(
997 AnnotationFact::new(AnnotationFactId::new(subject, kind), value).unwrap(),
998 )
999 }
1000
1001 fn function(body: &str) -> SchemaFact {
1002 SchemaFact::Function(FunctionFact::new(
1003 FunctionId::new("answer").unwrap(),
1004 FunctionSignature::new(
1005 vec![FunctionParameter::new(
1006 Label::new("seed").unwrap(),
1007 TypeReference::Value(ValueTypeTag::Long),
1008 )],
1009 FunctionReturnMode::scalar(FunctionReturnElement::new(
1010 TypeReference::Value(ValueTypeTag::Long),
1011 false,
1012 )),
1013 )
1014 .unwrap(),
1015 FunctionBody::new(body).unwrap(),
1016 ))
1017 }
1018
1019 fn full_binding() -> SchemaLoweringBinding {
1020 SchemaLoweringBinding::current(typedb_3_12_1_profile().required_capabilities.clone())
1021 .unwrap()
1022 }
1023
1024 fn dump(
1025 name: &str,
1026 operation: SchemaOperation,
1027 source: &SchemaFactCatalog,
1028 target: &SchemaFactCatalog,
1029 output: &mut String,
1030 ) {
1031 output.push_str("## ");
1032 output.push_str(name);
1033 output.push('\n');
1034 for (index, statement) in render_operation_inner(&operation, source, target)
1035 .unwrap()
1036 .iter()
1037 .enumerate()
1038 {
1039 if index != 0 {
1040 output.push_str("-- atomic-next --\n");
1041 }
1042 output.push_str(statement.query());
1043 output.push('\n');
1044 }
1045 }
1046
1047 #[test]
1048 fn supported_renderer_matches_exhaustive_golden() {
1049 let empty = SchemaFactCatalog::empty();
1050 let person = type_id(TypeKind::Entity, "person");
1051 let employee = type_id(TypeKind::Entity, "employee");
1052 let name = attribute_id("name");
1053 let owns_id = OwnsFactId::new(person.clone(), name.clone()).unwrap();
1054 let relation = type_id(TypeKind::Relation, "friendship");
1055 let role = role_id("friendship", "friend");
1056 let relates_id = RelatesFactId::new(relation.clone(), role.clone()).unwrap();
1057 let child_relation = type_id(TypeKind::Relation, "child-relation");
1058 let child_role = role_id("child-relation", "child-role");
1059 let child_relates = RelatesFactId::new(child_relation.clone(), child_role.clone()).unwrap();
1060 let parent_a = role_id("parent-relation", "parent-role-a");
1061 let parent_b = role_id("parent-relation", "parent-role-b");
1062 let plays_id = PlaysFactId::new(person.clone(), role.clone()).unwrap();
1063 let sub_id = SubFactId::new(employee.clone(), person.clone()).unwrap();
1064 let string_value = value_fact("name", ValueTypeTag::String);
1065 let integer_value = value_fact("name", ValueTypeTag::Long);
1066 let string_catalog = SchemaFactCatalog::new([string_value.clone()]).unwrap();
1067 let integer_catalog = SchemaFactCatalog::new([integer_value.clone()]).unwrap();
1068 let mut output = String::new();
1069
1070 let type_fact = SchemaFact::Type(TypeFact::new(person.clone()).unwrap());
1071 dump(
1072 "type-define",
1073 SchemaOperation::define(vec![type_fact.clone()]).unwrap(),
1074 &empty,
1075 &empty,
1076 &mut output,
1077 );
1078 dump(
1079 "type-undefine",
1080 SchemaOperation::undefine(type_fact),
1081 &empty,
1082 &empty,
1083 &mut output,
1084 );
1085 let sub_fact = SchemaFact::Sub(SubFact::new(sub_id.clone()));
1086 dump(
1087 "sub-define",
1088 SchemaOperation::define(vec![sub_fact.clone()]).unwrap(),
1089 &empty,
1090 &empty,
1091 &mut output,
1092 );
1093 dump(
1094 "sub-undefine",
1095 SchemaOperation::undefine(sub_fact),
1096 &empty,
1097 &empty,
1098 &mut output,
1099 );
1100 dump(
1101 "value-define",
1102 SchemaOperation::define(vec![string_value.clone()]).unwrap(),
1103 &empty,
1104 &string_catalog,
1105 &mut output,
1106 );
1107 dump(
1108 "value-redefine",
1109 SchemaOperation::redefine(string_value.clone(), integer_value.clone()).unwrap(),
1110 &string_catalog,
1111 &integer_catalog,
1112 &mut output,
1113 );
1114 dump(
1115 "value-undefine",
1116 SchemaOperation::undefine(string_value.clone()),
1117 &string_catalog,
1118 &empty,
1119 &mut output,
1120 );
1121 let owns = SchemaFact::Owns(OwnsFact::new(owns_id.clone()));
1122 dump(
1123 "owns-define",
1124 SchemaOperation::define(vec![owns.clone()]).unwrap(),
1125 &empty,
1126 &empty,
1127 &mut output,
1128 );
1129 dump(
1130 "owns-undefine",
1131 SchemaOperation::undefine(owns),
1132 &empty,
1133 &empty,
1134 &mut output,
1135 );
1136 let relates = SchemaFact::Relates(RelatesFact::new(relates_id, None).unwrap());
1137 dump(
1138 "relates-define",
1139 SchemaOperation::define(vec![relates.clone()]).unwrap(),
1140 &empty,
1141 &empty,
1142 &mut output,
1143 );
1144 dump(
1145 "relates-undefine",
1146 SchemaOperation::undefine(relates),
1147 &empty,
1148 &empty,
1149 &mut output,
1150 );
1151 let specialization_a = SchemaFact::Relates(
1152 RelatesFact::new(child_relates.clone(), Some(parent_a.clone())).unwrap(),
1153 );
1154 let specialization_b = SchemaFact::Relates(
1155 RelatesFact::new(child_relates.clone(), Some(parent_b.clone())).unwrap(),
1156 );
1157 let unspecialized = SchemaFact::Relates(RelatesFact::new(child_relates, None).unwrap());
1158 dump(
1159 "specialization-define",
1160 SchemaOperation::redefine(unspecialized.clone(), specialization_a.clone()).unwrap(),
1161 &empty,
1162 &empty,
1163 &mut output,
1164 );
1165 dump(
1166 "specialization-redefine",
1167 SchemaOperation::redefine(specialization_a.clone(), specialization_b.clone()).unwrap(),
1168 &empty,
1169 &empty,
1170 &mut output,
1171 );
1172 dump(
1173 "specialization-undefine",
1174 SchemaOperation::redefine(specialization_b, unspecialized).unwrap(),
1175 &empty,
1176 &empty,
1177 &mut output,
1178 );
1179 let plays = SchemaFact::Plays(PlaysFact::new(plays_id));
1180 dump(
1181 "plays-define",
1182 SchemaOperation::define(vec![plays.clone()]).unwrap(),
1183 &empty,
1184 &empty,
1185 &mut output,
1186 );
1187 dump(
1188 "plays-undefine",
1189 SchemaOperation::undefine(plays),
1190 &empty,
1191 &empty,
1192 &mut output,
1193 );
1194
1195 let doc_old = annotation(
1196 AnnotationSubjectId::Type(person.clone()),
1197 AnnotationKindId::Doc,
1198 SchemaAnnotationValue::Doc(DocText::new("line\n\"quoted\"").unwrap()),
1199 );
1200 let doc_new = annotation(
1201 AnnotationSubjectId::Type(person.clone()),
1202 AnnotationKindId::Doc,
1203 SchemaAnnotationValue::Doc(DocText::new("changed").unwrap()),
1204 );
1205 dump(
1206 "doc-define",
1207 SchemaOperation::define(vec![doc_old.clone()]).unwrap(),
1208 &empty,
1209 &empty,
1210 &mut output,
1211 );
1212 dump(
1213 "doc-redefine",
1214 SchemaOperation::redefine(doc_old.clone(), doc_new).unwrap(),
1215 &empty,
1216 &empty,
1217 &mut output,
1218 );
1219 dump(
1220 "doc-undefine",
1221 SchemaOperation::undefine(doc_old),
1222 &empty,
1223 &empty,
1224 &mut output,
1225 );
1226 let regex_old = annotation(
1227 AnnotationSubjectId::Value(ValueFactId::new(name.clone())),
1228 AnnotationKindId::Regex,
1229 SchemaAnnotationValue::Regex(RegexPattern::new("^a+\\\\d$").unwrap()),
1230 );
1231 let regex_new = annotation(
1232 AnnotationSubjectId::Value(ValueFactId::new(name.clone())),
1233 AnnotationKindId::Regex,
1234 SchemaAnnotationValue::Regex(RegexPattern::new("^b+$").unwrap()),
1235 );
1236 dump(
1237 "regex-redefine",
1238 SchemaOperation::redefine(regex_old, regex_new).unwrap(),
1239 &string_catalog,
1240 &string_catalog,
1241 &mut output,
1242 );
1243 let card_old = annotation(
1244 AnnotationSubjectId::Owns(owns_id.clone()),
1245 AnnotationKindId::Card,
1246 SchemaAnnotationValue::Cardinality(Cardinality::new(0, Some(1)).unwrap()),
1247 );
1248 let card_new = annotation(
1249 AnnotationSubjectId::Owns(owns_id),
1250 AnnotationKindId::Card,
1251 SchemaAnnotationValue::Cardinality(Cardinality::new(0, Some(2)).unwrap()),
1252 );
1253 dump(
1254 "card-redefine",
1255 SchemaOperation::redefine(card_old, card_new).unwrap(),
1256 &empty,
1257 &empty,
1258 &mut output,
1259 );
1260 let range = annotation(
1261 AnnotationSubjectId::Value(ValueFactId::new(name.clone())),
1262 AnnotationKindId::Range,
1263 SchemaAnnotationValue::Range(
1264 CanonicalValueRange::new(
1265 Some(CanonicalValue::Long(1)),
1266 Some(CanonicalValue::Long(10)),
1267 )
1268 .unwrap(),
1269 ),
1270 );
1271 dump(
1272 "range-define",
1273 SchemaOperation::define(vec![range]).unwrap(),
1274 &empty,
1275 &string_catalog,
1276 &mut output,
1277 );
1278 let values = annotation(
1279 AnnotationSubjectId::Value(ValueFactId::new(name)),
1280 AnnotationKindId::Values,
1281 SchemaAnnotationValue::Values(
1282 CanonicalValueSet::new([CanonicalValue::Long(2), CanonicalValue::Long(1)]).unwrap(),
1283 ),
1284 );
1285 dump(
1286 "values-define",
1287 SchemaOperation::define(vec![values]).unwrap(),
1288 &empty,
1289 &integer_catalog,
1290 &mut output,
1291 );
1292 let meta_old = annotation(
1293 AnnotationSubjectId::Sub(sub_id.clone()),
1294 AnnotationKindId::meta("owner").unwrap(),
1295 SchemaAnnotationValue::Meta(CanonicalValue::String(
1296 CanonicalString::new("old").unwrap(),
1297 )),
1298 );
1299 let meta_new = annotation(
1300 AnnotationSubjectId::Sub(sub_id),
1301 AnnotationKindId::meta("owner").unwrap(),
1302 SchemaAnnotationValue::Meta(CanonicalValue::String(
1303 CanonicalString::new("new").unwrap(),
1304 )),
1305 );
1306 dump(
1307 "sub-meta-fallback",
1308 SchemaOperation::redefine(meta_old.clone(), meta_new).unwrap(),
1309 &empty,
1310 &empty,
1311 &mut output,
1312 );
1313 dump(
1314 "meta-keyed-undefine",
1315 SchemaOperation::undefine(meta_old),
1316 &empty,
1317 &empty,
1318 &mut output,
1319 );
1320
1321 let function_fact = function("match\n let $value = $seed;\nreturn first $value;");
1322 let function_doc = annotation(
1323 AnnotationSubjectId::Function(FunctionId::new("answer").unwrap()),
1324 AnnotationKindId::Doc,
1325 SchemaAnnotationValue::Doc(DocText::new("answer docs").unwrap()),
1326 );
1327 let function_meta = annotation(
1328 AnnotationSubjectId::Function(FunctionId::new("answer").unwrap()),
1329 AnnotationKindId::meta("owner").unwrap(),
1330 SchemaAnnotationValue::Meta(CanonicalValue::String(
1331 CanonicalString::new("core").unwrap(),
1332 )),
1333 );
1334 dump(
1335 "function-define-with-metadata",
1336 SchemaOperation::define(vec![function_meta, function_fact.clone(), function_doc])
1337 .unwrap(),
1338 &empty,
1339 &empty,
1340 &mut output,
1341 );
1342 let changed_function = function("match\n let $value = 2;\nreturn first $value;");
1343 dump(
1344 "function-redefine",
1345 SchemaOperation::redefine(function_fact.clone(), changed_function).unwrap(),
1346 &empty,
1347 &empty,
1348 &mut output,
1349 );
1350 dump(
1351 "function-undefine",
1352 SchemaOperation::undefine(function_fact),
1353 &empty,
1354 &empty,
1355 &mut output,
1356 );
1357
1358 assert_eq!(
1359 output,
1360 include_str!("../tests/fixtures/lowering-supported-v1.txt")
1361 );
1362 }
1363
1364 #[test]
1365 fn safety_profile_and_capability_rejections_match_golden() {
1366 let empty = SchemaFactCatalog::empty();
1367 let full = full_binding();
1368 let person = type_id(TypeKind::Entity, "person");
1369 let employee = type_id(TypeKind::Entity, "employee");
1370 let sub = SchemaFact::Sub(SubFact::new(
1371 SubFactId::new(employee, person.clone()).unwrap(),
1372 ));
1373 let name = attribute_id("name");
1374 let owns_id = OwnsFactId::new(person.clone(), name).unwrap();
1375 let key = annotation(
1376 AnnotationSubjectId::Owns(owns_id),
1377 AnnotationKindId::Key,
1378 SchemaAnnotationValue::Presence,
1379 );
1380 let type_fact = SchemaFact::Type(TypeFact::new(person).unwrap());
1381 let function_old = function("match\n let $value = 1;\nreturn first $value;");
1382 let function_new = function("match\n let $value = 2;\nreturn first $value;");
1383 let struct_fact = SchemaFact::Struct(
1384 StructFact::new(
1385 StructId::new("record").unwrap(),
1386 vec![StructField::new(
1387 Label::new("field").unwrap(),
1388 ValueTypeTag::Long,
1389 false,
1390 )],
1391 )
1392 .unwrap(),
1393 );
1394 let persistent_function_doc = annotation(
1395 AnnotationSubjectId::Function(FunctionId::new("answer").unwrap()),
1396 AnnotationKindId::Doc,
1397 SchemaAnnotationValue::Doc(DocText::new("docs").unwrap()),
1398 );
1399 let cases = [
1400 (
1401 "conditional",
1402 SchemaOperation::define(vec![sub]).unwrap(),
1403 &full,
1404 ),
1405 (
1406 "backfill",
1407 SchemaOperation::define(vec![key]).unwrap(),
1408 &full,
1409 ),
1410 (
1411 "destructive",
1412 SchemaOperation::undefine(type_fact.clone()),
1413 &full,
1414 ),
1415 (
1416 "opaque",
1417 SchemaOperation::redefine(function_old, function_new).unwrap(),
1418 &full,
1419 ),
1420 (
1421 "struct-unsupported",
1422 SchemaOperation::define(vec![struct_fact]).unwrap(),
1423 &full,
1424 ),
1425 (
1426 "persistent-function-metadata",
1427 SchemaOperation::define(vec![persistent_function_doc]).unwrap(),
1428 &full,
1429 ),
1430 ];
1431 let mut output = String::new();
1432 for (name, operation, binding) in cases {
1433 let error =
1434 lower_operation(0, &operation, &empty, &empty, binding, false, false).unwrap_err();
1435 output.push_str(name);
1436 output.push('|');
1437 output.push_str(error.code());
1438 output.push('\n');
1439 }
1440 let no_capabilities = SchemaLoweringBinding::current(CapabilitySet::new()).unwrap();
1441 let capability_error = lower_operation(
1442 0,
1443 &SchemaOperation::define(vec![type_fact]).unwrap(),
1444 &empty,
1445 &empty,
1446 &no_capabilities,
1447 false,
1448 false,
1449 )
1450 .unwrap_err();
1451 output.push_str("capability|");
1452 output.push_str(capability_error.code());
1453 output.push('\n');
1454 let profile_error = SchemaLoweringBinding::new(
1455 SchemaLoweringProfileId::typedb_3_12_1(),
1456 SchemaLoweringProfileFingerprint::compute(b"wrong profile bytes"),
1457 CapabilitySet::new(),
1458 )
1459 .unwrap_err();
1460 output.push_str("profile|");
1461 output.push_str(profile_error.code());
1462 output.push('\n');
1463 assert_eq!(
1464 output,
1465 include_str!("../tests/fixtures/lowering-rejections-v1.txt")
1466 );
1467 }
1468
1469 #[test]
1470 fn equal_default_cardinality_is_formal_only_and_lowers() {
1471 let person = type_id(TypeKind::Entity, "person");
1472 let owns = OwnsFactId::new(person, attribute_id("name")).unwrap();
1473 let card = annotation(
1474 AnnotationSubjectId::Owns(owns),
1475 AnnotationKindId::Card,
1476 SchemaAnnotationValue::Cardinality(Cardinality::new(0, Some(1)).unwrap()),
1477 );
1478 let unit = lower_operation(
1479 0,
1480 &SchemaOperation::undefine(card),
1481 &SchemaFactCatalog::empty(),
1482 &SchemaFactCatalog::empty(),
1483 &full_binding(),
1484 false,
1485 false,
1486 )
1487 .unwrap();
1488 assert_eq!(unit.safety(), SafetyClass::FormalOnly);
1489 assert_eq!(
1490 unit.statements()[0].query(),
1491 "undefine\n@card from person owns name;"
1492 );
1493 }
1494
1495 #[test]
1496 fn safety_gate_matches_exhaustive_golden() {
1497 let mut output = String::new();
1498 for safety in SafetyClass::ALL {
1499 let name = match safety {
1500 SafetyClass::FormalOnly => "formal_only",
1501 SafetyClass::SchemaMetadata => "schema_metadata",
1502 SafetyClass::Additive => "additive",
1503 SafetyClass::Conditional => "conditional",
1504 SafetyClass::BackfillRequired => "backfill_required",
1505 SafetyClass::Destructive => "destructive",
1506 SafetyClass::Opaque => "opaque",
1507 SafetyClass::Unsupported => "unsupported",
1508 };
1509 output.push_str(name);
1510 match gate_safety(7, safety, false, false) {
1511 Ok(()) => output.push_str("|accepted\n"),
1512 Err(error) => {
1513 assert_eq!(error.operation_index(), Some(7));
1514 assert_eq!(error.safety(), Some(safety));
1515 output.push('|');
1516 output.push_str(error.code());
1517 output.push('|');
1518 output.push_str(error.message());
1519 output.push('\n');
1520 }
1521 }
1522 }
1523 assert_eq!(
1524 output,
1525 include_str!("../tests/fixtures/lowering-safety-gate-v1.txt")
1526 );
1527 }
1528
1529 #[test]
1530 fn destructive_gate_opens_only_under_approval() {
1531 assert!(gate_safety(0, SafetyClass::Destructive, false, true).is_ok());
1532 assert!(gate_safety(0, SafetyClass::Opaque, false, true).is_err());
1534 assert!(gate_safety(0, SafetyClass::BackfillRequired, false, true).is_err());
1535 assert!(gate_safety(0, SafetyClass::Unsupported, false, true).is_err());
1536 assert!(gate_safety(0, SafetyClass::Conditional, false, true).is_err());
1537 }
1538}