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