1use serde::Serialize;
5
6#[cfg(feature = "postgres")]
7use tokio_postgres::Client;
8
9use crate::config::WaypointConfig;
10#[cfg(feature = "postgres")]
11use crate::db::quote_ident;
12use crate::db::DbClient;
13use crate::dialect::DialectKind;
14use crate::error::{Result, WaypointError};
15use crate::history;
16use crate::migration::scan_migrations;
17use crate::placeholder::{build_placeholders, replace_placeholders};
18#[cfg(feature = "postgres")]
19use crate::schema;
20
21#[derive(Debug, Clone, Serialize)]
23pub struct SimulationReport {
24 pub passed: bool,
26 pub migrations_simulated: usize,
28 pub temp_schema: String,
30 pub errors: Vec<SimulationError>,
32 #[serde(default)]
36 pub warnings: Vec<String>,
37}
38
39#[derive(Debug, Clone, Serialize)]
41pub struct SimulationError {
42 pub script: String,
44 pub error: String,
46}
47
48#[cfg(feature = "postgres")]
50pub async fn execute(client: &Client, config: &WaypointConfig) -> Result<SimulationReport> {
51 let schema_name = &config.migrations.schema;
52 let table = &config.migrations.table;
53
54 history::create_history_table(client, schema_name, table).await?;
56
57 let temp_schema = format!(
59 "waypoint_sim_{}",
60 std::time::SystemTime::now()
61 .duration_since(std::time::UNIX_EPOCH)
62 .unwrap_or_default()
63 .as_millis()
64 );
65
66 let result = run_simulation(client, config, &temp_schema).await;
67
68 let drop_sql = format!(
70 "DROP SCHEMA IF EXISTS {} CASCADE",
71 quote_ident(&temp_schema)
72 );
73 if let Err(e) = client.batch_execute(&drop_sql).await {
74 log::warn!(
75 "First attempt to drop simulation schema {} failed, retrying: {}",
76 temp_schema,
77 e
78 );
79 if let Err(e2) = client.batch_execute(&drop_sql).await {
80 log::error!(
81 "Failed to drop simulation schema {} after retry: {}",
82 temp_schema,
83 e2
84 );
85 }
86 }
87
88 result
89}
90
91#[cfg(feature = "postgres")]
92async fn run_simulation(
93 client: &Client,
94 config: &WaypointConfig,
95 temp_schema: &str,
96) -> Result<SimulationReport> {
97 let schema_name = &config.migrations.schema;
98 let table = &config.migrations.table;
99
100 let create_sql = format!("CREATE SCHEMA {}", quote_ident(temp_schema));
102 client
103 .batch_execute(&create_sql)
104 .await
105 .map_err(|e| WaypointError::SimulationFailed {
106 reason: format!("Failed to create simulation schema: {}", e),
107 })?;
108
109 let snapshot = schema::introspect(client, schema_name).await?;
111 let ddl = schema::to_ddl(&snapshot);
112
113 if !ddl.is_empty() {
114 let set_path = format!("SET search_path TO {}", quote_ident(temp_schema));
116 client
117 .batch_execute(&set_path)
118 .await
119 .map_err(|e| WaypointError::SimulationFailed {
120 reason: format!("Failed to set search_path: {}", e),
121 })?;
122
123 if let Err(e) = client.batch_execute(&ddl).await {
125 log::debug!("Partial schema replication in simulation: {}", e);
126 }
127 }
128
129 let set_path = format!("SET search_path TO {}", quote_ident(temp_schema));
131 client
132 .batch_execute(&set_path)
133 .await
134 .map_err(|e| WaypointError::SimulationFailed {
135 reason: format!("Failed to set search_path: {}", e),
136 })?;
137
138 let resolved = scan_migrations(&config.migrations.locations)?;
140 let applied = history::get_applied_migrations(client, schema_name, table).await?;
141 let effective = history::effective_applied_versions(&applied);
142
143 let db_user = crate::db::get_current_user(client)
144 .await
145 .unwrap_or_else(|_| "unknown".to_string());
146 let db_name = crate::db::get_current_database(client)
147 .await
148 .unwrap_or_else(|_| "unknown".to_string());
149
150 let mut errors = Vec::new();
151 let mut simulated = 0;
152
153 for migration in &resolved {
154 if migration.is_undo() {
155 continue;
156 }
157 if let Some(version) = migration.version() {
158 if effective.contains(&version.raw) {
159 continue; }
161 }
162
163 let placeholders = build_placeholders(
164 &config.placeholders,
165 temp_schema,
166 &db_user,
167 &db_name,
168 &migration.script,
169 );
170 let sql = match replace_placeholders(&migration.sql, &placeholders) {
171 Ok(s) => s,
172 Err(e) => {
173 errors.push(SimulationError {
174 script: migration.script.clone(),
175 error: e.to_string(),
176 });
177 continue;
178 }
179 };
180
181 match client.batch_execute(&sql).await {
182 Ok(_) => {
183 simulated += 1;
184 }
185 Err(e) => {
186 errors.push(SimulationError {
187 script: migration.script.clone(),
188 error: crate::error::format_db_error(&e),
189 });
190 }
191 }
192 }
193
194 let restore_path = format!("SET search_path TO {}", quote_ident(schema_name));
196 if let Err(e) = client.batch_execute(&restore_path).await {
197 log::warn!("Failed to restore search_path: {}", e);
198 }
199
200 Ok(SimulationReport {
201 passed: errors.is_empty(),
202 migrations_simulated: simulated,
203 temp_schema: temp_schema.to_string(),
204 errors,
205 warnings: Vec::new(),
206 })
207}
208
209pub async fn execute_db(client: &DbClient, config: &WaypointConfig) -> Result<SimulationReport> {
211 match client.dialect_kind() {
212 #[cfg(feature = "postgres")]
213 DialectKind::Postgres => execute(client.as_postgres()?, config).await,
214 #[cfg(not(feature = "postgres"))]
215 DialectKind::Postgres => Err(WaypointError::ConfigError(
216 "PostgreSQL support is not compiled in (enable the `postgres` feature)".into(),
217 )),
218 #[cfg(feature = "mysql")]
219 DialectKind::Mysql => execute_mysql(client, config).await,
220 #[cfg(not(feature = "mysql"))]
221 DialectKind::Mysql => Err(WaypointError::ConfigError(
222 "MySQL support is not compiled in (enable the `mysql` feature)".into(),
223 )),
224 }
225}
226
227#[cfg(feature = "mysql")]
228async fn execute_mysql(client: &DbClient, config: &WaypointConfig) -> Result<SimulationReport> {
229 use mysql_async::prelude::*;
230 let pool = client.as_mysql()?;
231 let source_db = client.resolve_schema(&config.migrations.schema).await?;
232 let table = &config.migrations.table;
233
234 history::create_history_table_db(client, &source_db, table).await?;
235
236 let temp_db = format!(
237 "waypoint_sim_{}",
238 std::time::SystemTime::now()
239 .duration_since(std::time::UNIX_EPOCH)
240 .unwrap_or_default()
241 .as_millis()
242 );
243
244 let result = run_simulation_mysql(client, config, &source_db, &temp_db).await;
245
246 let mut conn = pool.get_conn().await?;
248 let drop_sql = format!("DROP DATABASE IF EXISTS `{}`", temp_db);
249 if let Err(e) = conn.query_drop(&drop_sql).await {
250 log::warn!(
251 "First attempt to drop simulation database {} failed, retrying: {}",
252 temp_db,
253 e
254 );
255 if let Err(e2) = conn.query_drop(&drop_sql).await {
256 log::error!(
257 "Failed to drop simulation database {} after retry: {}",
258 temp_db,
259 e2
260 );
261 }
262 }
263
264 result
265}
266
267#[cfg(feature = "mysql")]
268async fn run_simulation_mysql(
269 client: &DbClient,
270 config: &WaypointConfig,
271 source_db: &str,
272 temp_db: &str,
273) -> Result<SimulationReport> {
274 use mysql_async::prelude::*;
275 let pool = client.as_mysql()?;
276 let mut conn = pool.get_conn().await?;
277
278 let create_sql = format!("CREATE DATABASE `{}`", temp_db);
280 conn.query_drop(&create_sql)
281 .await
282 .map_err(|e| WaypointError::SimulationFailed {
283 reason: format!("Failed to create simulation database: {}", e),
284 })?;
285
286 let tables: Vec<String> = conn
292 .exec(
293 "SELECT TABLE_NAME FROM information_schema.TABLES \
294 WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE' \
295 ORDER BY TABLE_NAME",
296 (source_db,),
297 )
298 .await?;
299
300 conn.query_drop(format!("USE `{}`", temp_db)).await?;
301
302 let mut warnings: Vec<String> = Vec::new();
303
304 for table_name in &tables {
305 let show_stmt = format!("SHOW CREATE TABLE `{}`.`{}`", source_db, table_name);
306 if let Ok(Some((_, create_sql))) = conn.query_first::<(String, String), _>(&show_stmt).await
307 {
308 if let Err(e) = conn.query_drop(&create_sql).await {
311 warnings.push(format!(
312 "Could not replicate table `{}` into the simulation database: {}. \
313 Migrations that depend on this table may report misleading errors.",
314 table_name, e
315 ));
316 }
317 }
318 }
319
320 let views: Vec<String> = conn
328 .exec(
329 "SELECT TABLE_NAME FROM information_schema.VIEWS \
330 WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME",
331 (source_db,),
332 )
333 .await?;
334 for view_name in &views {
335 let show_stmt = format!("SHOW CREATE VIEW `{}`.`{}`", source_db, view_name);
336 if let Ok(Some(row)) = conn.query_first::<mysql_async::Row, _>(&show_stmt).await {
337 let mut row = row;
338 if let Some(create_sql) = row.take::<String, _>(1) {
339 let other_db = first_other_db_qualifier(&create_sql, source_db);
340 let rewritten = rewrite_view_db_qualifier(&create_sql, source_db);
341 if let Err(e) = conn.query_drop(&rewritten).await {
342 if let Some(other) = other_db {
343 warnings.push(format!(
344 "View `{}` references database `{}` which is not replicated \
345 into the simulation environment; skipped (error: {}). \
346 Migrations that read from this view may surface misleading errors.",
347 view_name, other, e
348 ));
349 } else {
350 warnings.push(format!(
351 "Could not replicate view `{}` into the simulation database: {}.",
352 view_name, e
353 ));
354 }
355 }
356 }
357 }
358 }
359
360 let resolved = scan_migrations(&config.migrations.locations)?;
362 let applied =
363 history::get_applied_migrations_db(client, source_db, &config.migrations.table).await?;
364 let effective = history::effective_applied_versions(&applied);
365
366 let db_user = client
367 .current_user()
368 .await
369 .unwrap_or_else(|_| "unknown".into());
370 let db_name = client
371 .current_database()
372 .await
373 .unwrap_or_else(|_| "unknown".into());
374
375 let mut errors = Vec::new();
376 let mut simulated = 0;
377
378 for migration in &resolved {
379 if migration.is_undo() {
380 continue;
381 }
382 if let Some(version) = migration.version() {
383 if effective.contains(&version.raw) {
384 continue;
385 }
386 }
387
388 let placeholders = build_placeholders(
389 &config.placeholders,
390 temp_db,
391 &db_user,
392 &db_name,
393 &migration.script,
394 );
395 let sql = match replace_placeholders(&migration.sql, &placeholders) {
396 Ok(s) => s,
397 Err(e) => {
398 errors.push(SimulationError {
399 script: migration.script.clone(),
400 error: e.to_string(),
401 });
402 continue;
403 }
404 };
405
406 match client.execute_raw(&sql).await {
409 Ok(_) => simulated += 1,
410 Err(e) => errors.push(SimulationError {
411 script: migration.script.clone(),
412 error: e.to_string(),
413 }),
414 }
415 }
416
417 Ok(SimulationReport {
418 passed: errors.is_empty(),
419 migrations_simulated: simulated,
420 temp_schema: temp_db.to_string(),
421 errors,
422 warnings,
423 })
424}
425
426#[cfg(feature = "mysql")]
436fn rewrite_view_db_qualifier(create_sql: &str, source_db: &str) -> String {
437 let qualifier = format!("`{}`.", source_db);
438 create_sql.replace(&qualifier, "")
439}
440
441#[cfg(feature = "mysql")]
449fn first_other_db_qualifier(create_sql: &str, source_db: &str) -> Option<String> {
450 let bytes = create_sql.as_bytes();
451 let mut i = 0;
452 while i < bytes.len() {
453 if bytes[i] == b'`' {
454 let start = i + 1;
456 let mut j = start;
457 while j < bytes.len() && bytes[j] != b'`' {
458 j += 1;
459 }
460 if j >= bytes.len() {
461 return None;
462 }
463 let preceded_by_dot = i > 0 && bytes[i - 1] == b'.';
469 let followed_by_dot = j + 1 < bytes.len() && bytes[j + 1] == b'.';
470 if !preceded_by_dot && followed_by_dot {
471 let ident = &create_sql[start..j];
472 if ident != source_db && !ident.is_empty() {
473 return Some(ident.to_string());
474 }
475 }
476 i = j + 1;
477 } else {
478 i += 1;
479 }
480 }
481 None
482}
483
484#[cfg(all(test, feature = "mysql"))]
485mod tests {
486 use super::*;
487
488 #[test]
489 fn rewrite_strips_source_db_prefix() {
490 let sql = "CREATE VIEW `v` AS SELECT `db1`.`t`.`c` FROM `db1`.`t`";
491 let out = rewrite_view_db_qualifier(sql, "db1");
492 assert_eq!(out, "CREATE VIEW `v` AS SELECT `t`.`c` FROM `t`");
493 }
494
495 #[test]
496 fn rewrite_preserves_unrelated_db_prefix() {
497 let sql = "CREATE VIEW `v` AS SELECT `other`.`t`.`c` FROM `other`.`t`";
498 let out = rewrite_view_db_qualifier(sql, "db1");
499 assert_eq!(out, sql);
500 }
501
502 #[test]
503 fn rewrite_handles_no_qualifier() {
504 let sql = "CREATE VIEW `v` AS SELECT 1 AS x";
505 let out = rewrite_view_db_qualifier(sql, "db1");
506 assert_eq!(out, sql);
507 }
508
509 #[test]
510 fn first_other_db_detects_cross_db_ref() {
511 let sql = "CREATE VIEW `v` AS SELECT `shared`.`t`.`c` FROM `shared`.`t`";
512 assert_eq!(
513 first_other_db_qualifier(sql, "app"),
514 Some("shared".to_string())
515 );
516 }
517
518 #[test]
519 fn first_other_db_ignores_source_db() {
520 let sql = "CREATE VIEW `v` AS SELECT `app`.`t`.`c` FROM `app`.`t`";
523 assert_eq!(first_other_db_qualifier(sql, "app"), None);
524 }
525
526 #[test]
527 fn first_other_db_returns_none_for_no_qualifier() {
528 let sql = "CREATE VIEW `v` AS SELECT 1 AS x";
529 assert_eq!(first_other_db_qualifier(sql, "app"), None);
530 }
531
532 #[test]
533 fn first_other_db_reports_first_match() {
534 let sql = "CREATE VIEW `v` AS \
536 SELECT `shared`.`t`.`c`, `audit`.`log`.`m` \
537 FROM `shared`.`t` JOIN `audit`.`log`";
538 assert_eq!(
539 first_other_db_qualifier(sql, "app"),
540 Some("shared".to_string())
541 );
542 }
543}