1use std::collections::BTreeSet;
9
10use serde::{Deserialize, Serialize};
11use type_bridge_orm::_schema::annotations::{
12 AnnotationToken, AnnotationTokenDiff, diff_annotation_tokens, split_annotation_tokens,
13};
14use type_bridge_orm::_schema::info::{
15 AttributeSchemaEntry, EntitySchemaEntry, OwnedAttributeEntry, RelationSchemaEntry, RoleEntry,
16 SchemaInfo,
17};
18use type_bridge_orm::TxType;
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::_entity::Annotation;
866 use type_bridge_orm::_schema::info::{
867 AttributeSchemaEntry, EntitySchemaEntry, OwnedAttributeEntry, RelationSchemaEntry,
868 RoleEntry, SchemaInfo,
869 };
870 use type_bridge_orm::ValueType;
871
872 use crate::graph::AppliedMigrationRecord;
873 use crate::spec::{MigrationDependencySpec, MigrationGraph, MigrationSpec, OperationSpec};
874
875 fn run_typeql(forward: &str, reverse: Option<&str>) -> OperationSpec {
878 OperationSpec::RunTypeql {
879 forward: forward.to_string(),
880 reverse: reverse.map(str::to_string),
881 }
882 }
883
884 fn define_schema_op() -> OperationSpec {
885 let mut schema = SchemaInfo::default();
886 schema.attributes.insert(
887 "name".to_string(),
888 AttributeSchemaEntry::new("name", ValueType::String),
889 );
890 schema.entities.insert(
891 "person".to_string(),
892 EntitySchemaEntry {
893 type_name: "person".to_string(),
894 is_abstract: false,
895 parent_type: None,
896 owned_attributes: vec![OwnedAttributeEntry {
897 attr_name: "name".to_string(),
898 value_type: ValueType::String,
899 annotations: vec![Annotation::Key],
900 is_ordered: false,
901 doc: None,
902 meta: Default::default(),
903 }],
904 plays_cardinalities: BTreeMap::new(),
905 doc: None,
906 meta: Default::default(),
907 },
908 );
909 OperationSpec::DefineSchema { schema }
910 }
911
912 fn owned_attr(
913 attr_name: &str,
914 value_type: ValueType,
915 annotations: Vec<Annotation>,
916 ) -> OwnedAttributeEntry {
917 OwnedAttributeEntry {
918 attr_name: attr_name.to_string(),
919 value_type,
920 annotations,
921 is_ordered: false,
922 doc: None,
923 meta: Default::default(),
924 }
925 }
926
927 fn entity_entry(type_name: &str) -> EntitySchemaEntry {
928 EntitySchemaEntry {
929 type_name: type_name.to_string(),
930 is_abstract: false,
931 parent_type: None,
932 owned_attributes: vec![owned_attr("name", ValueType::String, vec![Annotation::Key])],
933 plays_cardinalities: BTreeMap::new(),
934 doc: None,
935 meta: Default::default(),
936 }
937 }
938
939 fn relation_entry(type_name: &str) -> RelationSchemaEntry {
940 RelationSchemaEntry {
941 type_name: type_name.to_string(),
942 is_abstract: false,
943 parent_type: None,
944 owned_attributes: vec![],
945 roles: vec![RoleEntry {
946 role_name: "employee".to_string(),
947 player_type_names: vec!["person".to_string()],
948 cardinality: None,
949 overrides: None,
950 is_abstract: false,
951 ordered: false,
952 distinct: false,
953 doc: None,
954 meta: Default::default(),
955 }],
956 plays_cardinalities: BTreeMap::new(),
957 doc: None,
958 meta: Default::default(),
959 }
960 }
961
962 fn migration(name: &str, ops: Vec<OperationSpec>, deps: Vec<(&str, &str)>) -> MigrationSpec {
963 MigrationSpec {
964 app_label: "app".to_string(),
965 name: name.to_string(),
966 dependencies: deps
967 .into_iter()
968 .map(|(app, dep_name)| MigrationDependencySpec {
969 app_label: app.to_string(),
970 migration_name: dep_name.to_string(),
971 })
972 .collect(),
973 operations: ops,
974 checksum: Some(format!("{name}-csum")),
975 source_sha256: None,
976 reversible: true,
977 }
978 }
979
980 fn applied(name: &str) -> AppliedMigrationRecord {
981 AppliedMigrationRecord {
982 app_label: "app".to_string(),
983 name: name.to_string(),
984 checksum: format!("{name}-csum"),
985 applied_at: None,
986 }
987 }
988
989 fn graph(migrations: Vec<MigrationSpec>) -> MigrationGraph {
990 MigrationGraph { migrations }
991 }
992
993 #[test]
996 fn pending_only_all_pending_applies_in_order() {
997 let g = graph(vec![
998 migration(
999 "0001_initial",
1000 vec![run_typeql("define attribute a, value string;", None)],
1001 vec![],
1002 ),
1003 migration(
1004 "0002_add",
1005 vec![run_typeql(
1006 "define attribute b, value string;",
1007 Some("undefine attribute b;"),
1008 )],
1009 vec![("app", "0001_initial")],
1010 ),
1011 ]);
1012
1013 let result = plan(&g, &[], None).expect("plan should succeed");
1014
1015 assert_eq!(result.to_apply.len(), 2);
1016 assert_eq!(result.to_rollback.len(), 0);
1017 assert_eq!(result.to_apply[0].name, "0001_initial");
1018 assert_eq!(result.to_apply[1].name, "0002_add");
1019 }
1020
1021 #[test]
1022 fn pending_only_already_applied_excluded() {
1023 let g = graph(vec![
1024 migration(
1025 "0001_initial",
1026 vec![run_typeql("define attribute a, value string;", None)],
1027 vec![],
1028 ),
1029 migration(
1030 "0002_add",
1031 vec![run_typeql(
1032 "define attribute b, value string;",
1033 Some("undefine attribute b;"),
1034 )],
1035 vec![("app", "0001_initial")],
1036 ),
1037 ]);
1038
1039 let result = plan(&g, &[applied("0001_initial")], None).expect("plan should succeed");
1040
1041 assert_eq!(result.to_apply.len(), 1);
1042 assert_eq!(result.to_apply[0].name, "0002_add");
1043 assert_eq!(result.to_rollback.len(), 0);
1044 }
1045
1046 #[test]
1049 fn target_applies_up_to_and_including_target() {
1050 let g = graph(vec![
1051 migration(
1052 "0001_initial",
1053 vec![run_typeql("define attribute a, value string;", None)],
1054 vec![],
1055 ),
1056 migration(
1057 "0002_add",
1058 vec![run_typeql(
1059 "define attribute b, value string;",
1060 Some("undefine attribute b;"),
1061 )],
1062 vec![("app", "0001_initial")],
1063 ),
1064 migration(
1065 "0003_more",
1066 vec![run_typeql(
1067 "define attribute c, value string;",
1068 Some("undefine attribute c;"),
1069 )],
1070 vec![("app", "0002_add")],
1071 ),
1072 ]);
1073
1074 let result = plan(&g, &[], Some("0002_add")).expect("plan should succeed");
1076
1077 assert_eq!(result.to_apply.len(), 2);
1078 assert_eq!(result.to_apply[0].name, "0001_initial");
1079 assert_eq!(result.to_apply[1].name, "0002_add");
1080 assert_eq!(result.to_rollback.len(), 0);
1081 }
1082
1083 #[test]
1084 fn target_rolls_back_past_target_in_reverse_order() {
1085 let g = graph(vec![
1086 migration(
1087 "0001_initial",
1088 vec![run_typeql("define attribute a, value string;", None)],
1089 vec![],
1090 ),
1091 migration(
1092 "0002_add",
1093 vec![run_typeql(
1094 "define attribute b, value string;",
1095 Some("undefine attribute b;"),
1096 )],
1097 vec![("app", "0001_initial")],
1098 ),
1099 migration(
1100 "0003_more",
1101 vec![run_typeql(
1102 "define attribute c, value string;",
1103 Some("undefine attribute c;"),
1104 )],
1105 vec![("app", "0002_add")],
1106 ),
1107 ]);
1108
1109 let result = plan(
1111 &g,
1112 &[
1113 applied("0001_initial"),
1114 applied("0002_add"),
1115 applied("0003_more"),
1116 ],
1117 Some("0001_initial"),
1118 )
1119 .expect("plan should succeed");
1120
1121 assert_eq!(result.to_apply.len(), 0);
1122 assert_eq!(result.to_rollback.len(), 2);
1124 assert_eq!(result.to_rollback[0].name, "0003_more");
1125 assert_eq!(result.to_rollback[1].name, "0002_add");
1126 }
1127
1128 #[test]
1131 fn rollback_execution_action_is_rollback() {
1132 let g = graph(vec![
1133 migration(
1134 "0001_initial",
1135 vec![run_typeql("define attribute a, value string;", None)],
1136 vec![],
1137 ),
1138 migration(
1139 "0002_add",
1140 vec![run_typeql(
1141 "define attribute b, value string;",
1142 Some("undefine attribute b;"),
1143 )],
1144 vec![("app", "0001_initial")],
1145 ),
1146 ]);
1147
1148 let result = plan(
1149 &g,
1150 &[applied("0001_initial"), applied("0002_add")],
1151 Some("0001_initial"),
1152 )
1153 .expect("plan should succeed");
1154
1155 assert_eq!(result.to_rollback[0].action, MigrationAction::Rollback);
1156 }
1157
1158 #[test]
1161 fn define_schema_step_carries_typeql_from_generator() {
1162 let g = graph(vec![migration(
1163 "0001_initial",
1164 vec![define_schema_op()],
1165 vec![],
1166 )]);
1167
1168 let result = plan(&g, &[], None).expect("plan should succeed");
1169
1170 let exec = &result.to_apply[0];
1171 assert_eq!(exec.steps.len(), 1);
1172 let step = &exec.steps[0];
1173 assert!(
1175 !step.forward.is_empty(),
1176 "DefineSchema forward must be non-empty"
1177 );
1178 assert!(
1179 step.forward.contains("define"),
1180 "DefineSchema forward must contain 'define'"
1181 );
1182 assert!(step.reverse.is_none());
1184 }
1185
1186 #[test]
1187 fn define_schema_step_tx_type_is_schema() {
1188 let g = graph(vec![migration(
1189 "0001_initial",
1190 vec![define_schema_op()],
1191 vec![],
1192 )]);
1193
1194 let result = plan(&g, &[], None).expect("plan should succeed");
1195 assert_eq!(result.to_apply[0].steps[0].tx_type, TxType::Schema);
1196 }
1197
1198 #[test]
1201 fn run_typeql_step_tx_type_is_schema() {
1202 let g = graph(vec![migration(
1203 "0001_add",
1204 vec![run_typeql("define attribute a, value string;", None)],
1205 vec![],
1206 )]);
1207
1208 let result = plan(&g, &[], None).expect("plan should succeed");
1209 assert_eq!(result.to_apply[0].steps[0].tx_type, TxType::Schema);
1210 }
1211
1212 #[test]
1213 fn data_run_typeql_step_tx_type_is_write() {
1214 let g = graph(vec![migration(
1215 "0002_seed",
1216 vec![run_typeql(
1217 r#"match $a isa account, has account-id "acct-001";
1218insert $a has email "ops@example.com";"#,
1219 Some(
1220 r#"match $a isa account, has email "ops@example.com";
1221delete $a has email "ops@example.com";"#,
1222 ),
1223 )],
1224 vec![],
1225 )]);
1226
1227 let result = plan(&g, &[], None).expect("plan should succeed");
1228 let step = &result.to_apply[0].steps[0];
1229 assert_eq!(step.tx_type, TxType::Write);
1230 assert_eq!(step.kind, StepKind::Write);
1231 }
1232
1233 #[test]
1236 fn typed_attribute_operations_lower_to_schema_steps() {
1237 let g = graph(vec![migration(
1238 "0001_attrs",
1239 vec![
1240 OperationSpec::AddAttribute {
1241 attribute: AttributeSchemaEntry::new("score", ValueType::Long),
1242 },
1243 OperationSpec::RemoveAttribute {
1244 attr_name: "legacy-score".to_string(),
1245 },
1246 ],
1247 vec![],
1248 )]);
1249
1250 let result = plan(&g, &[], None).expect("plan should succeed");
1251 let steps = &result.to_apply[0].steps;
1252
1253 assert_eq!(steps.len(), 2);
1254 assert!(steps[0].forward.contains("attribute score, value integer;"));
1255 assert_eq!(steps[0].reverse.as_deref(), Some("undefine\nscore;"));
1256 assert_eq!(steps[1].forward, "undefine\nlegacy-score;");
1257 assert!(steps[1].reverse.is_none());
1258 assert!(!result.to_apply[0].reversible);
1259 }
1260
1261 #[test]
1262 fn typed_entity_and_relation_operations_lower_to_schema_steps() {
1263 let g = graph(vec![migration(
1264 "0001_types",
1265 vec![
1266 OperationSpec::AddEntity {
1267 entity: entity_entry("person"),
1268 },
1269 OperationSpec::AddRelation {
1270 relation: relation_entry("employment"),
1271 },
1272 ],
1273 vec![],
1274 )]);
1275
1276 let result = plan(&g, &[], None).expect("plan should succeed");
1277 let steps = &result.to_apply[0].steps;
1278
1279 assert!(steps[0].forward.contains("entity person,"));
1280 assert!(steps[0].forward.contains("owns name @key;"));
1281 assert_eq!(steps[0].reverse.as_deref(), Some("undefine\nperson;"));
1282 assert!(steps[1].forward.contains("relation employment,"));
1283 assert!(steps[1].forward.contains("relates employee;"));
1284 assert!(
1285 steps[1]
1286 .forward
1287 .contains("person plays employment:employee;")
1288 );
1289 assert!(
1290 steps[1]
1291 .reverse
1292 .as_deref()
1293 .unwrap()
1294 .contains("plays employment:employee from person;")
1295 );
1296 assert!(steps[1].reverse.as_deref().unwrap().contains("employment;"));
1297 }
1298
1299 #[test]
1300 fn add_entity_with_parent_outside_singleton_schema_lowers_without_panic() {
1301 let mut child = entity_entry("person");
1305 child.parent_type = Some("animal".to_string());
1306 let g = graph(vec![migration(
1307 "0001_sub_entity",
1308 vec![OperationSpec::AddEntity { entity: child }],
1309 vec![],
1310 )]);
1311
1312 let result = plan(&g, &[], None).expect("plan should succeed");
1313 let steps = &result.to_apply[0].steps;
1314
1315 assert_eq!(steps.len(), 1);
1316 assert!(steps[0].forward.contains("entity person sub animal,"));
1317 assert_eq!(steps[0].reverse.as_deref(), Some("undefine\nperson;"));
1318 }
1319
1320 #[test]
1321 fn typed_ownership_operations_lower_to_schema_steps() {
1322 let g = graph(vec![migration(
1323 "0001_ownership",
1324 vec![
1325 OperationSpec::AddOwnership {
1326 owner_type: "person".to_string(),
1327 attribute: owned_attr("email", ValueType::String, vec![Annotation::Key]),
1328 },
1329 OperationSpec::RemoveOwnership {
1330 owner_type: "person".to_string(),
1331 attr_name: "legacy-email".to_string(),
1332 },
1333 OperationSpec::ModifyOwnership {
1334 owner_type: "person".to_string(),
1335 attr_name: "nickname".to_string(),
1336 old_annotations: "@card(0..1)".to_string(),
1337 new_annotations: "@card(1..1)".to_string(),
1338 },
1339 ],
1340 vec![],
1341 )]);
1342
1343 let result = plan(&g, &[], None).expect("plan should succeed");
1344 let steps = &result.to_apply[0].steps;
1345
1346 assert_eq!(steps[0].forward, "define\nperson owns email @key;");
1347 assert_eq!(
1348 steps[0].reverse.as_deref(),
1349 Some("undefine\nowns email from person;")
1350 );
1351 assert_eq!(steps[1].forward, "undefine\nowns legacy-email from person;");
1352 assert!(steps[1].reverse.is_none());
1353 assert_eq!(
1354 steps[2].forward,
1355 "redefine\nperson owns nickname @card(1..1);"
1356 );
1357 assert_eq!(
1358 steps[2].reverse.as_deref(),
1359 Some("redefine\nperson owns nickname @card(0..1);")
1360 );
1361 }
1362
1363 #[test]
1364 fn modify_ownership_decomposes_parameterless_transitions() {
1365 let g = graph(vec![migration(
1370 "0002_key",
1371 vec![OperationSpec::ModifyOwnership {
1372 owner_type: "person".to_string(),
1373 attr_name: "nickname".to_string(),
1374 old_annotations: "@card(0..1)".to_string(),
1375 new_annotations: "@key".to_string(),
1376 }],
1377 vec![],
1378 )]);
1379
1380 let result = plan(&g, &[], None).expect("plan should succeed");
1381 let steps = &result.to_apply[0].steps;
1382
1383 assert_eq!(steps.len(), 2);
1384 assert_eq!(
1385 steps[0].forward,
1386 "undefine\n@card from person owns nickname;"
1387 );
1388 assert_eq!(
1389 steps[0].reverse.as_deref(),
1390 Some("define\nperson owns nickname @card(0..1);")
1391 );
1392 assert_eq!(steps[1].forward, "define\nperson owns nickname @key;");
1393 assert_eq!(
1394 steps[1].reverse.as_deref(),
1395 Some("undefine\n@key from person owns nickname;")
1396 );
1397 }
1398
1399 #[test]
1400 fn modify_ownership_from_plain_defines_and_identical_sets_lower_to_nothing() {
1401 let g = graph(vec![migration(
1402 "0002_tighten",
1403 vec![
1404 OperationSpec::ModifyOwnership {
1405 owner_type: "person".to_string(),
1406 attr_name: "nickname".to_string(),
1407 old_annotations: String::new(),
1408 new_annotations: "@key".to_string(),
1409 },
1410 OperationSpec::ModifyOwnership {
1411 owner_type: "person".to_string(),
1412 attr_name: "email".to_string(),
1413 old_annotations: "@unique".to_string(),
1414 new_annotations: "@unique".to_string(),
1415 },
1416 ],
1417 vec![],
1418 )]);
1419
1420 let result = plan(&g, &[], None).expect("plan should succeed");
1421 let steps = &result.to_apply[0].steps;
1422
1423 assert_eq!(steps.len(), 1);
1425 assert_eq!(steps[0].forward, "define\nperson owns nickname @key;");
1426 assert_eq!(
1427 steps[0].reverse.as_deref(),
1428 Some("undefine\n@key from person owns nickname;")
1429 );
1430 }
1431
1432 #[test]
1433 fn typed_role_operations_lower_to_schema_steps() {
1434 let role = RoleEntry {
1435 role_name: "reviewer".to_string(),
1436 player_type_names: vec!["person".to_string()],
1437 cardinality: Some((0, Some(2))),
1438 overrides: None,
1439 is_abstract: false,
1440 ordered: false,
1441 distinct: false,
1442 doc: None,
1443 meta: Default::default(),
1444 };
1445 let g = graph(vec![migration(
1446 "0001_roles",
1447 vec![
1448 OperationSpec::AddRole {
1449 relation_type: "employment".to_string(),
1450 role,
1451 },
1452 OperationSpec::RemoveRole {
1453 relation_type: "employment".to_string(),
1454 role_name: "legacy".to_string(),
1455 },
1456 OperationSpec::AddRolePlayer {
1457 relation_type: "employment".to_string(),
1458 role_name: "employee".to_string(),
1459 player_type_name: "contractor".to_string(),
1460 },
1461 OperationSpec::RemoveRolePlayer {
1462 relation_type: "employment".to_string(),
1463 role_name: "employee".to_string(),
1464 player_type_name: "company".to_string(),
1465 },
1466 ],
1467 vec![],
1468 )]);
1469
1470 let result = plan(&g, &[], None).expect("plan should succeed");
1471 let steps = &result.to_apply[0].steps;
1472
1473 assert_eq!(
1474 steps[0].forward,
1475 "define\nemployment relates reviewer @card(0..2);\nperson plays employment:reviewer;"
1476 );
1477 assert_eq!(
1478 steps[0].reverse.as_deref(),
1479 Some(
1480 "undefine\nplays employment:reviewer from person;\nrelates reviewer from employment;"
1481 )
1482 );
1483 assert_eq!(
1484 steps[1].forward,
1485 "undefine\nrelates legacy from employment;"
1486 );
1487 assert!(steps[1].reverse.is_none());
1488 assert_eq!(
1489 steps[2].forward,
1490 "define\ncontractor plays employment:employee;"
1491 );
1492 assert_eq!(
1493 steps[2].reverse.as_deref(),
1494 Some("undefine\nplays employment:employee from contractor;")
1495 );
1496 assert_eq!(
1497 steps[3].forward,
1498 "undefine\nplays employment:employee from company;"
1499 );
1500 assert_eq!(
1501 steps[3].reverse.as_deref(),
1502 Some("define\ncompany plays employment:employee;")
1503 );
1504 }
1505
1506 #[test]
1507 fn migration_reversible_flag_drops_typed_operation_reverses() {
1508 let mut spec = migration(
1509 "0001_non_reversible",
1510 vec![OperationSpec::AddAttribute {
1511 attribute: AttributeSchemaEntry::new("score", ValueType::Long),
1512 }],
1513 vec![],
1514 );
1515 spec.reversible = false;
1516 let g = graph(vec![spec]);
1517
1518 let result = plan(&g, &[], None).expect("plan should succeed");
1519
1520 assert!(result.to_apply[0].steps[0].reverse.is_none());
1521 assert!(!result.to_apply[0].reversible);
1522 }
1523
1524 #[test]
1527 fn unlowered_op_returns_err() {
1528 let g = graph(vec![migration(
1529 "0001_rename_attr",
1530 vec![OperationSpec::RenameAttribute {
1531 old_name: "old-score".to_string(),
1532 new_name: "new-score".to_string(),
1533 value_type: "string".to_string(),
1534 }],
1535 vec![],
1536 )]);
1537
1538 let err = plan(&g, &[], None).expect_err("should fail for unsupported op");
1539 match err {
1540 MigrationError::UnloweredOperation { kind } => {
1541 assert_eq!(kind, "RenameAttribute");
1542 }
1543 other => panic!("expected UnloweredOperation, got {other:?}"),
1544 }
1545 }
1546
1547 #[test]
1550 fn validation_failure_returns_planning_error() {
1551 let g = graph(vec![migration(
1553 "0002_next",
1554 vec![run_typeql("define attribute b, value string;", None)],
1555 vec![("app", "0001_initial")],
1556 )]);
1557
1558 let err = plan(&g, &[], None).expect_err("should fail on validation error");
1559 assert!(
1560 matches!(err, MigrationError::Planning { .. }),
1561 "expected Planning error, got {err:?}"
1562 );
1563 }
1564
1565 #[test]
1568 fn checksum_drift_returns_error() {
1569 let g = graph(vec![migration(
1570 "0001_initial",
1571 vec![run_typeql("define attribute a, value string;", None)],
1572 vec![],
1573 )]);
1574
1575 let bad_applied = AppliedMigrationRecord {
1577 app_label: "app".to_string(),
1578 name: "0001_initial".to_string(),
1579 checksum: "wrong-checksum".to_string(),
1580 applied_at: None,
1581 };
1582
1583 let err = plan(&g, &[bad_applied], None).expect_err("should fail on drift");
1584 assert!(
1585 matches!(err, MigrationError::ChecksumDrift { .. }),
1586 "expected ChecksumDrift error, got {err:?}"
1587 );
1588 }
1589
1590 #[test]
1593 fn migration_with_no_reverse_is_marked_not_reversible() {
1594 let g = graph(vec![migration(
1595 "0001_initial",
1596 vec![run_typeql("define attribute a, value string;", None)],
1598 vec![],
1599 )]);
1600
1601 let result = plan(&g, &[], None).expect("plan should succeed");
1602 assert!(!result.to_apply[0].reversible);
1603 }
1604
1605 #[test]
1606 fn migration_with_all_reverses_is_reversible() {
1607 let g = graph(vec![migration(
1608 "0001_add",
1609 vec![run_typeql(
1610 "define attribute a, value string;",
1611 Some("undefine attribute a;"),
1612 )],
1613 vec![],
1614 )]);
1615
1616 let result = plan(&g, &[], None).expect("plan should succeed");
1617 assert!(result.to_apply[0].reversible);
1618 }
1619
1620 #[test]
1621 fn define_schema_migration_is_not_reversible() {
1622 let g = graph(vec![migration(
1624 "0001_initial",
1625 vec![define_schema_op()],
1626 vec![],
1627 )]);
1628
1629 let result = plan(&g, &[], None).expect("plan should succeed");
1630 assert!(!result.to_apply[0].reversible);
1631 }
1632
1633 #[test]
1636 fn unknown_target_returns_target_not_found_error() {
1637 let g = graph(vec![migration(
1638 "0001_initial",
1639 vec![run_typeql("define attribute a, value string;", None)],
1640 vec![],
1641 )]);
1642
1643 let err = plan(&g, &[], Some("nonexistent_migration"))
1644 .expect_err("should fail for missing target");
1645 assert!(
1646 matches!(err, MigrationError::TargetNotFound { .. }),
1647 "expected TargetNotFound, got {err:?}"
1648 );
1649 }
1650
1651 #[test]
1654 fn copy_attribute_lowers_to_write_typed_backfill_step() {
1655 let forward = "match\n $x isa person, has old-name $v;\n \
1659 not { $x has new-name $d; };\ninsert\n $x has new-name == $v;";
1660 let reverse = "match $x isa person, has new-name $v;\ndelete $v of $x;";
1661 let g = graph(vec![migration(
1662 "0002_backfill",
1663 vec![OperationSpec::CopyAttribute {
1664 owner: None,
1665 source: None,
1666 dest: None,
1667 filter: None,
1668 forward: Some(forward.to_string()),
1669 reverse: Some(reverse.to_string()),
1670 }],
1671 vec![],
1672 )]);
1673
1674 let result = plan(&g, &[], None).expect("plan should succeed");
1675
1676 let exec = &result.to_apply[0];
1677 assert_eq!(exec.steps.len(), 1);
1678 let step = &exec.steps[0];
1679
1680 assert_eq!(
1681 step.tx_type,
1682 TxType::Write,
1683 "CopyAttribute step must use Write tx"
1684 );
1685 assert_eq!(
1686 step.kind,
1687 StepKind::Backfill,
1688 "CopyAttribute step kind must be Backfill"
1689 );
1690 assert_eq!(step.forward, forward, "forward must be carried verbatim");
1692 assert_eq!(
1693 step.reverse.as_deref(),
1694 Some(reverse),
1695 "reverse must be carried verbatim"
1696 );
1697 }
1698
1699 #[test]
1700 fn structured_copy_attribute_lowers_to_the_same_backfill_step() {
1701 let g = graph(vec![migration(
1704 "0002_backfill",
1705 vec![OperationSpec::CopyAttribute {
1706 owner: Some("person".to_string()),
1707 source: Some("old-name".to_string()),
1708 dest: Some("new-name".to_string()),
1709 filter: None,
1710 forward: None,
1711 reverse: None,
1712 }],
1713 vec![],
1714 )]);
1715
1716 let result = plan(&g, &[], None).expect("plan should succeed");
1717
1718 let step = &result.to_apply[0].steps[0];
1719 assert_eq!(step.tx_type, TxType::Write);
1720 assert_eq!(step.kind, StepKind::Backfill);
1721 assert_eq!(
1722 step.forward,
1723 "match\n $x isa person, has old-name $v;\n \
1724 not { $x has new-name $d; };\ninsert\n $x has new-name == $v;"
1725 );
1726 assert_eq!(
1727 step.reverse.as_deref(),
1728 Some("match $x isa person, has new-name $v;\ndelete $v of $x;")
1729 );
1730 }
1731
1732 #[test]
1733 fn step_kind_default_is_schema_for_serde_backcompat() {
1734 let json =
1736 r#"{"tx_type":"Schema","forward":"define attribute a, value string;","reverse":null}"#;
1737 let step: ExecutionStep =
1738 serde_json::from_str(json).expect("should deserialize legacy step");
1739 assert_eq!(
1740 step.kind,
1741 StepKind::Schema,
1742 "missing `kind` field must default to Schema for backward compat"
1743 );
1744 assert_eq!(step.operation_kind, OperationKind::RunTypeql);
1745 }
1746
1747 fn legacy_remove_relation_ops(relation: &str) -> Vec<OperationSpec> {
1752 vec![
1753 OperationSpec::RemoveRolePlayer {
1754 relation_type: relation.to_string(),
1755 role_name: "subject".to_string(),
1756 player_type_name: "person".to_string(),
1757 },
1758 OperationSpec::RemoveRole {
1759 relation_type: relation.to_string(),
1760 role_name: "subject".to_string(),
1761 },
1762 OperationSpec::RemoveRolePlayer {
1763 relation_type: relation.to_string(),
1764 role_name: "badge".to_string(),
1765 player_type_name: "temporary-badge".to_string(),
1766 },
1767 OperationSpec::RemoveRole {
1768 relation_type: relation.to_string(),
1769 role_name: "badge".to_string(),
1770 },
1771 OperationSpec::RemoveOwnership {
1772 owner_type: relation.to_string(),
1773 attr_name: "legacy-link-id".to_string(),
1774 },
1775 OperationSpec::RemoveRelation {
1776 type_name: relation.to_string(),
1777 },
1778 ]
1779 }
1780
1781 #[test]
1782 fn legacy_decomposed_relation_removal_normalizes_to_single_step() {
1783 let g = graph(vec![migration(
1784 "0005_remove_legacy_link",
1785 legacy_remove_relation_ops("legacy-link"),
1786 vec![],
1787 )]);
1788
1789 let result = plan(&g, &[], None).expect("plan should succeed");
1790
1791 let exec = &result.to_apply[0];
1792 assert_eq!(
1793 exec.steps.len(),
1794 1,
1795 "granular removals shadowed by RemoveRelation must be dropped"
1796 );
1797 assert_eq!(exec.steps[0].forward, "undefine\nlegacy-link;");
1798 assert_eq!(exec.steps[0].tx_type, TxType::Schema);
1799 }
1800
1801 #[test]
1802 fn surviving_relation_granular_removals_are_kept() {
1803 let ops = vec![
1805 OperationSpec::RemoveRolePlayer {
1806 relation_type: "employment".to_string(),
1807 role_name: "employee".to_string(),
1808 player_type_name: "contractor".to_string(),
1809 },
1810 OperationSpec::RemoveRole {
1811 relation_type: "employment".to_string(),
1812 role_name: "reviewer".to_string(),
1813 },
1814 OperationSpec::RemoveOwnership {
1815 owner_type: "employment".to_string(),
1816 attr_name: "note".to_string(),
1817 },
1818 ];
1819 let g = graph(vec![migration("0002_trim_employment", ops, vec![])]);
1820
1821 let result = plan(&g, &[], None).expect("plan should succeed");
1822
1823 assert_eq!(result.to_apply[0].steps.len(), 3);
1824 }
1825
1826 #[test]
1827 fn normalization_is_scoped_to_the_removed_relation() {
1828 let mut ops = legacy_remove_relation_ops("legacy-link");
1831 ops.push(OperationSpec::RemoveRolePlayer {
1832 relation_type: "employment".to_string(),
1833 role_name: "employee".to_string(),
1834 player_type_name: "contractor".to_string(),
1835 });
1836 ops.push(OperationSpec::RemoveOwnership {
1837 owner_type: "person".to_string(),
1838 attr_name: "nickname".to_string(),
1839 });
1840 let g = graph(vec![migration("0006_mixed_removals", ops, vec![])]);
1841
1842 let result = plan(&g, &[], None).expect("plan should succeed");
1843
1844 let forwards: Vec<&str> = result.to_apply[0]
1845 .steps
1846 .iter()
1847 .map(|s| s.forward.as_str())
1848 .collect();
1849 assert_eq!(
1850 forwards,
1851 vec![
1852 "undefine\nlegacy-link;",
1853 "undefine\nplays employment:employee from contractor;",
1854 "undefine\nowns nickname from person;",
1855 ]
1856 );
1857 }
1858
1859 #[test]
1860 fn modify_ownership_lowers_per_annotation_steps() {
1861 let g = graph(vec![migration(
1864 "0001_annotations",
1865 vec![OperationSpec::ModifyOwnership {
1866 owner_type: "person".to_string(),
1867 attr_name: "name".to_string(),
1868 old_annotations: "@key @doc(\"old doc\") @meta(\"x\", \"1\")".to_string(),
1869 new_annotations: "@unique @doc(\"new doc\") @meta(\"y\", \"2\")".to_string(),
1870 }],
1871 vec![],
1872 )]);
1873
1874 let result = plan(&g, &[], None).expect("plan should succeed");
1875 let steps = &result.to_apply[0].steps;
1876 assert_eq!(steps.len(), 3);
1877
1878 assert_eq!(
1881 steps[0].forward,
1882 "undefine\n@key from person owns name;\n@meta(\"x\") from person owns name;"
1883 );
1884 assert_eq!(
1885 steps[0].reverse.as_deref(),
1886 Some("define\nperson owns name @key;\nperson owns name @meta(\"x\", \"1\");")
1887 );
1888 assert_eq!(
1889 steps[1].forward,
1890 "redefine\nperson owns name @doc(\"new doc\");"
1891 );
1892 assert_eq!(
1893 steps[1].reverse.as_deref(),
1894 Some("redefine\nperson owns name @doc(\"old doc\");")
1895 );
1896 assert_eq!(
1897 steps[2].forward,
1898 "define\nperson owns name @meta(\"y\", \"2\");\nperson owns name @unique;"
1899 );
1900 assert_eq!(
1901 steps[2].reverse.as_deref(),
1902 Some("undefine\n@meta(\"y\") from person owns name;\n@unique from person owns name;")
1903 );
1904 }
1906
1907 #[test]
1908 fn modify_ownership_with_identical_annotations_lowers_to_no_steps() {
1909 let g = graph(vec![migration(
1910 "0001_noop",
1911 vec![OperationSpec::ModifyOwnership {
1912 owner_type: "person".to_string(),
1913 attr_name: "name".to_string(),
1914 old_annotations: "@key @doc(\"same\")".to_string(),
1915 new_annotations: "@key @doc(\"same\")".to_string(),
1916 }],
1917 vec![],
1918 )]);
1919
1920 let result = plan(&g, &[], None).expect("plan should succeed");
1921 assert!(result.to_apply[0].steps.is_empty());
1922 }
1923
1924 #[test]
1925 fn modify_type_annotations_lowers_add_update_remove() {
1926 let g = graph(vec![migration(
1927 "0001_type_annotations",
1928 vec![OperationSpec::ModifyTypeAnnotations {
1929 type_name: "person".to_string(),
1930 old_doc: Some("old type doc".to_string()),
1931 new_doc: Some("new type doc".to_string()),
1932 old_meta: BTreeMap::from([("gone".to_string(), "1".to_string())]),
1933 new_meta: BTreeMap::from([("added".to_string(), "2".to_string())]),
1934 }],
1935 vec![],
1936 )]);
1937
1938 let result = plan(&g, &[], None).expect("plan should succeed");
1939 let steps = &result.to_apply[0].steps;
1940 assert_eq!(steps.len(), 3);
1941 assert_eq!(steps[0].forward, "undefine\n@meta(\"gone\") from person;");
1942 assert_eq!(
1943 steps[0].reverse.as_deref(),
1944 Some("define\nperson @meta(\"gone\", \"1\");")
1945 );
1946 assert_eq!(steps[1].forward, "redefine\nperson @doc(\"new type doc\");");
1947 assert_eq!(
1948 steps[1].reverse.as_deref(),
1949 Some("redefine\nperson @doc(\"old type doc\");")
1950 );
1951 assert_eq!(steps[2].forward, "define\nperson @meta(\"added\", \"2\");");
1952 assert_eq!(
1953 steps[2].reverse.as_deref(),
1954 Some("undefine\n@meta(\"added\") from person;")
1955 );
1956 }
1957
1958 #[test]
1959 fn modify_role_annotations_lowers_on_relates_subject() {
1960 let g = graph(vec![migration(
1961 "0001_role_annotations",
1962 vec![OperationSpec::ModifyRoleAnnotations {
1963 relation_type: "employment".to_string(),
1964 role_name: "employee".to_string(),
1965 old_doc: None,
1966 new_doc: Some("The employed party.".to_string()),
1967 old_meta: BTreeMap::new(),
1968 new_meta: BTreeMap::new(),
1969 }],
1970 vec![],
1971 )]);
1972
1973 let result = plan(&g, &[], None).expect("plan should succeed");
1974 let steps = &result.to_apply[0].steps;
1975 assert_eq!(steps.len(), 1);
1976 assert_eq!(
1977 steps[0].forward,
1978 "define\nemployment relates employee @doc(\"The employed party.\");"
1979 );
1980 assert_eq!(
1981 steps[0].reverse.as_deref(),
1982 Some("undefine\n@doc from employment relates employee;")
1983 );
1984 }
1985}