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#[cfg(feature = "test-postgres")]
11pub mod test_hooks;
12
13#[cfg(test)]
14mod composed_tests;
15#[cfg(test)]
16mod tests;
17
18use std::{
19    fmt::Write,
20    sync::{
21        Arc, Weak,
22        atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
23    },
24    time::Duration,
25};
26
27use deadpool_postgres::{Config, ManagerConfig, Pool, RecyclingMethod, Runtime};
28use fraiseql_error::{FraiseQLError, Result};
29use tokio::sync::Semaphore;
30use tokio_postgres::{NoTls, Row};
31
32use super::{
33    server_version::require_supported_server,
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        // A standby replays the primary's WAL, so it is the same major version in a
783        // physical setup, but a logical replica or a misconfigured URL need not be.
784        require_supported_server(&client).await.map_err(|e| match e {
785            FraiseQLError::Unsupported { message } => FraiseQLError::Unsupported {
786                message: format!("Read replica {index}: {message}"),
787            },
788            other => other,
789        })?;
790        if !in_recovery {
791            tracing::warn!(
792                replica = index,
793                "Configured read replica is not in recovery (pg_is_in_recovery() = false); it \
794                 is a writable server, not a standby. Reads routed to it may diverge from the \
795                 primary."
796            );
797        }
798        drop(client);
799
800        pools.push(pool);
801        health.push(ReplicaHealth::booted(in_recovery));
802    }
803
804    // Reason (fallible u64::try_from + saturation): a pin window beyond u64::MAX
805    // milliseconds (~585 million years) saturates to "pin forever", which is the
806    // conservative, primary-only side.
807    let pin_after_write_ms = u64::try_from(rc.pin_after_write.as_millis()).unwrap_or(u64::MAX);
808    // Reason (saturation, opposite direction): a budget that overflows is
809    // effectively unbounded, and an unbounded budget is what `None` already
810    // means, so saturating to u64::MAX changes nothing an operator could observe.
811    let max_lag_ms = rc.max_lag.map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX));
812
813    Ok(ReadReplicaSet {
814        pools,
815        health,
816        next: AtomicUsize::new(0),
817        pin_after_write_ms,
818        max_lag_ms,
819        probe_interval: rc.health_probe_interval,
820        last_write_unix_ms: AtomicU64::new(0),
821    })
822}
823
824/// PostgreSQL database adapter with connection pooling.
825///
826/// Uses `deadpool-postgres` for connection pooling and `tokio-postgres` for async queries.
827///
828/// # Example
829///
830/// ```rust,no_run
831/// use fraiseql_db::postgres::PostgresAdapter;
832/// use fraiseql_db::{DatabaseAdapter, WhereClause, WhereOperator};
833/// use serde_json::json;
834///
835/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
836/// // Create adapter with connection string
837/// let adapter = PostgresAdapter::new("postgresql://localhost/mydb").await?;
838///
839/// // Execute query
840/// let where_clause = WhereClause::Field {
841///     path: vec!["email".to_string()],
842///     operator: WhereOperator::Icontains,
843///     value: json!("example.com"),
844/// };
845///
846/// let results = adapter
847///     .execute_where_query("v_user", Some(&where_clause), Some(10), None, None)
848///     .await?;
849///
850/// println!("Found {} users", results.len());
851/// # Ok(())
852/// # }
853/// ```
854#[derive(Clone)]
855pub struct PostgresAdapter {
856    pub(super) pool:         Pool,
857    /// Read-replica pool set, or `None` for a single-primary adapter (#407).
858    read_replicas:           Option<Arc<ReadReplicaSet>>,
859    /// Whether mutation timing injection is enabled.
860    mutation_timing_enabled: bool,
861    /// The PostgreSQL session variable name for timing.
862    timing_variable_name:    String,
863    /// Slots for reads that hold a connection for the life of a stream (#958).
864    ///
865    /// Shared across clones, because they share the pool the slots protect.
866    /// See [`PoolPrewarmConfig::max_streaming_reads`].
867    streaming_permits:       Arc<Semaphore>,
868}
869
870/// The built replica pools plus the shared read-your-writes watermark.
871///
872/// Held behind one `Arc` so adapter clones share a single round-robin cursor and
873/// a single write watermark — a clone that kept its own watermark would let a
874/// write through one clone go unseen by reads through another.
875struct ReadReplicaSet {
876    pools:              Vec<Pool>,
877    /// Per-replica probe results, parallel to [`pools`](Self::pools).
878    health:             Vec<ReplicaHealth>,
879    /// Round-robin cursor over `pools`.
880    next:               AtomicUsize,
881    /// [`ReadReplicaConfig::pin_after_write`], in milliseconds.
882    pin_after_write_ms: u64,
883    /// [`ReadReplicaConfig::max_lag`], in milliseconds; `None` = no lag gating.
884    max_lag_ms:         Option<u64>,
885    /// [`ReadReplicaConfig::health_probe_interval`].
886    probe_interval:     Duration,
887    /// Unix-epoch milliseconds of the most recent mutation-pipeline write;
888    /// `0` = never written.
889    last_write_unix_ms: AtomicU64,
890}
891
892/// What the last health probe observed about one replica (#957).
893///
894/// # Why a probe and not a measurement on the read path
895///
896/// Measuring lag inline would add a round trip to every replica-served read —
897/// the cost of the routing would exceed the cost of the read. The probe runs on
898/// its own cadence and the read path consults its last result, which means the
899/// result is *old*, and eligibility has to account for that.
900///
901/// # The staleness bound
902///
903/// A replica is eligible while `lag_at_probe + probe_age <= max_lag`.
904///
905/// That sum is a true upper bound on the replica's staleness right now, not an
906/// approximation: replay lag measured as "how long ago was the newest applied
907/// transaction committed" grows at most one millisecond per millisecond of wall
908/// clock (it grows exactly 1:1 while replay is stalled and shrinks whenever
909/// replay advances), so however far the probe has aged, the lag cannot have
910/// grown by more than that age.
911///
912/// It also makes the gate self-closing. Nothing has to notice that probing
913/// stopped — a probe that stops being refreshed ages past any budget on its own,
914/// and the replica falls out of rotation.
915struct ReplicaHealth {
916    /// Replay lag in milliseconds at [`probed_at_unix_ms`](Self::probed_at_unix_ms),
917    /// or [`LAG_UNMEASURABLE`] when the last probe could not produce one.
918    lag_ms:             AtomicU64,
919    /// Unix-epoch milliseconds of the last *successful* probe; `0` = never
920    /// probed, which reads as infinitely aged and therefore ineligible.
921    probed_at_unix_ms:  AtomicU64,
922    /// `pg_is_in_recovery()` as of the last successful probe. Seeded from the
923    /// boot health check so the first probe can report a *transition* — a
924    /// replica that left recovery between boot and now was promoted by a
925    /// failover, and is a writable server whose contents may diverge from the
926    /// primary in both directions.
927    in_recovery:        AtomicBool,
928    /// `pg_is_in_recovery()` at the boot health check, which is what makes the
929    /// transition legible.
930    ///
931    /// Booting outside recovery is *accepted* with a warning, because a dev rig
932    /// legitimately stands a plain database in for a replica; a server that
933    /// booted as a standby and later is not one has been promoted, and no dev
934    /// rig does that by accident. Only the second case stops taking reads —
935    /// which is why the boot reading has to be kept, not just the latest one.
936    booted_in_recovery: bool,
937}
938
939/// Sentinel [`ReplicaHealth::lag_ms`]: the last probe produced no lag reading.
940///
941/// A promoted replica (`pg_last_wal_replay_lsn()` is NULL outside recovery), a
942/// standby that has replayed no transaction yet, or a probe that failed
943/// outright. Saturating arithmetic keeps it above every budget, so "unknown"
944/// routes to the primary rather than being read as "zero".
945const LAG_UNMEASURABLE: u64 = u64::MAX;
946
947/// One round trip returning a replica's recovery state and replay lag.
948///
949/// `pg_last_xact_replay_timestamp()` alone over-reports on an idle primary — it
950/// answers "how old is the newest transaction I applied", which keeps growing
951/// when there is nothing left to apply. The first branch is the standard
952/// correction: a standby whose received WAL position equals its replayed one has
953/// applied everything it has been sent, and is by definition not behind.
954const REPLICA_PROBE_SQL: &str = "SELECT pg_is_in_recovery(), \
955     CASE \
956       WHEN NOT pg_is_in_recovery() THEN NULL::bigint \
957       WHEN pg_last_wal_replay_lsn() IS NOT NULL \
958            AND pg_last_wal_receive_lsn() = pg_last_wal_replay_lsn() THEN 0::bigint \
959       WHEN pg_last_xact_replay_timestamp() IS NULL THEN NULL::bigint \
960       ELSE GREATEST(0, EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp())) * 1000)::bigint \
961     END";
962
963impl ReplicaHealth {
964    /// Seed from the boot health check, before any probe has run.
965    const fn booted(in_recovery: bool) -> Self {
966        Self {
967            lag_ms:             AtomicU64::new(LAG_UNMEASURABLE),
968            probed_at_unix_ms:  AtomicU64::new(0),
969            in_recovery:        AtomicBool::new(in_recovery),
970            booted_in_recovery: in_recovery,
971        }
972    }
973
974    /// Whether this replica is still the kind of server it booted as.
975    ///
976    /// `false` only for the failover case: it was a standby at boot and the last
977    /// probe found it outside recovery, so something promoted it. That server is
978    /// now accepting writes of its own, and reads served from it can diverge
979    /// from the primary in *both* directions — unlike ordinary lag, which only
980    /// ever shows the past. It stops taking reads whether or not a staleness
981    /// budget is configured, because there is no budget under which "a different
982    /// database" is acceptable.
983    ///
984    /// A replica that booted outside recovery keeps the acceptance the boot check
985    /// gave it (dev rigs stand a plain database in for a standby, and the boot
986    /// warning already says so).
987    fn is_still_a_standby(&self) -> bool {
988        !self.booted_in_recovery || self.in_recovery.load(Ordering::Relaxed)
989    }
990
991    /// Whether this replica may serve a read with at most `max_lag_ms` of
992    /// staleness, as of `now_ms`. See the type docs for why the sum is a bound.
993    fn within_lag_budget(&self, now_ms: u64, max_lag_ms: u64) -> bool {
994        let probed_at = self.probed_at_unix_ms.load(Ordering::Relaxed);
995        if probed_at == 0 {
996            return false;
997        }
998        let lag = self.lag_ms.load(Ordering::Relaxed);
999        if lag == LAG_UNMEASURABLE {
1000            return false;
1001        }
1002        lag.saturating_add(now_ms.saturating_sub(probed_at)) <= max_lag_ms
1003    }
1004
1005    /// Record a probe result, logging a promotion the moment it is first seen.
1006    fn record(&self, index: usize, in_recovery: bool, lag_ms: Option<u64>) {
1007        if self.in_recovery.swap(in_recovery, Ordering::Relaxed) && !in_recovery {
1008            tracing::warn!(
1009                replica = index,
1010                "Read replica left recovery (pg_is_in_recovery() = false); a failover has \
1011                 promoted it to a writable server. Reads routed to it may diverge from the \
1012                 primary, and its replication lag is no longer measurable."
1013            );
1014        }
1015        self.lag_ms.store(lag_ms.unwrap_or(LAG_UNMEASURABLE), Ordering::Relaxed);
1016        // Only a probe that produced a reading counts as a probe: leaving the
1017        // timestamp behind is what ages an unreadable replica out of rotation.
1018        if lag_ms.is_some() {
1019            self.probed_at_unix_ms.store(now_unix_ms(), Ordering::Relaxed);
1020        }
1021    }
1022
1023    /// Mark the last probe as failed, without disturbing the recovery state a
1024    /// successful probe established.
1025    fn record_failure(&self) {
1026        self.lag_ms.store(LAG_UNMEASURABLE, Ordering::Relaxed);
1027    }
1028}
1029
1030/// Probe every replica in `set` forever, on its configured cadence.
1031///
1032/// Holds a [`Weak`] so the loop is not itself a reason for the replica set to
1033/// stay alive: when the last adapter clone drops, the upgrade fails and the task
1034/// ends. Probe failures are logged and retried on the next tick — a replica that
1035/// cannot be probed ages out of the lag budget on its own, and one configured
1036/// without a budget keeps serving, exactly as it did before probes existed.
1037async fn probe_read_replicas(set: Weak<ReadReplicaSet>) {
1038    loop {
1039        let Some(set) = set.upgrade() else { return };
1040        let interval = set.probe_interval;
1041
1042        for (index, pool) in set.pools.iter().enumerate() {
1043            let health = &set.health[index];
1044            let client = match pool.get().await {
1045                Ok(client) => client,
1046                Err(e) => {
1047                    tracing::debug!(replica = index, error = %e, "Read replica health probe \
1048                         could not acquire a connection");
1049                    health.record_failure();
1050                    continue;
1051                },
1052            };
1053            match client.query_one(REPLICA_PROBE_SQL, &[]).await {
1054                Ok(row) => {
1055                    let in_recovery: bool = row.get(0);
1056                    let lag_ms: Option<i64> = row.get(1);
1057                    // Reason (try_from): a negative lag would mean the standby
1058                    // replayed a transaction the future has not committed yet;
1059                    // GREATEST(0, …) already excludes it, and treating any such
1060                    // reading as unmeasurable keeps the fail-closed side.
1061                    health.record(index, in_recovery, lag_ms.and_then(|ms| u64::try_from(ms).ok()));
1062                },
1063                Err(e) => {
1064                    tracing::debug!(
1065                        replica = index,
1066                        error = %pg_detail(&e),
1067                        "Read replica health probe failed"
1068                    );
1069                    health.record_failure();
1070                },
1071            }
1072        }
1073
1074        // Drop the strong reference before sleeping: holding it across the sleep
1075        // would keep every replica pool open for one interval after the adapter
1076        // is gone.
1077        drop(set);
1078        tokio::time::sleep(interval).await;
1079    }
1080}
1081
1082/// Whether a read at `now_ms` falls inside the post-write primary pin.
1083///
1084/// Pure so the boundary cases are unit-testable without a clock: a never-armed
1085/// watermark (`last_write_ms == 0`) is only "recent" while the process's clock
1086/// is within `pin_ms` of the epoch, i.e. never in practice.
1087const fn pin_active(now_ms: u64, last_write_ms: u64, pin_ms: u64) -> bool {
1088    now_ms.saturating_sub(last_write_ms) < pin_ms
1089}
1090
1091/// Current Unix time in milliseconds.
1092fn now_unix_ms() -> u64 {
1093    use std::time::{SystemTime, UNIX_EPOCH};
1094    // Reason (map_or 0): a pre-epoch system clock degrades to "always pinned",
1095    // which is the safe (primary-only) side.
1096    SystemTime::now()
1097        .duration_since(UNIX_EPOCH)
1098        .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
1099}
1100
1101impl std::fmt::Debug for PostgresAdapter {
1102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1103        f.debug_struct("PostgresAdapter")
1104            .field("mutation_timing_enabled", &self.mutation_timing_enabled)
1105            .field("timing_variable_name", &self.timing_variable_name)
1106            .field("pool", &"<Pool>")
1107            .field(
1108                "read_replicas",
1109                &self.read_replicas.as_ref().map(|r| r.pools.len()).unwrap_or_default(),
1110            )
1111            .field("streaming_permits", &self.streaming_permits.available_permits())
1112            .finish()
1113    }
1114}
1115
1116impl PostgresAdapter {
1117    /// Create new PostgreSQL adapter with default pool configuration.
1118    ///
1119    /// # Arguments
1120    ///
1121    /// * `connection_string` - PostgreSQL connection string (e.g., "postgresql://localhost/mydb")
1122    ///
1123    /// # Errors
1124    ///
1125    /// Returns `FraiseQLError::ConnectionPool` if pool creation fails.
1126    ///
1127    /// # Example
1128    ///
1129    /// ```rust,no_run
1130    /// # use fraiseql_db::postgres::PostgresAdapter;
1131    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1132    /// let adapter = PostgresAdapter::new("postgresql://localhost/mydb").await?;
1133    /// # Ok(())
1134    /// # }
1135    /// ```
1136    pub async fn new(connection_string: &str) -> Result<Self> {
1137        Self::with_pool_config(
1138            connection_string,
1139            PoolPrewarmConfig {
1140                min_size:            0,
1141                max_size:            DEFAULT_POOL_SIZE,
1142                timeout_secs:        None,
1143                search_path:         None,
1144                max_streaming_reads: None,
1145                // The recall-preserving default; see `VectorScanConfig` (#1116).
1146                vector_scan:         VectorScanConfig::default(),
1147                // libpq's default: negotiate TLS when the server offers it. Callers
1148                // that need a guarantee go through `with_pool_config`.
1149                tls:                 PostgresTlsConfig::default(),
1150                read_replicas:       None,
1151            },
1152        )
1153        .await
1154    }
1155
1156    /// Create new PostgreSQL adapter with pre-warming and timeout configuration.
1157    ///
1158    /// Constructs the pool, runs a startup health check, then eagerly opens
1159    /// `cfg.min_size` connections so they are ready when the first request arrives.
1160    ///
1161    /// # Arguments
1162    ///
1163    /// * `connection_string` - PostgreSQL connection string
1164    /// * `cfg` - Pool pre-warming and timeout configuration
1165    ///
1166    /// # Errors
1167    ///
1168    /// Returns `FraiseQLError::ConnectionPool` if pool creation or the startup
1169    /// health check fails.
1170    pub async fn with_pool_config(connection_string: &str, cfg: PoolPrewarmConfig) -> Result<Self> {
1171        let pool = build_pool(
1172            connection_string,
1173            cfg.max_size,
1174            cfg.timeout_secs,
1175            cfg.search_path.as_ref(),
1176            cfg.vector_scan,
1177            &cfg.tls,
1178        )?;
1179
1180        // Startup health check — establishes the first connection, and refuses a
1181        // server below the supported floor before anything else runs against it.
1182        let client = pool.get().await.map_err(|e| FraiseQLError::ConnectionPool {
1183            message: format!("Failed to acquire connection: {e}"),
1184        })?;
1185        require_supported_server(&client).await?;
1186
1187        // Drop client back to the pool before pre-warming so that the health-check
1188        // connection counts as idle slot #1.
1189        drop(client);
1190
1191        // Replica pools are built from the SAME config as the primary — search
1192        // path, TLS, sizing — so per-tenant isolation applies to every pool this
1193        // adapter ever reads through, not just the one it writes through (#407).
1194        let read_replicas = match &cfg.read_replicas {
1195            None => None,
1196            Some(rc) => {
1197                let set = Arc::new(build_read_replica_set(rc, &cfg).await?);
1198                // Weak, so the probe loop ends when the last adapter clone drops
1199                // rather than keeping the pools alive for the process's lifetime.
1200                tokio::spawn(probe_read_replicas(Arc::downgrade(&set)));
1201                Some(set)
1202            },
1203        };
1204
1205        let adapter = Self {
1206            pool,
1207            read_replicas,
1208            mutation_timing_enabled: false,
1209            timing_variable_name: "fraiseql.started_at".to_string(),
1210            streaming_permits: Arc::new(Semaphore::new(streaming_read_slots(
1211                cfg.max_streaming_reads,
1212                cfg.max_size,
1213            ))),
1214        };
1215
1216        // Pre-warm to `min_size` — NOT `min_size - 1`. The health-check connection
1217        // above is returned to the pool, so the first warm acquisition recycles it
1218        // rather than opening a new one; subtracting for it left the pool one
1219        // connection short of `min_size` (#937). Acquiring `min_size` concurrently
1220        // is self-correcting: whatever is already idle gets reused, the rest are
1221        // opened, and the pool's own `max_size` caps the total.
1222        let warm_target = cfg.min_size.min(cfg.max_size);
1223        if warm_target > 0 {
1224            adapter.prewarm(warm_target).await;
1225        }
1226
1227        Ok(adapter)
1228    }
1229
1230    /// Create new PostgreSQL adapter with custom pool size.
1231    ///
1232    /// # Arguments
1233    ///
1234    /// * `connection_string` - PostgreSQL connection string
1235    /// * `max_size` - Maximum number of connections in pool
1236    ///
1237    /// # Errors
1238    ///
1239    /// Returns `FraiseQLError::ConnectionPool` if pool creation fails.
1240    pub async fn with_pool_size(connection_string: &str, max_size: usize) -> Result<Self> {
1241        Self::with_pool_config(
1242            connection_string,
1243            PoolPrewarmConfig {
1244                min_size: 0,
1245                max_size,
1246                timeout_secs: None,
1247                search_path: None,
1248                tls: PostgresTlsConfig::default(),
1249                read_replicas: None,
1250                max_streaming_reads: None,
1251                vector_scan: VectorScanConfig::default(),
1252            },
1253        )
1254        .await
1255    }
1256
1257    /// Pre-warm the pool by opening `count` additional connections.
1258    ///
1259    /// Pre-warming is best-effort: failures from individual connections are logged
1260    /// but do not prevent startup. A 10-second outer timeout ensures the server
1261    /// never blocks indefinitely on a slow or unreachable PostgreSQL instance.
1262    async fn prewarm(&self, count: usize) {
1263        use futures::future::join_all;
1264        use tokio::time::timeout;
1265
1266        // Acquire every guard CONCURRENTLY and hold them all until the last one
1267        // is in hand. Acquiring one at a time — or spawning tasks that release
1268        // as they finish — lets a later acquisition recycle a connection an
1269        // earlier one just returned, so the pool settles below `count` and
1270        // `pool_min_size` silently under-delivers (#937).
1271        let result =
1272            timeout(Duration::from_secs(10), join_all((0..count).map(|_| self.pool.get()))).await;
1273
1274        let (succeeded, failed) = match result {
1275            Ok(guards) => {
1276                let s = guards.iter().filter(|r| r.is_ok()).count();
1277                // Released together, so `s` distinct connections stay in the pool.
1278                drop(guards);
1279                (s, count - s)
1280            },
1281            Err(_elapsed) => {
1282                tracing::warn!(
1283                    target_connections = count,
1284                    "Pool pre-warm timed out after 10s; server will continue with partial pre-warm"
1285                );
1286                (0, count)
1287            },
1288        };
1289
1290        if failed > 0 {
1291            tracing::warn!(
1292                succeeded,
1293                failed,
1294                "Pool pre-warm: some connections could not be established"
1295            );
1296        } else {
1297            // Report what the pool actually holds, not what was attempted.
1298            tracing::info!(
1299                idle_connections = self.pool.status().available,
1300                "PostgreSQL pool pre-warmed successfully"
1301            );
1302        }
1303    }
1304
1305    /// Get a reference to the internal connection pool.
1306    ///
1307    /// This allows sharing the pool with other components like `PostgresIntrospector`.
1308    #[must_use]
1309    pub const fn pool(&self) -> &Pool {
1310        &self.pool
1311    }
1312
1313    /// Enable mutation timing injection.
1314    ///
1315    /// When enabled, `execute_write` sets a session variable to `clock_timestamp()::text` in
1316    /// each write's transaction before the call, allowing SQL functions to compute their own
1317    /// duration.
1318    ///
1319    /// # Arguments
1320    ///
1321    /// * `variable_name` - The PostgreSQL session variable name (e.g., `"fraiseql.started_at"`)
1322    #[must_use]
1323    pub fn with_mutation_timing(mut self, variable_name: &str) -> Self {
1324        self.mutation_timing_enabled = true;
1325        self.timing_variable_name = variable_name.to_string();
1326        self
1327    }
1328
1329    /// Returns whether mutation timing injection is enabled.
1330    #[must_use]
1331    pub const fn mutation_timing_enabled(&self) -> bool {
1332        self.mutation_timing_enabled
1333    }
1334
1335    /// Execute raw SQL query and return JSONB rows.
1336    ///
1337    /// # Errors
1338    ///
1339    /// Returns `FraiseQLError::Database` on query execution failure.
1340    pub(super) async fn execute_raw(
1341        &self,
1342        sql: &str,
1343        params: &[&(dyn tokio_postgres::types::ToSql + Sync)],
1344        routing: ReadRouting,
1345    ) -> Result<Vec<JsonbValue>> {
1346        // Read-only by construction (`SELECT data FROM <view>`): replica-eligible.
1347        let client = self.acquire_read_connection_with_retry(routing).await?;
1348
1349        let rows: Vec<Row> =
1350            client.query(sql, params).await.map_err(|e| FraiseQLError::Database {
1351                message:   format!("Query execution failed: {}", pg_detail(&e)),
1352                sql_state: e.code().map(|c| c.code().to_string()),
1353            })?;
1354
1355        let results = rows
1356            .into_iter()
1357            .map(|row| jsonb_cell(&row, 0, sql))
1358            .collect::<Result<Vec<_>>>()?;
1359
1360        Ok(results)
1361    }
1362
1363    /// Like [`execute_raw`](Self::execute_raw) but applies transaction-local
1364    /// session variables on the same connection / transaction that runs the
1365    /// query.
1366    ///
1367    /// `set_config(..., true)` and the `SELECT` share one transaction, so
1368    /// PostgreSQL RLS policies backed by `current_setting()` see the configured
1369    /// values (fixes #329).
1370    ///
1371    /// # Errors
1372    ///
1373    /// Returns `FraiseQLError::Database` on transaction, `set_config`, query, or
1374    /// commit failure.
1375    pub(super) async fn execute_raw_with_session(
1376        &self,
1377        sql: &str,
1378        params: &[&(dyn tokio_postgres::types::ToSql + Sync)],
1379        session_vars: &[(&str, &str)],
1380        routing: ReadRouting,
1381    ) -> Result<Vec<JsonbValue>> {
1382        // Read-only by construction; `set_config` + SELECT run fine inside a
1383        // hot-standby read-only transaction, so this stays replica-eligible.
1384        let mut client = self.acquire_read_connection_with_retry(routing).await?;
1385        let txn =
1386            client.build_transaction().start().await.map_err(|e| FraiseQLError::Database {
1387                message:   format!("Failed to start session-var transaction: {}", pg_detail(&e)),
1388                sql_state: e.code().map(|c| c.code().to_string()),
1389            })?;
1390
1391        database::apply_session_vars(&txn, session_vars).await?;
1392
1393        let rows: Vec<Row> = txn.query(sql, params).await.map_err(|e| FraiseQLError::Database {
1394            message:   format!("Query execution failed: {}", pg_detail(&e)),
1395            sql_state: e.code().map(|c| c.code().to_string()),
1396        })?;
1397
1398        txn.commit().await.map_err(|e| FraiseQLError::Database {
1399            message:   format!("Failed to commit session-var transaction: {}", pg_detail(&e)),
1400            sql_state: e.code().map(|c| c.code().to_string()),
1401        })?;
1402
1403        rows.into_iter().map(|row| jsonb_cell(&row, 0, sql)).collect()
1404    }
1405
1406    /// Record a mutation-pipeline write for read-your-writes pinning (#407).
1407    ///
1408    /// Called at both entry and successful exit of every mutation-pipeline
1409    /// method: entry so a read racing a slow write already pins, exit because
1410    /// replication lag is measured from commit. A no-op without replicas.
1411    pub(super) fn mark_write(&self) {
1412        if let Some(replicas) = &self.read_replicas {
1413            replicas.last_write_unix_ms.store(now_unix_ms(), Ordering::Relaxed);
1414        }
1415    }
1416
1417    /// Acquire a connection for a **structurally read-only** statement (#407).
1418    ///
1419    /// Routing, in order:
1420    /// 1. no replicas configured → primary;
1421    /// 2. inside the post-write pin window → primary (read-your-writes);
1422    /// 3. otherwise round-robin over the replicas, skipping any outside the bounded-staleness
1423    ///    budget and any whose acquisition fails, falling back to the primary when none is left.
1424    ///
1425    /// Only methods that can never write may call this; anything mixed-use
1426    /// (`execute_raw_query`, stats, health, DDL) stays on
1427    /// [`acquire_connection_with_retry`](Self::acquire_connection_with_retry).
1428    ///
1429    /// # Errors
1430    ///
1431    /// Returns `FraiseQLError::ConnectionPool` when every replica *and* the
1432    /// primary fallback fail to yield a connection.
1433    pub(super) async fn acquire_read_connection_with_retry(
1434        &self,
1435        routing: ReadRouting,
1436    ) -> Result<deadpool_postgres::Client> {
1437        let Some(replicas) = &self.read_replicas else {
1438            return self.acquire_connection_with_retry().await;
1439        };
1440
1441        // A query annotated `primary` is asking not to be served stale, which no
1442        // replica can promise (#957).
1443        if !routing.allows_replica() {
1444            return self.acquire_connection_with_retry().await;
1445        }
1446
1447        let now_ms = now_unix_ms();
1448        // `replica` opts out of the pin: the pin exists so a client reads its own
1449        // writes, and this query has said that is not what it is for.
1450        if routing.honours_write_pin()
1451            && pin_active(
1452                now_ms,
1453                replicas.last_write_unix_ms.load(Ordering::Relaxed),
1454                replicas.pin_after_write_ms,
1455            )
1456        {
1457            return self.acquire_connection_with_retry().await;
1458        }
1459
1460        let max_lag_ms = routing.effective_max_lag_ms(replicas.max_lag_ms);
1461
1462        let count = replicas.pools.len();
1463        let start = replicas.next.fetch_add(1, Ordering::Relaxed);
1464        let mut ineligible = 0_usize;
1465        for offset in 0..count {
1466            let index = (start.wrapping_add(offset)) % count;
1467            // The lag gate comes before acquisition, not after: a replica outside
1468            // the budget must not be *reached*, and checking afterwards would
1469            // burn a connection to learn what the last probe already knew.
1470            let health = &replicas.health[index];
1471            let eligible = health.is_still_a_standby()
1472                && max_lag_ms.is_none_or(|budget| health.within_lag_budget(now_ms, budget));
1473            if !eligible {
1474                ineligible += 1;
1475                continue;
1476            }
1477            match replicas.pools[index].get().await {
1478                Ok(client) => return Ok(client),
1479                Err(e) => {
1480                    tracing::warn!(
1481                        replica = index,
1482                        error = %e,
1483                        "Read replica connection acquisition failed; trying the next candidate"
1484                    );
1485                },
1486            }
1487        }
1488
1489        if ineligible > 0 {
1490            tracing::debug!(
1491                ineligible,
1492                replicas = count,
1493                "Read replicas outside the bounded-staleness budget, or promoted out of \
1494                 recovery; serving the read from the primary"
1495            );
1496        } else {
1497            tracing::warn!("All read replicas unavailable; serving the read from the primary");
1498        }
1499        self.acquire_connection_with_retry().await
1500    }
1501
1502    /// Acquire a connection from the pool with retry logic.
1503    ///
1504    /// - `PoolError::Timeout`: the pool was exhausted for the full configured wait period. This is
1505    ///   not transient — retrying would only multiply the wait. Fails immediately.
1506    /// - `PoolError::Backend` / create errors: potentially transient. Retries with exponential
1507    ///   backoff (up to `MAX_CONNECTION_RETRIES` attempts).
1508    ///
1509    /// # Errors
1510    ///
1511    /// Returns `FraiseQLError::ConnectionPool` on timeout or when all retries are exhausted.
1512    pub(super) async fn acquire_connection_with_retry(&self) -> Result<deadpool_postgres::Client> {
1513        use deadpool_postgres::PoolError;
1514
1515        let mut last_error = None;
1516
1517        for attempt in 0..MAX_CONNECTION_RETRIES {
1518            match self.pool.get().await {
1519                Ok(client) => {
1520                    if attempt > 0 {
1521                        tracing::info!(attempt, "Successfully acquired connection after retries");
1522                    }
1523                    return Ok(client);
1524                },
1525                // Pool exhausted for the full wait period — not transient, fail immediately.
1526                Err(PoolError::Timeout(_)) => {
1527                    let metrics = self.pool_metrics();
1528                    tracing::error!(
1529                        available = metrics.idle_connections,
1530                        active = metrics.active_connections,
1531                        max = metrics.total_connections,
1532                        "Connection pool timeout: all connections busy"
1533                    );
1534                    return Err(FraiseQLError::ConnectionPool {
1535                        message: format!(
1536                            "Connection pool timeout: {}/{} connections busy. \
1537                             Increase pool_max_size or reduce concurrent load.",
1538                            metrics.active_connections, metrics.total_connections,
1539                        ),
1540                    });
1541                },
1542                // Backend/create errors are potentially transient — retry with backoff.
1543                Err(e) => {
1544                    last_error = Some(e);
1545                    if attempt < MAX_CONNECTION_RETRIES - 1 {
1546                        let delay = CONNECTION_RETRY_DELAY_MS * (u64::from(attempt) + 1);
1547                        tracing::warn!(
1548                            attempt = attempt + 1,
1549                            total = MAX_CONNECTION_RETRIES,
1550                            delay_ms = delay,
1551                            "Transient connection error, retrying"
1552                        );
1553                        tokio::time::sleep(Duration::from_millis(delay)).await;
1554                    }
1555                },
1556            }
1557        }
1558
1559        // All retries for transient errors exhausted.
1560        let pool_metrics = self.pool_metrics();
1561        tracing::error!(
1562            retries = MAX_CONNECTION_RETRIES,
1563            available = pool_metrics.idle_connections,
1564            active = pool_metrics.active_connections,
1565            max = pool_metrics.total_connections,
1566            "Failed to acquire connection after all retries"
1567        );
1568
1569        Err(FraiseQLError::ConnectionPool {
1570            message: format!(
1571                "Failed to acquire connection after {} retries: {}. \
1572                 Pool state: idle={}, active={}, max={}",
1573                MAX_CONNECTION_RETRIES,
1574                last_error.expect("last_error is set on every retry iteration"),
1575                pool_metrics.idle_connections,
1576                pool_metrics.active_connections,
1577                pool_metrics.total_connections,
1578            ),
1579        })
1580    }
1581
1582    /// Execute query with SQL field projection optimization.
1583    ///
1584    /// Uses the provided `SqlProjectionHint` to generate optimized SQL that projects
1585    /// only the requested fields from the JSONB column, reducing network payload and
1586    /// JSON deserialization overhead.
1587    ///
1588    /// # Arguments
1589    ///
1590    /// * `view` - View/table name to query
1591    /// * `projection` - Optional SQL projection hint with field list
1592    /// * `where_clause` - Optional WHERE clause for filtering
1593    /// * `limit` - Optional row limit
1594    ///
1595    /// # Returns
1596    ///
1597    /// Vector of projected JSONB rows with only the requested fields
1598    ///
1599    /// # Errors
1600    ///
1601    /// Returns `FraiseQLError::Database` on query execution failure.
1602    ///
1603    /// # Panics
1604    ///
1605    /// Cannot panic in practice: the inner `expect` is guarded by an `is_none()` check
1606    /// immediately above it.
1607    ///
1608    /// # Example
1609    ///
1610    /// ```no_run
1611    /// // Requires: running PostgreSQL database.
1612    /// use fraiseql_db::postgres::PostgresAdapter;
1613    /// use fraiseql_db::types::SqlProjectionHint;
1614    /// use fraiseql_db::DatabaseType;
1615    ///
1616    /// # async fn example(adapter: &PostgresAdapter) -> Result<(), Box<dyn std::error::Error>> {
1617    /// let projection = SqlProjectionHint::new(
1618    ///     DatabaseType::PostgreSQL,
1619    ///     "jsonb_build_object('id', data->>'id')".to_string(),
1620    ///     75,
1621    /// );
1622    ///
1623    /// let results = adapter
1624    ///     .execute_with_projection("v_user", Some(&projection), None, Some(10), None)
1625    ///     .await?;
1626    /// # Ok(())
1627    /// # }
1628    /// ```
1629    /// Implementation of `execute_with_projection` with ORDER BY support.
1630    ///
1631    /// Called by both the inherent convenience method and the `DatabaseAdapter`
1632    /// trait implementation.
1633    pub(super) async fn execute_with_projection_impl(
1634        &self,
1635        view: &str,
1636        projection: Option<&SqlProjectionHint>,
1637        where_clause: Option<&WhereClause>,
1638        limit: Option<u32>,
1639        offset: Option<u32>,
1640        order_by: Option<&[OrderByClause]>,
1641    ) -> Result<Vec<JsonbValue>> {
1642        // If no projection, fall back to standard query
1643        if projection.is_none() {
1644            return self.execute_where_query(view, where_clause, limit, offset, order_by).await;
1645        }
1646
1647        let projection = projection.expect("projection is Some; None was returned above");
1648
1649        let (sql, typed_params) =
1650            build_projection_select_sql(projection, view, where_clause, limit, offset, order_by)?;
1651
1652        tracing::debug!("SQL with projection = {}", sql);
1653        tracing::debug!("typed_params = {:?}", typed_params);
1654
1655        let param_refs = crate::types::as_sql_param_refs(&typed_params);
1656
1657        self.execute_raw(&sql, &param_refs, ReadRouting::Any).await
1658    }
1659
1660    /// Execute query with SQL field projection optimization.
1661    ///
1662    /// Convenience wrapper for callers that don't need ORDER BY.
1663    /// See `execute_with_projection_impl` for details.
1664    ///
1665    /// # Errors
1666    ///
1667    /// Returns `FraiseQLError::Database` on query execution failure.
1668    pub async fn execute_with_projection(
1669        &self,
1670        view: &str,
1671        projection: Option<&SqlProjectionHint>,
1672        where_clause: Option<&WhereClause>,
1673        limit: Option<u32>,
1674        offset: Option<u32>,
1675    ) -> Result<Vec<JsonbValue>> {
1676        self.execute_with_projection_impl(view, projection, where_clause, limit, offset, None)
1677            .await
1678    }
1679}
1680
1681/// Build a parameterized `SELECT data FROM {view}` SQL string.
1682///
1683/// Shared by [`PostgresAdapter::execute_where_query`] and
1684/// [`PostgresAdapter::explain_where_query`] so that SQL construction
1685/// logic is never duplicated.
1686///
1687/// # Returns
1688///
1689/// `(sql, typed_params)` — the SQL string and the bound parameter values.
1690///
1691/// # Errors
1692///
1693/// Returns `FraiseQLError` if WHERE clause generation fails.
1694pub(super) fn build_where_select_sql(
1695    view: &str,
1696    where_clause: Option<&WhereClause>,
1697    limit: Option<u32>,
1698    offset: Option<u32>,
1699) -> Result<(String, Vec<QueryParam>)> {
1700    build_where_select_sql_ordered(view, where_clause, limit, offset, None)
1701}
1702
1703/// Build a parameterized `SELECT data FROM {view}` SQL string with optional ORDER BY.
1704///
1705/// ORDER BY is inserted between the WHERE clause and LIMIT/OFFSET as required by SQL.
1706///
1707/// # Returns
1708///
1709/// `(sql, typed_params)` — the SQL string and the bound parameter values.
1710///
1711/// # Errors
1712///
1713/// Returns `FraiseQLError` if WHERE clause generation or field name validation fails.
1714/// `SELECT COUNT(*) FROM {view} [WHERE …]` and its parameters (#938).
1715///
1716/// Deliberately shares [`PostgresWhereGenerator`] with the row query rather than
1717/// formatting its own predicate: the count and the page it describes have to
1718/// agree, and two independent renderings of the same `WhereClause` is how they
1719/// would stop agreeing. No `ORDER BY` (it cannot change a count, and sorting the
1720/// rows first is pure cost) and no `LIMIT`/`OFFSET` (the total is the whole
1721/// point).
1722pub(super) fn build_count_sql(
1723    view: &str,
1724    where_clause: Option<&WhereClause>,
1725) -> Result<(String, Vec<QueryParam>)> {
1726    let mut sql = format!("SELECT COUNT(*) FROM {}", quote_postgres_identifier(view));
1727    let typed_params: Vec<QueryParam> = if let Some(clause) = where_clause {
1728        let generator = PostgresWhereGenerator::new(PostgresDialect);
1729        let (where_sql, where_params) = generator.generate(clause)?;
1730        sql.push_str(" WHERE ");
1731        sql.push_str(&where_sql);
1732        where_params.into_iter().map(QueryParam::from).collect()
1733    } else {
1734        Vec::new()
1735    };
1736    Ok((sql, typed_params))
1737}
1738
1739pub(super) fn build_where_select_sql_ordered(
1740    view: &str,
1741    where_clause: Option<&WhereClause>,
1742    limit: Option<u32>,
1743    offset: Option<u32>,
1744    order_by: Option<&[OrderByClause]>,
1745) -> Result<(String, Vec<QueryParam>)> {
1746    // Build base query
1747    let mut sql = format!("SELECT data FROM {}", quote_postgres_identifier(view));
1748
1749    // Collect WHERE clause params (if any)
1750    let mut typed_params: Vec<QueryParam> = if let Some(clause) = where_clause {
1751        let generator = PostgresWhereGenerator::new(PostgresDialect);
1752        let (where_sql, where_params) = generator.generate(clause)?;
1753        sql.push_str(" WHERE ");
1754        sql.push_str(&where_sql);
1755
1756        // Convert WHERE clause JSON values to QueryParam
1757        where_params.into_iter().map(QueryParam::from).collect()
1758    } else {
1759        Vec::new()
1760    };
1761    let mut param_count = typed_params.len();
1762
1763    // ORDER BY must come before LIMIT/OFFSET in SQL — and so must its
1764    // parameters. A relevance ordering (#1284) binds the search text, so the
1765    // placeholders run WHERE → ORDER BY → LIMIT/OFFSET, which is the order the
1766    // fragments are appended in.
1767    // `Tiebreak::Identity`: this read is paged by LIMIT/OFFSET, so its ordering
1768    // has to be total or the pages are a slice of no sequence (#1287).
1769    for bound in append_order_by(
1770        &mut sql,
1771        order_by,
1772        DatabaseType::PostgreSQL,
1773        param_count + 1,
1774        Tiebreak::Identity,
1775    )? {
1776        param_count += 1;
1777        typed_params.push(QueryParam::Text(bound));
1778    }
1779
1780    // Add LIMIT as BigInt (PostgreSQL requires integer type for LIMIT).
1781    // Reason (expect below): fmt::Write for String is infallible.
1782    if let Some(lim) = limit {
1783        param_count += 1;
1784        write!(sql, " LIMIT ${param_count}").expect("write to String");
1785        typed_params.push(QueryParam::BigInt(i64::from(lim)));
1786    }
1787
1788    // Add OFFSET as BigInt (PostgreSQL requires integer type for OFFSET)
1789    if let Some(off) = offset {
1790        param_count += 1;
1791        write!(sql, " OFFSET ${param_count}").expect("write to String");
1792        typed_params.push(QueryParam::BigInt(i64::from(off)));
1793    }
1794
1795    Ok((sql, typed_params))
1796}
1797
1798/// Build a parameterized projection `SELECT` SQL string.
1799///
1800/// Mirrors the SQL produced inline by [`PostgresAdapter::execute_with_projection_impl`],
1801/// extracted so the connection-affine `*_with_session` path can reuse it
1802/// without acquiring its own connection.
1803///
1804/// # Returns
1805///
1806/// `(sql, typed_params)` — the SQL string and the bound parameter values.
1807///
1808/// # Errors
1809///
1810/// Returns `FraiseQLError` if WHERE clause generation fails.
1811pub(super) fn build_projection_select_sql(
1812    projection: &SqlProjectionHint,
1813    view: &str,
1814    where_clause: Option<&WhereClause>,
1815    limit: Option<u32>,
1816    offset: Option<u32>,
1817    order_by: Option<&[OrderByClause]>,
1818) -> Result<(String, Vec<QueryParam>)> {
1819    // The projection_template is the SELECT clause with projection SQL,
1820    // e.g. "jsonb_build_object('id', data->>'id', 'email', data->>'email')".
1821    let mut sql = format!(
1822        "SELECT {} FROM {}",
1823        projection.projection_template,
1824        quote_postgres_identifier(view)
1825    );
1826
1827    let mut typed_params: Vec<QueryParam> = if let Some(clause) = where_clause {
1828        let generator = PostgresWhereGenerator::new(PostgresDialect);
1829        let (where_sql, where_params) = generator.generate(clause)?;
1830        sql.push_str(" WHERE ");
1831        sql.push_str(&where_sql);
1832        where_params.into_iter().map(QueryParam::from).collect()
1833    } else {
1834        Vec::new()
1835    };
1836    let mut param_count = typed_params.len();
1837
1838    // ORDER BY must come before LIMIT/OFFSET in SQL — and so must its
1839    // parameters (#1284); see `build_where_select_sql_ordered`.
1840    // `Tiebreak::Identity`: this read is paged by LIMIT/OFFSET, so its ordering
1841    // has to be total or the pages are a slice of no sequence (#1287).
1842    for bound in append_order_by(
1843        &mut sql,
1844        order_by,
1845        DatabaseType::PostgreSQL,
1846        param_count + 1,
1847        Tiebreak::Identity,
1848    )? {
1849        param_count += 1;
1850        typed_params.push(QueryParam::Text(bound));
1851    }
1852
1853    // Append LIMIT/OFFSET as BigInt (PostgreSQL requires integer type).
1854    // Reason (expect below): fmt::Write for String is infallible.
1855    if let Some(lim) = limit {
1856        param_count += 1;
1857        write!(sql, " LIMIT ${param_count}").expect("write to String");
1858        typed_params.push(QueryParam::BigInt(i64::from(lim)));
1859    }
1860
1861    if let Some(off) = offset {
1862        param_count += 1;
1863        write!(sql, " OFFSET ${param_count}").expect("write to String");
1864        typed_params.push(QueryParam::BigInt(i64::from(off)));
1865    }
1866
1867    Ok((sql, typed_params))
1868}