Skip to main content

fraiseql_cli/
connection.rs

1//! The one PostgreSQL connection-string rule for every CLI command.
2//!
3//! Seven call sites each decided "is this a PostgreSQL URL" with their own prefix
4//! test — `starts_with("postgres")`, or `postgres://` / `postgresql://` — and so
5//! disagreed about the libpq `key=value` form (`host=db dbname=app`) that
6//! PostgreSQL itself, `tokio-postgres` and the server all accept. The disagreement
7//! was not cosmetic: `compile --database` ran the drift check over a `key=value`
8//! string and then silently skipped the mutation-contract check, which asked the
9//! prefix question again (#1403).
10//!
11//! The rule is now: refuse an engine whose support was removed, then accept
12//! whatever `tokio_postgres::Config` parses. Error messages never echo the
13//! connection string, which may carry a password.
14
15use anyhow::{Context, Result};
16use deadpool_postgres::Pool;
17use fraiseql_db::postgres::{
18    PoolPrewarmConfig, PostgresAdapter, PostgresTlsConfig, VectorScanConfig,
19};
20
21use crate::schema::database_validator::refuse_removed_engine_url;
22
23/// Connections a CLI pool opens: the CLI reads the catalogue, it does not serve.
24const CLI_POOL_SIZE: usize = 2;
25
26/// Check that `db_url` is a PostgreSQL connection string, in URL or libpq form.
27///
28/// # Errors
29///
30/// Returns an error naming the PostgreSQL-only rule for a removed engine's URL,
31/// and one naming the two accepted forms for anything `tokio-postgres` cannot
32/// parse.
33pub fn require_postgres(db_url: &str) -> Result<()> {
34    refuse_removed_engine_url(db_url)?;
35    db_url.parse::<tokio_postgres::Config>().map_err(|e| {
36        anyhow::anyhow!(
37            "Not a PostgreSQL connection string ({e}). Pass a postgresql://… URL or a libpq \
38             `key=value` string such as `host=localhost dbname=app user=app`."
39        )
40    })?;
41    Ok(())
42}
43
44/// A small connection pool for a CLI read, after [`require_postgres`].
45///
46/// Built by `fraiseql-db`'s own pool constructor, so the CLI and the server share one
47/// TLS policy (#1429): the URL's `sslmode` applies when `tls` sets no mode, a mode in
48/// `tls` (from `[database] ssl_mode`) overrides it, and the connector negotiates TLS
49/// rather than refusing it. The first connection is opened here, and the server is
50/// refused below the supported PostgreSQL floor before the command reads anything.
51///
52/// `purpose` completes "failed to connect to PostgreSQL for …".
53///
54/// # Errors
55///
56/// Returns the [`require_postgres`] error, one naming `purpose` when the server cannot
57/// be reached or the TLS settings cannot be honoured, and the floor refusal for a server
58/// older than PostgreSQL 18.
59pub async fn postgres_pool(db_url: &str, purpose: &str, tls: &PostgresTlsConfig) -> Result<Pool> {
60    Ok(postgres_adapter(db_url, purpose, tls).await?.pool().clone())
61}
62
63/// The adapter behind [`postgres_pool`], for a command that executes through the
64/// runtime rather than reading the catalogue (`doctor --runtime`).
65///
66/// # Errors
67///
68/// As [`postgres_pool`].
69pub async fn postgres_adapter(
70    db_url: &str,
71    purpose: &str,
72    tls: &PostgresTlsConfig,
73) -> Result<PostgresAdapter> {
74    require_postgres(db_url)?;
75    PostgresAdapter::with_pool_config(
76        db_url,
77        PoolPrewarmConfig {
78            min_size:            0,
79            max_size:            CLI_POOL_SIZE,
80            timeout_secs:        None,
81            search_path:         None,
82            tls:                 tls.clone(),
83            read_replicas:       None,
84            max_streaming_reads: None,
85            vector_scan:         VectorScanConfig::default(),
86        },
87    )
88    .await
89    .map_err(anyhow::Error::new)
90    .with_context(|| {
91        format!(
92            "failed to connect to PostgreSQL for {purpose} (ssl_mode = {})",
93            tls.effective_mode()
94        )
95    })
96}
97
98#[cfg(test)]
99mod tests;