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