Skip to main content

fraiseql_db/postgres/adapter/
database.rs

1//! `DatabaseAdapter` and `Writer` implementations for `PostgresAdapter`.
2
3use std::sync::Arc;
4
5use async_trait::async_trait;
6use bytes::BufMut as _;
7use fraiseql_error::{FraiseQLError, Result};
8use tokio_postgres::{
9    Row,
10    types::{FromSql, Kind, Type},
11};
12
13use super::{
14    PostgresAdapter, build_projection_select_sql, build_where_select_sql,
15    build_where_select_sql_ordered, composed::build_composed_select_sql, jsonb_cell,
16    numeric::PgNumericText,
17};
18use crate::{
19    identifier::quote_postgres_identifier,
20    postgres::pg_detail,
21    traits::{
22        ColumnRowStream, ComposedLevel, DatabaseAdapter, JsonbRowStream, ProjectionRequest, Writer,
23    },
24    types::{
25        DatabaseType, JsonbValue, PoolMetrics, QueryParam, ReadRouting,
26        sql_hints::{OrderByClause, SqlProjectionHint},
27    },
28    where_clause::WhereClause,
29};
30
31/// The one write (ruling AA 6): run a write function inside one explicit transaction,
32/// with the change-log outbox row in the same statement when the request carries one, and
33/// let the gate decide the transaction's end.
34///
35/// `gate` runs after the function has executed and before the transaction ends, on the
36/// rows it returned. An `Err` from it rolls back and is returned to the caller verbatim, so
37/// a refusal that could only be decided from the written row still refuses the write. In
38/// [`WriteMode::DryRun`] everything else happens exactly as in a commit — session
39/// variables, CDC marking, the timing stamp, the outbox row, the gate — and the transaction
40/// then rolls back: a dry run exercises the write it stands in for, and nothing persists.
41async fn run_function_in_txn(
42    adapter: &PostgresAdapter,
43    request: &crate::traits::WriteRequest<'_>,
44    gate: crate::traits::MutationRowGate<'_>,
45) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
46    let crate::traits::WriteRequest {
47        function: function_name,
48        args,
49        session_vars,
50        changelog,
51        mode,
52        ..
53    } = *request;
54    let commit = matches!(mode, crate::traits::WriteMode::Commit);
55    // Arm the read-your-writes pin (entry + post-commit) — #407. A dry run writes nothing
56    // for a later read to miss.
57    if commit {
58        adapter.mark_write();
59    }
60    let quoted_fn = quote_postgres_identifier(function_name);
61
62    // One statement when there is an outbox row: run the function once and INSERT
63    // its outbox row in the same txn, atomically, with no extra connection acquire
64    // (Change Spine). Otherwise the plain call.
65    let sql = if let Some(cl) = changelog {
66        build_changelog_cte_sql(&quoted_fn, args.len(), cl.pre_image)
67    } else {
68        let placeholders: Vec<String> = (1..=args.len()).map(|i| format!("${i}")).collect();
69        format!("SELECT * FROM {quoted_fn}({})", placeholders.join(", "))
70    };
71
72    // Arguments bind as FlexParam: serde_json::Value only accepts JSON/JSONB types, and
73    // Option<String> only text-family types, and a function signature can mix JSONB, UUID,
74    // INT4 and TEXT. FlexParam accepts every PostgreSQL type and serialises each value in the
75    // binary wire format of the server-resolved parameter type.
76    let mut flex_args: Vec<FlexParam> = args
77        .iter()
78        .map(|v| match v {
79            serde_json::Value::Null => FlexParam::Null,
80            serde_json::Value::String(s) => FlexParam::Text(s.clone()),
81            _ => FlexParam::Text(v.to_string()),
82        })
83        .collect();
84    if let Some(changelog) = changelog {
85        // Function args first; then the threaded change-log envelope params —
86        // object_type fallback ($n+1), modification_type verb ($n+2), the
87        // tenant_id stamp ($n+3, bound against `::uuid`), the trace_id
88        // ($n+4, plain text), the schema_version ($n+5, plain text), the
89        // trace_context ($n+6, bound against `::jsonb`), the actor_type ($n+7,
90        // plain text), the acting_for ($n+8, bound against `::uuid`) and the
91        // transport ($n+9, plain text merged into extra_metadata — #376). Order
92        // matches build_changelog_cte_sql's positional contract; appending the
93        // envelope params keeps the SQL text stable for prepare_cached.
94        flex_args.push(FlexParam::Text(changelog.object_type.to_string()));
95        flex_args.push(FlexParam::Text(changelog.modification_type.to_string()));
96        // tenant_id: bound as text and serialised by FlexParam's UUID branch
97        // (the `::uuid` cast pins the param type); None → SQL NULL.
98        flex_args
99            .push(changelog.tenant_id.map_or(FlexParam::Null, |t| FlexParam::Text(t.to_string())));
100        // trace_id ($n+4): plain text, None → SQL NULL.
101        flex_args
102            .push(changelog.trace_id.map_or(FlexParam::Null, |t| FlexParam::Text(t.to_string())));
103        // schema_version ($n+5): plain text, None → SQL NULL.
104        flex_args.push(
105            changelog
106                .schema_version
107                .map_or(FlexParam::Null, |s| FlexParam::Text(s.to_string())),
108        );
109        // trace_context ($n+6): JSON text bound against `::jsonb`, None → SQL NULL.
110        flex_args.push(
111            changelog
112                .trace_context
113                .map_or(FlexParam::Null, |s| FlexParam::Text(s.to_string())),
114        );
115        // actor_type ($n+7): plain text, None → SQL NULL.
116        flex_args
117            .push(changelog.actor_type.map_or(FlexParam::Null, |s| FlexParam::Text(s.to_string())));
118        // acting_for ($n+8): bound as text + serialised by FlexParam's UUID branch
119        // (the `::uuid` cast pins the param type); None → SQL NULL.
120        flex_args
121            .push(changelog.acting_for.map_or(FlexParam::Null, |u| FlexParam::Text(u.to_string())));
122        // transport ($n+9): plain text merged into extra_metadata.transport by the
123        // CTE's deterministic CASE; None → SQL NULL → key omitted (#376).
124        flex_args
125            .push(changelog.transport.map_or(FlexParam::Null, |s| FlexParam::Text(s.to_string())));
126    }
127    let params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = flex_args
128        .iter()
129        .map(|v| v as &(dyn tokio_postgres::types::ToSql + Sync))
130        .collect();
131
132    let mut client = adapter.acquire_connection_with_retry().await?;
133    // Parse/plan the (complex) CTE once per connection (statement cache) — this
134    // is the dominant outbox hot-path cost; cached, the in-txn write is ~free.
135    let stmt = prepare_cached_stmt(&client, sql.as_str()).await?;
136    // The messages below name the outbox when the outbox is what ran. They are the
137    // strings the error sanitizer's corpus is reduced from, and an operator reading a
138    // failed write wants to know whether the CTE was in it.
139    let mechanism = if changelog.is_some() {
140        " (with change-log outbox)"
141    } else {
142        ""
143    };
144    let txn = client.build_transaction().start().await.map_err(|e| FraiseQLError::Database {
145        message:   format!("Failed to start mutation{mechanism} transaction: {}", pg_detail(&e)),
146        sql_state: e.code().map(|c| c.code().to_string()),
147    })?;
148
149    // Apply session variables FIRST so the function body sees them (and so
150    // the started_at directive stamps the DB clock on this very txn).
151    apply_session_vars(&txn, session_vars).await?;
152
153    // Mark the txn FraiseQL-mediated so the #366 fallback-capture trigger
154    // suppresses its row — this write is already logged by the outbox below,
155    // and on the no-outbox path the opt-out means "no change-log row," not
156    // "let the trigger write a degraded one."
157    mark_cdc_mediated(&txn).await?;
158
159    // If mutation timing is on, stamp the timing variable in the same txn.
160    if adapter.mutation_timing_enabled {
161        txn.execute(
162            "SELECT set_config($1, clock_timestamp()::text, true)",
163            &[&adapter.timing_variable_name],
164        )
165        .await
166        .map_err(|e| FraiseQLError::Database {
167            message:   format!("Failed to set mutation timing variable: {}", pg_detail(&e)),
168            sql_state: e.code().map(|c| c.code().to_string()),
169        })?;
170    }
171
172    // The outbox INSERT reads `fraiseql.started_at` with current_setting()
173    // (no missing_ok), so the GUC MUST exist on this txn. The mutation runner
174    // injects it via session_vars on the authenticated path; guarantee it on
175    // every other path (e.g. an unauthenticated mutation that resolves no
176    // session vars) so the duration computation never hits an unset
177    // parameter and aborts the mutation. Only the outbox reads it, so the
178    // no-changelog path is left exactly as a plain call
179    // leaves it.
180    if changelog.is_some() {
181        let started_at_set =
182            session_vars.iter().any(|(name, _)| *name == crate::changelog::STARTED_AT_VAR)
183                || (adapter.mutation_timing_enabled
184                    && adapter.timing_variable_name == crate::changelog::STARTED_AT_VAR);
185        if !started_at_set {
186            txn.execute(
187                "SELECT set_config($1, clock_timestamp()::text, true)",
188                &[&crate::changelog::STARTED_AT_VAR],
189            )
190            .await
191            .map_err(|e| FraiseQLError::Database {
192                message:   format!("Failed to stamp change-log started_at: {}", pg_detail(&e)),
193                sql_state: e.code().map(|c| c.code().to_string()),
194            })?;
195        }
196    }
197
198    let rows: Vec<Row> =
199        txn.query(&stmt, params.as_slice()).await.map_err(|e| FraiseQLError::Database {
200            message:   format!(
201                "Function call {function_name}{mechanism} failed: {}",
202                pg_detail(&e)
203            ),
204            sql_state: e.code().map(|c| c.code().to_string()),
205        })?;
206
207    let results: Vec<std::collections::HashMap<String, serde_json::Value>> =
208        rows.iter().map(row_to_map).collect();
209
210    // The gate decides the commit. It sees the rows the function returned — the
211    // only thing a per-row decision can be keyed on — while they are still
212    // uncommitted, so a refusal takes the write with it rather than leaving the
213    // caller refused and the side effect standing (#1353).
214    if let Err(refusal) = gate(&results) {
215        txn.rollback().await.map_err(|e| FraiseQLError::Database {
216            message:   format!(
217                "Failed to roll back refused mutation transaction: {}",
218                pg_detail(&e)
219            ),
220            sql_state: e.code().map(|c| c.code().to_string()),
221        })?;
222        return Err(refusal);
223    }
224
225    if !commit {
226        // The whole point of a dry run: discard every write, the outbox row included.
227        txn.rollback().await.map_err(|e| FraiseQLError::Database {
228            message:   format!("Failed to roll back dry-run transaction: {}", pg_detail(&e)),
229            sql_state: e.code().map(|c| c.code().to_string()),
230        })?;
231        return Ok(results);
232    }
233
234    txn.commit().await.map_err(|e| FraiseQLError::Database {
235        message:   format!("Failed to commit mutation{mechanism} transaction: {}", pg_detail(&e)),
236        sql_state: e.code().map(|c| c.code().to_string()),
237    })?;
238    adapter.mark_write();
239
240    Ok(results)
241}
242
243/// PostgreSQL SQLSTATE 42703: undefined column.
244const PG_UNDEFINED_COLUMN: &str = "42703";
245
246/// A flexible SQL parameter that binds to any PostgreSQL type.
247///
248/// Solves the impedance mismatch between `serde_json::Value` (only accepts JSON/JSONB)
249/// and `Option<String>` (only accepts text-family types) when binding function-call
250/// arguments whose types are resolved at runtime from the function signature.
251///
252/// Serialisation strategy (binary wire format):
253/// - `JSONB`: 1-byte version header (1) + UTF-8 JSON bytes
254/// - `JSON`: UTF-8 JSON bytes
255/// - `UUID`: 16-byte big-endian UUID
256/// - `INT4`: 4-byte big-endian i32
257/// - `INT8`: 8-byte big-endian i64
258/// - `BOOL`: 1-byte (0 or 1)
259/// - All other types: UTF-8 bytes (PostgreSQL text binary = raw UTF-8)
260#[derive(Debug)]
261enum FlexParam {
262    /// SQL NULL — accepted by any PostgreSQL type.
263    Null,
264    /// A text-encoded value; binary-serialised according to the server-resolved type.
265    Text(String),
266}
267
268impl tokio_postgres::types::ToSql for FlexParam {
269    fn to_sql(
270        &self,
271        ty: &tokio_postgres::types::Type,
272        out: &mut bytes::BytesMut,
273    ) -> std::result::Result<tokio_postgres::types::IsNull, Box<dyn std::error::Error + Sync + Send>>
274    {
275        use tokio_postgres::types::{IsNull, Type};
276        match self {
277            Self::Null => Ok(IsNull::Yes),
278            Self::Text(s) => {
279                if *ty == Type::JSONB {
280                    // JSONB binary wire format: 1-byte version (1) + JSON bytes
281                    out.put_u8(1);
282                    out.extend_from_slice(s.as_bytes());
283                } else if *ty == Type::JSON {
284                    out.extend_from_slice(s.as_bytes());
285                } else if *ty == Type::UUID {
286                    let uuid = uuid::Uuid::parse_str(s)?;
287                    out.extend_from_slice(uuid.as_bytes());
288                } else if *ty == Type::INT4 {
289                    let n: i32 = s.parse()?;
290                    out.put_i32(n);
291                } else if *ty == Type::INT8 {
292                    let n: i64 = s.parse()?;
293                    out.put_i64(n);
294                } else if *ty == Type::BOOL {
295                    let b: bool = s.parse()?;
296                    out.put_u8(u8::from(b));
297                } else {
298                    // TEXT, VARCHAR, BPCHAR, NAME, UNKNOWN, and any user-defined type:
299                    // UTF-8 bytes are the binary wire representation for text-family types.
300                    out.extend_from_slice(s.as_bytes());
301                }
302                Ok(IsNull::No)
303            },
304        }
305    }
306
307    fn accepts(_ty: &tokio_postgres::types::Type) -> bool {
308        // Accepts all types; per-type serialisation is handled in `to_sql`.
309        true
310    }
311
312    fn to_sql_checked(
313        &self,
314        ty: &tokio_postgres::types::Type,
315        out: &mut bytes::BytesMut,
316    ) -> std::result::Result<tokio_postgres::types::IsNull, Box<dyn std::error::Error + Sync + Send>>
317    {
318        // `accepts()` returns true for all types, so the standard WrongType check is
319        // unnecessary.  Delegate directly to `to_sql`.
320        self.to_sql(ty, out)
321    }
322}
323
324/// Enrich a `FraiseQLError::Database` error for PostgreSQL SQLSTATE 42703 (undefined column)
325/// when the WHERE clause contains `NativeField` conditions.
326///
327/// Native columns may be inferred automatically at compile time from `ID`/`UUID`-typed
328/// arguments.  If the column does not exist on the target table at runtime, the raw
329/// PostgreSQL error is replaced with a diagnostic message that names the native columns
330/// involved and explains how to fix the schema.
331fn enrich_undefined_column_error(
332    err: FraiseQLError,
333    view: &str,
334    where_clause: Option<&WhereClause>,
335) -> FraiseQLError {
336    let FraiseQLError::Database { ref sql_state, .. } = err else {
337        return err;
338    };
339    if sql_state.as_deref() != Some(PG_UNDEFINED_COLUMN) {
340        return err;
341    }
342    let native_cols: Vec<&str> =
343        where_clause.map(|wc| wc.native_column_names()).unwrap_or_default();
344    if native_cols.is_empty() {
345        return err;
346    }
347    FraiseQLError::Database {
348        message:   format!(
349            "Column(s) {:?} referenced as native column(s) on `{view}` do not exist. \
350             These columns were auto-inferred from ID/UUID-typed query arguments. \
351             Either add the column(s) to the table/view, or set \
352             `native_columns = {{}}` explicitly in your schema to disable inference.",
353            native_cols,
354        ),
355        sql_state: Some(PG_UNDEFINED_COLUMN.to_string()),
356    }
357}
358
359/// Decodes any PostgreSQL `ENUM` value as its text label.
360///
361/// `String`'s `FromSql` accepts only the text family (TEXT/VARCHAR/BPCHAR/NAME), not
362/// a custom enum OID, so an enum column otherwise falls through [`row_to_map`]'s
363/// decode ladder to `Null`. An enum's binary wire representation *is* its label as
364/// UTF-8 text, so this reads it directly (#472).
365struct PgEnumLabel(String);
366
367impl<'a> FromSql<'a> for PgEnumLabel {
368    fn from_sql(
369        _ty: &Type,
370        raw: &'a [u8],
371    ) -> std::result::Result<Self, Box<dyn std::error::Error + Sync + Send>> {
372        Ok(Self(std::str::from_utf8(raw)?.to_owned()))
373    }
374
375    fn accepts(ty: &Type) -> bool {
376        matches!(ty.kind(), Kind::Enum(_))
377    }
378}
379
380/// Convert a single `tokio_postgres::Row` into a `HashMap<String, serde_json::Value>`.
381///
382/// Tries each PostgreSQL type in priority order; falls back to `Null` for
383/// types that cannot be represented as JSON.
384fn row_to_map(row: &Row) -> std::collections::HashMap<String, serde_json::Value> {
385    let mut map = std::collections::HashMap::new();
386    for (idx, column) in row.columns().iter().enumerate() {
387        map.insert(column.name().to_string(), decode_cell(row, idx));
388    }
389    map
390}
391
392/// Decode one cell of a `tokio_postgres::Row` to JSON.
393///
394/// The decode ladder itself, extracted from [`row_to_map`] so the positional
395/// reader the admin SQL console needs (#962) and the name-keyed map every other
396/// caller wants cannot disagree about what a `numeric`, an enum or a `timestamptz`
397/// becomes. A console keyed by column *name* would also collapse `SELECT 1 AS a,
398/// 2 AS a` to one column and silently drop the other.
399///
400/// Tries each PostgreSQL type in priority order; falls back to `Null` for types
401/// that cannot be represented as JSON.
402pub(super) fn decode_cell(row: &Row, idx: usize) -> serde_json::Value {
403    let column = &row.columns()[idx];
404    if let Ok(v) = row.try_get::<_, i16>(idx) {
405        // SMALLINT/int2 columns (e.g. `app.mutation_response.http_status`).
406        // Without this branch a non-null int2 fell through to `Null` because
407        // `FromSql for i32` rejects int2 — which dropped `MutationError`'s
408        // `httpStatus` to absent. Order among i16/i32/i64 is correctness-
409        // neutral (each `FromSql` accepts only its own PG int width), so this
410        // sits first for readability.
411        serde_json::json!(v)
412    } else if let Ok(v) = row.try_get::<_, i32>(idx) {
413        serde_json::json!(v)
414    } else if let Ok(v) = row.try_get::<_, i64>(idx) {
415        serde_json::json!(v)
416    } else if let Ok(v) = row.try_get::<_, f64>(idx) {
417        serde_json::json!(v)
418    } else if let Ok(v) = row.try_get::<_, String>(idx) {
419        serde_json::json!(v)
420    } else if let Ok(v) = row.try_get::<_, bool>(idx) {
421        serde_json::json!(v)
422    } else if let Ok(v) = row.try_get::<_, PgNumericText>(idx) {
423        // NUMERIC/DECIMAL — decoded to PostgreSQL's exact text rendering
424        // (#980), then re-parsed as a JSON number so the value isn't forced
425        // through f64 at construction. Without this branch a `SUM(revenue)`
426        // aggregate (and every raw NUMERIC column) fell through to `Null`
427        // (H35). Falls back to a JSON string for the values whose text is
428        // not JSON numeric syntax: NaN, Infinity and -Infinity.
429        let s = v.0;
430        serde_json::from_str::<serde_json::Value>(&s).unwrap_or(serde_json::Value::String(s))
431    } else if let Ok(v) = row.try_get::<_, uuid::Uuid>(idx) {
432        // UUID columns (e.g. `app.mutation_response.entity_id`) render as the
433        // canonical hyphenated string instead of silently nulling (H35).
434        serde_json::json!(v.to_string())
435    } else if let Ok(v) = row.try_get::<_, chrono::DateTime<chrono::Utc>>(idx) {
436        // `timestamptz` → RFC 3339 / ISO 8601 text (H35).
437        serde_json::json!(v.to_rfc3339())
438    } else if let Ok(v) = row.try_get::<_, chrono::NaiveDateTime>(idx) {
439        // `timestamp` (no tz) → ISO 8601 text (H35).
440        serde_json::json!(v.to_string())
441    } else if let Ok(v) = row.try_get::<_, chrono::NaiveDate>(idx) {
442        // `date` → ISO 8601 date text (H35).
443        serde_json::json!(v.to_string())
444    } else if let Ok(v) = row.try_get::<_, Vec<String>>(idx) {
445        // TEXT[] columns (e.g. `app.mutation_response.updated_fields`) — without
446        // this branch a non-null text array falls through to Null and a parser
447        // expecting a sequence fails. Must precede the jsonb branch (a jsonb
448        // column never deserializes as Vec<String>, so ordering is safe).
449        serde_json::json!(v)
450    } else if let Ok(v) = row.try_get::<_, PgEnumLabel>(idx) {
451        // ENUM columns (e.g. `app.mutation_response.error_class`, the
452        // `app.mutation_error_class` enum) render as their text label. Without
453        // this branch a non-null enum fell through to `Null` (`String: FromSql`
454        // rejects a custom enum OID), which dropped `error_class` to absent on a
455        // failed mutation — and the parser then rejected the row with
456        // "succeeded=false requires error_class", so the typed error arm was
457        // never reached (#472). `PgEnumLabel` accepts only `Kind::Enum`, so this
458        // never shadows another branch regardless of position.
459        serde_json::json!(v.0)
460    } else if let Ok(v) = row.try_get::<_, serde_json::Value>(idx) {
461        v
462    } else {
463        // A column whose PostgreSQL type none of the branches above can
464        // decode. Name the column and its type so the next drift is visible
465        // in logs instead of a silent null (H35).
466        tracing::warn!(
467            column = %column.name(),
468            pg_type = %column.type_(),
469            "decode_cell: column type not representable as JSON; returning null"
470        );
471        serde_json::Value::Null
472    }
473}
474
475/// Apply transaction-local session variables on an in-progress transaction.
476///
477/// Each `(name, value)` pair is set with `SELECT set_config($1, $2, true)`, so
478/// the values are scoped to `txn` and visible to every statement run on it
479/// (fixes #329). The caller is responsible for committing or rolling back.
480pub(super) async fn apply_session_vars(
481    txn: &tokio_postgres::Transaction<'_>,
482    session_vars: &[(&str, &str)],
483) -> Result<()> {
484    for (name, value) in session_vars {
485        // A var carrying the clock-timestamp directive (e.g. fraiseql.started_at)
486        // is stamped with the DB clock at apply time, on this very transaction, so
487        // the start timestamp and the close-of-interval at the outbox write share
488        // one clock (no app↔DB skew). All other vars bind their literal value.
489        if *value == crate::changelog::CLOCK_TIMESTAMP_DIRECTIVE {
490            txn.execute("SELECT set_config($1, clock_timestamp()::text, true)", &[name])
491                .await
492                .map_err(|e| FraiseQLError::Database {
493                    message:   format!(
494                        "set_config({name:?}, clock_timestamp()) failed: {}",
495                        pg_detail(&e)
496                    ),
497                    sql_state: e.code().map(|c| c.code().to_string()),
498                })?;
499        } else {
500            txn.execute("SELECT set_config($1, $2, true)", &[name, value])
501                .await
502                .map_err(|e| FraiseQLError::Database {
503                    message:   format!("set_config({name:?}) failed: {}", pg_detail(&e)),
504                    sql_state: e.code().map(|c| c.code().to_string()),
505                })?;
506        }
507    }
508    Ok(())
509}
510
511/// Mark the in-progress mutation transaction as **FraiseQL-mediated** (#366).
512///
513/// Sets [`crate::changelog::CDC_MEDIATED_VAR`] to
514/// [`crate::changelog::CDC_MEDIATED_ON`] transaction-locally
515/// (`set_config(..., true)`), so the value auto-resets on commit and is invisible
516/// to other connections. The shipped fallback-capture trigger reads it and
517/// suppresses its own change-log row for this write, so an app-path mutation —
518/// already logged by the in-transaction outbox — is never double-captured. Set
519/// on every mutation transaction (independent of whether the outbox row is
520/// written), so an opted-out mutation (`changelog=false`) also suppresses the
521/// trigger rather than leaving a degraded fallback row.
522pub(super) async fn mark_cdc_mediated(txn: &tokio_postgres::Transaction<'_>) -> Result<()> {
523    txn.execute(
524        "SELECT set_config($1, $2, true)",
525        &[
526            &crate::changelog::CDC_MEDIATED_VAR,
527            &crate::changelog::CDC_MEDIATED_ON,
528        ],
529    )
530    .await
531    .map_err(|e| FraiseQLError::Database {
532        message:   format!(
533            "Failed to set {} marker: {}",
534            crate::changelog::CDC_MEDIATED_VAR,
535            pg_detail(&e)
536        ),
537        sql_state: e.code().map(|c| c.code().to_string()),
538    })?;
539    Ok(())
540}
541
542/// Build the single statement that runs a mutation function and writes its
543/// `core.tb_entity_change_log` outbox row in the same transaction (the Change
544/// Spine transactional outbox).
545///
546/// The function call is materialised once (`WITH r AS MATERIALIZED`, so a
547/// volatile mutation function executes **exactly once** even though two CTEs
548/// read it), a data-modifying CTE INSERTs the change-log row for an effective
549/// change (`succeeded AND state_changed`), and the primary `SELECT * FROM r`
550/// returns the function's row unchanged to the caller. The changed-entity
551/// columns (`object_id`, `object_data`, `updated_fields`, `cascade`) come from
552/// the function's own `app.mutation_response` row; `$<n+1>` is the NOT-NULL
553/// `object_type` fallback, `$<n+2>` is the `modification_type` verb,
554/// `$<n+3>` is the envelope `tenant_id` (the Trinity public-facing UUID, NULL
555/// for system / unauthenticated / non-UUID-tenant rows), `$<n+4>` is the
556/// `trace_id` (the request's W3C trace id, plain text), `$<n+5>` is the
557/// `schema_version` (the compiled schema's content hash, plain text), `$<n+6>`
558/// is the `trace_context` (the full W3C trace context, cast `::jsonb`), and
559/// `$<n+9>` is the ingress `transport` (#376), merged into `extra_metadata` as
560/// `{"transport": …}` by a deterministic `CASE` (NULL omits the key, so the SQL
561/// text never depends on the value), where `n` = `n_args` (the number of
562/// function arguments). The envelope params are
563/// appended **after** the function args so the SQL text stays deterministic per
564/// `(function, n_args)` — `prepare_cached` keys the statement by text, so the
565/// column list and placeholder positions must never depend on the param *values*.
566///
567/// `started_at`/`duration_ms` read `fraiseql.started_at` (set txn-locally by the
568/// caller) on the DB clock — the canonical computation from
569/// [`crate::changelog::duration_ms_sql`], stamped with
570/// [`crate::changelog::DURATION_CALC_VERSION`]. `commit_time` is the DB clock at
571/// INSERT; `seq` is omitted so the column's `SEQUENCE` default fires (so any
572/// INSERTer, incl. cooperative external producers, gets a monotonic value).
573pub(super) fn build_changelog_cte_sql(quoted_fn: &str, n_args: usize, pre_image: bool) -> String {
574    let placeholders: Vec<String> = (1..=n_args).map(|i| format!("${i}")).collect();
575    let object_type_idx = n_args + 1;
576    let modification_type_idx = n_args + 2;
577    let tenant_id_idx = n_args + 3;
578    let trace_id_idx = n_args + 4;
579    let schema_version_idx = n_args + 5;
580    let trace_context_idx = n_args + 6;
581    let actor_type_idx = n_args + 7;
582    let acting_for_idx = n_args + 8;
583    let transport_idx = n_args + 9;
584    let started_var = crate::changelog::STARTED_AT_VAR;
585    let duration = crate::changelog::duration_ms_sql(started_var);
586    let calc_version = crate::changelog::DURATION_CALC_VERSION;
587    // Pre-image (opt-in, #changelog_pre_image): also record the changed entity's
588    // before-state from the function's own `entity_before` column, alongside the
589    // after-image `entity`. Adds `object_data_before` to the INSERT and
590    // `r.entity_before` to the SELECT ONLY when requested — `object_data` stays
591    // the after-image for every consumer; an absent flag emits today's SQL
592    // byte-for-byte. No new placeholder: the before-image is a column on `r`, like
593    // `r.entity`. The two SQL forms differ in text, so `prepare_cached` keys them
594    // as separate statements automatically (no extra cache discriminator needed).
595    let after_before_cols = if pre_image {
596        "object_data, object_data_before"
597    } else {
598        "object_data"
599    };
600    let after_before_vals = if pre_image {
601        "r.entity, r.entity_before"
602    } else {
603        "r.entity"
604    };
605    format!(
606        "WITH r AS MATERIALIZED (SELECT * FROM {quoted_fn}({args})), \
607         _changelog AS ( \
608           INSERT INTO core.tb_entity_change_log \
609             (object_type, modification_type, object_id, {after_before_cols}, \
610              updated_fields, cascade, started_at, duration_ms, extra_metadata, \
611              tenant_id, trace_id, schema_version, trace_context, \
612              actor_type, acting_for, commit_time) \
613           SELECT \
614             COALESCE(r.entity_type, ${object_type_idx}), \
615             ${modification_type_idx}, \
616             r.entity_id, {after_before_vals}, r.updated_fields, r.cascade, \
617             current_setting('{started_var}')::timestamptz, \
618             {duration}, \
619             jsonb_build_object('duration_calc_version', {calc_version}::int) \
620               || CASE WHEN ${transport_idx}::text IS NULL THEN '{{}}'::jsonb \
621                       ELSE jsonb_build_object('transport', ${transport_idx}::text) END, \
622             ${tenant_id_idx}::uuid, \
623             ${trace_id_idx}, \
624             ${schema_version_idx}, \
625             ${trace_context_idx}::jsonb, \
626             ${actor_type_idx}, \
627             ${acting_for_idx}::uuid, \
628             clock_timestamp() \
629           FROM r \
630           WHERE r.succeeded AND r.state_changed \
631           RETURNING 1 \
632         ) \
633         SELECT * FROM r",
634        args = placeholders.join(", "),
635    )
636}
637
638/// Prepare `sql` on `client` using deadpool's **per-connection statement cache**.
639///
640/// The mutation function-call path sends the same statement (a fixed
641/// `SELECT * FROM fn(...)` or the change-log CTE) on every call, so without
642/// caching PostgreSQL re-parses and re-plans it for each mutation — the dominant
643/// hot-path cost (the complex outbox CTE in particular). `prepare_cached` keys
644/// the parsed/planned [`tokio_postgres::Statement`] by SQL text per connection
645/// and reuses it across calls, so the parse/plan happens once per connection.
646///
647/// Returns an owned `Statement` (releasing the `&client` borrow), so it can be
648/// prepared before `client.build_transaction()` and used inside that transaction.
649async fn prepare_cached_stmt(
650    client: &deadpool_postgres::Client,
651    sql: &str,
652) -> Result<tokio_postgres::Statement> {
653    client.prepare_cached(sql).await.map_err(|e| {
654        // Surface the underlying PostgreSQL diagnostic (SQLSTATE message, e.g.
655        // `function "foo" does not exist`) rather than the opaque `db error` the
656        // top-level `Display` renders (#451, generalised to every query path by #888).
657        let detail = pg_detail(&e);
658        let hint = changelog_prepare_hint(&detail);
659        FraiseQLError::Database {
660            message:   format!("Failed to prepare statement: {detail}{hint}"),
661            sql_state: e.code().map(|c| c.code().to_string()),
662        }
663    })
664}
665
666/// Actionable operator guidance for the two prepare failures every freshly
667/// authored stack hits (#569). The bare PostgreSQL diagnostics — `relation
668/// "core.tb_entity_change_log" does not exist` and `column r.entity_type does
669/// not exist` — point at neither of the actual requirements: the change-log
670/// outbox table must be installed (`fraiseql setup`), and every mutation
671/// function must return the 13-column v2.2 `mutation_response` row the outbox
672/// CTE reads (`r.` is that CTE's alias for the function result, so a missing
673/// `r.*` column means the function returns some other shape — classically
674/// `RETURNS SETOF v_*`).
675fn changelog_prepare_hint(detail: &str) -> &'static str {
676    if detail.contains("core.tb_entity_change_log") {
677        "\nhint: the mutation change-log outbox table is not installed in this database — \
678         run `fraiseql setup` (or apply the fraiseql-observers change-log contract \
679         migration) before executing mutations"
680    } else if detail.contains("column r.") {
681        "\nhint: the mutation function does not return the v2.2 mutation_response row \
682         (13 columns: succeeded, state_changed, …) that the change-log outbox reads. \
683         `RETURNS SETOF v_*` and bare scalar returns are not supported — build the row \
684         with fraiseql.mutation_ok()/fraiseql.mutation_err() (installed by `fraiseql \
685         setup`); see docs/architecture/mutation-response.md"
686    } else {
687        ""
688    }
689}
690
691// Reason: DatabaseAdapter is defined with #[async_trait]; all implementations must match
692// its transformed method signatures to satisfy the trait contract
693// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
694#[async_trait]
695impl DatabaseAdapter for PostgresAdapter {
696    fn serves_reads_from_standbys(&self) -> bool {
697        self.read_replicas.is_some()
698    }
699
700    async fn execute_with_projection(
701        &self,
702        view: &str,
703        projection: Option<&SqlProjectionHint>,
704        where_clause: Option<&WhereClause>,
705        limit: Option<u32>,
706        offset: Option<u32>,
707        order_by: Option<&[OrderByClause]>,
708    ) -> Result<Vec<JsonbValue>> {
709        self.execute_with_projection_impl(view, projection, where_clause, limit, offset, order_by)
710            .await
711    }
712
713    async fn execute_where_query(
714        &self,
715        view: &str,
716        where_clause: Option<&WhereClause>,
717        limit: Option<u32>,
718        offset: Option<u32>,
719        order_by: Option<&[OrderByClause]>,
720    ) -> Result<Vec<JsonbValue>> {
721        let (sql, typed_params) =
722            build_where_select_sql_ordered(view, where_clause, limit, offset, order_by)?;
723
724        let param_refs = crate::types::as_sql_param_refs(&typed_params);
725
726        self.execute_raw(&sql, &param_refs, ReadRouting::Any)
727            .await
728            .map_err(|e| enrich_undefined_column_error(e, view, where_clause))
729    }
730
731    async fn explain_where_query(
732        &self,
733        view: &str,
734        where_clause: Option<&WhereClause>,
735        limit: Option<u32>,
736        offset: Option<u32>,
737    ) -> Result<serde_json::Value> {
738        let (select_sql, typed_params) = build_where_select_sql(view, where_clause, limit, offset)?;
739        // Defense-in-depth: compiler-generated SQL should never contain a
740        // semicolon, but guard against it to prevent statement injection.
741        if select_sql.contains(';') {
742            return Err(FraiseQLError::Validation {
743                message: "EXPLAIN SQL must be a single statement".into(),
744                path:    None,
745            });
746        }
747        let explain_sql = format!("EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) {select_sql}");
748
749        let param_refs = crate::types::as_sql_param_refs(&typed_params);
750
751        // EXPLAIN ANALYZE of a compiled SELECT: read-only, and most truthful when
752        // it runs where the reads it describes run — the replica route (#407).
753        let client = self.acquire_read_connection_with_retry(ReadRouting::Any).await?;
754        let rows = client.query(explain_sql.as_str(), &param_refs).await.map_err(|e| {
755            FraiseQLError::Database {
756                message:   format!("EXPLAIN ANALYZE failed: {}", pg_detail(&e)),
757                sql_state: e.code().map(|c| c.code().to_string()),
758            }
759        })?;
760
761        if let Some(row) = rows.first() {
762            let plan: serde_json::Value = row.try_get(0).map_err(|e| FraiseQLError::Database {
763                message:   format!("Failed to parse EXPLAIN output: {e}"),
764                sql_state: None,
765            })?;
766            Ok(plan)
767        } else {
768            Ok(serde_json::Value::Null)
769        }
770    }
771
772    fn database_type(&self) -> DatabaseType {
773        DatabaseType::PostgreSQL
774    }
775
776    /// `execute_composed_with_session` is implemented below.
777    fn supports_composed_reads(&self) -> bool {
778        true
779    }
780
781    async fn health_check(&self) -> Result<()> {
782        // Use retry logic for health check to avoid false negatives during pool exhaustion
783        let client = self.acquire_connection_with_retry().await?;
784
785        client.query("SELECT 1", &[]).await.map_err(|e| FraiseQLError::Database {
786            message:   format!("Health check failed: {}", pg_detail(&e)),
787            sql_state: e.code().map(|c| c.code().to_string()),
788        })?;
789
790        Ok(())
791    }
792
793    #[allow(clippy::cast_possible_truncation)] // Reason: value is bounded; truncation cannot occur in practice
794    fn pool_metrics(&self) -> PoolMetrics {
795        let status = self.pool.status();
796
797        PoolMetrics {
798            total_connections:  status.size as u32,
799            idle_connections:   status.available as u32,
800            active_connections: (status.size - status.available) as u32,
801            waiting_requests:   status.waiting as u32,
802        }
803    }
804
805    /// # Security
806    ///
807    /// `sql` **must** be compiler-generated. Never pass user-supplied strings
808    /// directly — doing so would open SQL-injection vulnerabilities.
809    async fn execute_raw_query(
810        &self,
811        sql: &str,
812    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
813        // Use retry logic for connection acquisition
814        let client = self.acquire_connection_with_retry().await?;
815
816        let rows: Vec<Row> = client.query(sql, &[]).await.map_err(|e| FraiseQLError::Database {
817            message:   format!("Query execution failed: {}", pg_detail(&e)),
818            sql_state: e.code().map(|c| c.code().to_string()),
819        })?;
820
821        // Convert each row to HashMap<String, Value>
822        let results: Vec<std::collections::HashMap<String, serde_json::Value>> =
823            rows.iter().map(row_to_map).collect();
824
825        Ok(results)
826    }
827
828    async fn execute_parameterized_aggregate(
829        &self,
830        sql: &str,
831        params: &[serde_json::Value],
832    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
833        // Convert serde_json::Value params to QueryParam so that strings are bound
834        // as TEXT (not JSONB), which is required for correct WHERE comparisons against
835        // data->>'field' expressions that return TEXT.
836        let typed: Vec<QueryParam> = params.iter().cloned().map(QueryParam::from).collect();
837        let param_refs = crate::types::as_sql_param_refs(&typed);
838
839        // Compiled aggregate SELECT: read-only, replica-eligible (#407).
840        let client = self.acquire_read_connection_with_retry(ReadRouting::Any).await?;
841        let rows: Vec<Row> =
842            client.query(sql, &param_refs).await.map_err(|e| FraiseQLError::Database {
843                message:   format!("Parameterized aggregate query failed: {}", pg_detail(&e)),
844                sql_state: e.code().map(|c| c.code().to_string()),
845            })?;
846
847        let results: Vec<std::collections::HashMap<String, serde_json::Value>> =
848            rows.iter().map(row_to_map).collect();
849
850        Ok(results)
851    }
852
853    async fn execute_parameterized_aggregate_with_session(
854        &self,
855        sql: &str,
856        params: &[serde_json::Value],
857        session_vars: &[(&str, &str)],
858        routing: ReadRouting,
859    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
860        if session_vars.is_empty() {
861            return self.execute_parameterized_aggregate(sql, params).await;
862        }
863
864        let typed: Vec<QueryParam> = params.iter().cloned().map(QueryParam::from).collect();
865        let param_refs = crate::types::as_sql_param_refs(&typed);
866
867        // Read-only aggregate with session vars; standby-safe → replica-eligible.
868        let mut client = self.acquire_read_connection_with_retry(routing).await?;
869        let txn =
870            client.build_transaction().start().await.map_err(|e| FraiseQLError::Database {
871                message:   format!("Failed to start session-var transaction: {}", pg_detail(&e)),
872                sql_state: e.code().map(|c| c.code().to_string()),
873            })?;
874        apply_session_vars(&txn, session_vars).await?;
875        let rows: Vec<Row> =
876            txn.query(sql, &param_refs).await.map_err(|e| FraiseQLError::Database {
877                message:   format!("Parameterized aggregate query failed: {}", pg_detail(&e)),
878                sql_state: e.code().map(|c| c.code().to_string()),
879            })?;
880        txn.commit().await.map_err(|e| FraiseQLError::Database {
881            message:   format!("Failed to commit session-var transaction: {}", pg_detail(&e)),
882            sql_state: e.code().map(|c| c.code().to_string()),
883        })?;
884
885        Ok(rows.iter().map(row_to_map).collect())
886    }
887
888    // PostgreSQL session variables are applied connection-affinely by the
889    // `*_with_session` methods below: `set_config(..., true)` and the operation
890    // share one transaction on one connection, so transaction-local GUCs are
891    // visible to the function / view (fixes #329).
892
893    async fn execute_where_query_arc_with_session(
894        &self,
895        view: &str,
896        where_clause: Option<&WhereClause>,
897        limit: Option<u32>,
898        offset: Option<u32>,
899        order_by: Option<&[OrderByClause]>,
900        session_vars: &[(&str, &str)],
901        routing: ReadRouting,
902    ) -> Result<Arc<Vec<JsonbValue>>> {
903        let (sql, typed_params) =
904            build_where_select_sql_ordered(view, where_clause, limit, offset, order_by)?;
905        let param_refs = crate::types::as_sql_param_refs(&typed_params);
906
907        // No session variables => no transaction wrapper, but still this query's
908        // routing. The fast path used to delegate to `execute_where_query_arc`,
909        // which has no routing parameter, so every read that happened not to carry
910        // session variables silently reverted to server policy — `read_routing`
911        // accepted and dropped on the most common shape there is.
912        if session_vars.is_empty() {
913            return self
914                .execute_raw(&sql, &param_refs, routing)
915                .await
916                .map(Arc::new)
917                .map_err(|e| enrich_undefined_column_error(e, view, where_clause));
918        }
919
920        self.execute_raw_with_session(&sql, &param_refs, session_vars, routing)
921            .await
922            .map(Arc::new)
923            .map_err(|e| enrich_undefined_column_error(e, view, where_clause))
924    }
925
926    /// `SELECT COUNT(*)`, pushed into the database rather than counted in Rust.
927    ///
928    /// The trait's default fetches every matching row and takes `.len()`, which
929    /// is correct but materialises the whole filtered set — on an unfiltered
930    /// list that is the entire table in memory to answer a single integer.
931    async fn count_where_query(
932        &self,
933        view: &str,
934        where_clause: Option<&WhereClause>,
935        session_vars: &[(&str, &str)],
936        routing: ReadRouting,
937    ) -> Result<u64> {
938        let (sql, typed_params) = super::build_count_sql(view, where_clause)?;
939        let param_refs = crate::types::as_sql_param_refs(&typed_params);
940
941        // Structurally read-only: replica-eligible, and `set_config` + SELECT are
942        // both fine inside a hot-standby read-only transaction (#407).
943        let row = if session_vars.is_empty() {
944            let client = self.acquire_read_connection_with_retry(routing).await?;
945            client.query_one(&sql, &param_refs).await.map_err(|e| FraiseQLError::Database {
946                message:   format!("Count query failed: {}", pg_detail(&e)),
947                sql_state: e.code().map(|c| c.code().to_string()),
948            })?
949        } else {
950            // Session variables must land on the *same* connection as the count,
951            // or an RLS-filtered page gets an unfiltered total beside it (#329).
952            let mut client = self.acquire_read_connection_with_retry(routing).await?;
953            let txn =
954                client.build_transaction().start().await.map_err(|e| FraiseQLError::Database {
955                    message:   format!(
956                        "Failed to start count session-var transaction: {}",
957                        pg_detail(&e)
958                    ),
959                    sql_state: e.code().map(|c| c.code().to_string()),
960                })?;
961            super::database::apply_session_vars(&txn, session_vars).await?;
962            let row =
963                txn.query_one(&sql, &param_refs).await.map_err(|e| FraiseQLError::Database {
964                    message:   format!("Count query failed: {}", pg_detail(&e)),
965                    sql_state: e.code().map(|c| c.code().to_string()),
966                })?;
967            txn.commit().await.map_err(|e| FraiseQLError::Database {
968                message:   format!(
969                    "Failed to commit count session-var transaction: {}",
970                    pg_detail(&e)
971                ),
972                sql_state: e.code().map(|c| c.code().to_string()),
973            })?;
974            row
975        };
976
977        let total: i64 = row.get(0);
978        // COUNT(*) is never negative, so the bit pattern is the value; `cast_unsigned`
979        // states that intent where `as u64` would only imply it.
980        Ok(total.cast_unsigned())
981    }
982
983    async fn execute_with_projection_arc_with_session(
984        &self,
985        request: &ProjectionRequest<'_>,
986        session_vars: &[(&str, &str)],
987        routing: ReadRouting,
988    ) -> Result<Arc<Vec<JsonbValue>>> {
989        // No projection => behave like a plain WHERE query, matching
990        // execute_with_projection_impl's fallback.
991        let Some(projection) = request.projection else {
992            return self
993                .execute_where_query_arc_with_session(
994                    request.view,
995                    request.where_clause,
996                    request.limit,
997                    request.offset,
998                    request.order_by,
999                    session_vars,
1000                    routing,
1001                )
1002                .await;
1003        };
1004
1005        let (sql, typed_params) = build_projection_select_sql(
1006            projection,
1007            request.view,
1008            request.where_clause,
1009            request.limit,
1010            request.offset,
1011            request.order_by,
1012        )?;
1013        let param_refs = crate::types::as_sql_param_refs(&typed_params);
1014
1015        // See `execute_where_query_arc_with_session`: the session-free path has to
1016        // carry the routing too.
1017        if session_vars.is_empty() {
1018            return self.execute_raw(&sql, &param_refs, routing).await.map(Arc::new);
1019        }
1020
1021        self.execute_raw_with_session(&sql, &param_refs, session_vars, routing)
1022            .await
1023            .map(Arc::new)
1024    }
1025
1026    /// One statement, one snapshot, one connection held for the delivery (#958).
1027    ///
1028    /// The SQL is built by the same two builders the collecting read uses, so a
1029    /// streamed export and a buffered read of the same query cannot select
1030    /// different rows — the drift that made `execute_query_direct` disagree with
1031    /// `count_rows` about a tenant filter (#739).
1032    async fn execute_composed_with_session(
1033        &self,
1034        read: &ComposedLevel,
1035        session_vars: &[(&str, &str)],
1036        routing: ReadRouting,
1037    ) -> Result<Arc<Vec<JsonbValue>>> {
1038        let (sql, typed_params) = build_composed_select_sql(read)?;
1039        let param_refs = crate::types::as_sql_param_refs(&typed_params);
1040
1041        // See `execute_where_query_arc_with_session`: the session-free path has to
1042        // carry the routing too.
1043        if session_vars.is_empty() {
1044            return self.execute_raw(&sql, &param_refs, routing).await.map(Arc::new);
1045        }
1046
1047        self.execute_raw_with_session(&sql, &param_refs, session_vars, routing)
1048            .await
1049            .map(Arc::new)
1050    }
1051
1052    async fn stream_with_projection(
1053        &self,
1054        request: &ProjectionRequest<'_>,
1055        session_vars: &[(&str, &str)],
1056        routing: ReadRouting,
1057    ) -> Result<JsonbRowStream> {
1058        let (sql, typed_params) = match request.projection {
1059            Some(projection) => build_projection_select_sql(
1060                projection,
1061                request.view,
1062                request.where_clause,
1063                request.limit,
1064                request.offset,
1065                request.order_by,
1066            )?,
1067            None => build_where_select_sql_ordered(
1068                request.view,
1069                request.where_clause,
1070                request.limit,
1071                request.offset,
1072                request.order_by,
1073            )?,
1074        };
1075
1076        super::streaming::stream_rows(self, sql, typed_params, session_vars, routing, |row, sql| {
1077            jsonb_cell(row, 0, sql)
1078        })
1079        .await
1080        .map_err(|e| enrich_undefined_column_error(e, request.view, request.where_clause))
1081    }
1082
1083    /// The column-shaped streaming read behind gRPC server-streaming (#958).
1084    ///
1085    /// Routed to the primary like its collecting sibling: `execute_row_query`
1086    /// reaches `execute_raw_query`, and a surface that executes composed SQL is
1087    /// never sent to a replica.
1088    async fn stream_row_query(
1089        &self,
1090        view_name: &str,
1091        columns: &[crate::types::ColumnSpec],
1092        where_sql: Option<&str>,
1093        order_by: Option<&str>,
1094        limit: Option<u32>,
1095        offset: Option<u32>,
1096    ) -> Result<ColumnRowStream> {
1097        let sql = crate::traits::build_row_query_sql(view_name, where_sql, order_by, limit, offset);
1098        let columns = columns.to_vec();
1099
1100        super::streaming::stream_rows(
1101            self,
1102            sql,
1103            Vec::new(),
1104            &[],
1105            ReadRouting::Primary,
1106            move |row, _sql| Ok(crate::traits::row_to_column_values(&row_to_map(row), &columns)),
1107        )
1108        .await
1109    }
1110
1111    /// The session-pinned collecting row read (#1351).
1112    ///
1113    /// Same shape as [`count_where_query`](Self::count_where_query): with no session
1114    /// variables it is a plain read; with them it is one transaction, so `set_config`
1115    /// and the SELECT land on the same connection and an RLS policy backed by
1116    /// `current_setting()` sees them (#329).
1117    async fn execute_row_query_with_session(
1118        &self,
1119        view_name: &str,
1120        columns: &[crate::types::ColumnSpec],
1121        where_sql: Option<&str>,
1122        order_by: Option<&str>,
1123        limit: Option<u32>,
1124        offset: Option<u32>,
1125        session_vars: &[(&str, &str)],
1126    ) -> Result<Vec<Vec<crate::types::ColumnValue>>> {
1127        if session_vars.is_empty() {
1128            return self
1129                .execute_row_query(view_name, columns, where_sql, order_by, limit, offset)
1130                .await;
1131        }
1132
1133        let sql = crate::traits::build_row_query_sql(view_name, where_sql, order_by, limit, offset);
1134
1135        let mut client = self.acquire_connection_with_retry().await?;
1136        let txn =
1137            client.build_transaction().start().await.map_err(|e| FraiseQLError::Database {
1138                message:   format!(
1139                    "Failed to start row-read session-var transaction: {}",
1140                    pg_detail(&e)
1141                ),
1142                sql_state: e.code().map(|c| c.code().to_string()),
1143            })?;
1144        apply_session_vars(&txn, session_vars).await?;
1145        let rows: Vec<Row> =
1146            txn.query(sql.as_str(), &[]).await.map_err(|e| FraiseQLError::Database {
1147                message:   format!("Row query execution failed: {}", pg_detail(&e)),
1148                sql_state: e.code().map(|c| c.code().to_string()),
1149            })?;
1150        txn.commit().await.map_err(|e| FraiseQLError::Database {
1151            message:   format!(
1152                "Failed to commit row-read session-var transaction: {}",
1153                pg_detail(&e)
1154            ),
1155            sql_state: e.code().map(|c| c.code().to_string()),
1156        })?;
1157
1158        Ok(rows
1159            .iter()
1160            .map(|row| crate::traits::row_to_column_values(&row_to_map(row), columns))
1161            .collect())
1162    }
1163
1164    /// The session-pinned streaming row read (#1351).
1165    ///
1166    /// `stream_rows` already pins session variables for the life of the portal —
1167    /// the non-session arm above passes `&[]` to the very same call.
1168    async fn stream_row_query_with_session(
1169        &self,
1170        view_name: &str,
1171        columns: &[crate::types::ColumnSpec],
1172        where_sql: Option<&str>,
1173        order_by: Option<&str>,
1174        limit: Option<u32>,
1175        offset: Option<u32>,
1176        session_vars: &[(&str, &str)],
1177    ) -> Result<ColumnRowStream> {
1178        let sql = crate::traits::build_row_query_sql(view_name, where_sql, order_by, limit, offset);
1179        let columns = columns.to_vec();
1180
1181        super::streaming::stream_rows(
1182            self,
1183            sql,
1184            Vec::new(),
1185            session_vars,
1186            ReadRouting::Primary,
1187            move |row, _sql| Ok(crate::traits::row_to_column_values(&row_to_map(row), &columns)),
1188        )
1189        .await
1190    }
1191
1192    async fn explain_query(
1193        &self,
1194        sql: &str,
1195        _params: &[serde_json::Value],
1196    ) -> Result<serde_json::Value> {
1197        // Defense-in-depth: reject multi-statement input even though this SQL is
1198        // compiler-generated. A semicolon would allow a second statement to be
1199        // appended to the EXPLAIN prefix.
1200        if sql.contains(';') {
1201            return Err(FraiseQLError::Validation {
1202                message: "EXPLAIN SQL must be a single statement".into(),
1203                path:    None,
1204            });
1205        }
1206        let explain_sql = format!("EXPLAIN (ANALYZE false, FORMAT JSON) {sql}");
1207        // Read-only (plan only, ANALYZE false): replica-eligible (#407).
1208        let client = self.acquire_read_connection_with_retry(ReadRouting::Any).await?;
1209        let rows: Vec<Row> =
1210            client
1211                .query(explain_sql.as_str(), &[])
1212                .await
1213                .map_err(|e| FraiseQLError::Database {
1214                    message:   format!("EXPLAIN failed: {}", pg_detail(&e)),
1215                    sql_state: e.code().map(|c| c.code().to_string()),
1216                })?;
1217
1218        if let Some(row) = rows.first() {
1219            let plan: serde_json::Value = row.try_get(0).map_err(|e| FraiseQLError::Database {
1220                message:   format!("Failed to parse EXPLAIN output: {e}"),
1221                sql_state: None,
1222            })?;
1223            Ok(plan)
1224        } else {
1225            Ok(serde_json::Value::Null)
1226        }
1227    }
1228
1229    async fn query_stats(&self, limit: u32) -> Result<Vec<crate::types::QueryStatEntry>> {
1230        self.pg_query_stats(limit).await
1231    }
1232
1233    async fn query_stats_by_id(&self, id: &str) -> Result<Option<crate::types::QueryStatEntry>> {
1234        self.pg_query_stats_by_id(id).await
1235    }
1236
1237    async fn reset_query_stats(&self) -> Result<()> {
1238        self.pg_reset_query_stats().await
1239    }
1240
1241    /// The operator SQL console (#962). The containment lives in the private
1242    /// `postgres::adapter::admin_sql` module; see [`AdminSqlRequest`] for what
1243    /// each bound is and how PostgreSQL — not a parse of the statement text —
1244    /// enforces it.
1245    ///
1246    /// [`AdminSqlRequest`]: crate::traits::AdminSqlRequest
1247    async fn execute_admin_sql(
1248        &self,
1249        request: &crate::traits::AdminSqlRequest,
1250    ) -> Result<crate::traits::AdminSqlOutcome> {
1251        super::admin_sql::execute_admin_sql_impl(self, request).await
1252    }
1253}
1254
1255// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
1256#[async_trait]
1257impl Writer for PostgresAdapter {
1258    async fn execute_write(
1259        &self,
1260        request: &crate::traits::WriteRequest<'_>,
1261        gate: crate::traits::MutationRowGate<'_>,
1262    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
1263        run_function_in_txn(self, request, gate).await
1264    }
1265}