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::<_, i32>(idx) {
149            serde_json::json!(v)
150        } else if let Ok(v) = row.try_get::<_, i64>(idx) {
151            serde_json::json!(v)
152        } else if let Ok(v) = row.try_get::<_, f64>(idx) {
153            serde_json::json!(v)
154        } else if let Ok(v) = row.try_get::<_, String>(idx) {
155            serde_json::json!(v)
156        } else if let Ok(v) = row.try_get::<_, bool>(idx) {
157            serde_json::json!(v)
158        } else if let Ok(v) = row.try_get::<_, Vec<String>>(idx) {
159            // TEXT[] columns (e.g. `app.mutation_response.updated_fields`) — without
160            // this branch a non-null text array falls through to Null and a parser
161            // expecting a sequence fails. Must precede the jsonb branch (a jsonb
162            // column never deserializes as Vec<String>, so ordering is safe).
163            serde_json::json!(v)
164        } else if let Ok(v) = row.try_get::<_, serde_json::Value>(idx) {
165            v
166        } else {
167            serde_json::Value::Null
168        };
169        map.insert(column_name, value);
170    }
171    map
172}
173
174/// Apply transaction-local session variables on an in-progress transaction.
175///
176/// Each `(name, value)` pair is set with `SELECT set_config($1, $2, true)`, so
177/// the values are scoped to `txn` and visible to every statement run on it
178/// (fixes #329). The caller is responsible for committing or rolling back.
179pub(super) async fn apply_session_vars(
180    txn: &tokio_postgres::Transaction<'_>,
181    session_vars: &[(&str, &str)],
182) -> Result<()> {
183    for (name, value) in session_vars {
184        // A var carrying the clock-timestamp directive (e.g. fraiseql.started_at)
185        // is stamped with the DB clock at apply time, on this very transaction, so
186        // the start timestamp and the close-of-interval at the outbox write share
187        // one clock (no app↔DB skew). All other vars bind their literal value.
188        if *value == crate::changelog::CLOCK_TIMESTAMP_DIRECTIVE {
189            txn.execute("SELECT set_config($1, clock_timestamp()::text, true)", &[name])
190                .await
191                .map_err(|e| FraiseQLError::Database {
192                    message:   format!("set_config({name:?}, clock_timestamp()) failed: {e}"),
193                    sql_state: e.code().map(|c| c.code().to_string()),
194                })?;
195        } else {
196            txn.execute("SELECT set_config($1, $2, true)", &[name, value])
197                .await
198                .map_err(|e| FraiseQLError::Database {
199                    message:   format!("set_config({name:?}) failed: {e}"),
200                    sql_state: e.code().map(|c| c.code().to_string()),
201                })?;
202        }
203    }
204    Ok(())
205}
206
207/// Mark the in-progress mutation transaction as **FraiseQL-mediated** (#366).
208///
209/// Sets [`crate::changelog::CDC_MEDIATED_VAR`] to
210/// [`crate::changelog::CDC_MEDIATED_ON`] transaction-locally
211/// (`set_config(..., true)`), so the value auto-resets on commit and is invisible
212/// to other connections. The shipped fallback-capture trigger reads it and
213/// suppresses its own change-log row for this write, so an app-path mutation —
214/// already logged by the in-transaction outbox — is never double-captured. Set
215/// on every mutation transaction (independent of whether the outbox row is
216/// written), so an opted-out mutation (`changelog=false`) also suppresses the
217/// trigger rather than leaving a degraded fallback row.
218pub(super) async fn mark_cdc_mediated(txn: &tokio_postgres::Transaction<'_>) -> Result<()> {
219    txn.execute(
220        "SELECT set_config($1, $2, true)",
221        &[
222            &crate::changelog::CDC_MEDIATED_VAR,
223            &crate::changelog::CDC_MEDIATED_ON,
224        ],
225    )
226    .await
227    .map_err(|e| FraiseQLError::Database {
228        message:   format!("Failed to set {} marker: {e}", crate::changelog::CDC_MEDIATED_VAR),
229        sql_state: e.code().map(|c| c.code().to_string()),
230    })?;
231    Ok(())
232}
233
234/// Build the single statement that runs a mutation function and writes its
235/// `core.tb_entity_change_log` outbox row in the same transaction (the Change
236/// Spine transactional outbox).
237///
238/// The function call is materialised once (`WITH r AS MATERIALIZED`, so a
239/// volatile mutation function executes **exactly once** even though two CTEs
240/// read it), a data-modifying CTE INSERTs the change-log row for an effective
241/// change (`succeeded AND state_changed`), and the primary `SELECT * FROM r`
242/// returns the function's row unchanged to the caller. The changed-entity
243/// columns (`object_id`, `object_data`, `updated_fields`, `cascade`) come from
244/// the function's own `app.mutation_response` row; `$<n+1>` is the NOT-NULL
245/// `object_type` fallback, `$<n+2>` is the `modification_type` verb,
246/// `$<n+3>` is the envelope `tenant_id` (the Trinity public-facing UUID, NULL
247/// for system / unauthenticated / non-UUID-tenant rows), `$<n+4>` is the
248/// `trace_id` (the request's W3C trace id, plain text), `$<n+5>` is the
249/// `schema_version` (the compiled schema's content hash, plain text), and
250/// `$<n+6>` is the `trace_context` (the full W3C trace context, cast `::jsonb`),
251/// where `n` = `n_args` (the number of function arguments). The envelope params are
252/// appended **after** the function args so the SQL text stays deterministic per
253/// `(function, n_args)` — `prepare_cached` keys the statement by text, so the
254/// column list and placeholder positions must never depend on the param *values*.
255///
256/// `started_at`/`duration_ms` read `fraiseql.started_at` (set txn-locally by the
257/// caller) on the DB clock — the canonical computation from
258/// [`crate::changelog::duration_ms_sql`], stamped with
259/// [`crate::changelog::DURATION_CALC_VERSION`]. `commit_time` is the DB clock at
260/// INSERT; `seq` is omitted so the column's `SEQUENCE` default fires (so any
261/// INSERTer, incl. cooperative external producers, gets a monotonic value).
262pub(super) fn build_changelog_cte_sql(quoted_fn: &str, n_args: usize) -> String {
263    let placeholders: Vec<String> = (1..=n_args).map(|i| format!("${i}")).collect();
264    let object_type_idx = n_args + 1;
265    let modification_type_idx = n_args + 2;
266    let tenant_id_idx = n_args + 3;
267    let trace_id_idx = n_args + 4;
268    let schema_version_idx = n_args + 5;
269    let trace_context_idx = n_args + 6;
270    let actor_type_idx = n_args + 7;
271    let acting_for_idx = n_args + 8;
272    let started_var = crate::changelog::STARTED_AT_VAR;
273    let duration = crate::changelog::duration_ms_sql(started_var);
274    let calc_version = crate::changelog::DURATION_CALC_VERSION;
275    format!(
276        "WITH r AS MATERIALIZED (SELECT * FROM {quoted_fn}({args})), \
277         _changelog AS ( \
278           INSERT INTO core.tb_entity_change_log \
279             (object_type, modification_type, object_id, object_data, \
280              updated_fields, cascade, started_at, duration_ms, extra_metadata, \
281              tenant_id, trace_id, schema_version, trace_context, \
282              actor_type, acting_for, commit_time) \
283           SELECT \
284             COALESCE(r.entity_type, ${object_type_idx}), \
285             ${modification_type_idx}, \
286             r.entity_id, r.entity, r.updated_fields, r.cascade, \
287             current_setting('{started_var}')::timestamptz, \
288             {duration}, \
289             jsonb_build_object('duration_calc_version', {calc_version}::int), \
290             ${tenant_id_idx}::uuid, \
291             ${trace_id_idx}, \
292             ${schema_version_idx}, \
293             ${trace_context_idx}::jsonb, \
294             ${actor_type_idx}, \
295             ${acting_for_idx}::uuid, \
296             clock_timestamp() \
297           FROM r \
298           WHERE r.succeeded AND r.state_changed \
299           RETURNING 1 \
300         ) \
301         SELECT * FROM r",
302        args = placeholders.join(", "),
303    )
304}
305
306/// Prepare `sql` on `client` using deadpool's **per-connection statement cache**.
307///
308/// The mutation function-call path sends the same statement (a fixed
309/// `SELECT * FROM fn(...)` or the change-log CTE) on every call, so without
310/// caching PostgreSQL re-parses and re-plans it for each mutation — the dominant
311/// hot-path cost (the complex outbox CTE in particular). `prepare_cached` keys
312/// the parsed/planned [`tokio_postgres::Statement`] by SQL text per connection
313/// and reuses it across calls, so the parse/plan happens once per connection.
314///
315/// Returns an owned `Statement` (releasing the `&client` borrow), so it can be
316/// prepared before `client.build_transaction()` and used inside that transaction.
317async fn prepare_cached_stmt(
318    client: &deadpool_postgres::Client,
319    sql: &str,
320) -> Result<tokio_postgres::Statement> {
321    client.prepare_cached(sql).await.map_err(|e| FraiseQLError::Database {
322        message:   format!("Failed to prepare statement: {e}"),
323        sql_state: e.code().map(|c| c.code().to_string()),
324    })
325}
326
327// Reason: DatabaseAdapter is defined with #[async_trait]; all implementations must match
328// its transformed method signatures to satisfy the trait contract
329// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
330#[async_trait]
331impl DatabaseAdapter for PostgresAdapter {
332    async fn execute_with_projection(
333        &self,
334        view: &str,
335        projection: Option<&SqlProjectionHint>,
336        where_clause: Option<&WhereClause>,
337        limit: Option<u32>,
338        offset: Option<u32>,
339        order_by: Option<&[OrderByClause]>,
340    ) -> Result<Vec<JsonbValue>> {
341        self.execute_with_projection_impl(view, projection, where_clause, limit, offset, order_by)
342            .await
343    }
344
345    async fn execute_where_query(
346        &self,
347        view: &str,
348        where_clause: Option<&WhereClause>,
349        limit: Option<u32>,
350        offset: Option<u32>,
351        order_by: Option<&[OrderByClause]>,
352    ) -> Result<Vec<JsonbValue>> {
353        let (sql, typed_params) =
354            build_where_select_sql_ordered(view, where_clause, limit, offset, order_by)?;
355
356        let param_refs = crate::types::as_sql_param_refs(&typed_params);
357
358        self.execute_raw(&sql, &param_refs)
359            .await
360            .map_err(|e| enrich_undefined_column_error(e, view, where_clause))
361    }
362
363    async fn explain_where_query(
364        &self,
365        view: &str,
366        where_clause: Option<&WhereClause>,
367        limit: Option<u32>,
368        offset: Option<u32>,
369    ) -> Result<serde_json::Value> {
370        let (select_sql, typed_params) = build_where_select_sql(view, where_clause, limit, offset)?;
371        // Defense-in-depth: compiler-generated SQL should never contain a
372        // semicolon, but guard against it to prevent statement injection.
373        if select_sql.contains(';') {
374            return Err(FraiseQLError::Validation {
375                message: "EXPLAIN SQL must be a single statement".into(),
376                path:    None,
377            });
378        }
379        let explain_sql = format!("EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) {select_sql}");
380
381        let param_refs = crate::types::as_sql_param_refs(&typed_params);
382
383        let client = self.acquire_connection_with_retry().await?;
384        let rows = client.query(explain_sql.as_str(), &param_refs).await.map_err(|e| {
385            FraiseQLError::Database {
386                message:   format!("EXPLAIN ANALYZE failed: {e}"),
387                sql_state: e.code().map(|c| c.code().to_string()),
388            }
389        })?;
390
391        if let Some(row) = rows.first() {
392            let plan: serde_json::Value = row.try_get(0).map_err(|e| FraiseQLError::Database {
393                message:   format!("Failed to parse EXPLAIN output: {e}"),
394                sql_state: None,
395            })?;
396            Ok(plan)
397        } else {
398            Ok(serde_json::Value::Null)
399        }
400    }
401
402    fn database_type(&self) -> DatabaseType {
403        DatabaseType::PostgreSQL
404    }
405
406    async fn health_check(&self) -> Result<()> {
407        // Use retry logic for health check to avoid false negatives during pool exhaustion
408        let client = self.acquire_connection_with_retry().await?;
409
410        client.query("SELECT 1", &[]).await.map_err(|e| FraiseQLError::Database {
411            message:   format!("Health check failed: {e}"),
412            sql_state: e.code().map(|c| c.code().to_string()),
413        })?;
414
415        Ok(())
416    }
417
418    #[allow(clippy::cast_possible_truncation)] // Reason: value is bounded; truncation cannot occur in practice
419    fn pool_metrics(&self) -> PoolMetrics {
420        let status = self.pool.status();
421
422        PoolMetrics {
423            total_connections:  status.size as u32,
424            idle_connections:   status.available as u32,
425            active_connections: (status.size - status.available) as u32,
426            waiting_requests:   status.waiting as u32,
427        }
428    }
429
430    /// # Security
431    ///
432    /// `sql` **must** be compiler-generated. Never pass user-supplied strings
433    /// directly — doing so would open SQL-injection vulnerabilities.
434    async fn execute_raw_query(
435        &self,
436        sql: &str,
437    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
438        // Use retry logic for connection acquisition
439        let client = self.acquire_connection_with_retry().await?;
440
441        let rows: Vec<Row> = client.query(sql, &[]).await.map_err(|e| FraiseQLError::Database {
442            message:   format!("Query execution failed: {e}"),
443            sql_state: e.code().map(|c| c.code().to_string()),
444        })?;
445
446        // Convert each row to HashMap<String, Value>
447        let results: Vec<std::collections::HashMap<String, serde_json::Value>> =
448            rows.iter().map(row_to_map).collect();
449
450        Ok(results)
451    }
452
453    async fn execute_parameterized_aggregate(
454        &self,
455        sql: &str,
456        params: &[serde_json::Value],
457    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
458        // Convert serde_json::Value params to QueryParam so that strings are bound
459        // as TEXT (not JSONB), which is required for correct WHERE comparisons against
460        // data->>'field' expressions that return TEXT.
461        let typed: Vec<QueryParam> = params.iter().cloned().map(QueryParam::from).collect();
462        let param_refs = crate::types::as_sql_param_refs(&typed);
463
464        let client = self.acquire_connection_with_retry().await?;
465        let rows: Vec<Row> =
466            client.query(sql, &param_refs).await.map_err(|e| FraiseQLError::Database {
467                message:   format!("Parameterized aggregate query failed: {e}"),
468                sql_state: e.code().map(|c| c.code().to_string()),
469            })?;
470
471        let results: Vec<std::collections::HashMap<String, serde_json::Value>> =
472            rows.iter().map(row_to_map).collect();
473
474        Ok(results)
475    }
476
477    async fn execute_parameterized_aggregate_with_session(
478        &self,
479        sql: &str,
480        params: &[serde_json::Value],
481        session_vars: &[(&str, &str)],
482    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
483        if session_vars.is_empty() {
484            return self.execute_parameterized_aggregate(sql, params).await;
485        }
486
487        let typed: Vec<QueryParam> = params.iter().cloned().map(QueryParam::from).collect();
488        let param_refs = crate::types::as_sql_param_refs(&typed);
489
490        let mut client = self.acquire_connection_with_retry().await?;
491        let txn =
492            client.build_transaction().start().await.map_err(|e| FraiseQLError::Database {
493                message:   format!("Failed to start session-var transaction: {e}"),
494                sql_state: e.code().map(|c| c.code().to_string()),
495            })?;
496        apply_session_vars(&txn, session_vars).await?;
497        let rows: Vec<Row> =
498            txn.query(sql, &param_refs).await.map_err(|e| FraiseQLError::Database {
499                message:   format!("Parameterized aggregate query failed: {e}"),
500                sql_state: e.code().map(|c| c.code().to_string()),
501            })?;
502        txn.commit().await.map_err(|e| FraiseQLError::Database {
503            message:   format!("Failed to commit session-var transaction: {e}"),
504            sql_state: e.code().map(|c| c.code().to_string()),
505        })?;
506
507        Ok(rows.iter().map(row_to_map).collect())
508    }
509
510    async fn execute_function_call(
511        &self,
512        function_name: &str,
513        args: &[serde_json::Value],
514    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
515        // Build: SELECT * FROM "fn_name"($1, $2, ...)
516        // Use the standard identifier quoting utility so that schema-qualified
517        // names like "benchmark.fn_update_user" are correctly split into
518        // "benchmark"."fn_update_user" instead of being wrapped as a single
519        // identifier.
520        let quoted_fn = quote_postgres_identifier(function_name);
521        let placeholders: Vec<String> = (1..=args.len()).map(|i| format!("${i}")).collect();
522        let sql = format!("SELECT * FROM {quoted_fn}({})", placeholders.join(", "));
523
524        let mut client = self.acquire_connection_with_retry().await?;
525
526        // Convert serde_json::Value arguments to FlexParam for binding.
527        //
528        // serde_json::Value only accepts JSON/JSONB types; Option<String> only accepts
529        // text-family types.  Neither works universally when the function signature
530        // contains a mix of JSONB, UUID, INT4, and TEXT parameters.  FlexParam accepts
531        // all PostgreSQL types and serialises each value in the correct binary wire
532        // format for the server-resolved parameter type.
533        let flex_args: Vec<FlexParam> = args
534            .iter()
535            .map(|v| match v {
536                serde_json::Value::Null => FlexParam::Null,
537                serde_json::Value::String(s) => FlexParam::Text(s.clone()),
538                _ => FlexParam::Text(v.to_string()),
539            })
540            .collect();
541        let params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = flex_args
542            .iter()
543            .map(|v| v as &(dyn tokio_postgres::types::ToSql + Sync))
544            .collect();
545
546        // Parse/plan the statement once per connection and reuse it (deadpool's
547        // statement cache); prepared before any transaction so the owned Statement
548        // is usable inside it.
549        let stmt = prepare_cached_stmt(&client, sql.as_str()).await?;
550
551        if self.mutation_timing_enabled {
552            // Wrap in a transaction so SET LOCAL scopes the variable to this call only.
553            // `set_config(name, value, is_local)` with is_local=true is equivalent to
554            // SET LOCAL and is parameterized to avoid SQL injection.
555            let txn =
556                client.build_transaction().start().await.map_err(|e| FraiseQLError::Database {
557                    message:   format!("Failed to start mutation timing transaction: {e}"),
558                    sql_state: e.code().map(|c| c.code().to_string()),
559                })?;
560
561            txn.execute(
562                "SELECT set_config($1, clock_timestamp()::text, true)",
563                &[&self.timing_variable_name],
564            )
565            .await
566            .map_err(|e| FraiseQLError::Database {
567                message:   format!("Failed to set mutation timing variable: {e}"),
568                sql_state: e.code().map(|c| c.code().to_string()),
569            })?;
570
571            let rows: Vec<Row> = txn.query(&stmt, params.as_slice()).await.map_err(|e| {
572                let detail = e.as_db_error().map_or("", |d| d.message());
573                FraiseQLError::Database {
574                    message:   format!("Function call {function_name} failed: {e}: {detail}"),
575                    sql_state: e.code().map(|c| c.code().to_string()),
576                }
577            })?;
578
579            txn.commit().await.map_err(|e| FraiseQLError::Database {
580                message:   format!("Failed to commit mutation timing transaction: {e}"),
581                sql_state: e.code().map(|c| c.code().to_string()),
582            })?;
583
584            let results: Vec<std::collections::HashMap<String, serde_json::Value>> =
585                rows.iter().map(row_to_map).collect();
586
587            Ok(results)
588        } else {
589            let rows: Vec<Row> = client.query(&stmt, params.as_slice()).await.map_err(|e| {
590                let detail = e.as_db_error().map_or("", |d| d.message());
591                FraiseQLError::Database {
592                    message:   format!("Function call {function_name} failed: {e}: {detail}"),
593                    sql_state: e.code().map(|c| c.code().to_string()),
594                }
595            })?;
596
597            let results: Vec<std::collections::HashMap<String, serde_json::Value>> =
598                rows.iter().map(row_to_map).collect();
599
600            Ok(results)
601        }
602    }
603
604    // PostgreSQL session variables are applied connection-affinely by the
605    // `*_with_session` methods below: `set_config(..., true)` and the operation
606    // share one transaction on one connection, so transaction-local GUCs are
607    // visible to the function / view (fixes #329).
608
609    async fn execute_function_call_with_session(
610        &self,
611        function_name: &str,
612        args: &[serde_json::Value],
613        session_vars: &[(&str, &str)],
614    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
615        // Fast path: no session variables and no mutation timing => behave
616        // exactly like execute_function_call (no transaction overhead, and
617        // execute_function_call already opens its own txn when timing is on).
618        if session_vars.is_empty() && !self.mutation_timing_enabled {
619            return self.execute_function_call(function_name, args).await;
620        }
621
622        let quoted_fn = quote_postgres_identifier(function_name);
623        let placeholders: Vec<String> = (1..=args.len()).map(|i| format!("${i}")).collect();
624        let sql = format!("SELECT * FROM {quoted_fn}({})", placeholders.join(", "));
625
626        // See execute_function_call for why FlexParam is required here.
627        let flex_args: Vec<FlexParam> = args
628            .iter()
629            .map(|v| match v {
630                serde_json::Value::Null => FlexParam::Null,
631                serde_json::Value::String(s) => FlexParam::Text(s.clone()),
632                _ => FlexParam::Text(v.to_string()),
633            })
634            .collect();
635        let params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = flex_args
636            .iter()
637            .map(|v| v as &(dyn tokio_postgres::types::ToSql + Sync))
638            .collect();
639
640        let mut client = self.acquire_connection_with_retry().await?;
641        // Parse/plan once per connection (statement cache), before the txn.
642        let stmt = prepare_cached_stmt(&client, sql.as_str()).await?;
643        let txn =
644            client.build_transaction().start().await.map_err(|e| FraiseQLError::Database {
645                message:   format!("Failed to start session-var transaction: {e}"),
646                sql_state: e.code().map(|c| c.code().to_string()),
647            })?;
648
649        // Apply session variables FIRST so the function body sees them.
650        apply_session_vars(&txn, session_vars).await?;
651
652        // Mark the txn FraiseQL-mediated (#366). Even on the no-outbox path
653        // (e.g. a `changelog=false` mutation), a mutation routed through the
654        // executor must suppress the fallback-capture trigger — the opt-out means
655        // "no change-log row," not "let the trigger write a degraded one."
656        mark_cdc_mediated(&txn).await?;
657
658        // If mutation timing is on, stamp the timing variable in the same txn.
659        if self.mutation_timing_enabled {
660            txn.execute(
661                "SELECT set_config($1, clock_timestamp()::text, true)",
662                &[&self.timing_variable_name],
663            )
664            .await
665            .map_err(|e| FraiseQLError::Database {
666                message:   format!("Failed to set mutation timing variable: {e}"),
667                sql_state: e.code().map(|c| c.code().to_string()),
668            })?;
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 session-var transaction: {e}"),
681            sql_state: e.code().map(|c| c.code().to_string()),
682        })?;
683
684        Ok(rows.iter().map(row_to_map).collect())
685    }
686
687    async fn execute_function_call_with_changelog(
688        &self,
689        function_name: &str,
690        args: &[serde_json::Value],
691        session_vars: &[(&str, &str)],
692        changelog: Option<&crate::traits::ChangeLogWrite<'_>>,
693    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
694        // No outbox write requested => identical to the session-affine path
695        // (and inherits its no-session/no-timing fast path).
696        let Some(changelog) = changelog else {
697            return self
698                .execute_function_call_with_session(function_name, args, session_vars)
699                .await;
700        };
701
702        let quoted_fn = quote_postgres_identifier(function_name);
703        // One statement: run the function once and INSERT its outbox row in the
704        // same txn, atomically, with no extra connection acquire (Change Spine).
705        let sql = build_changelog_cte_sql(&quoted_fn, args.len());
706
707        // Function args first; then the threaded change-log envelope params —
708        // object_type fallback ($n+1), modification_type verb ($n+2), the
709        // tenant_id stamp ($n+3, bound against `::uuid`), the trace_id
710        // ($n+4, plain text), the schema_version ($n+5, plain text), the
711        // trace_context ($n+6, bound against `::jsonb`), the actor_type ($n+7,
712        // plain text) and the acting_for ($n+8, bound against `::uuid`). Order
713        // matches build_changelog_cte_sql's positional contract; appending the
714        // envelope params keeps the SQL text stable for prepare_cached.
715        let mut flex_args: Vec<FlexParam> = args
716            .iter()
717            .map(|v| match v {
718                serde_json::Value::Null => FlexParam::Null,
719                serde_json::Value::String(s) => FlexParam::Text(s.clone()),
720                _ => FlexParam::Text(v.to_string()),
721            })
722            .collect();
723        flex_args.push(FlexParam::Text(changelog.object_type.to_string()));
724        flex_args.push(FlexParam::Text(changelog.modification_type.to_string()));
725        // tenant_id: bound as text and serialised by FlexParam's UUID branch
726        // (the `::uuid` cast pins the param type); None → SQL NULL.
727        flex_args
728            .push(changelog.tenant_id.map_or(FlexParam::Null, |t| FlexParam::Text(t.to_string())));
729        // trace_id ($n+4): plain text, None → SQL NULL.
730        flex_args
731            .push(changelog.trace_id.map_or(FlexParam::Null, |t| FlexParam::Text(t.to_string())));
732        // schema_version ($n+5): plain text, None → SQL NULL.
733        flex_args.push(
734            changelog
735                .schema_version
736                .map_or(FlexParam::Null, |s| FlexParam::Text(s.to_string())),
737        );
738        // trace_context ($n+6): JSON text bound against `::jsonb`, None → SQL NULL.
739        flex_args.push(
740            changelog
741                .trace_context
742                .map_or(FlexParam::Null, |s| FlexParam::Text(s.to_string())),
743        );
744        // actor_type ($n+7): plain text, None → SQL NULL.
745        flex_args
746            .push(changelog.actor_type.map_or(FlexParam::Null, |s| FlexParam::Text(s.to_string())));
747        // acting_for ($n+8): bound as text + serialised by FlexParam's UUID branch
748        // (the `::uuid` cast pins the param type); None → SQL NULL.
749        flex_args
750            .push(changelog.acting_for.map_or(FlexParam::Null, |u| FlexParam::Text(u.to_string())));
751        let params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = flex_args
752            .iter()
753            .map(|v| v as &(dyn tokio_postgres::types::ToSql + Sync))
754            .collect();
755
756        let mut client = self.acquire_connection_with_retry().await?;
757        // Parse/plan the (complex) CTE once per connection (statement cache) — this
758        // is the dominant outbox hot-path cost; cached, the in-txn write is ~free.
759        let stmt = prepare_cached_stmt(&client, sql.as_str()).await?;
760        let txn =
761            client.build_transaction().start().await.map_err(|e| FraiseQLError::Database {
762                message:   format!("Failed to start change-log outbox transaction: {e}"),
763                sql_state: e.code().map(|c| c.code().to_string()),
764            })?;
765
766        // Apply session variables FIRST so the function body sees them (and so
767        // the started_at directive stamps the DB clock on this very txn).
768        apply_session_vars(&txn, session_vars).await?;
769
770        // Mark the txn FraiseQL-mediated so the #366 fallback-capture trigger
771        // suppresses its row — this write is already logged by the outbox below.
772        mark_cdc_mediated(&txn).await?;
773
774        // If mutation timing is on, stamp the timing variable in the same txn.
775        if self.mutation_timing_enabled {
776            txn.execute(
777                "SELECT set_config($1, clock_timestamp()::text, true)",
778                &[&self.timing_variable_name],
779            )
780            .await
781            .map_err(|e| FraiseQLError::Database {
782                message:   format!("Failed to set mutation timing variable: {e}"),
783                sql_state: e.code().map(|c| c.code().to_string()),
784            })?;
785        }
786
787        // The outbox INSERT reads `fraiseql.started_at` with current_setting()
788        // (no missing_ok), so the GUC MUST exist on this txn. The mutation runner
789        // injects it via session_vars on the authenticated path; guarantee it on
790        // every other path (e.g. an unauthenticated mutation that resolves no
791        // session vars) so the duration computation never hits an unset
792        // parameter and aborts the mutation.
793        let started_at_set =
794            session_vars.iter().any(|(name, _)| *name == crate::changelog::STARTED_AT_VAR)
795                || (self.mutation_timing_enabled
796                    && self.timing_variable_name == crate::changelog::STARTED_AT_VAR);
797        if !started_at_set {
798            txn.execute(
799                "SELECT set_config($1, clock_timestamp()::text, true)",
800                &[&crate::changelog::STARTED_AT_VAR],
801            )
802            .await
803            .map_err(|e| FraiseQLError::Database {
804                message:   format!("Failed to stamp change-log started_at: {e}"),
805                sql_state: e.code().map(|c| c.code().to_string()),
806            })?;
807        }
808
809        let rows: Vec<Row> = txn.query(&stmt, params.as_slice()).await.map_err(|e| {
810            let detail = e.as_db_error().map_or("", |d| d.message());
811            FraiseQLError::Database {
812                message:   format!(
813                    "Function call {function_name} (with change-log outbox) failed: {e}: {detail}"
814                ),
815                sql_state: e.code().map(|c| c.code().to_string()),
816            }
817        })?;
818
819        txn.commit().await.map_err(|e| FraiseQLError::Database {
820            message:   format!("Failed to commit change-log outbox transaction: {e}"),
821            sql_state: e.code().map(|c| c.code().to_string()),
822        })?;
823
824        Ok(rows.iter().map(row_to_map).collect())
825    }
826
827    async fn execute_where_query_arc_with_session(
828        &self,
829        view: &str,
830        where_clause: Option<&WhereClause>,
831        limit: Option<u32>,
832        offset: Option<u32>,
833        order_by: Option<&[OrderByClause]>,
834        session_vars: &[(&str, &str)],
835    ) -> Result<Arc<Vec<JsonbValue>>> {
836        if session_vars.is_empty() {
837            return self.execute_where_query_arc(view, where_clause, limit, offset, order_by).await;
838        }
839
840        let (sql, typed_params) =
841            build_where_select_sql_ordered(view, where_clause, limit, offset, order_by)?;
842        let param_refs = crate::types::as_sql_param_refs(&typed_params);
843
844        self.execute_raw_with_session(&sql, &param_refs, session_vars)
845            .await
846            .map(Arc::new)
847            .map_err(|e| enrich_undefined_column_error(e, view, where_clause))
848    }
849
850    async fn execute_with_projection_arc_with_session(
851        &self,
852        request: &ProjectionRequest<'_>,
853        session_vars: &[(&str, &str)],
854    ) -> Result<Arc<Vec<JsonbValue>>> {
855        if session_vars.is_empty() {
856            return self.execute_with_projection_arc(request).await;
857        }
858
859        // No projection => behave like a plain WHERE query, matching
860        // execute_with_projection_impl's fallback.
861        let Some(projection) = request.projection else {
862            return self
863                .execute_where_query_arc_with_session(
864                    request.view,
865                    request.where_clause,
866                    request.limit,
867                    request.offset,
868                    request.order_by,
869                    session_vars,
870                )
871                .await;
872        };
873
874        let (sql, typed_params) = build_projection_select_sql(
875            projection,
876            request.view,
877            request.where_clause,
878            request.limit,
879            request.offset,
880            request.order_by,
881        )?;
882        let param_refs = crate::types::as_sql_param_refs(&typed_params);
883
884        self.execute_raw_with_session(&sql, &param_refs, session_vars)
885            .await
886            .map(Arc::new)
887    }
888
889    async fn explain_query(
890        &self,
891        sql: &str,
892        _params: &[serde_json::Value],
893    ) -> Result<serde_json::Value> {
894        // Defense-in-depth: reject multi-statement input even though this SQL is
895        // compiler-generated. A semicolon would allow a second statement to be
896        // appended to the EXPLAIN prefix.
897        if sql.contains(';') {
898            return Err(FraiseQLError::Validation {
899                message: "EXPLAIN SQL must be a single statement".into(),
900                path:    None,
901            });
902        }
903        let explain_sql = format!("EXPLAIN (ANALYZE false, FORMAT JSON) {sql}");
904        let client = self.acquire_connection_with_retry().await?;
905        let rows: Vec<Row> =
906            client
907                .query(explain_sql.as_str(), &[])
908                .await
909                .map_err(|e| FraiseQLError::Database {
910                    message:   format!("EXPLAIN failed: {e}"),
911                    sql_state: e.code().map(|c| c.code().to_string()),
912                })?;
913
914        if let Some(row) = rows.first() {
915            let plan: serde_json::Value = row.try_get(0).map_err(|e| FraiseQLError::Database {
916                message:   format!("Failed to parse EXPLAIN output: {e}"),
917                sql_state: None,
918            })?;
919            Ok(plan)
920        } else {
921            Ok(serde_json::Value::Null)
922        }
923    }
924
925    async fn query_stats(&self, limit: u32) -> Result<Vec<crate::types::QueryStatEntry>> {
926        self.pg_query_stats(limit).await
927    }
928
929    async fn query_stats_by_id(&self, id: &str) -> Result<Option<crate::types::QueryStatEntry>> {
930        self.pg_query_stats_by_id(id).await
931    }
932
933    async fn reset_query_stats(&self) -> Result<()> {
934        self.pg_reset_query_stats().await
935    }
936}
937
938impl SupportsMutations for PostgresAdapter {}