1use std::str::FromStr;
2
3use sqlx::{
4 AssertSqlSafe, Connection, PgConnection, PgPool, Postgres, Row, Transaction,
5 postgres::{PgConnectOptions, PgPoolOptions},
6};
7
8use crate::{Migration, PostgresKitError, SchemaPlan, SetupOutcome, UpgradeOutcome};
9
10const LEDGER_TABLE: &str = "_lenso_schema_migrations";
11
12#[derive(Clone, Debug)]
14pub struct OwnedPostgres {
15 plan: SchemaPlan,
16 pool: PgPool,
17}
18
19impl OwnedPostgres {
20 pub async fn prepare(database_url: &str, plan: SchemaPlan) -> Result<Self, PostgresKitError> {
25 Self::prepare_with_pool_options(database_url, plan, PgPoolOptions::new()).await
26 }
27
28 pub async fn prepare_with_pool_options(
30 database_url: &str,
31 plan: SchemaPlan,
32 pool_options: PgPoolOptions,
33 ) -> Result<Self, PostgresKitError> {
34 let search_path = format!("{},pg_catalog", plan.schema());
35 let connect_options = PgConnectOptions::from_str(database_url)
36 .map_err(|error| PostgresKitError::database("parse connection options", error))?
37 .options([
38 ("search_path", search_path.as_str()),
39 ("application_name", "lenso-postgres-kit"),
40 ]);
41 let pool = pool_options
42 .connect_with(connect_options)
43 .await
44 .map_err(|error| PostgresKitError::database("connect runtime pool", error))?;
45
46 if let Err(error) = verify_pool(&pool, &plan).await {
47 pool.close().await;
48 return Err(error);
49 }
50 Ok(Self { plan, pool })
51 }
52
53 pub fn schema(&self) -> &str {
55 self.plan.schema()
56 }
57
58 pub fn schema_version(&self) -> u64 {
60 self.plan.current_version()
61 }
62
63 pub const fn pool(&self) -> &PgPool {
69 &self.pool
70 }
71}
72
73#[derive(Debug)]
75pub struct SchemaOperator {
76 plan: SchemaPlan,
77 pool: PgPool,
78}
79
80impl SchemaOperator {
81 pub async fn connect(database_url: &str, plan: SchemaPlan) -> Result<Self, PostgresKitError> {
83 let connect_options = PgConnectOptions::from_str(database_url)
84 .map_err(|error| PostgresKitError::database("parse connection options", error))?
85 .options([("application_name", "lenso-postgres-kit-operator")]);
86 let pool = PgPoolOptions::new()
87 .max_connections(1)
88 .connect_with(connect_options)
89 .await
90 .map_err(|error| PostgresKitError::database("connect operator pool", error))?;
91 Ok(Self { plan, pool })
92 }
93
94 pub async fn setup(&self) -> Result<SetupOutcome, PostgresKitError> {
99 let mut connection = self
100 .pool
101 .acquire()
102 .await
103 .map_err(|error| PostgresKitError::database("acquire setup connection", error))?;
104 let mut transaction = connection
105 .begin()
106 .await
107 .map_err(|error| PostgresKitError::database("begin setup transaction", error))?;
108 acquire_schema_lock(&mut transaction, self.plan.schema()).await?;
109
110 match inspect_schema(&mut transaction, &self.plan).await? {
111 SchemaState::Missing => {
112 create_managed_schema(&mut transaction, &self.plan).await?;
113 apply_migrations(&mut transaction, &self.plan, 0).await?;
114 transaction
115 .commit()
116 .await
117 .map_err(|error| PostgresKitError::database("commit schema setup", error))?;
118 Ok(SetupOutcome::Created {
119 version: self.plan.current_version(),
120 applied: self.plan.migrations().len(),
121 })
122 }
123 SchemaState::Unmanaged => Err(PostgresKitError::UnmanagedSchema {
124 schema: self.plan.schema().to_owned(),
125 }),
126 SchemaState::Managed { applied } => {
127 let current = validate_history(&self.plan, &applied)?;
128 if current == self.plan.current_version() {
129 Ok(SetupOutcome::AlreadyCurrent { version: current })
130 } else {
131 Err(PostgresKitError::UpgradeRequired {
132 schema: self.plan.schema().to_owned(),
133 current,
134 expected: self.plan.current_version(),
135 })
136 }
137 }
138 }
139 }
140
141 pub async fn upgrade(&self) -> Result<UpgradeOutcome, PostgresKitError> {
143 let mut connection = self
144 .pool
145 .acquire()
146 .await
147 .map_err(|error| PostgresKitError::database("acquire upgrade connection", error))?;
148 let mut transaction = connection
149 .begin()
150 .await
151 .map_err(|error| PostgresKitError::database("begin upgrade transaction", error))?;
152 acquire_schema_lock(&mut transaction, self.plan.schema()).await?;
153
154 let applied = match inspect_schema(&mut transaction, &self.plan).await? {
155 SchemaState::Missing => {
156 return Err(PostgresKitError::SetupRequired {
157 schema: self.plan.schema().to_owned(),
158 });
159 }
160 SchemaState::Unmanaged => {
161 return Err(PostgresKitError::UnmanagedSchema {
162 schema: self.plan.schema().to_owned(),
163 });
164 }
165 SchemaState::Managed { applied } => applied,
166 };
167 let current = validate_history(&self.plan, &applied)?;
168 if current == self.plan.current_version() {
169 return Ok(UpgradeOutcome::AlreadyCurrent { version: current });
170 }
171
172 let applied_count = self.plan.migrations().len() - applied.len();
173 apply_migrations(&mut transaction, &self.plan, applied.len()).await?;
174 transaction
175 .commit()
176 .await
177 .map_err(|error| PostgresKitError::database("commit schema upgrade", error))?;
178 Ok(UpgradeOutcome::Applied {
179 from: current,
180 to: self.plan.current_version(),
181 applied: applied_count,
182 })
183 }
184}
185
186#[derive(Debug)]
187enum SchemaState {
188 Missing,
189 Unmanaged,
190 Managed { applied: Vec<AppliedMigration> },
191}
192
193#[derive(Debug)]
194struct AppliedMigration {
195 version: u64,
196 name: String,
197 checksum: String,
198}
199
200async fn verify_pool(pool: &PgPool, plan: &SchemaPlan) -> Result<(), PostgresKitError> {
201 let mut connection = pool
202 .acquire()
203 .await
204 .map_err(|error| PostgresKitError::database("acquire verification connection", error))?;
205 match inspect_schema(&mut connection, plan).await? {
206 SchemaState::Missing => Err(PostgresKitError::SetupRequired {
207 schema: plan.schema().to_owned(),
208 }),
209 SchemaState::Unmanaged => Err(PostgresKitError::UnmanagedSchema {
210 schema: plan.schema().to_owned(),
211 }),
212 SchemaState::Managed { applied } => {
213 let current = validate_history(plan, &applied)?;
214 if current == plan.current_version() {
215 Ok(())
216 } else {
217 Err(PostgresKitError::UpgradeRequired {
218 schema: plan.schema().to_owned(),
219 current,
220 expected: plan.current_version(),
221 })
222 }
223 }
224 }
225}
226
227async fn inspect_schema(
228 connection: &mut PgConnection,
229 plan: &SchemaPlan,
230) -> Result<SchemaState, PostgresKitError> {
231 let owner: Option<String> = sqlx::query_scalar(
232 "SELECT roles.rolname::text\n\
233 FROM pg_namespace AS namespaces\n\
234 JOIN pg_roles AS roles ON roles.oid = namespaces.nspowner\n\
235 WHERE namespaces.nspname = $1",
236 )
237 .bind(plan.schema())
238 .fetch_optional(&mut *connection)
239 .await
240 .map_err(|error| PostgresKitError::database("inspect schema ownership", error))?;
241 let Some(owner) = owner else {
242 return Ok(SchemaState::Missing);
243 };
244
245 let current_role: String = sqlx::query_scalar("SELECT current_user::text")
246 .fetch_one(&mut *connection)
247 .await
248 .map_err(|error| PostgresKitError::database("inspect current database role", error))?;
249 if owner != current_role {
250 return Err(PostgresKitError::OwnershipMismatch {
251 schema: plan.schema().to_owned(),
252 owner,
253 current_role,
254 });
255 }
256
257 let ledger_exists: bool = sqlx::query_scalar(
258 "SELECT EXISTS (\n\
259 SELECT 1\n\
260 FROM pg_class AS relations\n\
261 JOIN pg_namespace AS namespaces ON namespaces.oid = relations.relnamespace\n\
262 WHERE namespaces.nspname = $1\n\
263 AND relations.relname = $2\n\
264 AND relations.relkind = 'r'\n\
265 )",
266 )
267 .bind(plan.schema())
268 .bind(LEDGER_TABLE)
269 .fetch_one(&mut *connection)
270 .await
271 .map_err(|error| PostgresKitError::database("inspect migration ledger", error))?;
272 if !ledger_exists {
273 return Ok(SchemaState::Unmanaged);
274 }
275
276 let ledger = qualified_table(plan.schema(), LEDGER_TABLE);
277 let read_ledger = format!("SELECT version, name, checksum FROM {ledger} ORDER BY version");
278 let rows = sqlx::query(AssertSqlSafe(read_ledger))
279 .fetch_all(&mut *connection)
280 .await
281 .map_err(|error| PostgresKitError::database("read migration ledger", error))?;
282 let mut applied = Vec::with_capacity(rows.len());
283 for row in rows {
284 let version: i64 = row
285 .try_get("version")
286 .map_err(|error| PostgresKitError::database("decode migration version", error))?;
287 let version = u64::try_from(version).map_err(|_| PostgresKitError::HistoryDiverged {
288 schema: plan.schema().to_owned(),
289 version: 0,
290 })?;
291 applied.push(AppliedMigration {
292 version,
293 name: row
294 .try_get("name")
295 .map_err(|error| PostgresKitError::database("decode migration name", error))?,
296 checksum: row
297 .try_get("checksum")
298 .map_err(|error| PostgresKitError::database("decode migration checksum", error))?,
299 });
300 }
301 Ok(SchemaState::Managed { applied })
302}
303
304fn validate_history(
305 plan: &SchemaPlan,
306 applied: &[AppliedMigration],
307) -> Result<u64, PostgresKitError> {
308 if let Some(actual) = applied.iter().map(|migration| migration.version).max()
309 && actual > plan.current_version()
310 {
311 return Err(PostgresKitError::SchemaAhead {
312 schema: plan.schema().to_owned(),
313 actual,
314 expected: plan.current_version(),
315 });
316 }
317
318 for (index, actual) in applied.iter().enumerate() {
319 let Some(expected) = plan.migrations().get(index) else {
320 return Err(PostgresKitError::SchemaAhead {
321 schema: plan.schema().to_owned(),
322 actual: actual.version,
323 expected: plan.current_version(),
324 });
325 };
326 if actual.version != expected.version()
327 || actual.name != expected.name()
328 || actual.checksum != expected.checksum()
329 {
330 return Err(PostgresKitError::HistoryDiverged {
331 schema: plan.schema().to_owned(),
332 version: actual.version,
333 });
334 }
335 }
336 Ok(applied.last().map_or(0, |migration| migration.version))
337}
338
339async fn acquire_schema_lock(
340 transaction: &mut Transaction<'_, Postgres>,
341 schema: &str,
342) -> Result<(), PostgresKitError> {
343 sqlx::query(
344 "SELECT pg_advisory_xact_lock(\n\
345 hashtextextended(current_database() || ':' || $1, 0)\n\
346 )",
347 )
348 .bind(schema)
349 .execute(&mut **transaction)
350 .await
351 .map_err(|error| PostgresKitError::database("lock owned schema", error))?;
352 Ok(())
353}
354
355async fn create_managed_schema(
356 transaction: &mut Transaction<'_, Postgres>,
357 plan: &SchemaPlan,
358) -> Result<(), PostgresKitError> {
359 let schema = quote_identifier(plan.schema());
360 sqlx::raw_sql(AssertSqlSafe(format!("CREATE SCHEMA {schema}")))
361 .execute(&mut **transaction)
362 .await
363 .map_err(|error| PostgresKitError::database("create owned schema", error))?;
364 let ledger = qualified_table(plan.schema(), LEDGER_TABLE);
365 sqlx::raw_sql(AssertSqlSafe(format!(
366 "CREATE TABLE {ledger} (\n\
367 version bigint PRIMARY KEY CHECK (version > 0),\n\
368 name text NOT NULL,\n\
369 checksum text NOT NULL,\n\
370 applied_at timestamptz NOT NULL DEFAULT transaction_timestamp()\n\
371 )"
372 )))
373 .execute(&mut **transaction)
374 .await
375 .map_err(|error| PostgresKitError::database("create migration ledger", error))?;
376 Ok(())
377}
378
379async fn apply_migrations(
380 transaction: &mut Transaction<'_, Postgres>,
381 plan: &SchemaPlan,
382 skip: usize,
383) -> Result<(), PostgresKitError> {
384 let search_path = format!(
385 "SET LOCAL search_path TO {}, pg_catalog",
386 quote_identifier(plan.schema())
387 );
388 sqlx::raw_sql(AssertSqlSafe(search_path))
389 .execute(&mut **transaction)
390 .await
391 .map_err(|error| PostgresKitError::database("select owned schema", error))?;
392
393 let ledger = qualified_table(plan.schema(), LEDGER_TABLE);
394 for migration in plan.migrations().iter().skip(skip) {
395 sqlx::raw_sql(migration.sql())
396 .execute(&mut **transaction)
397 .await
398 .map_err(|error| PostgresKitError::database("apply owned migration", error))?;
399 record_migration(transaction, &ledger, migration).await?;
400 }
401 Ok(())
402}
403
404async fn record_migration(
405 transaction: &mut Transaction<'_, Postgres>,
406 ledger: &str,
407 migration: &Migration,
408) -> Result<(), PostgresKitError> {
409 let version = i64::try_from(migration.version()).expect("validated migration version fits i64");
410 sqlx::query(AssertSqlSafe(format!(
411 "INSERT INTO {ledger} (version, name, checksum) VALUES ($1, $2, $3)"
412 )))
413 .bind(version)
414 .bind(migration.name())
415 .bind(migration.checksum())
416 .execute(&mut **transaction)
417 .await
418 .map_err(|error| PostgresKitError::database("record owned migration", error))?;
419 Ok(())
420}
421
422fn quote_identifier(identifier: &str) -> String {
423 format!("\"{identifier}\"")
424}
425
426fn qualified_table(schema: &str, table: &str) -> String {
427 format!("{}.{}", quote_identifier(schema), quote_identifier(table))
428}