Skip to main content

fraiseql_db/postgres/adapter/
database.rs

1//! `DatabaseAdapter` and `SupportsMutations` implementations for `PostgresAdapter`.
2
3use std::sync::Arc;
4
5use async_trait::async_trait;
6use bytes::BufMut as _;
7use fraiseql_error::{FraiseQLError, Result};
8use tokio_postgres::{
9    Row,
10    types::{FromSql, Kind, Type},
11};
12
13use super::{
14    PostgresAdapter, build_projection_select_sql, build_where_select_sql,
15    build_where_select_sql_ordered,
16};
17use crate::{
18    identifier::quote_postgres_identifier,
19    traits::{DatabaseAdapter, ProjectionRequest, SupportsMutations},
20    types::{
21        DatabaseType, JsonbValue, PoolMetrics, QueryParam,
22        sql_hints::{OrderByClause, SqlProjectionHint},
23    },
24    where_clause::WhereClause,
25};
26
27/// PostgreSQL SQLSTATE 42703: undefined column.
28const PG_UNDEFINED_COLUMN: &str = "42703";
29
30/// A flexible SQL parameter that binds to any PostgreSQL type.
31///
32/// Solves the impedance mismatch between `serde_json::Value` (only accepts JSON/JSONB)
33/// and `Option<String>` (only accepts text-family types) when binding function-call
34/// arguments whose types are resolved at runtime from the function signature.
35///
36/// Serialisation strategy (binary wire format):
37/// - `JSONB`: 1-byte version header (1) + UTF-8 JSON bytes
38/// - `JSON`: UTF-8 JSON bytes
39/// - `UUID`: 16-byte big-endian UUID
40/// - `INT4`: 4-byte big-endian i32
41/// - `INT8`: 8-byte big-endian i64
42/// - `BOOL`: 1-byte (0 or 1)
43/// - All other types: UTF-8 bytes (PostgreSQL text binary = raw UTF-8)
44#[derive(Debug)]
45enum FlexParam {
46    /// SQL NULL — accepted by any PostgreSQL type.
47    Null,
48    /// A text-encoded value; binary-serialised according to the server-resolved type.
49    Text(String),
50}
51
52impl tokio_postgres::types::ToSql for FlexParam {
53    fn to_sql(
54        &self,
55        ty: &tokio_postgres::types::Type,
56        out: &mut bytes::BytesMut,
57    ) -> std::result::Result<tokio_postgres::types::IsNull, Box<dyn std::error::Error + Sync + Send>>
58    {
59        use tokio_postgres::types::{IsNull, Type};
60        match self {
61            Self::Null => Ok(IsNull::Yes),
62            Self::Text(s) => {
63                if *ty == Type::JSONB {
64                    // JSONB binary wire format: 1-byte version (1) + JSON bytes
65                    out.put_u8(1);
66                    out.extend_from_slice(s.as_bytes());
67                } else if *ty == Type::JSON {
68                    out.extend_from_slice(s.as_bytes());
69                } else if *ty == Type::UUID {
70                    let uuid = uuid::Uuid::parse_str(s)?;
71                    out.extend_from_slice(uuid.as_bytes());
72                } else if *ty == Type::INT4 {
73                    let n: i32 = s.parse()?;
74                    out.put_i32(n);
75                } else if *ty == Type::INT8 {
76                    let n: i64 = s.parse()?;
77                    out.put_i64(n);
78                } else if *ty == Type::BOOL {
79                    let b: bool = s.parse()?;
80                    out.put_u8(u8::from(b));
81                } else {
82                    // TEXT, VARCHAR, BPCHAR, NAME, UNKNOWN, and any user-defined type:
83                    // UTF-8 bytes are the binary wire representation for text-family types.
84                    out.extend_from_slice(s.as_bytes());
85                }
86                Ok(IsNull::No)
87            },
88        }
89    }
90
91    fn accepts(_ty: &tokio_postgres::types::Type) -> bool {
92        // Accepts all types; per-type serialisation is handled in `to_sql`.
93        true
94    }
95
96    fn to_sql_checked(
97        &self,
98        ty: &tokio_postgres::types::Type,
99        out: &mut bytes::BytesMut,
100    ) -> std::result::Result<tokio_postgres::types::IsNull, Box<dyn std::error::Error + Sync + Send>>
101    {
102        // `accepts()` returns true for all types, so the standard WrongType check is
103        // unnecessary.  Delegate directly to `to_sql`.
104        self.to_sql(ty, out)
105    }
106}
107
108/// Enrich a `FraiseQLError::Database` error for PostgreSQL SQLSTATE 42703 (undefined column)
109/// when the WHERE clause contains `NativeField` conditions.
110///
111/// Native columns may be inferred automatically at compile time from `ID`/`UUID`-typed
112/// arguments.  If the column does not exist on the target table at runtime, the raw
113/// PostgreSQL error is replaced with a diagnostic message that names the native columns
114/// involved and explains how to fix the schema.
115fn enrich_undefined_column_error(
116    err: FraiseQLError,
117    view: &str,
118    where_clause: Option<&WhereClause>,
119) -> FraiseQLError {
120    let FraiseQLError::Database { ref sql_state, .. } = err else {
121        return err;
122    };
123    if sql_state.as_deref() != Some(PG_UNDEFINED_COLUMN) {
124        return err;
125    }
126    let native_cols: Vec<&str> =
127        where_clause.map(|wc| wc.native_column_names()).unwrap_or_default();
128    if native_cols.is_empty() {
129        return err;
130    }
131    FraiseQLError::Database {
132        message:   format!(
133            "Column(s) {:?} referenced as native column(s) on `{view}` do not exist. \
134             These columns were auto-inferred from ID/UUID-typed query arguments. \
135             Either add the column(s) to the table/view, or set \
136             `native_columns = {{}}` explicitly in your schema to disable inference.",
137            native_cols,
138        ),
139        sql_state: Some(PG_UNDEFINED_COLUMN.to_string()),
140    }
141}
142
143/// Decodes any PostgreSQL `ENUM` value as its text label.
144///
145/// `String`'s `FromSql` accepts only the text family (TEXT/VARCHAR/BPCHAR/NAME), not
146/// a custom enum OID, so an enum column otherwise falls through [`row_to_map`]'s
147/// decode ladder to `Null`. An enum's binary wire representation *is* its label as
148/// UTF-8 text, so this reads it directly (#472).
149struct PgEnumLabel(String);
150
151impl<'a> FromSql<'a> for PgEnumLabel {
152    fn from_sql(
153        _ty: &Type,
154        raw: &'a [u8],
155    ) -> std::result::Result<Self, Box<dyn std::error::Error + Sync + Send>> {
156        Ok(Self(std::str::from_utf8(raw)?.to_owned()))
157    }
158
159    fn accepts(ty: &Type) -> bool {
160        matches!(ty.kind(), Kind::Enum(_))
161    }
162}
163
164/// Convert a single `tokio_postgres::Row` into a `HashMap<String, serde_json::Value>`.
165///
166/// Tries each PostgreSQL type in priority order; falls back to `Null` for
167/// types that cannot be represented as JSON.
168fn row_to_map(row: &Row) -> std::collections::HashMap<String, serde_json::Value> {
169    let mut map = std::collections::HashMap::new();
170    for (idx, column) in row.columns().iter().enumerate() {
171        let column_name = column.name().to_string();
172        let value: serde_json::Value = if let Ok(v) = row.try_get::<_, i16>(idx) {
173            // SMALLINT/int2 columns (e.g. `app.mutation_response.http_status`).
174            // Without this branch a non-null int2 fell through to `Null` because
175            // `FromSql for i32` rejects int2 — which dropped `MutationError`'s
176            // `httpStatus` to absent. Order among i16/i32/i64 is correctness-
177            // neutral (each `FromSql` accepts only its own PG int width), so this
178            // sits first for readability.
179            serde_json::json!(v)
180        } else if let Ok(v) = row.try_get::<_, i32>(idx) {
181            serde_json::json!(v)
182        } else if let Ok(v) = row.try_get::<_, i64>(idx) {
183            serde_json::json!(v)
184        } else if let Ok(v) = row.try_get::<_, f64>(idx) {
185            serde_json::json!(v)
186        } else if let Ok(v) = row.try_get::<_, String>(idx) {
187            serde_json::json!(v)
188        } else if let Ok(v) = row.try_get::<_, bool>(idx) {
189            serde_json::json!(v)
190        } else if let Ok(v) = row.try_get::<_, rust_decimal::Decimal>(idx) {
191            // NUMERIC/DECIMAL — render as a JSON number by parsing the canonical
192            // decimal text, so the value isn't forced through f64 at construction.
193            // Without this branch a `SUM(revenue)` aggregate (and every raw
194            // NUMERIC column) fell through to `Null` (H35). Falls back to a JSON
195            // string only if the decimal text isn't valid JSON numeric syntax,
196            // which `Decimal::to_string` never produces.
197            let s = v.to_string();
198            serde_json::from_str::<serde_json::Value>(&s).unwrap_or(serde_json::Value::String(s))
199        } else if let Ok(v) = row.try_get::<_, uuid::Uuid>(idx) {
200            // UUID columns (e.g. `app.mutation_response.entity_id`) render as the
201            // canonical hyphenated string instead of silently nulling (H35).
202            serde_json::json!(v.to_string())
203        } else if let Ok(v) = row.try_get::<_, chrono::DateTime<chrono::Utc>>(idx) {
204            // `timestamptz` → RFC 3339 / ISO 8601 text (H35).
205            serde_json::json!(v.to_rfc3339())
206        } else if let Ok(v) = row.try_get::<_, chrono::NaiveDateTime>(idx) {
207            // `timestamp` (no tz) → ISO 8601 text (H35).
208            serde_json::json!(v.to_string())
209        } else if let Ok(v) = row.try_get::<_, chrono::NaiveDate>(idx) {
210            // `date` → ISO 8601 date text (H35).
211            serde_json::json!(v.to_string())
212        } else if let Ok(v) = row.try_get::<_, Vec<String>>(idx) {
213            // TEXT[] columns (e.g. `app.mutation_response.updated_fields`) — without
214            // this branch a non-null text array falls through to Null and a parser
215            // expecting a sequence fails. Must precede the jsonb branch (a jsonb
216            // column never deserializes as Vec<String>, so ordering is safe).
217            serde_json::json!(v)
218        } else if let Ok(v) = row.try_get::<_, PgEnumLabel>(idx) {
219            // ENUM columns (e.g. `app.mutation_response.error_class`, the
220            // `app.mutation_error_class` enum) render as their text label. Without
221            // this branch a non-null enum fell through to `Null` (`String: FromSql`
222            // rejects a custom enum OID), which dropped `error_class` to absent on a
223            // failed mutation — and the parser then rejected the row with
224            // "succeeded=false requires error_class", so the typed error arm was
225            // never reached (#472). `PgEnumLabel` accepts only `Kind::Enum`, so this
226            // never shadows another branch regardless of position.
227            serde_json::json!(v.0)
228        } else if let Ok(v) = row.try_get::<_, serde_json::Value>(idx) {
229            v
230        } else {
231            // A column whose PostgreSQL type none of the branches above can
232            // decode. Name the column and its type so the next drift is visible
233            // in logs instead of a silent null (H35).
234            tracing::warn!(
235                column = %column_name,
236                pg_type = %column.type_(),
237                "row_to_map: column type not representable as JSON; returning null"
238            );
239            serde_json::Value::Null
240        };
241        map.insert(column_name, value);
242    }
243    map
244}
245
246/// Apply transaction-local session variables on an in-progress transaction.
247///
248/// Each `(name, value)` pair is set with `SELECT set_config($1, $2, true)`, so
249/// the values are scoped to `txn` and visible to every statement run on it
250/// (fixes #329). The caller is responsible for committing or rolling back.
251pub(super) async fn apply_session_vars(
252    txn: &tokio_postgres::Transaction<'_>,
253    session_vars: &[(&str, &str)],
254) -> Result<()> {
255    for (name, value) in session_vars {
256        // A var carrying the clock-timestamp directive (e.g. fraiseql.started_at)
257        // is stamped with the DB clock at apply time, on this very transaction, so
258        // the start timestamp and the close-of-interval at the outbox write share
259        // one clock (no app↔DB skew). All other vars bind their literal value.
260        if *value == crate::changelog::CLOCK_TIMESTAMP_DIRECTIVE {
261            txn.execute("SELECT set_config($1, clock_timestamp()::text, true)", &[name])
262                .await
263                .map_err(|e| FraiseQLError::Database {
264                    message:   format!("set_config({name:?}, clock_timestamp()) failed: {e}"),
265                    sql_state: e.code().map(|c| c.code().to_string()),
266                })?;
267        } else {
268            txn.execute("SELECT set_config($1, $2, true)", &[name, value])
269                .await
270                .map_err(|e| FraiseQLError::Database {
271                    message:   format!("set_config({name:?}) failed: {e}"),
272                    sql_state: e.code().map(|c| c.code().to_string()),
273                })?;
274        }
275    }
276    Ok(())
277}
278
279/// Mark the in-progress mutation transaction as **FraiseQL-mediated** (#366).
280///
281/// Sets [`crate::changelog::CDC_MEDIATED_VAR`] to
282/// [`crate::changelog::CDC_MEDIATED_ON`] transaction-locally
283/// (`set_config(..., true)`), so the value auto-resets on commit and is invisible
284/// to other connections. The shipped fallback-capture trigger reads it and
285/// suppresses its own change-log row for this write, so an app-path mutation —
286/// already logged by the in-transaction outbox — is never double-captured. Set
287/// on every mutation transaction (independent of whether the outbox row is
288/// written), so an opted-out mutation (`changelog=false`) also suppresses the
289/// trigger rather than leaving a degraded fallback row.
290pub(super) async fn mark_cdc_mediated(txn: &tokio_postgres::Transaction<'_>) -> Result<()> {
291    txn.execute(
292        "SELECT set_config($1, $2, true)",
293        &[
294            &crate::changelog::CDC_MEDIATED_VAR,
295            &crate::changelog::CDC_MEDIATED_ON,
296        ],
297    )
298    .await
299    .map_err(|e| FraiseQLError::Database {
300        message:   format!("Failed to set {} marker: {e}", crate::changelog::CDC_MEDIATED_VAR),
301        sql_state: e.code().map(|c| c.code().to_string()),
302    })?;
303    Ok(())
304}
305
306/// Build the single statement that runs a mutation function and writes its
307/// `core.tb_entity_change_log` outbox row in the same transaction (the Change
308/// Spine transactional outbox).
309///
310/// The function call is materialised once (`WITH r AS MATERIALIZED`, so a
311/// volatile mutation function executes **exactly once** even though two CTEs
312/// read it), a data-modifying CTE INSERTs the change-log row for an effective
313/// change (`succeeded AND state_changed`), and the primary `SELECT * FROM r`
314/// returns the function's row unchanged to the caller. The changed-entity
315/// columns (`object_id`, `object_data`, `updated_fields`, `cascade`) come from
316/// the function's own `app.mutation_response` row; `$<n+1>` is the NOT-NULL
317/// `object_type` fallback, `$<n+2>` is the `modification_type` verb,
318/// `$<n+3>` is the envelope `tenant_id` (the Trinity public-facing UUID, NULL
319/// for system / unauthenticated / non-UUID-tenant rows), `$<n+4>` is the
320/// `trace_id` (the request's W3C trace id, plain text), `$<n+5>` is the
321/// `schema_version` (the compiled schema's content hash, plain text), and
322/// `$<n+6>` is the `trace_context` (the full W3C trace context, cast `::jsonb`),
323/// where `n` = `n_args` (the number of function arguments). The envelope params are
324/// appended **after** the function args so the SQL text stays deterministic per
325/// `(function, n_args)` — `prepare_cached` keys the statement by text, so the
326/// column list and placeholder positions must never depend on the param *values*.
327///
328/// `started_at`/`duration_ms` read `fraiseql.started_at` (set txn-locally by the
329/// caller) on the DB clock — the canonical computation from
330/// [`crate::changelog::duration_ms_sql`], stamped with
331/// [`crate::changelog::DURATION_CALC_VERSION`]. `commit_time` is the DB clock at
332/// INSERT; `seq` is omitted so the column's `SEQUENCE` default fires (so any
333/// INSERTer, incl. cooperative external producers, gets a monotonic value).
334pub(super) fn build_changelog_cte_sql(quoted_fn: &str, n_args: usize, pre_image: bool) -> String {
335    let placeholders: Vec<String> = (1..=n_args).map(|i| format!("${i}")).collect();
336    let object_type_idx = n_args + 1;
337    let modification_type_idx = n_args + 2;
338    let tenant_id_idx = n_args + 3;
339    let trace_id_idx = n_args + 4;
340    let schema_version_idx = n_args + 5;
341    let trace_context_idx = n_args + 6;
342    let actor_type_idx = n_args + 7;
343    let acting_for_idx = n_args + 8;
344    let started_var = crate::changelog::STARTED_AT_VAR;
345    let duration = crate::changelog::duration_ms_sql(started_var);
346    let calc_version = crate::changelog::DURATION_CALC_VERSION;
347    // Pre-image (opt-in, #changelog_pre_image): also record the changed entity's
348    // before-state from the function's own `entity_before` column, alongside the
349    // after-image `entity`. Adds `object_data_before` to the INSERT and
350    // `r.entity_before` to the SELECT ONLY when requested — `object_data` stays
351    // the after-image for every consumer; an absent flag emits today's SQL
352    // byte-for-byte. No new placeholder: the before-image is a column on `r`, like
353    // `r.entity`. The two SQL forms differ in text, so `prepare_cached` keys them
354    // as separate statements automatically (no extra cache discriminator needed).
355    let after_before_cols = if pre_image {
356        "object_data, object_data_before"
357    } else {
358        "object_data"
359    };
360    let after_before_vals = if pre_image {
361        "r.entity, r.entity_before"
362    } else {
363        "r.entity"
364    };
365    format!(
366        "WITH r AS MATERIALIZED (SELECT * FROM {quoted_fn}({args})), \
367         _changelog AS ( \
368           INSERT INTO core.tb_entity_change_log \
369             (object_type, modification_type, object_id, {after_before_cols}, \
370              updated_fields, cascade, started_at, duration_ms, extra_metadata, \
371              tenant_id, trace_id, schema_version, trace_context, \
372              actor_type, acting_for, commit_time) \
373           SELECT \
374             COALESCE(r.entity_type, ${object_type_idx}), \
375             ${modification_type_idx}, \
376             r.entity_id, {after_before_vals}, r.updated_fields, r.cascade, \
377             current_setting('{started_var}')::timestamptz, \
378             {duration}, \
379             jsonb_build_object('duration_calc_version', {calc_version}::int), \
380             ${tenant_id_idx}::uuid, \
381             ${trace_id_idx}, \
382             ${schema_version_idx}, \
383             ${trace_context_idx}::jsonb, \
384             ${actor_type_idx}, \
385             ${acting_for_idx}::uuid, \
386             clock_timestamp() \
387           FROM r \
388           WHERE r.succeeded AND r.state_changed \
389           RETURNING 1 \
390         ) \
391         SELECT * FROM r",
392        args = placeholders.join(", "),
393    )
394}
395
396/// Prepare `sql` on `client` using deadpool's **per-connection statement cache**.
397///
398/// The mutation function-call path sends the same statement (a fixed
399/// `SELECT * FROM fn(...)` or the change-log CTE) on every call, so without
400/// caching PostgreSQL re-parses and re-plans it for each mutation — the dominant
401/// hot-path cost (the complex outbox CTE in particular). `prepare_cached` keys
402/// the parsed/planned [`tokio_postgres::Statement`] by SQL text per connection
403/// and reuses it across calls, so the parse/plan happens once per connection.
404///
405/// Returns an owned `Statement` (releasing the `&client` borrow), so it can be
406/// prepared before `client.build_transaction()` and used inside that transaction.
407async fn prepare_cached_stmt(
408    client: &deadpool_postgres::Client,
409    sql: &str,
410) -> Result<tokio_postgres::Statement> {
411    client.prepare_cached(sql).await.map_err(|e| {
412        // Surface the underlying PostgreSQL diagnostic (SQLSTATE message, e.g.
413        // `function "foo" does not exist`). The top-level `Display` renders only
414        // as the opaque `db error`, dropping the one detail that names the
415        // offending object (#451). The function-call path extracts the same
416        // `as_db_error()` detail; here we put it *in place of* the useless
417        // `db error` (rather than alongside it), falling back to `Display` so the
418        // message is never empty.
419        let detail = e.as_db_error().map_or_else(|| e.to_string(), |d| d.message().to_string());
420        FraiseQLError::Database {
421            message:   format!("Failed to prepare statement: {detail}"),
422            sql_state: e.code().map(|c| c.code().to_string()),
423        }
424    })
425}
426
427// Reason: DatabaseAdapter is defined with #[async_trait]; all implementations must match
428// its transformed method signatures to satisfy the trait contract
429// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
430#[async_trait]
431impl DatabaseAdapter for PostgresAdapter {
432    async fn execute_with_projection(
433        &self,
434        view: &str,
435        projection: Option<&SqlProjectionHint>,
436        where_clause: Option<&WhereClause>,
437        limit: Option<u32>,
438        offset: Option<u32>,
439        order_by: Option<&[OrderByClause]>,
440    ) -> Result<Vec<JsonbValue>> {
441        self.execute_with_projection_impl(view, projection, where_clause, limit, offset, order_by)
442            .await
443    }
444
445    async fn execute_where_query(
446        &self,
447        view: &str,
448        where_clause: Option<&WhereClause>,
449        limit: Option<u32>,
450        offset: Option<u32>,
451        order_by: Option<&[OrderByClause]>,
452    ) -> Result<Vec<JsonbValue>> {
453        let (sql, typed_params) =
454            build_where_select_sql_ordered(view, where_clause, limit, offset, order_by)?;
455
456        let param_refs = crate::types::as_sql_param_refs(&typed_params);
457
458        self.execute_raw(&sql, &param_refs)
459            .await
460            .map_err(|e| enrich_undefined_column_error(e, view, where_clause))
461    }
462
463    async fn explain_where_query(
464        &self,
465        view: &str,
466        where_clause: Option<&WhereClause>,
467        limit: Option<u32>,
468        offset: Option<u32>,
469    ) -> Result<serde_json::Value> {
470        let (select_sql, typed_params) = build_where_select_sql(view, where_clause, limit, offset)?;
471        // Defense-in-depth: compiler-generated SQL should never contain a
472        // semicolon, but guard against it to prevent statement injection.
473        if select_sql.contains(';') {
474            return Err(FraiseQLError::Validation {
475                message: "EXPLAIN SQL must be a single statement".into(),
476                path:    None,
477            });
478        }
479        let explain_sql = format!("EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) {select_sql}");
480
481        let param_refs = crate::types::as_sql_param_refs(&typed_params);
482
483        let client = self.acquire_connection_with_retry().await?;
484        let rows = client.query(explain_sql.as_str(), &param_refs).await.map_err(|e| {
485            FraiseQLError::Database {
486                message:   format!("EXPLAIN ANALYZE failed: {e}"),
487                sql_state: e.code().map(|c| c.code().to_string()),
488            }
489        })?;
490
491        if let Some(row) = rows.first() {
492            let plan: serde_json::Value = row.try_get(0).map_err(|e| FraiseQLError::Database {
493                message:   format!("Failed to parse EXPLAIN output: {e}"),
494                sql_state: None,
495            })?;
496            Ok(plan)
497        } else {
498            Ok(serde_json::Value::Null)
499        }
500    }
501
502    fn database_type(&self) -> DatabaseType {
503        DatabaseType::PostgreSQL
504    }
505
506    async fn health_check(&self) -> Result<()> {
507        // Use retry logic for health check to avoid false negatives during pool exhaustion
508        let client = self.acquire_connection_with_retry().await?;
509
510        client.query("SELECT 1", &[]).await.map_err(|e| FraiseQLError::Database {
511            message:   format!("Health check failed: {e}"),
512            sql_state: e.code().map(|c| c.code().to_string()),
513        })?;
514
515        Ok(())
516    }
517
518    #[allow(clippy::cast_possible_truncation)] // Reason: value is bounded; truncation cannot occur in practice
519    fn pool_metrics(&self) -> PoolMetrics {
520        let status = self.pool.status();
521
522        PoolMetrics {
523            total_connections:  status.size as u32,
524            idle_connections:   status.available as u32,
525            active_connections: (status.size - status.available) as u32,
526            waiting_requests:   status.waiting as u32,
527        }
528    }
529
530    /// # Security
531    ///
532    /// `sql` **must** be compiler-generated. Never pass user-supplied strings
533    /// directly — doing so would open SQL-injection vulnerabilities.
534    async fn execute_raw_query(
535        &self,
536        sql: &str,
537    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
538        // Use retry logic for connection acquisition
539        let client = self.acquire_connection_with_retry().await?;
540
541        let rows: Vec<Row> = client.query(sql, &[]).await.map_err(|e| FraiseQLError::Database {
542            message:   format!("Query execution failed: {e}"),
543            sql_state: e.code().map(|c| c.code().to_string()),
544        })?;
545
546        // Convert each row to HashMap<String, Value>
547        let results: Vec<std::collections::HashMap<String, serde_json::Value>> =
548            rows.iter().map(row_to_map).collect();
549
550        Ok(results)
551    }
552
553    async fn execute_parameterized_aggregate(
554        &self,
555        sql: &str,
556        params: &[serde_json::Value],
557    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
558        // Convert serde_json::Value params to QueryParam so that strings are bound
559        // as TEXT (not JSONB), which is required for correct WHERE comparisons against
560        // data->>'field' expressions that return TEXT.
561        let typed: Vec<QueryParam> = params.iter().cloned().map(QueryParam::from).collect();
562        let param_refs = crate::types::as_sql_param_refs(&typed);
563
564        let client = self.acquire_connection_with_retry().await?;
565        let rows: Vec<Row> =
566            client.query(sql, &param_refs).await.map_err(|e| FraiseQLError::Database {
567                message:   format!("Parameterized aggregate query failed: {e}"),
568                sql_state: e.code().map(|c| c.code().to_string()),
569            })?;
570
571        let results: Vec<std::collections::HashMap<String, serde_json::Value>> =
572            rows.iter().map(row_to_map).collect();
573
574        Ok(results)
575    }
576
577    async fn execute_parameterized_aggregate_with_session(
578        &self,
579        sql: &str,
580        params: &[serde_json::Value],
581        session_vars: &[(&str, &str)],
582    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
583        if session_vars.is_empty() {
584            return self.execute_parameterized_aggregate(sql, params).await;
585        }
586
587        let typed: Vec<QueryParam> = params.iter().cloned().map(QueryParam::from).collect();
588        let param_refs = crate::types::as_sql_param_refs(&typed);
589
590        let mut client = self.acquire_connection_with_retry().await?;
591        let txn =
592            client.build_transaction().start().await.map_err(|e| FraiseQLError::Database {
593                message:   format!("Failed to start session-var transaction: {e}"),
594                sql_state: e.code().map(|c| c.code().to_string()),
595            })?;
596        apply_session_vars(&txn, session_vars).await?;
597        let rows: Vec<Row> =
598            txn.query(sql, &param_refs).await.map_err(|e| FraiseQLError::Database {
599                message:   format!("Parameterized aggregate query failed: {e}"),
600                sql_state: e.code().map(|c| c.code().to_string()),
601            })?;
602        txn.commit().await.map_err(|e| FraiseQLError::Database {
603            message:   format!("Failed to commit session-var transaction: {e}"),
604            sql_state: e.code().map(|c| c.code().to_string()),
605        })?;
606
607        Ok(rows.iter().map(row_to_map).collect())
608    }
609
610    async fn execute_function_call(
611        &self,
612        function_name: &str,
613        args: &[serde_json::Value],
614    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
615        // Build: SELECT * FROM "fn_name"($1, $2, ...)
616        // Use the standard identifier quoting utility so that schema-qualified
617        // names like "benchmark.fn_update_user" are correctly split into
618        // "benchmark"."fn_update_user" instead of being wrapped as a single
619        // identifier.
620        let quoted_fn = quote_postgres_identifier(function_name);
621        let placeholders: Vec<String> = (1..=args.len()).map(|i| format!("${i}")).collect();
622        let sql = format!("SELECT * FROM {quoted_fn}({})", placeholders.join(", "));
623
624        let mut client = self.acquire_connection_with_retry().await?;
625
626        // Convert serde_json::Value arguments to FlexParam for binding.
627        //
628        // serde_json::Value only accepts JSON/JSONB types; Option<String> only accepts
629        // text-family types.  Neither works universally when the function signature
630        // contains a mix of JSONB, UUID, INT4, and TEXT parameters.  FlexParam accepts
631        // all PostgreSQL types and serialises each value in the correct binary wire
632        // format for the server-resolved parameter type.
633        let flex_args: Vec<FlexParam> = args
634            .iter()
635            .map(|v| match v {
636                serde_json::Value::Null => FlexParam::Null,
637                serde_json::Value::String(s) => FlexParam::Text(s.clone()),
638                _ => FlexParam::Text(v.to_string()),
639            })
640            .collect();
641        let params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = flex_args
642            .iter()
643            .map(|v| v as &(dyn tokio_postgres::types::ToSql + Sync))
644            .collect();
645
646        // Parse/plan the statement once per connection and reuse it (deadpool's
647        // statement cache); prepared before any transaction so the owned Statement
648        // is usable inside it.
649        let stmt = prepare_cached_stmt(&client, sql.as_str()).await?;
650
651        if self.mutation_timing_enabled {
652            // Wrap in a transaction so SET LOCAL scopes the variable to this call only.
653            // `set_config(name, value, is_local)` with is_local=true is equivalent to
654            // SET LOCAL and is parameterized to avoid SQL injection.
655            let txn =
656                client.build_transaction().start().await.map_err(|e| FraiseQLError::Database {
657                    message:   format!("Failed to start mutation timing transaction: {e}"),
658                    sql_state: e.code().map(|c| c.code().to_string()),
659                })?;
660
661            txn.execute(
662                "SELECT set_config($1, clock_timestamp()::text, true)",
663                &[&self.timing_variable_name],
664            )
665            .await
666            .map_err(|e| FraiseQLError::Database {
667                message:   format!("Failed to set mutation timing variable: {e}"),
668                sql_state: e.code().map(|c| c.code().to_string()),
669            })?;
670
671            let rows: Vec<Row> = txn.query(&stmt, params.as_slice()).await.map_err(|e| {
672                let detail = e.as_db_error().map_or("", |d| d.message());
673                FraiseQLError::Database {
674                    message:   format!("Function call {function_name} failed: {e}: {detail}"),
675                    sql_state: e.code().map(|c| c.code().to_string()),
676                }
677            })?;
678
679            txn.commit().await.map_err(|e| FraiseQLError::Database {
680                message:   format!("Failed to commit mutation timing transaction: {e}"),
681                sql_state: e.code().map(|c| c.code().to_string()),
682            })?;
683
684            let results: Vec<std::collections::HashMap<String, serde_json::Value>> =
685                rows.iter().map(row_to_map).collect();
686
687            Ok(results)
688        } else {
689            let rows: Vec<Row> = client.query(&stmt, params.as_slice()).await.map_err(|e| {
690                let detail = e.as_db_error().map_or("", |d| d.message());
691                FraiseQLError::Database {
692                    message:   format!("Function call {function_name} failed: {e}: {detail}"),
693                    sql_state: e.code().map(|c| c.code().to_string()),
694                }
695            })?;
696
697            let results: Vec<std::collections::HashMap<String, serde_json::Value>> =
698                rows.iter().map(row_to_map).collect();
699
700            Ok(results)
701        }
702    }
703
704    async fn execute_function_call_dry_run(
705        &self,
706        function_name: &str,
707        args: &[serde_json::Value],
708        session_vars: &[(&str, &str)],
709    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
710        // Validate-bind-without-commit: run the mutation function inside a
711        // transaction we ROLL BACK unconditionally. The function executes for
712        // real (constraints, triggers, and the app.mutation_response shape are all
713        // exercised), but no writes persist. Mirrors execute_function_call's
714        // statement build + FlexParam binding; the only difference is rollback
715        // instead of commit, and no change-log outbox write.
716        let quoted_fn = quote_postgres_identifier(function_name);
717        let placeholders: Vec<String> = (1..=args.len()).map(|i| format!("${i}")).collect();
718        let sql = format!("SELECT * FROM {quoted_fn}({})", placeholders.join(", "));
719
720        // See execute_function_call for why FlexParam is required here.
721        let flex_args: Vec<FlexParam> = args
722            .iter()
723            .map(|v| match v {
724                serde_json::Value::Null => FlexParam::Null,
725                serde_json::Value::String(s) => FlexParam::Text(s.clone()),
726                _ => FlexParam::Text(v.to_string()),
727            })
728            .collect();
729        let params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = flex_args
730            .iter()
731            .map(|v| v as &(dyn tokio_postgres::types::ToSql + Sync))
732            .collect();
733
734        let mut client = self.acquire_connection_with_retry().await?;
735        // Parse/plan once per connection (statement cache), before the txn.
736        let stmt = prepare_cached_stmt(&client, sql.as_str()).await?;
737        let txn =
738            client.build_transaction().start().await.map_err(|e| FraiseQLError::Database {
739                message:   format!("Failed to start dry-run transaction: {e}"),
740                sql_state: e.code().map(|c| c.code().to_string()),
741            })?;
742
743        // Apply the same transaction-local context a real call would see, so the
744        // dry-run exercises RLS / the function body identically (no-op when empty).
745        apply_session_vars(&txn, session_vars).await?;
746        // Suppress the fallback-capture trigger (#366) for parity with the real
747        // mutation path; the marker rolls back with everything else.
748        mark_cdc_mediated(&txn).await?;
749
750        let rows: Vec<Row> = txn.query(&stmt, params.as_slice()).await.map_err(|e| {
751            let detail = e.as_db_error().map_or("", |d| d.message());
752            FraiseQLError::Database {
753                message:   format!("Dry-run function call {function_name} failed: {e}: {detail}"),
754                sql_state: e.code().map(|c| c.code().to_string()),
755            }
756        })?;
757
758        // The whole point of dry-run: discard every write.
759        txn.rollback().await.map_err(|e| FraiseQLError::Database {
760            message:   format!("Failed to roll back dry-run transaction: {e}"),
761            sql_state: e.code().map(|c| c.code().to_string()),
762        })?;
763
764        Ok(rows.iter().map(row_to_map).collect())
765    }
766
767    // PostgreSQL session variables are applied connection-affinely by the
768    // `*_with_session` methods below: `set_config(..., true)` and the operation
769    // share one transaction on one connection, so transaction-local GUCs are
770    // visible to the function / view (fixes #329).
771
772    async fn execute_function_call_with_session(
773        &self,
774        function_name: &str,
775        args: &[serde_json::Value],
776        session_vars: &[(&str, &str)],
777    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
778        // Fast path: no session variables and no mutation timing => behave
779        // exactly like execute_function_call (no transaction overhead, and
780        // execute_function_call already opens its own txn when timing is on).
781        if session_vars.is_empty() && !self.mutation_timing_enabled {
782            return self.execute_function_call(function_name, args).await;
783        }
784
785        let quoted_fn = quote_postgres_identifier(function_name);
786        let placeholders: Vec<String> = (1..=args.len()).map(|i| format!("${i}")).collect();
787        let sql = format!("SELECT * FROM {quoted_fn}({})", placeholders.join(", "));
788
789        // See execute_function_call for why FlexParam is required here.
790        let flex_args: Vec<FlexParam> = args
791            .iter()
792            .map(|v| match v {
793                serde_json::Value::Null => FlexParam::Null,
794                serde_json::Value::String(s) => FlexParam::Text(s.clone()),
795                _ => FlexParam::Text(v.to_string()),
796            })
797            .collect();
798        let params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = flex_args
799            .iter()
800            .map(|v| v as &(dyn tokio_postgres::types::ToSql + Sync))
801            .collect();
802
803        let mut client = self.acquire_connection_with_retry().await?;
804        // Parse/plan once per connection (statement cache), before the txn.
805        let stmt = prepare_cached_stmt(&client, sql.as_str()).await?;
806        let txn =
807            client.build_transaction().start().await.map_err(|e| FraiseQLError::Database {
808                message:   format!("Failed to start session-var transaction: {e}"),
809                sql_state: e.code().map(|c| c.code().to_string()),
810            })?;
811
812        // Apply session variables FIRST so the function body sees them.
813        apply_session_vars(&txn, session_vars).await?;
814
815        // Mark the txn FraiseQL-mediated (#366). Even on the no-outbox path
816        // (e.g. a `changelog=false` mutation), a mutation routed through the
817        // executor must suppress the fallback-capture trigger — the opt-out means
818        // "no change-log row," not "let the trigger write a degraded one."
819        mark_cdc_mediated(&txn).await?;
820
821        // If mutation timing is on, stamp the timing variable in the same txn.
822        if self.mutation_timing_enabled {
823            txn.execute(
824                "SELECT set_config($1, clock_timestamp()::text, true)",
825                &[&self.timing_variable_name],
826            )
827            .await
828            .map_err(|e| FraiseQLError::Database {
829                message:   format!("Failed to set mutation timing variable: {e}"),
830                sql_state: e.code().map(|c| c.code().to_string()),
831            })?;
832        }
833
834        let rows: Vec<Row> = txn.query(&stmt, params.as_slice()).await.map_err(|e| {
835            let detail = e.as_db_error().map_or("", |d| d.message());
836            FraiseQLError::Database {
837                message:   format!("Function call {function_name} failed: {e}: {detail}"),
838                sql_state: e.code().map(|c| c.code().to_string()),
839            }
840        })?;
841
842        txn.commit().await.map_err(|e| FraiseQLError::Database {
843            message:   format!("Failed to commit session-var transaction: {e}"),
844            sql_state: e.code().map(|c| c.code().to_string()),
845        })?;
846
847        Ok(rows.iter().map(row_to_map).collect())
848    }
849
850    async fn execute_function_call_with_changelog(
851        &self,
852        function_name: &str,
853        args: &[serde_json::Value],
854        session_vars: &[(&str, &str)],
855        changelog: Option<&crate::traits::ChangeLogWrite<'_>>,
856    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
857        // No outbox write requested => identical to the session-affine path
858        // (and inherits its no-session/no-timing fast path).
859        let Some(changelog) = changelog else {
860            return self
861                .execute_function_call_with_session(function_name, args, session_vars)
862                .await;
863        };
864
865        let quoted_fn = quote_postgres_identifier(function_name);
866        // One statement: run the function once and INSERT its outbox row in the
867        // same txn, atomically, with no extra connection acquire (Change Spine).
868        let sql = build_changelog_cte_sql(&quoted_fn, args.len(), changelog.pre_image);
869
870        // Function args first; then the threaded change-log envelope params —
871        // object_type fallback ($n+1), modification_type verb ($n+2), the
872        // tenant_id stamp ($n+3, bound against `::uuid`), the trace_id
873        // ($n+4, plain text), the schema_version ($n+5, plain text), the
874        // trace_context ($n+6, bound against `::jsonb`), the actor_type ($n+7,
875        // plain text) and the acting_for ($n+8, bound against `::uuid`). Order
876        // matches build_changelog_cte_sql's positional contract; appending the
877        // envelope params keeps the SQL text stable for prepare_cached.
878        let mut flex_args: Vec<FlexParam> = args
879            .iter()
880            .map(|v| match v {
881                serde_json::Value::Null => FlexParam::Null,
882                serde_json::Value::String(s) => FlexParam::Text(s.clone()),
883                _ => FlexParam::Text(v.to_string()),
884            })
885            .collect();
886        flex_args.push(FlexParam::Text(changelog.object_type.to_string()));
887        flex_args.push(FlexParam::Text(changelog.modification_type.to_string()));
888        // tenant_id: bound as text and serialised by FlexParam's UUID branch
889        // (the `::uuid` cast pins the param type); None → SQL NULL.
890        flex_args
891            .push(changelog.tenant_id.map_or(FlexParam::Null, |t| FlexParam::Text(t.to_string())));
892        // trace_id ($n+4): plain text, None → SQL NULL.
893        flex_args
894            .push(changelog.trace_id.map_or(FlexParam::Null, |t| FlexParam::Text(t.to_string())));
895        // schema_version ($n+5): plain text, None → SQL NULL.
896        flex_args.push(
897            changelog
898                .schema_version
899                .map_or(FlexParam::Null, |s| FlexParam::Text(s.to_string())),
900        );
901        // trace_context ($n+6): JSON text bound against `::jsonb`, None → SQL NULL.
902        flex_args.push(
903            changelog
904                .trace_context
905                .map_or(FlexParam::Null, |s| FlexParam::Text(s.to_string())),
906        );
907        // actor_type ($n+7): plain text, None → SQL NULL.
908        flex_args
909            .push(changelog.actor_type.map_or(FlexParam::Null, |s| FlexParam::Text(s.to_string())));
910        // acting_for ($n+8): bound as text + serialised by FlexParam's UUID branch
911        // (the `::uuid` cast pins the param type); None → SQL NULL.
912        flex_args
913            .push(changelog.acting_for.map_or(FlexParam::Null, |u| FlexParam::Text(u.to_string())));
914        let params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = flex_args
915            .iter()
916            .map(|v| v as &(dyn tokio_postgres::types::ToSql + Sync))
917            .collect();
918
919        let mut client = self.acquire_connection_with_retry().await?;
920        // Parse/plan the (complex) CTE once per connection (statement cache) — this
921        // is the dominant outbox hot-path cost; cached, the in-txn write is ~free.
922        let stmt = prepare_cached_stmt(&client, sql.as_str()).await?;
923        let txn =
924            client.build_transaction().start().await.map_err(|e| FraiseQLError::Database {
925                message:   format!("Failed to start change-log outbox transaction: {e}"),
926                sql_state: e.code().map(|c| c.code().to_string()),
927            })?;
928
929        // Apply session variables FIRST so the function body sees them (and so
930        // the started_at directive stamps the DB clock on this very txn).
931        apply_session_vars(&txn, session_vars).await?;
932
933        // Mark the txn FraiseQL-mediated so the #366 fallback-capture trigger
934        // suppresses its row — this write is already logged by the outbox below.
935        mark_cdc_mediated(&txn).await?;
936
937        // If mutation timing is on, stamp the timing variable in the same txn.
938        if self.mutation_timing_enabled {
939            txn.execute(
940                "SELECT set_config($1, clock_timestamp()::text, true)",
941                &[&self.timing_variable_name],
942            )
943            .await
944            .map_err(|e| FraiseQLError::Database {
945                message:   format!("Failed to set mutation timing variable: {e}"),
946                sql_state: e.code().map(|c| c.code().to_string()),
947            })?;
948        }
949
950        // The outbox INSERT reads `fraiseql.started_at` with current_setting()
951        // (no missing_ok), so the GUC MUST exist on this txn. The mutation runner
952        // injects it via session_vars on the authenticated path; guarantee it on
953        // every other path (e.g. an unauthenticated mutation that resolves no
954        // session vars) so the duration computation never hits an unset
955        // parameter and aborts the mutation.
956        let started_at_set =
957            session_vars.iter().any(|(name, _)| *name == crate::changelog::STARTED_AT_VAR)
958                || (self.mutation_timing_enabled
959                    && self.timing_variable_name == crate::changelog::STARTED_AT_VAR);
960        if !started_at_set {
961            txn.execute(
962                "SELECT set_config($1, clock_timestamp()::text, true)",
963                &[&crate::changelog::STARTED_AT_VAR],
964            )
965            .await
966            .map_err(|e| FraiseQLError::Database {
967                message:   format!("Failed to stamp change-log started_at: {e}"),
968                sql_state: e.code().map(|c| c.code().to_string()),
969            })?;
970        }
971
972        let rows: Vec<Row> = txn.query(&stmt, params.as_slice()).await.map_err(|e| {
973            let detail = e.as_db_error().map_or("", |d| d.message());
974            FraiseQLError::Database {
975                message:   format!(
976                    "Function call {function_name} (with change-log outbox) failed: {e}: {detail}"
977                ),
978                sql_state: e.code().map(|c| c.code().to_string()),
979            }
980        })?;
981
982        txn.commit().await.map_err(|e| FraiseQLError::Database {
983            message:   format!("Failed to commit change-log outbox transaction: {e}"),
984            sql_state: e.code().map(|c| c.code().to_string()),
985        })?;
986
987        Ok(rows.iter().map(row_to_map).collect())
988    }
989
990    async fn execute_where_query_arc_with_session(
991        &self,
992        view: &str,
993        where_clause: Option<&WhereClause>,
994        limit: Option<u32>,
995        offset: Option<u32>,
996        order_by: Option<&[OrderByClause]>,
997        session_vars: &[(&str, &str)],
998    ) -> Result<Arc<Vec<JsonbValue>>> {
999        if session_vars.is_empty() {
1000            return self.execute_where_query_arc(view, where_clause, limit, offset, order_by).await;
1001        }
1002
1003        let (sql, typed_params) =
1004            build_where_select_sql_ordered(view, where_clause, limit, offset, order_by)?;
1005        let param_refs = crate::types::as_sql_param_refs(&typed_params);
1006
1007        self.execute_raw_with_session(&sql, &param_refs, session_vars)
1008            .await
1009            .map(Arc::new)
1010            .map_err(|e| enrich_undefined_column_error(e, view, where_clause))
1011    }
1012
1013    async fn execute_with_projection_arc_with_session(
1014        &self,
1015        request: &ProjectionRequest<'_>,
1016        session_vars: &[(&str, &str)],
1017    ) -> Result<Arc<Vec<JsonbValue>>> {
1018        if session_vars.is_empty() {
1019            return self.execute_with_projection_arc(request).await;
1020        }
1021
1022        // No projection => behave like a plain WHERE query, matching
1023        // execute_with_projection_impl's fallback.
1024        let Some(projection) = request.projection else {
1025            return self
1026                .execute_where_query_arc_with_session(
1027                    request.view,
1028                    request.where_clause,
1029                    request.limit,
1030                    request.offset,
1031                    request.order_by,
1032                    session_vars,
1033                )
1034                .await;
1035        };
1036
1037        let (sql, typed_params) = build_projection_select_sql(
1038            projection,
1039            request.view,
1040            request.where_clause,
1041            request.limit,
1042            request.offset,
1043            request.order_by,
1044        )?;
1045        let param_refs = crate::types::as_sql_param_refs(&typed_params);
1046
1047        self.execute_raw_with_session(&sql, &param_refs, session_vars)
1048            .await
1049            .map(Arc::new)
1050    }
1051
1052    async fn explain_query(
1053        &self,
1054        sql: &str,
1055        _params: &[serde_json::Value],
1056    ) -> Result<serde_json::Value> {
1057        // Defense-in-depth: reject multi-statement input even though this SQL is
1058        // compiler-generated. A semicolon would allow a second statement to be
1059        // appended to the EXPLAIN prefix.
1060        if sql.contains(';') {
1061            return Err(FraiseQLError::Validation {
1062                message: "EXPLAIN SQL must be a single statement".into(),
1063                path:    None,
1064            });
1065        }
1066        let explain_sql = format!("EXPLAIN (ANALYZE false, FORMAT JSON) {sql}");
1067        let client = self.acquire_connection_with_retry().await?;
1068        let rows: Vec<Row> =
1069            client
1070                .query(explain_sql.as_str(), &[])
1071                .await
1072                .map_err(|e| FraiseQLError::Database {
1073                    message:   format!("EXPLAIN failed: {e}"),
1074                    sql_state: e.code().map(|c| c.code().to_string()),
1075                })?;
1076
1077        if let Some(row) = rows.first() {
1078            let plan: serde_json::Value = row.try_get(0).map_err(|e| FraiseQLError::Database {
1079                message:   format!("Failed to parse EXPLAIN output: {e}"),
1080                sql_state: None,
1081            })?;
1082            Ok(plan)
1083        } else {
1084            Ok(serde_json::Value::Null)
1085        }
1086    }
1087
1088    async fn query_stats(&self, limit: u32) -> Result<Vec<crate::types::QueryStatEntry>> {
1089        self.pg_query_stats(limit).await
1090    }
1091
1092    async fn query_stats_by_id(&self, id: &str) -> Result<Option<crate::types::QueryStatEntry>> {
1093        self.pg_query_stats_by_id(id).await
1094    }
1095
1096    async fn reset_query_stats(&self) -> Result<()> {
1097        self.pg_reset_query_stats().await
1098    }
1099}
1100
1101impl SupportsMutations for PostgresAdapter {}