1use 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}