1use std::collections::{HashMap, HashSet};
9
10use serde::Serialize;
11
12use crate::config::{DatabaseConfig, HooksConfig, MigrationSettings, WaypointConfig};
13use crate::db::DbClient;
14use crate::dialect::DialectKind;
15use crate::error::{Result, WaypointError};
16
17#[derive(Debug, Clone)]
19pub struct NamedDatabaseConfig {
20 pub name: String,
22 pub database: DatabaseConfig,
24 pub migrations: MigrationSettings,
26 pub hooks: HooksConfig,
28 pub placeholders: HashMap<String, String>,
30 pub depends_on: Vec<String>,
32}
33
34impl NamedDatabaseConfig {
35 pub fn to_waypoint_config(&self) -> WaypointConfig {
43 self.to_waypoint_config_inheriting(&WaypointConfig::default())
44 }
45
46 pub fn to_waypoint_config_inheriting(&self, parent: &WaypointConfig) -> WaypointConfig {
54 WaypointConfig {
55 database: self.database.clone(),
56 migrations: self.migrations.clone(),
57 hooks: self.hooks.clone(),
58 placeholders: self.placeholders.clone(),
59 lint: parent.lint.clone(),
61 snapshots: parent.snapshots.clone(),
62 preflight: parent.preflight.clone(),
63 guards: parent.guards.clone(),
64 reversals: parent.reversals.clone(),
65 safety: parent.safety.clone(),
66 advisor: parent.advisor.clone(),
67 simulation: parent.simulation.clone(),
68 multi_database: None,
70 }
71 }
72}
73
74pub struct MultiWaypoint {
76 pub databases: Vec<NamedDatabaseConfig>,
78}
79
80#[derive(Debug, Serialize)]
82pub struct DatabaseResult {
83 pub name: String,
85 pub success: bool,
87 pub message: String,
89}
90
91#[derive(Debug, Serialize)]
93pub struct MultiResult {
94 pub results: Vec<DatabaseResult>,
96 pub all_succeeded: bool,
98}
99
100impl MultiWaypoint {
101 pub fn execution_order(databases: &[NamedDatabaseConfig]) -> Result<Vec<String>> {
116 let all_names: HashSet<&str> = databases.iter().map(|d| d.name.as_str()).collect();
117 let declared: Vec<&str> = databases.iter().map(|d| d.name.as_str()).collect();
120 let rank: HashMap<&str, usize> = declared
121 .iter()
122 .enumerate()
123 .map(|(i, &name)| (name, i))
124 .collect();
125
126 let mut in_degree: HashMap<&str, usize> = HashMap::new();
127 let mut reverse_edges: HashMap<&str, Vec<&str>> = HashMap::new();
128
129 for db in databases {
130 in_degree.entry(db.name.as_str()).or_insert(0);
131 for dep in &db.depends_on {
132 if !all_names.contains(dep.as_str()) {
133 return Err(WaypointError::DatabaseNotFound {
134 name: dep.clone(),
135 available: declared.join(", "),
136 });
137 }
138 *in_degree.entry(db.name.as_str()).or_insert(0) += 1;
139 reverse_edges
140 .entry(dep.as_str())
141 .or_default()
142 .push(db.name.as_str());
143 }
144 }
145
146 let mut ready: std::collections::BTreeSet<(usize, &str)> = declared
147 .iter()
148 .filter(|name| in_degree.get(*name).copied().unwrap_or(0) == 0)
149 .map(|&name| (rank[name], name))
150 .collect();
151
152 let mut sorted = Vec::new();
153 while let Some(&(_, name)) = ready.iter().next() {
154 ready.remove(&(rank[name], name));
155 sorted.push(name.to_string());
156 if let Some(dependents) = reverse_edges.get(name) {
157 for &dep in dependents {
158 let deg = in_degree
159 .get_mut(dep)
160 .expect("dependency not found in in_degree map");
161 *deg -= 1;
162 if *deg == 0 {
163 ready.insert((rank[dep], dep));
164 }
165 }
166 }
167 }
168
169 if sorted.len() != databases.len() {
170 let in_cycle: Vec<&str> = declared
173 .iter()
174 .copied()
175 .filter(|name| in_degree.get(name).copied().unwrap_or(0) > 0)
176 .collect();
177 return Err(WaypointError::MultiDbDependencyCycle {
178 path: in_cycle.join(" -> "),
179 });
180 }
181
182 Ok(sorted)
183 }
184
185 pub async fn connect(
193 databases: &[NamedDatabaseConfig],
194 filter: Option<&str>,
195 ) -> Result<HashMap<String, DbClient>> {
196 Self::connect_inheriting(databases, filter, &WaypointConfig::default()).await
197 }
198
199 pub async fn connect_inheriting(
201 databases: &[NamedDatabaseConfig],
202 filter: Option<&str>,
203 parent: &WaypointConfig,
204 ) -> Result<HashMap<String, DbClient>> {
205 let mut clients = HashMap::new();
206
207 for db in databases {
208 if let Some(name_filter) = filter
209 && db.name != name_filter
210 {
211 continue;
212 }
213
214 let config = db.to_waypoint_config_inheriting(parent);
215 let conn_string = config.connection_string()?;
216 let client = crate::db::connect_for_url(&conn_string, &config).await?;
217 clients.insert(db.name.clone(), client);
218 }
219
220 if let Some(name_filter) = filter
221 && !clients.contains_key(name_filter)
222 {
223 let available = databases
224 .iter()
225 .map(|d| d.name.clone())
226 .collect::<Vec<_>>()
227 .join(", ");
228 return Err(WaypointError::DatabaseNotFound {
229 name: name_filter.to_string(),
230 available,
231 });
232 }
233
234 Ok(clients)
235 }
236
237 pub async fn migrate(
239 databases: &[NamedDatabaseConfig],
240 clients: &HashMap<String, DbClient>,
241 order: &[String],
242 target_version: Option<&str>,
243 fail_fast: bool,
244 ) -> Result<MultiResult> {
245 Self::migrate_with_options(databases, clients, order, target_version, fail_fast, false)
246 .await
247 }
248
249 pub async fn migrate_with_options(
256 databases: &[NamedDatabaseConfig],
257 clients: &HashMap<String, DbClient>,
258 order: &[String],
259 target_version: Option<&str>,
260 fail_fast: bool,
261 force: bool,
262 ) -> Result<MultiResult> {
263 Self::migrate_inheriting(
264 databases,
265 clients,
266 order,
267 target_version,
268 fail_fast,
269 force,
270 &WaypointConfig::default(),
271 )
272 .await
273 }
274
275 #[allow(clippy::too_many_arguments)]
278 pub async fn migrate_inheriting(
279 databases: &[NamedDatabaseConfig],
280 clients: &HashMap<String, DbClient>,
281 order: &[String],
282 target_version: Option<&str>,
283 fail_fast: bool,
284 force: bool,
285 parent: &WaypointConfig,
286 ) -> Result<MultiResult> {
287 let mut results = Vec::new();
288
289 for name in order {
290 let db = databases.iter().find(|d| &d.name == name);
291 let client = clients.get(name);
292
293 match (db, client) {
294 (Some(db), Some(client)) => {
295 let config = db.to_waypoint_config_inheriting(parent);
296 let outcome = dispatch_migrate(client, &config, target_version, force).await;
297 match outcome {
298 Ok(report) => {
299 results.push(DatabaseResult {
300 name: name.clone(),
301 success: true,
302 message: format!(
303 "Applied {} migration(s) ({}ms)",
304 report.migrations_applied, report.total_time_ms
305 ),
306 });
307 }
308 Err(e) => {
309 results.push(DatabaseResult {
310 name: name.clone(),
311 success: false,
312 message: format!("{}", e),
313 });
314 if fail_fast {
315 break;
316 }
317 }
318 }
319 }
320 _ => {
321 results.push(DatabaseResult {
322 name: name.clone(),
323 success: false,
324 message: "Database not connected".to_string(),
325 });
326 if fail_fast {
327 break;
328 }
329 }
330 }
331 }
332
333 let all_succeeded = results.iter().all(|r| r.success);
334 Ok(MultiResult {
335 results,
336 all_succeeded,
337 })
338 }
339
340 pub async fn info(
342 databases: &[NamedDatabaseConfig],
343 clients: &HashMap<String, DbClient>,
344 order: &[String],
345 ) -> Result<HashMap<String, Vec<crate::commands::info::MigrationInfo>>> {
346 Self::info_inheriting(databases, clients, order, &WaypointConfig::default()).await
347 }
348
349 pub async fn info_inheriting(
351 databases: &[NamedDatabaseConfig],
352 clients: &HashMap<String, DbClient>,
353 order: &[String],
354 parent: &WaypointConfig,
355 ) -> Result<HashMap<String, Vec<crate::commands::info::MigrationInfo>>> {
356 let mut all_info = HashMap::new();
357
358 for name in order {
359 let db = databases.iter().find(|d| &d.name == name);
360 let client = clients.get(name);
361
362 if let (Some(db), Some(client)) = (db, client) {
363 let config = db.to_waypoint_config_inheriting(parent);
364 let info = crate::commands::info::execute_db(client, &config).await?;
365 all_info.insert(name.clone(), info);
366 }
367 }
368
369 Ok(all_info)
370 }
371}
372
373async fn dispatch_migrate(
375 client: &DbClient,
376 config: &WaypointConfig,
377 target_version: Option<&str>,
378 force: bool,
379) -> Result<crate::commands::migrate::MigrateReport> {
380 match client.dialect_kind() {
381 #[cfg(feature = "postgres")]
382 DialectKind::Postgres => {
383 crate::commands::migrate::execute_with_options(
384 client.as_postgres()?,
385 config,
386 target_version,
387 force,
388 )
389 .await
390 }
391 #[cfg(not(feature = "postgres"))]
392 DialectKind::Postgres => Err(WaypointError::ConfigError(
393 "PostgreSQL support is not compiled in (enable the `postgres` feature)".into(),
394 )),
395 #[cfg(feature = "mysql")]
396 DialectKind::Mysql => {
397 crate::commands::migrate::execute_mysql_with_options(
398 client,
399 config,
400 target_version,
401 force,
402 )
403 .await
404 }
405 #[cfg(not(feature = "mysql"))]
406 DialectKind::Mysql => Err(WaypointError::ConfigError(
407 "MySQL support is not compiled in (enable the `mysql` feature)".into(),
408 )),
409 }
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415 use crate::config::{DatabaseConfig, HooksConfig, MigrationSettings};
416
417 fn db(name: &str, depends_on: &[&str]) -> NamedDatabaseConfig {
418 NamedDatabaseConfig {
419 name: name.to_string(),
420 database: DatabaseConfig::default(),
421 migrations: MigrationSettings::default(),
422 hooks: HooksConfig::default(),
423 placeholders: std::collections::HashMap::new(),
424 depends_on: depends_on.iter().map(|s| s.to_string()).collect(),
425 }
426 }
427
428 #[test]
429 fn test_execution_order_is_deterministic_for_independent_databases() {
430 let dbs = vec![
433 db("alpha", &[]),
434 db("bravo", &[]),
435 db("charlie", &[]),
436 db("delta", &[]),
437 ];
438 let order = MultiWaypoint::execution_order(&dbs).unwrap();
439 assert_eq!(
440 order,
441 vec!["alpha", "bravo", "charlie", "delta"],
442 "independent databases must run in declaration order"
443 );
444 }
445
446 #[test]
447 fn test_execution_order_respects_dependencies_then_declaration_order() {
448 let dbs = vec![db("app", &["auth"]), db("reports", &[]), db("auth", &[])];
451 let order = MultiWaypoint::execution_order(&dbs).unwrap();
452 assert_eq!(order, vec!["reports", "auth", "app"]);
453 }
454
455 #[test]
456 fn test_execution_order_reports_a_missing_dependency_stably() {
457 let dbs = vec![db("app", &["nope"]), db("auth", &[]), db("reports", &[])];
458 let err = MultiWaypoint::execution_order(&dbs).unwrap_err();
459 let msg = err.to_string();
460 assert!(msg.contains("nope"), "{msg}");
461 assert!(msg.contains("app, auth, reports"), "{msg}");
463 }
464}