Skip to main content

toasty_driver_postgresql/
lib.rs

1#![warn(missing_docs)]
2
3//! Toasty driver for [PostgreSQL](https://www.postgresql.org/) using
4//! [`tokio-postgres`](https://docs.rs/tokio-postgres).
5//!
6//! # Examples
7//!
8//! ```no_run
9//! use toasty_driver_postgresql::PostgreSQL;
10//!
11//! let driver = PostgreSQL::new("postgresql://localhost/mydb").unwrap();
12//! ```
13
14mod oid_cache;
15mod statement_cache;
16#[cfg(feature = "tls")]
17mod tls;
18mod r#type;
19mod value;
20
21pub(crate) use value::Value;
22
23use async_trait::async_trait;
24use percent_encoding::percent_decode_str;
25use std::{borrow::Cow, sync::Arc};
26use toasty_core::{
27    Result, Schema,
28    driver::{Capability, Driver, ExecResponse, Operation},
29    schema::db::{self, Migration, SchemaDiff, Table},
30    stmt,
31    stmt::ValueRecord,
32};
33use toasty_sql::{self as sql};
34use tokio_postgres::{Client, Config, Socket, tls::MakeTlsConnect, types::ToSql};
35use url::Url;
36
37use crate::{oid_cache::OidCache, statement_cache::StatementCache};
38
39/// Classifies a `tokio_postgres::Error` into a Toasty error.
40///
41/// Errors that carry a server-side `DbError` are mapped to typed
42/// variants where one exists (`SerializationFailure`,
43/// `ReadOnlyTransaction`); everything else with a `DbError` becomes
44/// `DriverOperationFailed`. Errors *without* a `DbError` are
45/// classified as `ConnectionLost`: per `tokio-postgres`, those
46/// originate from the underlying socket or protocol layer (closed
47/// socket, IO error, end-of-stream during handshake), which the
48/// pool treats as evictable.
49fn classify_pg_error(e: tokio_postgres::Error) -> toasty_core::Error {
50    if let Some(db_err) = e.as_db_error() {
51        match db_err.code().code() {
52            "40001" => toasty_core::Error::serialization_failure(db_err.message()),
53            "25006" => toasty_core::Error::read_only_transaction(db_err.message()),
54            _ => toasty_core::Error::driver_operation_failed(e),
55        }
56    } else {
57        toasty_core::Error::connection_lost(e)
58    }
59}
60
61/// A PostgreSQL [`Driver`] that connects via `tokio-postgres`.
62///
63/// # Examples
64///
65/// ```no_run
66/// use toasty_driver_postgresql::PostgreSQL;
67///
68/// let driver = PostgreSQL::new("postgresql://localhost/mydb").unwrap();
69/// ```
70#[derive(Debug)]
71pub struct PostgreSQL {
72    url: String,
73    config: Config,
74    #[cfg(feature = "tls")]
75    tls: Option<tls::MakeRustlsConnect>,
76}
77
78impl PostgreSQL {
79    /// Create a new PostgreSQL driver from a connection URL
80    pub fn new(url: impl Into<String>) -> Result<Self> {
81        let url_str = url.into();
82        let url = Url::parse(&url_str).map_err(toasty_core::Error::driver_operation_failed)?;
83
84        if !matches!(url.scheme(), "postgresql" | "postgres") {
85            return Err(toasty_core::Error::invalid_connection_url(format!(
86                "connection URL does not have a `postgresql` scheme; url={}",
87                url
88            )));
89        }
90
91        let host = url.host_str().ok_or_else(|| {
92            toasty_core::Error::invalid_connection_url(format!(
93                "missing host in connection URL; url={}",
94                url
95            ))
96        })?;
97
98        if url.path().is_empty() {
99            return Err(toasty_core::Error::invalid_connection_url(format!(
100                "no database specified - missing path in connection URL; url={}",
101                url
102            )));
103        }
104
105        let mut config = Config::new();
106        config.host(host);
107
108        let dbname = percent_decode_str(url.path().trim_start_matches('/'))
109            .decode_utf8()
110            .map_err(|_| {
111                toasty_core::Error::invalid_connection_url("database name is not valid UTF-8")
112            })?;
113        config.dbname(&*dbname);
114
115        if let Some(port) = url.port() {
116            config.port(port);
117        }
118
119        if !url.username().is_empty() {
120            let user = percent_decode_str(url.username())
121                .decode_utf8()
122                .map_err(|_| {
123                    toasty_core::Error::invalid_connection_url("username is not valid UTF-8")
124                })?;
125            config.user(&*user);
126        }
127
128        if let Some(password) = url.password() {
129            config.password(percent_decode_str(password).collect::<Vec<u8>>());
130        }
131
132        for (key, value) in url.query_pairs() {
133            if key == "application_name" {
134                config.application_name(&*value);
135            }
136        }
137
138        #[cfg(feature = "tls")]
139        let tls = tls::configure_tls(&url, &mut config)?;
140
141        #[cfg(not(feature = "tls"))]
142        for (key, value) in url.query_pairs() {
143            if key == "sslmode" && value != "disable" {
144                return Err(toasty_core::Error::invalid_connection_url(
145                    "TLS not available: compile with the `tls` feature",
146                ));
147            }
148        }
149
150        Ok(Self {
151            url: url_str,
152            config,
153            #[cfg(feature = "tls")]
154            tls,
155        })
156    }
157
158    async fn connect_with_config(&self, config: Config) -> Result<Connection> {
159        #[cfg(feature = "tls")]
160        if let Some(ref tls) = self.tls {
161            return Connection::connect(config, tls.clone()).await;
162        }
163        Connection::connect(config, tokio_postgres::NoTls).await
164    }
165}
166
167#[async_trait]
168impl Driver for PostgreSQL {
169    fn url(&self) -> Cow<'_, str> {
170        Cow::Borrowed(&self.url)
171    }
172
173    fn capability(&self) -> &'static Capability {
174        &Capability::POSTGRESQL
175    }
176
177    async fn connect(&self) -> toasty_core::Result<Box<dyn toasty_core::driver::Connection>> {
178        Ok(Box::new(
179            self.connect_with_config(self.config.clone()).await?,
180        ))
181    }
182
183    fn generate_migration(&self, schema_diff: &SchemaDiff<'_>) -> Migration {
184        let statements = sql::MigrationStatement::from_diff(schema_diff, &Capability::POSTGRESQL);
185
186        let sql_strings: Vec<String> = statements
187            .iter()
188            .map(|stmt| sql::Serializer::postgresql(stmt.schema()).serialize(stmt.statement()))
189            .collect();
190
191        Migration::new_sql(sql_strings.join("\n"))
192    }
193
194    async fn reset_db(&self) -> toasty_core::Result<()> {
195        let dbname = self
196            .config
197            .get_dbname()
198            .ok_or_else(|| {
199                toasty_core::Error::invalid_connection_url("no database name configured")
200            })?
201            .to_string();
202
203        // We cannot drop a database we are currently connected to, so we need a temp database.
204        let temp_dbname = "__toasty_reset_temp";
205
206        let connect = |dbname: &str| {
207            let mut config = self.config.clone();
208            config.dbname(dbname);
209            self.connect_with_config(config)
210        };
211
212        // Step 1: Connect to the target DB and create a temp DB
213        let conn = connect(&dbname).await?;
214        conn.client
215            .execute(&format!("DROP DATABASE IF EXISTS \"{}\"", temp_dbname), &[])
216            .await
217            .map_err(classify_pg_error)?;
218        conn.client
219            .execute(&format!("CREATE DATABASE \"{}\"", temp_dbname), &[])
220            .await
221            .map_err(classify_pg_error)?;
222        drop(conn);
223
224        // Step 2: Connect to the temp DB, drop and recreate the target
225        let conn = connect(temp_dbname).await?;
226        conn.client
227            .execute(
228                "SELECT pg_terminate_backend(pid) \
229                 FROM pg_stat_activity \
230                 WHERE datname = $1 AND pid <> pg_backend_pid()",
231                &[&dbname],
232            )
233            .await
234            .map_err(classify_pg_error)?;
235        conn.client
236            .execute(&format!("DROP DATABASE IF EXISTS \"{}\"", dbname), &[])
237            .await
238            .map_err(classify_pg_error)?;
239        conn.client
240            .execute(&format!("CREATE DATABASE \"{}\"", dbname), &[])
241            .await
242            .map_err(classify_pg_error)?;
243        drop(conn);
244
245        // Step 3: Connect back to the target and clean up the temp DB
246        let conn = connect(&dbname).await?;
247        conn.client
248            .execute(&format!("DROP DATABASE IF EXISTS \"{}\"", temp_dbname), &[])
249            .await
250            .map_err(classify_pg_error)?;
251
252        Ok(())
253    }
254}
255
256/// An open connection to a PostgreSQL database.
257#[derive(Debug)]
258pub struct Connection {
259    client: Client,
260    statement_cache: StatementCache,
261    oid_cache: OidCache,
262}
263
264impl Connection {
265    /// Initialize a Toasty PostgreSQL connection using an initialized client.
266    pub fn new(client: Client) -> Self {
267        Self {
268            client,
269            statement_cache: StatementCache::new(100),
270            oid_cache: OidCache::new(),
271        }
272    }
273
274    /// Connects to a PostgreSQL database using a [`postgres::Config`].
275    ///
276    /// See [`postgres::Client::configure`] for more information.
277    pub async fn connect<T>(config: Config, tls: T) -> Result<Self>
278    where
279        T: MakeTlsConnect<Socket> + 'static,
280        T::Stream: Send,
281    {
282        let (client, connection) = config.connect(tls).await.map_err(classify_pg_error)?;
283
284        tokio::spawn(async move {
285            if let Err(e) = connection.await {
286                eprintln!("connection error: {e}");
287            }
288        });
289
290        Ok(Self::new(client))
291    }
292
293    /// Creates a table.
294    pub async fn create_table(&mut self, schema: &db::Schema, table: &Table) -> Result<()> {
295        let serializer = sql::Serializer::postgresql(schema);
296
297        let sql = serializer.serialize(&sql::Statement::create_table(
298            table,
299            &Capability::POSTGRESQL,
300        ));
301
302        self.client
303            .execute(&sql, &[])
304            .await
305            .map_err(classify_pg_error)?;
306
307        for index in &table.indices {
308            if index.primary_key {
309                continue;
310            }
311
312            let sql = serializer.serialize(&sql::Statement::create_index(index));
313
314            self.client
315                .execute(&sql, &[])
316                .await
317                .map_err(classify_pg_error)?;
318        }
319
320        Ok(())
321    }
322}
323
324impl From<Client> for Connection {
325    fn from(client: Client) -> Self {
326        Self::new(client)
327    }
328}
329
330#[async_trait]
331impl toasty_core::driver::Connection for Connection {
332    async fn exec(&mut self, schema: &Arc<Schema>, op: Operation) -> Result<ExecResponse> {
333        tracing::trace!(driver = "postgresql", op = %op.name(), "driver exec");
334
335        if let Operation::Transaction(ref t) = op {
336            let sql = sql::Serializer::postgresql(&schema.db).serialize_transaction(t);
337            self.client
338                .batch_execute(&sql)
339                .await
340                .map_err(classify_pg_error)?;
341            return Ok(ExecResponse::count(0));
342        }
343
344        let (sql, typed_params, ret_tys) = match op {
345            Operation::Insert(op) => (sql::Statement::from(op.stmt), op.params, None),
346            Operation::QuerySql(query) => {
347                assert!(
348                    query.last_insert_id_hack.is_none(),
349                    "last_insert_id_hack is MySQL-specific and should not be set for PostgreSQL"
350                );
351                (sql::Statement::from(query.stmt), query.params, query.ret)
352            }
353            op => todo!("op={:#?}", op),
354        };
355
356        let width = sql.returning_len();
357
358        let sql_as_str = sql::Serializer::postgresql(&schema.db).serialize(&sql);
359
360        tracing::debug!(db.system = "postgresql", db.statement = %sql_as_str, params = typed_params.len(), "executing SQL");
361
362        self.oid_cache
363            .preload(&self.client, typed_params.iter().map(|tv| &tv.ty))
364            .await?;
365        let param_types: Vec<_> = typed_params
366            .iter()
367            .map(|tv| self.oid_cache.get(&tv.ty).clone())
368            .collect();
369
370        let values: Vec<_> = typed_params
371            .into_iter()
372            .map(|tv| Value::from(tv.value))
373            .collect();
374        let params = values
375            .iter()
376            .map(|param| param as &(dyn ToSql + Sync))
377            .collect::<Vec<_>>();
378
379        let statement = self
380            .statement_cache
381            .prepare_typed(&mut self.client, &sql_as_str, &param_types)
382            .await
383            .map_err(classify_pg_error)?;
384
385        if width.is_none() {
386            let count = self
387                .client
388                .execute(&statement, &params)
389                .await
390                .map_err(classify_pg_error)?;
391            return Ok(ExecResponse::count(count));
392        }
393
394        let rows = self
395            .client
396            .query(&statement, &params)
397            .await
398            .map_err(classify_pg_error)?;
399
400        if width.is_none() {
401            let [row] = &rows[..] else { todo!() };
402            let total = row.get::<usize, i64>(0);
403            let condition_matched = row.get::<usize, i64>(1);
404
405            if total == condition_matched {
406                Ok(ExecResponse::count(total as _))
407            } else {
408                Err(toasty_core::Error::condition_failed(
409                    "update condition did not match",
410                ))
411            }
412        } else {
413            let ret_tys = ret_tys.as_ref().unwrap().clone();
414            let results = rows.into_iter().map(move |row| {
415                let mut results = Vec::new();
416                for (i, column) in row.columns().iter().enumerate() {
417                    results.push(Value::from_sql(i, &row, column, &ret_tys[i]).into_inner());
418                }
419
420                Ok(ValueRecord::from_vec(results))
421            });
422
423            Ok(ExecResponse::value_stream(stmt::ValueStream::from_iter(
424                results,
425            )))
426        }
427    }
428
429    async fn push_schema(&mut self, schema: &Schema) -> Result<()> {
430        let serializer = sql::Serializer::postgresql(&schema.db);
431
432        // Create PostgreSQL enum types before creating tables.
433        // Collect unique enum types across all columns.
434        let mut created_enum_types = hashbrown::HashSet::new();
435        for table in &schema.db.tables {
436            for column in &table.columns {
437                if let toasty_core::schema::db::Type::Enum(type_enum) = &column.storage_ty
438                    && created_enum_types.insert(type_enum.name.clone())
439                {
440                    let sql = serializer.serialize(&sql::Statement::create_enum_type(type_enum));
441
442                    tracing::debug!(enum_type = ?type_enum.name, "creating enum type");
443                    self.client
444                        .execute(&sql, &[])
445                        .await
446                        .map_err(classify_pg_error)?;
447                }
448            }
449        }
450
451        for table in &schema.db.tables {
452            tracing::debug!(table = %table.name, "creating table");
453            self.create_table(&schema.db, table).await?;
454        }
455        Ok(())
456    }
457
458    async fn applied_migrations(
459        &mut self,
460    ) -> Result<Vec<toasty_core::schema::db::AppliedMigration>> {
461        // Ensure the migrations table exists
462        self.client
463            .execute(
464                "CREATE TABLE IF NOT EXISTS __toasty_migrations (
465                id BIGINT PRIMARY KEY,
466                name TEXT NOT NULL,
467                applied_at TIMESTAMP NOT NULL
468            )",
469                &[],
470            )
471            .await
472            .map_err(classify_pg_error)?;
473
474        // Query all applied migrations
475        let rows = self
476            .client
477            .query(
478                "SELECT id FROM __toasty_migrations ORDER BY applied_at",
479                &[],
480            )
481            .await
482            .map_err(classify_pg_error)?;
483
484        Ok(rows
485            .iter()
486            .map(|row| {
487                let id: i64 = row.get(0);
488                toasty_core::schema::db::AppliedMigration::new(id as u64)
489            })
490            .collect())
491    }
492
493    async fn apply_migration(
494        &mut self,
495        id: u64,
496        name: &str,
497        migration: &toasty_core::schema::db::Migration,
498    ) -> Result<()> {
499        tracing::info!(id = id, name = %name, "applying migration");
500        // Ensure the migrations table exists
501        self.client
502            .execute(
503                "CREATE TABLE IF NOT EXISTS __toasty_migrations (
504                id BIGINT PRIMARY KEY,
505                name TEXT NOT NULL,
506                applied_at TIMESTAMP NOT NULL
507            )",
508                &[],
509            )
510            .await
511            .map_err(classify_pg_error)?;
512
513        // Start transaction
514        let transaction = self.client.transaction().await.map_err(classify_pg_error)?;
515
516        // Execute each migration statement
517        for statement in migration.statements() {
518            if let Err(e) = transaction
519                .batch_execute(statement)
520                .await
521                .map_err(classify_pg_error)
522            {
523                transaction.rollback().await.map_err(classify_pg_error)?;
524                return Err(e);
525            }
526        }
527
528        // Record the migration
529        if let Err(e) = transaction
530            .execute(
531                "INSERT INTO __toasty_migrations (id, name, applied_at) VALUES ($1, $2, NOW())",
532                &[&(id as i64), &name],
533            )
534            .await
535            .map_err(classify_pg_error)
536        {
537            transaction.rollback().await.map_err(classify_pg_error)?;
538            return Err(e);
539        }
540
541        // Commit transaction
542        transaction.commit().await.map_err(classify_pg_error)?;
543        Ok(())
544    }
545
546    fn is_valid(&self) -> bool {
547        !self.client.is_closed()
548    }
549
550    async fn ping(&mut self) -> Result<()> {
551        // An empty `simple_query` is the lightest sync round-trip in
552        // the PG protocol — it skips parsing entirely. Any failure is
553        // surfaced as `connection_lost`: the only meaningful outcome
554        // of a ping is "the connection is alive" or "evict it."
555        self.client
556            .simple_query("")
557            .await
558            .map(|_| ())
559            .map_err(toasty_core::Error::connection_lost)
560    }
561}