1use std::time::{SystemTime, UNIX_EPOCH};
42
43use crate::error::{MigrateResult, MigrationError};
44use crate::file::MigrationFile;
45
46#[derive(Debug, Clone)]
48pub struct ShadowConfig {
49 pub base_url: String,
51 pub prefix: String,
53 pub auto_cleanup: bool,
55 pub timeout_seconds: u64,
57}
58
59impl Default for ShadowConfig {
60 fn default() -> Self {
61 Self {
62 base_url: String::new(),
63 prefix: "_prax_shadow_".to_string(),
64 auto_cleanup: true,
65 timeout_seconds: 300, }
67 }
68}
69
70impl ShadowConfig {
71 pub fn new(base_url: impl Into<String>) -> Self {
73 Self {
74 base_url: base_url.into(),
75 ..Default::default()
76 }
77 }
78
79 pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
81 self.prefix = prefix.into();
82 self
83 }
84
85 pub fn no_auto_cleanup(mut self) -> Self {
87 self.auto_cleanup = false;
88 self
89 }
90
91 pub fn with_timeout(mut self, seconds: u64) -> Self {
93 self.timeout_seconds = seconds;
94 self
95 }
96
97 pub fn generate_name(&self) -> String {
99 let timestamp = SystemTime::now()
100 .duration_since(UNIX_EPOCH)
101 .unwrap_or_default()
102 .as_millis();
103 let random: u32 = rand_simple();
104 format!("{}{:x}_{:x}", self.prefix, timestamp, random)
105 }
106
107 pub fn shadow_url(&self, db_name: &str) -> String {
109 if self.base_url.contains("://") {
111 if let Some(idx) = self.base_url.rfind('/') {
113 format!("{}/{}", &self.base_url[..idx], db_name)
114 } else {
115 format!("{}/{}", self.base_url, db_name)
116 }
117 } else {
118 format!("{}/{}", self.base_url, db_name)
120 }
121 }
122}
123
124fn rand_simple() -> u32 {
126 use std::hash::{Hash, Hasher};
127 let mut hasher = std::collections::hash_map::DefaultHasher::new();
128 std::thread::current().id().hash(&mut hasher);
129 SystemTime::now().hash(&mut hasher);
130 hasher.finish() as u32
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum ShadowState {
136 NotCreated,
138 Ready,
140 Dropped,
142 Error,
144}
145
146#[derive(Debug)]
148pub struct ShadowDatabase {
149 config: ShadowConfig,
150 db_name: String,
151 state: ShadowState,
152 applied_migrations: Vec<String>,
153}
154
155impl ShadowDatabase {
156 pub fn new(config: ShadowConfig) -> Self {
158 let db_name = config.generate_name();
159 Self {
160 config,
161 db_name,
162 state: ShadowState::NotCreated,
163 applied_migrations: Vec::new(),
164 }
165 }
166
167 pub fn name(&self) -> &str {
169 &self.db_name
170 }
171
172 pub fn url(&self) -> String {
174 self.config.shadow_url(&self.db_name)
175 }
176
177 pub fn state(&self) -> ShadowState {
179 self.state
180 }
181
182 pub fn applied_migrations(&self) -> &[String] {
184 &self.applied_migrations
185 }
186
187 pub async fn create(&mut self) -> MigrateResult<String> {
191 if self.state != ShadowState::NotCreated {
192 return Err(MigrationError::ShadowDatabaseError(format!(
193 "Shadow database already in state {:?}",
194 self.state
195 )));
196 }
197
198 self.state = ShadowState::Ready;
202
203 Ok(self.url())
204 }
205
206 pub fn create_sql(&self) -> String {
208 format!(
209 "CREATE DATABASE {} WITH TEMPLATE template0 ENCODING 'UTF8'",
210 quote_identifier(&self.db_name)
211 )
212 }
213
214 pub fn drop_sql(&self) -> String {
216 format!(
217 "DROP DATABASE IF EXISTS {}",
218 quote_identifier(&self.db_name)
219 )
220 }
221
222 pub async fn apply_migration(&mut self, migration: &MigrationFile) -> MigrateResult<()> {
224 if self.state != ShadowState::Ready {
225 return Err(MigrationError::ShadowDatabaseError(
226 "Shadow database not ready".to_string(),
227 ));
228 }
229
230 self.applied_migrations.push(migration.id.clone());
233
234 Ok(())
235 }
236
237 pub async fn apply_migrations(&mut self, migrations: &[MigrationFile]) -> MigrateResult<()> {
239 for migration in migrations {
240 self.apply_migration(migration).await?;
241 }
242 Ok(())
243 }
244
245 pub fn is_ready_for_introspection(&self) -> bool {
250 self.state == ShadowState::Ready
251 }
252
253 pub fn verify_schema_sql(&self, table_name: &str) -> String {
257 format!(
258 "SELECT column_name, data_type, is_nullable \
259 FROM information_schema.columns \
260 WHERE table_schema = 'public' AND table_name = '{}' \
261 ORDER BY ordinal_position",
262 table_name.replace('\'', "''")
263 )
264 }
265
266 pub async fn reset(&mut self) -> MigrateResult<()> {
268 if self.state == ShadowState::Ready {
269 self.drop().await?;
270 }
271
272 self.db_name = self.config.generate_name();
274 self.applied_migrations.clear();
275 self.state = ShadowState::NotCreated;
276
277 self.create().await?;
278 Ok(())
279 }
280
281 pub async fn drop(&mut self) -> MigrateResult<()> {
283 if self.state == ShadowState::Dropped {
284 return Ok(());
285 }
286
287 self.state = ShadowState::Dropped;
290 self.applied_migrations.clear();
291
292 Ok(())
293 }
294}
295
296impl Drop for ShadowDatabase {
297 fn drop(&mut self) {
298 if self.config.auto_cleanup && self.state == ShadowState::Ready {
299 #[cfg(feature = "tracing")]
302 tracing::warn!(
303 "Shadow database '{}' was not explicitly dropped. Consider calling drop() explicitly.",
304 self.db_name
305 );
306 }
307 }
308}
309
310fn quote_identifier(name: &str) -> String {
312 format!("\"{}\"", name.replace('"', "\"\""))
313}
314
315#[derive(Debug)]
317pub struct ShadowDatabaseManager {
318 config: ShadowConfig,
319 active_shadows: Vec<String>,
320}
321
322impl ShadowDatabaseManager {
323 pub fn new(config: ShadowConfig) -> Self {
325 Self {
326 config,
327 active_shadows: Vec::new(),
328 }
329 }
330
331 pub fn create_shadow(&mut self) -> ShadowDatabase {
333 let shadow = ShadowDatabase::new(self.config.clone());
334 self.active_shadows.push(shadow.name().to_string());
335 shadow
336 }
337
338 pub async fn cleanup_all(&mut self) -> MigrateResult<()> {
340 self.active_shadows.clear();
342 Ok(())
343 }
344
345 pub fn active_shadows(&self) -> &[String] {
347 &self.active_shadows
348 }
349
350 pub fn is_shadow_database(&self, name: &str) -> bool {
352 name.starts_with(&self.config.prefix)
353 }
354}
355
356#[derive(Debug)]
358pub struct ShadowDiffResult {
359 pub desired: prax_schema::Schema,
361 pub actual: prax_schema::Schema,
363 pub drift: SchemaDrift,
365}
366
367#[derive(Debug, Default)]
369pub struct SchemaDrift {
370 pub missing_models: Vec<String>,
372 pub extra_models: Vec<String>,
374 pub field_differences: Vec<FieldDrift>,
376 pub index_differences: Vec<IndexDrift>,
378}
379
380impl SchemaDrift {
381 pub fn has_drift(&self) -> bool {
383 !self.missing_models.is_empty()
384 || !self.extra_models.is_empty()
385 || !self.field_differences.is_empty()
386 || !self.index_differences.is_empty()
387 }
388
389 pub fn summary(&self) -> String {
391 let mut parts = Vec::new();
392
393 if !self.missing_models.is_empty() {
394 parts.push(format!("{} missing models", self.missing_models.len()));
395 }
396 if !self.extra_models.is_empty() {
397 parts.push(format!("{} extra models", self.extra_models.len()));
398 }
399 if !self.field_differences.is_empty() {
400 parts.push(format!(
401 "{} field differences",
402 self.field_differences.len()
403 ));
404 }
405 if !self.index_differences.is_empty() {
406 parts.push(format!(
407 "{} index differences",
408 self.index_differences.len()
409 ));
410 }
411
412 if parts.is_empty() {
413 "No drift detected".to_string()
414 } else {
415 parts.join(", ")
416 }
417 }
418}
419
420#[derive(Debug)]
422pub struct FieldDrift {
423 pub model: String,
425 pub field: String,
427 pub description: String,
429}
430
431#[derive(Debug)]
433pub struct IndexDrift {
434 pub model: String,
436 pub index: String,
438 pub description: String,
440}
441
442pub fn detect_drift(desired: &prax_schema::Schema, actual: &prax_schema::Schema) -> SchemaDrift {
444 let mut drift = SchemaDrift::default();
445
446 let desired_models: std::collections::HashSet<&str> = desired
448 .models
449 .iter()
450 .map(|(name, _)| name.as_str())
451 .collect();
452 let actual_models: std::collections::HashSet<&str> = actual
453 .models
454 .iter()
455 .map(|(name, _)| name.as_str())
456 .collect();
457
458 drift.missing_models = desired_models
460 .difference(&actual_models)
461 .map(|s: &&str| s.to_string())
462 .collect();
463 drift.extra_models = actual_models
464 .difference(&desired_models)
465 .map(|s: &&str| s.to_string())
466 .collect();
467
468 for (model_name, desired_model) in &desired.models {
470 let model_name_str = model_name.as_str();
471 if let Some(actual_model) = actual.models.get(model_name_str) {
472 let desired_field_names: std::collections::HashSet<&str> =
474 desired_model.fields.keys().map(|k| k.as_str()).collect();
475 let actual_field_names: std::collections::HashSet<&str> =
476 actual_model.fields.keys().map(|k| k.as_str()).collect();
477
478 for field_name in desired_field_names.difference(&actual_field_names) {
480 drift.field_differences.push(FieldDrift {
481 model: model_name_str.to_string(),
482 field: field_name.to_string(),
483 description: "Field missing in actual schema".to_string(),
484 });
485 }
486
487 for field_name in actual_field_names.difference(&desired_field_names) {
488 drift.field_differences.push(FieldDrift {
489 model: model_name_str.to_string(),
490 field: field_name.to_string(),
491 description: "Extra field in actual schema".to_string(),
492 });
493 }
494
495 for field_name in desired_field_names.intersection(&actual_field_names) {
497 let desired_field = desired_model.fields.get(*field_name).unwrap();
498 let actual_field = actual_model.fields.get(*field_name).unwrap();
499
500 if desired_field.field_type != actual_field.field_type {
501 drift.field_differences.push(FieldDrift {
502 model: model_name_str.to_string(),
503 field: field_name.to_string(),
504 description: format!(
505 "Type mismatch: expected {:?}, got {:?}",
506 desired_field.field_type, actual_field.field_type
507 ),
508 });
509 }
510 if desired_field.modifier != actual_field.modifier {
511 drift.field_differences.push(FieldDrift {
512 model: model_name_str.to_string(),
513 field: field_name.to_string(),
514 description: format!(
515 "Modifier mismatch: expected {:?}, got {:?}",
516 desired_field.modifier, actual_field.modifier
517 ),
518 });
519 }
520 }
521 }
522 }
523
524 drift
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530
531 #[test]
532 fn test_shadow_config_default() {
533 let config = ShadowConfig::default();
534 assert_eq!(config.prefix, "_prax_shadow_");
535 assert!(config.auto_cleanup);
536 assert_eq!(config.timeout_seconds, 300);
537 }
538
539 #[test]
540 fn test_shadow_config_builder() {
541 let config = ShadowConfig::new("postgresql://localhost")
542 .with_prefix("_test_shadow_")
543 .no_auto_cleanup()
544 .with_timeout(60);
545
546 assert_eq!(config.base_url, "postgresql://localhost");
547 assert_eq!(config.prefix, "_test_shadow_");
548 assert!(!config.auto_cleanup);
549 assert_eq!(config.timeout_seconds, 60);
550 }
551
552 #[test]
553 fn test_generate_name() {
554 let config = ShadowConfig::default();
555 let name1 = config.generate_name();
556 let name2 = config.generate_name();
557
558 assert!(name1.starts_with("_prax_shadow_"));
559 assert!(name2.starts_with("_prax_shadow_"));
560 assert_ne!(name1, name2);
562 }
563
564 #[test]
565 fn test_shadow_url() {
566 let config = ShadowConfig::new("postgresql://user:pass@localhost:5432/original");
567 let url = config.shadow_url("shadow_db");
568 assert_eq!(url, "postgresql://user:pass@localhost:5432/shadow_db");
569 }
570
571 #[test]
572 fn test_shadow_database_new() {
573 let config = ShadowConfig::new("postgresql://localhost");
574 let shadow = ShadowDatabase::new(config);
575
576 assert!(shadow.name().starts_with("_prax_shadow_"));
577 assert_eq!(shadow.state(), ShadowState::NotCreated);
578 assert!(shadow.applied_migrations().is_empty());
579 }
580
581 #[tokio::test]
582 async fn test_shadow_database_lifecycle() {
583 let config = ShadowConfig::new("postgresql://localhost");
584 let mut shadow = ShadowDatabase::new(config);
585
586 let url = shadow.create().await.unwrap();
588 assert!(url.contains(&shadow.name().to_string()));
589 assert_eq!(shadow.state(), ShadowState::Ready);
590
591 shadow.drop().await.unwrap();
593 assert_eq!(shadow.state(), ShadowState::Dropped);
594 }
595
596 #[test]
597 fn test_create_sql() {
598 let config = ShadowConfig::new("postgresql://localhost");
599 let shadow = ShadowDatabase::new(config);
600 let sql = shadow.create_sql();
601
602 assert!(sql.starts_with("CREATE DATABASE"));
603 assert!(sql.contains(&shadow.name().to_string()));
604 }
605
606 #[test]
607 fn test_drop_sql() {
608 let config = ShadowConfig::new("postgresql://localhost");
609 let shadow = ShadowDatabase::new(config);
610 let sql = shadow.drop_sql();
611
612 assert!(sql.starts_with("DROP DATABASE IF EXISTS"));
613 assert!(sql.contains(&shadow.name().to_string()));
614 }
615
616 #[test]
617 fn test_shadow_manager() {
618 let config = ShadowConfig::new("postgresql://localhost");
619 let mut manager = ShadowDatabaseManager::new(config);
620
621 let shadow1 = manager.create_shadow();
622 let shadow2 = manager.create_shadow();
623
624 assert_eq!(manager.active_shadows().len(), 2);
625 assert!(manager.is_shadow_database(shadow1.name()));
626 assert!(manager.is_shadow_database(shadow2.name()));
627 assert!(!manager.is_shadow_database("regular_db"));
628 }
629
630 #[test]
631 fn test_schema_drift_empty() {
632 let drift = SchemaDrift::default();
633 assert!(!drift.has_drift());
634 assert_eq!(drift.summary(), "No drift detected");
635 }
636
637 #[test]
638 fn test_schema_drift_with_differences() {
639 let drift = SchemaDrift {
640 missing_models: vec!["User".to_string()],
641 extra_models: vec!["OldTable".to_string()],
642 field_differences: vec![FieldDrift {
643 model: "Post".to_string(),
644 field: "title".to_string(),
645 description: "Type mismatch".to_string(),
646 }],
647 index_differences: Vec::new(),
648 };
649
650 assert!(drift.has_drift());
651 let summary = drift.summary();
652 assert!(summary.contains("1 missing models"));
653 assert!(summary.contains("1 extra models"));
654 assert!(summary.contains("1 field differences"));
655 }
656
657 #[test]
658 fn test_detect_drift_no_drift() {
659 let schema = prax_schema::Schema::new();
660 let drift = detect_drift(&schema, &schema);
661 assert!(!drift.has_drift());
662 }
663
664 #[test]
665 fn test_quote_identifier() {
666 assert_eq!(quote_identifier("table"), "\"table\"");
667 assert_eq!(quote_identifier("has\"quote"), "\"has\"\"quote\"");
668 }
669}