1use std::collections::BTreeSet;
9
10use serde::{Deserialize, Serialize};
11use type_bridge_orm::TxType;
12use type_bridge_orm::schema::annotations::{
13 AnnotationToken, AnnotationTokenDiff, diff_annotation_tokens, split_annotation_tokens,
14};
15use type_bridge_orm::schema::info::{
16 AttributeSchemaEntry, EntitySchemaEntry, OwnedAttributeEntry, RelationSchemaEntry, RoleEntry,
17 SchemaInfo,
18};
19
20use crate::checksum::check_checksum_drift;
21use crate::error::MigrationError;
22use crate::graph::{AppliedMigrationRecord, validate_graph};
23use crate::spec::{MigrationGraph, OperationSpec, copy_attribute_typeql};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
31#[serde(rename_all = "snake_case")]
32pub enum StepKind {
33 #[default]
35 Schema,
36 Write,
38 Backfill,
40}
41
42#[derive(
48 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default,
49)]
50#[serde(rename_all = "snake_case")]
51pub enum OperationKind {
52 #[default]
54 RunTypeql,
55 DefineSchema,
57 AddAttribute,
59 RemoveAttribute,
61 AddEntity,
63 RemoveEntity,
65 AddRelation,
67 RemoveRelation,
69 AddOwnership,
71 RemoveOwnership,
73 ModifyOwnership,
75 ModifyTypeAnnotations,
77 ModifyRoleAnnotations,
79 AddRole,
81 RemoveRole,
83 AddRolePlayer,
85 RemoveRolePlayer,
87 RenameAttribute,
89 CopyAttribute,
91}
92
93impl OperationKind {
94 pub const fn as_str(self) -> &'static str {
96 match self {
97 Self::RunTypeql => "run_typeql",
98 Self::DefineSchema => "define_schema",
99 Self::AddAttribute => "add_attribute",
100 Self::RemoveAttribute => "remove_attribute",
101 Self::AddEntity => "add_entity",
102 Self::RemoveEntity => "remove_entity",
103 Self::AddRelation => "add_relation",
104 Self::RemoveRelation => "remove_relation",
105 Self::AddOwnership => "add_ownership",
106 Self::RemoveOwnership => "remove_ownership",
107 Self::ModifyOwnership => "modify_ownership",
108 Self::ModifyTypeAnnotations => "modify_type_annotations",
109 Self::ModifyRoleAnnotations => "modify_role_annotations",
110 Self::AddRole => "add_role",
111 Self::RemoveRole => "remove_role",
112 Self::AddRolePlayer => "add_role_player",
113 Self::RemoveRolePlayer => "remove_role_player",
114 Self::RenameAttribute => "rename_attribute",
115 Self::CopyAttribute => "copy_attribute",
116 }
117 }
118
119 fn from_spec(operation: &OperationSpec) -> Self {
120 match operation {
121 OperationSpec::RunTypeql { .. } => Self::RunTypeql,
122 OperationSpec::DefineSchema { .. } => Self::DefineSchema,
123 OperationSpec::AddAttribute { .. } => Self::AddAttribute,
124 OperationSpec::RemoveAttribute { .. } => Self::RemoveAttribute,
125 OperationSpec::AddEntity { .. } => Self::AddEntity,
126 OperationSpec::RemoveEntity { .. } => Self::RemoveEntity,
127 OperationSpec::AddRelation { .. } => Self::AddRelation,
128 OperationSpec::RemoveRelation { .. } => Self::RemoveRelation,
129 OperationSpec::AddOwnership { .. } => Self::AddOwnership,
130 OperationSpec::RemoveOwnership { .. } => Self::RemoveOwnership,
131 OperationSpec::ModifyOwnership { .. } => Self::ModifyOwnership,
132 OperationSpec::ModifyTypeAnnotations { .. } => Self::ModifyTypeAnnotations,
133 OperationSpec::ModifyRoleAnnotations { .. } => Self::ModifyRoleAnnotations,
134 OperationSpec::AddRole { .. } => Self::AddRole,
135 OperationSpec::RemoveRole { .. } => Self::RemoveRole,
136 OperationSpec::AddRolePlayer { .. } => Self::AddRolePlayer,
137 OperationSpec::RemoveRolePlayer { .. } => Self::RemoveRolePlayer,
138 OperationSpec::RenameAttribute { .. } => Self::RenameAttribute,
139 OperationSpec::CopyAttribute { .. } => Self::CopyAttribute,
140 }
141 }
142}
143
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
149pub struct ExecutionStep {
150 pub tx_type: TxType,
152 #[serde(default)]
157 pub kind: StepKind,
158 #[serde(default)]
163 pub operation_kind: OperationKind,
164 pub forward: String,
166 pub reverse: Option<String>,
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(rename_all = "snake_case")]
174pub enum MigrationAction {
175 Apply,
177 Rollback,
179}
180
181#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
183pub struct MigrationExecution {
184 pub app_label: String,
186 pub name: String,
188 pub action: MigrationAction,
190 pub steps: Vec<ExecutionStep>,
192 pub reversible: bool,
197}
198
199#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
201pub struct ExecutionPlan {
202 pub to_apply: Vec<MigrationExecution>,
204 pub to_rollback: Vec<MigrationExecution>,
206}
207
208pub fn plan(
219 graph: &MigrationGraph,
220 applied: &[AppliedMigrationRecord],
221 target: Option<&str>,
222) -> crate::Result<ExecutionPlan> {
223 let errors = validate_graph(graph, applied);
225 if !errors.is_empty() {
226 return Err(MigrationError::Planning { errors });
227 }
228
229 check_checksum_drift(graph, applied)?;
231
232 let applied_keys: std::collections::BTreeSet<(&str, &str)> = applied
234 .iter()
235 .map(|r| (r.app_label.as_str(), r.name.as_str()))
236 .collect();
237
238 let (to_apply, to_rollback) = if let Some(target_name) = target {
239 let target_idx = graph
241 .migrations
242 .iter()
243 .position(|m| {
244 m.name == target_name || format!("{}::{}", m.app_label, m.name) == target_name
245 })
246 .ok_or_else(|| MigrationError::TargetNotFound {
247 target: target_name.to_string(),
248 })?;
249
250 let mut apply = Vec::new();
251 let mut rollback = Vec::new();
252
253 for (i, migration) in graph.migrations.iter().enumerate() {
254 let is_applied =
255 applied_keys.contains(&(migration.app_label.as_str(), migration.name.as_str()));
256 if i <= target_idx && !is_applied {
257 apply.push(migration);
258 } else if i > target_idx && is_applied {
259 rollback.push(migration);
260 }
261 }
262
263 rollback.reverse();
265 (apply, rollback)
266 } else {
267 let apply: Vec<_> = graph
269 .migrations
270 .iter()
271 .filter(|m| !applied_keys.contains(&(m.app_label.as_str(), m.name.as_str())))
272 .collect();
273 (apply, Vec::new())
274 };
275
276 let mut apply_executions = Vec::with_capacity(to_apply.len());
278 for migration in to_apply {
279 let steps = assemble_steps(&migration.operations, migration.reversible)?;
280 let reversible = steps.iter().all(|s| s.reverse.is_some());
281 apply_executions.push(MigrationExecution {
282 app_label: migration.app_label.clone(),
283 name: migration.name.clone(),
284 action: MigrationAction::Apply,
285 steps,
286 reversible,
287 });
288 }
289
290 let mut rollback_executions = Vec::with_capacity(to_rollback.len());
292 for migration in to_rollback {
293 let steps = assemble_steps(&migration.operations, migration.reversible)?;
294 let reversible = steps.iter().all(|s| s.reverse.is_some());
295 rollback_executions.push(MigrationExecution {
296 app_label: migration.app_label.clone(),
297 name: migration.name.clone(),
298 action: MigrationAction::Rollback,
299 steps,
300 reversible,
301 });
302 }
303
304 Ok(ExecutionPlan {
305 to_apply: apply_executions,
306 to_rollback: rollback_executions,
307 })
308}
309
310fn removed_relation_labels(operations: &[OperationSpec]) -> BTreeSet<&str> {
312 operations
313 .iter()
314 .filter_map(|op| match op {
315 OperationSpec::RemoveRelation { type_name } => Some(type_name.as_str()),
316 _ => None,
317 })
318 .collect()
319}
320
321fn shadowed_by_remove_relation(op: &OperationSpec, removed: &BTreeSet<&str>) -> bool {
334 match op {
335 OperationSpec::RemoveRole { relation_type, .. }
336 | OperationSpec::RemoveRolePlayer { relation_type, .. } => {
337 removed.contains(relation_type.as_str())
338 }
339 OperationSpec::RemoveOwnership { owner_type, .. } => removed.contains(owner_type.as_str()),
340 _ => false,
341 }
342}
343
344fn assemble_steps(
346 operations: &[OperationSpec],
347 migration_reversible: bool,
348) -> crate::Result<Vec<ExecutionStep>> {
349 let removed_relations = removed_relation_labels(operations);
350 let mut steps = Vec::with_capacity(operations.len());
351 for op in operations {
352 if shadowed_by_remove_relation(op, &removed_relations) {
353 continue;
354 }
355 let mut op_steps: Vec<ExecutionStep> = match op {
356 OperationSpec::RunTypeql { forward, reverse } => {
357 let tx_type = run_typeql_tx_type(forward);
358 vec![ExecutionStep {
359 tx_type,
360 kind: if tx_type == TxType::Write {
361 StepKind::Write
362 } else {
363 StepKind::Schema
364 },
365 operation_kind: OperationKind::RunTypeql,
366 forward: forward.clone(),
367 reverse: reverse.clone(),
368 }]
369 }
370 OperationSpec::DefineSchema { schema } => {
371 let forward = schema
376 .to_typeql()
377 .map_err(|e| MigrationError::SchemaGeneration {
378 message: e.to_string(),
379 })?;
380 vec![ExecutionStep {
381 tx_type: TxType::Schema,
382 kind: StepKind::Schema,
383 operation_kind: OperationKind::DefineSchema,
384 forward,
385 reverse: None,
387 }]
388 }
389 OperationSpec::AddAttribute { attribute } => vec![schema_step(
390 define_attribute(attribute)?,
391 Some(undefine_attribute(&attribute.attr_name)),
392 )],
393 OperationSpec::RemoveAttribute { attr_name } => {
394 vec![schema_step(undefine_attribute(attr_name), None)]
395 }
396 OperationSpec::AddEntity { entity } => vec![schema_step(
397 define_entity(entity)?,
398 Some(undefine_entity(&entity.type_name)),
399 )],
400 OperationSpec::RemoveEntity { type_name } => {
401 vec![schema_step(undefine_entity(type_name), None)]
402 }
403 OperationSpec::AddRelation { relation } => vec![schema_step(
404 define_relation(relation)?,
405 Some(undefine_relation_with_players(relation)),
406 )],
407 OperationSpec::RemoveRelation { type_name } => {
408 vec![schema_step(undefine_relation(type_name), None)]
409 }
410 OperationSpec::AddOwnership {
411 owner_type,
412 attribute,
413 } => vec![schema_step(
414 define_ownership(owner_type, attribute),
415 Some(undefine_ownership(
416 owner_type,
417 &owned_attribute_type_ref(attribute),
418 )),
419 )],
420 OperationSpec::RemoveOwnership {
421 owner_type,
422 attr_name,
423 } => vec![schema_step(undefine_ownership(owner_type, attr_name), None)],
424 OperationSpec::ModifyOwnership {
425 owner_type,
426 attr_name,
427 old_annotations,
428 new_annotations,
429 } => annotation_token_steps(
430 &format!("{owner_type} owns {attr_name}"),
431 &diff_annotation_tokens(
432 &split_annotation_tokens(old_annotations),
433 &split_annotation_tokens(new_annotations),
434 ),
435 ),
436 OperationSpec::ModifyTypeAnnotations {
437 type_name,
438 old_doc,
439 new_doc,
440 old_meta,
441 new_meta,
442 } => annotation_token_steps(
443 type_name,
444 &diff_annotation_tokens(
445 &doc_meta_tokens(old_doc.as_deref(), old_meta),
446 &doc_meta_tokens(new_doc.as_deref(), new_meta),
447 ),
448 ),
449 OperationSpec::ModifyRoleAnnotations {
450 relation_type,
451 role_name,
452 old_doc,
453 new_doc,
454 old_meta,
455 new_meta,
456 } => annotation_token_steps(
457 &format!("{relation_type} relates {role_name}"),
458 &diff_annotation_tokens(
459 &doc_meta_tokens(old_doc.as_deref(), old_meta),
460 &doc_meta_tokens(new_doc.as_deref(), new_meta),
461 ),
462 ),
463 OperationSpec::AddRole {
464 relation_type,
465 role,
466 } => vec![schema_step(
467 define_role(relation_type, role),
468 Some(undefine_role_with_players(relation_type, role)),
469 )],
470 OperationSpec::RemoveRole {
471 relation_type,
472 role_name,
473 } => vec![schema_step(undefine_role(relation_type, role_name), None)],
474 OperationSpec::AddRolePlayer {
475 relation_type,
476 role_name,
477 player_type_name,
478 } => vec![schema_step(
479 define_role_player(relation_type, role_name, player_type_name),
480 Some(undefine_role_player(
481 relation_type,
482 role_name,
483 player_type_name,
484 )),
485 )],
486 OperationSpec::RemoveRolePlayer {
487 relation_type,
488 role_name,
489 player_type_name,
490 } => vec![schema_step(
491 undefine_role_player(relation_type, role_name, player_type_name),
492 Some(define_role_player(
493 relation_type,
494 role_name,
495 player_type_name,
496 )),
497 )],
498 copy @ OperationSpec::CopyAttribute { .. } => {
499 let (forward, reverse) = copy_attribute_typeql(copy)?;
504 vec![ExecutionStep {
505 tx_type: TxType::Write,
506 kind: StepKind::Backfill,
507 operation_kind: OperationKind::CopyAttribute,
508 forward,
509 reverse,
510 }]
511 }
512 other @ OperationSpec::RenameAttribute { .. } => {
513 return Err(MigrationError::UnloweredOperation {
514 kind: op_kind_name(other).to_string(),
515 });
516 }
517 };
518 if !migration_reversible {
519 for step in &mut op_steps {
520 step.reverse = None;
521 }
522 }
523 let operation_kind = OperationKind::from_spec(op);
524 for step in &mut op_steps {
525 step.operation_kind = operation_kind;
526 }
527 steps.append(&mut op_steps);
528 }
529 Ok(steps)
530}
531
532fn annotation_token_steps(subject: &str, diff: &AnnotationTokenDiff) -> Vec<ExecutionStep> {
544 let mut steps = Vec::new();
545 if !diff.removed.is_empty() {
546 let forward = typeql_block(
547 "undefine",
548 diff.removed
549 .iter()
550 .map(|token| format!("{} from {subject};", undefine_annotation_ref(token)))
551 .collect(),
552 );
553 let reverse = typeql_block(
554 "define",
555 diff.removed
556 .iter()
557 .map(|token| format!("{subject} {};", token.render()))
558 .collect(),
559 );
560 steps.push(schema_step(forward, Some(reverse)));
561 }
562 for (old_token, new_token) in &diff.changed {
563 steps.push(schema_step(
564 typeql_block(
565 "redefine",
566 vec![format!("{subject} {};", new_token.render())],
567 ),
568 Some(typeql_block(
569 "redefine",
570 vec![format!("{subject} {};", old_token.render())],
571 )),
572 ));
573 }
574 if !diff.added.is_empty() {
575 let forward = typeql_block(
576 "define",
577 diff.added
578 .iter()
579 .map(|token| format!("{subject} {};", token.render()))
580 .collect(),
581 );
582 let reverse = typeql_block(
583 "undefine",
584 diff.added
585 .iter()
586 .map(|token| format!("{} from {subject};", undefine_annotation_ref(token)))
587 .collect(),
588 );
589 steps.push(schema_step(forward, Some(reverse)));
590 }
591 steps
592}
593
594fn undefine_annotation_ref(token: &AnnotationToken) -> String {
599 if token.name == "meta"
600 && let Some(key) = token.meta_key()
601 {
602 return format!(
603 "@meta({})",
604 type_bridge_orm::schema::annotations::escaped_string_literal(&key)
605 );
606 }
607 format!("@{}", token.name)
608}
609
610fn doc_meta_tokens(
613 doc: Option<&str>,
614 meta: &std::collections::BTreeMap<String, String>,
615) -> Vec<AnnotationToken> {
616 let mut tokens = Vec::new();
617 if let Some(doc) = doc {
618 tokens.push(AnnotationToken::doc(doc));
619 }
620 for (key, value) in meta {
621 tokens.push(AnnotationToken::meta(key, value));
622 }
623 tokens
624}
625
626fn schema_step(forward: String, reverse: Option<String>) -> ExecutionStep {
627 ExecutionStep {
628 tx_type: TxType::Schema,
629 kind: StepKind::Schema,
630 operation_kind: OperationKind::RunTypeql,
631 forward,
632 reverse,
633 }
634}
635
636fn run_typeql_tx_type(forward: &str) -> TxType {
637 let first_statement = forward
638 .lines()
639 .map(str::trim)
640 .find(|line| !line.is_empty() && !line.starts_with('#') && !line.starts_with("//"))
641 .unwrap_or_default()
642 .to_ascii_lowercase();
643 if first_statement.starts_with("define")
644 || first_statement.starts_with("undefine")
645 || first_statement.starts_with("redefine")
646 {
647 TxType::Schema
648 } else {
649 TxType::Write
650 }
651}
652
653fn schema_to_typeql(schema: &SchemaInfo) -> crate::Result<String> {
654 schema
655 .to_typeql()
656 .map_err(|e| MigrationError::SchemaGeneration {
657 message: e.to_string(),
658 })
659}
660
661fn define_attribute(attribute: &AttributeSchemaEntry) -> crate::Result<String> {
662 let mut schema = SchemaInfo::default();
663 schema
664 .attributes
665 .insert(attribute.attr_name.clone(), attribute.clone());
666 schema_to_typeql(&schema)
667}
668
669fn undefine_attribute(attr_name: &str) -> String {
670 format!("undefine\n{attr_name};")
671}
672
673fn define_entity(entity: &EntitySchemaEntry) -> crate::Result<String> {
674 let mut schema = SchemaInfo::default();
675 schema
676 .entities
677 .insert(entity.type_name.clone(), entity.clone());
678 schema_to_typeql(&schema)
679}
680
681fn undefine_entity(type_name: &str) -> String {
682 format!("undefine\n{type_name};")
683}
684
685fn define_relation(relation: &RelationSchemaEntry) -> crate::Result<String> {
686 let mut schema = SchemaInfo::default();
687 schema
688 .relations
689 .insert(relation.type_name.clone(), relation.clone());
690 schema_to_typeql(&schema)
691}
692
693fn undefine_relation(type_name: &str) -> String {
694 format!("undefine\n{type_name};")
695}
696
697fn undefine_relation_with_players(relation: &RelationSchemaEntry) -> String {
698 let mut statements = Vec::new();
699 for role in &relation.roles {
700 for player_type_name in &role.player_type_names {
701 statements.push(format!(
702 "plays {}:{} from {player_type_name};",
703 relation.type_name, role.role_name
704 ));
705 }
706 }
707 statements.push(format!("{};", relation.type_name));
708 typeql_block("undefine", statements)
709}
710
711fn define_ownership(owner_type: &str, attribute: &OwnedAttributeEntry) -> String {
712 let attr_ref = owned_attribute_type_ref(attribute);
713 let flags = annotation_suffix(&attribute.flags_string());
714 typeql_block(
715 "define",
716 vec![format!("{owner_type} owns {attr_ref}{flags};")],
717 )
718}
719
720fn undefine_ownership(owner_type: &str, attr_name: &str) -> String {
721 typeql_block(
722 "undefine",
723 vec![format!("owns {attr_name} from {owner_type};")],
724 )
725}
726
727fn owned_attribute_type_ref(attribute: &OwnedAttributeEntry) -> String {
728 if attribute.is_ordered {
729 format!("{}[]", attribute.attr_name)
730 } else {
731 attribute.attr_name.clone()
732 }
733}
734
735fn define_role(relation_type: &str, role: &RoleEntry) -> String {
736 let mut statements = vec![format!(
737 "{relation_type} relates {};",
738 role_definition(role)
739 )];
740 for player_type_name in &role.player_type_names {
741 statements.push(format!(
742 "{player_type_name} plays {relation_type}:{};",
743 role.role_name
744 ));
745 }
746 typeql_block("define", statements)
747}
748
749fn undefine_role(relation_type: &str, role_name: &str) -> String {
750 typeql_block(
751 "undefine",
752 vec![format!("relates {role_name} from {relation_type};")],
753 )
754}
755
756fn undefine_role_with_players(relation_type: &str, role: &RoleEntry) -> String {
757 let mut statements = Vec::new();
758 for player_type_name in &role.player_type_names {
759 statements.push(format!(
760 "plays {relation_type}:{} from {player_type_name};",
761 role.role_name
762 ));
763 }
764 statements.push(format!(
765 "relates {} from {relation_type};",
766 role_type_ref(role)
767 ));
768 typeql_block("undefine", statements)
769}
770
771fn define_role_player(relation_type: &str, role_name: &str, player_type_name: &str) -> String {
772 typeql_block(
773 "define",
774 vec![format!(
775 "{player_type_name} plays {relation_type}:{role_name};"
776 )],
777 )
778}
779
780fn undefine_role_player(relation_type: &str, role_name: &str, player_type_name: &str) -> String {
781 typeql_block(
782 "undefine",
783 vec![format!(
784 "plays {relation_type}:{role_name} from {player_type_name};"
785 )],
786 )
787}
788
789fn role_definition(role: &RoleEntry) -> String {
790 let mut definition = role_type_ref(role);
791 if let Some(ref parent_role) = role.overrides {
792 definition.push_str(&format!(" as {parent_role}"));
793 }
794 if role.is_abstract {
795 definition.push_str(" @abstract");
796 }
797 if role.distinct {
798 definition.push_str(" @distinct");
799 }
800 if let Some((min, max)) = role.cardinality {
801 definition.push(' ');
802 definition.push_str(&card_annotation(min, max));
803 }
804 definition
805}
806
807fn role_type_ref(role: &RoleEntry) -> String {
808 if role.ordered {
809 format!("{}[]", role.role_name)
810 } else {
811 role.role_name.clone()
812 }
813}
814
815fn card_annotation(min: u32, max: Option<u32>) -> String {
816 let max_str = max.map(|value| value.to_string()).unwrap_or_default();
817 format!("@card({min}..{max_str})")
818}
819
820fn annotation_suffix(annotations: &str) -> String {
821 let trimmed = annotations.trim();
822 if trimmed.is_empty() {
823 String::new()
824 } else {
825 format!(" {trimmed}")
826 }
827}
828
829fn typeql_block(keyword: &str, statements: Vec<String>) -> String {
830 format!("{keyword}\n{}", statements.join("\n"))
831}
832
833fn op_kind_name(op: &OperationSpec) -> &'static str {
837 match op {
838 OperationSpec::RunTypeql { .. } => "RunTypeql",
839 OperationSpec::DefineSchema { .. } => "DefineSchema",
840 OperationSpec::AddAttribute { .. } => "AddAttribute",
841 OperationSpec::RemoveAttribute { .. } => "RemoveAttribute",
842 OperationSpec::AddEntity { .. } => "AddEntity",
843 OperationSpec::RemoveEntity { .. } => "RemoveEntity",
844 OperationSpec::AddRelation { .. } => "AddRelation",
845 OperationSpec::RemoveRelation { .. } => "RemoveRelation",
846 OperationSpec::AddOwnership { .. } => "AddOwnership",
847 OperationSpec::RemoveOwnership { .. } => "RemoveOwnership",
848 OperationSpec::ModifyOwnership { .. } => "ModifyOwnership",
849 OperationSpec::ModifyTypeAnnotations { .. } => "ModifyTypeAnnotations",
850 OperationSpec::ModifyRoleAnnotations { .. } => "ModifyRoleAnnotations",
851 OperationSpec::AddRole { .. } => "AddRole",
852 OperationSpec::RemoveRole { .. } => "RemoveRole",
853 OperationSpec::AddRolePlayer { .. } => "AddRolePlayer",
854 OperationSpec::RemoveRolePlayer { .. } => "RemoveRolePlayer",
855 OperationSpec::RenameAttribute { .. } => "RenameAttribute",
856 OperationSpec::CopyAttribute { .. } => "CopyAttribute",
857 }
858}
859
860#[cfg(test)]
861mod tests {
862 use std::collections::BTreeMap;
863
864 use super::*;
865 use type_bridge_orm::schema::info::{
866 AttributeSchemaEntry, EntitySchemaEntry, OwnedAttributeEntry, RelationSchemaEntry,
867 RoleEntry, SchemaInfo,
868 };
869 use type_bridge_orm::{Annotation, ValueType};
870
871 use crate::graph::AppliedMigrationRecord;
872 use crate::spec::{MigrationDependencySpec, MigrationGraph, MigrationSpec, OperationSpec};
873
874 fn run_typeql(forward: &str, reverse: Option<&str>) -> OperationSpec {
877 OperationSpec::RunTypeql {
878 forward: forward.to_string(),
879 reverse: reverse.map(str::to_string),
880 }
881 }
882
883 fn define_schema_op() -> OperationSpec {
884 let mut schema = SchemaInfo::default();
885 schema.attributes.insert(
886 "name".to_string(),
887 AttributeSchemaEntry::new("name", ValueType::String),
888 );
889 schema.entities.insert(
890 "person".to_string(),
891 EntitySchemaEntry {
892 type_name: "person".to_string(),
893 is_abstract: false,
894 parent_type: None,
895 owned_attributes: vec![OwnedAttributeEntry {
896 attr_name: "name".to_string(),
897 value_type: ValueType::String,
898 annotations: vec![Annotation::Key],
899 is_ordered: false,
900 doc: None,
901 meta: Default::default(),
902 }],
903 plays_cardinalities: BTreeMap::new(),
904 doc: None,
905 meta: Default::default(),
906 },
907 );
908 OperationSpec::DefineSchema { schema }
909 }
910
911 fn owned_attr(
912 attr_name: &str,
913 value_type: ValueType,
914 annotations: Vec<Annotation>,
915 ) -> OwnedAttributeEntry {
916 OwnedAttributeEntry {
917 attr_name: attr_name.to_string(),
918 value_type,
919 annotations,
920 is_ordered: false,
921 doc: None,
922 meta: Default::default(),
923 }
924 }
925
926 fn entity_entry(type_name: &str) -> EntitySchemaEntry {
927 EntitySchemaEntry {
928 type_name: type_name.to_string(),
929 is_abstract: false,
930 parent_type: None,
931 owned_attributes: vec![owned_attr("name", ValueType::String, vec![Annotation::Key])],
932 plays_cardinalities: BTreeMap::new(),
933 doc: None,
934 meta: Default::default(),
935 }
936 }
937
938 fn relation_entry(type_name: &str) -> RelationSchemaEntry {
939 RelationSchemaEntry {
940 type_name: type_name.to_string(),
941 is_abstract: false,
942 parent_type: None,
943 owned_attributes: vec![],
944 roles: vec![RoleEntry {
945 role_name: "employee".to_string(),
946 player_type_names: vec!["person".to_string()],
947 cardinality: None,
948 overrides: None,
949 is_abstract: false,
950 ordered: false,
951 distinct: false,
952 doc: None,
953 meta: Default::default(),
954 }],
955 plays_cardinalities: BTreeMap::new(),
956 doc: None,
957 meta: Default::default(),
958 }
959 }
960
961 fn migration(name: &str, ops: Vec<OperationSpec>, deps: Vec<(&str, &str)>) -> MigrationSpec {
962 MigrationSpec {
963 app_label: "app".to_string(),
964 name: name.to_string(),
965 dependencies: deps
966 .into_iter()
967 .map(|(app, dep_name)| MigrationDependencySpec {
968 app_label: app.to_string(),
969 migration_name: dep_name.to_string(),
970 })
971 .collect(),
972 operations: ops,
973 checksum: Some(format!("{name}-csum")),
974 source_sha256: None,
975 reversible: true,
976 }
977 }
978
979 fn applied(name: &str) -> AppliedMigrationRecord {
980 AppliedMigrationRecord {
981 app_label: "app".to_string(),
982 name: name.to_string(),
983 checksum: format!("{name}-csum"),
984 applied_at: None,
985 }
986 }
987
988 fn graph(migrations: Vec<MigrationSpec>) -> MigrationGraph {
989 MigrationGraph { migrations }
990 }
991
992 #[test]
995 fn pending_only_all_pending_applies_in_order() {
996 let g = graph(vec![
997 migration(
998 "0001_initial",
999 vec![run_typeql("define attribute a, value string;", None)],
1000 vec![],
1001 ),
1002 migration(
1003 "0002_add",
1004 vec![run_typeql(
1005 "define attribute b, value string;",
1006 Some("undefine attribute b;"),
1007 )],
1008 vec![("app", "0001_initial")],
1009 ),
1010 ]);
1011
1012 let result = plan(&g, &[], None).expect("plan should succeed");
1013
1014 assert_eq!(result.to_apply.len(), 2);
1015 assert_eq!(result.to_rollback.len(), 0);
1016 assert_eq!(result.to_apply[0].name, "0001_initial");
1017 assert_eq!(result.to_apply[1].name, "0002_add");
1018 }
1019
1020 #[test]
1021 fn pending_only_already_applied_excluded() {
1022 let g = graph(vec![
1023 migration(
1024 "0001_initial",
1025 vec![run_typeql("define attribute a, value string;", None)],
1026 vec![],
1027 ),
1028 migration(
1029 "0002_add",
1030 vec![run_typeql(
1031 "define attribute b, value string;",
1032 Some("undefine attribute b;"),
1033 )],
1034 vec![("app", "0001_initial")],
1035 ),
1036 ]);
1037
1038 let result = plan(&g, &[applied("0001_initial")], None).expect("plan should succeed");
1039
1040 assert_eq!(result.to_apply.len(), 1);
1041 assert_eq!(result.to_apply[0].name, "0002_add");
1042 assert_eq!(result.to_rollback.len(), 0);
1043 }
1044
1045 #[test]
1048 fn target_applies_up_to_and_including_target() {
1049 let g = graph(vec![
1050 migration(
1051 "0001_initial",
1052 vec![run_typeql("define attribute a, value string;", None)],
1053 vec![],
1054 ),
1055 migration(
1056 "0002_add",
1057 vec![run_typeql(
1058 "define attribute b, value string;",
1059 Some("undefine attribute b;"),
1060 )],
1061 vec![("app", "0001_initial")],
1062 ),
1063 migration(
1064 "0003_more",
1065 vec![run_typeql(
1066 "define attribute c, value string;",
1067 Some("undefine attribute c;"),
1068 )],
1069 vec![("app", "0002_add")],
1070 ),
1071 ]);
1072
1073 let result = plan(&g, &[], Some("0002_add")).expect("plan should succeed");
1075
1076 assert_eq!(result.to_apply.len(), 2);
1077 assert_eq!(result.to_apply[0].name, "0001_initial");
1078 assert_eq!(result.to_apply[1].name, "0002_add");
1079 assert_eq!(result.to_rollback.len(), 0);
1080 }
1081
1082 #[test]
1083 fn target_rolls_back_past_target_in_reverse_order() {
1084 let g = graph(vec![
1085 migration(
1086 "0001_initial",
1087 vec![run_typeql("define attribute a, value string;", None)],
1088 vec![],
1089 ),
1090 migration(
1091 "0002_add",
1092 vec![run_typeql(
1093 "define attribute b, value string;",
1094 Some("undefine attribute b;"),
1095 )],
1096 vec![("app", "0001_initial")],
1097 ),
1098 migration(
1099 "0003_more",
1100 vec![run_typeql(
1101 "define attribute c, value string;",
1102 Some("undefine attribute c;"),
1103 )],
1104 vec![("app", "0002_add")],
1105 ),
1106 ]);
1107
1108 let result = plan(
1110 &g,
1111 &[
1112 applied("0001_initial"),
1113 applied("0002_add"),
1114 applied("0003_more"),
1115 ],
1116 Some("0001_initial"),
1117 )
1118 .expect("plan should succeed");
1119
1120 assert_eq!(result.to_apply.len(), 0);
1121 assert_eq!(result.to_rollback.len(), 2);
1123 assert_eq!(result.to_rollback[0].name, "0003_more");
1124 assert_eq!(result.to_rollback[1].name, "0002_add");
1125 }
1126
1127 #[test]
1130 fn rollback_execution_action_is_rollback() {
1131 let g = graph(vec![
1132 migration(
1133 "0001_initial",
1134 vec![run_typeql("define attribute a, value string;", None)],
1135 vec![],
1136 ),
1137 migration(
1138 "0002_add",
1139 vec![run_typeql(
1140 "define attribute b, value string;",
1141 Some("undefine attribute b;"),
1142 )],
1143 vec![("app", "0001_initial")],
1144 ),
1145 ]);
1146
1147 let result = plan(
1148 &g,
1149 &[applied("0001_initial"), applied("0002_add")],
1150 Some("0001_initial"),
1151 )
1152 .expect("plan should succeed");
1153
1154 assert_eq!(result.to_rollback[0].action, MigrationAction::Rollback);
1155 }
1156
1157 #[test]
1160 fn define_schema_step_carries_typeql_from_generator() {
1161 let g = graph(vec![migration(
1162 "0001_initial",
1163 vec![define_schema_op()],
1164 vec![],
1165 )]);
1166
1167 let result = plan(&g, &[], None).expect("plan should succeed");
1168
1169 let exec = &result.to_apply[0];
1170 assert_eq!(exec.steps.len(), 1);
1171 let step = &exec.steps[0];
1172 assert!(
1174 !step.forward.is_empty(),
1175 "DefineSchema forward must be non-empty"
1176 );
1177 assert!(
1178 step.forward.contains("define"),
1179 "DefineSchema forward must contain 'define'"
1180 );
1181 assert!(step.reverse.is_none());
1183 }
1184
1185 #[test]
1186 fn define_schema_step_tx_type_is_schema() {
1187 let g = graph(vec![migration(
1188 "0001_initial",
1189 vec![define_schema_op()],
1190 vec![],
1191 )]);
1192
1193 let result = plan(&g, &[], None).expect("plan should succeed");
1194 assert_eq!(result.to_apply[0].steps[0].tx_type, TxType::Schema);
1195 }
1196
1197 #[test]
1200 fn run_typeql_step_tx_type_is_schema() {
1201 let g = graph(vec![migration(
1202 "0001_add",
1203 vec![run_typeql("define attribute a, value string;", None)],
1204 vec![],
1205 )]);
1206
1207 let result = plan(&g, &[], None).expect("plan should succeed");
1208 assert_eq!(result.to_apply[0].steps[0].tx_type, TxType::Schema);
1209 }
1210
1211 #[test]
1212 fn data_run_typeql_step_tx_type_is_write() {
1213 let g = graph(vec![migration(
1214 "0002_seed",
1215 vec![run_typeql(
1216 r#"match $a isa account, has account-id "acct-001";
1217insert $a has email "ops@example.com";"#,
1218 Some(
1219 r#"match $a isa account, has email "ops@example.com";
1220delete $a has email "ops@example.com";"#,
1221 ),
1222 )],
1223 vec![],
1224 )]);
1225
1226 let result = plan(&g, &[], None).expect("plan should succeed");
1227 let step = &result.to_apply[0].steps[0];
1228 assert_eq!(step.tx_type, TxType::Write);
1229 assert_eq!(step.kind, StepKind::Write);
1230 }
1231
1232 #[test]
1235 fn typed_attribute_operations_lower_to_schema_steps() {
1236 let g = graph(vec![migration(
1237 "0001_attrs",
1238 vec![
1239 OperationSpec::AddAttribute {
1240 attribute: AttributeSchemaEntry::new("score", ValueType::Long),
1241 },
1242 OperationSpec::RemoveAttribute {
1243 attr_name: "legacy-score".to_string(),
1244 },
1245 ],
1246 vec![],
1247 )]);
1248
1249 let result = plan(&g, &[], None).expect("plan should succeed");
1250 let steps = &result.to_apply[0].steps;
1251
1252 assert_eq!(steps.len(), 2);
1253 assert!(steps[0].forward.contains("attribute score, value integer;"));
1254 assert_eq!(steps[0].reverse.as_deref(), Some("undefine\nscore;"));
1255 assert_eq!(steps[1].forward, "undefine\nlegacy-score;");
1256 assert!(steps[1].reverse.is_none());
1257 assert!(!result.to_apply[0].reversible);
1258 }
1259
1260 #[test]
1261 fn typed_entity_and_relation_operations_lower_to_schema_steps() {
1262 let g = graph(vec![migration(
1263 "0001_types",
1264 vec![
1265 OperationSpec::AddEntity {
1266 entity: entity_entry("person"),
1267 },
1268 OperationSpec::AddRelation {
1269 relation: relation_entry("employment"),
1270 },
1271 ],
1272 vec![],
1273 )]);
1274
1275 let result = plan(&g, &[], None).expect("plan should succeed");
1276 let steps = &result.to_apply[0].steps;
1277
1278 assert!(steps[0].forward.contains("entity person,"));
1279 assert!(steps[0].forward.contains("owns name @key;"));
1280 assert_eq!(steps[0].reverse.as_deref(), Some("undefine\nperson;"));
1281 assert!(steps[1].forward.contains("relation employment,"));
1282 assert!(steps[1].forward.contains("relates employee;"));
1283 assert!(
1284 steps[1]
1285 .forward
1286 .contains("person plays employment:employee;")
1287 );
1288 assert!(
1289 steps[1]
1290 .reverse
1291 .as_deref()
1292 .unwrap()
1293 .contains("plays employment:employee from person;")
1294 );
1295 assert!(steps[1].reverse.as_deref().unwrap().contains("employment;"));
1296 }
1297
1298 #[test]
1299 fn add_entity_with_parent_outside_singleton_schema_lowers_without_panic() {
1300 let mut child = entity_entry("person");
1304 child.parent_type = Some("animal".to_string());
1305 let g = graph(vec![migration(
1306 "0001_sub_entity",
1307 vec![OperationSpec::AddEntity { entity: child }],
1308 vec![],
1309 )]);
1310
1311 let result = plan(&g, &[], None).expect("plan should succeed");
1312 let steps = &result.to_apply[0].steps;
1313
1314 assert_eq!(steps.len(), 1);
1315 assert!(steps[0].forward.contains("entity person sub animal,"));
1316 assert_eq!(steps[0].reverse.as_deref(), Some("undefine\nperson;"));
1317 }
1318
1319 #[test]
1320 fn typed_ownership_operations_lower_to_schema_steps() {
1321 let g = graph(vec![migration(
1322 "0001_ownership",
1323 vec![
1324 OperationSpec::AddOwnership {
1325 owner_type: "person".to_string(),
1326 attribute: owned_attr("email", ValueType::String, vec![Annotation::Key]),
1327 },
1328 OperationSpec::RemoveOwnership {
1329 owner_type: "person".to_string(),
1330 attr_name: "legacy-email".to_string(),
1331 },
1332 OperationSpec::ModifyOwnership {
1333 owner_type: "person".to_string(),
1334 attr_name: "nickname".to_string(),
1335 old_annotations: "@card(0..1)".to_string(),
1336 new_annotations: "@card(1..1)".to_string(),
1337 },
1338 ],
1339 vec![],
1340 )]);
1341
1342 let result = plan(&g, &[], None).expect("plan should succeed");
1343 let steps = &result.to_apply[0].steps;
1344
1345 assert_eq!(steps[0].forward, "define\nperson owns email @key;");
1346 assert_eq!(
1347 steps[0].reverse.as_deref(),
1348 Some("undefine\nowns email from person;")
1349 );
1350 assert_eq!(steps[1].forward, "undefine\nowns legacy-email from person;");
1351 assert!(steps[1].reverse.is_none());
1352 assert_eq!(
1353 steps[2].forward,
1354 "redefine\nperson owns nickname @card(1..1);"
1355 );
1356 assert_eq!(
1357 steps[2].reverse.as_deref(),
1358 Some("redefine\nperson owns nickname @card(0..1);")
1359 );
1360 }
1361
1362 #[test]
1363 fn modify_ownership_decomposes_parameterless_transitions() {
1364 let g = graph(vec![migration(
1369 "0002_key",
1370 vec![OperationSpec::ModifyOwnership {
1371 owner_type: "person".to_string(),
1372 attr_name: "nickname".to_string(),
1373 old_annotations: "@card(0..1)".to_string(),
1374 new_annotations: "@key".to_string(),
1375 }],
1376 vec![],
1377 )]);
1378
1379 let result = plan(&g, &[], None).expect("plan should succeed");
1380 let steps = &result.to_apply[0].steps;
1381
1382 assert_eq!(steps.len(), 2);
1383 assert_eq!(
1384 steps[0].forward,
1385 "undefine\n@card from person owns nickname;"
1386 );
1387 assert_eq!(
1388 steps[0].reverse.as_deref(),
1389 Some("define\nperson owns nickname @card(0..1);")
1390 );
1391 assert_eq!(steps[1].forward, "define\nperson owns nickname @key;");
1392 assert_eq!(
1393 steps[1].reverse.as_deref(),
1394 Some("undefine\n@key from person owns nickname;")
1395 );
1396 }
1397
1398 #[test]
1399 fn modify_ownership_from_plain_defines_and_identical_sets_lower_to_nothing() {
1400 let g = graph(vec![migration(
1401 "0002_tighten",
1402 vec![
1403 OperationSpec::ModifyOwnership {
1404 owner_type: "person".to_string(),
1405 attr_name: "nickname".to_string(),
1406 old_annotations: String::new(),
1407 new_annotations: "@key".to_string(),
1408 },
1409 OperationSpec::ModifyOwnership {
1410 owner_type: "person".to_string(),
1411 attr_name: "email".to_string(),
1412 old_annotations: "@unique".to_string(),
1413 new_annotations: "@unique".to_string(),
1414 },
1415 ],
1416 vec![],
1417 )]);
1418
1419 let result = plan(&g, &[], None).expect("plan should succeed");
1420 let steps = &result.to_apply[0].steps;
1421
1422 assert_eq!(steps.len(), 1);
1424 assert_eq!(steps[0].forward, "define\nperson owns nickname @key;");
1425 assert_eq!(
1426 steps[0].reverse.as_deref(),
1427 Some("undefine\n@key from person owns nickname;")
1428 );
1429 }
1430
1431 #[test]
1432 fn typed_role_operations_lower_to_schema_steps() {
1433 let role = RoleEntry {
1434 role_name: "reviewer".to_string(),
1435 player_type_names: vec!["person".to_string()],
1436 cardinality: Some((0, Some(2))),
1437 overrides: None,
1438 is_abstract: false,
1439 ordered: false,
1440 distinct: false,
1441 doc: None,
1442 meta: Default::default(),
1443 };
1444 let g = graph(vec![migration(
1445 "0001_roles",
1446 vec![
1447 OperationSpec::AddRole {
1448 relation_type: "employment".to_string(),
1449 role,
1450 },
1451 OperationSpec::RemoveRole {
1452 relation_type: "employment".to_string(),
1453 role_name: "legacy".to_string(),
1454 },
1455 OperationSpec::AddRolePlayer {
1456 relation_type: "employment".to_string(),
1457 role_name: "employee".to_string(),
1458 player_type_name: "contractor".to_string(),
1459 },
1460 OperationSpec::RemoveRolePlayer {
1461 relation_type: "employment".to_string(),
1462 role_name: "employee".to_string(),
1463 player_type_name: "company".to_string(),
1464 },
1465 ],
1466 vec![],
1467 )]);
1468
1469 let result = plan(&g, &[], None).expect("plan should succeed");
1470 let steps = &result.to_apply[0].steps;
1471
1472 assert_eq!(
1473 steps[0].forward,
1474 "define\nemployment relates reviewer @card(0..2);\nperson plays employment:reviewer;"
1475 );
1476 assert_eq!(
1477 steps[0].reverse.as_deref(),
1478 Some(
1479 "undefine\nplays employment:reviewer from person;\nrelates reviewer from employment;"
1480 )
1481 );
1482 assert_eq!(
1483 steps[1].forward,
1484 "undefine\nrelates legacy from employment;"
1485 );
1486 assert!(steps[1].reverse.is_none());
1487 assert_eq!(
1488 steps[2].forward,
1489 "define\ncontractor plays employment:employee;"
1490 );
1491 assert_eq!(
1492 steps[2].reverse.as_deref(),
1493 Some("undefine\nplays employment:employee from contractor;")
1494 );
1495 assert_eq!(
1496 steps[3].forward,
1497 "undefine\nplays employment:employee from company;"
1498 );
1499 assert_eq!(
1500 steps[3].reverse.as_deref(),
1501 Some("define\ncompany plays employment:employee;")
1502 );
1503 }
1504
1505 #[test]
1506 fn migration_reversible_flag_drops_typed_operation_reverses() {
1507 let mut spec = migration(
1508 "0001_non_reversible",
1509 vec![OperationSpec::AddAttribute {
1510 attribute: AttributeSchemaEntry::new("score", ValueType::Long),
1511 }],
1512 vec![],
1513 );
1514 spec.reversible = false;
1515 let g = graph(vec![spec]);
1516
1517 let result = plan(&g, &[], None).expect("plan should succeed");
1518
1519 assert!(result.to_apply[0].steps[0].reverse.is_none());
1520 assert!(!result.to_apply[0].reversible);
1521 }
1522
1523 #[test]
1526 fn unlowered_op_returns_err() {
1527 let g = graph(vec![migration(
1528 "0001_rename_attr",
1529 vec![OperationSpec::RenameAttribute {
1530 old_name: "old-score".to_string(),
1531 new_name: "new-score".to_string(),
1532 value_type: "string".to_string(),
1533 }],
1534 vec![],
1535 )]);
1536
1537 let err = plan(&g, &[], None).expect_err("should fail for unsupported op");
1538 match err {
1539 MigrationError::UnloweredOperation { kind } => {
1540 assert_eq!(kind, "RenameAttribute");
1541 }
1542 other => panic!("expected UnloweredOperation, got {other:?}"),
1543 }
1544 }
1545
1546 #[test]
1549 fn validation_failure_returns_planning_error() {
1550 let g = graph(vec![migration(
1552 "0002_next",
1553 vec![run_typeql("define attribute b, value string;", None)],
1554 vec![("app", "0001_initial")],
1555 )]);
1556
1557 let err = plan(&g, &[], None).expect_err("should fail on validation error");
1558 assert!(
1559 matches!(err, MigrationError::Planning { .. }),
1560 "expected Planning error, got {err:?}"
1561 );
1562 }
1563
1564 #[test]
1567 fn checksum_drift_returns_error() {
1568 let g = graph(vec![migration(
1569 "0001_initial",
1570 vec![run_typeql("define attribute a, value string;", None)],
1571 vec![],
1572 )]);
1573
1574 let bad_applied = AppliedMigrationRecord {
1576 app_label: "app".to_string(),
1577 name: "0001_initial".to_string(),
1578 checksum: "wrong-checksum".to_string(),
1579 applied_at: None,
1580 };
1581
1582 let err = plan(&g, &[bad_applied], None).expect_err("should fail on drift");
1583 assert!(
1584 matches!(err, MigrationError::ChecksumDrift { .. }),
1585 "expected ChecksumDrift error, got {err:?}"
1586 );
1587 }
1588
1589 #[test]
1592 fn migration_with_no_reverse_is_marked_not_reversible() {
1593 let g = graph(vec![migration(
1594 "0001_initial",
1595 vec![run_typeql("define attribute a, value string;", None)],
1597 vec![],
1598 )]);
1599
1600 let result = plan(&g, &[], None).expect("plan should succeed");
1601 assert!(!result.to_apply[0].reversible);
1602 }
1603
1604 #[test]
1605 fn migration_with_all_reverses_is_reversible() {
1606 let g = graph(vec![migration(
1607 "0001_add",
1608 vec![run_typeql(
1609 "define attribute a, value string;",
1610 Some("undefine attribute a;"),
1611 )],
1612 vec![],
1613 )]);
1614
1615 let result = plan(&g, &[], None).expect("plan should succeed");
1616 assert!(result.to_apply[0].reversible);
1617 }
1618
1619 #[test]
1620 fn define_schema_migration_is_not_reversible() {
1621 let g = graph(vec![migration(
1623 "0001_initial",
1624 vec![define_schema_op()],
1625 vec![],
1626 )]);
1627
1628 let result = plan(&g, &[], None).expect("plan should succeed");
1629 assert!(!result.to_apply[0].reversible);
1630 }
1631
1632 #[test]
1635 fn unknown_target_returns_target_not_found_error() {
1636 let g = graph(vec![migration(
1637 "0001_initial",
1638 vec![run_typeql("define attribute a, value string;", None)],
1639 vec![],
1640 )]);
1641
1642 let err = plan(&g, &[], Some("nonexistent_migration"))
1643 .expect_err("should fail for missing target");
1644 assert!(
1645 matches!(err, MigrationError::TargetNotFound { .. }),
1646 "expected TargetNotFound, got {err:?}"
1647 );
1648 }
1649
1650 #[test]
1653 fn copy_attribute_lowers_to_write_typed_backfill_step() {
1654 let forward = "match\n $x isa person, has old-name $v;\n \
1658 not { $x has new-name $d; };\ninsert\n $x has new-name == $v;";
1659 let reverse = "match $x isa person, has new-name $v;\ndelete $v of $x;";
1660 let g = graph(vec![migration(
1661 "0002_backfill",
1662 vec![OperationSpec::CopyAttribute {
1663 owner: None,
1664 source: None,
1665 dest: None,
1666 filter: None,
1667 forward: Some(forward.to_string()),
1668 reverse: Some(reverse.to_string()),
1669 }],
1670 vec![],
1671 )]);
1672
1673 let result = plan(&g, &[], None).expect("plan should succeed");
1674
1675 let exec = &result.to_apply[0];
1676 assert_eq!(exec.steps.len(), 1);
1677 let step = &exec.steps[0];
1678
1679 assert_eq!(
1680 step.tx_type,
1681 TxType::Write,
1682 "CopyAttribute step must use Write tx"
1683 );
1684 assert_eq!(
1685 step.kind,
1686 StepKind::Backfill,
1687 "CopyAttribute step kind must be Backfill"
1688 );
1689 assert_eq!(step.forward, forward, "forward must be carried verbatim");
1691 assert_eq!(
1692 step.reverse.as_deref(),
1693 Some(reverse),
1694 "reverse must be carried verbatim"
1695 );
1696 }
1697
1698 #[test]
1699 fn structured_copy_attribute_lowers_to_the_same_backfill_step() {
1700 let g = graph(vec![migration(
1703 "0002_backfill",
1704 vec![OperationSpec::CopyAttribute {
1705 owner: Some("person".to_string()),
1706 source: Some("old-name".to_string()),
1707 dest: Some("new-name".to_string()),
1708 filter: None,
1709 forward: None,
1710 reverse: None,
1711 }],
1712 vec![],
1713 )]);
1714
1715 let result = plan(&g, &[], None).expect("plan should succeed");
1716
1717 let step = &result.to_apply[0].steps[0];
1718 assert_eq!(step.tx_type, TxType::Write);
1719 assert_eq!(step.kind, StepKind::Backfill);
1720 assert_eq!(
1721 step.forward,
1722 "match\n $x isa person, has old-name $v;\n \
1723 not { $x has new-name $d; };\ninsert\n $x has new-name == $v;"
1724 );
1725 assert_eq!(
1726 step.reverse.as_deref(),
1727 Some("match $x isa person, has new-name $v;\ndelete $v of $x;")
1728 );
1729 }
1730
1731 #[test]
1732 fn step_kind_default_is_schema_for_serde_backcompat() {
1733 let json =
1735 r#"{"tx_type":"Schema","forward":"define attribute a, value string;","reverse":null}"#;
1736 let step: ExecutionStep =
1737 serde_json::from_str(json).expect("should deserialize legacy step");
1738 assert_eq!(
1739 step.kind,
1740 StepKind::Schema,
1741 "missing `kind` field must default to Schema for backward compat"
1742 );
1743 assert_eq!(step.operation_kind, OperationKind::RunTypeql);
1744 }
1745
1746 fn legacy_remove_relation_ops(relation: &str) -> Vec<OperationSpec> {
1751 vec![
1752 OperationSpec::RemoveRolePlayer {
1753 relation_type: relation.to_string(),
1754 role_name: "subject".to_string(),
1755 player_type_name: "person".to_string(),
1756 },
1757 OperationSpec::RemoveRole {
1758 relation_type: relation.to_string(),
1759 role_name: "subject".to_string(),
1760 },
1761 OperationSpec::RemoveRolePlayer {
1762 relation_type: relation.to_string(),
1763 role_name: "badge".to_string(),
1764 player_type_name: "temporary-badge".to_string(),
1765 },
1766 OperationSpec::RemoveRole {
1767 relation_type: relation.to_string(),
1768 role_name: "badge".to_string(),
1769 },
1770 OperationSpec::RemoveOwnership {
1771 owner_type: relation.to_string(),
1772 attr_name: "legacy-link-id".to_string(),
1773 },
1774 OperationSpec::RemoveRelation {
1775 type_name: relation.to_string(),
1776 },
1777 ]
1778 }
1779
1780 #[test]
1781 fn legacy_decomposed_relation_removal_normalizes_to_single_step() {
1782 let g = graph(vec![migration(
1783 "0005_remove_legacy_link",
1784 legacy_remove_relation_ops("legacy-link"),
1785 vec![],
1786 )]);
1787
1788 let result = plan(&g, &[], None).expect("plan should succeed");
1789
1790 let exec = &result.to_apply[0];
1791 assert_eq!(
1792 exec.steps.len(),
1793 1,
1794 "granular removals shadowed by RemoveRelation must be dropped"
1795 );
1796 assert_eq!(exec.steps[0].forward, "undefine\nlegacy-link;");
1797 assert_eq!(exec.steps[0].tx_type, TxType::Schema);
1798 }
1799
1800 #[test]
1801 fn surviving_relation_granular_removals_are_kept() {
1802 let ops = vec![
1804 OperationSpec::RemoveRolePlayer {
1805 relation_type: "employment".to_string(),
1806 role_name: "employee".to_string(),
1807 player_type_name: "contractor".to_string(),
1808 },
1809 OperationSpec::RemoveRole {
1810 relation_type: "employment".to_string(),
1811 role_name: "reviewer".to_string(),
1812 },
1813 OperationSpec::RemoveOwnership {
1814 owner_type: "employment".to_string(),
1815 attr_name: "note".to_string(),
1816 },
1817 ];
1818 let g = graph(vec![migration("0002_trim_employment", ops, vec![])]);
1819
1820 let result = plan(&g, &[], None).expect("plan should succeed");
1821
1822 assert_eq!(result.to_apply[0].steps.len(), 3);
1823 }
1824
1825 #[test]
1826 fn normalization_is_scoped_to_the_removed_relation() {
1827 let mut ops = legacy_remove_relation_ops("legacy-link");
1830 ops.push(OperationSpec::RemoveRolePlayer {
1831 relation_type: "employment".to_string(),
1832 role_name: "employee".to_string(),
1833 player_type_name: "contractor".to_string(),
1834 });
1835 ops.push(OperationSpec::RemoveOwnership {
1836 owner_type: "person".to_string(),
1837 attr_name: "nickname".to_string(),
1838 });
1839 let g = graph(vec![migration("0006_mixed_removals", ops, vec![])]);
1840
1841 let result = plan(&g, &[], None).expect("plan should succeed");
1842
1843 let forwards: Vec<&str> = result.to_apply[0]
1844 .steps
1845 .iter()
1846 .map(|s| s.forward.as_str())
1847 .collect();
1848 assert_eq!(
1849 forwards,
1850 vec![
1851 "undefine\nlegacy-link;",
1852 "undefine\nplays employment:employee from contractor;",
1853 "undefine\nowns nickname from person;",
1854 ]
1855 );
1856 }
1857
1858 #[test]
1859 fn modify_ownership_lowers_per_annotation_steps() {
1860 let g = graph(vec![migration(
1863 "0001_annotations",
1864 vec![OperationSpec::ModifyOwnership {
1865 owner_type: "person".to_string(),
1866 attr_name: "name".to_string(),
1867 old_annotations: "@key @doc(\"old doc\") @meta(\"x\", \"1\")".to_string(),
1868 new_annotations: "@unique @doc(\"new doc\") @meta(\"y\", \"2\")".to_string(),
1869 }],
1870 vec![],
1871 )]);
1872
1873 let result = plan(&g, &[], None).expect("plan should succeed");
1874 let steps = &result.to_apply[0].steps;
1875 assert_eq!(steps.len(), 3);
1876
1877 assert_eq!(
1880 steps[0].forward,
1881 "undefine\n@key from person owns name;\n@meta(\"x\") from person owns name;"
1882 );
1883 assert_eq!(
1884 steps[0].reverse.as_deref(),
1885 Some("define\nperson owns name @key;\nperson owns name @meta(\"x\", \"1\");")
1886 );
1887 assert_eq!(
1888 steps[1].forward,
1889 "redefine\nperson owns name @doc(\"new doc\");"
1890 );
1891 assert_eq!(
1892 steps[1].reverse.as_deref(),
1893 Some("redefine\nperson owns name @doc(\"old doc\");")
1894 );
1895 assert_eq!(
1896 steps[2].forward,
1897 "define\nperson owns name @meta(\"y\", \"2\");\nperson owns name @unique;"
1898 );
1899 assert_eq!(
1900 steps[2].reverse.as_deref(),
1901 Some("undefine\n@meta(\"y\") from person owns name;\n@unique from person owns name;")
1902 );
1903 }
1905
1906 #[test]
1907 fn modify_ownership_with_identical_annotations_lowers_to_no_steps() {
1908 let g = graph(vec![migration(
1909 "0001_noop",
1910 vec![OperationSpec::ModifyOwnership {
1911 owner_type: "person".to_string(),
1912 attr_name: "name".to_string(),
1913 old_annotations: "@key @doc(\"same\")".to_string(),
1914 new_annotations: "@key @doc(\"same\")".to_string(),
1915 }],
1916 vec![],
1917 )]);
1918
1919 let result = plan(&g, &[], None).expect("plan should succeed");
1920 assert!(result.to_apply[0].steps.is_empty());
1921 }
1922
1923 #[test]
1924 fn modify_type_annotations_lowers_add_update_remove() {
1925 let g = graph(vec![migration(
1926 "0001_type_annotations",
1927 vec![OperationSpec::ModifyTypeAnnotations {
1928 type_name: "person".to_string(),
1929 old_doc: Some("old type doc".to_string()),
1930 new_doc: Some("new type doc".to_string()),
1931 old_meta: BTreeMap::from([("gone".to_string(), "1".to_string())]),
1932 new_meta: BTreeMap::from([("added".to_string(), "2".to_string())]),
1933 }],
1934 vec![],
1935 )]);
1936
1937 let result = plan(&g, &[], None).expect("plan should succeed");
1938 let steps = &result.to_apply[0].steps;
1939 assert_eq!(steps.len(), 3);
1940 assert_eq!(steps[0].forward, "undefine\n@meta(\"gone\") from person;");
1941 assert_eq!(
1942 steps[0].reverse.as_deref(),
1943 Some("define\nperson @meta(\"gone\", \"1\");")
1944 );
1945 assert_eq!(steps[1].forward, "redefine\nperson @doc(\"new type doc\");");
1946 assert_eq!(
1947 steps[1].reverse.as_deref(),
1948 Some("redefine\nperson @doc(\"old type doc\");")
1949 );
1950 assert_eq!(steps[2].forward, "define\nperson @meta(\"added\", \"2\");");
1951 assert_eq!(
1952 steps[2].reverse.as_deref(),
1953 Some("undefine\n@meta(\"added\") from person;")
1954 );
1955 }
1956
1957 #[test]
1958 fn modify_role_annotations_lowers_on_relates_subject() {
1959 let g = graph(vec![migration(
1960 "0001_role_annotations",
1961 vec![OperationSpec::ModifyRoleAnnotations {
1962 relation_type: "employment".to_string(),
1963 role_name: "employee".to_string(),
1964 old_doc: None,
1965 new_doc: Some("The employed party.".to_string()),
1966 old_meta: BTreeMap::new(),
1967 new_meta: BTreeMap::new(),
1968 }],
1969 vec![],
1970 )]);
1971
1972 let result = plan(&g, &[], None).expect("plan should succeed");
1973 let steps = &result.to_apply[0].steps;
1974 assert_eq!(steps.len(), 1);
1975 assert_eq!(
1976 steps[0].forward,
1977 "define\nemployment relates employee @doc(\"The employed party.\");"
1978 );
1979 assert_eq!(
1980 steps[0].reverse.as_deref(),
1981 Some("undefine\n@doc from employment relates employee;")
1982 );
1983 }
1984}