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;