1use std::time::Duration;
20
21pub use sqlx;
22use sqlx::{
23 AssertSqlSafe, PgPool, Row,
24 migrate::{MigrateError, Migrator},
25 postgres::PgPoolOptions,
26};
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct PoolConfig {
31 pub uri: String,
33 pub min_connections: u32,
35 pub max_connections: u32,
37 pub connect_timeout_ms: u64,
39 pub idle_timeout_ms: u64,
41}
42
43impl PoolConfig {
44 #[must_use]
46 pub fn new(uri: impl Into<String>) -> Self {
47 Self {
48 uri: uri.into(),
49 min_connections: 1,
50 max_connections: 10,
51 connect_timeout_ms: 5_000,
52 idle_timeout_ms: 600_000,
53 }
54 }
55
56 #[must_use]
58 pub fn options(&self) -> PgPoolOptions {
59 PgPoolOptions::new()
60 .min_connections(self.min_connections)
61 .max_connections(self.max_connections)
62 .acquire_timeout(Duration::from_millis(self.connect_timeout_ms))
63 .idle_timeout(Duration::from_millis(self.idle_timeout_ms))
64 }
65}
66
67pub async fn connect(config: &PoolConfig) -> Result<PgPool, sqlx::Error> {
73 config.options().connect(&config.uri).await
74}
75
76pub async fn ping(pool: &PgPool) -> Result<(), sqlx::Error> {
82 sqlx::query("SELECT 1").execute(pool).await?;
83 Ok(())
84}
85
86#[must_use]
89pub fn is_unique_violation(error: &sqlx::Error) -> bool {
90 error
91 .as_database_error()
92 .is_some_and(sqlx::error::DatabaseError::is_unique_violation)
93}
94
95#[must_use]
107pub fn quote_identifier(name: &str) -> Option<String> {
108 let mut chars = name.chars();
109 let first = chars.next()?;
110 let valid = (first.is_ascii_lowercase() || first == '_')
111 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
112 valid.then(|| format!("\"{name}\""))
113}
114
115fn quote_all<S: AsRef<str>>(names: &[S]) -> Result<Vec<String>, sqlx::Error> {
117 names
118 .iter()
119 .map(|name| {
120 quote_identifier(name.as_ref()).ok_or_else(|| {
121 sqlx::Error::Protocol(format!("invalid identifier: {}", name.as_ref()))
122 })
123 })
124 .collect()
125}
126
127pub async fn migrate(pool: &PgPool, migrator: &Migrator) -> Result<(), MigrateError> {
134 migrator.run(pool).await
135}
136
137async fn applied_versions(pool: &PgPool) -> Result<Vec<i64>, sqlx::Error> {
140 let exists: bool = sqlx::query("SELECT to_regclass('_sqlx_migrations') IS NOT NULL AS found")
141 .fetch_one(pool)
142 .await?
143 .try_get("found")?;
144 if !exists {
145 return Ok(Vec::new());
146 }
147 let rows =
148 sqlx::query("SELECT version FROM _sqlx_migrations WHERE success ORDER BY version DESC")
149 .fetch_all(pool)
150 .await?;
151 rows.iter().map(|row| row.try_get("version")).collect()
152}
153
154pub async fn revert(pool: &PgPool, migrator: &Migrator, steps: u32) -> Result<(), MigrateError> {
160 let applied = applied_versions(pool).await?;
161 let steps = usize::try_from(steps).unwrap_or(usize::MAX);
162 let target = applied.get(steps).copied().unwrap_or(0);
165 migrator.undo(pool, target).await
166}
167
168#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct MigrationStatus {
171 pub version: i64,
173 pub description: String,
175 pub applied: bool,
177}
178
179pub async fn status(
185 pool: &PgPool,
186 migrator: &Migrator,
187) -> Result<Vec<MigrationStatus>, sqlx::Error> {
188 let applied = applied_versions(pool).await?;
189 Ok(migrator
190 .iter()
191 .filter(|migration| !migration.migration_type.is_down_migration())
192 .map(|migration| MigrationStatus {
193 version: migration.version,
194 description: migration.description.to_string(),
195 applied: applied.contains(&migration.version),
196 })
197 .collect())
198}
199
200pub async fn truncate<S: AsRef<str>>(pool: &PgPool, tables: &[S]) -> Result<(), sqlx::Error> {
207 if tables.is_empty() {
208 return Ok(());
209 }
210 let tables = quote_all(tables)?.join(", ");
211 let sql = format!("TRUNCATE {tables} RESTART IDENTITY CASCADE");
212 sqlx::query(AssertSqlSafe(sql)).execute(pool).await?;
213 Ok(())
214}
215
216pub async fn dangerously_recreate(pool: &PgPool, migrator: &Migrator) -> Result<(), MigrateError> {
231 let tables: Vec<String> =
232 sqlx::query("SELECT tablename::text AS name FROM pg_tables WHERE schemaname = 'public'")
233 .fetch_all(pool)
234 .await?
235 .iter()
236 .map(|row| row.try_get("name"))
237 .collect::<Result<_, _>>()?;
238 let types: Vec<String> = sqlx::query(
240 "SELECT t.typname::text AS name FROM pg_type t
241 JOIN pg_namespace n ON n.oid = t.typnamespace
242 WHERE t.typtype = 'e' AND n.nspname = 'public'
243 AND NOT EXISTS (
244 SELECT 1 FROM pg_depend d WHERE d.objid = t.oid AND d.deptype = 'e'
245 )",
246 )
247 .fetch_all(pool)
248 .await?
249 .iter()
250 .map(|row| row.try_get("name"))
251 .collect::<Result<_, _>>()?;
252
253 if !tables.is_empty() {
254 let names = quote_all(&tables)?.join(", ");
255 let sql = format!("DROP TABLE {names} CASCADE");
256 sqlx::query(AssertSqlSafe(sql)).execute(pool).await?;
257 }
258 for name in quote_all(&types)? {
259 let sql = format!("DROP TYPE {name} CASCADE");
260 sqlx::query(AssertSqlSafe(sql)).execute(pool).await?;
261 }
262 migrate(pool, migrator).await
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 #[test]
270 fn identifiers_are_quoted() {
271 assert_eq!(quote_identifier("users").as_deref(), Some("\"users\""));
272 assert_eq!(quote_identifier("_a1").as_deref(), Some("\"_a1\""));
273 }
274
275 #[test]
276 fn unsafe_identifiers_are_refused() {
277 for name in [
278 "", "1users", "Users", "a b", "a\"b", "a;b", "a.b", "users--", "é",
279 ] {
280 assert_eq!(quote_identifier(name), None, "{name:?}");
281 }
282 }
283
284 #[test]
285 fn quote_all_fails_on_any_bad_name() {
286 assert!(quote_all(&["good", "also_good"]).is_ok());
287 assert!(quote_all(&["good", "bad name"]).is_err());
288 }
289
290 #[test]
291 fn pool_settings_reach_the_options() {
292 let config = PoolConfig {
293 min_connections: 2,
294 max_connections: 7,
295 connect_timeout_ms: 1_500,
296 idle_timeout_ms: 9_000,
297 ..PoolConfig::new("postgres://x")
298 };
299 let options = config.options();
300 assert_eq!(options.get_min_connections(), 2);
301 assert_eq!(options.get_max_connections(), 7);
302 assert_eq!(options.get_acquire_timeout(), Duration::from_millis(1_500));
303 assert_eq!(options.get_idle_timeout(), Some(Duration::from_secs(9)));
304 }
305
306 #[test]
307 fn errors_that_are_not_from_the_database_are_not_unique_violations() {
308 assert!(!is_unique_violation(&sqlx::Error::RowNotFound));
309 }
310}