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