Skip to main content

type_bridge_migration/
plan.rs

1//! Pure migration planner.
2//!
3//! Lowers a validated [`MigrationGraph`] and applied-state into an ordered
4//! [`ExecutionPlan`] of [`ExecutionStep`]s, each carrying its [`TxType`] and
5//! the executable TypeQL to run.  No database connection, no async, no TypeDB
6//! driver is touched here.
7
8use 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/// The kind of execution step, controlling how the executor dispatches it.
26///
27/// `Schema` and `Write` run the carried TypeQL directly.  `Backfill` is a
28/// write-typed step that additionally derives matched/inserted/skipped counts
29/// via bracketing `reduce $c = count;` read queries.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
31#[serde(rename_all = "snake_case")]
32pub enum StepKind {
33    /// Schema DDL step — opened under a schema transaction.
34    #[default]
35    Schema,
36    /// Data-write step — opened under a write transaction.
37    Write,
38    /// Backfill step — write transaction + bracketing count derivation.
39    Backfill,
40}
41
42/// Authored operation kind from which an execution step was lowered.
43///
44/// This discriminant is carried separately from [`StepKind`]: `StepKind`
45/// controls transaction dispatch, while `OperationKind` preserves the stable
46/// artifact-level operation identity used by per-step recovery.
47#[derive(
48    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default,
49)]
50#[serde(rename_all = "snake_case")]
51pub enum OperationKind {
52    /// Arbitrary authored TypeQL.
53    #[default]
54    RunTypeql,
55    /// Define a complete initial schema.
56    DefineSchema,
57    /// Add an attribute type.
58    AddAttribute,
59    /// Remove an attribute type.
60    RemoveAttribute,
61    /// Add an entity type.
62    AddEntity,
63    /// Remove an entity type.
64    RemoveEntity,
65    /// Add a relation type.
66    AddRelation,
67    /// Remove a relation type.
68    RemoveRelation,
69    /// Add an ownership capability.
70    AddOwnership,
71    /// Remove an ownership capability.
72    RemoveOwnership,
73    /// Modify ownership annotations.
74    ModifyOwnership,
75    /// Modify type annotations.
76    ModifyTypeAnnotations,
77    /// Modify role annotations.
78    ModifyRoleAnnotations,
79    /// Add a relation role.
80    AddRole,
81    /// Remove a relation role.
82    RemoveRole,
83    /// Add a role player capability.
84    AddRolePlayer,
85    /// Remove a role player capability.
86    RemoveRolePlayer,
87    /// Rename an attribute type.
88    RenameAttribute,
89    /// Copy attribute values as a backfill.
90    CopyAttribute,
91}
92
93impl OperationKind {
94    /// Stable snake-case token used in deterministic step identities.
95    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/// One executable step within a migration.
145///
146/// Carries the transaction type and the forward (and optional reverse) TypeQL.
147/// Every step maps to exactly one TypeDB transaction.
148#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
149pub struct ExecutionStep {
150    /// Transaction type to open for this step.
151    pub tx_type: TxType,
152    /// Discriminant controlling executor dispatch.
153    ///
154    /// Defaults to [`StepKind::Schema`] for backwards-compatible deserialization
155    /// of steps persisted before this field was introduced.
156    #[serde(default)]
157    pub kind: StepKind,
158    /// Artifact operation kind that produced this step.
159    ///
160    /// Defaults to [`OperationKind::RunTypeql`] when deserializing legacy
161    /// persisted plans that predate per-step recovery metadata.
162    #[serde(default)]
163    pub operation_kind: OperationKind,
164    /// Forward (apply) TypeQL text.
165    pub forward: String,
166    /// Reverse (rollback) TypeQL text, or `None` when the step is
167    /// non-reversible.
168    pub reverse: Option<String>,
169}
170
171/// Whether a migration execution is an apply or a rollback.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(rename_all = "snake_case")]
174pub enum MigrationAction {
175    /// Apply the migration forward.
176    Apply,
177    /// Roll the migration back.
178    Rollback,
179}
180
181/// One migration scheduled for execution, together with its assembled steps.
182#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
183pub struct MigrationExecution {
184    /// Application or migration package label.
185    pub app_label: String,
186    /// Migration file stem, such as `0001_initial`.
187    pub name: String,
188    /// Apply or rollback.
189    pub action: MigrationAction,
190    /// Ordered execution steps.
191    pub steps: Vec<ExecutionStep>,
192    /// Whether every step in this migration has a reverse.
193    ///
194    /// `false` when the migration contains a `DefineSchema` op (model-initial
195    /// schema; no reverse) or any op whose reverse is `None`.
196    pub reversible: bool,
197}
198
199/// Ordered execution plan produced by [`plan`].
200#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
201pub struct ExecutionPlan {
202    /// Migrations to apply, in graph (dependency) order.
203    pub to_apply: Vec<MigrationExecution>,
204    /// Migrations to roll back, in reverse discovery order.
205    pub to_rollback: Vec<MigrationExecution>,
206}
207
208/// Produce an ordered [`ExecutionPlan`] from a validated graph and applied state.
209///
210/// # Errors
211///
212/// - [`MigrationError::Planning`] – graph validation found one or more errors.
213/// - [`MigrationError::ChecksumDrift`] – an applied migration's checksum has
214///   drifted (hard gate; no plan is produced).
215/// - [`MigrationError::UnloweredOperation`] – the graph contains an
216///   [`OperationSpec`] variant that is intentionally unsupported by the Rust
217///   planner.
218pub fn plan(
219    graph: &MigrationGraph,
220    applied: &[AppliedMigrationRecord],
221    target: Option<&str>,
222) -> crate::Result<ExecutionPlan> {
223    // Gate 1: structural graph validation (04).
224    let errors = validate_graph(graph, applied);
225    if !errors.is_empty() {
226        return Err(MigrationError::Planning { errors });
227    }
228
229    // Gate 2: checksum drift (04 gate, hard stop before any step assembly).
230    check_checksum_drift(graph, applied)?;
231
232    // Build an applied-key set for O(1) membership checks.
233    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        // Find the target index by name (or app_label::name).
240        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        // Rollbacks go in reverse order (matches Python _create_plan).
264        rollback.reverse();
265        (apply, rollback)
266    } else {
267        // Apply all pending migrations.
268        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    // Assemble MigrationExecution objects for the apply list.
277    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    // Assemble MigrationExecution objects for the rollback list.
291    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
310/// Relation labels deleted wholesale by a `RemoveRelation` in this migration.
311fn 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
321/// True when `op` is a relation-scoped granular removal shadowed by a
322/// `RemoveRelation` of the same relation in the same migration.
323///
324/// Legacy v1.5.x artifacts decomposed whole-relation deletion into
325/// `RemoveRolePlayer`/`RemoveRole`/`RemoveOwnership` before the final
326/// `RemoveRelation`. Executed step-per-transaction, committing the last
327/// role's removal violates TypeDB's commit-time rule that a concrete
328/// relation must relate at least one role, stranding the migration after
329/// partial schema changes (#168). `undefine <relation>` already cascades
330/// roles, player capabilities, and ownerships in one schema transaction, so
331/// the shadowed steps are dropped at plan time — the artifact bytes and
332/// checksum are never touched.
333fn 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
344/// Lower a slice of [`OperationSpec`] into [`ExecutionStep`]s.
345fn 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                // Route through the existing canonical Rust generator
372                // (SchemaInfo::to_typeql → generator::generate_define_block).
373                // This is the only TypeQL generation in plan.rs — no per-variant
374                // re-derivation for any other OperationSpec (invariant 2).
375                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                    // Model-initial migrations are non-reversible.
386                    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                // Carried TypeQL (the frozen `CopyAttribute.to_typeql()` output)
500                // executes verbatim; the structured portable form synthesizes the
501                // identical template. `backfill.rs` composes its count queries
502                // from this `forward` text's match clause.
503                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
532/// Lower an annotation-set change on `subject` into execution steps.
533///
534/// TypeDB 3.12 semantics (verified live): `define`/`undefine` blocks accept
535/// multiple statements per query, while `redefine` mutates exactly one schema
536/// element per query; parameterless annotations (`@key`, `@unique`,
537/// `@distinct`) can only be defined or undefined, never redefined. Removals
538/// therefore group into one `undefine` step, additions into one `define`
539/// step, and each value change becomes its own `redefine` step. Removals run
540/// first — adding `@key` while a conflicting explicit `@card` is still
541/// declared fails schema validation. Reverse steps restore the prior state
542/// with the mirrored verb.
543fn 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
594/// The `@...` reference used in `undefine <ref> from <subject>`.
595///
596/// `@meta` must use the keyed form `@meta("key")`; every other annotation
597/// (parameterless or parameterized) undefines by bare name.
598fn 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
610/// Build the `@doc`/`@meta` token list for one side of a type or role
611/// annotation change.
612fn 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
833/// Return a stable string name for an [`OperationSpec`] variant.
834///
835/// Used only in error messages.
836fn 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    // ── helpers ──────────────────────────────────────────────────────────────
875
876    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: pending-only ordering (target=None) ────────────────────────────
992
993    #[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: target-based apply/rollback split ───────────────────────────────
1045
1046    #[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        // target = 0002_add; none applied yet.
1073        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        // All three applied; target = 0001_initial → rollback 0002 and 0003.
1108        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        // rollback list must be in reverse order: 0003, then 0002
1121        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: rollback reverse ordering for steps ─────────────────────────────
1127
1128    #[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: DefineSchema carries non-empty TypeQL from the generator ─────────
1157
1158    #[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        // Forward must be non-empty TypeQL produced by SchemaInfo::to_typeql().
1172        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        // DefineSchema is non-reversible — no reverse.
1181        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: per-step TxType is Schema for RunTypeql ─────────────────────────
1197
1198    #[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    // ── tests: typed OperationSpec variants lower in Rust ─────────────────────
1232
1233    #[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        // @key can never be redefined (REX28): swapping @card(0..1) for
1343        // @key must lower to an undefine step followed by a define step,
1344        // removals first (defining @key beside a conflicting explicit
1345        // @card fails schema validation).
1346        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        // The no-op transition contributes zero steps.
1401        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: intentionally unsupported operation returns Err ────────────────
1502
1503    #[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: validation failure short-circuits ────────────────────────────────
1525
1526    #[test]
1527    fn validation_failure_returns_planning_error() {
1528        // 0002 depends on 0001 which is not in the graph.
1529        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: checksum drift short-circuits ───────────────────────────────────
1543
1544    #[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        // Record "0001_initial" with a wrong checksum.
1553        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: reversible flag ─────────────────────────────────────────────────
1568
1569    #[test]
1570    fn migration_with_no_reverse_is_marked_not_reversible() {
1571        let g = graph(vec![migration(
1572            "0001_initial",
1573            // reverse is None
1574            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        // DefineSchema never has a reverse.
1600        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: target not found returns TargetNotFound ─────────────────────────
1611
1612    #[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: CopyAttribute lowers to Write-typed Backfill step ───────────────
1629
1630    #[test]
1631    fn copy_attribute_lowers_to_write_typed_backfill_step() {
1632        // The carried forward/reverse mirror `CopyAttribute.to_typeql()` /
1633        // `to_rollback_typeql()`; assemble_steps must pass them through verbatim
1634        // under a Write/Backfill step (no re-synthesis — invariant 2).
1635        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        // The carried strings are passed through unchanged.
1668        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        // The structured portable form synthesizes the exact TypeQL the
1679        // carried form would have contained.
1680        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        // Simulate a legacy JSON step without the `kind` field.
1712        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    // ── test: whole-relation removal normalization (#168) ───────────────────
1725
1726    /// The exact operation shape v1.5.5/v1.5.6 generators authored for a
1727    /// whole-relation deletion: granular unwind, then `RemoveRelation`.
1728    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        // No RemoveRelation for `employment`: granular ops must lower 1:1.
1781        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        // One relation is removed wholesale while another one is trimmed in
1806        // the same migration; only ops scoped to the removed relation drop.
1807        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        // Mixed add/update/remove including parameterless @key/@unique, which
1839        // can never be redefined (REX28) — they must go through define/undefine.
1840        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        // Removals run first: adding @unique while the conflicting @key is
1856        // still declared would fail schema validation.
1857        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        // added order: "meta:y" < "unique" in identity order.
1882    }
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}