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::{
29        Capability, ConnectContext, Driver, ExecResponse, Operation, QueryLogConfig,
30        log::QueryLog,
31        operation::{RawSqlRet, Transaction, TransactionMode, TypedValue},
32    },
33    schema::{
34        db::{self, Migration, Table},
35        diff,
36    },
37    stmt,
38    stmt::ValueRecord,
39};
40use toasty_sql::{self as sql};
41use tokio_postgres::{Client, Config, Socket, tls::MakeTlsConnect, types::ToSql};
42use url::Url;
43
44enum SqlReturn {
45    Count,
46    Infer,
47    Types(Vec<stmt::Type>),
48}
49
50use crate::{oid_cache::OidCache, statement_cache::StatementCache};
51
52/// Classifies a `tokio_postgres::Error` into a Toasty error.
53///
54/// Errors that carry a server-side `DbError` are mapped to typed
55/// variants where one exists (`SerializationFailure`,
56/// `ReadOnlyTransaction`); everything else with a `DbError` becomes
57/// `DriverOperationFailed`. Errors *without* a `DbError` are
58/// classified as `ConnectionLost`: per `tokio-postgres`, those
59/// originate from the underlying socket or protocol layer (closed
60/// socket, IO error, end-of-stream during handshake), which the
61/// pool treats as evictable.
62fn classify_pg_error(e: tokio_postgres::Error) -> toasty_core::Error {
63    if let Some(db_err) = e.as_db_error() {
64        match db_err.code().code() {
65            "40001" => toasty_core::Error::serialization_failure(db_err.message()),
66            "25006" => toasty_core::Error::read_only_transaction(db_err.message()),
67            _ => toasty_core::Error::driver_operation_failed(e),
68        }
69    } else {
70        toasty_core::Error::connection_lost(e)
71    }
72}
73
74/// A PostgreSQL [`Driver`] that connects via `tokio-postgres`.
75///
76/// # Examples
77///
78/// ```no_run
79/// use toasty_driver_postgresql::PostgreSQL;
80///
81/// let driver = PostgreSQL::new("postgresql://localhost/mydb").unwrap();
82/// ```
83pub struct PostgreSQL {
84    url: String,
85    config: Config,
86    #[cfg(feature = "tls")]
87    tls: Option<tokio_postgres_rustls::MakeRustlsConnect>,
88}
89
90impl std::fmt::Debug for PostgreSQL {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        let Self {
93            url,
94            config,
95            #[cfg(feature = "tls")]
96            tls,
97        } = self;
98        let mut s = f.debug_struct("PostgreSQL");
99        s.field("url", url);
100        s.field("config", config);
101        #[cfg(feature = "tls")]
102        s.field("tls", &tls.as_ref().map(|_| "MakeRustlsConnect"));
103        s.finish()
104    }
105}
106
107impl PostgreSQL {
108    /// Create a new PostgreSQL driver from a connection URL
109    pub fn new(url: impl Into<String>) -> Result<Self> {
110        let url_str = url.into();
111        let url = Url::parse(&url_str).map_err(toasty_core::Error::driver_operation_failed)?;
112
113        if !matches!(url.scheme(), "postgresql" | "postgres") {
114            return Err(toasty_core::Error::invalid_connection_url(format!(
115                "connection URL does not have a `postgresql` scheme; url={}",
116                url
117            )));
118        }
119
120        if url.path().is_empty() {
121            return Err(toasty_core::Error::invalid_connection_url(format!(
122                "no database specified - missing path in connection URL; url={}",
123                url
124            )));
125        }
126
127        let mut config = Config::new();
128
129        let dbname = percent_decode_str(url.path().trim_start_matches('/'))
130            .decode_utf8()
131            .map_err(|_| {
132                toasty_core::Error::invalid_connection_url("database name is not valid UTF-8")
133            })?;
134        config.dbname(&*dbname);
135
136        if !url.username().is_empty() {
137            let user = percent_decode_str(url.username())
138                .decode_utf8()
139                .map_err(|_| {
140                    toasty_core::Error::invalid_connection_url("username is not valid UTF-8")
141                })?;
142            config.user(&*user);
143        }
144
145        if let Some(password) = url.password() {
146            config.password(percent_decode_str(password).collect::<Vec<u8>>());
147        }
148
149        // libpq lets standard connection parameters appear in the query
150        // string; honor the ones a Toasty user can reasonably set so that
151        // `postgresql:///mydb?host=/tmp&user=alice` reaches the server.
152        // Single-valued setters (user, password, dbname, application_name)
153        // replace earlier calls, so we can apply them inline; host and
154        // port are list-valued — staged into Options below so a query
155        // parameter cleanly overrides the URL component instead of being
156        // appended as a fallback tokio-postgres would try first.
157        let mut host: Option<String> = None;
158        let mut port: Option<u16> = None;
159
160        for (key, value) in url.query_pairs() {
161            match key.as_ref() {
162                "host" => host = Some(value.into_owned()),
163                "port" => {
164                    port = Some(value.parse::<u16>().map_err(|_| {
165                        toasty_core::Error::invalid_connection_url(format!(
166                            "invalid port in connection URL query parameter: {value}"
167                        ))
168                    })?);
169                }
170                "user" => {
171                    config.user(&*value);
172                }
173                "password" => {
174                    config.password(value.as_bytes());
175                }
176                "dbname" => {
177                    config.dbname(&*value);
178                }
179                "application_name" => {
180                    config.application_name(&*value);
181                }
182                _ => {}
183            }
184        }
185
186        let host = host
187            .or_else(|| url.host_str().filter(|h| !h.is_empty()).map(String::from))
188            .ok_or_else(|| {
189                toasty_core::Error::invalid_connection_url(format!(
190                    "missing host in connection URL; url={}",
191                    url
192                ))
193            })?;
194        config.host(&host);
195
196        if let Some(port) = port.or_else(|| url.port()) {
197            config.port(port);
198        }
199
200        #[cfg(feature = "tls")]
201        let tls = tls::configure_tls(&url, &mut config)?;
202
203        #[cfg(not(feature = "tls"))]
204        for (key, value) in url.query_pairs() {
205            if key == "sslmode" && value != "disable" {
206                return Err(toasty_core::Error::invalid_connection_url(
207                    "TLS not available: compile with the `tls` feature",
208                ));
209            }
210        }
211
212        Ok(Self {
213            url: url_str,
214            config,
215            #[cfg(feature = "tls")]
216            tls,
217        })
218    }
219
220    async fn connect_with_config(&self, config: Config) -> Result<Connection> {
221        #[cfg(feature = "tls")]
222        if let Some(ref tls) = self.tls {
223            return Connection::connect(config, tls.clone()).await;
224        }
225        Connection::connect(config, tokio_postgres::NoTls).await
226    }
227}
228
229#[async_trait]
230impl Driver for PostgreSQL {
231    fn url(&self) -> Cow<'_, str> {
232        Cow::Borrowed(&self.url)
233    }
234
235    fn capability(&self) -> &'static Capability {
236        &Capability::POSTGRESQL
237    }
238
239    async fn connect(
240        &self,
241        cx: &ConnectContext,
242    ) -> toasty_core::Result<Box<dyn toasty_core::driver::Connection>> {
243        let mut connection = self.connect_with_config(self.config.clone()).await?;
244        connection.query_log = cx.query_log;
245        Ok(Box::new(connection))
246    }
247
248    fn generate_migration(&self, schema_diff: &diff::Schema<'_>) -> Migration {
249        let statements = sql::MigrationStatement::from_diff(schema_diff, &Capability::POSTGRESQL);
250
251        let sql_strings: Vec<String> = statements
252            .iter()
253            .map(|stmt| sql::Serializer::postgresql(stmt.schema()).serialize(stmt.statement()))
254            .collect();
255
256        Migration::new_sql(sql_strings.join("\n"))
257    }
258
259    async fn reset_db(&self) -> toasty_core::Result<()> {
260        let dbname = self
261            .config
262            .get_dbname()
263            .ok_or_else(|| {
264                toasty_core::Error::invalid_connection_url("no database name configured")
265            })?
266            .to_string();
267
268        // We cannot drop a database we are currently connected to, so we need a temp database.
269        let temp_dbname = "__toasty_reset_temp";
270
271        let connect = |dbname: &str| {
272            let mut config = self.config.clone();
273            config.dbname(dbname);
274            self.connect_with_config(config)
275        };
276
277        // Step 1: Connect to the target DB and create a temp DB
278        let conn = connect(&dbname).await?;
279        conn.client
280            .execute(&format!("DROP DATABASE IF EXISTS \"{}\"", temp_dbname), &[])
281            .await
282            .map_err(classify_pg_error)?;
283        conn.client
284            .execute(&format!("CREATE DATABASE \"{}\"", temp_dbname), &[])
285            .await
286            .map_err(classify_pg_error)?;
287        drop(conn);
288
289        // Step 2: Connect to the temp DB, drop and recreate the target
290        let conn = connect(temp_dbname).await?;
291        conn.client
292            .execute(
293                "SELECT pg_terminate_backend(pid) \
294                 FROM pg_stat_activity \
295                 WHERE datname = $1 AND pid <> pg_backend_pid()",
296                &[&dbname],
297            )
298            .await
299            .map_err(classify_pg_error)?;
300        conn.client
301            .execute(&format!("DROP DATABASE IF EXISTS \"{}\"", dbname), &[])
302            .await
303            .map_err(classify_pg_error)?;
304        conn.client
305            .execute(&format!("CREATE DATABASE \"{}\"", dbname), &[])
306            .await
307            .map_err(classify_pg_error)?;
308        drop(conn);
309
310        // Step 3: Connect back to the target and clean up the temp DB
311        let conn = connect(&dbname).await?;
312        conn.client
313            .execute(&format!("DROP DATABASE IF EXISTS \"{}\"", temp_dbname), &[])
314            .await
315            .map_err(classify_pg_error)?;
316
317        Ok(())
318    }
319}
320
321/// An open connection to a PostgreSQL database.
322#[derive(Debug)]
323pub struct Connection {
324    client: Client,
325    statement_cache: StatementCache,
326    oid_cache: OidCache,
327    query_log: QueryLogConfig,
328}
329
330impl Connection {
331    /// Initialize a Toasty PostgreSQL connection using an initialized client.
332    pub fn new(client: Client) -> Self {
333        Self {
334            client,
335            statement_cache: StatementCache::new(100),
336            oid_cache: OidCache::new(),
337            query_log: QueryLogConfig::default(),
338        }
339    }
340
341    /// Connects to a PostgreSQL database using a [`postgres::Config`].
342    ///
343    /// See [`postgres::Client::configure`] for more information.
344    pub async fn connect<T>(config: Config, tls: T) -> Result<Self>
345    where
346        T: MakeTlsConnect<Socket> + 'static,
347        T::Stream: Send,
348    {
349        let (client, connection) = config.connect(tls).await.map_err(classify_pg_error)?;
350
351        tokio::spawn(async move {
352            if let Err(e) = connection.await {
353                eprintln!("connection error: {e}");
354            }
355        });
356
357        Ok(Self::new(client))
358    }
359
360    async fn exec_sql(
361        &mut self,
362        sql_as_str: &str,
363        typed_params: Vec<TypedValue>,
364        ret: SqlReturn,
365    ) -> Result<ExecResponse> {
366        let mut log = QueryLog::sql(
367            &self.query_log,
368            "postgresql",
369            sql_as_str,
370            typed_params.iter().map(|tv| &tv.value),
371        );
372        let result = self
373            .exec_sql_inner(sql_as_str, typed_params, ret, &mut log)
374            .await;
375        log.finish(&result);
376        result
377    }
378
379    async fn exec_sql_inner(
380        &mut self,
381        sql_as_str: &str,
382        typed_params: Vec<TypedValue>,
383        ret: SqlReturn,
384        log: &mut QueryLog<'_>,
385    ) -> Result<ExecResponse> {
386        self.oid_cache
387            .preload(&self.client, typed_params.iter().map(|tv| &tv.ty))
388            .await?;
389        let param_types: Vec<_> = typed_params
390            .iter()
391            .map(|tv| self.oid_cache.get(&tv.ty).clone())
392            .collect();
393
394        let values: Vec<_> = typed_params
395            .into_iter()
396            .map(|tv| Value::from(tv.value))
397            .collect();
398        let params = values
399            .iter()
400            .map(|param| param as &(dyn ToSql + Sync))
401            .collect::<Vec<_>>();
402
403        let statement = self
404            .statement_cache
405            .prepare_typed(&mut self.client, sql_as_str, &param_types)
406            .await
407            .map_err(classify_pg_error)?;
408
409        if matches!(ret, SqlReturn::Count) {
410            let count = self
411                .client
412                .execute(&statement, &params)
413                .await
414                .map_err(classify_pg_error)?;
415            return Ok(ExecResponse::count(count));
416        }
417
418        let rows = self
419            .client
420            .query(&statement, &params)
421            .await
422            .map_err(classify_pg_error)?;
423
424        log.rows(rows.len() as u64);
425
426        let results = rows.into_iter().map(move |row| {
427            let mut results = Vec::new();
428
429            match &ret {
430                SqlReturn::Count => unreachable!(),
431                SqlReturn::Infer => {
432                    for (i, column) in row.columns().iter().enumerate() {
433                        results.push(Value::from_sql_infer(i, &row, column).into_inner());
434                    }
435                }
436                SqlReturn::Types(ret_tys) => {
437                    for (i, column) in row.columns().iter().enumerate() {
438                        results.push(Value::from_sql(i, &row, column, &ret_tys[i]).into_inner());
439                    }
440                }
441            }
442
443            Ok(ValueRecord::from_vec(results))
444        });
445
446        Ok(ExecResponse::value_stream(stmt::ValueStream::from_iter(
447            results,
448        )))
449    }
450
451    /// Creates a table.
452    pub async fn create_table(&mut self, schema: &db::Schema, table: &Table) -> Result<()> {
453        let serializer = sql::Serializer::postgresql(schema);
454
455        let sql = serializer.serialize(&sql::Statement::create_table(
456            table,
457            &Capability::POSTGRESQL,
458        ));
459
460        self.client
461            .execute(&sql, &[])
462            .await
463            .map_err(classify_pg_error)?;
464
465        for index in &table.indices {
466            if index.primary_key {
467                continue;
468            }
469
470            let sql = serializer.serialize(&sql::Statement::create_index(index));
471
472            self.client
473                .execute(&sql, &[])
474                .await
475                .map_err(classify_pg_error)?;
476        }
477
478        Ok(())
479    }
480}
481
482impl From<Client> for Connection {
483    fn from(client: Client) -> Self {
484        Self::new(client)
485    }
486}
487
488#[async_trait]
489impl toasty_core::driver::Connection for Connection {
490    async fn exec(&mut self, schema: &Arc<Schema>, op: Operation) -> Result<ExecResponse> {
491        tracing::trace!(driver = "postgresql", op = %op.name(), "driver exec");
492
493        if let Operation::Transaction(ref t) = op {
494            // PostgreSQL has no `BEGIN IMMEDIATE` / `BEGIN EXCLUSIVE`
495            // analogue; reject non-Default modes loudly rather than
496            // silently dropping them at the serializer.
497            if let Transaction::Start {
498                mode: mode @ (TransactionMode::Immediate | TransactionMode::Exclusive),
499                ..
500            } = t
501            {
502                return Err(toasty_core::Error::unsupported_feature(format!(
503                    "PostgreSQL does not support TransactionMode::{mode:?}"
504                )));
505            }
506            let sql = sql::Serializer::postgresql(&schema.db).serialize_transaction(t);
507            self.client
508                .batch_execute(&sql)
509                .await
510                .map_err(classify_pg_error)?;
511            return Ok(ExecResponse::count(0));
512        }
513
514        let (sql, typed_params, ret_tys) = match op {
515            Operation::Insert(op) => (sql::Statement::from(op.stmt), op.params, None),
516            Operation::QuerySql(query) => {
517                assert!(
518                    query.last_insert_id_hack.is_none(),
519                    "last_insert_id_hack is MySQL-specific and should not be set for PostgreSQL"
520                );
521                (sql::Statement::from(query.stmt), query.params, query.ret)
522            }
523            Operation::RawSql(op) => {
524                let ret = match op.ret {
525                    RawSqlRet::None => SqlReturn::Count,
526                    RawSqlRet::Infer => SqlReturn::Infer,
527                    RawSqlRet::Types(types) => SqlReturn::Types(types),
528                };
529                return self.exec_sql(&op.sql, op.params, ret).await;
530            }
531            op => todo!("op={:#?}", op),
532        };
533
534        let sql_as_str = sql::Serializer::postgresql(&schema.db).serialize(&sql);
535
536        let ret = if sql.returning_len().is_some() {
537            SqlReturn::Types(ret_tys.unwrap())
538        } else {
539            SqlReturn::Count
540        };
541
542        self.exec_sql(&sql_as_str, typed_params, ret).await
543    }
544
545    async fn push_schema(&mut self, schema: &Schema) -> Result<()> {
546        let serializer = sql::Serializer::postgresql(&schema.db);
547
548        // Create PostgreSQL enum types before creating tables.
549        // Collect unique enum types across all columns.
550        let mut created_enum_types = hashbrown::HashSet::new();
551        for table in &schema.db.tables {
552            for column in &table.columns {
553                if let toasty_core::schema::db::Type::Enum(type_enum) = &column.storage_ty
554                    && created_enum_types.insert(type_enum.name.clone())
555                {
556                    let sql = serializer.serialize(&sql::Statement::create_enum_type(type_enum));
557
558                    tracing::debug!(enum_type = ?type_enum.name, "creating enum type");
559                    self.client
560                        .execute(&sql, &[])
561                        .await
562                        .map_err(classify_pg_error)?;
563                }
564            }
565        }
566
567        for table in &schema.db.tables {
568            tracing::debug!(table = %table.name, "creating table");
569            self.create_table(&schema.db, table).await?;
570        }
571        Ok(())
572    }
573
574    async fn applied_migrations(
575        &mut self,
576    ) -> Result<Vec<toasty_core::schema::db::AppliedMigration>> {
577        // Ensure the migrations table exists
578        self.client
579            .execute(
580                "CREATE TABLE IF NOT EXISTS __toasty_migrations (
581                id BIGINT PRIMARY KEY,
582                name TEXT NOT NULL,
583                applied_at TIMESTAMP NOT NULL
584            )",
585                &[],
586            )
587            .await
588            .map_err(classify_pg_error)?;
589
590        // Query all applied migrations
591        let rows = self
592            .client
593            .query(
594                "SELECT id FROM __toasty_migrations ORDER BY applied_at",
595                &[],
596            )
597            .await
598            .map_err(classify_pg_error)?;
599
600        Ok(rows
601            .iter()
602            .map(|row| {
603                let id: i64 = row.get(0);
604                toasty_core::schema::db::AppliedMigration::new(id as u64)
605            })
606            .collect())
607    }
608
609    async fn apply_migration(
610        &mut self,
611        id: u64,
612        name: &str,
613        migration: &toasty_core::schema::db::Migration,
614    ) -> Result<()> {
615        tracing::info!(id = id, name = %name, "applying migration");
616        // Ensure the migrations table exists
617        self.client
618            .execute(
619                "CREATE TABLE IF NOT EXISTS __toasty_migrations (
620                id BIGINT PRIMARY KEY,
621                name TEXT NOT NULL,
622                applied_at TIMESTAMP NOT NULL
623            )",
624                &[],
625            )
626            .await
627            .map_err(classify_pg_error)?;
628
629        // Start transaction
630        let transaction = self.client.transaction().await.map_err(classify_pg_error)?;
631
632        // Execute each migration statement
633        for statement in migration.statements() {
634            if let Err(e) = transaction
635                .batch_execute(statement)
636                .await
637                .map_err(classify_pg_error)
638            {
639                transaction.rollback().await.map_err(classify_pg_error)?;
640                return Err(e);
641            }
642        }
643
644        // Record the migration
645        if let Err(e) = transaction
646            .execute(
647                "INSERT INTO __toasty_migrations (id, name, applied_at) VALUES ($1, $2, NOW())",
648                &[&(id as i64), &name],
649            )
650            .await
651            .map_err(classify_pg_error)
652        {
653            transaction.rollback().await.map_err(classify_pg_error)?;
654            return Err(e);
655        }
656
657        // Commit transaction
658        transaction.commit().await.map_err(classify_pg_error)?;
659        Ok(())
660    }
661
662    fn is_valid(&self) -> bool {
663        !self.client.is_closed()
664    }
665
666    async fn ping(&mut self) -> Result<()> {
667        // An empty `simple_query` is the lightest sync round-trip in
668        // the PG protocol — it skips parsing entirely. Any failure is
669        // surfaced as `connection_lost`: the only meaningful outcome
670        // of a ping is "the connection is alive" or "evict it."
671        self.client
672            .simple_query("")
673            .await
674            .map(|_| ())
675            .map_err(toasty_core::Error::connection_lost)
676    }
677}
678
679#[cfg(test)]
680mod tests {
681    use super::*;
682    use tokio_postgres::config::Host;
683
684    fn cfg(url: &str) -> Config {
685        PostgreSQL::new(url).expect("valid URL").config
686    }
687
688    #[test]
689    fn host_in_url_authority() {
690        let c = cfg("postgresql://example.com/mydb");
691        assert_eq!(c.get_hosts(), &[Host::Tcp("example.com".into())]);
692        assert_eq!(c.get_dbname(), Some("mydb"));
693    }
694
695    // `Host::Unix` only exists on Unix targets in tokio-postgres, so the
696    // tests that assert socket-path resolution are gated to those
697    // platforms. Windows builds still exercise the URL-parsing path via
698    // the non-Unix tests below.
699    #[cfg(unix)]
700    mod unix_socket {
701        use super::*;
702        use std::path::PathBuf;
703
704        #[test]
705        fn unix_socket_via_host_query_param() {
706            // Regression for #984: libpq lets a Unix-socket directory be
707            // supplied via `?host=/path`, since URL syntax cannot put a
708            // filesystem path in the authority.
709            let c = cfg("postgresql:///mydb?host=/tmp&user=myuser");
710            assert_eq!(c.get_hosts(), &[Host::Unix(PathBuf::from("/tmp"))]);
711            assert_eq!(c.get_user(), Some("myuser"));
712            assert_eq!(c.get_dbname(), Some("mydb"));
713        }
714
715        #[test]
716        fn query_param_host_overrides_url_authority() {
717            // libpq semantics: a `host=` query parameter replaces (not
718            // appends to) the URL authority host. tokio-postgres's
719            // `Config::host` is additive across calls, so an authority
720            // host would otherwise be tried first and the configured
721            // socket reached only as a fallback.
722            let c = cfg("postgresql://example.com/mydb?host=/var/run/postgresql");
723            assert_eq!(
724                c.get_hosts(),
725                &[Host::Unix(PathBuf::from("/var/run/postgresql"))]
726            );
727        }
728
729        #[test]
730        fn query_param_port_user_password_dbname() {
731            let c = cfg(
732                "postgresql:///placeholder?host=/tmp&port=5433&user=alice&password=s3cret&dbname=real",
733            );
734            assert_eq!(c.get_hosts(), &[Host::Unix(PathBuf::from("/tmp"))]);
735            assert_eq!(c.get_ports(), &[5433]);
736            assert_eq!(c.get_user(), Some("alice"));
737            assert_eq!(c.get_password(), Some(&b"s3cret"[..]));
738            assert_eq!(c.get_dbname(), Some("real"));
739        }
740    }
741
742    #[test]
743    fn query_param_port_overrides_url_port() {
744        // `Config::port` is additive too, so a `port=` query parameter
745        // must replace the URL authority port for the same reason.
746        let c = cfg("postgresql://example.com:5432/mydb?port=5433");
747        assert_eq!(c.get_ports(), &[5433]);
748    }
749
750    #[test]
751    fn application_name_query_param() {
752        let c = cfg("postgresql://localhost/mydb?application_name=my_app");
753        assert_eq!(c.get_application_name(), Some("my_app"));
754    }
755
756    #[test]
757    fn missing_host_rejected() {
758        let err = PostgreSQL::new("postgresql:///mydb").unwrap_err();
759        assert!(
760            err.to_string().contains("missing host"),
761            "expected missing-host error, got: {err}"
762        );
763    }
764
765    #[test]
766    fn invalid_port_query_param_rejected() {
767        let err = PostgreSQL::new("postgresql:///mydb?host=/tmp&port=not-a-number").unwrap_err();
768        assert!(
769            err.to_string().contains("invalid port"),
770            "expected invalid-port error, got: {err}"
771        );
772    }
773}