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 = crate::db::sandbox_name("waypoint_sim");
60
61 let result = run_simulation(client, config, &temp_schema).await;
62
63 let restore_path = format!("SET search_path TO {}", quote_ident(schema_name));
68 if let Err(e) = client.batch_execute(&restore_path).await {
69 log::warn!("Failed to restore search_path: {}", e);
70 }
71
72 let drop_sql = format!(
74 "DROP SCHEMA IF EXISTS {} CASCADE",
75 quote_ident(&temp_schema)
76 );
77 if let Err(e) = client.batch_execute(&drop_sql).await {
78 log::warn!(
79 "First attempt to drop simulation schema {} failed, retrying: {}",
80 temp_schema,
81 e
82 );
83 if let Err(e2) = client.batch_execute(&drop_sql).await {
84 log::error!(
85 "Failed to drop simulation schema {} after retry: {}",
86 temp_schema,
87 e2
88 );
89 }
90 }
91
92 result
93}
94
95#[cfg(feature = "postgres")]
96async fn run_simulation(
97 client: &Client,
98 config: &WaypointConfig,
99 temp_schema: &str,
100) -> Result<SimulationReport> {
101 let schema_name = &config.migrations.schema;
102 let table = &config.migrations.table;
103
104 let create_sql = format!("CREATE SCHEMA {}", quote_ident(temp_schema));
106 client
107 .batch_execute(&create_sql)
108 .await
109 .map_err(|e| WaypointError::SimulationFailed {
110 reason: format!("Failed to create simulation schema: {}", e),
111 })?;
112
113 let snapshot = schema::introspect(client, schema_name).await?;
115 let ddl = schema::to_ddl(&snapshot);
116
117 if !ddl.is_empty() {
118 let set_path = format!("SET search_path TO {}", quote_ident(temp_schema));
120 client
121 .batch_execute(&set_path)
122 .await
123 .map_err(|e| WaypointError::SimulationFailed {
124 reason: format!("Failed to set search_path: {}", e),
125 })?;
126
127 if let Err(e) = client.batch_execute(&ddl).await {
129 log::debug!("Partial schema replication in simulation: {}", e);
130 }
131 }
132
133 let set_path = format!("SET search_path TO {}", quote_ident(temp_schema));
135 client
136 .batch_execute(&set_path)
137 .await
138 .map_err(|e| WaypointError::SimulationFailed {
139 reason: format!("Failed to set search_path: {}", e),
140 })?;
141
142 let resolved = scan_migrations(&config.migrations.locations)?;
144 let applied = history::get_applied_migrations(client, schema_name, table).await?;
145 let effective = history::effective_applied_versions(&applied);
146
147 let db_user = crate::db::get_current_user(client)
148 .await
149 .unwrap_or_else(|_| "unknown".to_string());
150 let db_name = crate::db::get_current_database(client)
151 .await
152 .unwrap_or_else(|_| "unknown".to_string());
153
154 let mut errors = Vec::new();
155 let mut simulated = 0;
156
157 for migration in &resolved {
158 if migration.is_undo() {
159 continue;
160 }
161 if let Some(version) = migration.version()
162 && effective.contains(&version.raw)
163 {
164 continue; }
166
167 let placeholders = build_placeholders(
168 &config.placeholders,
169 temp_schema,
170 &db_user,
171 &db_name,
172 &migration.script,
173 );
174 let sql = match replace_placeholders(&migration.sql, &placeholders) {
175 Ok(s) => s,
176 Err(e) => {
177 errors.push(SimulationError {
178 script: migration.script.clone(),
179 error: e.to_string(),
180 });
181 continue;
182 }
183 };
184
185 match client.batch_execute(&sql).await {
186 Ok(_) => {
187 simulated += 1;
188 }
189 Err(e) => {
190 errors.push(SimulationError {
191 script: migration.script.clone(),
192 error: crate::error::format_db_error(&e),
193 });
194 }
195 }
196 }
197
198 Ok(SimulationReport {
202 passed: errors.is_empty(),
203 migrations_simulated: simulated,
204 temp_schema: temp_schema.to_string(),
205 errors,
206 warnings: Vec::new(),
207 })
208}
209
210pub async fn execute_db(client: &DbClient, config: &WaypointConfig) -> Result<SimulationReport> {
212 match client.dialect_kind() {
213 #[cfg(feature = "postgres")]
214 DialectKind::Postgres => execute(client.as_postgres()?, config).await,
215 #[cfg(not(feature = "postgres"))]
216 DialectKind::Postgres => Err(WaypointError::ConfigError(
217 "PostgreSQL support is not compiled in (enable the `postgres` feature)".into(),
218 )),
219 #[cfg(feature = "mysql")]
220 DialectKind::Mysql => execute_mysql(client, config).await,
221 #[cfg(not(feature = "mysql"))]
222 DialectKind::Mysql => Err(WaypointError::ConfigError(
223 "MySQL support is not compiled in (enable the `mysql` feature)".into(),
224 )),
225 }
226}
227
228#[cfg(feature = "mysql")]
229async fn execute_mysql(client: &DbClient, config: &WaypointConfig) -> Result<SimulationReport> {
230 use mysql_async::prelude::*;
231 let pool = client.as_mysql()?;
232 let source_db = client.resolve_schema(&config.migrations.schema).await?;
233 let table = &config.migrations.table;
234
235 history::create_history_table_db(client, &source_db, table).await?;
236
237 let temp_db = crate::db::sandbox_name("waypoint_sim");
238
239 let result = run_simulation_mysql(client, config, &source_db, &temp_db).await;
240
241 let mut conn = pool.get_conn().await?;
243 let drop_sql = format!("DROP DATABASE IF EXISTS {}", qi(&temp_db));
244 if let Err(e) = conn.query_drop(&drop_sql).await {
245 log::warn!(
246 "First attempt to drop simulation database {} failed, retrying: {}",
247 temp_db,
248 e
249 );
250 if let Err(e2) = conn.query_drop(&drop_sql).await {
251 log::error!(
252 "Failed to drop simulation database {} after retry: {}",
253 temp_db,
254 e2
255 );
256 }
257 }
258
259 result
260}
261
262#[cfg(feature = "mysql")]
263async fn run_simulation_mysql(
264 client: &DbClient,
265 config: &WaypointConfig,
266 source_db: &str,
267 temp_db: &str,
268) -> Result<SimulationReport> {
269 use mysql_async::prelude::*;
270 let pool = client.as_mysql()?;
271 let mut conn = pool.get_conn().await?;
272
273 let create_sql = format!("CREATE DATABASE {}", qi(temp_db));
275 conn.query_drop(&create_sql)
276 .await
277 .map_err(|e| WaypointError::SimulationFailed {
278 reason: format!("Failed to create simulation database: {}", e),
279 })?;
280
281 let tables: Vec<String> = conn
287 .exec(
288 "SELECT TABLE_NAME FROM information_schema.TABLES \
289 WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE' \
290 ORDER BY TABLE_NAME",
291 (source_db,),
292 )
293 .await?;
294
295 conn.query_drop(format!("USE {}", qi(temp_db))).await?;
296
297 let mut warnings: Vec<String> = Vec::new();
298
299 for table_name in &tables {
300 let show_stmt = format!("SHOW CREATE TABLE {}.{}", qi(source_db), qi(table_name));
301 if let Ok(Some((_, create_sql))) = conn.query_first::<(String, String), _>(&show_stmt).await
302 {
303 if let Err(e) = conn.query_drop(&create_sql).await {
306 warnings.push(format!(
307 "Could not replicate table `{}` into the simulation database: {}. \
308 Migrations that depend on this table may report misleading errors.",
309 table_name, e
310 ));
311 }
312 }
313 }
314
315 let views: Vec<String> = conn
323 .exec(
324 "SELECT TABLE_NAME FROM information_schema.VIEWS \
325 WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME",
326 (source_db,),
327 )
328 .await?;
329 for view_name in &views {
330 let show_stmt = format!("SHOW CREATE VIEW {}.{}", qi(source_db), qi(view_name));
331 if let Ok(Some(row)) = conn.query_first::<mysql_async::Row, _>(&show_stmt).await {
332 let mut row = row;
333 if let Some(create_sql) = row.take::<String, _>(1) {
334 let other_db = first_other_db_qualifier(&create_sql, source_db);
335 let rewritten = rewrite_view_db_qualifier(&create_sql, source_db);
336 if let Err(e) = conn.query_drop(&rewritten).await {
337 if let Some(other) = other_db {
338 warnings.push(format!(
339 "View `{}` references database `{}` which is not replicated \
340 into the simulation environment; skipped (error: {}). \
341 Migrations that read from this view may surface misleading errors.",
342 view_name, other, e
343 ));
344 } else {
345 warnings.push(format!(
346 "Could not replicate view `{}` into the simulation database: {}.",
347 view_name, e
348 ));
349 }
350 }
351 }
352 }
353 }
354
355 let resolved = scan_migrations(&config.migrations.locations)?;
357 let applied =
358 history::get_applied_migrations_db(client, source_db, &config.migrations.table).await?;
359 let effective = history::effective_applied_versions(&applied);
360
361 let db_user = client
362 .current_user()
363 .await
364 .unwrap_or_else(|_| "unknown".into());
365
366 let mut errors = Vec::new();
367 let mut simulated = 0;
368
369 for migration in &resolved {
370 if migration.is_undo() {
371 continue;
372 }
373 if let Some(version) = migration.version()
374 && effective.contains(&version.raw)
375 {
376 continue;
377 }
378
379 let placeholders = build_placeholders(
384 &config.placeholders,
385 temp_db,
386 &db_user,
387 temp_db,
388 &migration.script,
389 );
390 let sql = match replace_placeholders(&migration.sql, &placeholders) {
391 Ok(s) => s,
392 Err(e) => {
393 errors.push(SimulationError {
394 script: migration.script.clone(),
395 error: e.to_string(),
396 });
397 continue;
398 }
399 };
400
401 let mut failed = None;
407 for stmt in crate::sql_parser::split_mysql_statements(&sql) {
408 if let Err(e) = conn.query_drop(&stmt).await {
409 failed = Some(e.to_string());
410 break;
411 }
412 }
413 match failed {
414 None => simulated += 1,
415 Some(error) => errors.push(SimulationError {
416 script: migration.script.clone(),
417 error,
418 }),
419 }
420 }
421
422 Ok(SimulationReport {
423 passed: errors.is_empty(),
424 migrations_simulated: simulated,
425 temp_schema: temp_db.to_string(),
426 errors,
427 warnings,
428 })
429}
430
431#[cfg(feature = "mysql")]
441fn rewrite_view_db_qualifier(create_sql: &str, source_db: &str) -> String {
442 let qualifier = format!("`{}`.", source_db);
443 create_sql.replace(&qualifier, "")
444}
445
446#[cfg(feature = "mysql")]
454fn first_other_db_qualifier(create_sql: &str, source_db: &str) -> Option<String> {
455 let bytes = create_sql.as_bytes();
456 let mut i = 0;
457 while i < bytes.len() {
458 if bytes[i] == b'`' {
459 let start = i + 1;
461 let mut j = start;
462 while j < bytes.len() && bytes[j] != b'`' {
463 j += 1;
464 }
465 if j >= bytes.len() {
466 return None;
467 }
468 let preceded_by_dot = i > 0 && bytes[i - 1] == b'.';
474 let followed_by_dot = j + 1 < bytes.len() && bytes[j + 1] == b'.';
475 if !preceded_by_dot && followed_by_dot {
476 let ident = &create_sql[start..j];
477 if ident != source_db && !ident.is_empty() {
478 return Some(ident.to_string());
479 }
480 }
481 i = j + 1;
482 } else {
483 i += 1;
484 }
485 }
486 None
487}
488
489#[cfg(all(test, feature = "mysql"))]
490mod tests {
491 use super::*;
492
493 #[test]
494 fn rewrite_strips_source_db_prefix() {
495 let sql = "CREATE VIEW `v` AS SELECT `db1`.`t`.`c` FROM `db1`.`t`";
496 let out = rewrite_view_db_qualifier(sql, "db1");
497 assert_eq!(out, "CREATE VIEW `v` AS SELECT `t`.`c` FROM `t`");
498 }
499
500 #[test]
501 fn rewrite_preserves_unrelated_db_prefix() {
502 let sql = "CREATE VIEW `v` AS SELECT `other`.`t`.`c` FROM `other`.`t`";
503 let out = rewrite_view_db_qualifier(sql, "db1");
504 assert_eq!(out, sql);
505 }
506
507 #[test]
508 fn rewrite_handles_no_qualifier() {
509 let sql = "CREATE VIEW `v` AS SELECT 1 AS x";
510 let out = rewrite_view_db_qualifier(sql, "db1");
511 assert_eq!(out, sql);
512 }
513
514 #[test]
515 fn first_other_db_detects_cross_db_ref() {
516 let sql = "CREATE VIEW `v` AS SELECT `shared`.`t`.`c` FROM `shared`.`t`";
517 assert_eq!(
518 first_other_db_qualifier(sql, "app"),
519 Some("shared".to_string())
520 );
521 }
522
523 #[test]
524 fn first_other_db_ignores_source_db() {
525 let sql = "CREATE VIEW `v` AS SELECT `app`.`t`.`c` FROM `app`.`t`";
528 assert_eq!(first_other_db_qualifier(sql, "app"), None);
529 }
530
531 #[test]
532 fn first_other_db_returns_none_for_no_qualifier() {
533 let sql = "CREATE VIEW `v` AS SELECT 1 AS x";
534 assert_eq!(first_other_db_qualifier(sql, "app"), None);
535 }
536
537 #[test]
538 fn first_other_db_reports_first_match() {
539 let sql = "CREATE VIEW `v` AS \
541 SELECT `shared`.`t`.`c`, `audit`.`log`.`m` \
542 FROM `shared`.`t` JOIN `audit`.`log`";
543 assert_eq!(
544 first_other_db_qualifier(sql, "app"),
545 Some("shared".to_string())
546 );
547 }
548}