Skip to main content

type_bridge_migration/
spec.rs

1//! Serde migration specification types.
2
3use serde::{Deserialize, Serialize};
4use type_bridge_orm::_schema::info::{
5    AttributeSchemaEntry, EntitySchemaEntry, OwnedAttributeEntry, RelationSchemaEntry, RoleEntry,
6    SchemaInfo,
7};
8
9/// Reference to a migration that must be ordered before another migration.
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11pub struct MigrationDependencySpec {
12    /// Application or migration package label.
13    pub app_label: String,
14    /// Migration file stem, such as `0001_initial`.
15    pub migration_name: String,
16}
17
18/// One migration lowered from the Python authoring API.
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20pub struct MigrationSpec {
21    /// Application or migration package label.
22    pub app_label: String,
23    /// Migration file stem, such as `0001_initial`.
24    pub name: String,
25    /// Ordered dependency references.
26    #[serde(default)]
27    pub dependencies: Vec<MigrationDependencySpec>,
28    /// Ordered operations in this migration.
29    #[serde(default)]
30    pub operations: Vec<OperationSpec>,
31    /// Optional loader checksum for later drift detection.
32    #[serde(default)]
33    pub checksum: Option<String>,
34    /// Optional exact raw Python-source digest for locale-independent drift checks.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub source_sha256: Option<String>,
37    /// Whether the migration is declared reversible by Python.
38    pub reversible: bool,
39}
40
41/// Ordered migration container.
42///
43/// Full graph validation is deliberately left to sub-plan 04.
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45pub struct MigrationGraph {
46    /// Migrations in discovery order.
47    pub migrations: Vec<MigrationSpec>,
48}
49
50/// Operation variants covering the frozen Python `ops.*` authoring surface.
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52#[serde(tag = "kind", rename_all = "snake_case")]
53pub enum OperationSpec {
54    /// Define the complete schema for a model-based initial migration.
55    DefineSchema {
56        /// Complete model schema.
57        schema: SchemaInfo,
58    },
59    /// Add a standalone attribute type.
60    AddAttribute {
61        /// Attribute schema payload.
62        attribute: AttributeSchemaEntry,
63    },
64    /// Remove a standalone attribute type.
65    RemoveAttribute {
66        /// Attribute type name.
67        attr_name: String,
68    },
69    /// Add an entity type.
70    AddEntity {
71        /// Entity schema payload.
72        entity: EntitySchemaEntry,
73    },
74    /// Remove an entity type.
75    RemoveEntity {
76        /// Entity type name.
77        type_name: String,
78    },
79    /// Add a relation type.
80    AddRelation {
81        /// Relation schema payload.
82        relation: RelationSchemaEntry,
83    },
84    /// Remove a relation type.
85    RemoveRelation {
86        /// Relation type name.
87        type_name: String,
88    },
89    /// Add attribute ownership to an entity or relation.
90    AddOwnership {
91        /// Owning entity or relation type name.
92        owner_type: String,
93        /// Owned attribute payload.
94        attribute: OwnedAttributeEntry,
95    },
96    /// Remove attribute ownership from an entity or relation.
97    RemoveOwnership {
98        /// Owning entity or relation type name.
99        owner_type: String,
100        /// Attribute type name.
101        attr_name: String,
102    },
103    /// Modify ownership annotations.
104    ModifyOwnership {
105        /// Owning entity or relation type name.
106        owner_type: String,
107        /// Attribute type name.
108        attr_name: String,
109        /// Previous annotations as authored by Python.
110        old_annotations: String,
111        /// New annotations as authored by Python.
112        new_annotations: String,
113    },
114    /// Modify `@doc`/`@meta` annotations on an entity, relation, or attribute
115    /// type (TypeDB 3.12+).
116    ModifyTypeAnnotations {
117        /// Type name (entity, relation, or attribute type).
118        type_name: String,
119        /// Previous `@doc` value.
120        #[serde(default)]
121        old_doc: Option<String>,
122        /// New `@doc` value.
123        #[serde(default)]
124        new_doc: Option<String>,
125        /// Previous `@meta` annotations, keyed by meta key.
126        #[serde(default)]
127        old_meta: std::collections::BTreeMap<String, String>,
128        /// New `@meta` annotations, keyed by meta key.
129        #[serde(default)]
130        new_meta: std::collections::BTreeMap<String, String>,
131    },
132    /// Modify `@doc`/`@meta` annotations on a relation role (TypeDB 3.12+).
133    ModifyRoleAnnotations {
134        /// Relation type name.
135        relation_type: String,
136        /// Role name.
137        role_name: String,
138        /// Previous `@doc` value.
139        #[serde(default)]
140        old_doc: Option<String>,
141        /// New `@doc` value.
142        #[serde(default)]
143        new_doc: Option<String>,
144        /// Previous `@meta` annotations, keyed by meta key.
145        #[serde(default)]
146        old_meta: std::collections::BTreeMap<String, String>,
147        /// New `@meta` annotations, keyed by meta key.
148        #[serde(default)]
149        new_meta: std::collections::BTreeMap<String, String>,
150    },
151    /// Add a role to a relation.
152    AddRole {
153        /// Relation type name.
154        relation_type: String,
155        /// Role payload.
156        role: RoleEntry,
157    },
158    /// Remove a role from a relation.
159    RemoveRole {
160        /// Relation type name.
161        relation_type: String,
162        /// Role name.
163        role_name: String,
164    },
165    /// Add a player type to a role.
166    AddRolePlayer {
167        /// Relation type name.
168        relation_type: String,
169        /// Role name.
170        role_name: String,
171        /// Player type name.
172        player_type_name: String,
173    },
174    /// Remove a player type from a role.
175    RemoveRolePlayer {
176        /// Relation type name.
177        relation_type: String,
178        /// Role name.
179        role_name: String,
180        /// Player type name.
181        player_type_name: String,
182    },
183    /// Execute authored TypeQL without interpreting it in this sub-plan.
184    RunTypeql {
185        /// Forward TypeQL text.
186        forward: String,
187        /// Optional rollback TypeQL text.
188        #[serde(default)]
189        reverse: Option<String>,
190    },
191    /// Rename an attribute type.
192    RenameAttribute {
193        /// Previous attribute type name.
194        old_name: String,
195        /// New attribute type name.
196        new_name: String,
197        /// TypeDB value type.
198        value_type: String,
199    },
200    /// Copy an attribute from source to dest on every instance of the owner type.
201    ///
202    /// This is a DML (write-typed) backfill operation — it inserts attribute values, not
203    /// schema.  The forward TypeQL is an insert-if-absent backfill; the reverse deletes the
204    /// destination attribute.
205    ///
206    /// Two wire forms exist. The lowered form carries only `forward`/`reverse`
207    /// TypeQL (what `.py` execution lowers to); the structured form carries
208    /// `owner`/`source`/`dest`(/`filter`) and lets the authoring core
209    /// synthesize the TypeQL via [`copy_attribute_typeql`] and render a
210    /// faithful `ops.CopyAttribute(...)` in the generated `.py`. Sidecars
211    /// written by the authoring core always carry the synthesized strings, so
212    /// the executor keeps running carried TypeQL.
213    CopyAttribute {
214        /// Owner type label (structured portable form).
215        #[serde(default, skip_serializing_if = "Option::is_none")]
216        owner: Option<String>,
217        /// Source attribute label (structured portable form).
218        #[serde(default, skip_serializing_if = "Option::is_none")]
219        source: Option<String>,
220        /// Destination attribute label (structured portable form).
221        #[serde(default, skip_serializing_if = "Option::is_none")]
222        dest: Option<String>,
223        /// Optional extra match constraint line (structured portable form).
224        #[serde(default, skip_serializing_if = "Option::is_none")]
225        filter: Option<String>,
226        /// Forward backfill TypeQL. Absent only in the structured form before
227        /// normalization; the executor derives its count queries from this
228        /// string's match clause.
229        #[serde(default, skip_serializing_if = "Option::is_none")]
230        forward: Option<String>,
231        /// Reverse (rollback) TypeQL from `to_rollback_typeql()`, or `None` when
232        /// the migration is irreversible.
233        #[serde(default)]
234        reverse: Option<String>,
235    },
236}
237
238/// Resolve the executable forward/reverse TypeQL of a `copy_attribute` op.
239///
240/// Carried TypeQL wins when present (it is the frozen Python
241/// `CopyAttribute.to_typeql()` output); otherwise the structured
242/// `owner`/`source`/`dest` fields synthesize it. The synthesis is pinned
243/// byte-identical to the Python template by a parity test on the Python side.
244///
245/// # Errors
246///
247/// [`crate::error::MigrationError::AuthoringInput`] when neither the carried
248/// TypeQL nor the complete structured form is present.
249pub fn copy_attribute_typeql(op: &OperationSpec) -> crate::Result<(String, Option<String>)> {
250    let OperationSpec::CopyAttribute {
251        owner,
252        source,
253        dest,
254        filter,
255        forward,
256        reverse,
257    } = op
258    else {
259        return Err(crate::error::MigrationError::AuthoringInput {
260            message: "copy_attribute_typeql called on a non-copy_attribute operation".to_string(),
261        });
262    };
263    if let Some(forward) = forward {
264        return Ok((forward.clone(), reverse.clone()));
265    }
266    let (Some(owner), Some(source), Some(dest)) = (owner, source, dest) else {
267        return Err(crate::error::MigrationError::AuthoringInput {
268            message: "copy_attribute requires either forward TypeQL or the structured \
269                      owner/source/dest fields"
270                .to_string(),
271        });
272    };
273    let filter_line = match filter {
274        Some(filter) => format!("\n  {filter};"),
275        None => String::new(),
276    };
277    let synthesized_forward = format!(
278        "match\n  $x isa {owner}, has {source} $v;\n  not {{ $x has {dest} $d; }};{filter_line}\ninsert\n  $x has {dest} == $v;"
279    );
280    let synthesized_reverse = format!("match $x isa {owner}, has {dest} $v;\ndelete $v of $x;");
281    Ok((
282        synthesized_forward,
283        Some(reverse.clone().unwrap_or(synthesized_reverse)),
284    ))
285}
286
287impl OperationSpec {
288    /// Return the operation with any structured `copy_attribute` filled in
289    /// with its synthesized executable TypeQL. Other variants pass through.
290    ///
291    /// # Errors
292    ///
293    /// [`crate::error::MigrationError::AuthoringInput`] when a
294    /// `copy_attribute` carries neither TypeQL nor the structured fields.
295    pub fn normalized(self) -> crate::Result<OperationSpec> {
296        if !matches!(self, OperationSpec::CopyAttribute { .. }) {
297            return Ok(self);
298        }
299        let (forward, reverse) = copy_attribute_typeql(&self)?;
300        let OperationSpec::CopyAttribute {
301            owner,
302            source,
303            dest,
304            filter,
305            ..
306        } = self
307        else {
308            unreachable!("guarded by the matches! check above");
309        };
310        Ok(OperationSpec::CopyAttribute {
311            owner,
312            source,
313            dest,
314            filter,
315            forward: Some(forward),
316            reverse,
317        })
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use std::collections::BTreeMap;
324
325    use super::*;
326    use type_bridge_orm::_entity::Annotation;
327    use type_bridge_orm::ValueType;
328
329    fn attribute(name: &str) -> AttributeSchemaEntry {
330        AttributeSchemaEntry::new(name, ValueType::String)
331    }
332
333    fn schema() -> SchemaInfo {
334        let mut schema = SchemaInfo::default();
335        schema
336            .attributes
337            .insert("name".to_string(), attribute("name"));
338        schema.entities.insert(
339            "person".to_string(),
340            EntitySchemaEntry {
341                type_name: "person".to_string(),
342                is_abstract: false,
343                parent_type: None,
344                owned_attributes: vec![OwnedAttributeEntry {
345                    attr_name: "name".to_string(),
346                    value_type: ValueType::String,
347                    annotations: vec![Annotation::Key],
348                    is_ordered: false,
349                    doc: None,
350                    meta: Default::default(),
351                }],
352                plays_cardinalities: BTreeMap::new(),
353                doc: None,
354                meta: Default::default(),
355            },
356        );
357        schema
358    }
359
360    #[test]
361    fn define_schema_operation_round_trips_json() {
362        let operation = OperationSpec::DefineSchema { schema: schema() };
363
364        let json = serde_json::to_string(&operation).unwrap();
365        assert!(json.contains("\"kind\":\"define_schema\""));
366
367        let parsed: OperationSpec = serde_json::from_str(&json).unwrap();
368        assert_eq!(parsed, operation);
369    }
370
371    #[test]
372    fn schema_bearing_operation_round_trips_json() {
373        let operation = OperationSpec::AddOwnership {
374            owner_type: "person".to_string(),
375            attribute: OwnedAttributeEntry {
376                attr_name: "name".to_string(),
377                value_type: ValueType::String,
378                annotations: vec![Annotation::Key],
379                is_ordered: false,
380                doc: None,
381                meta: Default::default(),
382            },
383        };
384
385        let parsed: OperationSpec =
386            serde_json::from_str(&serde_json::to_string(&operation).unwrap()).unwrap();
387
388        assert_eq!(parsed, operation);
389    }
390
391    #[test]
392    fn run_typeql_operation_round_trips_json() {
393        let operation = OperationSpec::RunTypeql {
394            forward: "define attribute nickname, value string;".to_string(),
395            reverse: Some("undefine attribute nickname;".to_string()),
396        };
397
398        let json = serde_json::to_value(&operation).unwrap();
399        assert_eq!(json["kind"], "run_typeql");
400
401        let parsed: OperationSpec = serde_json::from_value(json).unwrap();
402        assert_eq!(parsed, operation);
403    }
404
405    fn lowered_copy_attribute(forward: &str, reverse: Option<&str>) -> OperationSpec {
406        OperationSpec::CopyAttribute {
407            owner: None,
408            source: None,
409            dest: None,
410            filter: None,
411            forward: Some(forward.to_string()),
412            reverse: reverse.map(str::to_string),
413        }
414    }
415
416    fn structured_copy_attribute(filter: Option<&str>) -> OperationSpec {
417        OperationSpec::CopyAttribute {
418            owner: Some("person".to_string()),
419            source: Some("old-name".to_string()),
420            dest: Some("new-name".to_string()),
421            filter: filter.map(str::to_string),
422            forward: None,
423            reverse: None,
424        }
425    }
426
427    #[test]
428    fn copy_attribute_operation_round_trips_json() {
429        let operation = lowered_copy_attribute(
430            "match\n  $x isa person, has old-name $v;\n  not { $x has new-name $d; };\ninsert\n  $x has new-name == $v;",
431            Some("match $x isa person, has new-name $v;\ndelete $v of $x;"),
432        );
433
434        let json = serde_json::to_value(&operation).unwrap();
435        assert_eq!(json["kind"], "copy_attribute");
436        assert!(
437            json["forward"]
438                .as_str()
439                .unwrap()
440                .contains("has new-name == $v")
441        );
442        // Absent structured fields stay off the wire so the lowered form
443        // serializes exactly as it did before the structured form existed.
444        assert!(json.get("owner").is_none());
445
446        let parsed: OperationSpec = serde_json::from_value(json).unwrap();
447        assert_eq!(parsed, operation);
448    }
449
450    #[test]
451    fn copy_attribute_without_reverse_round_trips_json() {
452        let operation = lowered_copy_attribute(
453            "match\n  $x isa company, has legacy-id $v;\n  not { $x has new-id $d; };\ninsert\n  $x has new-id == $v;",
454            None,
455        );
456
457        let json = serde_json::to_value(&operation).unwrap();
458        assert_eq!(json["kind"], "copy_attribute");
459        // reverse is None → omitted (serde default).
460
461        let parsed: OperationSpec = serde_json::from_value(json).unwrap();
462        assert_eq!(parsed, operation);
463    }
464
465    #[test]
466    fn legacy_copy_attribute_sidecar_json_still_parses() {
467        // Sidecars written before the structured form carry only the TypeQL.
468        let json = r#"{"kind":"copy_attribute","forward":"match ...;","reverse":null}"#;
469
470        let parsed: OperationSpec = serde_json::from_str(json).unwrap();
471        assert_eq!(parsed, lowered_copy_attribute("match ...;", None));
472    }
473
474    #[test]
475    fn structured_copy_attribute_round_trips_json() {
476        let operation = structured_copy_attribute(Some("$x has age $a;"));
477
478        let json = serde_json::to_value(&operation).unwrap();
479        assert_eq!(json["kind"], "copy_attribute");
480        assert_eq!(json["owner"], "person");
481        assert!(json.get("forward").is_none());
482
483        let parsed: OperationSpec = serde_json::from_value(json).unwrap();
484        assert_eq!(parsed, operation);
485    }
486
487    #[test]
488    fn structured_copy_attribute_synthesizes_python_shaped_typeql() {
489        let (forward, reverse) = copy_attribute_typeql(&structured_copy_attribute(None)).unwrap();
490
491        assert_eq!(
492            forward,
493            "match\n  $x isa person, has old-name $v;\n  not { $x has new-name $d; };\ninsert\n  $x has new-name == $v;"
494        );
495        assert_eq!(
496            reverse.as_deref(),
497            Some("match $x isa person, has new-name $v;\ndelete $v of $x;")
498        );
499    }
500
501    #[test]
502    fn structured_copy_attribute_synthesizes_filter_line() {
503        // The template appends the terminating `;`, mirroring the Python
504        // `CopyAttribute.to_typeql()` filter line.
505        let (forward, _) =
506            copy_attribute_typeql(&structured_copy_attribute(Some("$x has age $a"))).unwrap();
507
508        assert_eq!(
509            forward,
510            "match\n  $x isa person, has old-name $v;\n  not { $x has new-name $d; };\n  $x has age $a;\ninsert\n  $x has new-name == $v;"
511        );
512    }
513
514    #[test]
515    fn carried_typeql_wins_over_structured_fields() {
516        let operation = OperationSpec::CopyAttribute {
517            owner: Some("person".to_string()),
518            source: Some("old-name".to_string()),
519            dest: Some("new-name".to_string()),
520            filter: None,
521            forward: Some("match carried;".to_string()),
522            reverse: Some("match carried-reverse;".to_string()),
523        };
524
525        let (forward, reverse) = copy_attribute_typeql(&operation).unwrap();
526        assert_eq!(forward, "match carried;");
527        assert_eq!(reverse.as_deref(), Some("match carried-reverse;"));
528    }
529
530    #[test]
531    fn copy_attribute_without_typeql_or_fields_is_rejected() {
532        let operation = OperationSpec::CopyAttribute {
533            owner: Some("person".to_string()),
534            source: None,
535            dest: Some("new-name".to_string()),
536            filter: None,
537            forward: None,
538            reverse: None,
539        };
540
541        let error = copy_attribute_typeql(&operation).unwrap_err();
542        assert!(matches!(
543            error,
544            crate::error::MigrationError::AuthoringInput { .. }
545        ));
546    }
547
548    #[test]
549    fn normalized_fills_structured_copy_attribute_and_passes_others_through() {
550        let normalized = structured_copy_attribute(None).normalized().unwrap();
551        let OperationSpec::CopyAttribute {
552            owner,
553            forward,
554            reverse,
555            ..
556        } = &normalized
557        else {
558            panic!("normalized must stay a copy_attribute");
559        };
560        assert_eq!(owner.as_deref(), Some("person"));
561        assert!(forward.as_deref().unwrap().contains("has new-name == $v"));
562        assert!(reverse.as_deref().unwrap().contains("delete $v of $x"));
563
564        let passthrough = OperationSpec::RunTypeql {
565            forward: "match $x isa person;".to_string(),
566            reverse: None,
567        };
568        assert_eq!(passthrough.clone().normalized().unwrap(), passthrough);
569    }
570
571    #[test]
572    fn graph_preserves_spec_order() {
573        let graph = MigrationGraph {
574            migrations: vec![
575                MigrationSpec {
576                    app_label: "app".to_string(),
577                    name: "0001_initial".to_string(),
578                    dependencies: vec![],
579                    operations: vec![OperationSpec::DefineSchema { schema: schema() }],
580                    checksum: Some("aaa".to_string()),
581                    source_sha256: None,
582                    reversible: true,
583                },
584                MigrationSpec {
585                    app_label: "app".to_string(),
586                    name: "0002_custom".to_string(),
587                    dependencies: vec![MigrationDependencySpec {
588                        app_label: "app".to_string(),
589                        migration_name: "0001_initial".to_string(),
590                    }],
591                    operations: vec![OperationSpec::RunTypeql {
592                        forward: "define attribute nickname, value string;".to_string(),
593                        reverse: None,
594                    }],
595                    checksum: Some("bbb".to_string()),
596                    source_sha256: None,
597                    reversible: false,
598                },
599            ],
600        };
601
602        let parsed: MigrationGraph =
603            serde_json::from_str(&serde_json::to_string(&graph).unwrap()).unwrap();
604
605        assert_eq!(parsed.migrations[0].name, "0001_initial");
606        assert_eq!(parsed.migrations[1].name, "0002_custom");
607        assert_eq!(parsed, graph);
608    }
609
610    #[test]
611    fn absent_raw_digest_preserves_the_legacy_sidecar_wire_shape() {
612        let spec = MigrationSpec {
613            app_label: "app".to_string(),
614            name: "0001_initial".to_string(),
615            dependencies: vec![],
616            operations: vec![],
617            checksum: Some("aaa".to_string()),
618            source_sha256: None,
619            reversible: true,
620        };
621
622        assert_eq!(
623            serde_json::to_string(&spec).unwrap(),
624            r#"{"app_label":"app","name":"0001_initial","dependencies":[],"operations":[],"checksum":"aaa","reversible":true}"#,
625        );
626    }
627
628    #[test]
629    fn annotation_operations_round_trip_json() {
630        let operations = vec![
631            OperationSpec::ModifyTypeAnnotations {
632                type_name: "person".to_string(),
633                old_doc: None,
634                new_doc: Some("A person.".to_string()),
635                old_meta: BTreeMap::new(),
636                new_meta: BTreeMap::from([("owner".to_string(), "core".to_string())]),
637            },
638            OperationSpec::ModifyRoleAnnotations {
639                relation_type: "employment".to_string(),
640                role_name: "employee".to_string(),
641                old_doc: Some("old".to_string()),
642                new_doc: None,
643                old_meta: BTreeMap::new(),
644                new_meta: BTreeMap::new(),
645            },
646        ];
647        for operation in operations {
648            let json = serde_json::to_string(&operation).expect("serialize");
649            let back: OperationSpec = serde_json::from_str(&json).expect("deserialize");
650            assert_eq!(back, operation);
651        }
652        // Kind tags follow the snake_case convention of the frozen surface.
653        let json = serde_json::to_string(&OperationSpec::ModifyTypeAnnotations {
654            type_name: "person".to_string(),
655            old_doc: None,
656            new_doc: None,
657            old_meta: BTreeMap::new(),
658            new_meta: BTreeMap::new(),
659        })
660        .expect("serialize");
661        assert!(json.contains("\"kind\":\"modify_type_annotations\""));
662    }
663}