Skip to main content

type_bridge_schema_migration/
lowering.rs

1//! Deterministic offline lowering of validated schema deltas to TypeDB 3.12.1 TypeQL.
2
3use 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/// Stable, provider-neutral failure returned before any provider I/O exists.
34#[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    /// Return the stable machine-readable diagnostic code.
70    pub const fn code(&self) -> &'static str {
71        self.code
72    }
73
74    /// Return the stable human-readable summary.
75    pub const fn message(&self) -> &'static str {
76        self.message
77    }
78
79    /// Return the outer delta-operation index, when applicable.
80    pub const fn operation_index(&self) -> Option<usize> {
81        self.operation_index
82    }
83
84    /// Return the safety class which stopped lowering, when applicable.
85    pub const fn safety(&self) -> Option<SafetyClass> {
86        self.safety
87    }
88
89    /// Return missing provider capabilities in deterministic order.
90    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/// Exact fact payloads associated with one managed schema state.
104#[derive(Clone, Debug, Eq, PartialEq)]
105pub struct SchemaFactCatalog(BTreeMap<SchemaFactId, SchemaFact>);
106
107impl SchemaFactCatalog {
108    /// Build a deterministic catalog, rejecting duplicate identities.
109    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    /// Return an empty catalog.
126    pub fn empty() -> Self {
127        Self(BTreeMap::new())
128    }
129
130    /// Return one exact fact payload.
131    pub fn get(&self, id: &SchemaFactId) -> Option<&SchemaFact> {
132        self.0.get(id)
133    }
134
135    /// Iterate in canonical fact-identity order.
136    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/// Fixed lowering-profile identity plus provider capabilities available to the caller.
154#[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    /// Bind caller capabilities to the exact compiled lowering profile.
163    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    /// Bind capabilities to the current compiled profile.
183    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    /// Return available provider capabilities.
194    pub const fn available_capabilities(&self) -> &CapabilitySet {
195        &self.available_capabilities
196    }
197
198    /// Return the exact compiled lowering-profile identity.
199    pub const fn profile_id(&self) -> &SchemaLoweringProfileId {
200        &self.profile_id
201    }
202
203    /// Return the exact compiled lowering-profile content fingerprint.
204    pub const fn profile_fingerprint(&self) -> &SchemaLoweringProfileFingerprint {
205        &self.profile_fingerprint
206    }
207}
208
209/// TypeQL schema query verb.
210#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
211#[serde(rename_all = "snake_case")]
212pub enum TypeQlVerb {
213    /// Introduce schema definitions.
214    Define,
215    /// Remove schema definitions.
216    Undefine,
217    /// Replace schema definitions in place.
218    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/// One complete TypeQL schema query.
232#[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    /// Return the schema query verb.
247    pub const fn verb(&self) -> TypeQlVerb {
248        self.verb
249    }
250
251    /// Return exact deterministic query text.
252    pub fn query(&self) -> &str {
253        &self.query
254    }
255}
256
257/// Outer formal operation represented by a statement unit.
258#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
259#[serde(rename_all = "snake_case")]
260pub enum StatementOperationKind {
261    /// The source delta operation defines a fact.
262    Define,
263    /// The source delta operation replaces a fact.
264    Redefine,
265    /// The source delta operation removes a fact.
266    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/// One preserved outer delta operation and its atomic TypeQL query sequence.
280#[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    /// Return the zero-based index of the source delta operation.
293    pub const fn operation_index(&self) -> usize {
294        self.operation_index
295    }
296
297    /// Return the formal operation represented by this unit.
298    pub const fn operation_kind(&self) -> StatementOperationKind {
299        self.operation_kind
300    }
301
302    /// Return the classified safety level for the operation.
303    pub const fn safety(&self) -> SafetyClass {
304        self.safety
305    }
306
307    /// Return whether every statement must execute atomically.
308    pub const fn atomic(&self) -> bool {
309        self.atomic
310    }
311
312    /// Return the canonical schema fact identities affected by the unit.
313    pub fn affected_ids(&self) -> &[SchemaFactId] {
314        &self.affected_ids
315    }
316
317    /// Return the provider capabilities required to execute the unit.
318    pub const fn required_capabilities(&self) -> &CapabilitySet {
319        &self.required_capabilities
320    }
321
322    /// Return the deterministic TypeQL statements in execution order.
323    pub fn statements(&self) -> &[TypeQlStatement] {
324        &self.statements
325    }
326}
327
328/// Provider-bound, safety-gated statement plan retaining its exact formal delta.
329#[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    /// Return the exact formal delta retained by the plan.
339    pub const fn delta(&self) -> &SchemaDelta {
340        &self.delta
341    }
342
343    /// Return the lowering-profile identity used to build the plan.
344    pub const fn profile_id(&self) -> &SchemaLoweringProfileId {
345        &self.profile_id
346    }
347
348    /// Return the lowering-profile content fingerprint used to build the plan.
349    pub const fn profile_fingerprint(&self) -> &SchemaLoweringProfileFingerprint {
350        &self.profile_fingerprint
351    }
352
353    /// Return operation units in source-delta order.
354    pub fn units(&self) -> &[StatementUnit] {
355        &self.units
356    }
357}
358
359/// Lower a complete formal delta after validating its exact fact payload context.
360pub 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    // TypeDB deletes an attribute's value declaration together with the
405    // attribute type. Sending a separate `undefine value ...` first is not a
406    // valid intermediate schema because a concrete attribute must retain its
407    // value type. Preserve the formal delta unit, but let the later exact type
408    // deletion own that provider-side cascade.
409    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        // Type deletion takes the bare label: the kind keyword belongs to
726        // the define grammar only.
727        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        // An approval never opens classes no approval can execute.
1643        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}