1use async_trait::async_trait;
2use sqlx::{postgres::PgConnection, Connection, Executor, PgPool};
3use std::sync::Arc;
4use tokio::sync::Mutex;
5
6use crate::driver::{AppliedMigration, LockGuard, MigrationDriver};
7use crate::error::{Error, Result};
8use crate::migration::Migration;
9
10fn validate_ident(s: &str) -> Result<()> {
13 if s.is_empty() || !s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
14 return Err(Error::InvalidIdentifier(s.to_owned()));
15 }
16 Ok(())
17}
18
19#[derive(Debug, Clone)]
28pub struct PostgresConfig {
29 pub schema: Option<String>,
31 pub table: String,
33 pub advisory_lock_key: i64,
35}
36
37impl Default for PostgresConfig {
38 fn default() -> Self {
39 Self {
40 schema: None,
41 table: "00_schema_migrations".to_owned(),
42 advisory_lock_key: 918273645,
43 }
44 }
45}
46
47pub struct PgLockGuard {
50 conn: Arc<Mutex<Option<PgConnection>>>,
52 key: i64,
53}
54
55impl LockGuard for PgLockGuard {}
56
57impl Drop for PgLockGuard {
58 fn drop(&mut self) {
59 let conn_arc = Arc::clone(&self.conn);
60 let key = self.key;
61 if let Ok(handle) = tokio::runtime::Handle::try_current() {
68 handle.spawn(async move {
69 let mut guard = conn_arc.lock().await;
70 if let Some(mut conn) = guard.take() {
71 if let Err(e) = sqlx::query("SELECT pg_advisory_unlock($1)")
72 .bind(key)
73 .execute(&mut conn)
74 .await
75 {
76 eprintln!("WARN soma-schema: pg_advisory_unlock(key={key}) failed: {e}; lock will be released when the connection closes");
79 }
80 let _ = conn.close().await;
81 }
82 });
83 }
84 }
85}
86
87#[derive(Debug)]
93pub struct PostgresDriver {
94 pool: PgPool,
95 config: PostgresConfig,
96}
97
98impl PostgresDriver {
99 pub fn new(pool: PgPool, config: PostgresConfig) -> Result<Self> {
111 validate_ident(&config.table)?;
112 if let Some(ref s) = config.schema {
113 validate_ident(s)?;
114 }
115 if pool.options().get_max_connections() < 2 {
118 return Err(Error::PoolTooSmall);
119 }
120 Ok(Self { pool, config })
121 }
122
123 fn qualified_table(&self) -> String {
125 match self.config.schema.as_deref() {
126 Some(s) => format!("\"{s}\".\"{}\"", &self.config.table),
127 None => format!("\"{}\"", &self.config.table),
128 }
129 }
130
131 fn set_search_path_sql(&self) -> Option<String> {
132 self.config
135 .schema
136 .as_deref()
137 .map(|s| format!("SET LOCAL search_path TO \"{s}\""))
138 }
139}
140
141#[async_trait]
142impl MigrationDriver for PostgresDriver {
143 async fn acquire_lock(&self) -> Result<Box<dyn LockGuard>> {
144 let mut conn = self.pool.acquire().await?.detach();
145 sqlx::query("SELECT pg_advisory_lock($1)")
146 .bind(self.config.advisory_lock_key)
147 .execute(&mut conn)
148 .await?;
149 let guard = PgLockGuard {
150 conn: Arc::new(Mutex::new(Some(conn))),
151 key: self.config.advisory_lock_key,
152 };
153 Ok(Box::new(guard))
154 }
155
156 async fn run_setup_sql(&self, name: &str, sql: &str) -> Result<()> {
157 let name = name.to_owned();
160 let sql = sql.to_owned();
161 let mut tx = self.pool.begin().await?;
162 if let Some(sp) = self.set_search_path_sql() {
163 sqlx::query(&sp)
164 .execute(&mut *tx)
165 .await
166 .map_err(|e| Error::SetupFailed {
167 file: name.clone(),
168 source: e,
169 })?;
170 }
171 {
178 let conn: &mut PgConnection = &mut tx;
179 conn.execute(sqlx::raw_sql(&sql))
180 .await
181 .map_err(|e| Error::SetupFailed {
182 file: name.clone(),
183 source: e,
184 })?;
185 }
186 tx.commit().await.map_err(|e| Error::SetupFailed {
187 file: name,
188 source: e,
189 })?;
190 Ok(())
191 }
192
193 async fn ensure_tracking_table(&self) -> Result<()> {
194 let mut tx = self.pool.begin().await?;
195 if let Some(schema) = &self.config.schema {
197 let create_schema = format!("CREATE SCHEMA IF NOT EXISTS \"{schema}\"");
198 sqlx::query(&create_schema).execute(&mut *tx).await?;
199 }
200 if let Some(sp) = self.set_search_path_sql() {
202 sqlx::query(&sp).execute(&mut *tx).await?;
203 }
204 let qt = self.qualified_table();
205 let create_table = format!(
206 r#"CREATE TABLE IF NOT EXISTS {qt} (
207 version INTEGER NOT NULL,
208 file VARCHAR(255) NOT NULL,
209 name VARCHAR(255) NOT NULL,
210 checksum TEXT NOT NULL,
211 description TEXT,
212 batch INTEGER NOT NULL,
213 applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
214 applied_by TEXT NOT NULL DEFAULT current_user,
215 execution_ms INTEGER,
216 PRIMARY KEY (version, file)
217)"#
218 );
219 sqlx::query(&create_table).execute(&mut *tx).await?;
220 tx.commit().await?;
221 Ok(())
222 }
223
224 async fn applied(&self) -> Result<Vec<AppliedMigration>> {
225 use chrono::{DateTime, Utc};
226 let qt = self.qualified_table();
227 let sql = format!(
228 "SELECT version, file, name, checksum, description, batch, applied_at, applied_by, execution_ms \
229 FROM {qt} ORDER BY version ASC, file ASC"
230 );
231 let rows = sqlx::query_as::<
232 _,
233 (
234 i32,
235 String,
236 String,
237 String,
238 Option<String>,
239 i32,
240 DateTime<Utc>,
241 String,
242 Option<i32>,
243 ),
244 >(&sql)
245 .fetch_all(&self.pool)
246 .await?;
247 Ok(rows
248 .into_iter()
249 .map(
250 |(
251 version,
252 file,
253 name,
254 checksum,
255 description,
256 batch,
257 applied_at,
258 applied_by,
259 execution_ms,
260 )| {
261 AppliedMigration {
262 version: version as u32,
263 file,
264 name,
265 checksum,
266 description,
267 batch,
268 applied_at,
269 applied_by,
270 execution_ms,
271 }
272 },
273 )
274 .collect())
275 }
276
277 async fn apply(&self, migration: &Migration, up_sql: &str, batch: i32) -> Result<()> {
278 let up_sql = up_sql.to_owned();
280 let start = std::time::Instant::now();
281 let mut tx = self.pool.begin().await?;
282 if let Some(sp) = self.set_search_path_sql() {
284 sqlx::query(&sp).execute(&mut *tx).await?;
285 }
286 {
291 let conn: &mut PgConnection = &mut tx;
292 conn.execute(sqlx::raw_sql(&up_sql)).await?;
293 }
294 let elapsed_ms = start.elapsed().as_millis() as i32;
295 let qt = self.qualified_table();
296 let insert = format!(
297 "INSERT INTO {qt} (version, file, name, checksum, description, batch, applied_at, applied_by, execution_ms) \
298 VALUES ($1, $2, $3, $4, $5, $6, now(), current_user, $7)"
299 );
300 sqlx::query(&insert)
301 .bind(migration.version as i32)
302 .bind(&migration.file)
303 .bind(&migration.name)
304 .bind(&migration.checksum)
305 .bind(migration.why.as_deref())
306 .bind(batch)
307 .bind(elapsed_ms)
308 .execute(&mut *tx)
309 .await?;
310 tx.commit().await?;
311 Ok(())
312 }
313
314 async fn revert(&self, applied: &AppliedMigration, down_sql: &str) -> Result<()> {
315 let down_sql = down_sql.to_owned();
317 let mut tx = self.pool.begin().await?;
318 if let Some(sp) = self.set_search_path_sql() {
319 sqlx::query(&sp).execute(&mut *tx).await?;
320 }
321 {
325 let conn: &mut PgConnection = &mut tx;
326 conn.execute(sqlx::raw_sql(&down_sql)).await?;
327 }
328 let qt = self.qualified_table();
329 let delete = format!("DELETE FROM {qt} WHERE version = $1 AND file = $2");
330 sqlx::query(&delete)
331 .bind(applied.version as i32)
332 .bind(&applied.file)
333 .execute(&mut *tx)
334 .await?;
335 tx.commit().await?;
336 Ok(())
337 }
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 #[test]
345 fn valid_ident() {
346 assert!(validate_ident("00_schema_migrations").is_ok());
347 assert!(validate_ident("my_schema").is_ok());
348 assert!(validate_ident("ABC123").is_ok());
349 }
350
351 #[test]
352 fn invalid_ident_rejects_special_chars() {
353 assert!(matches!(
354 validate_ident("bad-name"),
355 Err(Error::InvalidIdentifier(_))
356 ));
357 assert!(matches!(
358 validate_ident("has space"),
359 Err(Error::InvalidIdentifier(_))
360 ));
361 assert!(matches!(
362 validate_ident(""),
363 Err(Error::InvalidIdentifier(_))
364 ));
365 assert!(matches!(
366 validate_ident("semi;colon"),
367 Err(Error::InvalidIdentifier(_))
368 ));
369 }
370
371 #[test]
372 fn leading_digit_allowed() {
373 assert!(validate_ident("00_schema_migrations").is_ok());
375 assert!(validate_ident("01_vault").is_ok());
376 }
377
378 #[test]
379 fn qualified_table_format() {
380 let with_schema = {
382 let s = "myschema";
383 let t = "00_schema_migrations";
384 format!("\"{s}\".\"{t}\"")
385 };
386 assert_eq!(with_schema, "\"myschema\".\"00_schema_migrations\"");
387
388 let without_schema = {
389 let t = "00_schema_migrations";
390 format!("\"{t}\"")
391 };
392 assert_eq!(without_schema, "\"00_schema_migrations\"");
393 }
394}