1use serde::{Deserialize, Serialize};
4use type_bridge_orm::_schema::info::{
5 AttributeSchemaEntry, EntitySchemaEntry, OwnedAttributeEntry, RelationSchemaEntry, RoleEntry,
6 SchemaInfo,
7};
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11pub struct MigrationDependencySpec {
12 pub app_label: String,
14 pub migration_name: String,
16}
17
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20pub struct MigrationSpec {
21 pub app_label: String,
23 pub name: String,
25 #[serde(default)]
27 pub dependencies: Vec<MigrationDependencySpec>,
28 #[serde(default)]
30 pub operations: Vec<OperationSpec>,
31 #[serde(default)]
33 pub checksum: Option<String>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub source_sha256: Option<String>,
37 pub reversible: bool,
39}
40
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45pub struct MigrationGraph {
46 pub migrations: Vec<MigrationSpec>,
48}
49
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52#[serde(tag = "kind", rename_all = "snake_case")]
53pub enum OperationSpec {
54 DefineSchema {
56 schema: SchemaInfo,
58 },
59 AddAttribute {
61 attribute: AttributeSchemaEntry,
63 },
64 RemoveAttribute {
66 attr_name: String,
68 },
69 AddEntity {
71 entity: EntitySchemaEntry,
73 },
74 RemoveEntity {
76 type_name: String,
78 },
79 AddRelation {
81 relation: RelationSchemaEntry,
83 },
84 RemoveRelation {
86 type_name: String,
88 },
89 AddOwnership {
91 owner_type: String,
93 attribute: OwnedAttributeEntry,
95 },
96 RemoveOwnership {
98 owner_type: String,
100 attr_name: String,
102 },
103 ModifyOwnership {
105 owner_type: String,
107 attr_name: String,
109 old_annotations: String,
111 new_annotations: String,
113 },
114 ModifyTypeAnnotations {
117 type_name: String,
119 #[serde(default)]
121 old_doc: Option<String>,
122 #[serde(default)]
124 new_doc: Option<String>,
125 #[serde(default)]
127 old_meta: std::collections::BTreeMap<String, String>,
128 #[serde(default)]
130 new_meta: std::collections::BTreeMap<String, String>,
131 },
132 ModifyRoleAnnotations {
134 relation_type: String,
136 role_name: String,
138 #[serde(default)]
140 old_doc: Option<String>,
141 #[serde(default)]
143 new_doc: Option<String>,
144 #[serde(default)]
146 old_meta: std::collections::BTreeMap<String, String>,
147 #[serde(default)]
149 new_meta: std::collections::BTreeMap<String, String>,
150 },
151 AddRole {
153 relation_type: String,
155 role: RoleEntry,
157 },
158 RemoveRole {
160 relation_type: String,
162 role_name: String,
164 },
165 AddRolePlayer {
167 relation_type: String,
169 role_name: String,
171 player_type_name: String,
173 },
174 RemoveRolePlayer {
176 relation_type: String,
178 role_name: String,
180 player_type_name: String,
182 },
183 RunTypeql {
185 forward: String,
187 #[serde(default)]
189 reverse: Option<String>,
190 },
191 RenameAttribute {
193 old_name: String,
195 new_name: String,
197 value_type: String,
199 },
200 CopyAttribute {
214 #[serde(default, skip_serializing_if = "Option::is_none")]
216 owner: Option<String>,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
219 source: Option<String>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
222 dest: Option<String>,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
225 filter: Option<String>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
230 forward: Option<String>,
231 #[serde(default)]
234 reverse: Option<String>,
235 },
236}
237
238pub 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 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 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 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 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 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 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}