Skip to main content

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, &param_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}