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    async fn execute_with_projection(
697        &self,
698        view: &str,
699        projection: Option<&SqlProjectionHint>,
700        where_clause: Option<&WhereClause>,
701        limit: Option<u32>,
702        offset: Option<u32>,
703        order_by: Option<&[OrderByClause]>,
704    ) -> Result<Vec<JsonbValue>> {
705        self.execute_with_projection_impl(view, projection, where_clause, limit, offset, order_by)
706            .await
707    }
708
709    async fn execute_where_query(
710        &self,
711        view: &str,
712        where_clause: Option<&WhereClause>,
713        limit: Option<u32>,
714        offset: Option<u32>,
715        order_by: Option<&[OrderByClause]>,
716    ) -> Result<Vec<JsonbValue>> {
717        let (sql, typed_params) =
718            build_where_select_sql_ordered(view, where_clause, limit, offset, order_by)?;
719
720        let param_refs = crate::types::as_sql_param_refs(&typed_params);
721
722        self.execute_raw(&sql, &param_refs, ReadRouting::Any)
723            .await
724            .map_err(|e| enrich_undefined_column_error(e, view, where_clause))
725    }
726
727    async fn explain_where_query(
728        &self,
729        view: &str,
730        where_clause: Option<&WhereClause>,
731        limit: Option<u32>,
732        offset: Option<u32>,
733    ) -> Result<serde_json::Value> {
734        let (select_sql, typed_params) = build_where_select_sql(view, where_clause, limit, offset)?;
735        // Defense-in-depth: compiler-generated SQL should never contain a
736        // semicolon, but guard against it to prevent statement injection.
737        if select_sql.contains(';') {
738            return Err(FraiseQLError::Validation {
739                message: "EXPLAIN SQL must be a single statement".into(),
740                path:    None,
741            });
742        }
743        let explain_sql = format!("EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) {select_sql}");
744
745        let param_refs = crate::types::as_sql_param_refs(&typed_params);
746
747        // EXPLAIN ANALYZE of a compiled SELECT: read-only, and most truthful when
748        // it runs where the reads it describes run — the replica route (#407).
749        let client = self.acquire_read_connection_with_retry(ReadRouting::Any).await?;
750        let rows = client.query(explain_sql.as_str(), &param_refs).await.map_err(|e| {
751            FraiseQLError::Database {
752                message:   format!("EXPLAIN ANALYZE failed: {}", pg_detail(&e)),
753                sql_state: e.code().map(|c| c.code().to_string()),
754            }
755        })?;
756
757        if let Some(row) = rows.first() {
758            let plan: serde_json::Value = row.try_get(0).map_err(|e| FraiseQLError::Database {
759                message:   format!("Failed to parse EXPLAIN output: {e}"),
760                sql_state: None,
761            })?;
762            Ok(plan)
763        } else {
764            Ok(serde_json::Value::Null)
765        }
766    }
767
768    fn database_type(&self) -> DatabaseType {
769        DatabaseType::PostgreSQL
770    }
771
772    /// `execute_composed_with_session` is implemented below.
773    fn supports_composed_reads(&self) -> bool {
774        true
775    }
776
777    async fn health_check(&self) -> Result<()> {
778        // Use retry logic for health check to avoid false negatives during pool exhaustion
779        let client = self.acquire_connection_with_retry().await?;
780
781        client.query("SELECT 1", &[]).await.map_err(|e| FraiseQLError::Database {
782            message:   format!("Health check failed: {}", pg_detail(&e)),
783            sql_state: e.code().map(|c| c.code().to_string()),
784        })?;
785
786        Ok(())
787    }
788
789    #[allow(clippy::cast_possible_truncation)] // Reason: value is bounded; truncation cannot occur in practice
790    fn pool_metrics(&self) -> PoolMetrics {
791        let status = self.pool.status();
792
793        PoolMetrics {
794            total_connections:  status.size as u32,
795            idle_connections:   status.available as u32,
796            active_connections: (status.size - status.available) as u32,
797            waiting_requests:   status.waiting as u32,
798        }
799    }
800
801    /// # Security
802    ///
803    /// `sql` **must** be compiler-generated. Never pass user-supplied strings
804    /// directly — doing so would open SQL-injection vulnerabilities.
805    async fn execute_raw_query(
806        &self,
807        sql: &str,
808    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
809        // Use retry logic for connection acquisition
810        let client = self.acquire_connection_with_retry().await?;
811
812        let rows: Vec<Row> = client.query(sql, &[]).await.map_err(|e| FraiseQLError::Database {
813            message:   format!("Query execution failed: {}", pg_detail(&e)),
814            sql_state: e.code().map(|c| c.code().to_string()),
815        })?;
816
817        // Convert each row to HashMap<String, Value>
818        let results: Vec<std::collections::HashMap<String, serde_json::Value>> =
819            rows.iter().map(row_to_map).collect();
820
821        Ok(results)
822    }
823
824    async fn execute_parameterized_aggregate(
825        &self,
826        sql: &str,
827        params: &[serde_json::Value],
828    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
829        // Convert serde_json::Value params to QueryParam so that strings are bound
830        // as TEXT (not JSONB), which is required for correct WHERE comparisons against
831        // data->>'field' expressions that return TEXT.
832        let typed: Vec<QueryParam> = params.iter().cloned().map(QueryParam::from).collect();
833        let param_refs = crate::types::as_sql_param_refs(&typed);
834
835        // Compiled aggregate SELECT: read-only, replica-eligible (#407).
836        let client = self.acquire_read_connection_with_retry(ReadRouting::Any).await?;
837        let rows: Vec<Row> =
838            client.query(sql, &param_refs).await.map_err(|e| FraiseQLError::Database {
839                message:   format!("Parameterized aggregate query failed: {}", pg_detail(&e)),
840                sql_state: e.code().map(|c| c.code().to_string()),
841            })?;
842
843        let results: Vec<std::collections::HashMap<String, serde_json::Value>> =
844            rows.iter().map(row_to_map).collect();
845
846        Ok(results)
847    }
848
849    async fn execute_parameterized_aggregate_with_session(
850        &self,
851        sql: &str,
852        params: &[serde_json::Value],
853        session_vars: &[(&str, &str)],
854        routing: ReadRouting,
855    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
856        if session_vars.is_empty() {
857            return self.execute_parameterized_aggregate(sql, params).await;
858        }
859
860        let typed: Vec<QueryParam> = params.iter().cloned().map(QueryParam::from).collect();
861        let param_refs = crate::types::as_sql_param_refs(&typed);
862
863        // Read-only aggregate with session vars; standby-safe → replica-eligible.
864        let mut client = self.acquire_read_connection_with_retry(routing).await?;
865        let txn =
866            client.build_transaction().start().await.map_err(|e| FraiseQLError::Database {
867                message:   format!("Failed to start session-var transaction: {}", pg_detail(&e)),
868                sql_state: e.code().map(|c| c.code().to_string()),
869            })?;
870        apply_session_vars(&txn, session_vars).await?;
871        let rows: Vec<Row> =
872            txn.query(sql, &param_refs).await.map_err(|e| FraiseQLError::Database {
873                message:   format!("Parameterized aggregate query failed: {}", pg_detail(&e)),
874                sql_state: e.code().map(|c| c.code().to_string()),
875            })?;
876        txn.commit().await.map_err(|e| FraiseQLError::Database {
877            message:   format!("Failed to commit session-var transaction: {}", pg_detail(&e)),
878            sql_state: e.code().map(|c| c.code().to_string()),
879        })?;
880
881        Ok(rows.iter().map(row_to_map).collect())
882    }
883
884    // PostgreSQL session variables are applied connection-affinely by the
885    // `*_with_session` methods below: `set_config(..., true)` and the operation
886    // share one transaction on one connection, so transaction-local GUCs are
887    // visible to the function / view (fixes #329).
888
889    async fn execute_where_query_arc_with_session(
890        &self,
891        view: &str,
892        where_clause: Option<&WhereClause>,
893        limit: Option<u32>,
894        offset: Option<u32>,
895        order_by: Option<&[OrderByClause]>,
896        session_vars: &[(&str, &str)],
897        routing: ReadRouting,
898    ) -> Result<Arc<Vec<JsonbValue>>> {
899        let (sql, typed_params) =
900            build_where_select_sql_ordered(view, where_clause, limit, offset, order_by)?;
901        let param_refs = crate::types::as_sql_param_refs(&typed_params);
902
903        // No session variables => no transaction wrapper, but still this query's
904        // routing. The fast path used to delegate to `execute_where_query_arc`,
905        // which has no routing parameter, so every read that happened not to carry
906        // session variables silently reverted to server policy — `read_routing`
907        // accepted and dropped on the most common shape there is.
908        if session_vars.is_empty() {
909            return self
910                .execute_raw(&sql, &param_refs, routing)
911                .await
912                .map(Arc::new)
913                .map_err(|e| enrich_undefined_column_error(e, view, where_clause));
914        }
915
916        self.execute_raw_with_session(&sql, &param_refs, session_vars, routing)
917            .await
918            .map(Arc::new)
919            .map_err(|e| enrich_undefined_column_error(e, view, where_clause))
920    }
921
922    /// `SELECT COUNT(*)`, pushed into the database rather than counted in Rust.
923    ///
924    /// The trait's default fetches every matching row and takes `.len()`, which
925    /// is correct but materialises the whole filtered set — on an unfiltered
926    /// list that is the entire table in memory to answer a single integer.
927    async fn count_where_query(
928        &self,
929        view: &str,
930        where_clause: Option<&WhereClause>,
931        session_vars: &[(&str, &str)],
932        routing: ReadRouting,
933    ) -> Result<u64> {
934        let (sql, typed_params) = super::build_count_sql(view, where_clause)?;
935        let param_refs = crate::types::as_sql_param_refs(&typed_params);
936
937        // Structurally read-only: replica-eligible, and `set_config` + SELECT are
938        // both fine inside a hot-standby read-only transaction (#407).
939        let row = if session_vars.is_empty() {
940            let client = self.acquire_read_connection_with_retry(routing).await?;
941            client.query_one(&sql, &param_refs).await.map_err(|e| FraiseQLError::Database {
942                message:   format!("Count query failed: {}", pg_detail(&e)),
943                sql_state: e.code().map(|c| c.code().to_string()),
944            })?
945        } else {
946            // Session variables must land on the *same* connection as the count,
947            // or an RLS-filtered page gets an unfiltered total beside it (#329).
948            let mut client = self.acquire_read_connection_with_retry(routing).await?;
949            let txn =
950                client.build_transaction().start().await.map_err(|e| FraiseQLError::Database {
951                    message:   format!(
952                        "Failed to start count session-var transaction: {}",
953                        pg_detail(&e)
954                    ),
955                    sql_state: e.code().map(|c| c.code().to_string()),
956                })?;
957            super::database::apply_session_vars(&txn, session_vars).await?;
958            let row =
959                txn.query_one(&sql, &param_refs).await.map_err(|e| FraiseQLError::Database {
960                    message:   format!("Count query failed: {}", pg_detail(&e)),
961                    sql_state: e.code().map(|c| c.code().to_string()),
962                })?;
963            txn.commit().await.map_err(|e| FraiseQLError::Database {
964                message:   format!(
965                    "Failed to commit count session-var transaction: {}",
966                    pg_detail(&e)
967                ),
968                sql_state: e.code().map(|c| c.code().to_string()),
969            })?;
970            row
971        };
972
973        let total: i64 = row.get(0);
974        // COUNT(*) is never negative, so the bit pattern is the value; `cast_unsigned`
975        // states that intent where `as u64` would only imply it.
976        Ok(total.cast_unsigned())
977    }
978
979    async fn execute_with_projection_arc_with_session(
980        &self,
981        request: &ProjectionRequest<'_>,
982        session_vars: &[(&str, &str)],
983        routing: ReadRouting,
984    ) -> Result<Arc<Vec<JsonbValue>>> {
985        // No projection => behave like a plain WHERE query, matching
986        // execute_with_projection_impl's fallback.
987        let Some(projection) = request.projection else {
988            return self
989                .execute_where_query_arc_with_session(
990                    request.view,
991                    request.where_clause,
992                    request.limit,
993                    request.offset,
994                    request.order_by,
995                    session_vars,
996                    routing,
997                )
998                .await;
999        };
1000
1001        let (sql, typed_params) = build_projection_select_sql(
1002            projection,
1003            request.view,
1004            request.where_clause,
1005            request.limit,
1006            request.offset,
1007            request.order_by,
1008        )?;
1009        let param_refs = crate::types::as_sql_param_refs(&typed_params);
1010
1011        // See `execute_where_query_arc_with_session`: the session-free path has to
1012        // carry the routing too.
1013        if session_vars.is_empty() {
1014            return self.execute_raw(&sql, &param_refs, routing).await.map(Arc::new);
1015        }
1016
1017        self.execute_raw_with_session(&sql, &param_refs, session_vars, routing)
1018            .await
1019            .map(Arc::new)
1020    }
1021
1022    /// One statement, one snapshot, one connection held for the delivery (#958).
1023    ///
1024    /// The SQL is built by the same two builders the collecting read uses, so a
1025    /// streamed export and a buffered read of the same query cannot select
1026    /// different rows — the drift that made `execute_query_direct` disagree with
1027    /// `count_rows` about a tenant filter (#739).
1028    async fn execute_composed_with_session(
1029        &self,
1030        read: &ComposedLevel,
1031        session_vars: &[(&str, &str)],
1032        routing: ReadRouting,
1033    ) -> Result<Arc<Vec<JsonbValue>>> {
1034        let (sql, typed_params) = build_composed_select_sql(read)?;
1035        let param_refs = crate::types::as_sql_param_refs(&typed_params);
1036
1037        // See `execute_where_query_arc_with_session`: the session-free path has to
1038        // carry the routing too.
1039        if session_vars.is_empty() {
1040            return self.execute_raw(&sql, &param_refs, routing).await.map(Arc::new);
1041        }
1042
1043        self.execute_raw_with_session(&sql, &param_refs, session_vars, routing)
1044            .await
1045            .map(Arc::new)
1046    }
1047
1048    async fn stream_with_projection(
1049        &self,
1050        request: &ProjectionRequest<'_>,
1051        session_vars: &[(&str, &str)],
1052        routing: ReadRouting,
1053    ) -> Result<JsonbRowStream> {
1054        let (sql, typed_params) = match request.projection {
1055            Some(projection) => build_projection_select_sql(
1056                projection,
1057                request.view,
1058                request.where_clause,
1059                request.limit,
1060                request.offset,
1061                request.order_by,
1062            )?,
1063            None => build_where_select_sql_ordered(
1064                request.view,
1065                request.where_clause,
1066                request.limit,
1067                request.offset,
1068                request.order_by,
1069            )?,
1070        };
1071
1072        super::streaming::stream_rows(self, sql, typed_params, session_vars, routing, |row, sql| {
1073            jsonb_cell(row, 0, sql)
1074        })
1075        .await
1076        .map_err(|e| enrich_undefined_column_error(e, request.view, request.where_clause))
1077    }
1078
1079    /// The column-shaped streaming read behind gRPC server-streaming (#958).
1080    ///
1081    /// Routed to the primary like its collecting sibling: `execute_row_query`
1082    /// reaches `execute_raw_query`, and a surface that executes composed SQL is
1083    /// never sent to a replica.
1084    async fn stream_row_query(
1085        &self,
1086        view_name: &str,
1087        columns: &[crate::types::ColumnSpec],
1088        where_sql: Option<&str>,
1089        order_by: Option<&str>,
1090        limit: Option<u32>,
1091        offset: Option<u32>,
1092    ) -> Result<ColumnRowStream> {
1093        let sql = crate::traits::build_row_query_sql(view_name, where_sql, order_by, limit, offset);
1094        let columns = columns.to_vec();
1095
1096        super::streaming::stream_rows(
1097            self,
1098            sql,
1099            Vec::new(),
1100            &[],
1101            ReadRouting::Primary,
1102            move |row, _sql| Ok(crate::traits::row_to_column_values(&row_to_map(row), &columns)),
1103        )
1104        .await
1105    }
1106
1107    /// The session-pinned collecting row read (#1351).
1108    ///
1109    /// Same shape as [`count_where_query`](Self::count_where_query): with no session
1110    /// variables it is a plain read; with them it is one transaction, so `set_config`
1111    /// and the SELECT land on the same connection and an RLS policy backed by
1112    /// `current_setting()` sees them (#329).
1113    async fn execute_row_query_with_session(
1114        &self,
1115        view_name: &str,
1116        columns: &[crate::types::ColumnSpec],
1117        where_sql: Option<&str>,
1118        order_by: Option<&str>,
1119        limit: Option<u32>,
1120        offset: Option<u32>,
1121        session_vars: &[(&str, &str)],
1122    ) -> Result<Vec<Vec<crate::types::ColumnValue>>> {
1123        if session_vars.is_empty() {
1124            return self
1125                .execute_row_query(view_name, columns, where_sql, order_by, limit, offset)
1126                .await;
1127        }
1128
1129        let sql = crate::traits::build_row_query_sql(view_name, where_sql, order_by, limit, offset);
1130
1131        let mut client = self.acquire_connection_with_retry().await?;
1132        let txn =
1133            client.build_transaction().start().await.map_err(|e| FraiseQLError::Database {
1134                message:   format!(
1135                    "Failed to start row-read session-var transaction: {}",
1136                    pg_detail(&e)
1137                ),
1138                sql_state: e.code().map(|c| c.code().to_string()),
1139            })?;
1140        apply_session_vars(&txn, session_vars).await?;
1141        let rows: Vec<Row> =
1142            txn.query(sql.as_str(), &[]).await.map_err(|e| FraiseQLError::Database {
1143                message:   format!("Row query execution failed: {}", pg_detail(&e)),
1144                sql_state: e.code().map(|c| c.code().to_string()),
1145            })?;
1146        txn.commit().await.map_err(|e| FraiseQLError::Database {
1147            message:   format!(
1148                "Failed to commit row-read session-var transaction: {}",
1149                pg_detail(&e)
1150            ),
1151            sql_state: e.code().map(|c| c.code().to_string()),
1152        })?;
1153
1154        Ok(rows
1155            .iter()
1156            .map(|row| crate::traits::row_to_column_values(&row_to_map(row), columns))
1157            .collect())
1158    }
1159
1160    /// The session-pinned streaming row read (#1351).
1161    ///
1162    /// `stream_rows` already pins session variables for the life of the portal —
1163    /// the non-session arm above passes `&[]` to the very same call.
1164    async fn stream_row_query_with_session(
1165        &self,
1166        view_name: &str,
1167        columns: &[crate::types::ColumnSpec],
1168        where_sql: Option<&str>,
1169        order_by: Option<&str>,
1170        limit: Option<u32>,
1171        offset: Option<u32>,
1172        session_vars: &[(&str, &str)],
1173    ) -> Result<ColumnRowStream> {
1174        let sql = crate::traits::build_row_query_sql(view_name, where_sql, order_by, limit, offset);
1175        let columns = columns.to_vec();
1176
1177        super::streaming::stream_rows(
1178            self,
1179            sql,
1180            Vec::new(),
1181            session_vars,
1182            ReadRouting::Primary,
1183            move |row, _sql| Ok(crate::traits::row_to_column_values(&row_to_map(row), &columns)),
1184        )
1185        .await
1186    }
1187
1188    async fn explain_query(
1189        &self,
1190        sql: &str,
1191        _params: &[serde_json::Value],
1192    ) -> Result<serde_json::Value> {
1193        // Defense-in-depth: reject multi-statement input even though this SQL is
1194        // compiler-generated. A semicolon would allow a second statement to be
1195        // appended to the EXPLAIN prefix.
1196        if sql.contains(';') {
1197            return Err(FraiseQLError::Validation {
1198                message: "EXPLAIN SQL must be a single statement".into(),
1199                path:    None,
1200            });
1201        }
1202        let explain_sql = format!("EXPLAIN (ANALYZE false, FORMAT JSON) {sql}");
1203        // Read-only (plan only, ANALYZE false): replica-eligible (#407).
1204        let client = self.acquire_read_connection_with_retry(ReadRouting::Any).await?;
1205        let rows: Vec<Row> =
1206            client
1207                .query(explain_sql.as_str(), &[])
1208                .await
1209                .map_err(|e| FraiseQLError::Database {
1210                    message:   format!("EXPLAIN failed: {}", pg_detail(&e)),
1211                    sql_state: e.code().map(|c| c.code().to_string()),
1212                })?;
1213
1214        if let Some(row) = rows.first() {
1215            let plan: serde_json::Value = row.try_get(0).map_err(|e| FraiseQLError::Database {
1216                message:   format!("Failed to parse EXPLAIN output: {e}"),
1217                sql_state: None,
1218            })?;
1219            Ok(plan)
1220        } else {
1221            Ok(serde_json::Value::Null)
1222        }
1223    }
1224
1225    async fn query_stats(&self, limit: u32) -> Result<Vec<crate::types::QueryStatEntry>> {
1226        self.pg_query_stats(limit).await
1227    }
1228
1229    async fn query_stats_by_id(&self, id: &str) -> Result<Option<crate::types::QueryStatEntry>> {
1230        self.pg_query_stats_by_id(id).await
1231    }
1232
1233    async fn reset_query_stats(&self) -> Result<()> {
1234        self.pg_reset_query_stats().await
1235    }
1236
1237    /// The operator SQL console (#962). The containment lives in the private
1238    /// `postgres::adapter::admin_sql` module; see [`AdminSqlRequest`] for what
1239    /// each bound is and how PostgreSQL — not a parse of the statement text —
1240    /// enforces it.
1241    ///
1242    /// [`AdminSqlRequest`]: crate::traits::AdminSqlRequest
1243    async fn execute_admin_sql(
1244        &self,
1245        request: &crate::traits::AdminSqlRequest,
1246    ) -> Result<crate::traits::AdminSqlOutcome> {
1247        super::admin_sql::execute_admin_sql_impl(self, request).await
1248    }
1249}
1250
1251// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
1252#[async_trait]
1253impl Writer for PostgresAdapter {
1254    async fn execute_write(
1255        &self,
1256        request: &crate::traits::WriteRequest<'_>,
1257        gate: crate::traits::MutationRowGate<'_>,
1258    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
1259        run_function_in_txn(self, request, gate).await
1260    }
1261}