prax_migrate/
shadow.rs

1//! Shadow database support for safe migration testing.
2//!
3//! Shadow databases are temporary databases used to:
4//! - Test migrations before applying them to production
5//! - Validate migration correctness and rollback safety
6//! - Generate accurate diffs by comparing desired schema vs actual state
7//! - Detect drift between schema definition and database state
8//!
9//! # How it works
10//!
11//! 1. Create a temporary database with a unique name
12//! 2. Apply all migrations to the shadow database
13//! 3. Introspect the shadow database to get actual schema state
14//! 4. Compare with desired schema to detect drift
15//! 5. Clean up (drop) the shadow database
16//!
17//! # Example
18//!
19//! ```rust,ignore
20//! use prax_migrate::shadow::{ShadowDatabase, ShadowConfig};
21//!
22//! // Create shadow database manager
23//! let shadow = ShadowDatabase::new(ShadowConfig::default());
24//!
25//! // Create and use shadow database
26//! let shadow_url = shadow.create().await?;
27//!
28//! // Apply migrations to shadow
29//! shadow.apply_migrations(&migrations).await?;
30//!
31//! // Introspect to get actual state
32//! let actual_schema = shadow.introspect().await?;
33//!
34//! // Compare with desired
35//! let diff = compare_schemas(&desired_schema, &actual_schema);
36//!
37//! // Clean up
38//! shadow.drop().await?;
39//! ```
40
41use std::time::{SystemTime, UNIX_EPOCH};
42
43use crate::error::{MigrateResult, MigrationError};
44use crate::file::MigrationFile;
45
46/// Configuration for shadow database operations.
47#[derive(Debug, Clone)]
48pub struct ShadowConfig {
49    /// Base connection URL (without database name).
50    pub base_url: String,
51    /// Prefix for shadow database names.
52    pub prefix: String,
53    /// Whether to auto-cleanup on drop.
54    pub auto_cleanup: bool,
55    /// Timeout for shadow operations in seconds.
56    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, // 5 minutes
66        }
67    }
68}
69
70impl ShadowConfig {
71    /// Create a new shadow database config.
72    pub fn new(base_url: impl Into<String>) -> Self {
73        Self {
74            base_url: base_url.into(),
75            ..Default::default()
76        }
77    }
78
79    /// Set a custom prefix for shadow database names.
80    pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
81        self.prefix = prefix.into();
82        self
83    }
84
85    /// Disable auto-cleanup (for debugging).
86    pub fn no_auto_cleanup(mut self) -> Self {
87        self.auto_cleanup = false;
88        self
89    }
90
91    /// Set operation timeout.
92    pub fn with_timeout(mut self, seconds: u64) -> Self {
93        self.timeout_seconds = seconds;
94        self
95    }
96
97    /// Generate a unique shadow database name.
98    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    /// Get the full connection URL for a shadow database.
108    pub fn shadow_url(&self, db_name: &str) -> String {
109        // Parse base URL and replace database name
110        if self.base_url.contains("://") {
111            // Handle URL format: protocol://user:pass@host:port/dbname
112            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            // Simple format
119            format!("{}/{}", self.base_url, db_name)
120        }
121    }
122}
123
124/// Simple random number generator for unique names.
125fn 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/// Current state of a shadow database.
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum ShadowState {
136    /// Shadow database not yet created.
137    NotCreated,
138    /// Shadow database exists and is ready.
139    Ready,
140    /// Shadow database has been dropped.
141    Dropped,
142    /// Shadow database is in an error state.
143    Error,
144}
145
146/// A shadow database instance for testing migrations.
147#[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    /// Create a new shadow database manager.
157    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    /// Get the shadow database name.
168    pub fn name(&self) -> &str {
169        &self.db_name
170    }
171
172    /// Get the full connection URL for this shadow database.
173    pub fn url(&self) -> String {
174        self.config.shadow_url(&self.db_name)
175    }
176
177    /// Get the current state.
178    pub fn state(&self) -> ShadowState {
179        self.state
180    }
181
182    /// Get list of applied migrations.
183    pub fn applied_migrations(&self) -> &[String] {
184        &self.applied_migrations
185    }
186
187    /// Create the shadow database.
188    ///
189    /// This creates an empty database that can be used for migration testing.
190    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        // The actual creation would be done through the query engine:
199        // CREATE DATABASE shadow_name
200        // For now, we just update state
201        self.state = ShadowState::Ready;
202
203        Ok(self.url())
204    }
205
206    /// Generate the SQL to create this shadow database.
207    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    /// Generate the SQL to drop this shadow database.
215    pub fn drop_sql(&self) -> String {
216        format!(
217            "DROP DATABASE IF EXISTS {}",
218            quote_identifier(&self.db_name)
219        )
220    }
221
222    /// Apply a migration to the shadow database.
223    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        // The actual execution would be done through the query engine
231        // For now, we track the applied migration
232        self.applied_migrations.push(migration.id.clone());
233
234        Ok(())
235    }
236
237    /// Apply multiple migrations to the shadow database.
238    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    /// Check if the shadow database is ready for introspection.
246    ///
247    /// The actual introspection should be done through a concrete `Introspector`
248    /// implementation connected to this shadow database's URL.
249    pub fn is_ready_for_introspection(&self) -> bool {
250        self.state == ShadowState::Ready
251    }
252
253    /// Get SQL to verify the shadow database schema matches expected structure.
254    ///
255    /// This can be used to validate migrations before applying to production.
256    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    /// Reset the shadow database (drop and recreate).
267    pub async fn reset(&mut self) -> MigrateResult<()> {
268        if self.state == ShadowState::Ready {
269            self.drop().await?;
270        }
271
272        // Generate new name
273        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    /// Drop the shadow database.
282    pub async fn drop(&mut self) -> MigrateResult<()> {
283        if self.state == ShadowState::Dropped {
284            return Ok(());
285        }
286
287        // The actual drop would be done through the query engine:
288        // DROP DATABASE shadow_name
289        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            // In a real implementation, we'd spawn a task to drop the database
300            // For now, we just log
301            #[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
310/// Quote a PostgreSQL identifier.
311fn quote_identifier(name: &str) -> String {
312    format!("\"{}\"", name.replace('"', "\"\""))
313}
314
315/// Manager for shadow database operations.
316#[derive(Debug)]
317pub struct ShadowDatabaseManager {
318    config: ShadowConfig,
319    active_shadows: Vec<String>,
320}
321
322impl ShadowDatabaseManager {
323    /// Create a new shadow database manager.
324    pub fn new(config: ShadowConfig) -> Self {
325        Self {
326            config,
327            active_shadows: Vec::new(),
328        }
329    }
330
331    /// Create a new shadow database.
332    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    /// Clean up all active shadow databases.
339    pub async fn cleanup_all(&mut self) -> MigrateResult<()> {
340        // In a real implementation, this would drop all shadow databases
341        self.active_shadows.clear();
342        Ok(())
343    }
344
345    /// Get list of active shadow database names.
346    pub fn active_shadows(&self) -> &[String] {
347        &self.active_shadows
348    }
349
350    /// Check if a database name looks like a shadow database.
351    pub fn is_shadow_database(&self, name: &str) -> bool {
352        name.starts_with(&self.config.prefix)
353    }
354}
355
356/// Result of comparing schemas using a shadow database.
357#[derive(Debug)]
358pub struct ShadowDiffResult {
359    /// The desired schema (from Prax schema files).
360    pub desired: prax_schema::Schema,
361    /// The actual schema (from shadow database after migrations).
362    pub actual: prax_schema::Schema,
363    /// Drift detected between schemas.
364    pub drift: SchemaDrift,
365}
366
367/// Detected drift between desired and actual schema.
368#[derive(Debug, Default)]
369pub struct SchemaDrift {
370    /// Models in desired but not in actual.
371    pub missing_models: Vec<String>,
372    /// Models in actual but not in desired.
373    pub extra_models: Vec<String>,
374    /// Fields that differ between schemas.
375    pub field_differences: Vec<FieldDrift>,
376    /// Index differences.
377    pub index_differences: Vec<IndexDrift>,
378}
379
380impl SchemaDrift {
381    /// Check if there's any drift.
382    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    /// Get a summary of the drift.
390    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/// A detected difference in a field.
421#[derive(Debug)]
422pub struct FieldDrift {
423    /// Model name.
424    pub model: String,
425    /// Field name.
426    pub field: String,
427    /// Description of the difference.
428    pub description: String,
429}
430
431/// A detected difference in an index.
432#[derive(Debug)]
433pub struct IndexDrift {
434    /// Model name.
435    pub model: String,
436    /// Index name.
437    pub index: String,
438    /// Description of the difference.
439    pub description: String,
440}
441
442/// Compare two schemas and detect drift.
443pub fn detect_drift(desired: &prax_schema::Schema, actual: &prax_schema::Schema) -> SchemaDrift {
444    let mut drift = SchemaDrift::default();
445
446    // Collect model names (IndexMap returns (key, value) tuples)
447    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    // Find missing and extra models
459    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    // Compare fields in common models
469    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            // Compare fields (fields is IndexMap<SmolStr, Field>)
473            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            // Check for missing and extra fields
479            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            // Check for type/modifier differences in common fields
496            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        // Names should be unique
561        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        // Create
587        let url = shadow.create().await.unwrap();
588        assert!(url.contains(&shadow.name().to_string()));
589        assert_eq!(shadow.state(), ShadowState::Ready);
590
591        // Drop
592        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}