Skip to main content

dbkit/
initialization.rs

1use crate::DbkitError;
2use crate::base_handler::{BaseHandler, FetchMode, WriteOp};
3use crate::config::Backend;
4use sqlx::{AnyPool, Row};
5use std::collections::hash_map::DefaultHasher;
6use std::hash::{Hash, Hasher};
7use tracing::{error, info};
8
9/// Batch DDL migration executor with tracking.
10///
11/// Maintains a `_dbkit_migrations` table so already-applied migrations
12/// are skipped on subsequent runs. Each migration is identified by a
13/// user-provided name and a content hash.
14///
15/// The tracking table DDL and the internal queries are backend-aware
16/// (Postgres / MySQL / SQLite), so the migration runner works on any backend.
17/// The *user-supplied* migration SQL is still backend-native — write it for the
18/// database you connected to.
19pub struct InitializationHandler {
20    handler: BaseHandler,
21    backend: Backend,
22}
23
24impl InitializationHandler {
25    /// Create a handler for the given pool and backend.
26    ///
27    /// The backend is typically obtained from
28    /// [`ConnectionManager::backend`](crate::ConnectionManager::backend).
29    pub fn new(pool: AnyPool, backend: Backend) -> Self {
30        Self {
31            handler: BaseHandler::new(pool),
32            backend,
33        }
34    }
35
36    /// The placeholder for the `n`-th bind parameter (1-based) for this backend.
37    fn placeholder(&self, n: usize) -> String {
38        match self.backend {
39            Backend::Postgres => format!("${n}"),
40            Backend::MySql | Backend::Sqlite => "?".to_string(),
41        }
42    }
43
44    /// Backend-specific DDL for the migrations tracking table.
45    ///
46    /// `name` is the primary key (no auto-increment column needed), which keeps
47    /// the schema portable. `applied_at` is a timestamp defaulting to "now".
48    fn tracking_table_ddl(&self) -> &'static str {
49        match self.backend {
50            Backend::Postgres => {
51                "CREATE TABLE IF NOT EXISTS _dbkit_migrations (
52                    name TEXT PRIMARY KEY,
53                    hash TEXT NOT NULL,
54                    applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
55                )"
56            }
57            Backend::MySql => {
58                // TEXT can't be a primary key without a prefix length in MySQL.
59                "CREATE TABLE IF NOT EXISTS _dbkit_migrations (
60                    name VARCHAR(255) PRIMARY KEY,
61                    hash TEXT NOT NULL,
62                    applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
63                )"
64            }
65            Backend::Sqlite => {
66                "CREATE TABLE IF NOT EXISTS _dbkit_migrations (
67                    name TEXT PRIMARY KEY,
68                    hash TEXT NOT NULL,
69                    applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
70                )"
71            }
72        }
73    }
74
75    /// A SQL expression that renders `applied_at` as text, so it can be read
76    /// through sqlx's `Any` driver (which can't decode native timestamp types).
77    fn applied_at_as_text(&self) -> &'static str {
78        match self.backend {
79            // MySQL casts to CHAR; Postgres and SQLite cast to TEXT.
80            Backend::MySql => "CAST(applied_at AS CHAR)",
81            Backend::Postgres | Backend::Sqlite => "CAST(applied_at AS TEXT)",
82        }
83    }
84
85    /// Ensure the migrations tracking table exists.
86    async fn ensure_tracking_table(&self) -> Result<(), DbkitError> {
87        self.handler
88            .execute_write(WriteOp::BatchDDL {
89                queries: &[self.tracking_table_ddl()],
90            })
91            .await?;
92        Ok(())
93    }
94
95    /// Compute a hash of the SQL content for change detection.
96    fn hash_sql(sql: &str) -> String {
97        let mut hasher = DefaultHasher::new();
98        sql.hash(&mut hasher);
99        format!("{:016x}", hasher.finish())
100    }
101
102    /// Run a named migration. Skips if already applied with the same content hash.
103    ///
104    /// If the migration name exists but the hash differs, it returns an error
105    /// (content changed after being applied).
106    pub async fn run_named_migration(&self, name: &str, sql: &str) -> Result<(), DbkitError> {
107        self.ensure_tracking_table().await?;
108
109        let hash = Self::hash_sql(sql);
110
111        // Check if already applied
112        let select = format!(
113            "SELECT hash FROM _dbkit_migrations WHERE name = {}",
114            self.placeholder(1)
115        );
116        let result = self
117            .handler
118            .execute_write(WriteOp::Single {
119                query: select.as_str(),
120                params: vec![name.into()],
121                mode: FetchMode::Optional,
122            })
123            .await?;
124
125        if let Some(row) = result.optional()? {
126            let existing_hash: String = row.get(0);
127            if existing_hash == hash {
128                info!("migration '{}' already applied, skipping", name);
129                return Ok(());
130            } else {
131                return Err(DbkitError::Migration(format!(
132                    "migration '{}' was already applied but content has changed (hash {} → {})",
133                    name, existing_hash, hash
134                )));
135            }
136        }
137
138        // Run the migration
139        info!("applying migration '{}'...", name);
140        let queries: Vec<String> = sql
141            .split(';')
142            .map(|s| s.trim().to_string())
143            .filter(|s| !s.is_empty())
144            .collect();
145
146        let query_refs: Vec<&str> = queries.iter().map(|s| s.as_str()).collect();
147
148        match self
149            .handler
150            .execute_write(WriteOp::BatchDDL {
151                queries: &query_refs,
152            })
153            .await
154        {
155            Ok(_) => {
156                info!(
157                    "migration '{}': {} DDL statements executed",
158                    name,
159                    query_refs.len()
160                );
161            }
162            Err(e) => {
163                error!("migration '{}' failed: {:?}", name, e);
164                return Err(DbkitError::Migration(e.to_string()));
165            }
166        }
167
168        // Record the migration
169        let insert = format!(
170            "INSERT INTO _dbkit_migrations (name, hash) VALUES ({}, {})",
171            self.placeholder(1),
172            self.placeholder(2)
173        );
174        self.handler
175            .execute_write(WriteOp::Single {
176                query: insert.as_str(),
177                params: vec![name.into(), hash.clone().into()],
178                mode: FetchMode::None,
179            })
180            .await?;
181
182        info!("migration '{}' recorded", name);
183        Ok(())
184    }
185
186    /// Run migrations from a SQL string (semicolon-separated DDL statements).
187    ///
188    /// This is the simple/legacy API — it runs all statements unconditionally
189    /// without tracking. Use [`run_named_migration`](Self::run_named_migration)
190    /// for tracked migrations.
191    pub async fn run_migrations(&self, sql: &str) -> Result<(), DbkitError> {
192        info!("running database migrations...");
193
194        let queries: Vec<String> = sql
195            .split(';')
196            .map(|s| s.trim().to_string())
197            .filter(|s| !s.is_empty())
198            .collect();
199
200        let query_refs: Vec<&str> = queries.iter().map(|s| s.as_str()).collect();
201
202        match self
203            .handler
204            .execute_write(WriteOp::BatchDDL {
205                queries: &query_refs,
206            })
207            .await
208        {
209            Ok(_) => {
210                info!("{} DDL statements executed", query_refs.len());
211            }
212            Err(e) => {
213                error!("migration failed: {:?}", e);
214                return Err(DbkitError::Migration(e.to_string()));
215            }
216        }
217
218        Ok(())
219    }
220
221    /// List all applied migrations (name, hash, applied_at) in application order.
222    pub async fn applied_migrations(&self) -> Result<Vec<(String, String, String)>, DbkitError> {
223        self.ensure_tracking_table().await?;
224
225        let select = format!(
226            "SELECT name, hash, {} FROM _dbkit_migrations ORDER BY applied_at, name",
227            self.applied_at_as_text()
228        );
229        let result = self
230            .handler
231            .execute_write(WriteOp::Single {
232                query: select.as_str(),
233                params: vec![],
234                mode: FetchMode::All,
235            })
236            .await?;
237
238        let rows = result.all()?;
239        Ok(rows
240            .iter()
241            .map(|row| {
242                let name: String = row.get(0);
243                let hash: String = row.get(1);
244                let applied_at: String = row.get(2);
245                (name, hash, applied_at)
246            })
247            .collect())
248    }
249}