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, ¶m_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}