Skip to main content

coil_data/
migration.rs

1use std::fmt;
2
3use crate::{
4    CompiledStatement, DataModelError, DataRuntime, DataValue, MigrationId, quote_identifier,
5    require_non_empty,
6};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum MigrationOwner {
10    Core,
11    Module(String),
12    CustomerApp(String),
13    AuthPackage(String),
14}
15
16impl fmt::Display for MigrationOwner {
17    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
18        match self {
19            Self::Core => f.write_str("core"),
20            Self::Module(module) => write!(f, "module:{module}"),
21            Self::CustomerApp(app) => write!(f, "customer_app:{app}"),
22            Self::AuthPackage(package) => write!(f, "auth_package:{package}"),
23        }
24    }
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct MigrationStep {
29    pub id: MigrationId,
30    pub owner: MigrationOwner,
31    pub order: u32,
32    pub description: String,
33    pub online_safe: bool,
34    pub statements: Vec<String>,
35}
36
37impl MigrationStep {
38    pub fn new(
39        id: MigrationId,
40        owner: MigrationOwner,
41        order: u32,
42        description: impl Into<String>,
43    ) -> Result<Self, DataModelError> {
44        Ok(Self {
45            id,
46            owner,
47            order,
48            description: require_non_empty("migration_description", description.into())?,
49            online_safe: true,
50            statements: Vec::new(),
51        })
52    }
53
54    pub fn blocking(mut self) -> Self {
55        self.online_safe = false;
56        self
57    }
58
59    pub fn with_statement(mut self, sql: impl Into<String>) -> Result<Self, DataModelError> {
60        self.statements
61            .push(require_non_empty("migration_statement", sql.into())?);
62        Ok(self)
63    }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Default)]
67pub struct MigrationPlan {
68    steps: Vec<MigrationStep>,
69}
70
71impl MigrationPlan {
72    pub fn new() -> Self {
73        Self::default()
74    }
75
76    pub fn insert(&mut self, step: MigrationStep) -> Result<(), DataModelError> {
77        if self
78            .steps
79            .iter()
80            .any(|existing| existing.owner == step.owner && existing.id == step.id)
81        {
82            return Err(DataModelError::DuplicateMigration {
83                owner: step.owner.to_string(),
84                migration_id: step.id.to_string(),
85            });
86        }
87
88        self.steps.push(step);
89        self.steps.sort_by(|left, right| {
90            owner_rank(&left.owner)
91                .cmp(&owner_rank(&right.owner))
92                .then(left.order.cmp(&right.order))
93                .then(left.id.as_str().cmp(right.id.as_str()))
94        });
95        Ok(())
96    }
97
98    pub fn ordered_steps(&self) -> &[MigrationStep] {
99        &self.steps
100    }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Default)]
104pub struct MigrationRegistry {
105    plan: MigrationPlan,
106}
107
108impl MigrationRegistry {
109    pub fn new() -> Self {
110        Self::default()
111    }
112
113    pub fn register(&mut self, plan: &MigrationPlan) -> Result<(), DataModelError> {
114        for step in plan.ordered_steps() {
115            self.plan.insert(step.clone())?;
116        }
117        Ok(())
118    }
119
120    pub fn composed_plan(&self) -> &MigrationPlan {
121        &self.plan
122    }
123
124    pub fn compile_apply_batch(
125        &self,
126        runtime: &DataRuntime,
127    ) -> Result<CompiledMigrationBatch, DataModelError> {
128        let mut statements = Vec::new();
129        let migrations_table =
130            quote_identifier(&format!("{}.{}", runtime.schema, runtime.migrations_table));
131        statements.push(CompiledStatement {
132            sql: format!(
133                "CREATE TABLE IF NOT EXISTS {migrations_table} (owner TEXT NOT NULL, migration_id TEXT NOT NULL, description TEXT NOT NULL, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (owner, migration_id))"
134            ),
135            bind_values: Vec::new(),
136        });
137
138        for step in self.plan.ordered_steps() {
139            if step.statements.is_empty() {
140                return Err(DataModelError::MissingMigrationStatements {
141                    migration_id: step.id.to_string(),
142                });
143            }
144
145            for sql in &step.statements {
146                statements.push(CompiledStatement {
147                    sql: sql.clone(),
148                    bind_values: Vec::new(),
149                });
150            }
151
152            statements.push(CompiledStatement {
153                sql: format!(
154                    "INSERT INTO {migrations_table} (owner, migration_id, description) VALUES ($1, $2, $3) ON CONFLICT (owner, migration_id) DO NOTHING"
155                ),
156                bind_values: vec![
157                    DataValue::String(step.owner.to_string()),
158                    DataValue::String(step.id.to_string()),
159                    DataValue::String(step.description.clone()),
160                ],
161            });
162        }
163
164        Ok(CompiledMigrationBatch { statements })
165    }
166}
167
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct CompiledMigrationBatch {
170    pub statements: Vec<CompiledStatement>,
171}
172
173fn owner_rank(owner: &MigrationOwner) -> u8 {
174    match owner {
175        MigrationOwner::Core => 0,
176        MigrationOwner::Module(_) => 1,
177        MigrationOwner::AuthPackage(_) => 2,
178        MigrationOwner::CustomerApp(_) => 3,
179    }
180}