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 typed_ownership_operations_lower_to_schema_steps() {
1299 let g = graph(vec![migration(
1300 "0001_ownership",
1301 vec![
1302 OperationSpec::AddOwnership {
1303 owner_type: "person".to_string(),
1304 attribute: owned_attr("email", ValueType::String, vec![Annotation::Key]),
1305 },
1306 OperationSpec::RemoveOwnership {
1307 owner_type: "person".to_string(),
1308 attr_name: "legacy-email".to_string(),
1309 },
1310 OperationSpec::ModifyOwnership {
1311 owner_type: "person".to_string(),
1312 attr_name: "nickname".to_string(),
1313 old_annotations: "@card(0..1)".to_string(),
1314 new_annotations: "@card(1..1)".to_string(),
1315 },
1316 ],
1317 vec![],
1318 )]);
1319
1320 let result = plan(&g, &[], None).expect("plan should succeed");
1321 let steps = &result.to_apply[0].steps;
1322
1323 assert_eq!(steps[0].forward, "define\nperson owns email @key;");
1324 assert_eq!(
1325 steps[0].reverse.as_deref(),
1326 Some("undefine\nowns email from person;")
1327 );
1328 assert_eq!(steps[1].forward, "undefine\nowns legacy-email from person;");
1329 assert!(steps[1].reverse.is_none());
1330 assert_eq!(
1331 steps[2].forward,
1332 "redefine\nperson owns nickname @card(1..1);"
1333 );
1334 assert_eq!(
1335 steps[2].reverse.as_deref(),
1336 Some("redefine\nperson owns nickname @card(0..1);")
1337 );
1338 }
1339
1340 #[test]
1341 fn modify_ownership_decomposes_parameterless_transitions() {
1342 let g = graph(vec![migration(
1347 "0002_key",
1348 vec![OperationSpec::ModifyOwnership {
1349 owner_type: "person".to_string(),
1350 attr_name: "nickname".to_string(),
1351 old_annotations: "@card(0..1)".to_string(),
1352 new_annotations: "@key".to_string(),
1353 }],
1354 vec![],
1355 )]);
1356
1357 let result = plan(&g, &[], None).expect("plan should succeed");
1358 let steps = &result.to_apply[0].steps;
1359
1360 assert_eq!(steps.len(), 2);
1361 assert_eq!(
1362 steps[0].forward,
1363 "undefine\n@card from person owns nickname;"
1364 );
1365 assert_eq!(
1366 steps[0].reverse.as_deref(),
1367 Some("define\nperson owns nickname @card(0..1);")
1368 );
1369 assert_eq!(steps[1].forward, "define\nperson owns nickname @key;");
1370 assert_eq!(
1371 steps[1].reverse.as_deref(),
1372 Some("undefine\n@key from person owns nickname;")
1373 );
1374 }
1375
1376 #[test]
1377 fn modify_ownership_from_plain_defines_and_identical_sets_lower_to_nothing() {
1378 let g = graph(vec![migration(
1379 "0002_tighten",
1380 vec![
1381 OperationSpec::ModifyOwnership {
1382 owner_type: "person".to_string(),
1383 attr_name: "nickname".to_string(),
1384 old_annotations: String::new(),
1385 new_annotations: "@key".to_string(),
1386 },
1387 OperationSpec::ModifyOwnership {
1388 owner_type: "person".to_string(),
1389 attr_name: "email".to_string(),
1390 old_annotations: "@unique".to_string(),
1391 new_annotations: "@unique".to_string(),
1392 },
1393 ],
1394 vec![],
1395 )]);
1396
1397 let result = plan(&g, &[], None).expect("plan should succeed");
1398 let steps = &result.to_apply[0].steps;
1399
1400 assert_eq!(steps.len(), 1);
1402 assert_eq!(steps[0].forward, "define\nperson owns nickname @key;");
1403 assert_eq!(
1404 steps[0].reverse.as_deref(),
1405 Some("undefine\n@key from person owns nickname;")
1406 );
1407 }
1408
1409 #[test]
1410 fn typed_role_operations_lower_to_schema_steps() {
1411 let role = RoleEntry {
1412 role_name: "reviewer".to_string(),
1413 player_type_names: vec!["person".to_string()],
1414 cardinality: Some((0, Some(2))),
1415 overrides: None,
1416 is_abstract: false,
1417 ordered: false,
1418 distinct: false,
1419 doc: None,
1420 meta: Default::default(),
1421 };
1422 let g = graph(vec![migration(
1423 "0001_roles",
1424 vec![
1425 OperationSpec::AddRole {
1426 relation_type: "employment".to_string(),
1427 role,
1428 },
1429 OperationSpec::RemoveRole {
1430 relation_type: "employment".to_string(),
1431 role_name: "legacy".to_string(),
1432 },
1433 OperationSpec::AddRolePlayer {
1434 relation_type: "employment".to_string(),
1435 role_name: "employee".to_string(),
1436 player_type_name: "contractor".to_string(),
1437 },
1438 OperationSpec::RemoveRolePlayer {
1439 relation_type: "employment".to_string(),
1440 role_name: "employee".to_string(),
1441 player_type_name: "company".to_string(),
1442 },
1443 ],
1444 vec![],
1445 )]);
1446
1447 let result = plan(&g, &[], None).expect("plan should succeed");
1448 let steps = &result.to_apply[0].steps;
1449
1450 assert_eq!(
1451 steps[0].forward,
1452 "define\nemployment relates reviewer @card(0..2);\nperson plays employment:reviewer;"
1453 );
1454 assert_eq!(
1455 steps[0].reverse.as_deref(),
1456 Some(
1457 "undefine\nplays employment:reviewer from person;\nrelates reviewer from employment;"
1458 )
1459 );
1460 assert_eq!(
1461 steps[1].forward,
1462 "undefine\nrelates legacy from employment;"
1463 );
1464 assert!(steps[1].reverse.is_none());
1465 assert_eq!(
1466 steps[2].forward,
1467 "define\ncontractor plays employment:employee;"
1468 );
1469 assert_eq!(
1470 steps[2].reverse.as_deref(),
1471 Some("undefine\nplays employment:employee from contractor;")
1472 );
1473 assert_eq!(
1474 steps[3].forward,
1475 "undefine\nplays employment:employee from company;"
1476 );
1477 assert_eq!(
1478 steps[3].reverse.as_deref(),
1479 Some("define\ncompany plays employment:employee;")
1480 );
1481 }
1482
1483 #[test]
1484 fn migration_reversible_flag_drops_typed_operation_reverses() {
1485 let mut spec = migration(
1486 "0001_non_reversible",
1487 vec![OperationSpec::AddAttribute {
1488 attribute: AttributeSchemaEntry::new("score", ValueType::Long),
1489 }],
1490 vec![],
1491 );
1492 spec.reversible = false;
1493 let g = graph(vec![spec]);
1494
1495 let result = plan(&g, &[], None).expect("plan should succeed");
1496
1497 assert!(result.to_apply[0].steps[0].reverse.is_none());
1498 assert!(!result.to_apply[0].reversible);
1499 }
1500
1501 #[test]
1504 fn unlowered_op_returns_err() {
1505 let g = graph(vec![migration(
1506 "0001_rename_attr",
1507 vec![OperationSpec::RenameAttribute {
1508 old_name: "old-score".to_string(),
1509 new_name: "new-score".to_string(),
1510 value_type: "string".to_string(),
1511 }],
1512 vec![],
1513 )]);
1514
1515 let err = plan(&g, &[], None).expect_err("should fail for unsupported op");
1516 match err {
1517 MigrationError::UnloweredOperation { kind } => {
1518 assert_eq!(kind, "RenameAttribute");
1519 }
1520 other => panic!("expected UnloweredOperation, got {other:?}"),
1521 }
1522 }
1523
1524 #[test]
1527 fn validation_failure_returns_planning_error() {
1528 let g = graph(vec![migration(
1530 "0002_next",
1531 vec![run_typeql("define attribute b, value string;", None)],
1532 vec![("app", "0001_initial")],
1533 )]);
1534
1535 let err = plan(&g, &[], None).expect_err("should fail on validation error");
1536 assert!(
1537 matches!(err, MigrationError::Planning { .. }),
1538 "expected Planning error, got {err:?}"
1539 );
1540 }
1541
1542 #[test]
1545 fn checksum_drift_returns_error() {
1546 let g = graph(vec![migration(
1547 "0001_initial",
1548 vec![run_typeql("define attribute a, value string;", None)],
1549 vec![],
1550 )]);
1551
1552 let bad_applied = AppliedMigrationRecord {
1554 app_label: "app".to_string(),
1555 name: "0001_initial".to_string(),
1556 checksum: "wrong-checksum".to_string(),
1557 applied_at: None,
1558 };
1559
1560 let err = plan(&g, &[bad_applied], None).expect_err("should fail on drift");
1561 assert!(
1562 matches!(err, MigrationError::ChecksumDrift { .. }),
1563 "expected ChecksumDrift error, got {err:?}"
1564 );
1565 }
1566
1567 #[test]
1570 fn migration_with_no_reverse_is_marked_not_reversible() {
1571 let g = graph(vec![migration(
1572 "0001_initial",
1573 vec![run_typeql("define attribute a, value string;", None)],
1575 vec![],
1576 )]);
1577
1578 let result = plan(&g, &[], None).expect("plan should succeed");
1579 assert!(!result.to_apply[0].reversible);
1580 }
1581
1582 #[test]
1583 fn migration_with_all_reverses_is_reversible() {
1584 let g = graph(vec![migration(
1585 "0001_add",
1586 vec![run_typeql(
1587 "define attribute a, value string;",
1588 Some("undefine attribute a;"),
1589 )],
1590 vec![],
1591 )]);
1592
1593 let result = plan(&g, &[], None).expect("plan should succeed");
1594 assert!(result.to_apply[0].reversible);
1595 }
1596
1597 #[test]
1598 fn define_schema_migration_is_not_reversible() {
1599 let g = graph(vec![migration(
1601 "0001_initial",
1602 vec![define_schema_op()],
1603 vec![],
1604 )]);
1605
1606 let result = plan(&g, &[], None).expect("plan should succeed");
1607 assert!(!result.to_apply[0].reversible);
1608 }
1609
1610 #[test]
1613 fn unknown_target_returns_target_not_found_error() {
1614 let g = graph(vec![migration(
1615 "0001_initial",
1616 vec![run_typeql("define attribute a, value string;", None)],
1617 vec![],
1618 )]);
1619
1620 let err = plan(&g, &[], Some("nonexistent_migration"))
1621 .expect_err("should fail for missing target");
1622 assert!(
1623 matches!(err, MigrationError::TargetNotFound { .. }),
1624 "expected TargetNotFound, got {err:?}"
1625 );
1626 }
1627
1628 #[test]
1631 fn copy_attribute_lowers_to_write_typed_backfill_step() {
1632 let forward = "match\n $x isa person, has old-name $v;\n \
1636 not { $x has new-name $d; };\ninsert\n $x has new-name == $v;";
1637 let reverse = "match $x isa person, has new-name $v;\ndelete $v of $x;";
1638 let g = graph(vec![migration(
1639 "0002_backfill",
1640 vec![OperationSpec::CopyAttribute {
1641 owner: None,
1642 source: None,
1643 dest: None,
1644 filter: None,
1645 forward: Some(forward.to_string()),
1646 reverse: Some(reverse.to_string()),
1647 }],
1648 vec![],
1649 )]);
1650
1651 let result = plan(&g, &[], None).expect("plan should succeed");
1652
1653 let exec = &result.to_apply[0];
1654 assert_eq!(exec.steps.len(), 1);
1655 let step = &exec.steps[0];
1656
1657 assert_eq!(
1658 step.tx_type,
1659 TxType::Write,
1660 "CopyAttribute step must use Write tx"
1661 );
1662 assert_eq!(
1663 step.kind,
1664 StepKind::Backfill,
1665 "CopyAttribute step kind must be Backfill"
1666 );
1667 assert_eq!(step.forward, forward, "forward must be carried verbatim");
1669 assert_eq!(
1670 step.reverse.as_deref(),
1671 Some(reverse),
1672 "reverse must be carried verbatim"
1673 );
1674 }
1675
1676 #[test]
1677 fn structured_copy_attribute_lowers_to_the_same_backfill_step() {
1678 let g = graph(vec![migration(
1681 "0002_backfill",
1682 vec![OperationSpec::CopyAttribute {
1683 owner: Some("person".to_string()),
1684 source: Some("old-name".to_string()),
1685 dest: Some("new-name".to_string()),
1686 filter: None,
1687 forward: None,
1688 reverse: None,
1689 }],
1690 vec![],
1691 )]);
1692
1693 let result = plan(&g, &[], None).expect("plan should succeed");
1694
1695 let step = &result.to_apply[0].steps[0];
1696 assert_eq!(step.tx_type, TxType::Write);
1697 assert_eq!(step.kind, StepKind::Backfill);
1698 assert_eq!(
1699 step.forward,
1700 "match\n $x isa person, has old-name $v;\n \
1701 not { $x has new-name $d; };\ninsert\n $x has new-name == $v;"
1702 );
1703 assert_eq!(
1704 step.reverse.as_deref(),
1705 Some("match $x isa person, has new-name $v;\ndelete $v of $x;")
1706 );
1707 }
1708
1709 #[test]
1710 fn step_kind_default_is_schema_for_serde_backcompat() {
1711 let json =
1713 r#"{"tx_type":"Schema","forward":"define attribute a, value string;","reverse":null}"#;
1714 let step: ExecutionStep =
1715 serde_json::from_str(json).expect("should deserialize legacy step");
1716 assert_eq!(
1717 step.kind,
1718 StepKind::Schema,
1719 "missing `kind` field must default to Schema for backward compat"
1720 );
1721 assert_eq!(step.operation_kind, OperationKind::RunTypeql);
1722 }
1723
1724 fn legacy_remove_relation_ops(relation: &str) -> Vec<OperationSpec> {
1729 vec![
1730 OperationSpec::RemoveRolePlayer {
1731 relation_type: relation.to_string(),
1732 role_name: "subject".to_string(),
1733 player_type_name: "person".to_string(),
1734 },
1735 OperationSpec::RemoveRole {
1736 relation_type: relation.to_string(),
1737 role_name: "subject".to_string(),
1738 },
1739 OperationSpec::RemoveRolePlayer {
1740 relation_type: relation.to_string(),
1741 role_name: "badge".to_string(),
1742 player_type_name: "temporary-badge".to_string(),
1743 },
1744 OperationSpec::RemoveRole {
1745 relation_type: relation.to_string(),
1746 role_name: "badge".to_string(),
1747 },
1748 OperationSpec::RemoveOwnership {
1749 owner_type: relation.to_string(),
1750 attr_name: "legacy-link-id".to_string(),
1751 },
1752 OperationSpec::RemoveRelation {
1753 type_name: relation.to_string(),
1754 },
1755 ]
1756 }
1757
1758 #[test]
1759 fn legacy_decomposed_relation_removal_normalizes_to_single_step() {
1760 let g = graph(vec![migration(
1761 "0005_remove_legacy_link",
1762 legacy_remove_relation_ops("legacy-link"),
1763 vec![],
1764 )]);
1765
1766 let result = plan(&g, &[], None).expect("plan should succeed");
1767
1768 let exec = &result.to_apply[0];
1769 assert_eq!(
1770 exec.steps.len(),
1771 1,
1772 "granular removals shadowed by RemoveRelation must be dropped"
1773 );
1774 assert_eq!(exec.steps[0].forward, "undefine\nlegacy-link;");
1775 assert_eq!(exec.steps[0].tx_type, TxType::Schema);
1776 }
1777
1778 #[test]
1779 fn surviving_relation_granular_removals_are_kept() {
1780 let ops = vec![
1782 OperationSpec::RemoveRolePlayer {
1783 relation_type: "employment".to_string(),
1784 role_name: "employee".to_string(),
1785 player_type_name: "contractor".to_string(),
1786 },
1787 OperationSpec::RemoveRole {
1788 relation_type: "employment".to_string(),
1789 role_name: "reviewer".to_string(),
1790 },
1791 OperationSpec::RemoveOwnership {
1792 owner_type: "employment".to_string(),
1793 attr_name: "note".to_string(),
1794 },
1795 ];
1796 let g = graph(vec![migration("0002_trim_employment", ops, vec![])]);
1797
1798 let result = plan(&g, &[], None).expect("plan should succeed");
1799
1800 assert_eq!(result.to_apply[0].steps.len(), 3);
1801 }
1802
1803 #[test]
1804 fn normalization_is_scoped_to_the_removed_relation() {
1805 let mut ops = legacy_remove_relation_ops("legacy-link");
1808 ops.push(OperationSpec::RemoveRolePlayer {
1809 relation_type: "employment".to_string(),
1810 role_name: "employee".to_string(),
1811 player_type_name: "contractor".to_string(),
1812 });
1813 ops.push(OperationSpec::RemoveOwnership {
1814 owner_type: "person".to_string(),
1815 attr_name: "nickname".to_string(),
1816 });
1817 let g = graph(vec![migration("0006_mixed_removals", ops, vec![])]);
1818
1819 let result = plan(&g, &[], None).expect("plan should succeed");
1820
1821 let forwards: Vec<&str> = result.to_apply[0]
1822 .steps
1823 .iter()
1824 .map(|s| s.forward.as_str())
1825 .collect();
1826 assert_eq!(
1827 forwards,
1828 vec![
1829 "undefine\nlegacy-link;",
1830 "undefine\nplays employment:employee from contractor;",
1831 "undefine\nowns nickname from person;",
1832 ]
1833 );
1834 }
1835
1836 #[test]
1837 fn modify_ownership_lowers_per_annotation_steps() {
1838 let g = graph(vec![migration(
1841 "0001_annotations",
1842 vec![OperationSpec::ModifyOwnership {
1843 owner_type: "person".to_string(),
1844 attr_name: "name".to_string(),
1845 old_annotations: "@key @doc(\"old doc\") @meta(\"x\", \"1\")".to_string(),
1846 new_annotations: "@unique @doc(\"new doc\") @meta(\"y\", \"2\")".to_string(),
1847 }],
1848 vec![],
1849 )]);
1850
1851 let result = plan(&g, &[], None).expect("plan should succeed");
1852 let steps = &result.to_apply[0].steps;
1853 assert_eq!(steps.len(), 3);
1854
1855 assert_eq!(
1858 steps[0].forward,
1859 "undefine\n@key from person owns name;\n@meta(\"x\") from person owns name;"
1860 );
1861 assert_eq!(
1862 steps[0].reverse.as_deref(),
1863 Some("define\nperson owns name @key;\nperson owns name @meta(\"x\", \"1\");")
1864 );
1865 assert_eq!(
1866 steps[1].forward,
1867 "redefine\nperson owns name @doc(\"new doc\");"
1868 );
1869 assert_eq!(
1870 steps[1].reverse.as_deref(),
1871 Some("redefine\nperson owns name @doc(\"old doc\");")
1872 );
1873 assert_eq!(
1874 steps[2].forward,
1875 "define\nperson owns name @meta(\"y\", \"2\");\nperson owns name @unique;"
1876 );
1877 assert_eq!(
1878 steps[2].reverse.as_deref(),
1879 Some("undefine\n@meta(\"y\") from person owns name;\n@unique from person owns name;")
1880 );
1881 }
1883
1884 #[test]
1885 fn modify_ownership_with_identical_annotations_lowers_to_no_steps() {
1886 let g = graph(vec![migration(
1887 "0001_noop",
1888 vec![OperationSpec::ModifyOwnership {
1889 owner_type: "person".to_string(),
1890 attr_name: "name".to_string(),
1891 old_annotations: "@key @doc(\"same\")".to_string(),
1892 new_annotations: "@key @doc(\"same\")".to_string(),
1893 }],
1894 vec![],
1895 )]);
1896
1897 let result = plan(&g, &[], None).expect("plan should succeed");
1898 assert!(result.to_apply[0].steps.is_empty());
1899 }
1900
1901 #[test]
1902 fn modify_type_annotations_lowers_add_update_remove() {
1903 let g = graph(vec![migration(
1904 "0001_type_annotations",
1905 vec![OperationSpec::ModifyTypeAnnotations {
1906 type_name: "person".to_string(),
1907 old_doc: Some("old type doc".to_string()),
1908 new_doc: Some("new type doc".to_string()),
1909 old_meta: BTreeMap::from([("gone".to_string(), "1".to_string())]),
1910 new_meta: BTreeMap::from([("added".to_string(), "2".to_string())]),
1911 }],
1912 vec![],
1913 )]);
1914
1915 let result = plan(&g, &[], None).expect("plan should succeed");
1916 let steps = &result.to_apply[0].steps;
1917 assert_eq!(steps.len(), 3);
1918 assert_eq!(steps[0].forward, "undefine\n@meta(\"gone\") from person;");
1919 assert_eq!(
1920 steps[0].reverse.as_deref(),
1921 Some("define\nperson @meta(\"gone\", \"1\");")
1922 );
1923 assert_eq!(steps[1].forward, "redefine\nperson @doc(\"new type doc\");");
1924 assert_eq!(
1925 steps[1].reverse.as_deref(),
1926 Some("redefine\nperson @doc(\"old type doc\");")
1927 );
1928 assert_eq!(steps[2].forward, "define\nperson @meta(\"added\", \"2\");");
1929 assert_eq!(
1930 steps[2].reverse.as_deref(),
1931 Some("undefine\n@meta(\"added\") from person;")
1932 );
1933 }
1934
1935 #[test]
1936 fn modify_role_annotations_lowers_on_relates_subject() {
1937 let g = graph(vec![migration(
1938 "0001_role_annotations",
1939 vec![OperationSpec::ModifyRoleAnnotations {
1940 relation_type: "employment".to_string(),
1941 role_name: "employee".to_string(),
1942 old_doc: None,
1943 new_doc: Some("The employed party.".to_string()),
1944 old_meta: BTreeMap::new(),
1945 new_meta: BTreeMap::new(),
1946 }],
1947 vec![],
1948 )]);
1949
1950 let result = plan(&g, &[], None).expect("plan should succeed");
1951 let steps = &result.to_apply[0].steps;
1952 assert_eq!(steps.len(), 1);
1953 assert_eq!(
1954 steps[0].forward,
1955 "define\nemployment relates employee @doc(\"The employed party.\");"
1956 );
1957 assert_eq!(
1958 steps[0].reverse.as_deref(),
1959 Some("undefine\n@doc from employment relates employee;")
1960 );
1961 }
1962}