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