oxide_sql_core/migrations/
state.rs

1//! Migration state tracking.
2//!
3//! This module provides functionality to track which migrations have been
4//! applied to the database, similar to Django's migration tracking.
5
6use std::collections::HashSet;
7
8/// SQL for creating the migrations tracking table (SQLite/PostgreSQL compatible).
9pub const MIGRATIONS_TABLE_SQL: &str = r"
10CREATE TABLE IF NOT EXISTS _oxide_migrations (
11    id VARCHAR(255) PRIMARY KEY,
12    applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
13)
14";
15
16/// SQL for inserting a migration record.
17pub const INSERT_MIGRATION_SQL: &str =
18    "INSERT INTO _oxide_migrations (id, applied_at) VALUES (?, CURRENT_TIMESTAMP)";
19
20/// SQL for deleting a migration record.
21pub const DELETE_MIGRATION_SQL: &str = "DELETE FROM _oxide_migrations WHERE id = ?";
22
23/// SQL for checking if a migration is applied.
24pub const CHECK_MIGRATION_SQL: &str = "SELECT 1 FROM _oxide_migrations WHERE id = ?";
25
26/// SQL for listing all applied migrations.
27pub const LIST_MIGRATIONS_SQL: &str =
28    "SELECT id, applied_at FROM _oxide_migrations ORDER BY applied_at";
29
30/// Tracks which migrations have been applied.
31///
32/// This struct provides an in-memory representation of the migration state.
33/// In a real application, you would load this from the database using the
34/// SQL constants provided in this module.
35///
36/// # Example
37///
38/// ```rust
39/// use oxide_sql_core::migrations::MigrationState;
40///
41/// let mut state = MigrationState::new();
42///
43/// // Check if a migration is applied
44/// assert!(!state.is_applied("0001_initial"));
45///
46/// // Mark a migration as applied
47/// state.mark_applied("0001_initial");
48/// assert!(state.is_applied("0001_initial"));
49///
50/// // Mark a migration as unapplied (rolled back)
51/// state.mark_unapplied("0001_initial");
52/// assert!(!state.is_applied("0001_initial"));
53/// ```
54#[derive(Debug, Clone, Default)]
55pub struct MigrationState {
56    /// Set of applied migration IDs.
57    applied: HashSet<String>,
58}
59
60impl MigrationState {
61    /// Creates a new empty migration state.
62    #[must_use]
63    pub fn new() -> Self {
64        Self::default()
65    }
66
67    /// Creates a migration state from a list of applied migration IDs.
68    ///
69    /// This is useful for loading state from the database.
70    #[must_use]
71    pub fn from_applied(applied: impl IntoIterator<Item = String>) -> Self {
72        Self {
73            applied: applied.into_iter().collect(),
74        }
75    }
76
77    /// Checks if a migration has been applied.
78    #[must_use]
79    pub fn is_applied(&self, id: &str) -> bool {
80        self.applied.contains(id)
81    }
82
83    /// Marks a migration as applied.
84    pub fn mark_applied(&mut self, id: impl Into<String>) {
85        self.applied.insert(id.into());
86    }
87
88    /// Marks a migration as unapplied (rolled back).
89    pub fn mark_unapplied(&mut self, id: &str) {
90        self.applied.remove(id);
91    }
92
93    /// Returns an iterator over all applied migration IDs.
94    pub fn applied_migrations(&self) -> impl Iterator<Item = &str> {
95        self.applied.iter().map(String::as_str)
96    }
97
98    /// Returns the number of applied migrations.
99    #[must_use]
100    pub fn applied_count(&self) -> usize {
101        self.applied.len()
102    }
103
104    /// Returns the SQL to create the migrations tracking table.
105    #[must_use]
106    pub const fn create_table_sql() -> &'static str {
107        MIGRATIONS_TABLE_SQL
108    }
109
110    /// Returns the SQL to insert a migration record.
111    #[must_use]
112    pub const fn insert_sql() -> &'static str {
113        INSERT_MIGRATION_SQL
114    }
115
116    /// Returns the SQL to delete a migration record.
117    #[must_use]
118    pub const fn delete_sql() -> &'static str {
119        DELETE_MIGRATION_SQL
120    }
121
122    /// Returns the SQL to check if a migration is applied.
123    #[must_use]
124    pub const fn check_sql() -> &'static str {
125        CHECK_MIGRATION_SQL
126    }
127
128    /// Returns the SQL to list all applied migrations.
129    #[must_use]
130    pub const fn list_sql() -> &'static str {
131        LIST_MIGRATIONS_SQL
132    }
133}
134
135/// Information about a migration's application status.
136#[derive(Debug, Clone, PartialEq, Eq)]
137#[allow(dead_code)]
138pub struct AppliedMigration {
139    /// The migration ID.
140    pub id: String,
141    /// When the migration was applied (ISO 8601 format).
142    pub applied_at: String,
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn test_new_state_is_empty() {
151        let state = MigrationState::new();
152        assert_eq!(state.applied_count(), 0);
153        assert!(!state.is_applied("anything"));
154    }
155
156    #[test]
157    fn test_mark_applied() {
158        let mut state = MigrationState::new();
159        state.mark_applied("0001_initial");
160
161        assert!(state.is_applied("0001_initial"));
162        assert!(!state.is_applied("0002_add_users"));
163        assert_eq!(state.applied_count(), 1);
164    }
165
166    #[test]
167    fn test_mark_unapplied() {
168        let mut state = MigrationState::new();
169        state.mark_applied("0001_initial");
170        state.mark_applied("0002_add_users");
171
172        assert_eq!(state.applied_count(), 2);
173
174        state.mark_unapplied("0002_add_users");
175        assert!(!state.is_applied("0002_add_users"));
176        assert!(state.is_applied("0001_initial"));
177        assert_eq!(state.applied_count(), 1);
178    }
179
180    #[test]
181    fn test_from_applied() {
182        let state = MigrationState::from_applied(vec![
183            "0001_initial".to_string(),
184            "0002_add_users".to_string(),
185        ]);
186
187        assert!(state.is_applied("0001_initial"));
188        assert!(state.is_applied("0002_add_users"));
189        assert!(!state.is_applied("0003_something"));
190        assert_eq!(state.applied_count(), 2);
191    }
192
193    #[test]
194    fn test_applied_migrations_iterator() {
195        let mut state = MigrationState::new();
196        state.mark_applied("0001_initial");
197        state.mark_applied("0002_add_users");
198
199        let applied: HashSet<&str> = state.applied_migrations().collect();
200        assert!(applied.contains("0001_initial"));
201        assert!(applied.contains("0002_add_users"));
202    }
203
204    #[test]
205    fn test_sql_constants() {
206        assert!(MigrationState::create_table_sql().contains("CREATE TABLE"));
207        assert!(MigrationState::insert_sql().contains("INSERT"));
208        assert!(MigrationState::delete_sql().contains("DELETE"));
209        assert!(MigrationState::check_sql().contains("SELECT"));
210        assert!(MigrationState::list_sql().contains("SELECT"));
211    }
212}