Skip to main content

gaman_core/
offline_planner.rs

1//! Offline migration planner for replaying committed migrations and generating the next migration.
2//!
3//! The planner owns the offline lifecycle: replay baseline, normalize schemas, clarify risky
4//! operations, canonicalize through dialect preparation, diff, and render SQL on request.
5
6use thiserror::Error;
7
8use crate::clarifier::{
9    Clarifier, ClarifyError, ClarifyResult, Decision, TypeResolution, non_type_decisions,
10    resolve_unknown_types,
11};
12use crate::dialects::{Dialect, DialectError};
13use crate::diff::{DiffEngine, DiffError};
14use crate::graphs::{GraphError, MigrationGraph};
15use crate::migrations::Migration;
16use crate::replay::{ReplayEngine, ReplaySources, compute_deps, deterministic_name_from_ops};
17use crate::sql_plan::{SqlPlanError, SqlPlanRenderer};
18use crate::states::{ReplayError, Schema};
19
20#[derive(Debug, Error)]
21pub enum OfflineError {
22    #[error(transparent)]
23    Graph(#[from] GraphError),
24    #[error(transparent)]
25    Diff(#[from] DiffError),
26    #[error(transparent)]
27    Clarifier(#[from] ClarifyError),
28    #[error("migration generation needs clarification input")]
29    NeedsInput(Vec<crate::clarifier::Clarification>),
30    #[error(transparent)]
31    Dialect(#[from] DialectError),
32    #[error(transparent)]
33    Replay(#[from] ReplayError),
34    #[error(transparent)]
35    SqlPlan(#[from] SqlPlanError),
36    #[error("schema validation failed: {0}")]
37    Schema(String),
38}
39
40#[derive(Debug, Clone)]
41pub struct OfflinePlanner {
42    dialect: Dialect,
43    migrations: Vec<Migration>,
44}
45
46#[derive(Copy, Clone)]
47pub struct EmbeddedMigrations {
48    pub files: &'static [(&'static str, &'static str)],
49    pub dir: &'static str,
50    pub children: &'static [(&'static str, &'static EmbeddedMigrations)],
51}
52
53impl OfflinePlanner {
54    pub fn new(dialect: Dialect) -> Self {
55        Self {
56            dialect,
57            migrations: Vec::new(),
58        }
59    }
60
61    pub fn from_migrations(mut self, migrations: Vec<Migration>) -> Self {
62        self.migrations = migrations;
63        self
64    }
65
66    pub fn replay(&self) -> Result<Schema, OfflineError> {
67        self.replay_with_sources()?
68            .schema
69            .prepare(self.dialect)
70            .map_err(|err| OfflineError::Schema(err.to_string()))
71    }
72
73    pub fn make_migration(
74        &self,
75        desired_schema: Schema,
76        decisions: &[Decision],
77    ) -> Result<Option<Migration>, OfflineError> {
78        let replay = self.replay_with_sources()?;
79        let previous = replay
80            .schema
81            .prepare(self.dialect)
82            .map_err(|err| OfflineError::Schema(err.to_string()))?;
83        let desired_schema = desired_schema
84            .prepare(self.dialect)
85            .map_err(|err| OfflineError::Schema(err.to_string()))?;
86        let desired_schema =
87            match resolve_unknown_types(self.dialect, desired_schema, &previous, decisions)? {
88                TypeResolution::Resolved(schema) => schema,
89                TypeResolution::NeedsInput(clarifications) => {
90                    return Err(OfflineError::NeedsInput(clarifications));
91                }
92            }
93            .prepare(self.dialect)
94            .map_err(|err| OfflineError::Schema(err.to_string()))?;
95        let raw_ops = DiffEngine::new().diff(&desired_schema, &previous, &self.dialect)?;
96        if raw_ops.is_empty() {
97            return Ok(None);
98        }
99        let op_decisions = non_type_decisions(decisions);
100        let ops = match Clarifier.process(&raw_ops, &op_decisions)? {
101            ClarifyResult::NeedsInput(clarifications) => {
102                return Err(OfflineError::NeedsInput(clarifications));
103            }
104            ClarifyResult::Resolved(ops) => ops,
105        };
106        let ops = self.dialect.reorder(ops, &previous, &desired_schema);
107        let id = format!(
108            "{:04}_{}",
109            self.next_number(),
110            deterministic_name_from_ops(&ops)
111        );
112        MigrationGraph::validate_id(&id)?;
113        Ok(Some(Migration {
114            id,
115            dependencies: compute_deps(&ops, &replay.last_per_ns, &replay.entity_ns),
116            operations: ops,
117            atomic: true,
118        }))
119    }
120
121    pub fn sql_migrate(&self, migrations: &[Migration]) -> Result<Vec<String>, OfflineError> {
122        Ok(SqlPlanRenderer::new(self.dialect, self.migrations.clone())?
123            .render_migrations(migrations)?)
124    }
125
126    fn graph(&self) -> Result<(MigrationGraph, Vec<String>), OfflineError> {
127        let mut graph = MigrationGraph::new();
128        for migration in self.migrations.clone() {
129            graph.add(migration)?;
130        }
131        let ordered_ids = graph
132            .topological_order()?
133            .into_iter()
134            .map(str::to_string)
135            .collect();
136        Ok((graph, ordered_ids))
137    }
138
139    fn next_number(&self) -> u32 {
140        self.migrations
141            .iter()
142            .filter(|migration| !migration.id.contains('/'))
143            .filter_map(|migration| migration.id.split('_').next()?.parse::<u32>().ok())
144            .max()
145            .map(|number| number + 1)
146            .unwrap_or(1)
147    }
148
149    fn replay_with_sources(&self) -> Result<ReplaySources, OfflineError> {
150        let (graph, ordered_ids) = self.graph()?;
151        Ok(ReplayEngine::new(&graph).replay_with_sources(&ordered_ids)?)
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use crate::clarifier::{Answer, ClarificationKind, Decision};
159    use crate::operations::Operation;
160    use crate::states::{Column, Schema, Table};
161
162    fn users_table(columns: Vec<Column>) -> Table {
163        Table {
164            name: "users".to_string(),
165            schema: None,
166            primary_key: None,
167            columns,
168            foreign_keys: vec![],
169            indexes: vec![],
170            constraints: vec![],
171            triggers: vec![],
172            options: Default::default(),
173        }
174    }
175
176    fn id_col() -> Column {
177        Column {
178            name: "id".to_string(),
179            col_type: "integer".to_string(),
180            nullable: false,
181            default: None,
182            primary_key: true,
183            references: None,
184            check: None,
185            generated: None,
186        }
187    }
188
189    fn email_col() -> Column {
190        Column {
191            name: "email".to_string(),
192            col_type: "text".to_string(),
193            nullable: true,
194            default: None,
195            primary_key: false,
196            references: None,
197            check: None,
198            generated: None,
199        }
200    }
201
202    fn col(name: &str, col_type: &str) -> Column {
203        Column {
204            name: name.to_string(),
205            col_type: col_type.to_string(),
206            nullable: true,
207            default: None,
208            primary_key: false,
209            references: None,
210            check: None,
211            generated: None,
212        }
213    }
214
215    #[test]
216    fn offline_planner_generates_migration_without_database_driver() {
217        let mut desired = Schema::default();
218        desired.tables.insert(
219            "users".to_string(),
220            users_table(vec![id_col(), email_col()]),
221        );
222
223        let migration = OfflinePlanner::new(Dialect::Postgres)
224            .make_migration(desired, &[])
225            .expect("offline planning should succeed")
226            .expect("migration should be generated");
227
228        assert_eq!(migration.id, "0001_users");
229        assert!(migration.dependencies.is_empty());
230        assert!(matches!(
231            migration.operations.as_slice(),
232            [Operation::CreateTable { table }] if table.name == "users"
233        ));
234    }
235
236    #[test]
237    fn offline_replay_is_deterministic() {
238        let migration = Migration {
239            id: "0001_users".to_string(),
240            dependencies: vec![],
241            operations: vec![Operation::CreateTable {
242                table: users_table(vec![id_col()]),
243            }],
244            atomic: true,
245        };
246        let planner = OfflinePlanner::new(Dialect::Postgres).from_migrations(vec![migration]);
247
248        let first = planner.replay().expect("first replay should succeed");
249        let second = planner.replay().expect("second replay should succeed");
250
251        assert_eq!(first, second);
252        assert!(first.tables.contains_key("users"));
253    }
254
255    #[test]
256    fn migration_yaml_round_trips_from_strings() {
257        let migration = Migration {
258            id: "0001_users".to_string(),
259            dependencies: vec![],
260            operations: vec![Operation::CreateTable {
261                table: users_table(vec![id_col()]),
262            }],
263            atomic: true,
264        };
265
266        let yaml = migration.to_yaml_string().expect("serialize migration");
267        let mut parsed = Migration::from_yaml_str(&yaml).expect("parse migration");
268        parsed.id = migration.id.clone();
269
270        assert_eq!(parsed.id, migration.id);
271        assert_eq!(parsed.operations, migration.operations);
272    }
273
274    #[test]
275    fn make_migration_asks_for_new_unknown_type_before_diff() {
276        let mut desired = Schema::default();
277        desired.tables.insert(
278            "users".to_string(),
279            users_table(vec![id_col(), col("age", "intger")]),
280        );
281
282        let err = OfflinePlanner::new(Dialect::Postgres)
283            .make_migration(desired, &[])
284            .unwrap_err();
285        let OfflineError::NeedsInput(clarifications) = err else {
286            panic!("expected needs input");
287        };
288
289        assert_eq!(clarifications.len(), 1);
290        assert!(matches!(
291            &clarifications[0].kind,
292            ClarificationKind::UnknownType { type_name, suggested, .. }
293                if type_name == "intger" && suggested.contains(&"integer".to_string())
294        ));
295    }
296
297    #[test]
298    fn make_migration_type_decision_rewrites_desired_schema() {
299        let mut desired = Schema::default();
300        desired.tables.insert(
301            "users".to_string(),
302            users_table(vec![id_col(), col("age", "intger")]),
303        );
304        let decisions = vec![Decision {
305            clarification_id: "unknown_type:users:age".to_string(),
306            answer: Answer::UseType("integer".to_string()),
307        }];
308
309        let migration = OfflinePlanner::new(Dialect::Postgres)
310            .make_migration(desired, &decisions)
311            .expect("planning should succeed")
312            .expect("migration should be generated");
313        let Operation::CreateTable { table } = &migration.operations[0] else {
314            panic!("expected create table");
315        };
316
317        assert_eq!(table.columns[1].col_type, "integer");
318    }
319
320    #[test]
321    fn make_migration_trusts_unknown_type_from_replay() {
322        let previous = Migration {
323            id: "0001_users".to_string(),
324            dependencies: vec![],
325            operations: vec![Operation::CreateTable {
326                table: users_table(vec![id_col(), col("code", "project_code")]),
327            }],
328            atomic: true,
329        };
330        let mut desired = Schema::default();
331        desired.tables.insert(
332            "users".to_string(),
333            users_table(vec![
334                id_col(),
335                col("code", "project_code"),
336                col("other_code", "project_code"),
337            ]),
338        );
339
340        let migration = OfflinePlanner::new(Dialect::Postgres)
341            .from_migrations(vec![previous])
342            .make_migration(desired, &[])
343            .expect("trusted replay type should not ask")
344            .expect("migration should be generated");
345
346        assert!(matches!(
347            migration.operations.as_slice(),
348            [Operation::AddColumn { column, .. }] if column.col_type == "project_code"
349        ));
350    }
351
352    #[test]
353    fn make_migration_accepts_known_extension_type_without_prompt() {
354        let mut desired = Schema::default();
355        desired.tables.insert(
356            "users".to_string(),
357            users_table(vec![id_col(), col("email", "citext")]),
358        );
359
360        let migration = OfflinePlanner::new(Dialect::Postgres)
361            .make_migration(desired, &[])
362            .expect("known extension type should not ask")
363            .expect("migration should be generated");
364
365        assert!(matches!(
366            migration.operations.as_slice(),
367            [Operation::CreateTable { table }] if table.columns[1].col_type == "citext"
368        ));
369    }
370}