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