fraiseql_db/postgres/adapter/mod.rs
1//! PostgreSQL database adapter implementation.
2
3mod database;
4mod query_stats;
5mod relay;
6
7#[cfg(test)]
8mod tests;
9
10#[cfg(all(test, feature = "test-postgres"))]
11mod integration_tests;
12
13use std::{fmt::Write, time::Duration};
14
15use deadpool_postgres::{Config, ManagerConfig, Pool, RecyclingMethod, Runtime};
16use fraiseql_error::{FraiseQLError, Result};
17use tokio_postgres::{NoTls, Row};
18
19use super::where_generator::PostgresWhereGenerator;
20use crate::{
21 dialect::PostgresDialect,
22 identifier::quote_postgres_identifier,
23 order_by::append_order_by,
24 traits::DatabaseAdapter,
25 types::{
26 DatabaseType, JsonbValue, QueryParam,
27 sql_hints::{OrderByClause, SqlProjectionHint},
28 },
29 where_clause::WhereClause,
30};
31
32/// Extract the JSONB `data` cell from a result row, failing loud rather than
33/// panicking.
34///
35/// `Row::get` panics on SQL NULL or a non-JSONB column type, so a backing view
36/// that projects NULL `data` (e.g. via a LEFT JOIN) or a mistyped `data` column
37/// turned a query into a request-path panic — the PostgreSQL adapter was the
38/// only backend that aborted here instead of returning an error (audit H34).
39/// Both the NULL and the type-mismatch cases now map to
40/// [`FraiseQLError::Database`], including a bounded, char-safe slice of the
41/// query so an operator can identify the offending view.
42fn jsonb_cell<I>(row: &Row, column: I, sql: &str) -> Result<JsonbValue>
43where
44 I: tokio_postgres::row::RowIndex + std::fmt::Display,
45{
46 // `.chars().take(..)` is inherently char-boundary-safe (no byte slicing).
47 let query_preview = || sql.chars().take(200).collect::<String>();
48 match row.try_get::<_, Option<serde_json::Value>>(column) {
49 Ok(Some(value)) => Ok(JsonbValue::new(value)),
50 Ok(None) => Err(FraiseQLError::Database {
51 message: format!(
52 "Query returned a NULL `data` column; the backing view must project a \
53 non-NULL JSONB `data` value (a view yielding NULL `data`, e.g. via a \
54 LEFT JOIN, is unsupported). Query: {}",
55 query_preview()
56 ),
57 sql_state: None,
58 }),
59 Err(e) => Err(FraiseQLError::Database {
60 message: format!(
61 "Failed to read the `data` column as JSONB ({e}); the backing view must \
62 project a JSONB `data` column. Query: {}",
63 query_preview()
64 ),
65 sql_state: None,
66 }),
67 }
68}
69
70/// Default maximum pool size for PostgreSQL connections.
71/// Increased from 10 to 25 to prevent pool exhaustion under concurrent
72/// nested query load (fixes Issue #41).
73const DEFAULT_POOL_SIZE: usize = 25;
74
75/// Maximum retries for connection acquisition with exponential backoff.
76const MAX_CONNECTION_RETRIES: u32 = 3;
77
78/// Base delay in milliseconds for connection retry backoff.
79const CONNECTION_RETRY_DELAY_MS: u64 = 50;
80
81/// Configuration for connection pool construction and pre-warming.
82///
83/// Controls the minimum guaranteed connections (pre-warmed at startup),
84/// the maximum pool ceiling, and the wait/create timeout for connection
85/// acquisition.
86///
87/// # Example
88///
89/// ```rust
90/// use fraiseql_db::postgres::PoolPrewarmConfig;
91///
92/// let cfg = PoolPrewarmConfig {
93/// min_size: 5,
94/// max_size: 20,
95/// timeout_secs: Some(30),
96/// };
97/// ```
98#[derive(Debug, Clone)]
99pub struct PoolPrewarmConfig {
100 /// Number of connections to establish at pool creation time.
101 ///
102 /// After the pool is created, `min_size` connections are opened eagerly
103 /// so they are ready when the first request arrives. Set to `0` to disable
104 /// pre-warming (lazy init — one connection from the startup health check).
105 pub min_size: usize,
106
107 /// Maximum number of connections the pool may hold.
108 pub max_size: usize,
109
110 /// Optional timeout (in seconds) for connection acquisition and creation.
111 ///
112 /// Applied to both the `wait` (blocked waiting for an idle connection) and
113 /// `create` (time to open a new TCP connection to PostgreSQL) deadpool slots.
114 /// When `None`, acquisition can block indefinitely on pool exhaustion.
115 pub timeout_secs: Option<u64>,
116}
117
118/// Build a `deadpool-postgres` pool with an optional wait/create timeout.
119///
120/// # Errors
121///
122/// Returns `FraiseQLError::ConnectionPool` if pool creation fails (e.g., unparseable URL).
123fn build_pool(connection_string: &str, max_size: usize, timeout_secs: Option<u64>) -> Result<Pool> {
124 let mut cfg = Config::new();
125 cfg.url = Some(connection_string.to_string());
126 cfg.manager = Some(ManagerConfig {
127 recycling_method: RecyclingMethod::Fast,
128 });
129
130 let mut pool_cfg = deadpool_postgres::PoolConfig::new(max_size);
131 if let Some(secs) = timeout_secs {
132 let t = Duration::from_secs(secs);
133 pool_cfg.timeouts.wait = Some(t);
134 pool_cfg.timeouts.create = Some(t);
135 // `recycle` intentionally stays None — fast recycle, not user-configurable.
136 }
137 cfg.pool = Some(pool_cfg);
138
139 // Connections use `NoTls` by design (#445). FraiseQL's deployment model terminates
140 // transport security outside the adapter: the database is reached over a trusted
141 // network — a TLS-terminating proxy (pgbouncer, cloud-sql-proxy, a service mesh) or a
142 // loopback/private link (the prod compose stacks bind Postgres to loopback, #436/H46).
143 // This mirrors the server-side TLS stance (terminated at the ingress/proxy, not the app).
144 // A `sslmode=require` in the connection string is therefore NOT honored here; wiring a
145 // rustls `MakeTlsConnect` through deadpool is a deliberate future extension, not a
146 // silent partial one.
147 cfg.create_pool(Some(Runtime::Tokio1), NoTls)
148 .map_err(|e| FraiseQLError::ConnectionPool {
149 message: format!("Failed to create connection pool: {e}"),
150 })
151}
152
153/// Escape a JSONB key for use in a PostgreSQL string literal (`data->>'key'`).
154///
155/// PostgreSQL string literals use single-quote doubling for escaping (`'` → `''`).
156/// This function is defense-in-depth: `OrderByClause` already rejects field names
157/// that are not valid GraphQL identifiers (which cannot contain `'`), but this
158/// escaping ensures correctness for any future caller that bypasses that validation.
159pub(super) fn escape_jsonb_key(key: &str) -> String {
160 key.replace('\'', "''")
161}
162
163/// PostgreSQL database adapter with connection pooling.
164///
165/// Uses `deadpool-postgres` for connection pooling and `tokio-postgres` for async queries.
166///
167/// # Example
168///
169/// ```rust,no_run
170/// use fraiseql_db::postgres::PostgresAdapter;
171/// use fraiseql_db::{DatabaseAdapter, WhereClause, WhereOperator};
172/// use serde_json::json;
173///
174/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
175/// // Create adapter with connection string
176/// let adapter = PostgresAdapter::new("postgresql://localhost/mydb").await?;
177///
178/// // Execute query
179/// let where_clause = WhereClause::Field {
180/// path: vec!["email".to_string()],
181/// operator: WhereOperator::Icontains,
182/// value: json!("example.com"),
183/// };
184///
185/// let results = adapter
186/// .execute_where_query("v_user", Some(&where_clause), Some(10), None, None)
187/// .await?;
188///
189/// println!("Found {} users", results.len());
190/// # Ok(())
191/// # }
192/// ```
193#[derive(Clone)]
194pub struct PostgresAdapter {
195 pub(super) pool: Pool,
196 /// Whether mutation timing injection is enabled.
197 mutation_timing_enabled: bool,
198 /// The PostgreSQL session variable name for timing.
199 timing_variable_name: String,
200}
201
202impl std::fmt::Debug for PostgresAdapter {
203 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204 f.debug_struct("PostgresAdapter")
205 .field("mutation_timing_enabled", &self.mutation_timing_enabled)
206 .field("timing_variable_name", &self.timing_variable_name)
207 .field("pool", &"<Pool>")
208 .finish()
209 }
210}
211
212impl PostgresAdapter {
213 /// Create new PostgreSQL adapter with default pool configuration.
214 ///
215 /// # Arguments
216 ///
217 /// * `connection_string` - PostgreSQL connection string (e.g., "postgresql://localhost/mydb")
218 ///
219 /// # Errors
220 ///
221 /// Returns `FraiseQLError::ConnectionPool` if pool creation fails.
222 ///
223 /// # Example
224 ///
225 /// ```rust,no_run
226 /// # use fraiseql_db::postgres::PostgresAdapter;
227 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
228 /// let adapter = PostgresAdapter::new("postgresql://localhost/mydb").await?;
229 /// # Ok(())
230 /// # }
231 /// ```
232 pub async fn new(connection_string: &str) -> Result<Self> {
233 Self::with_pool_config(
234 connection_string,
235 PoolPrewarmConfig {
236 min_size: 0,
237 max_size: DEFAULT_POOL_SIZE,
238 timeout_secs: None,
239 },
240 )
241 .await
242 }
243
244 /// Create new PostgreSQL adapter with pre-warming and timeout configuration.
245 ///
246 /// Constructs the pool, runs a startup health check, then eagerly opens
247 /// `cfg.min_size` connections so they are ready when the first request arrives.
248 ///
249 /// # Arguments
250 ///
251 /// * `connection_string` - PostgreSQL connection string
252 /// * `cfg` - Pool pre-warming and timeout configuration
253 ///
254 /// # Errors
255 ///
256 /// Returns `FraiseQLError::ConnectionPool` if pool creation or the startup
257 /// health check fails.
258 pub async fn with_pool_config(connection_string: &str, cfg: PoolPrewarmConfig) -> Result<Self> {
259 let pool = build_pool(connection_string, cfg.max_size, cfg.timeout_secs)?;
260
261 // Startup health check — establishes the first connection.
262 let client = pool.get().await.map_err(|e| FraiseQLError::ConnectionPool {
263 message: format!("Failed to acquire connection: {e}"),
264 })?;
265
266 client.query("SELECT 1", &[]).await.map_err(|e| FraiseQLError::Database {
267 message: format!("Failed to connect to database: {e}"),
268 sql_state: e.code().map(|c| c.code().to_string()),
269 })?;
270
271 // Drop client back to the pool before pre-warming so that the health-check
272 // connection counts as idle slot #1.
273 drop(client);
274
275 let adapter = Self {
276 pool,
277 mutation_timing_enabled: false,
278 timing_variable_name: "fraiseql.started_at".to_string(),
279 };
280
281 // Pre-warm: open `min_size - 1` additional connections (one already exists).
282 let warm_target = cfg.min_size.min(cfg.max_size).saturating_sub(1);
283 if warm_target > 0 {
284 adapter.prewarm(warm_target).await;
285 }
286
287 Ok(adapter)
288 }
289
290 /// Create new PostgreSQL adapter with custom pool size.
291 ///
292 /// # Arguments
293 ///
294 /// * `connection_string` - PostgreSQL connection string
295 /// * `max_size` - Maximum number of connections in pool
296 ///
297 /// # Errors
298 ///
299 /// Returns `FraiseQLError::ConnectionPool` if pool creation fails.
300 pub async fn with_pool_size(connection_string: &str, max_size: usize) -> Result<Self> {
301 Self::with_pool_config(
302 connection_string,
303 PoolPrewarmConfig {
304 min_size: 0,
305 max_size,
306 timeout_secs: None,
307 },
308 )
309 .await
310 }
311
312 /// Pre-warm the pool by opening `count` additional connections.
313 ///
314 /// Pre-warming is best-effort: failures from individual connections are logged
315 /// but do not prevent startup. A 10-second outer timeout ensures the server
316 /// never blocks indefinitely on a slow or unreachable PostgreSQL instance.
317 async fn prewarm(&self, count: usize) {
318 use futures::future::join_all;
319 use tokio::time::timeout;
320
321 let handles: Vec<_> = (0..count)
322 .map(|_| {
323 let pool = self.pool.clone();
324 tokio::spawn(async move { pool.get().await })
325 })
326 .collect();
327
328 let result = timeout(Duration::from_secs(10), join_all(handles)).await;
329
330 let (succeeded, failed) = match result {
331 Ok(outcomes) => {
332 let s = outcomes
333 .iter()
334 .filter(|r| r.as_ref().map(|inner| inner.is_ok()).unwrap_or(false))
335 .count();
336 (s, count - s)
337 },
338 Err(_elapsed) => {
339 tracing::warn!(
340 target_connections = count,
341 "Pool pre-warm timed out after 10s; server will continue with partial pre-warm"
342 );
343 (0, count)
344 },
345 };
346
347 if failed > 0 {
348 tracing::warn!(
349 succeeded,
350 failed,
351 "Pool pre-warm: some connections could not be established"
352 );
353 } else {
354 tracing::info!(
355 idle_connections = succeeded + 1,
356 "PostgreSQL pool pre-warmed successfully"
357 );
358 }
359 }
360
361 /// Get a reference to the internal connection pool.
362 ///
363 /// This allows sharing the pool with other components like `PostgresIntrospector`.
364 #[must_use]
365 pub const fn pool(&self) -> &Pool {
366 &self.pool
367 }
368
369 /// Enable mutation timing injection.
370 ///
371 /// When enabled, `execute_function_call` wraps each mutation in a transaction
372 /// and sets a session variable to `clock_timestamp()::text` before execution,
373 /// allowing SQL functions to compute their own duration.
374 ///
375 /// # Arguments
376 ///
377 /// * `variable_name` - The PostgreSQL session variable name (e.g., `"fraiseql.started_at"`)
378 #[must_use]
379 pub fn with_mutation_timing(mut self, variable_name: &str) -> Self {
380 self.mutation_timing_enabled = true;
381 self.timing_variable_name = variable_name.to_string();
382 self
383 }
384
385 /// Returns whether mutation timing injection is enabled.
386 #[must_use]
387 pub const fn mutation_timing_enabled(&self) -> bool {
388 self.mutation_timing_enabled
389 }
390
391 /// Execute raw SQL query and return JSONB rows.
392 ///
393 /// # Errors
394 ///
395 /// Returns `FraiseQLError::Database` on query execution failure.
396 pub(super) async fn execute_raw(
397 &self,
398 sql: &str,
399 params: &[&(dyn tokio_postgres::types::ToSql + Sync)],
400 ) -> Result<Vec<JsonbValue>> {
401 let client = self.acquire_connection_with_retry().await?;
402
403 let rows: Vec<Row> =
404 client.query(sql, params).await.map_err(|e| FraiseQLError::Database {
405 message: format!("Query execution failed: {e}"),
406 sql_state: e.code().map(|c| c.code().to_string()),
407 })?;
408
409 let results = rows
410 .into_iter()
411 .map(|row| jsonb_cell(&row, 0, sql))
412 .collect::<Result<Vec<_>>>()?;
413
414 Ok(results)
415 }
416
417 /// Like [`execute_raw`](Self::execute_raw) but applies transaction-local
418 /// session variables on the same connection / transaction that runs the
419 /// query.
420 ///
421 /// `set_config(..., true)` and the `SELECT` share one transaction, so
422 /// PostgreSQL RLS policies backed by `current_setting()` see the configured
423 /// values (fixes #329).
424 ///
425 /// # Errors
426 ///
427 /// Returns `FraiseQLError::Database` on transaction, `set_config`, query, or
428 /// commit failure.
429 pub(super) async fn execute_raw_with_session(
430 &self,
431 sql: &str,
432 params: &[&(dyn tokio_postgres::types::ToSql + Sync)],
433 session_vars: &[(&str, &str)],
434 ) -> Result<Vec<JsonbValue>> {
435 let mut client = self.acquire_connection_with_retry().await?;
436 let txn =
437 client.build_transaction().start().await.map_err(|e| FraiseQLError::Database {
438 message: format!("Failed to start session-var transaction: {e}"),
439 sql_state: e.code().map(|c| c.code().to_string()),
440 })?;
441
442 database::apply_session_vars(&txn, session_vars).await?;
443
444 let rows: Vec<Row> = txn.query(sql, params).await.map_err(|e| FraiseQLError::Database {
445 message: format!("Query execution failed: {e}"),
446 sql_state: e.code().map(|c| c.code().to_string()),
447 })?;
448
449 txn.commit().await.map_err(|e| FraiseQLError::Database {
450 message: format!("Failed to commit session-var transaction: {e}"),
451 sql_state: e.code().map(|c| c.code().to_string()),
452 })?;
453
454 rows.into_iter().map(|row| jsonb_cell(&row, 0, sql)).collect()
455 }
456
457 /// Acquire a connection from the pool with retry logic.
458 ///
459 /// - `PoolError::Timeout`: the pool was exhausted for the full configured wait period. This is
460 /// not transient — retrying would only multiply the wait. Fails immediately.
461 /// - `PoolError::Backend` / create errors: potentially transient. Retries with exponential
462 /// backoff (up to `MAX_CONNECTION_RETRIES` attempts).
463 ///
464 /// # Errors
465 ///
466 /// Returns `FraiseQLError::ConnectionPool` on timeout or when all retries are exhausted.
467 pub(super) async fn acquire_connection_with_retry(&self) -> Result<deadpool_postgres::Client> {
468 use deadpool_postgres::PoolError;
469
470 let mut last_error = None;
471
472 for attempt in 0..MAX_CONNECTION_RETRIES {
473 match self.pool.get().await {
474 Ok(client) => {
475 if attempt > 0 {
476 tracing::info!(attempt, "Successfully acquired connection after retries");
477 }
478 return Ok(client);
479 },
480 // Pool exhausted for the full wait period — not transient, fail immediately.
481 Err(PoolError::Timeout(_)) => {
482 let metrics = self.pool_metrics();
483 tracing::error!(
484 available = metrics.idle_connections,
485 active = metrics.active_connections,
486 max = metrics.total_connections,
487 "Connection pool timeout: all connections busy"
488 );
489 return Err(FraiseQLError::ConnectionPool {
490 message: format!(
491 "Connection pool timeout: {}/{} connections busy. \
492 Increase pool_max_size or reduce concurrent load.",
493 metrics.active_connections, metrics.total_connections,
494 ),
495 });
496 },
497 // Backend/create errors are potentially transient — retry with backoff.
498 Err(e) => {
499 last_error = Some(e);
500 if attempt < MAX_CONNECTION_RETRIES - 1 {
501 let delay = CONNECTION_RETRY_DELAY_MS * (u64::from(attempt) + 1);
502 tracing::warn!(
503 attempt = attempt + 1,
504 total = MAX_CONNECTION_RETRIES,
505 delay_ms = delay,
506 "Transient connection error, retrying"
507 );
508 tokio::time::sleep(Duration::from_millis(delay)).await;
509 }
510 },
511 }
512 }
513
514 // All retries for transient errors exhausted.
515 let pool_metrics = self.pool_metrics();
516 tracing::error!(
517 retries = MAX_CONNECTION_RETRIES,
518 available = pool_metrics.idle_connections,
519 active = pool_metrics.active_connections,
520 max = pool_metrics.total_connections,
521 "Failed to acquire connection after all retries"
522 );
523
524 Err(FraiseQLError::ConnectionPool {
525 message: format!(
526 "Failed to acquire connection after {} retries: {}. \
527 Pool state: idle={}, active={}, max={}",
528 MAX_CONNECTION_RETRIES,
529 last_error.expect("last_error is set on every retry iteration"),
530 pool_metrics.idle_connections,
531 pool_metrics.active_connections,
532 pool_metrics.total_connections,
533 ),
534 })
535 }
536
537 /// Execute query with SQL field projection optimization.
538 ///
539 /// Uses the provided `SqlProjectionHint` to generate optimized SQL that projects
540 /// only the requested fields from the JSONB column, reducing network payload and
541 /// JSON deserialization overhead.
542 ///
543 /// # Arguments
544 ///
545 /// * `view` - View/table name to query
546 /// * `projection` - Optional SQL projection hint with field list
547 /// * `where_clause` - Optional WHERE clause for filtering
548 /// * `limit` - Optional row limit
549 ///
550 /// # Returns
551 ///
552 /// Vector of projected JSONB rows with only the requested fields
553 ///
554 /// # Errors
555 ///
556 /// Returns `FraiseQLError::Database` on query execution failure.
557 ///
558 /// # Panics
559 ///
560 /// Cannot panic in practice: the inner `expect` is guarded by an `is_none()` check
561 /// immediately above it.
562 ///
563 /// # Example
564 ///
565 /// ```no_run
566 /// // Requires: running PostgreSQL database.
567 /// use fraiseql_db::postgres::PostgresAdapter;
568 /// use fraiseql_db::types::SqlProjectionHint;
569 /// use fraiseql_db::DatabaseType;
570 ///
571 /// # async fn example(adapter: &PostgresAdapter) -> Result<(), Box<dyn std::error::Error>> {
572 /// let projection = SqlProjectionHint::new(
573 /// DatabaseType::PostgreSQL,
574 /// "jsonb_build_object('id', data->>'id')".to_string(),
575 /// 75,
576 /// );
577 ///
578 /// let results = adapter
579 /// .execute_with_projection("v_user", Some(&projection), None, Some(10), None)
580 /// .await?;
581 /// # Ok(())
582 /// # }
583 /// ```
584 /// Implementation of `execute_with_projection` with ORDER BY support.
585 ///
586 /// Called by both the inherent convenience method and the `DatabaseAdapter`
587 /// trait implementation.
588 pub(super) async fn execute_with_projection_impl(
589 &self,
590 view: &str,
591 projection: Option<&SqlProjectionHint>,
592 where_clause: Option<&WhereClause>,
593 limit: Option<u32>,
594 offset: Option<u32>,
595 order_by: Option<&[OrderByClause]>,
596 ) -> Result<Vec<JsonbValue>> {
597 // If no projection, fall back to standard query
598 if projection.is_none() {
599 return self.execute_where_query(view, where_clause, limit, offset, order_by).await;
600 }
601
602 let projection = projection.expect("projection is Some; None was returned above");
603
604 let (sql, typed_params) =
605 build_projection_select_sql(projection, view, where_clause, limit, offset, order_by)?;
606
607 tracing::debug!("SQL with projection = {}", sql);
608 tracing::debug!("typed_params = {:?}", typed_params);
609
610 let param_refs = crate::types::as_sql_param_refs(&typed_params);
611
612 self.execute_raw(&sql, ¶m_refs).await
613 }
614
615 /// Execute query with SQL field projection optimization.
616 ///
617 /// Convenience wrapper for callers that don't need ORDER BY.
618 /// See `execute_with_projection_impl` for details.
619 ///
620 /// # Errors
621 ///
622 /// Returns `FraiseQLError::Database` on query execution failure.
623 pub async fn execute_with_projection(
624 &self,
625 view: &str,
626 projection: Option<&SqlProjectionHint>,
627 where_clause: Option<&WhereClause>,
628 limit: Option<u32>,
629 offset: Option<u32>,
630 ) -> Result<Vec<JsonbValue>> {
631 self.execute_with_projection_impl(view, projection, where_clause, limit, offset, None)
632 .await
633 }
634}
635
636/// Build a parameterized `SELECT data FROM {view}` SQL string.
637///
638/// Shared by [`PostgresAdapter::execute_where_query`] and
639/// [`PostgresAdapter::explain_where_query`] so that SQL construction
640/// logic is never duplicated.
641///
642/// # Returns
643///
644/// `(sql, typed_params)` — the SQL string and the bound parameter values.
645///
646/// # Errors
647///
648/// Returns `FraiseQLError` if WHERE clause generation fails.
649pub(super) fn build_where_select_sql(
650 view: &str,
651 where_clause: Option<&WhereClause>,
652 limit: Option<u32>,
653 offset: Option<u32>,
654) -> Result<(String, Vec<QueryParam>)> {
655 build_where_select_sql_ordered(view, where_clause, limit, offset, None)
656}
657
658/// Build a parameterized `SELECT data FROM {view}` SQL string with optional ORDER BY.
659///
660/// ORDER BY is inserted between the WHERE clause and LIMIT/OFFSET as required by SQL.
661///
662/// # Returns
663///
664/// `(sql, typed_params)` — the SQL string and the bound parameter values.
665///
666/// # Errors
667///
668/// Returns `FraiseQLError` if WHERE clause generation or field name validation fails.
669pub(super) fn build_where_select_sql_ordered(
670 view: &str,
671 where_clause: Option<&WhereClause>,
672 limit: Option<u32>,
673 offset: Option<u32>,
674 order_by: Option<&[OrderByClause]>,
675) -> Result<(String, Vec<QueryParam>)> {
676 // Build base query
677 let mut sql = format!("SELECT data FROM {}", quote_postgres_identifier(view));
678
679 // Collect WHERE clause params (if any)
680 let mut typed_params: Vec<QueryParam> = if let Some(clause) = where_clause {
681 let generator = PostgresWhereGenerator::new(PostgresDialect);
682 let (where_sql, where_params) = generator.generate(clause)?;
683 sql.push_str(" WHERE ");
684 sql.push_str(&where_sql);
685
686 // Convert WHERE clause JSON values to QueryParam
687 where_params.into_iter().map(QueryParam::from).collect()
688 } else {
689 Vec::new()
690 };
691 let mut param_count = typed_params.len();
692
693 // ORDER BY must come before LIMIT/OFFSET in SQL.
694 append_order_by(&mut sql, order_by, DatabaseType::PostgreSQL)?;
695
696 // Add LIMIT as BigInt (PostgreSQL requires integer type for LIMIT).
697 // Reason (expect below): fmt::Write for String is infallible.
698 if let Some(lim) = limit {
699 param_count += 1;
700 write!(sql, " LIMIT ${param_count}").expect("write to String");
701 typed_params.push(QueryParam::BigInt(i64::from(lim)));
702 }
703
704 // Add OFFSET as BigInt (PostgreSQL requires integer type for OFFSET)
705 if let Some(off) = offset {
706 param_count += 1;
707 write!(sql, " OFFSET ${param_count}").expect("write to String");
708 typed_params.push(QueryParam::BigInt(i64::from(off)));
709 }
710
711 Ok((sql, typed_params))
712}
713
714/// Build a parameterized projection `SELECT` SQL string.
715///
716/// Mirrors the SQL produced inline by [`PostgresAdapter::execute_with_projection_impl`],
717/// extracted so the connection-affine `*_with_session` path can reuse it
718/// without acquiring its own connection.
719///
720/// # Returns
721///
722/// `(sql, typed_params)` — the SQL string and the bound parameter values.
723///
724/// # Errors
725///
726/// Returns `FraiseQLError` if WHERE clause generation fails.
727pub(super) fn build_projection_select_sql(
728 projection: &SqlProjectionHint,
729 view: &str,
730 where_clause: Option<&WhereClause>,
731 limit: Option<u32>,
732 offset: Option<u32>,
733 order_by: Option<&[OrderByClause]>,
734) -> Result<(String, Vec<QueryParam>)> {
735 // The projection_template is the SELECT clause with projection SQL,
736 // e.g. "jsonb_build_object('id', data->>'id', 'email', data->>'email')".
737 let mut sql = format!(
738 "SELECT {} FROM {}",
739 projection.projection_template,
740 quote_postgres_identifier(view)
741 );
742
743 let mut typed_params: Vec<QueryParam> = if let Some(clause) = where_clause {
744 let generator = PostgresWhereGenerator::new(PostgresDialect);
745 let (where_sql, where_params) = generator.generate(clause)?;
746 sql.push_str(" WHERE ");
747 sql.push_str(&where_sql);
748 where_params.into_iter().map(QueryParam::from).collect()
749 } else {
750 Vec::new()
751 };
752 let mut param_count = typed_params.len();
753
754 // ORDER BY must come before LIMIT/OFFSET in SQL.
755 append_order_by(&mut sql, order_by, DatabaseType::PostgreSQL)?;
756
757 // Append LIMIT/OFFSET as BigInt (PostgreSQL requires integer type).
758 // Reason (expect below): fmt::Write for String is infallible.
759 if let Some(lim) = limit {
760 param_count += 1;
761 write!(sql, " LIMIT ${param_count}").expect("write to String");
762 typed_params.push(QueryParam::BigInt(i64::from(lim)));
763 }
764
765 if let Some(off) = offset {
766 param_count += 1;
767 write!(sql, " OFFSET ${param_count}").expect("write to String");
768 typed_params.push(QueryParam::BigInt(i64::from(off)));
769 }
770
771 Ok((sql, typed_params))
772}