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