cratefield_adapter_postgres/
migrate.rs1use crate::convert::{bind_values, rebind_placeholders};
10use cratefield_core::{Database, DbError, Harness, Migrations, SqlMigration, Statement};
11use sea_query::{Alias, ColumnDef, PostgresQueryBuilder, Query, Table};
12use sqlx::postgres::{PgArguments, PgConnection};
13use std::collections::HashMap;
14
15pub fn select_set(migrations: &Migrations) -> Result<&'static [SqlMigration], String> {
26 if !migrations.postgres.is_empty() {
27 return Ok(migrations.postgres);
28 }
29 for migration in migrations.sqlite {
30 let hits = cratefield_core::lint_portable_sql(migration.sql);
31 if let Some((token, explanation)) = hits.first() {
32 return Err(format!(
33 "sqlite migration {}/{} fails the portable-SQL lint (found {token:?}: \
34 {explanation}); ship a migrations/postgres override instead",
35 migration.id, migration.name
36 ));
37 }
38 }
39 Ok(migrations.sqlite)
40}
41
42pub(crate) fn tracking_table_ddl() -> Statement {
43 let sql = Table::create()
44 .table(Alias::new("harness_migrations"))
45 .if_not_exists()
46 .col(ColumnDef::new(Alias::new("id")).text().primary_key())
47 .col(ColumnDef::new(Alias::new("applied_at")).text().not_null())
48 .col(ColumnDef::new(Alias::new("checksum")).text())
49 .build(PostgresQueryBuilder);
50 Statement::new(sql)
51}
52
53pub(crate) fn tracking_table_backfill_ddl() -> Statement {
57 Statement::new(
58 "ALTER TABLE harness_migrations ADD COLUMN IF NOT EXISTS checksum text".to_owned(),
59 )
60}
61
62fn applied_query() -> Statement {
63 let (sql, values) = Query::select()
64 .column(Alias::new("id"))
65 .column(Alias::new("checksum"))
66 .from(Alias::new("harness_migrations"))
67 .build(PostgresQueryBuilder);
68 debug_assert!(values.0.is_empty(), "the applied query binds nothing");
69 Statement::new(sql)
70}
71
72fn record_insert(id: &str, applied_at: &str, checksum: &str) -> Result<Statement, DbError> {
75 let (sql, values) = Query::insert()
76 .into_table(Alias::new("harness_migrations"))
77 .columns([
78 Alias::new("id"),
79 Alias::new("applied_at"),
80 Alias::new("checksum"),
81 ])
82 .values([id.into(), applied_at.into(), checksum.into()])
83 .map_err(|err| DbError::Batch(err.to_string()))?
84 .build(PostgresQueryBuilder);
85 Ok(Statement::with_values(sql, values.0))
86}
87
88async fn exec_on(conn: &mut PgConnection, stmt: &Statement) -> Result<(), DbError> {
94 let sql = rebind_placeholders(&stmt.sql);
95 let mut args = PgArguments::default();
96 bind_values(&mut args, &stmt.values.0)?;
97 sqlx::query_with(sql.as_str(), args)
98 .execute(conn)
99 .await
100 .map_err(|err| DbError::Batch(first_error_line(&err.to_string()).to_owned()))?;
101 Ok(())
102}
103
104pub(crate) fn first_error_line(message: &str) -> &str {
107 message
108 .lines()
109 .find(|line| !line.is_empty())
110 .unwrap_or("unknown error")
111}
112
113fn iso_now() -> String {
114 time::OffsetDateTime::now_utc()
115 .format(&time::format_description::well_known::Rfc3339)
116 .unwrap_or_default()
117}
118
119impl crate::Postgres {
120 pub async fn apply_migrations(
131 &self,
132 module: &str,
133 migrations: &[SqlMigration],
134 ) -> Result<(), DbError> {
135 self.execute(&tracking_table_ddl()).await?;
136 self.execute(&tracking_table_backfill_ddl()).await?;
137
138 let applied: HashMap<String, Option<String>> = self
139 .query(&applied_query())
140 .await?
141 .rows
142 .iter()
143 .filter_map(|row| Some((row.get::<String>("id")?, row.get::<String>("checksum"))))
144 .collect();
145
146 let mut ordered: Vec<&SqlMigration> = migrations.iter().collect();
149 ordered.sort_by_key(|migration| migration.id);
150
151 for migration in ordered {
152 let key = format!("{module}/{}", migration.id);
153 let checksum = cratefield_core::migration_checksum(migration.sql);
154 if let Some(recorded) = applied.get(&key) {
155 if let Some(recorded) = recorded.as_ref().filter(|hash| *hash != &checksum) {
156 return Err(DbError::Batch(cratefield_core::migration_edited(
157 &key, recorded, &checksum,
158 )));
159 }
160 continue;
161 }
162 if !migration.transactional {
163 if !cratefield_core::is_idempotent_sql(migration.sql) {
168 return Err(DbError::Batch(cratefield_core::migration_missing_guard(
169 &key,
170 )));
171 }
172 sqlx::raw_sql(migration.sql)
173 .execute(&self.pool)
174 .await
175 .map_err(|err| {
176 DbError::Batch(format!(
177 "migration {key} failed: {}",
178 first_error_line(&err.to_string())
179 ))
180 })?;
181 let record = record_insert(&key, &iso_now(), &checksum)?;
182 self.execute(&record).await?;
183 continue;
184 }
185 let mut tx = self
186 .pool
187 .begin()
188 .await
189 .map_err(|err| DbError::Batch(err.to_string()))?;
190 sqlx::raw_sql(migration.sql)
193 .execute(&mut *tx)
194 .await
195 .map_err(|err| {
196 DbError::Batch(format!(
197 "migration {key} failed: {}",
198 first_error_line(&err.to_string())
199 ))
200 })?;
201 let record = record_insert(&key, &iso_now(), &checksum)?;
202 exec_on(&mut tx, &record).await?;
203 tx.commit()
204 .await
205 .map_err(|err| DbError::Batch(err.to_string()))?;
206 }
207 Ok(())
208 }
209
210 pub async fn apply_harness_migrations(&self, harness: &Harness) -> Result<(), DbError> {
222 for module in harness.modules() {
223 let set = select_set(&module.migrations())
224 .map_err(|reason| DbError::Batch(format!("module {}: {reason}", module.name())))?;
225 self.apply_migrations(module.name(), set).await?;
226 }
227 Ok(())
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::select_set;
234 use cratefield_core::{Migrations, SqlMigration};
235
236 const PORTABLE: SqlMigration = SqlMigration::new(
237 "0001",
238 "init",
239 "CREATE TABLE t (id TEXT PRIMARY KEY, n INTEGER NOT NULL DEFAULT 0);",
240 );
241
242 #[test]
243 fn prefers_the_postgres_set_when_shipped() {
244 const PG: SqlMigration =
245 SqlMigration::new("0001", "init", "CREATE TABLE t (id TEXT PRIMARY KEY);");
246 let migrations = Migrations {
247 sqlite: &[PORTABLE],
248 postgres: &[PG],
249 };
250 let selected = select_set(&migrations).expect("postgres set wins");
251 assert_eq!(selected.len(), 1);
252 assert!(selected[0].sql.contains("TEXT"));
253 }
254
255 #[test]
256 fn falls_back_to_sqlite_that_passes_the_lint() {
257 let migrations = Migrations::sqlite(&[PORTABLE]);
258 let selected = select_set(&migrations).expect("portable sqlite applies");
259 assert_eq!(selected.len(), 1);
260 assert!(selected[0].sql.contains("TEXT"));
261 }
262
263 #[test]
264 fn rejects_sqlite_that_fails_the_lint() {
265 const NOT_PORTABLE: SqlMigration =
266 SqlMigration::new("0002", "oops", "CREATE TABLE t (id SERIAL PRIMARY KEY);");
267 let migrations = Migrations::sqlite(&[PORTABLE, NOT_PORTABLE]);
268 let err = select_set(&migrations).expect_err("SERIAL is not portable");
269 assert!(err.contains("0002/oops"), "names the file: {err}");
270 assert!(err.contains("SERIAL"), "names the token: {err}");
271 }
272}