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