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