1use postgres::Client;
11
12use crate::error::{Db, Error, Result};
13use crate::PgStore;
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub struct Migration {
18 pub version: u32,
20 pub name: &'static str,
22 pub sql: &'static str,
24}
25
26impl Migration {
27 pub fn checksum(&self) -> String {
30 datumesh_core::sha256_hex(self.sql.as_bytes())
31 }
32}
33
34pub const MIGRATIONS: &[Migration] = &[
36 Migration { version: 1, name: "0001_initial.sql", sql: include_str!("../migrations/0001_initial.sql") },
37 Migration {
38 version: 2,
39 name: "0002_computed_versions.sql",
40 sql: include_str!("../migrations/0002_computed_versions.sql"),
41 },
42 Migration { version: 3, name: "0003_slices.sql", sql: include_str!("../migrations/0003_slices.sql") },
43 Migration {
44 version: 4,
45 name: "0004_receipt_values.sql",
46 sql: include_str!("../migrations/0004_receipt_values.sql"),
47 },
48];
49
50const TRACKING: &str = "CREATE TABLE IF NOT EXISTS datumesh_schema_migrations (
51 version integer PRIMARY KEY,
52 name text NOT NULL,
53 checksum text NOT NULL,
54 applied_at timestamptz NOT NULL DEFAULT now()
55)";
56
57const LOCK: &str = "SELECT pg_advisory_lock(hashtextextended('datumesh-store:migrate', 0))";
59const UNLOCK: &str = "SELECT pg_advisory_unlock(hashtextextended('datumesh-store:migrate', 0))";
60
61pub fn apply(store: &PgStore) -> Result<Vec<u32>> {
67 let mut conn = store.conn()?;
68 apply_client(&mut conn.client)
69}
70
71fn apply_client(client: &mut Client) -> Result<Vec<u32>> {
72 client.batch_execute(LOCK).db()?;
73 let result = apply_locked(client);
74 let unlocked = client.batch_execute(UNLOCK).db();
75 let applied = result?;
76 unlocked?;
77 for v in &applied {
78 tracing::info!(target: "datumesh_store", version = v, "migration applied");
79 }
80 Ok(applied)
81}
82
83fn apply_locked(client: &mut Client) -> Result<Vec<u32>> {
84 client.batch_execute(TRACKING).db()?;
85 let rows = client.query("SELECT version, checksum FROM datumesh_schema_migrations", &[]).db()?;
86 let mut done = Vec::new();
87 for r in &rows {
88 let v: i32 = r.try_get(0).db()?;
89 let c: String = r.try_get(1).db()?;
90 done.push((v, c));
91 }
92 let mut applied = Vec::new();
93 for m in MIGRATIONS {
94 let version =
95 i32::try_from(m.version).map_err(|_| Error::Migration(format!("{}: version too large", m.name)))?;
96 match done.iter().find(|(v, _)| *v == version) {
97 Some((_, c)) if *c == m.checksum() => continue,
98 Some(_) => {
99 return Err(Error::Migration(format!(
100 "{} was applied with another checksum: the file changed after it was applied",
101 m.name
102 )))
103 }
104 None => {}
105 }
106 let mut t = client.transaction().db()?;
107 t.batch_execute(m.sql).db()?;
108 t.execute(
109 "INSERT INTO datumesh_schema_migrations (version, name, checksum) VALUES ($1, $2, $3)",
110 &[&version, &m.name, &m.checksum()],
111 )
112 .db()?;
113 t.commit().db()?;
114 applied.push(m.version);
115 }
116 if let Some((v, _)) = done.iter().find(|(v, _)| !MIGRATIONS.iter().any(|m| i64::from(m.version) == i64::from(*v))) {
117 return Err(Error::Migration(format!("the database has migration {v}, which this version does not know")));
118 }
119 Ok(applied)
120}
121
122pub fn catalog(store: &PgStore) -> Result<Vec<String>> {
126 catalog_client(&mut store.conn()?.client)
127}
128
129fn catalog_client(client: &mut Client) -> Result<Vec<String>> {
130 let q = r#"
131WITH ns AS (SELECT oid FROM pg_namespace WHERE nspname = current_schema())
132SELECT 'relation ' || c.relkind::text || ' ' || c.relname
133 FROM pg_class c WHERE c.relnamespace = (SELECT oid FROM ns) AND c.relkind IN ('r', 'p', 'v', 'm', 'S', 'f')
134UNION ALL
135SELECT 'column ' || c.relname || '.' || a.attname || ' #' || a.attnum || ' ' || format_type(a.atttypid, a.atttypmod)
136 || CASE WHEN a.attnotnull THEN ' not null' ELSE '' END
137 || coalesce(' default ' || pg_get_expr(d.adbin, d.adrelid), '')
138 || coalesce(' collate ' || co.collname, '')
139 || CASE WHEN a.attgenerated::text <> '' THEN ' generated ' || a.attgenerated::text ELSE '' END
140 || CASE WHEN a.attidentity::text <> '' THEN ' identity ' || a.attidentity::text ELSE '' END
141 FROM pg_attribute a
142 JOIN pg_class c ON c.oid = a.attrelid
143 LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
144 LEFT JOIN pg_collation co ON co.oid = a.attcollation AND a.attcollation <> 0
145 AND co.oid <> (SELECT oid FROM pg_collation WHERE collname = 'default')
146 WHERE c.relnamespace = (SELECT oid FROM ns) AND c.relkind IN ('r', 'p', 'v', 'm', 'f')
147 AND a.attnum > 0 AND NOT a.attisdropped
148UNION ALL
149SELECT 'constraint ' || c.relname || '.' || k.conname || ' ' || pg_get_constraintdef(k.oid)
150 FROM pg_constraint k JOIN pg_class c ON c.oid = k.conrelid
151 WHERE k.connamespace = (SELECT oid FROM ns)
152UNION ALL
153SELECT 'index ' || c.relname || ' ' || pg_get_indexdef(i.indexrelid)
154 FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
155 WHERE c.relnamespace = (SELECT oid FROM ns)
156UNION ALL
157SELECT 'trigger ' || c.relname || '.' || t.tgname || ' enabled ' || t.tgenabled::text || ' ' || pg_get_triggerdef(t.oid)
158 FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid
159 WHERE c.relnamespace = (SELECT oid FROM ns) AND NOT t.tgisinternal
160UNION ALL
161SELECT 'function ' || p.oid::regprocedure::text || ' ' || md5(pg_get_functiondef(p.oid))
162 FROM pg_proc p WHERE p.pronamespace = (SELECT oid FROM ns) AND p.prokind IN ('f', 'p')
163"#;
164 let mut lines = Vec::new();
165 for r in client.query(q, &[]).db()? {
166 lines.push(r.try_get::<_, String>(0).db()?);
167 }
168 let tracked: bool =
169 client.query_one("SELECT to_regclass('datumesh_schema_migrations') IS NOT NULL", &[]).db()?.try_get(0).db()?;
170 if tracked {
171 for r in client.query("SELECT version, name, checksum FROM datumesh_schema_migrations", &[]).db()? {
172 let (v, n, c): (i32, String, String) = (r.try_get(0).db()?, r.try_get(1).db()?, r.try_get(2).db()?);
173 lines.push(format!("migration {v} {n} {c}"));
174 }
175 }
176 lines.sort();
177 Ok(lines)
178}
179
180pub fn check(live: &PgStore, scratch: &PgStore) -> Result<Vec<String>> {
185 let mut sc = scratch.conn()?;
186 let objects: i64 = sc
187 .client
188 .query_one(
189 "SELECT count(*) FROM pg_class WHERE relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())",
190 &[],
191 ).db()?
192 .try_get(0).db()?;
193 if objects != 0 {
194 return Err(Error::Migration("the scratch database is not empty".into()));
195 }
196 apply_client(&mut sc.client)?;
197 let want = catalog_client(&mut sc.client)?;
198 drop(sc);
199 let have = catalog(live)?;
200 let mut diff: Vec<String> = want.iter().filter(|l| !have.contains(l)).map(|l| format!("- {l}")).collect();
201 diff.extend(have.iter().filter(|l| !want.contains(l)).map(|l| format!("+ {l}")));
202 Ok(diff)
203}
204
205#[cfg(feature = "sqlite")]
210pub mod sqlite {
211 use rusqlite::Connection;
212
213 use super::Migration;
214 use crate::error::{Db, Error, Result};
215 use crate::SqliteStore;
216
217 pub const MIGRATIONS: &[Migration] = &[Migration {
220 version: 1,
221 name: "0001_initial.sql",
222 sql: include_str!("../migrations/sqlite/0001_initial.sql"),
223 }];
224
225 const TRACKING: &str = "CREATE TABLE IF NOT EXISTS datumesh_schema_migrations (
226 version INTEGER PRIMARY KEY,
227 name TEXT NOT NULL,
228 checksum TEXT NOT NULL,
229 applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
230) STRICT";
231
232 pub fn apply(store: &SqliteStore) -> Result<Vec<u32>> {
238 let c = store.raw()?;
239 let applied = apply_conn(&c)?;
240 for v in &applied {
241 tracing::info!(target: "datumesh_store", version = v, "migration applied");
242 }
243 Ok(applied)
244 }
245
246 fn apply_conn(c: &Connection) -> Result<Vec<u32>> {
247 crate::sqlite::write(c, |c| {
248 c.execute_batch(TRACKING).db()?;
249 let mut done: Vec<(i64, String)> = Vec::new();
250 {
251 let mut st = c.prepare("SELECT version, checksum FROM datumesh_schema_migrations").db()?;
252 let rows = st.query_map([], |r| Ok((r.get(0)?, r.get(1)?))).db()?;
253 for r in rows {
254 done.push(r.db()?);
255 }
256 }
257 let mut applied = Vec::new();
258 for m in MIGRATIONS {
259 match done.iter().find(|(v, _)| *v == i64::from(m.version)) {
260 Some((_, c)) if *c == m.checksum() => continue,
261 Some(_) => {
262 return Err(Error::Migration(format!(
263 "{} was applied with another checksum: the file changed after it was applied",
264 m.name
265 )))
266 }
267 None => {}
268 }
269 c.execute_batch(m.sql).db()?;
270 c.execute(
271 "INSERT INTO datumesh_schema_migrations (version, name, checksum) VALUES (?1, ?2, ?3)",
272 rusqlite::params![m.version, m.name, m.checksum()],
273 )
274 .db()?;
275 applied.push(m.version);
276 }
277 if let Some((v, _)) = done.iter().find(|(v, _)| !MIGRATIONS.iter().any(|m| i64::from(m.version) == *v)) {
278 return Err(Error::Migration(format!(
279 "the database has migration {v}, which this version does not know"
280 )));
281 }
282 Ok(applied)
283 })
284 }
285
286 pub fn catalog(store: &SqliteStore) -> Result<Vec<String>> {
289 catalog_conn(&*store.raw()?)
290 }
291
292 fn catalog_conn(c: &Connection) -> Result<Vec<String>> {
293 let mut lines = Vec::new();
294 {
295 let mut st = c
296 .prepare(
297 "SELECT type, name, tbl_name, sql FROM sqlite_schema
298 WHERE name NOT LIKE 'sqlite_stat%' ORDER BY type, name",
299 )
300 .db()?;
301 let rows = st
302 .query_map([], |r| {
303 Ok((
304 r.get::<_, String>(0)?,
305 r.get::<_, String>(1)?,
306 r.get::<_, String>(2)?,
307 r.get::<_, Option<String>>(3)?,
308 ))
309 })
310 .db()?;
311 for r in rows {
312 let (ty, name, table, sql) = r.db()?;
313 let sql = sql.map(|s| s.split_whitespace().collect::<Vec<_>>().join(" ")).unwrap_or_default();
314 lines.push(format!("{ty} {name} on {table}: {sql}"));
315 }
316 }
317 let tracked: bool = c
318 .query_row(
319 "SELECT EXISTS (SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'datumesh_schema_migrations')",
320 [],
321 |r| r.get(0),
322 )
323 .db()?;
324 if tracked {
325 let mut st = c.prepare("SELECT version, name, checksum FROM datumesh_schema_migrations").db()?;
326 let rows =
327 st.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?))).db()?;
328 for r in rows {
329 let (v, n, c) = r.db()?;
330 lines.push(format!("migration {v} {n} {c}"));
331 }
332 }
333 lines.sort();
334 Ok(lines)
335 }
336
337 pub fn check_file(path: impl AsRef<std::path::Path>) -> Result<Vec<String>> {
341 check(&SqliteStore::open_existing(path)?)
342 }
343
344 pub fn check(live: &SqliteStore) -> Result<Vec<String>> {
351 let scratch = Connection::open_in_memory().db()?;
352 apply_conn(&scratch)?;
353 let mut want = catalog_conn(&scratch)?;
354 let mut have = catalog(live)?;
355 want.sort();
356 have.sort();
357 let mut diff: Vec<String> = want.iter().filter(|l| !have.contains(l)).map(|l| format!("- {l}")).collect();
358 diff.extend(have.iter().filter(|l| !want.contains(l)).map(|l| format!("+ {l}")));
359 Ok(diff)
360 }
361}