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 pub reversible: bool,
36}
37
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
42pub struct MigrationGraph {
43 pub migrations: Vec<MigrationSpec>,
45}
46
47#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
49#[serde(tag = "kind", rename_all = "snake_case")]
50pub enum OperationSpec {
51 DefineSchema {
53 schema: SchemaInfo,
55 },
56 AddAttribute {
58 attribute: AttributeSchemaEntry,
60 },
61 RemoveAttribute {
63 attr_name: String,
65 },
66 AddEntity {
68 entity: EntitySchemaEntry,
70 },
71 RemoveEntity {
73 type_name: String,
75 },
76 AddRelation {
78 relation: RelationSchemaEntry,
80 },
81 RemoveRelation {
83 type_name: String,
85 },
86 AddOwnership {
88 owner_type: String,
90 attribute: OwnedAttributeEntry,
92 },
93 RemoveOwnership {
95 owner_type: String,
97 attr_name: String,
99 },
100 ModifyOwnership {
102 owner_type: String,
104 attr_name: String,
106 old_annotations: String,
108 new_annotations: String,
110 },
111 ModifyTypeAnnotations {
114 type_name: String,
116 #[serde(default)]
118 old_doc: Option<String>,
119 #[serde(default)]
121 new_doc: Option<String>,
122 #[serde(default)]
124 old_meta: std::collections::BTreeMap<String, String>,
125 #[serde(default)]
127 new_meta: std::collections::BTreeMap<String, String>,
128 },
129 ModifyRoleAnnotations {
131 relation_type: String,
133 role_name: String,
135 #[serde(default)]
137 old_doc: Option<String>,
138 #[serde(default)]
140 new_doc: Option<String>,
141 #[serde(default)]
143 old_meta: std::collections::BTreeMap<String, String>,
144 #[serde(default)]
146 new_meta: std::collections::BTreeMap<String, String>,
147 },
148 AddRole {
150 relation_type: String,
152 role: RoleEntry,
154 },
155 RemoveRole {
157 relation_type: String,
159 role_name: String,
161 },
162 AddRolePlayer {
164 relation_type: String,
166 role_name: String,
168 player_type_name: String,
170 },
171 RemoveRolePlayer {
173 relation_type: String,
175 role_name: String,
177 player_type_name: String,
179 },
180 RunTypeql {
182 forward: String,
184 #[serde(default)]
186 reverse: Option<String>,
187 },
188 RenameAttribute {
190 old_name: String,
192 new_name: String,
194 value_type: String,
196 },
197 CopyAttribute {
211 #[serde(default, skip_serializing_if = "Option::is_none")]
213 owner: Option<String>,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
216 source: Option<String>,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
219 dest: Option<String>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
222 filter: Option<String>,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
227 forward: Option<String>,
228 #[serde(default)]
231 reverse: Option<String>,
232 },
233}
234
235pub 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 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 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 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 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 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 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}