Skip to main content

fraiseql_db/postgres/adapter/
database.rs

1//! `DatabaseAdapter` and `SupportsMutations` implementations for `PostgresAdapter`.
2
3use std::sync::Arc;
4
5use async_trait::async_trait;
6use bytes::BufMut as _;
7use fraiseql_error::{FraiseQLError, Result};
8use tokio_postgres::Row;
9
10use super::{
11    PostgresAdapter, build_projection_select_sql, build_where_select_sql,
12    build_where_select_sql_ordered,
13};
14use crate::{
15    identifier::quote_postgres_identifier,
16    traits::{DatabaseAdapter, ProjectionRequest, SupportsMutations},
17    types::{
18        DatabaseType, JsonbValue, PoolMetrics, QueryParam,
19        sql_hints::{OrderByClause, SqlProjectionHint},
20    },
21    where_clause::WhereClause,
22};
23
24/// PostgreSQL SQLSTATE 42703: undefined column.
25const PG_UNDEFINED_COLUMN: &str = "42703";
26
27/// A flexible SQL parameter that binds to any PostgreSQL type.
28///
29/// Solves the impedance mismatch between `serde_json::Value` (only accepts JSON/JSONB)
30/// and `Option<String>` (only accepts text-family types) when binding function-call
31/// arguments whose types are resolved at runtime from the function signature.
32///
33/// Serialisation strategy (binary wire format):
34/// - `JSONB`: 1-byte version header (1) + UTF-8 JSON bytes
35/// - `JSON`: UTF-8 JSON bytes
36/// - `UUID`: 16-byte big-endian UUID
37/// - `INT4`: 4-byte big-endian i32
38/// - `INT8`: 8-byte big-endian i64
39/// - `BOOL`: 1-byte (0 or 1)
40/// - All other types: UTF-8 bytes (PostgreSQL text binary = raw UTF-8)
41#[derive(Debug)]
42enum FlexParam {
43    /// SQL NULL — accepted by any PostgreSQL type.
44    Null,
45    /// A text-encoded value; binary-serialised according to the server-resolved type.
46    Text(String),
47}
48
49impl tokio_postgres::types::ToSql for FlexParam {
50    fn to_sql(
51        &self,
52        ty: &tokio_postgres::types::Type,
53        out: &mut bytes::BytesMut,
54    ) -> std::result::Result<tokio_postgres::types::IsNull, Box<dyn std::error::Error + Sync + Send>>
55    {
56        use tokio_postgres::types::{IsNull, Type};
57        match self {
58            Self::Null => Ok(IsNull::Yes),
59            Self::Text(s) => {
60                if *ty == Type::JSONB {
61                    // JSONB binary wire format: 1-byte version (1) + JSON bytes
62                    out.put_u8(1);
63                    out.extend_from_slice(s.as_bytes());
64                } else if *ty == Type::JSON {
65                    out.extend_from_slice(s.as_bytes());
66                } else if *ty == Type::UUID {
67                    let uuid = uuid::Uuid::parse_str(s)?;
68                    out.extend_from_slice(uuid.as_bytes());
69                } else if *ty == Type::INT4 {
70                    let n: i32 = s.parse()?;
71                    out.put_i32(n);
72                } else if *ty == Type::INT8 {
73                    let n: i64 = s.parse()?;
74                    out.put_i64(n);
75                } else if *ty == Type::BOOL {
76                    let b: bool = s.parse()?;
77                    out.put_u8(u8::from(b));
78                } else {
79                    // TEXT, VARCHAR, BPCHAR, NAME, UNKNOWN, and any user-defined type:
80                    // UTF-8 bytes are the binary wire representation for text-family types.
81                    out.extend_from_slice(s.as_bytes());
82                }
83                Ok(IsNull::No)
84            },
85        }
86    }
87
88    fn accepts(_ty: &tokio_postgres::types::Type) -> bool {
89        // Accepts all types; per-type serialisation is handled in `to_sql`.
90        true
91    }
92
93    fn to_sql_checked(
94        &self,
95        ty: &tokio_postgres::types::Type,
96        out: &mut bytes::BytesMut,
97    ) -> std::result::Result<tokio_postgres::types::IsNull, Box<dyn std::error::Error + Sync + Send>>
98    {
99        // `accepts()` returns true for all types, so the standard WrongType check is
100        // unnecessary.  Delegate directly to `to_sql`.
101        self.to_sql(ty, out)
102    }
103}
104
105/// Enrich a `FraiseQLError::Database` error for PostgreSQL SQLSTATE 42703 (undefined column)
106/// when the WHERE clause contains `NativeField` conditions.
107///
108/// Native columns may be inferred automatically at compile time from `ID`/`UUID`-typed
109/// arguments.  If the column does not exist on the target table at runtime, the raw
110/// PostgreSQL error is replaced with a diagnostic message that names the native columns
111/// involved and explains how to fix the schema.
112fn enrich_undefined_column_error(
113    err: FraiseQLError,
114    view: &str,
115    where_clause: Option<&WhereClause>,
116) -> FraiseQLError {
117    let FraiseQLError::Database { ref sql_state, .. } = err else {
118        return err;
119    };
120    if sql_state.as_deref() != Some(PG_UNDEFINED_COLUMN) {
121        return err;
122    }
123    let native_cols: Vec<&str> =
124        where_clause.map(|wc| wc.native_column_names()).unwrap_or_default();
125    if native_cols.is_empty() {
126        return err;
127    }
128    FraiseQLError::Database {
129        message:   format!(
130            "Column(s) {:?} referenced as native column(s) on `{view}` do not exist. \
131             These columns were auto-inferred from ID/UUID-typed query arguments. \
132             Either add the column(s) to the table/view, or set \
133             `native_columns = {{}}` explicitly in your schema to disable inference.",
134            native_cols,
135        ),
136        sql_state: Some(PG_UNDEFINED_COLUMN.to_string()),
137    }
138}
139
140/// Convert a single `tokio_postgres::Row` into a `HashMap<String, serde_json::Value>`.
141///
142/// Tries each PostgreSQL type in priority order; falls back to `Null` for
143/// types that cannot be represented as JSON.
144fn row_to_map(row: &Row) -> std::collections::HashMap<String, serde_json::Value> {
145    let mut map = std::collections::HashMap::new();
146    for (idx, column) in row.columns().iter().enumerate() {
147        let column_name = column.name().to_string();
148        let value: serde_json::Value = if let Ok(v) = row.try_get::<_, i32>(idx) {
149            serde_json::json!(v)
150        } else if let Ok(v) = row.try_get::<_, i64>(idx) {
151            serde_json::json!(v)
152        } else if let Ok(v) = row.try_get::<_, f64>(idx) {
153            serde_json::json!(v)
154        } else if let Ok(v) = row.try_get::<_, String>(idx) {
155            serde_json::json!(v)
156        } else if let Ok(v) = row.try_get::<_, bool>(idx) {
157            serde_json::json!(v)
158        } else if let Ok(v) = row.try_get::<_, Vec<String>>(idx) {
159            // TEXT[] columns (e.g. `app.mutation_response.updated_fields`) — without
160            // this branch a non-null text array falls through to Null and a parser
161            // expecting a sequence fails. Must precede the jsonb branch (a jsonb
162            // column never deserializes as Vec<String>, so ordering is safe).
163            serde_json::json!(v)
164        } else if let Ok(v) = row.try_get::<_, serde_json::Value>(idx) {
165            v
166        } else {
167            serde_json::Value::Null
168        };
169        map.insert(column_name, value);
170    }
171    map
172}
173
174/// Apply transaction-local session variables on an in-progress transaction.
175///
176/// Each `(name, value)` pair is set with `SELECT set_config($1, $2, true)`, so
177/// the values are scoped to `txn` and visible to every statement run on it
178/// (fixes #329). The caller is responsible for committing or rolling back.
179pub(super) async fn apply_session_vars(
180    txn: &tokio_postgres::Transaction<'_>,
181    session_vars: &[(&str, &str)],
182) -> Result<()> {
183    for (name, value) in session_vars {
184        txn.execute("SELECT set_config($1, $2, true)", &[name, value])
185            .await
186            .map_err(|e| FraiseQLError::Database {
187                message:   format!("set_config({name:?}) failed: {e}"),
188                sql_state: e.code().map(|c| c.code().to_string()),
189            })?;
190    }
191    Ok(())
192}
193
194// Reason: DatabaseAdapter is defined with #[async_trait]; all implementations must match
195// its transformed method signatures to satisfy the trait contract
196// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
197#[async_trait]
198impl DatabaseAdapter for PostgresAdapter {
199    async fn execute_with_projection(
200        &self,
201        view: &str,
202        projection: Option<&SqlProjectionHint>,
203        where_clause: Option<&WhereClause>,
204        limit: Option<u32>,
205        offset: Option<u32>,
206        order_by: Option<&[OrderByClause]>,
207    ) -> Result<Vec<JsonbValue>> {
208        self.execute_with_projection_impl(view, projection, where_clause, limit, offset, order_by)
209            .await
210    }
211
212    async fn execute_where_query(
213        &self,
214        view: &str,
215        where_clause: Option<&WhereClause>,
216        limit: Option<u32>,
217        offset: Option<u32>,
218        order_by: Option<&[OrderByClause]>,
219    ) -> Result<Vec<JsonbValue>> {
220        let (sql, typed_params) =
221            build_where_select_sql_ordered(view, where_clause, limit, offset, order_by)?;
222
223        let param_refs = crate::types::as_sql_param_refs(&typed_params);
224
225        self.execute_raw(&sql, &param_refs)
226            .await
227            .map_err(|e| enrich_undefined_column_error(e, view, where_clause))
228    }
229
230    async fn explain_where_query(
231        &self,
232        view: &str,
233        where_clause: Option<&WhereClause>,
234        limit: Option<u32>,
235        offset: Option<u32>,
236    ) -> Result<serde_json::Value> {
237        let (select_sql, typed_params) = build_where_select_sql(view, where_clause, limit, offset)?;
238        // Defense-in-depth: compiler-generated SQL should never contain a
239        // semicolon, but guard against it to prevent statement injection.
240        if select_sql.contains(';') {
241            return Err(FraiseQLError::Validation {
242                message: "EXPLAIN SQL must be a single statement".into(),
243                path:    None,
244            });
245        }
246        let explain_sql = format!("EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) {select_sql}");
247
248        let param_refs = crate::types::as_sql_param_refs(&typed_params);
249
250        let client = self.acquire_connection_with_retry().await?;
251        let rows = client.query(explain_sql.as_str(), &param_refs).await.map_err(|e| {
252            FraiseQLError::Database {
253                message:   format!("EXPLAIN ANALYZE failed: {e}"),
254                sql_state: e.code().map(|c| c.code().to_string()),
255            }
256        })?;
257
258        if let Some(row) = rows.first() {
259            let plan: serde_json::Value = row.try_get(0).map_err(|e| FraiseQLError::Database {
260                message:   format!("Failed to parse EXPLAIN output: {e}"),
261                sql_state: None,
262            })?;
263            Ok(plan)
264        } else {
265            Ok(serde_json::Value::Null)
266        }
267    }
268
269    fn database_type(&self) -> DatabaseType {
270        DatabaseType::PostgreSQL
271    }
272
273    async fn health_check(&self) -> Result<()> {
274        // Use retry logic for health check to avoid false negatives during pool exhaustion
275        let client = self.acquire_connection_with_retry().await?;
276
277        client.query("SELECT 1", &[]).await.map_err(|e| FraiseQLError::Database {
278            message:   format!("Health check failed: {e}"),
279            sql_state: e.code().map(|c| c.code().to_string()),
280        })?;
281
282        Ok(())
283    }
284
285    #[allow(clippy::cast_possible_truncation)] // Reason: value is bounded; truncation cannot occur in practice
286    fn pool_metrics(&self) -> PoolMetrics {
287        let status = self.pool.status();
288
289        PoolMetrics {
290            total_connections:  status.size as u32,
291            idle_connections:   status.available as u32,
292            active_connections: (status.size - status.available) as u32,
293            waiting_requests:   status.waiting as u32,
294        }
295    }
296
297    /// # Security
298    ///
299    /// `sql` **must** be compiler-generated. Never pass user-supplied strings
300    /// directly — doing so would open SQL-injection vulnerabilities.
301    async fn execute_raw_query(
302        &self,
303        sql: &str,
304    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
305        // Use retry logic for connection acquisition
306        let client = self.acquire_connection_with_retry().await?;
307
308        let rows: Vec<Row> = client.query(sql, &[]).await.map_err(|e| FraiseQLError::Database {
309            message:   format!("Query execution failed: {e}"),
310            sql_state: e.code().map(|c| c.code().to_string()),
311        })?;
312
313        // Convert each row to HashMap<String, Value>
314        let results: Vec<std::collections::HashMap<String, serde_json::Value>> =
315            rows.iter().map(row_to_map).collect();
316
317        Ok(results)
318    }
319
320    async fn execute_parameterized_aggregate(
321        &self,
322        sql: &str,
323        params: &[serde_json::Value],
324    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
325        // Convert serde_json::Value params to QueryParam so that strings are bound
326        // as TEXT (not JSONB), which is required for correct WHERE comparisons against
327        // data->>'field' expressions that return TEXT.
328        let typed: Vec<QueryParam> = params.iter().cloned().map(QueryParam::from).collect();
329        let param_refs = crate::types::as_sql_param_refs(&typed);
330
331        let client = self.acquire_connection_with_retry().await?;
332        let rows: Vec<Row> =
333            client.query(sql, &param_refs).await.map_err(|e| FraiseQLError::Database {
334                message:   format!("Parameterized aggregate query failed: {e}"),
335                sql_state: e.code().map(|c| c.code().to_string()),
336            })?;
337
338        let results: Vec<std::collections::HashMap<String, serde_json::Value>> =
339            rows.iter().map(row_to_map).collect();
340
341        Ok(results)
342    }
343
344    async fn execute_parameterized_aggregate_with_session(
345        &self,
346        sql: &str,
347        params: &[serde_json::Value],
348        session_vars: &[(&str, &str)],
349    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
350        if session_vars.is_empty() {
351            return self.execute_parameterized_aggregate(sql, params).await;
352        }
353
354        let typed: Vec<QueryParam> = params.iter().cloned().map(QueryParam::from).collect();
355        let param_refs = crate::types::as_sql_param_refs(&typed);
356
357        let mut client = self.acquire_connection_with_retry().await?;
358        let txn =
359            client.build_transaction().start().await.map_err(|e| FraiseQLError::Database {
360                message:   format!("Failed to start session-var transaction: {e}"),
361                sql_state: e.code().map(|c| c.code().to_string()),
362            })?;
363        apply_session_vars(&txn, session_vars).await?;
364        let rows: Vec<Row> =
365            txn.query(sql, &param_refs).await.map_err(|e| FraiseQLError::Database {
366                message:   format!("Parameterized aggregate query failed: {e}"),
367                sql_state: e.code().map(|c| c.code().to_string()),
368            })?;
369        txn.commit().await.map_err(|e| FraiseQLError::Database {
370            message:   format!("Failed to commit session-var transaction: {e}"),
371            sql_state: e.code().map(|c| c.code().to_string()),
372        })?;
373
374        Ok(rows.iter().map(row_to_map).collect())
375    }
376
377    async fn execute_function_call(
378        &self,
379        function_name: &str,
380        args: &[serde_json::Value],
381    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
382        // Build: SELECT * FROM "fn_name"($1, $2, ...)
383        // Use the standard identifier quoting utility so that schema-qualified
384        // names like "benchmark.fn_update_user" are correctly split into
385        // "benchmark"."fn_update_user" instead of being wrapped as a single
386        // identifier.
387        let quoted_fn = quote_postgres_identifier(function_name);
388        let placeholders: Vec<String> = (1..=args.len()).map(|i| format!("${i}")).collect();
389        let sql = format!("SELECT * FROM {quoted_fn}({})", placeholders.join(", "));
390
391        let mut client = self.acquire_connection_with_retry().await?;
392
393        // Convert serde_json::Value arguments to FlexParam for binding.
394        //
395        // serde_json::Value only accepts JSON/JSONB types; Option<String> only accepts
396        // text-family types.  Neither works universally when the function signature
397        // contains a mix of JSONB, UUID, INT4, and TEXT parameters.  FlexParam accepts
398        // all PostgreSQL types and serialises each value in the correct binary wire
399        // format for the server-resolved parameter type.
400        let flex_args: Vec<FlexParam> = args
401            .iter()
402            .map(|v| match v {
403                serde_json::Value::Null => FlexParam::Null,
404                serde_json::Value::String(s) => FlexParam::Text(s.clone()),
405                _ => FlexParam::Text(v.to_string()),
406            })
407            .collect();
408        let params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = flex_args
409            .iter()
410            .map(|v| v as &(dyn tokio_postgres::types::ToSql + Sync))
411            .collect();
412
413        if self.mutation_timing_enabled {
414            // Wrap in a transaction so SET LOCAL scopes the variable to this call only.
415            // `set_config(name, value, is_local)` with is_local=true is equivalent to
416            // SET LOCAL and is parameterized to avoid SQL injection.
417            let txn =
418                client.build_transaction().start().await.map_err(|e| FraiseQLError::Database {
419                    message:   format!("Failed to start mutation timing transaction: {e}"),
420                    sql_state: e.code().map(|c| c.code().to_string()),
421                })?;
422
423            txn.execute(
424                "SELECT set_config($1, clock_timestamp()::text, true)",
425                &[&self.timing_variable_name],
426            )
427            .await
428            .map_err(|e| FraiseQLError::Database {
429                message:   format!("Failed to set mutation timing variable: {e}"),
430                sql_state: e.code().map(|c| c.code().to_string()),
431            })?;
432
433            let rows: Vec<Row> = txn.query(sql.as_str(), params.as_slice()).await.map_err(|e| {
434                let detail = e.as_db_error().map_or("", |d| d.message());
435                FraiseQLError::Database {
436                    message:   format!("Function call {function_name} failed: {e}: {detail}"),
437                    sql_state: e.code().map(|c| c.code().to_string()),
438                }
439            })?;
440
441            txn.commit().await.map_err(|e| FraiseQLError::Database {
442                message:   format!("Failed to commit mutation timing transaction: {e}"),
443                sql_state: e.code().map(|c| c.code().to_string()),
444            })?;
445
446            let results: Vec<std::collections::HashMap<String, serde_json::Value>> =
447                rows.iter().map(row_to_map).collect();
448
449            Ok(results)
450        } else {
451            let rows: Vec<Row> =
452                client.query(sql.as_str(), params.as_slice()).await.map_err(|e| {
453                    let detail = e.as_db_error().map_or("", |d| d.message());
454                    FraiseQLError::Database {
455                        message:   format!("Function call {function_name} failed: {e}: {detail}"),
456                        sql_state: e.code().map(|c| c.code().to_string()),
457                    }
458                })?;
459
460            let results: Vec<std::collections::HashMap<String, serde_json::Value>> =
461                rows.iter().map(row_to_map).collect();
462
463            Ok(results)
464        }
465    }
466
467    // PostgreSQL session variables are applied connection-affinely by the
468    // `*_with_session` methods below: `set_config(..., true)` and the operation
469    // share one transaction on one connection, so transaction-local GUCs are
470    // visible to the function / view (fixes #329).
471
472    async fn execute_function_call_with_session(
473        &self,
474        function_name: &str,
475        args: &[serde_json::Value],
476        session_vars: &[(&str, &str)],
477    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
478        // Fast path: no session variables and no mutation timing => behave
479        // exactly like execute_function_call (no transaction overhead, and
480        // execute_function_call already opens its own txn when timing is on).
481        if session_vars.is_empty() && !self.mutation_timing_enabled {
482            return self.execute_function_call(function_name, args).await;
483        }
484
485        let quoted_fn = quote_postgres_identifier(function_name);
486        let placeholders: Vec<String> = (1..=args.len()).map(|i| format!("${i}")).collect();
487        let sql = format!("SELECT * FROM {quoted_fn}({})", placeholders.join(", "));
488
489        // See execute_function_call for why FlexParam is required here.
490        let flex_args: Vec<FlexParam> = args
491            .iter()
492            .map(|v| match v {
493                serde_json::Value::Null => FlexParam::Null,
494                serde_json::Value::String(s) => FlexParam::Text(s.clone()),
495                _ => FlexParam::Text(v.to_string()),
496            })
497            .collect();
498        let params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = flex_args
499            .iter()
500            .map(|v| v as &(dyn tokio_postgres::types::ToSql + Sync))
501            .collect();
502
503        let mut client = self.acquire_connection_with_retry().await?;
504        let txn =
505            client.build_transaction().start().await.map_err(|e| FraiseQLError::Database {
506                message:   format!("Failed to start session-var transaction: {e}"),
507                sql_state: e.code().map(|c| c.code().to_string()),
508            })?;
509
510        // Apply session variables FIRST so the function body sees them.
511        apply_session_vars(&txn, session_vars).await?;
512
513        // If mutation timing is on, stamp the timing variable in the same txn.
514        if self.mutation_timing_enabled {
515            txn.execute(
516                "SELECT set_config($1, clock_timestamp()::text, true)",
517                &[&self.timing_variable_name],
518            )
519            .await
520            .map_err(|e| FraiseQLError::Database {
521                message:   format!("Failed to set mutation timing variable: {e}"),
522                sql_state: e.code().map(|c| c.code().to_string()),
523            })?;
524        }
525
526        let rows: Vec<Row> = txn.query(sql.as_str(), params.as_slice()).await.map_err(|e| {
527            let detail = e.as_db_error().map_or("", |d| d.message());
528            FraiseQLError::Database {
529                message:   format!("Function call {function_name} failed: {e}: {detail}"),
530                sql_state: e.code().map(|c| c.code().to_string()),
531            }
532        })?;
533
534        txn.commit().await.map_err(|e| FraiseQLError::Database {
535            message:   format!("Failed to commit session-var transaction: {e}"),
536            sql_state: e.code().map(|c| c.code().to_string()),
537        })?;
538
539        Ok(rows.iter().map(row_to_map).collect())
540    }
541
542    async fn execute_where_query_arc_with_session(
543        &self,
544        view: &str,
545        where_clause: Option<&WhereClause>,
546        limit: Option<u32>,
547        offset: Option<u32>,
548        order_by: Option<&[OrderByClause]>,
549        session_vars: &[(&str, &str)],
550    ) -> Result<Arc<Vec<JsonbValue>>> {
551        if session_vars.is_empty() {
552            return self.execute_where_query_arc(view, where_clause, limit, offset, order_by).await;
553        }
554
555        let (sql, typed_params) =
556            build_where_select_sql_ordered(view, where_clause, limit, offset, order_by)?;
557        let param_refs = crate::types::as_sql_param_refs(&typed_params);
558
559        self.execute_raw_with_session(&sql, &param_refs, session_vars)
560            .await
561            .map(Arc::new)
562            .map_err(|e| enrich_undefined_column_error(e, view, where_clause))
563    }
564
565    async fn execute_with_projection_arc_with_session(
566        &self,
567        request: &ProjectionRequest<'_>,
568        session_vars: &[(&str, &str)],
569    ) -> Result<Arc<Vec<JsonbValue>>> {
570        if session_vars.is_empty() {
571            return self.execute_with_projection_arc(request).await;
572        }
573
574        // No projection => behave like a plain WHERE query, matching
575        // execute_with_projection_impl's fallback.
576        let Some(projection) = request.projection else {
577            return self
578                .execute_where_query_arc_with_session(
579                    request.view,
580                    request.where_clause,
581                    request.limit,
582                    request.offset,
583                    request.order_by,
584                    session_vars,
585                )
586                .await;
587        };
588
589        let (sql, typed_params) = build_projection_select_sql(
590            projection,
591            request.view,
592            request.where_clause,
593            request.limit,
594            request.offset,
595            request.order_by,
596        )?;
597        let param_refs = crate::types::as_sql_param_refs(&typed_params);
598
599        self.execute_raw_with_session(&sql, &param_refs, session_vars)
600            .await
601            .map(Arc::new)
602    }
603
604    async fn explain_query(
605        &self,
606        sql: &str,
607        _params: &[serde_json::Value],
608    ) -> Result<serde_json::Value> {
609        // Defense-in-depth: reject multi-statement input even though this SQL is
610        // compiler-generated. A semicolon would allow a second statement to be
611        // appended to the EXPLAIN prefix.
612        if sql.contains(';') {
613            return Err(FraiseQLError::Validation {
614                message: "EXPLAIN SQL must be a single statement".into(),
615                path:    None,
616            });
617        }
618        let explain_sql = format!("EXPLAIN (ANALYZE false, FORMAT JSON) {sql}");
619        let client = self.acquire_connection_with_retry().await?;
620        let rows: Vec<Row> =
621            client
622                .query(explain_sql.as_str(), &[])
623                .await
624                .map_err(|e| FraiseQLError::Database {
625                    message:   format!("EXPLAIN failed: {e}"),
626                    sql_state: e.code().map(|c| c.code().to_string()),
627                })?;
628
629        if let Some(row) = rows.first() {
630            let plan: serde_json::Value = row.try_get(0).map_err(|e| FraiseQLError::Database {
631                message:   format!("Failed to parse EXPLAIN output: {e}"),
632                sql_state: None,
633            })?;
634            Ok(plan)
635        } else {
636            Ok(serde_json::Value::Null)
637        }
638    }
639
640    async fn query_stats(&self, limit: u32) -> Result<Vec<crate::types::QueryStatEntry>> {
641        self.pg_query_stats(limit).await
642    }
643
644    async fn query_stats_by_id(&self, id: &str) -> Result<Option<crate::types::QueryStatEntry>> {
645        self.pg_query_stats_by_id(id).await
646    }
647
648    async fn reset_query_stats(&self) -> Result<()> {
649        self.pg_reset_query_stats().await
650    }
651}
652
653impl SupportsMutations for PostgresAdapter {}