Skip to main content

fraiseql_db/postgres/adapter/
mod.rs

1//! PostgreSQL database adapter implementation.
2
3mod admin_sql;
4mod composed;
5mod database;
6mod numeric;
7mod query_stats;
8mod relay;
9mod streaming;
10
11#[cfg(test)]
12mod composed_tests;
13#[cfg(test)]
14mod tests;
15
16#[cfg(all(test, feature = "test-postgres"))]
17mod integration_tests;
18
19use std::{
20    fmt::Write,
21    sync::{
22        Arc, Weak,
23        atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
24    },
25    time::Duration,
26};
27
28use deadpool_postgres::{Config, ManagerConfig, Pool, RecyclingMethod, Runtime};
29use fraiseql_error::{FraiseQLError, Result};
30use tokio::sync::Semaphore;
31use tokio_postgres::{NoTls, Row};
32
33use super::{
34    tls::{PostgresConnector, PostgresSslMode, PostgresTlsConfig},
35    where_generator::PostgresWhereGenerator,
36};
37use crate::{
38    dialect::PostgresDialect,
39    identifier::quote_postgres_identifier,
40    order_by::{Tiebreak, append_order_by},
41    postgres::pg_detail,
42    traits::DatabaseAdapter,
43    types::{
44        DatabaseType, JsonbValue, QueryParam, ReadRouting,
45        sql_hints::{OrderByClause, SqlProjectionHint},
46    },
47    where_clause::WhereClause,
48};
49
50/// How many concurrent streaming reads this pool allows (#958).
51///
52/// The derived default is a quarter of the pool, never zero and never the whole
53/// pool: a streaming read holds its connection until the client finishes reading,
54/// so a bound at `max_size` is no bound at all — exports would evict every
55/// ordinary request from the pool. An explicit `Some(n)` is the operator's
56/// decision and is honoured as given, except that `Some(0)` would disable
57/// streaming reads *silently* (they would wait forever on a permit that can
58/// never be issued), so it is raised to 1.
59const fn streaming_read_slots(configured: Option<usize>, max_size: usize) -> usize {
60    let requested = match configured {
61        Some(n) => n,
62        None => max_size / 4,
63    };
64    if requested == 0 { 1 } else { requested }
65}
66
67/// Extract the JSONB `data` cell from a result row, failing loud rather than
68/// panicking.
69///
70/// `Row::get` panics on SQL NULL or a non-JSONB column type, so a backing view
71/// that projects NULL `data` (e.g. via a LEFT JOIN) or a mistyped `data` column
72/// turned a query into a request-path panic — the PostgreSQL adapter was the
73/// only backend that aborted here instead of returning an error (audit H34).
74/// Both the NULL and the type-mismatch cases now map to
75/// [`FraiseQLError::Database`], including a bounded, char-safe slice of the
76/// query so an operator can identify the offending view.
77fn jsonb_cell<I>(row: &Row, column: I, sql: &str) -> Result<JsonbValue>
78where
79    I: tokio_postgres::row::RowIndex + std::fmt::Display,
80{
81    // `.chars().take(..)` is inherently char-boundary-safe (no byte slicing).
82    let query_preview = || sql.chars().take(200).collect::<String>();
83    match row.try_get::<_, Option<serde_json::Value>>(column) {
84        Ok(Some(value)) => Ok(JsonbValue::new(value)),
85        Ok(None) => Err(FraiseQLError::Database {
86            message:   format!(
87                "Query returned a NULL `data` column; the backing view must project a \
88                 non-NULL JSONB `data` value (a view yielding NULL `data`, e.g. via a \
89                 LEFT JOIN, is unsupported). Query: {}",
90                query_preview()
91            ),
92            sql_state: None,
93        }),
94        Err(e) => Err(FraiseQLError::Database {
95            message:   format!(
96                "Failed to read the `data` column as JSONB ({e}); the backing view must \
97                 project a JSONB `data` column. Query: {}",
98                query_preview()
99            ),
100            sql_state: None,
101        }),
102    }
103}
104
105/// Default maximum pool size for PostgreSQL connections.
106/// Increased from 10 to 25 to prevent pool exhaustion under concurrent
107/// nested query load (fixes Issue #41).
108const DEFAULT_POOL_SIZE: usize = 25;
109
110/// Maximum retries for connection acquisition with exponential backoff.
111const MAX_CONNECTION_RETRIES: u32 = 3;
112
113/// Base delay in milliseconds for connection retry backoff.
114const CONNECTION_RETRY_DELAY_MS: u64 = 50;
115
116/// Configuration for connection pool construction and pre-warming.
117///
118/// Controls the minimum guaranteed connections (pre-warmed at startup),
119/// the maximum pool ceiling, and the wait/create timeout for connection
120/// acquisition.
121///
122/// # Example
123///
124/// ```rust
125/// use fraiseql_db::postgres::{PoolPrewarmConfig, PostgresTlsConfig, VectorScanConfig};
126///
127/// let cfg = PoolPrewarmConfig {
128///     min_size:      5,
129///     max_size:      20,
130///     timeout_secs:  Some(30),
131///     search_path:   None,
132///     // Mandatory: every pool site must state its transport security (#801/#824).
133///     tls:           PostgresTlsConfig::default(),
134///     // Mandatory: every pool site must state its replica topology (#407).
135///     read_replicas: None,
136///     // `None` derives a quarter of `max_size` (#958).
137///     max_streaming_reads: None,
138///     // Continue a filtered ANN scan until `k` rows survive (#1116).
139///     vector_scan: VectorScanConfig::default(),
140/// };
141/// ```
142#[derive(Debug, Clone)]
143pub struct PoolPrewarmConfig {
144    /// Number of connections to establish at pool creation time.
145    ///
146    /// After the pool is created, `min_size` connections are opened eagerly
147    /// so they are ready when the first request arrives. Set to `0` to disable
148    /// pre-warming (lazy init — one connection from the startup health check).
149    pub min_size: usize,
150
151    /// Maximum number of connections the pool may hold.
152    pub max_size: usize,
153
154    /// Optional timeout (in seconds) for connection acquisition and creation.
155    ///
156    /// Applied to both the `wait` (blocked waiting for an idle connection) and
157    /// `create` (time to open a new TCP connection to PostgreSQL) deadpool slots.
158    /// When `None`, acquisition can block indefinitely on pool exhaustion.
159    pub timeout_secs: Option<u64>,
160
161    /// Schema search path every connection in this pool must resolve unqualified
162    /// relations against. `None` leaves the server default in force.
163    ///
164    /// This is a **pool** property, not a session one: it is lowered into the
165    /// PostgreSQL startup `options` parameter, so the server applies it while
166    /// establishing each connection — including connections deadpool opens later
167    /// to grow the pool, and replacements it creates after a backend dies. See
168    /// [`SearchPath`] for why the session-`SET` alternative is unsound.
169    pub search_path: Option<SearchPath>,
170
171    /// Transport security for every connection this pool opens.
172    ///
173    /// Mandatory, and deliberately not `Option`: the pool used to be built with
174    /// `NoTls` unconditionally while three separate configuration surfaces claimed
175    /// to control database TLS, so each caller must now state what it wants and a
176    /// new pool site cannot compile without deciding (#801, #824). Callers with no
177    /// opinion pass [`PostgresTlsConfig::default`] (libpq's `prefer`).
178    pub tls: PostgresTlsConfig,
179
180    /// How many of this pool's connections may be held by streaming reads at once
181    /// (#958). `None` derives a quarter of [`max_size`](Self::max_size), at least 1.
182    ///
183    /// A streamed read holds its connection for the whole life of the stream — for
184    /// an export, that is however long the client takes to consume a table, not
185    /// however long a query takes to run. Without a bound below `max_size`,
186    /// concurrent exports can occupy every connection in the pool and ordinary
187    /// requests then fail on the acquisition timeout: a feature for large reads
188    /// would take the server down by being used.
189    ///
190    /// The bound is *not* a queue-length limit. A streaming read past the bound
191    /// waits for a slot, exactly as any read waits for a connection.
192    pub max_streaming_reads: Option<usize>,
193
194    /// pgvector scan behaviour for every connection this pool opens (#1116).
195    ///
196    /// Defaults, via [`VectorScanConfig`], to continuing an index scan until `k`
197    /// rows survive a filter. pgvector's own default stops at one bounded
198    /// candidate list, which makes a filtered similarity search return fewer
199    /// rows than asked for **and report success** — the reason this is not left
200    /// to the server's setting.
201    ///
202    /// Like [`search_path`](Self::search_path) it is lowered into the startup
203    /// packet, so it reaches every connection the pool opens later, and it is
204    /// inert on a database without pgvector.
205    pub vector_scan: VectorScanConfig,
206
207    /// Read replicas for this pool set, or `None` for a single-primary adapter.
208    ///
209    /// Like [`tls`](Self::tls), this is a mandatory decision at every pool site:
210    /// replica pools are built **from this same config** — the same
211    /// [`search_path`](Self::search_path), the same [`tls`](Self::tls), the same
212    /// sizing — so tenant isolation and transport security cannot silently differ
213    /// between the primary and a replica (#809 generalised: isolation is a
214    /// property of how *every* pool's connections are made). See
215    /// [`ReadReplicaConfig`] for the routing and consistency rules (#407).
216    pub read_replicas: Option<ReadReplicaConfig>,
217}
218
219/// Read-replica configuration for a PostgreSQL adapter (#407).
220///
221/// # Routing model
222///
223/// FraiseQL's read/write partition is **static**: compiled GraphQL queries execute
224/// through the adapter's structurally read-only methods (`execute_where_query*`,
225/// `execute_with_projection*`, `execute_parameterized_aggregate*`, `explain_*`,
226/// relay pagination), and those route to a replica selected round-robin. The
227/// mutation pipeline (`execute_write`) and every mixed-use or
228/// administrative surface (`execute_raw_query`, `execute_row_query`, query stats,
229/// health checks, schema DDL) always run on the primary — a surface that *can*
230/// write is never routed to a replica.
231///
232/// # Read-your-writes
233///
234/// Every mutation-pipeline write arms a shared watermark; for
235/// [`pin_after_write`](Self::pin_after_write) afterwards, reads route to the
236/// primary so replication lag cannot serve a client its own stale write. The
237/// window is an operator assertion about worst-case replica lag: writes that
238/// bypass the mutation pipeline (raw SQL, out-of-band jobs) do not arm it, and a
239/// replica lagging beyond the window can serve stale — but never torn — rows.
240///
241/// # Failure behaviour
242///
243/// A replica that is unreachable at boot **refuses to boot** (a configured-but-
244/// unusable pool must not downgrade silently). A replica that fails at runtime is
245/// skipped for that acquisition — the next replica is tried, then the primary —
246/// so replica loss degrades read capacity, never read availability.
247/// # Bounded staleness
248///
249/// [`max_lag`](Self::max_lag) turns the pin window's *assertion* into a
250/// *measurement*: a replica is only eligible while its last probed replay lag,
251/// aged by how long ago that probe ran, is still inside the budget.
252///
253/// That sum is a true upper bound rather than an estimate — replay lag grows at
254/// most one millisecond per millisecond of wall clock, so however stale the probe
255/// is, the lag cannot have grown by more than its age — and it makes the gate
256/// self-closing: an unrefreshed probe ages past any budget on its own. A replica
257/// whose lag cannot be *measured* at all is never eligible; unknown staleness is
258/// not zero staleness.
259#[derive(Debug, Clone)]
260pub struct ReadReplicaConfig {
261    /// Connection URLs of the read replicas, tried round-robin. Must be non-empty.
262    pub urls: Vec<String>,
263
264    /// How long after a mutation-pipeline write reads keep routing to the primary.
265    ///
266    /// Must be at least the worst replication lag the operator is prepared to
267    /// tolerate; within it, a client is guaranteed to read its own writes.
268    pub pin_after_write: Duration,
269
270    /// Bounded staleness: the most replication lag a replica-served read may
271    /// carry. `None` disables lag-based routing entirely — replicas are then
272    /// selected round-robin regardless of how far behind they are, which is the
273    /// behaviour before this option existed.
274    ///
275    /// A replica whose lag cannot be measured — never probed, last probe failed,
276    /// no transaction replayed yet, or promoted out of recovery by a failover —
277    /// is **not** eligible while this is set. Unknown staleness is not "zero
278    /// staleness", and the primary is always a correct answer.
279    ///
280    /// Must be strictly greater than [`health_probe_interval`](Self::health_probe_interval):
281    /// eligibility ages the last probe, so a budget no larger than the probe
282    /// period would make even a perfectly caught-up replica ineligible for part
283    /// of every cycle.
284    pub max_lag: Option<Duration>,
285
286    /// How often each replica is probed for recovery state and replay lag.
287    ///
288    /// Probing is unconditional when replicas are configured: the boot health
289    /// check runs once, and a replica promoted by a failover after boot is a
290    /// writable server that a `pg_is_in_recovery()` reading taken at startup can
291    /// never notice.
292    pub health_probe_interval: Duration,
293}
294
295/// [`ReadReplicaConfig`] without the URLs: how replica routing *behaves*,
296/// separated from *where* the replicas are (#957).
297///
298/// The split exists because the two halves have different owners once tenant
299/// pools carry replicas. A tenant registration names its own replica URLs, the
300/// same way it names its own primary connection string — but the pin window, the
301/// staleness budget and the probe cadence are the operator's policy, stamped
302/// onto every tenant pool by `make_executor_factory`, exactly as `[database_tls]`
303/// is (#801). A tenant that could send its own `max_lag` in a registration body
304/// would be choosing how stale *its* reads may be, against a server whose
305/// operator already decided.
306///
307/// [`Default`] carries the shipped defaults — a 5 s pin, no staleness budget, a
308/// 1 s probe cadence — so they live in one place rather than once per binary.
309#[derive(Debug, Clone)]
310pub struct ReadReplicaPolicy {
311    /// See [`ReadReplicaConfig::pin_after_write`].
312    pub pin_after_write:       Duration,
313    /// See [`ReadReplicaConfig::max_lag`].
314    pub max_lag:               Option<Duration>,
315    /// See [`ReadReplicaConfig::health_probe_interval`].
316    pub health_probe_interval: Duration,
317}
318
319impl Default for ReadReplicaPolicy {
320    fn default() -> Self {
321        Self {
322            pin_after_write:       Duration::from_secs(5),
323            max_lag:               None,
324            health_probe_interval: Duration::from_secs(1),
325        }
326    }
327}
328
329impl ReadReplicaPolicy {
330    /// Apply this policy to `urls`, or `None` when there are no replicas.
331    ///
332    /// Returning `None` for an empty list is what keeps a pool site that has no
333    /// replicas from constructing the one shape
334    /// [`ReadReplicaConfig`](ReadReplicaConfig) refuses to build.
335    #[must_use]
336    pub fn with_urls(&self, urls: Vec<String>) -> Option<ReadReplicaConfig> {
337        if urls.is_empty() {
338            return None;
339        }
340        Some(ReadReplicaConfig {
341            urls,
342            pin_after_write: self.pin_after_write,
343            max_lag: self.max_lag,
344            health_probe_interval: self.health_probe_interval,
345        })
346    }
347}
348
349/// A validated PostgreSQL `search_path`, applied at connection establishment.
350///
351/// # Why this is a type and not a `SET` statement
352///
353/// `SET search_path` is *session*-scoped, and a connection pool is N independent
354/// sessions. Issuing the `SET` through a pooled query configures whichever single
355/// connection was checked out and leaves every other connection — including ones
356/// the pool opens later, and every replacement created after a backend
357/// disconnects — on the server default. Schema-per-tenant isolation built that way
358/// resolves most queries against `public`: silently wrong rows where `public`
359/// shadows the relation, an intermittent `relation ... does not exist` where it does
360/// not (#809).
361///
362/// Lowering the path into the startup `options` parameter instead makes it a
363/// property of *how connections are made*, so it cannot be missed by a connection
364/// and cannot be lost to recycling — `RESET`/`DISCARD ALL` restore the startup
365/// value, not the server default.
366///
367/// # Example
368///
369/// ```rust
370/// use fraiseql_db::postgres::SearchPath;
371///
372/// let path = SearchPath::new(["tenant_acme", "public"])?;
373/// assert_eq!(path.as_str(), "tenant_acme,public");
374///
375/// // Anything that is not a bare identifier is refused rather than escaped.
376/// assert!(SearchPath::new(["public; DROP SCHEMA x"]).is_err());
377/// # Ok::<(), fraiseql_error::FraiseQLError>(())
378/// ```
379#[derive(Debug, Clone, PartialEq, Eq)]
380pub struct SearchPath(String);
381
382impl SearchPath {
383    /// Build a search path from schema names, in resolution order.
384    ///
385    /// # Errors
386    ///
387    /// Returns [`FraiseQLError::Validation`] if the list is empty or any entry is
388    /// not a bare, unquoted PostgreSQL identifier (`[A-Za-z_][A-Za-z0-9_]*`, at most
389    /// 63 characters). Rejecting rather than quoting keeps the emitted startup
390    /// option free of characters that could terminate it.
391    pub fn new<I, S>(schemas: I) -> Result<Self>
392    where
393        I: IntoIterator<Item = S>,
394        S: AsRef<str>,
395    {
396        let mut parts: Vec<String> = Vec::new();
397        for schema in schemas {
398            let name = schema.as_ref();
399            let mut chars = name.chars();
400            let valid = match chars.next() {
401                Some(c) if c.is_ascii_alphabetic() || c == '_' => {
402                    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
403                },
404                _ => false,
405            };
406            if !valid || name.len() > MAX_PG_IDENTIFIER_LEN {
407                return Err(FraiseQLError::validation(format!(
408                    "Schema name '{name}' is not a bare PostgreSQL identifier and cannot be \
409                     used in a search path"
410                )));
411            }
412            parts.push(name.to_string());
413        }
414        if parts.is_empty() {
415            return Err(FraiseQLError::validation("A search path needs at least one schema"));
416        }
417        Ok(Self(parts.join(",")))
418    }
419
420    /// The comma-separated path, as it is written into `-c search_path=…`.
421    #[must_use]
422    pub fn as_str(&self) -> &str {
423        &self.0
424    }
425}
426
427impl std::fmt::Display for SearchPath {
428    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
429        f.write_str(&self.0)
430    }
431}
432
433/// Maximum length of a PostgreSQL identifier.
434const MAX_PG_IDENTIFIER_LEN: usize = 63;
435
436/// How pgvector's HNSW index behaves when a filter eliminates the candidates it
437/// handed up (#1116).
438///
439/// With pgvector's own default — `off` — an HNSW scan produces one bounded
440/// candidate list and the filter is applied to it. Once the filter is selective
441/// the list is exhausted before `k` survivors exist, and the query **succeeds**
442/// with fewer rows: measured at 100 000 documents, `k = 10` and a 1%-selective
443/// filter, three rows and a recall of 0.20, with nothing to distinguish that
444/// from "only three matched". That is why FraiseQL names a value here instead
445/// of inheriting the server's.
446///
447/// ⚠ An invalid value is **not** an error. pgvector's GUC is defined when its
448/// library loads, after the startup packet has been accepted, so a bad value
449/// produces `WARNING: invalid value for parameter "hnsw.iterative_scan"` and
450/// silently falls back to `off` — the failure this setting exists to prevent.
451/// Being an enum is what keeps an unrepresentable value from reaching the wire.
452#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
453#[serde(rename_all = "snake_case")]
454pub enum HnswIterativeScan {
455    /// pgvector's own default: one bounded candidate list, no continuation.
456    /// Filtered searches may silently return fewer than `k` rows.
457    Off,
458    /// Continue scanning until `k` rows survive the filter, without buffering
459    /// for exact ordering. Results may be slightly out of distance order.
460    RelaxedOrder,
461    /// Continue scanning until `k` rows survive, preserving exact distance
462    /// order.
463    ///
464    /// The default, and the reason is the contract rather than the benchmark:
465    /// `nearest` is documented to return rows most-similar-first, and a plan
466    /// whose ordering comes from the index scan can only honour that if the
467    /// scan is ordered. `relaxed_order` would trade #1116's silent
468    /// under-return for a quieter version of the same thing — a result that
469    /// looks right and is subtly not.
470    ///
471    /// The benchmark in `benches/vector_filtered_ann.sql` found the two
472    /// indistinguishable in both recall and latency, so the guarantee is the
473    /// only thing separating them there. An operator whose corpus disagrees can
474    /// say so.
475    #[default]
476    StrictOrder,
477}
478
479/// The same setting for pgvector's `IVFFlat` index (#1116).
480///
481/// A separate type from [`HnswIterativeScan`] because pgvector accepts a
482/// **different set of values** here — measured against pgvector 0.8.6:
483///
484/// ```text
485/// WARNING:  invalid value for parameter "ivfflat.iterative_scan": "strict_order"
486/// HINT:  Available values: off, relaxed_order.
487/// ```
488///
489/// and the setting then falls back to `off`. One shared enum with a clamp would
490/// turn the operator's choice into a value the server discards, which is the
491/// shape of the defect being fixed. Here it is simply not expressible.
492#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
493#[serde(rename_all = "snake_case")]
494pub enum IvfflatIterativeScan {
495    /// pgvector's own default; see [`HnswIterativeScan::Off`].
496    Off,
497    /// Continue scanning until `k` rows survive the filter. The only
498    /// continuation mode `IVFFlat` defines.
499    #[default]
500    RelaxedOrder,
501}
502
503/// pgvector scan settings applied to every connection a pool opens (#1116).
504///
505/// Lowered into the PostgreSQL startup `options` packet for the same reason
506/// [`SearchPath`] is: a pooled `SET` configures one session out of N, and misses
507/// every connection the pool opens later. Riding the startup packet costs no
508/// round trip and survives `DISCARD ALL`.
509///
510/// ⚠ Safe on a database **without** pgvector. A two-part GUC name is accepted as
511/// a placeholder when no extension has claimed it, so the connection succeeds
512/// and the value simply never takes effect — verified against a database with
513/// the extension absent. The same mechanism covers a pgvector older than 0.8,
514/// which defines neither GUC.
515#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
516pub struct VectorScanConfig {
517    /// `hnsw.iterative_scan`.
518    pub hnsw:      HnswIterativeScan,
519    /// `ivfflat.iterative_scan`.
520    pub ivfflat:   IvfflatIterativeScan,
521    /// `hnsw.ef_search`, or `None` to leave pgvector's default of 40.
522    ///
523    /// ⚠ **`iterative_scan` alone does not guarantee `k` rows, and on some
524    /// corpora it is not even the binding constraint.** Measured on this
525    /// repository's rig — 20 000 rows × 64 dimensions of uniform-random vectors,
526    /// a filter matching 1%, `k = 10`, three independently generated datasets:
527    ///
528    /// | configuration | rows returned |
529    /// |---|---|
530    /// | `iterative_scan=off` (pgvector's default) | 2, 3, 4 |
531    /// | `iterative_scan=strict_order`, other GUCs default | 10, 5, 4 |
532    /// | …plus `max_scan_tuples` raised 100× | 10, 5, 4 — **no change** |
533    /// | …plus `ef_search=1000` instead | **10, 10, 10** |
534    /// | `iterative_scan=off` **and** `ef_search=1000` | **10, 10, 10** |
535    ///
536    /// So `ef_search` — the size of the candidate list the graph search keeps —
537    /// is what decides whether ten members of a 1% subset are found at all, and
538    /// `max_scan_tuples` (which #1314 was filed against) changed nothing.
539    /// Uniform-random high-dimensional vectors are the adversarial case, and the
540    /// benchmark in `benches/vector_filtered_ann.sql` reports recall 1.00 from
541    /// `iterative_scan` alone on its own corpus — which is the point: **which
542    /// knob binds depends on the data**, so both have to be reachable.
543    ///
544    /// Left unset by default deliberately. `iterative_scan` costs nothing on an
545    /// unfiltered search (measured: recall already 1.000, timings tie), so
546    /// naming it is free. `ef_search` is a latency/recall trade paid by **every**
547    /// search on the deployment, filtered or not, and its right value is a
548    /// property of the corpus. FraiseQL will not choose it for an operator; it
549    /// makes it reachable and says, above, how to tell that it is the one to
550    /// reach for.
551    pub ef_search: Option<u32>,
552}
553
554impl HnswIterativeScan {
555    /// The value as pgvector spells it.
556    #[must_use]
557    pub const fn as_guc_value(self) -> &'static str {
558        match self {
559            Self::Off => "off",
560            Self::RelaxedOrder => "relaxed_order",
561            Self::StrictOrder => "strict_order",
562        }
563    }
564}
565
566impl IvfflatIterativeScan {
567    /// The value as pgvector spells it.
568    #[must_use]
569    pub const fn as_guc_value(self) -> &'static str {
570        match self {
571            Self::Off => "off",
572            Self::RelaxedOrder => "relaxed_order",
573        }
574    }
575}
576
577impl VectorScanConfig {
578    /// The `-c` fragments these settings contribute to the startup packet.
579    ///
580    /// Empty when both are [`Off`](HnswIterativeScan::Off) — an operator who
581    /// turned the feature off gets pgvector's own default rather than an
582    /// explicit `off`, so the server's or the database's own `ALTER DATABASE`
583    /// setting still applies. Writing `-c hnsw.iterative_scan=off` would
584    /// *override* such a setting, which is not what "leave it alone" means.
585    #[must_use]
586    pub fn startup_fragments(self) -> Vec<String> {
587        let mut out = Vec::new();
588        if self.hnsw != HnswIterativeScan::Off {
589            out.push(format!("-c hnsw.iterative_scan={}", self.hnsw.as_guc_value()));
590        }
591        if self.ivfflat != IvfflatIterativeScan::Off {
592            out.push(format!("-c ivfflat.iterative_scan={}", self.ivfflat.as_guc_value()));
593        }
594        if let Some(ef) = self.ef_search {
595            out.push(format!("-c hnsw.ef_search={ef}"));
596        }
597        out
598    }
599}
600
601/// Compose the PostgreSQL startup `options` string for a pool.
602///
603/// Appends this pool's `-c` settings to whatever `options` the operator already put
604/// in the connection string, rather than replacing them: deadpool's struct-level
605/// `options` field overrides the URL-parsed value outright, so building this by
606/// assignment alone would silently discard an operator's own `-c` settings.
607///
608/// Returns `None` when the pool contributes nothing, so the caller can leave
609/// `cfg.options` unset and let deadpool use the URL's own value — assigning the
610/// URL's options back to the struct field would be a no-op today and a trap the
611/// first time the two are read differently.
612fn compose_startup_options(
613    connection_string: &str,
614    search_path: Option<&SearchPath>,
615    vector_scan: VectorScanConfig,
616) -> Option<String> {
617    let mut fragments: Vec<String> = Vec::new();
618    if let Some(path) = search_path {
619        fragments.push(format!("-c search_path={path}"));
620    }
621    // Both settings ride the same packet as the search path. Vector scan
622    // behaviour is a property of connection establishment for exactly the reason
623    // schema isolation is (#1116, #809): a pooled `SET` reaches one session of N.
624    fragments.extend(vector_scan.startup_fragments());
625    if fragments.is_empty() {
626        return None;
627    }
628
629    let existing = connection_string
630        .parse::<tokio_postgres::Config>()
631        .ok()
632        .and_then(|c| c.get_options().map(str::to_owned))
633        .unwrap_or_default();
634    let existing = existing.trim();
635    if existing.is_empty() {
636        Some(fragments.join(" "))
637    } else {
638        Some(format!("{existing} {}", fragments.join(" ")))
639    }
640}
641
642/// Build a `deadpool-postgres` pool with an optional wait/create timeout.
643///
644/// # Errors
645///
646/// Returns `FraiseQLError::ConnectionPool` if pool creation fails (e.g., unparseable URL).
647fn build_pool(
648    connection_string: &str,
649    max_size: usize,
650    timeout_secs: Option<u64>,
651    search_path: Option<&SearchPath>,
652    vector_scan: VectorScanConfig,
653    tls: &PostgresTlsConfig,
654) -> Result<Pool> {
655    let mut cfg = Config::new();
656    cfg.url = Some(connection_string.to_string());
657    cfg.manager = Some(ManagerConfig {
658        recycling_method: RecyclingMethod::Fast,
659    });
660
661    // Schema isolation is a property of connection *establishment*, so it rides the
662    // startup packet and applies to every connection this pool ever opens — see
663    // `SearchPath` for why a pooled `SET` is unsound (#809). `VectorScanConfig`
664    // rides the same packet for the same reason (#1116).
665    cfg.options = compose_startup_options(connection_string, search_path, vector_scan);
666
667    let mut pool_cfg = deadpool_postgres::PoolConfig::new(max_size);
668    if let Some(secs) = timeout_secs {
669        let t = Duration::from_secs(secs);
670        pool_cfg.timeouts.wait = Some(t);
671        pool_cfg.timeouts.create = Some(t);
672        // `recycle` intentionally stays None — fast recycle, not user-configurable.
673    }
674    cfg.pool = Some(pool_cfg);
675
676    // Transport security is a property of the connection, so it is decided here, at
677    // the one place connections are made, rather than by a log line elsewhere (#801).
678    //
679    // `ssl_mode` is applied by deadpool *after* it parses `cfg.url`, so an explicit
680    // setting overrides a `?sslmode=` in the URL while leaving the URL authoritative
681    // when the caller expressed no preference. Both routes now reach a connector that
682    // can actually negotiate TLS; previously the hard-coded `NoTls` made the driver's
683    // own sslmode handling unreachable no matter which surface set it.
684    // Only when the operator actually chose one: deadpool applies `ssl_mode` after
685    // parsing the URL, so setting it unconditionally would override an explicit
686    // `?sslmode=require` with this struct's default and silently downgrade it. The
687    // URL form is the one surface that already refused a plaintext server, so it
688    // must keep outranking a default nobody wrote.
689    cfg.ssl_mode = tls.mode.map(|mode| match mode {
690        PostgresSslMode::Disable => deadpool_postgres::SslMode::Disable,
691        PostgresSslMode::Prefer => deadpool_postgres::SslMode::Prefer,
692        PostgresSslMode::Require | PostgresSslMode::VerifyFull => {
693            deadpool_postgres::SslMode::Require
694        },
695    });
696
697    let pool = match tls.connector()? {
698        PostgresConnector::Plaintext => cfg.create_pool(Some(Runtime::Tokio1), NoTls),
699        PostgresConnector::Tls(connector) => cfg.create_pool(Some(Runtime::Tokio1), *connector),
700    };
701
702    pool.map_err(|e| FraiseQLError::ConnectionPool {
703        message: format!("Failed to create connection pool: {e}"),
704    })
705}
706
707/// Build and boot-verify the replica pool set (#407).
708///
709/// Each replica pool inherits the primary's `search_path`, TLS, sizing and
710/// timeouts from `cfg`. Every replica is health-checked at boot; an unreachable
711/// replica refuses to boot rather than silently shrinking the read fleet, and a
712/// reachable server that is not in recovery (not actually a standby) is allowed
713/// but loudly logged — dev rigs legitimately stand in a plain database for a
714/// replica, but in production that warning means reads may diverge from the
715/// primary in both directions.
716///
717/// # Errors
718///
719/// Returns [`FraiseQLError::Validation`] when `rc.urls` is empty, and
720/// [`FraiseQLError::ConnectionPool`] / [`FraiseQLError::Database`] when a replica
721/// pool cannot be created or its boot health check fails.
722async fn build_read_replica_set(
723    rc: &ReadReplicaConfig,
724    cfg: &PoolPrewarmConfig,
725) -> Result<ReadReplicaSet> {
726    if rc.urls.is_empty() {
727        return Err(FraiseQLError::validation(
728            "read_replicas was configured with an empty URL list; remove the configuration \
729             or list at least one replica",
730        ));
731    }
732    if rc.health_probe_interval.is_zero() {
733        return Err(FraiseQLError::validation(
734            "read replica health_probe_interval is zero; probing every replica in a tight \
735             loop is never intended. Set an interval, or remove the replica configuration.",
736        ));
737    }
738    if rc.max_lag.is_some_and(|max_lag| max_lag <= rc.health_probe_interval) {
739        return Err(FraiseQLError::validation(format!(
740            "read replica max_lag ({} ms) must be greater than health_probe_interval ({} ms): \
741             a replica's eligibility ages its last probe, so a staleness budget no larger than \
742             the probe period would drop even a fully caught-up replica out of rotation for \
743             part of every cycle. Lower the probe interval or raise the budget.",
744            rc.max_lag.unwrap_or_default().as_millis(),
745            rc.health_probe_interval.as_millis(),
746        )));
747    }
748
749    let mut pools = Vec::with_capacity(rc.urls.len());
750    let mut health = Vec::with_capacity(rc.urls.len());
751    for (index, url) in rc.urls.iter().enumerate() {
752        // The replica pools are built from the SAME config as the primary, so a
753        // schema-isolated tenant's search path, the server's TLS settings and its
754        // vector-scan settings all reach the replicas too (#809 generalised).
755        let pool = build_pool(
756            url,
757            cfg.max_size,
758            cfg.timeout_secs,
759            cfg.search_path.as_ref(),
760            cfg.vector_scan,
761            &cfg.tls,
762        )?;
763
764        // Boot health check: a configured replica that cannot serve refuses to
765        // boot — the alternative is a read fleet that silently collapsed onto
766        // the primary before the first request.
767        let client = pool.get().await.map_err(|e| FraiseQLError::ConnectionPool {
768            message: format!(
769                "Read replica {index} is unreachable at boot ({e}); refusing to start"
770            ),
771        })?;
772        let row = client.query_one("SELECT pg_is_in_recovery()", &[]).await.map_err(|e| {
773            FraiseQLError::Database {
774                message:   format!(
775                    "Read replica {index} failed its boot health check: {}",
776                    pg_detail(&e)
777                ),
778                sql_state: e.code().map(|c| c.code().to_string()),
779            }
780        })?;
781        let in_recovery: bool = row.get(0);
782        if !in_recovery {
783            tracing::warn!(
784                replica = index,
785                "Configured read replica is not in recovery (pg_is_in_recovery() = false); it \
786                 is a writable server, not a standby. Reads routed to it may diverge from the \
787                 primary."
788            );
789        }
790        drop(client);
791
792        pools.push(pool);
793        health.push(ReplicaHealth::booted(in_recovery));
794    }
795
796    // Reason (fallible u64::try_from + saturation): a pin window beyond u64::MAX
797    // milliseconds (~585 million years) saturates to "pin forever", which is the
798    // conservative, primary-only side.
799    let pin_after_write_ms = u64::try_from(rc.pin_after_write.as_millis()).unwrap_or(u64::MAX);
800    // Reason (saturation, opposite direction): a budget that overflows is
801    // effectively unbounded, and an unbounded budget is what `None` already
802    // means, so saturating to u64::MAX changes nothing an operator could observe.
803    let max_lag_ms = rc.max_lag.map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX));
804
805    Ok(ReadReplicaSet {
806        pools,
807        health,
808        next: AtomicUsize::new(0),
809        pin_after_write_ms,
810        max_lag_ms,
811        probe_interval: rc.health_probe_interval,
812        last_write_unix_ms: AtomicU64::new(0),
813    })
814}
815
816/// PostgreSQL database adapter with connection pooling.
817///
818/// Uses `deadpool-postgres` for connection pooling and `tokio-postgres` for async queries.
819///
820/// # Example
821///
822/// ```rust,no_run
823/// use fraiseql_db::postgres::PostgresAdapter;
824/// use fraiseql_db::{DatabaseAdapter, WhereClause, WhereOperator};
825/// use serde_json::json;
826///
827/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
828/// // Create adapter with connection string
829/// let adapter = PostgresAdapter::new("postgresql://localhost/mydb").await?;
830///
831/// // Execute query
832/// let where_clause = WhereClause::Field {
833///     path: vec!["email".to_string()],
834///     operator: WhereOperator::Icontains,
835///     value: json!("example.com"),
836/// };
837///
838/// let results = adapter
839///     .execute_where_query("v_user", Some(&where_clause), Some(10), None, None)
840///     .await?;
841///
842/// println!("Found {} users", results.len());
843/// # Ok(())
844/// # }
845/// ```
846#[derive(Clone)]
847pub struct PostgresAdapter {
848    pub(super) pool:         Pool,
849    /// Read-replica pool set, or `None` for a single-primary adapter (#407).
850    read_replicas:           Option<Arc<ReadReplicaSet>>,
851    /// Whether mutation timing injection is enabled.
852    mutation_timing_enabled: bool,
853    /// The PostgreSQL session variable name for timing.
854    timing_variable_name:    String,
855    /// Slots for reads that hold a connection for the life of a stream (#958).
856    ///
857    /// Shared across clones, because they share the pool the slots protect.
858    /// See [`PoolPrewarmConfig::max_streaming_reads`].
859    streaming_permits:       Arc<Semaphore>,
860}
861
862/// The built replica pools plus the shared read-your-writes watermark.
863///
864/// Held behind one `Arc` so adapter clones share a single round-robin cursor and
865/// a single write watermark — a clone that kept its own watermark would let a
866/// write through one clone go unseen by reads through another.
867struct ReadReplicaSet {
868    pools:              Vec<Pool>,
869    /// Per-replica probe results, parallel to [`pools`](Self::pools).
870    health:             Vec<ReplicaHealth>,
871    /// Round-robin cursor over `pools`.
872    next:               AtomicUsize,
873    /// [`ReadReplicaConfig::pin_after_write`], in milliseconds.
874    pin_after_write_ms: u64,
875    /// [`ReadReplicaConfig::max_lag`], in milliseconds; `None` = no lag gating.
876    max_lag_ms:         Option<u64>,
877    /// [`ReadReplicaConfig::health_probe_interval`].
878    probe_interval:     Duration,
879    /// Unix-epoch milliseconds of the most recent mutation-pipeline write;
880    /// `0` = never written.
881    last_write_unix_ms: AtomicU64,
882}
883
884/// What the last health probe observed about one replica (#957).
885///
886/// # Why a probe and not a measurement on the read path
887///
888/// Measuring lag inline would add a round trip to every replica-served read —
889/// the cost of the routing would exceed the cost of the read. The probe runs on
890/// its own cadence and the read path consults its last result, which means the
891/// result is *old*, and eligibility has to account for that.
892///
893/// # The staleness bound
894///
895/// A replica is eligible while `lag_at_probe + probe_age <= max_lag`.
896///
897/// That sum is a true upper bound on the replica's staleness right now, not an
898/// approximation: replay lag measured as "how long ago was the newest applied
899/// transaction committed" grows at most one millisecond per millisecond of wall
900/// clock (it grows exactly 1:1 while replay is stalled and shrinks whenever
901/// replay advances), so however far the probe has aged, the lag cannot have
902/// grown by more than that age.
903///
904/// It also makes the gate self-closing. Nothing has to notice that probing
905/// stopped — a probe that stops being refreshed ages past any budget on its own,
906/// and the replica falls out of rotation.
907struct ReplicaHealth {
908    /// Replay lag in milliseconds at [`probed_at_unix_ms`](Self::probed_at_unix_ms),
909    /// or [`LAG_UNMEASURABLE`] when the last probe could not produce one.
910    lag_ms:             AtomicU64,
911    /// Unix-epoch milliseconds of the last *successful* probe; `0` = never
912    /// probed, which reads as infinitely aged and therefore ineligible.
913    probed_at_unix_ms:  AtomicU64,
914    /// `pg_is_in_recovery()` as of the last successful probe. Seeded from the
915    /// boot health check so the first probe can report a *transition* — a
916    /// replica that left recovery between boot and now was promoted by a
917    /// failover, and is a writable server whose contents may diverge from the
918    /// primary in both directions.
919    in_recovery:        AtomicBool,
920    /// `pg_is_in_recovery()` at the boot health check, which is what makes the
921    /// transition legible.
922    ///
923    /// Booting outside recovery is *accepted* with a warning, because a dev rig
924    /// legitimately stands a plain database in for a replica; a server that
925    /// booted as a standby and later is not one has been promoted, and no dev
926    /// rig does that by accident. Only the second case stops taking reads —
927    /// which is why the boot reading has to be kept, not just the latest one.
928    booted_in_recovery: bool,
929}
930
931/// Sentinel [`ReplicaHealth::lag_ms`]: the last probe produced no lag reading.
932///
933/// A promoted replica (`pg_last_wal_replay_lsn()` is NULL outside recovery), a
934/// standby that has replayed no transaction yet, or a probe that failed
935/// outright. Saturating arithmetic keeps it above every budget, so "unknown"
936/// routes to the primary rather than being read as "zero".
937const LAG_UNMEASURABLE: u64 = u64::MAX;
938
939/// One round trip returning a replica's recovery state and replay lag.
940///
941/// `pg_last_xact_replay_timestamp()` alone over-reports on an idle primary — it
942/// answers "how old is the newest transaction I applied", which keeps growing
943/// when there is nothing left to apply. The first branch is the standard
944/// correction: a standby whose received WAL position equals its replayed one has
945/// applied everything it has been sent, and is by definition not behind.
946const REPLICA_PROBE_SQL: &str = "SELECT pg_is_in_recovery(), \
947     CASE \
948       WHEN NOT pg_is_in_recovery() THEN NULL::bigint \
949       WHEN pg_last_wal_replay_lsn() IS NOT NULL \
950            AND pg_last_wal_receive_lsn() = pg_last_wal_replay_lsn() THEN 0::bigint \
951       WHEN pg_last_xact_replay_timestamp() IS NULL THEN NULL::bigint \
952       ELSE GREATEST(0, EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp())) * 1000)::bigint \
953     END";
954
955impl ReplicaHealth {
956    /// Seed from the boot health check, before any probe has run.
957    const fn booted(in_recovery: bool) -> Self {
958        Self {
959            lag_ms:             AtomicU64::new(LAG_UNMEASURABLE),
960            probed_at_unix_ms:  AtomicU64::new(0),
961            in_recovery:        AtomicBool::new(in_recovery),
962            booted_in_recovery: in_recovery,
963        }
964    }
965
966    /// Whether this replica is still the kind of server it booted as.
967    ///
968    /// `false` only for the failover case: it was a standby at boot and the last
969    /// probe found it outside recovery, so something promoted it. That server is
970    /// now accepting writes of its own, and reads served from it can diverge
971    /// from the primary in *both* directions — unlike ordinary lag, which only
972    /// ever shows the past. It stops taking reads whether or not a staleness
973    /// budget is configured, because there is no budget under which "a different
974    /// database" is acceptable.
975    ///
976    /// A replica that booted outside recovery keeps the acceptance the boot check
977    /// gave it (dev rigs stand a plain database in for a standby, and the boot
978    /// warning already says so).
979    fn is_still_a_standby(&self) -> bool {
980        !self.booted_in_recovery || self.in_recovery.load(Ordering::Relaxed)
981    }
982
983    /// Whether this replica may serve a read with at most `max_lag_ms` of
984    /// staleness, as of `now_ms`. See the type docs for why the sum is a bound.
985    fn within_lag_budget(&self, now_ms: u64, max_lag_ms: u64) -> bool {
986        let probed_at = self.probed_at_unix_ms.load(Ordering::Relaxed);
987        if probed_at == 0 {
988            return false;
989        }
990        let lag = self.lag_ms.load(Ordering::Relaxed);
991        if lag == LAG_UNMEASURABLE {
992            return false;
993        }
994        lag.saturating_add(now_ms.saturating_sub(probed_at)) <= max_lag_ms
995    }
996
997    /// Record a probe result, logging a promotion the moment it is first seen.
998    fn record(&self, index: usize, in_recovery: bool, lag_ms: Option<u64>) {
999        if self.in_recovery.swap(in_recovery, Ordering::Relaxed) && !in_recovery {
1000            tracing::warn!(
1001                replica = index,
1002                "Read replica left recovery (pg_is_in_recovery() = false); a failover has \
1003                 promoted it to a writable server. Reads routed to it may diverge from the \
1004                 primary, and its replication lag is no longer measurable."
1005            );
1006        }
1007        self.lag_ms.store(lag_ms.unwrap_or(LAG_UNMEASURABLE), Ordering::Relaxed);
1008        // Only a probe that produced a reading counts as a probe: leaving the
1009        // timestamp behind is what ages an unreadable replica out of rotation.
1010        if lag_ms.is_some() {
1011            self.probed_at_unix_ms.store(now_unix_ms(), Ordering::Relaxed);
1012        }
1013    }
1014
1015    /// Mark the last probe as failed, without disturbing the recovery state a
1016    /// successful probe established.
1017    fn record_failure(&self) {
1018        self.lag_ms.store(LAG_UNMEASURABLE, Ordering::Relaxed);
1019    }
1020}
1021
1022/// Probe every replica in `set` forever, on its configured cadence.
1023///
1024/// Holds a [`Weak`] so the loop is not itself a reason for the replica set to
1025/// stay alive: when the last adapter clone drops, the upgrade fails and the task
1026/// ends. Probe failures are logged and retried on the next tick — a replica that
1027/// cannot be probed ages out of the lag budget on its own, and one configured
1028/// without a budget keeps serving, exactly as it did before probes existed.
1029async fn probe_read_replicas(set: Weak<ReadReplicaSet>) {
1030    loop {
1031        let Some(set) = set.upgrade() else { return };
1032        let interval = set.probe_interval;
1033
1034        for (index, pool) in set.pools.iter().enumerate() {
1035            let health = &set.health[index];
1036            let client = match pool.get().await {
1037                Ok(client) => client,
1038                Err(e) => {
1039                    tracing::debug!(replica = index, error = %e, "Read replica health probe \
1040                         could not acquire a connection");
1041                    health.record_failure();
1042                    continue;
1043                },
1044            };
1045            match client.query_one(REPLICA_PROBE_SQL, &[]).await {
1046                Ok(row) => {
1047                    let in_recovery: bool = row.get(0);
1048                    let lag_ms: Option<i64> = row.get(1);
1049                    // Reason (try_from): a negative lag would mean the standby
1050                    // replayed a transaction the future has not committed yet;
1051                    // GREATEST(0, …) already excludes it, and treating any such
1052                    // reading as unmeasurable keeps the fail-closed side.
1053                    health.record(index, in_recovery, lag_ms.and_then(|ms| u64::try_from(ms).ok()));
1054                },
1055                Err(e) => {
1056                    tracing::debug!(
1057                        replica = index,
1058                        error = %pg_detail(&e),
1059                        "Read replica health probe failed"
1060                    );
1061                    health.record_failure();
1062                },
1063            }
1064        }
1065
1066        // Drop the strong reference before sleeping: holding it across the sleep
1067        // would keep every replica pool open for one interval after the adapter
1068        // is gone.
1069        drop(set);
1070        tokio::time::sleep(interval).await;
1071    }
1072}
1073
1074/// Whether a read at `now_ms` falls inside the post-write primary pin.
1075///
1076/// Pure so the boundary cases are unit-testable without a clock: a never-armed
1077/// watermark (`last_write_ms == 0`) is only "recent" while the process's clock
1078/// is within `pin_ms` of the epoch, i.e. never in practice.
1079const fn pin_active(now_ms: u64, last_write_ms: u64, pin_ms: u64) -> bool {
1080    now_ms.saturating_sub(last_write_ms) < pin_ms
1081}
1082
1083/// Current Unix time in milliseconds.
1084fn now_unix_ms() -> u64 {
1085    use std::time::{SystemTime, UNIX_EPOCH};
1086    // Reason (map_or 0): a pre-epoch system clock degrades to "always pinned",
1087    // which is the safe (primary-only) side.
1088    SystemTime::now()
1089        .duration_since(UNIX_EPOCH)
1090        .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
1091}
1092
1093impl std::fmt::Debug for PostgresAdapter {
1094    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1095        f.debug_struct("PostgresAdapter")
1096            .field("mutation_timing_enabled", &self.mutation_timing_enabled)
1097            .field("timing_variable_name", &self.timing_variable_name)
1098            .field("pool", &"<Pool>")
1099            .field(
1100                "read_replicas",
1101                &self.read_replicas.as_ref().map(|r| r.pools.len()).unwrap_or_default(),
1102            )
1103            .field("streaming_permits", &self.streaming_permits.available_permits())
1104            .finish()
1105    }
1106}
1107
1108impl PostgresAdapter {
1109    /// Create new PostgreSQL adapter with default pool configuration.
1110    ///
1111    /// # Arguments
1112    ///
1113    /// * `connection_string` - PostgreSQL connection string (e.g., "postgresql://localhost/mydb")
1114    ///
1115    /// # Errors
1116    ///
1117    /// Returns `FraiseQLError::ConnectionPool` if pool creation fails.
1118    ///
1119    /// # Example
1120    ///
1121    /// ```rust,no_run
1122    /// # use fraiseql_db::postgres::PostgresAdapter;
1123    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1124    /// let adapter = PostgresAdapter::new("postgresql://localhost/mydb").await?;
1125    /// # Ok(())
1126    /// # }
1127    /// ```
1128    pub async fn new(connection_string: &str) -> Result<Self> {
1129        Self::with_pool_config(
1130            connection_string,
1131            PoolPrewarmConfig {
1132                min_size:            0,
1133                max_size:            DEFAULT_POOL_SIZE,
1134                timeout_secs:        None,
1135                search_path:         None,
1136                max_streaming_reads: None,
1137                // The recall-preserving default; see `VectorScanConfig` (#1116).
1138                vector_scan:         VectorScanConfig::default(),
1139                // libpq's default: negotiate TLS when the server offers it. Callers
1140                // that need a guarantee go through `with_pool_config`.
1141                tls:                 PostgresTlsConfig::default(),
1142                read_replicas:       None,
1143            },
1144        )
1145        .await
1146    }
1147
1148    /// Create new PostgreSQL adapter with pre-warming and timeout configuration.
1149    ///
1150    /// Constructs the pool, runs a startup health check, then eagerly opens
1151    /// `cfg.min_size` connections so they are ready when the first request arrives.
1152    ///
1153    /// # Arguments
1154    ///
1155    /// * `connection_string` - PostgreSQL connection string
1156    /// * `cfg` - Pool pre-warming and timeout configuration
1157    ///
1158    /// # Errors
1159    ///
1160    /// Returns `FraiseQLError::ConnectionPool` if pool creation or the startup
1161    /// health check fails.
1162    pub async fn with_pool_config(connection_string: &str, cfg: PoolPrewarmConfig) -> Result<Self> {
1163        let pool = build_pool(
1164            connection_string,
1165            cfg.max_size,
1166            cfg.timeout_secs,
1167            cfg.search_path.as_ref(),
1168            cfg.vector_scan,
1169            &cfg.tls,
1170        )?;
1171
1172        // Startup health check — establishes the first connection.
1173        let client = pool.get().await.map_err(|e| FraiseQLError::ConnectionPool {
1174            message: format!("Failed to acquire connection: {e}"),
1175        })?;
1176
1177        client.query("SELECT 1", &[]).await.map_err(|e| FraiseQLError::Database {
1178            message:   format!("Failed to connect to database: {}", pg_detail(&e)),
1179            sql_state: e.code().map(|c| c.code().to_string()),
1180        })?;
1181
1182        // Drop client back to the pool before pre-warming so that the health-check
1183        // connection counts as idle slot #1.
1184        drop(client);
1185
1186        // Replica pools are built from the SAME config as the primary — search
1187        // path, TLS, sizing — so per-tenant isolation applies to every pool this
1188        // adapter ever reads through, not just the one it writes through (#407).
1189        let read_replicas = match &cfg.read_replicas {
1190            None => None,
1191            Some(rc) => {
1192                let set = Arc::new(build_read_replica_set(rc, &cfg).await?);
1193                // Weak, so the probe loop ends when the last adapter clone drops
1194                // rather than keeping the pools alive for the process's lifetime.
1195                tokio::spawn(probe_read_replicas(Arc::downgrade(&set)));
1196                Some(set)
1197            },
1198        };
1199
1200        let adapter = Self {
1201            pool,
1202            read_replicas,
1203            mutation_timing_enabled: false,
1204            timing_variable_name: "fraiseql.started_at".to_string(),
1205            streaming_permits: Arc::new(Semaphore::new(streaming_read_slots(
1206                cfg.max_streaming_reads,
1207                cfg.max_size,
1208            ))),
1209        };
1210
1211        // Pre-warm to `min_size` — NOT `min_size - 1`. The health-check connection
1212        // above is returned to the pool, so the first warm acquisition recycles it
1213        // rather than opening a new one; subtracting for it left the pool one
1214        // connection short of `min_size` (#937). Acquiring `min_size` concurrently
1215        // is self-correcting: whatever is already idle gets reused, the rest are
1216        // opened, and the pool's own `max_size` caps the total.
1217        let warm_target = cfg.min_size.min(cfg.max_size);
1218        if warm_target > 0 {
1219            adapter.prewarm(warm_target).await;
1220        }
1221
1222        Ok(adapter)
1223    }
1224
1225    /// Create new PostgreSQL adapter with custom pool size.
1226    ///
1227    /// # Arguments
1228    ///
1229    /// * `connection_string` - PostgreSQL connection string
1230    /// * `max_size` - Maximum number of connections in pool
1231    ///
1232    /// # Errors
1233    ///
1234    /// Returns `FraiseQLError::ConnectionPool` if pool creation fails.
1235    pub async fn with_pool_size(connection_string: &str, max_size: usize) -> Result<Self> {
1236        Self::with_pool_config(
1237            connection_string,
1238            PoolPrewarmConfig {
1239                min_size: 0,
1240                max_size,
1241                timeout_secs: None,
1242                search_path: None,
1243                tls: PostgresTlsConfig::default(),
1244                read_replicas: None,
1245                max_streaming_reads: None,
1246                vector_scan: VectorScanConfig::default(),
1247            },
1248        )
1249        .await
1250    }
1251
1252    /// Pre-warm the pool by opening `count` additional connections.
1253    ///
1254    /// Pre-warming is best-effort: failures from individual connections are logged
1255    /// but do not prevent startup. A 10-second outer timeout ensures the server
1256    /// never blocks indefinitely on a slow or unreachable PostgreSQL instance.
1257    async fn prewarm(&self, count: usize) {
1258        use futures::future::join_all;
1259        use tokio::time::timeout;
1260
1261        // Acquire every guard CONCURRENTLY and hold them all until the last one
1262        // is in hand. Acquiring one at a time — or spawning tasks that release
1263        // as they finish — lets a later acquisition recycle a connection an
1264        // earlier one just returned, so the pool settles below `count` and
1265        // `pool_min_size` silently under-delivers (#937).
1266        let result =
1267            timeout(Duration::from_secs(10), join_all((0..count).map(|_| self.pool.get()))).await;
1268
1269        let (succeeded, failed) = match result {
1270            Ok(guards) => {
1271                let s = guards.iter().filter(|r| r.is_ok()).count();
1272                // Released together, so `s` distinct connections stay in the pool.
1273                drop(guards);
1274                (s, count - s)
1275            },
1276            Err(_elapsed) => {
1277                tracing::warn!(
1278                    target_connections = count,
1279                    "Pool pre-warm timed out after 10s; server will continue with partial pre-warm"
1280                );
1281                (0, count)
1282            },
1283        };
1284
1285        if failed > 0 {
1286            tracing::warn!(
1287                succeeded,
1288                failed,
1289                "Pool pre-warm: some connections could not be established"
1290            );
1291        } else {
1292            // Report what the pool actually holds, not what was attempted.
1293            tracing::info!(
1294                idle_connections = self.pool.status().available,
1295                "PostgreSQL pool pre-warmed successfully"
1296            );
1297        }
1298    }
1299
1300    /// Get a reference to the internal connection pool.
1301    ///
1302    /// This allows sharing the pool with other components like `PostgresIntrospector`.
1303    #[must_use]
1304    pub const fn pool(&self) -> &Pool {
1305        &self.pool
1306    }
1307
1308    /// Enable mutation timing injection.
1309    ///
1310    /// When enabled, `execute_write` sets a session variable to `clock_timestamp()::text` in
1311    /// each write's transaction before the call, allowing SQL functions to compute their own
1312    /// duration.
1313    ///
1314    /// # Arguments
1315    ///
1316    /// * `variable_name` - The PostgreSQL session variable name (e.g., `"fraiseql.started_at"`)
1317    #[must_use]
1318    pub fn with_mutation_timing(mut self, variable_name: &str) -> Self {
1319        self.mutation_timing_enabled = true;
1320        self.timing_variable_name = variable_name.to_string();
1321        self
1322    }
1323
1324    /// Returns whether mutation timing injection is enabled.
1325    #[must_use]
1326    pub const fn mutation_timing_enabled(&self) -> bool {
1327        self.mutation_timing_enabled
1328    }
1329
1330    /// Execute raw SQL query and return JSONB rows.
1331    ///
1332    /// # Errors
1333    ///
1334    /// Returns `FraiseQLError::Database` on query execution failure.
1335    pub(super) async fn execute_raw(
1336        &self,
1337        sql: &str,
1338        params: &[&(dyn tokio_postgres::types::ToSql + Sync)],
1339        routing: ReadRouting,
1340    ) -> Result<Vec<JsonbValue>> {
1341        // Read-only by construction (`SELECT data FROM <view>`): replica-eligible.
1342        let client = self.acquire_read_connection_with_retry(routing).await?;
1343
1344        let rows: Vec<Row> =
1345            client.query(sql, params).await.map_err(|e| FraiseQLError::Database {
1346                message:   format!("Query execution failed: {}", pg_detail(&e)),
1347                sql_state: e.code().map(|c| c.code().to_string()),
1348            })?;
1349
1350        let results = rows
1351            .into_iter()
1352            .map(|row| jsonb_cell(&row, 0, sql))
1353            .collect::<Result<Vec<_>>>()?;
1354
1355        Ok(results)
1356    }
1357
1358    /// Like [`execute_raw`](Self::execute_raw) but applies transaction-local
1359    /// session variables on the same connection / transaction that runs the
1360    /// query.
1361    ///
1362    /// `set_config(..., true)` and the `SELECT` share one transaction, so
1363    /// PostgreSQL RLS policies backed by `current_setting()` see the configured
1364    /// values (fixes #329).
1365    ///
1366    /// # Errors
1367    ///
1368    /// Returns `FraiseQLError::Database` on transaction, `set_config`, query, or
1369    /// commit failure.
1370    pub(super) async fn execute_raw_with_session(
1371        &self,
1372        sql: &str,
1373        params: &[&(dyn tokio_postgres::types::ToSql + Sync)],
1374        session_vars: &[(&str, &str)],
1375        routing: ReadRouting,
1376    ) -> Result<Vec<JsonbValue>> {
1377        // Read-only by construction; `set_config` + SELECT run fine inside a
1378        // hot-standby read-only transaction, so this stays replica-eligible.
1379        let mut client = self.acquire_read_connection_with_retry(routing).await?;
1380        let txn =
1381            client.build_transaction().start().await.map_err(|e| FraiseQLError::Database {
1382                message:   format!("Failed to start session-var transaction: {}", pg_detail(&e)),
1383                sql_state: e.code().map(|c| c.code().to_string()),
1384            })?;
1385
1386        database::apply_session_vars(&txn, session_vars).await?;
1387
1388        let rows: Vec<Row> = txn.query(sql, params).await.map_err(|e| FraiseQLError::Database {
1389            message:   format!("Query execution failed: {}", pg_detail(&e)),
1390            sql_state: e.code().map(|c| c.code().to_string()),
1391        })?;
1392
1393        txn.commit().await.map_err(|e| FraiseQLError::Database {
1394            message:   format!("Failed to commit session-var transaction: {}", pg_detail(&e)),
1395            sql_state: e.code().map(|c| c.code().to_string()),
1396        })?;
1397
1398        rows.into_iter().map(|row| jsonb_cell(&row, 0, sql)).collect()
1399    }
1400
1401    /// Record a mutation-pipeline write for read-your-writes pinning (#407).
1402    ///
1403    /// Called at both entry and successful exit of every mutation-pipeline
1404    /// method: entry so a read racing a slow write already pins, exit because
1405    /// replication lag is measured from commit. A no-op without replicas.
1406    pub(super) fn mark_write(&self) {
1407        if let Some(replicas) = &self.read_replicas {
1408            replicas.last_write_unix_ms.store(now_unix_ms(), Ordering::Relaxed);
1409        }
1410    }
1411
1412    /// Acquire a connection for a **structurally read-only** statement (#407).
1413    ///
1414    /// Routing, in order:
1415    /// 1. no replicas configured → primary;
1416    /// 2. inside the post-write pin window → primary (read-your-writes);
1417    /// 3. otherwise round-robin over the replicas, skipping any outside the bounded-staleness
1418    ///    budget and any whose acquisition fails, falling back to the primary when none is left.
1419    ///
1420    /// Only methods that can never write may call this; anything mixed-use
1421    /// (`execute_raw_query`, stats, health, DDL) stays on
1422    /// [`acquire_connection_with_retry`](Self::acquire_connection_with_retry).
1423    ///
1424    /// # Errors
1425    ///
1426    /// Returns `FraiseQLError::ConnectionPool` when every replica *and* the
1427    /// primary fallback fail to yield a connection.
1428    pub(super) async fn acquire_read_connection_with_retry(
1429        &self,
1430        routing: ReadRouting,
1431    ) -> Result<deadpool_postgres::Client> {
1432        let Some(replicas) = &self.read_replicas else {
1433            return self.acquire_connection_with_retry().await;
1434        };
1435
1436        // A query annotated `primary` is asking not to be served stale, which no
1437        // replica can promise (#957).
1438        if !routing.allows_replica() {
1439            return self.acquire_connection_with_retry().await;
1440        }
1441
1442        let now_ms = now_unix_ms();
1443        // `replica` opts out of the pin: the pin exists so a client reads its own
1444        // writes, and this query has said that is not what it is for.
1445        if routing.honours_write_pin()
1446            && pin_active(
1447                now_ms,
1448                replicas.last_write_unix_ms.load(Ordering::Relaxed),
1449                replicas.pin_after_write_ms,
1450            )
1451        {
1452            return self.acquire_connection_with_retry().await;
1453        }
1454
1455        let max_lag_ms = routing.effective_max_lag_ms(replicas.max_lag_ms);
1456
1457        let count = replicas.pools.len();
1458        let start = replicas.next.fetch_add(1, Ordering::Relaxed);
1459        let mut ineligible = 0_usize;
1460        for offset in 0..count {
1461            let index = (start.wrapping_add(offset)) % count;
1462            // The lag gate comes before acquisition, not after: a replica outside
1463            // the budget must not be *reached*, and checking afterwards would
1464            // burn a connection to learn what the last probe already knew.
1465            let health = &replicas.health[index];
1466            let eligible = health.is_still_a_standby()
1467                && max_lag_ms.is_none_or(|budget| health.within_lag_budget(now_ms, budget));
1468            if !eligible {
1469                ineligible += 1;
1470                continue;
1471            }
1472            match replicas.pools[index].get().await {
1473                Ok(client) => return Ok(client),
1474                Err(e) => {
1475                    tracing::warn!(
1476                        replica = index,
1477                        error = %e,
1478                        "Read replica connection acquisition failed; trying the next candidate"
1479                    );
1480                },
1481            }
1482        }
1483
1484        if ineligible > 0 {
1485            tracing::debug!(
1486                ineligible,
1487                replicas = count,
1488                "Read replicas outside the bounded-staleness budget, or promoted out of \
1489                 recovery; serving the read from the primary"
1490            );
1491        } else {
1492            tracing::warn!("All read replicas unavailable; serving the read from the primary");
1493        }
1494        self.acquire_connection_with_retry().await
1495    }
1496
1497    /// Acquire a connection from the pool with retry logic.
1498    ///
1499    /// - `PoolError::Timeout`: the pool was exhausted for the full configured wait period. This is
1500    ///   not transient — retrying would only multiply the wait. Fails immediately.
1501    /// - `PoolError::Backend` / create errors: potentially transient. Retries with exponential
1502    ///   backoff (up to `MAX_CONNECTION_RETRIES` attempts).
1503    ///
1504    /// # Errors
1505    ///
1506    /// Returns `FraiseQLError::ConnectionPool` on timeout or when all retries are exhausted.
1507    pub(super) async fn acquire_connection_with_retry(&self) -> Result<deadpool_postgres::Client> {
1508        use deadpool_postgres::PoolError;
1509
1510        let mut last_error = None;
1511
1512        for attempt in 0..MAX_CONNECTION_RETRIES {
1513            match self.pool.get().await {
1514                Ok(client) => {
1515                    if attempt > 0 {
1516                        tracing::info!(attempt, "Successfully acquired connection after retries");
1517                    }
1518                    return Ok(client);
1519                },
1520                // Pool exhausted for the full wait period — not transient, fail immediately.
1521                Err(PoolError::Timeout(_)) => {
1522                    let metrics = self.pool_metrics();
1523                    tracing::error!(
1524                        available = metrics.idle_connections,
1525                        active = metrics.active_connections,
1526                        max = metrics.total_connections,
1527                        "Connection pool timeout: all connections busy"
1528                    );
1529                    return Err(FraiseQLError::ConnectionPool {
1530                        message: format!(
1531                            "Connection pool timeout: {}/{} connections busy. \
1532                             Increase pool_max_size or reduce concurrent load.",
1533                            metrics.active_connections, metrics.total_connections,
1534                        ),
1535                    });
1536                },
1537                // Backend/create errors are potentially transient — retry with backoff.
1538                Err(e) => {
1539                    last_error = Some(e);
1540                    if attempt < MAX_CONNECTION_RETRIES - 1 {
1541                        let delay = CONNECTION_RETRY_DELAY_MS * (u64::from(attempt) + 1);
1542                        tracing::warn!(
1543                            attempt = attempt + 1,
1544                            total = MAX_CONNECTION_RETRIES,
1545                            delay_ms = delay,
1546                            "Transient connection error, retrying"
1547                        );
1548                        tokio::time::sleep(Duration::from_millis(delay)).await;
1549                    }
1550                },
1551            }
1552        }
1553
1554        // All retries for transient errors exhausted.
1555        let pool_metrics = self.pool_metrics();
1556        tracing::error!(
1557            retries = MAX_CONNECTION_RETRIES,
1558            available = pool_metrics.idle_connections,
1559            active = pool_metrics.active_connections,
1560            max = pool_metrics.total_connections,
1561            "Failed to acquire connection after all retries"
1562        );
1563
1564        Err(FraiseQLError::ConnectionPool {
1565            message: format!(
1566                "Failed to acquire connection after {} retries: {}. \
1567                 Pool state: idle={}, active={}, max={}",
1568                MAX_CONNECTION_RETRIES,
1569                last_error.expect("last_error is set on every retry iteration"),
1570                pool_metrics.idle_connections,
1571                pool_metrics.active_connections,
1572                pool_metrics.total_connections,
1573            ),
1574        })
1575    }
1576
1577    /// Execute query with SQL field projection optimization.
1578    ///
1579    /// Uses the provided `SqlProjectionHint` to generate optimized SQL that projects
1580    /// only the requested fields from the JSONB column, reducing network payload and
1581    /// JSON deserialization overhead.
1582    ///
1583    /// # Arguments
1584    ///
1585    /// * `view` - View/table name to query
1586    /// * `projection` - Optional SQL projection hint with field list
1587    /// * `where_clause` - Optional WHERE clause for filtering
1588    /// * `limit` - Optional row limit
1589    ///
1590    /// # Returns
1591    ///
1592    /// Vector of projected JSONB rows with only the requested fields
1593    ///
1594    /// # Errors
1595    ///
1596    /// Returns `FraiseQLError::Database` on query execution failure.
1597    ///
1598    /// # Panics
1599    ///
1600    /// Cannot panic in practice: the inner `expect` is guarded by an `is_none()` check
1601    /// immediately above it.
1602    ///
1603    /// # Example
1604    ///
1605    /// ```no_run
1606    /// // Requires: running PostgreSQL database.
1607    /// use fraiseql_db::postgres::PostgresAdapter;
1608    /// use fraiseql_db::types::SqlProjectionHint;
1609    /// use fraiseql_db::DatabaseType;
1610    ///
1611    /// # async fn example(adapter: &PostgresAdapter) -> Result<(), Box<dyn std::error::Error>> {
1612    /// let projection = SqlProjectionHint::new(
1613    ///     DatabaseType::PostgreSQL,
1614    ///     "jsonb_build_object('id', data->>'id')".to_string(),
1615    ///     75,
1616    /// );
1617    ///
1618    /// let results = adapter
1619    ///     .execute_with_projection("v_user", Some(&projection), None, Some(10), None)
1620    ///     .await?;
1621    /// # Ok(())
1622    /// # }
1623    /// ```
1624    /// Implementation of `execute_with_projection` with ORDER BY support.
1625    ///
1626    /// Called by both the inherent convenience method and the `DatabaseAdapter`
1627    /// trait implementation.
1628    pub(super) async fn execute_with_projection_impl(
1629        &self,
1630        view: &str,
1631        projection: Option<&SqlProjectionHint>,
1632        where_clause: Option<&WhereClause>,
1633        limit: Option<u32>,
1634        offset: Option<u32>,
1635        order_by: Option<&[OrderByClause]>,
1636    ) -> Result<Vec<JsonbValue>> {
1637        // If no projection, fall back to standard query
1638        if projection.is_none() {
1639            return self.execute_where_query(view, where_clause, limit, offset, order_by).await;
1640        }
1641
1642        let projection = projection.expect("projection is Some; None was returned above");
1643
1644        let (sql, typed_params) =
1645            build_projection_select_sql(projection, view, where_clause, limit, offset, order_by)?;
1646
1647        tracing::debug!("SQL with projection = {}", sql);
1648        tracing::debug!("typed_params = {:?}", typed_params);
1649
1650        let param_refs = crate::types::as_sql_param_refs(&typed_params);
1651
1652        self.execute_raw(&sql, &param_refs, ReadRouting::Any).await
1653    }
1654
1655    /// Execute query with SQL field projection optimization.
1656    ///
1657    /// Convenience wrapper for callers that don't need ORDER BY.
1658    /// See `execute_with_projection_impl` for details.
1659    ///
1660    /// # Errors
1661    ///
1662    /// Returns `FraiseQLError::Database` on query execution failure.
1663    pub async fn execute_with_projection(
1664        &self,
1665        view: &str,
1666        projection: Option<&SqlProjectionHint>,
1667        where_clause: Option<&WhereClause>,
1668        limit: Option<u32>,
1669        offset: Option<u32>,
1670    ) -> Result<Vec<JsonbValue>> {
1671        self.execute_with_projection_impl(view, projection, where_clause, limit, offset, None)
1672            .await
1673    }
1674}
1675
1676/// Build a parameterized `SELECT data FROM {view}` SQL string.
1677///
1678/// Shared by [`PostgresAdapter::execute_where_query`] and
1679/// [`PostgresAdapter::explain_where_query`] so that SQL construction
1680/// logic is never duplicated.
1681///
1682/// # Returns
1683///
1684/// `(sql, typed_params)` — the SQL string and the bound parameter values.
1685///
1686/// # Errors
1687///
1688/// Returns `FraiseQLError` if WHERE clause generation fails.
1689pub(super) fn build_where_select_sql(
1690    view: &str,
1691    where_clause: Option<&WhereClause>,
1692    limit: Option<u32>,
1693    offset: Option<u32>,
1694) -> Result<(String, Vec<QueryParam>)> {
1695    build_where_select_sql_ordered(view, where_clause, limit, offset, None)
1696}
1697
1698/// Build a parameterized `SELECT data FROM {view}` SQL string with optional ORDER BY.
1699///
1700/// ORDER BY is inserted between the WHERE clause and LIMIT/OFFSET as required by SQL.
1701///
1702/// # Returns
1703///
1704/// `(sql, typed_params)` — the SQL string and the bound parameter values.
1705///
1706/// # Errors
1707///
1708/// Returns `FraiseQLError` if WHERE clause generation or field name validation fails.
1709/// `SELECT COUNT(*) FROM {view} [WHERE …]` and its parameters (#938).
1710///
1711/// Deliberately shares [`PostgresWhereGenerator`] with the row query rather than
1712/// formatting its own predicate: the count and the page it describes have to
1713/// agree, and two independent renderings of the same `WhereClause` is how they
1714/// would stop agreeing. No `ORDER BY` (it cannot change a count, and sorting the
1715/// rows first is pure cost) and no `LIMIT`/`OFFSET` (the total is the whole
1716/// point).
1717pub(super) fn build_count_sql(
1718    view: &str,
1719    where_clause: Option<&WhereClause>,
1720) -> Result<(String, Vec<QueryParam>)> {
1721    let mut sql = format!("SELECT COUNT(*) FROM {}", quote_postgres_identifier(view));
1722    let typed_params: Vec<QueryParam> = if let Some(clause) = where_clause {
1723        let generator = PostgresWhereGenerator::new(PostgresDialect);
1724        let (where_sql, where_params) = generator.generate(clause)?;
1725        sql.push_str(" WHERE ");
1726        sql.push_str(&where_sql);
1727        where_params.into_iter().map(QueryParam::from).collect()
1728    } else {
1729        Vec::new()
1730    };
1731    Ok((sql, typed_params))
1732}
1733
1734pub(super) fn build_where_select_sql_ordered(
1735    view: &str,
1736    where_clause: Option<&WhereClause>,
1737    limit: Option<u32>,
1738    offset: Option<u32>,
1739    order_by: Option<&[OrderByClause]>,
1740) -> Result<(String, Vec<QueryParam>)> {
1741    // Build base query
1742    let mut sql = format!("SELECT data FROM {}", quote_postgres_identifier(view));
1743
1744    // Collect WHERE clause params (if any)
1745    let mut typed_params: Vec<QueryParam> = if let Some(clause) = where_clause {
1746        let generator = PostgresWhereGenerator::new(PostgresDialect);
1747        let (where_sql, where_params) = generator.generate(clause)?;
1748        sql.push_str(" WHERE ");
1749        sql.push_str(&where_sql);
1750
1751        // Convert WHERE clause JSON values to QueryParam
1752        where_params.into_iter().map(QueryParam::from).collect()
1753    } else {
1754        Vec::new()
1755    };
1756    let mut param_count = typed_params.len();
1757
1758    // ORDER BY must come before LIMIT/OFFSET in SQL — and so must its
1759    // parameters. A relevance ordering (#1284) binds the search text, so the
1760    // placeholders run WHERE → ORDER BY → LIMIT/OFFSET, which is the order the
1761    // fragments are appended in.
1762    // `Tiebreak::Identity`: this read is paged by LIMIT/OFFSET, so its ordering
1763    // has to be total or the pages are a slice of no sequence (#1287).
1764    for bound in append_order_by(
1765        &mut sql,
1766        order_by,
1767        DatabaseType::PostgreSQL,
1768        param_count + 1,
1769        Tiebreak::Identity,
1770    )? {
1771        param_count += 1;
1772        typed_params.push(QueryParam::Text(bound));
1773    }
1774
1775    // Add LIMIT as BigInt (PostgreSQL requires integer type for LIMIT).
1776    // Reason (expect below): fmt::Write for String is infallible.
1777    if let Some(lim) = limit {
1778        param_count += 1;
1779        write!(sql, " LIMIT ${param_count}").expect("write to String");
1780        typed_params.push(QueryParam::BigInt(i64::from(lim)));
1781    }
1782
1783    // Add OFFSET as BigInt (PostgreSQL requires integer type for OFFSET)
1784    if let Some(off) = offset {
1785        param_count += 1;
1786        write!(sql, " OFFSET ${param_count}").expect("write to String");
1787        typed_params.push(QueryParam::BigInt(i64::from(off)));
1788    }
1789
1790    Ok((sql, typed_params))
1791}
1792
1793/// Build a parameterized projection `SELECT` SQL string.
1794///
1795/// Mirrors the SQL produced inline by [`PostgresAdapter::execute_with_projection_impl`],
1796/// extracted so the connection-affine `*_with_session` path can reuse it
1797/// without acquiring its own connection.
1798///
1799/// # Returns
1800///
1801/// `(sql, typed_params)` — the SQL string and the bound parameter values.
1802///
1803/// # Errors
1804///
1805/// Returns `FraiseQLError` if WHERE clause generation fails.
1806pub(super) fn build_projection_select_sql(
1807    projection: &SqlProjectionHint,
1808    view: &str,
1809    where_clause: Option<&WhereClause>,
1810    limit: Option<u32>,
1811    offset: Option<u32>,
1812    order_by: Option<&[OrderByClause]>,
1813) -> Result<(String, Vec<QueryParam>)> {
1814    // The projection_template is the SELECT clause with projection SQL,
1815    // e.g. "jsonb_build_object('id', data->>'id', 'email', data->>'email')".
1816    let mut sql = format!(
1817        "SELECT {} FROM {}",
1818        projection.projection_template,
1819        quote_postgres_identifier(view)
1820    );
1821
1822    let mut typed_params: Vec<QueryParam> = if let Some(clause) = where_clause {
1823        let generator = PostgresWhereGenerator::new(PostgresDialect);
1824        let (where_sql, where_params) = generator.generate(clause)?;
1825        sql.push_str(" WHERE ");
1826        sql.push_str(&where_sql);
1827        where_params.into_iter().map(QueryParam::from).collect()
1828    } else {
1829        Vec::new()
1830    };
1831    let mut param_count = typed_params.len();
1832
1833    // ORDER BY must come before LIMIT/OFFSET in SQL — and so must its
1834    // parameters (#1284); see `build_where_select_sql_ordered`.
1835    // `Tiebreak::Identity`: this read is paged by LIMIT/OFFSET, so its ordering
1836    // has to be total or the pages are a slice of no sequence (#1287).
1837    for bound in append_order_by(
1838        &mut sql,
1839        order_by,
1840        DatabaseType::PostgreSQL,
1841        param_count + 1,
1842        Tiebreak::Identity,
1843    )? {
1844        param_count += 1;
1845        typed_params.push(QueryParam::Text(bound));
1846    }
1847
1848    // Append LIMIT/OFFSET as BigInt (PostgreSQL requires integer type).
1849    // Reason (expect below): fmt::Write for String is infallible.
1850    if let Some(lim) = limit {
1851        param_count += 1;
1852        write!(sql, " LIMIT ${param_count}").expect("write to String");
1853        typed_params.push(QueryParam::BigInt(i64::from(lim)));
1854    }
1855
1856    if let Some(off) = offset {
1857        param_count += 1;
1858        write!(sql, " OFFSET ${param_count}").expect("write to String");
1859        typed_params.push(QueryParam::BigInt(i64::from(off)));
1860    }
1861
1862    Ok((sql, typed_params))
1863}