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