Skip to main content

fraiseql_db/
traits.rs

1//! Database adapter trait definitions.
2//!
3//! The main [`DatabaseAdapter`] trait lives in this file. Supporting types
4//! (`RelayPageResult`, `DatabaseCapabilities`, enums, type aliases) are in
5//! the `adapter_types` submodule.
6
7mod adapter_types;
8mod mutations;
9mod relay;
10
11use std::sync::Arc;
12
13pub use adapter_types::*;
14use async_trait::async_trait;
15use fraiseql_error::{FraiseQLError, Result};
16pub use mutations::SupportsMutations;
17pub use relay::RelayDatabaseAdapter;
18
19use crate::{
20    types::{
21        DatabaseType, JsonbValue, PoolMetrics,
22        sql_hints::{OrderByClause, SqlProjectionHint},
23    },
24    where_clause::WhereClause,
25};
26
27/// The framework-owned change-log row the mutation executor writes in-txn.
28///
29/// Carries only the fields the adapter cannot derive from the
30/// `app.mutation_response` row it already holds: the DML verb and a NOT-NULL
31/// `object_type` fallback. The changed-entity identity + payload (`object_id`,
32/// `object_data`, `updated_fields`, `cascade`) are read from the function's own
33/// returned row inside the same transaction (see
34/// [`DatabaseAdapter::execute_function_call_with_changelog`]).
35///
36/// This is the Change Spine transactional-outbox contract. Beyond the
37/// `object_type`/`modification_type` + changed-entity columns, it stamps the
38/// envelope: `tenant_id` (carried here, from `SecurityContext`),
39/// `trace_id` (the W3C trace id of the originating request), `schema_version`
40/// (the compiled schema's content hash — a per-deployment constant),
41/// `trace_context` (the full W3C trace context as JSON), `actor_type` /
42/// `acting_for` (the request's actor classification and, for a delegated agent,
43/// the underlying human — #390), `commit_time` (`clock_timestamp()` at INSERT),
44/// and `seq` (the table's `SEQUENCE` default).
45#[derive(Debug, Clone, Copy)]
46pub struct ChangeLogWrite<'a> {
47    /// NOT-NULL fallback for `object_type` when the row's `entity_type` is NULL.
48    /// Sourced from `MutationDefinition.return_type` (always present).
49    pub object_type:       &'a str,
50    /// The DML verb written to `modification_type` (e.g. `"INSERT"`,
51    /// `"UPDATE"`, `"DELETE"`, `"CUSTOM"`), from `MutationOperation`.
52    pub modification_type: &'a str,
53    /// The tenant partition stamp written to the `tenant_id UUID` column — the
54    /// Trinity public-facing identifier, read from `SecurityContext.tenant_id`
55    /// at write time and **never** reconstructed from connection / RLS state
56    /// (RLS is PG-only; out-of-session spine consumers bypass it, so the row
57    /// must carry tenant identity explicitly). `None` (→ SQL NULL) for an
58    /// unauthenticated request, a request with no tenant, or a tenant
59    /// identifier that is not a UUID.
60    pub tenant_id:         Option<uuid::Uuid>,
61    /// The W3C trace id of the originating request, written to the `trace_id`
62    /// column so an outbox row links back to its distributed trace (the #392
63    /// perf tooling surfaces it as the investigation handle). Read from the
64    /// request's `traceparent` header at write time; `None` (→ SQL NULL) for a
65    /// request without a trace context — e.g. an unauthenticated mutation, which
66    /// carries no `SecurityContext` to stamp.
67    pub trace_id:          Option<&'a str>,
68    /// The compiled schema's version written to the `schema_version` column so an
69    /// outbox row records which deployment produced it — the replay /
70    /// zero-downtime correctness handle for #378 (reject a row replayed under a
71    /// different schema). A per-deployment constant derived from the compiled
72    /// schema (`CompiledSchema::content_hash()`), **not** from the request, so it
73    /// changes on any schema change. `None` (→ SQL NULL) for producers with no
74    /// compiled schema in scope — cooperative external producers (ETL) and the
75    /// non-PostgreSQL no-op path.
76    pub schema_version:    Option<&'a str>,
77    /// The originating request's **full W3C trace context** as a JSON object
78    /// (`{version, trace_id, parent_id, trace_flags, tracestate?}`), written to the
79    /// `trace_context` JSONB column so a row carries enough to re-propagate /
80    /// reconstruct the distributed trace — not just the scalar `trace_id`. Carried
81    /// here as pre-serialized JSON **text** (the adapter binds it to the JSONB
82    /// column). Built from the request's `traceparent` / `tracestate` headers at
83    /// write time; `None` (→ SQL NULL) for a request without a valid trace context,
84    /// consistent with `trace_id`.
85    pub trace_context:     Option<&'a str>,
86    /// The request's actor classification written to the `actor_type` column (the
87    /// `snake_case` `ActorType` token: `"human_user"`, `"service_account"`,
88    /// `"ai_agent"`, `"system_job"`), from `SecurityContext.actor_type()` at write
89    /// time (#390). `None` (→ SQL NULL) for a request with no `SecurityContext` to
90    /// stamp (an unauthenticated mutation), or a cooperative external producer.
91    pub actor_type:        Option<&'a str>,
92    /// For a delegated agent request, the **underlying human** the agent acts for
93    /// — the public-facing UUID, written to the `acting_for UUID` column from
94    /// `SecurityContext.acting_for()` (#390). Mirrors `tenant_id`'s UUID shape so
95    /// it is stamped without a DB lookup. `None` (→ SQL NULL) for a non-delegated
96    /// request, an unauthenticated mutation, or a subject that is not UUID-shaped.
97    pub acting_for:        Option<uuid::Uuid>,
98}
99
100impl<'a> ChangeLogWrite<'a> {
101    /// Build a change-log write descriptor with no envelope stamps (`tenant_id`,
102    /// `trace_id`, `schema_version`, `trace_context`, `actor_type` and
103    /// `acting_for` NULL). Chain [`with_tenant_id`](Self::with_tenant_id) /
104    /// [`with_trace_id`](Self::with_trace_id) /
105    /// [`with_schema_version`](Self::with_schema_version) /
106    /// [`with_trace_context`](Self::with_trace_context) /
107    /// [`with_actor_type`](Self::with_actor_type) /
108    /// [`with_acting_for`](Self::with_acting_for) to stamp them.
109    #[must_use]
110    pub const fn new(object_type: &'a str, modification_type: &'a str) -> Self {
111        Self {
112            object_type,
113            modification_type,
114            tenant_id: None,
115            trace_id: None,
116            schema_version: None,
117            trace_context: None,
118            actor_type: None,
119            acting_for: None,
120        }
121    }
122
123    /// Stamp the tenant partition id (the Trinity public-facing UUID) onto the
124    /// outbox row. `None` leaves `tenant_id` NULL — for system / unauthenticated
125    /// rows, or a tenant identifier that is not UUID-shaped.
126    #[must_use]
127    pub const fn with_tenant_id(mut self, tenant_id: Option<uuid::Uuid>) -> Self {
128        self.tenant_id = tenant_id;
129        self
130    }
131
132    /// Stamp the originating request's W3C trace id onto the outbox row. `None`
133    /// leaves `trace_id` NULL — for a request with no trace context.
134    #[must_use]
135    pub const fn with_trace_id(mut self, trace_id: Option<&'a str>) -> Self {
136        self.trace_id = trace_id;
137        self
138    }
139
140    /// Stamp the compiled schema's version (its content hash) onto the outbox
141    /// row. `None` leaves `schema_version` NULL — for producers with no compiled
142    /// schema in scope (cooperative external producers, the non-PostgreSQL no-op
143    /// path).
144    #[must_use]
145    pub const fn with_schema_version(mut self, schema_version: Option<&'a str>) -> Self {
146        self.schema_version = schema_version;
147        self
148    }
149
150    /// Stamp the originating request's full W3C trace context (pre-serialized JSON
151    /// text) onto the outbox row's `trace_context` JSONB column. `None` leaves it
152    /// NULL — for a request with no valid trace context, or a non-PostgreSQL
153    /// no-op / cooperative producer.
154    #[must_use]
155    pub const fn with_trace_context(mut self, trace_context: Option<&'a str>) -> Self {
156        self.trace_context = trace_context;
157        self
158    }
159
160    /// Stamp the request's actor classification (the `snake_case` `ActorType`
161    /// token) onto the outbox row's `actor_type` column (#390). `None` leaves it
162    /// NULL — for an unauthenticated mutation or a cooperative producer.
163    #[must_use]
164    pub const fn with_actor_type(mut self, actor_type: Option<&'a str>) -> Self {
165        self.actor_type = actor_type;
166        self
167    }
168
169    /// Stamp the delegated user's UUID (the human a delegated agent acts for) onto
170    /// the outbox row's `acting_for` column (#390). `None` leaves it NULL — for a
171    /// non-delegated request, an unauthenticated mutation, or a non-UUID subject.
172    #[must_use]
173    pub const fn with_acting_for(mut self, acting_for: Option<uuid::Uuid>) -> Self {
174        self.acting_for = acting_for;
175        self
176    }
177}
178
179/// Database adapter for executing queries against views.
180///
181/// This trait abstracts over different database backends (PostgreSQL, MySQL, SQLite, SQL Server).
182/// All implementations must support:
183/// - Executing parameterized WHERE queries against views
184/// - Returning JSONB data from the `data` column
185/// - Connection pooling and health checks
186/// - Row-level security (RLS) WHERE clauses
187///
188/// # Architecture
189///
190/// The adapter is the runtime interface to the database. It receives:
191/// - View/table name (e.g., "v_user", "tf_sales")
192/// - Parameterized WHERE clauses (AST form, not strings)
193/// - Projection hints (for performance optimization)
194/// - Pagination parameters (LIMIT/OFFSET)
195///
196/// And returns:
197/// - JSONB rows from the `data` column (most operations)
198/// - Arbitrary rows as HashMap (for aggregation queries)
199/// - Mutation results from stored procedures
200///
201/// # Implementing a New Adapter
202///
203/// To add support for a new database (e.g., Oracle, Snowflake):
204///
205/// 1. **Create a new module** in `src/db/your_database/`
206/// 2. **Implement the trait**:
207///
208///    ```rust,ignore
209///    pub struct YourDatabaseAdapter { /* fields */ }
210///
211///    #[async_trait]
212///    impl DatabaseAdapter for YourDatabaseAdapter {
213///        async fn execute_where_query(&self, ...) -> Result<Vec<JsonbValue>> {
214///            // 1. Build parameterized SQL from WhereClause AST
215///            // 2. Execute with bound parameters (NO string concatenation)
216///            // 3. Return JSONB from data column
217///        }
218///        // Implement other required methods...
219///    }
220///    ```
221/// 3. **Add feature flag** to `Cargo.toml` (e.g., `feature = "your-database"`)
222/// 4. **Copy structure from PostgreSQL adapter** — see `src/db/postgres/adapter.rs`
223/// 5. **Add tests** in `tests/integration/your_database_test.rs`
224///
225/// # Security Requirements
226///
227/// All implementations MUST:
228/// - **Never concatenate user input into SQL strings**
229/// - **Always use parameterized queries** with bind parameters
230/// - **Validate parameter types** before binding
231/// - **Preserve RLS WHERE clauses** (never filter them out)
232/// - **Return errors, not silently fail** (e.g., connection loss)
233///
234/// # Connection Management
235///
236/// - Use a connection pool (recommended: 20 connections default)
237/// - Implement `health_check()` for ping-based monitoring
238/// - Provide `pool_metrics()` for observability
239/// - Handle stale connections gracefully
240///
241/// # Performance Characteristics
242///
243/// Expected throughput when properly implemented:
244/// - **Simple queries** (single table, no WHERE): 250+ Kelem/s
245/// - **Complex queries** (JOINs, multiple conditions): 50+ Kelem/s
246/// - **Mutations** (stored procedures): 1-10 RPS (depends on procedure)
247/// - **Relay pagination** (keyset cursors): 15-30ms latency
248///
249/// # Example: PostgreSQL Implementation
250///
251/// ```rust,ignore
252/// use sqlx::postgres::PgPool;
253/// use async_trait::async_trait;
254///
255/// pub struct PostgresAdapter {
256///     pool: PgPool,
257/// }
258///
259/// #[async_trait]
260/// impl DatabaseAdapter for PostgresAdapter {
261///     async fn execute_where_query(
262///         &self,
263///         view: &str,
264///         where_clause: Option<&WhereClause>,
265///         limit: Option<u32>,
266///         offset: Option<u32>,
267///     ) -> Result<Vec<JsonbValue>> {
268///         // 1. Build SQL: SELECT data FROM {view} WHERE {where_clause} LIMIT {limit}
269///         let mut sql = format!(r#"SELECT data FROM "{}""#, view);
270///
271///         // 2. Add WHERE clause (converts AST to parameterized SQL)
272///         let params = if let Some(where_clause) = where_clause {
273///             sql.push_str(" WHERE ");
274///             let (where_sql, params) = build_where_sql(where_clause)?;
275///             sql.push_str(&where_sql);
276///             params
277///         } else {
278///             vec![]
279///         };
280///
281///         // 3. Add LIMIT and OFFSET
282///         if let Some(limit) = limit {
283///             sql.push_str(" LIMIT ");
284///             sql.push_str(&limit.to_string());
285///         }
286///         if let Some(offset) = offset {
287///             sql.push_str(" OFFSET ");
288///             sql.push_str(&offset.to_string());
289///         }
290///
291///         // 4. Execute with bound parameters (NO string interpolation)
292///         let rows: Vec<(serde_json::Value,)> = sqlx::query_as(&sql)
293///             .bind(&params[0])
294///             .bind(&params[1])
295///             // ... bind all parameters
296///             .fetch_all(&self.pool)
297///             .await?;
298///
299///         // 5. Extract JSONB and return
300///         Ok(rows.into_iter().map(|(data,)| data).collect())
301///     }
302///
303///     // Implement other required methods...
304/// }
305/// ```
306///
307/// # Example: Basic Usage
308///
309/// ```rust,no_run
310/// use fraiseql_db::{DatabaseAdapter, WhereClause, WhereOperator};
311/// use serde_json::json;
312///
313/// # async fn example(adapter: impl DatabaseAdapter) -> Result<(), Box<dyn std::error::Error>> {
314/// // Build WHERE clause (AST, not string)
315/// let where_clause = WhereClause::Field {
316///     path: vec!["email".to_string()],
317///     operator: WhereOperator::Icontains,
318///     value: json!("example.com"),
319/// };
320///
321/// // Execute query with parameters
322/// let results = adapter
323///     .execute_where_query("v_user", Some(&where_clause), Some(10), None, None)
324///     .await?;
325///
326/// println!("Found {} users matching filter", results.len());
327/// # Ok(())
328/// # }
329/// ```
330///
331/// # See Also
332///
333/// - `WhereClause` — AST for parameterized WHERE clauses
334/// - `RelayDatabaseAdapter` — Optional trait for keyset pagination
335/// - `DatabaseCapabilities` — Feature detection for the adapter
336/// - [Performance Guide](https://docs.fraiseql.rs/performance/database-adapters.md)
337// POLICY: `#[async_trait]` placement for `DatabaseAdapter`
338//
339// `DatabaseAdapter` is used both generically (`Server<A: DatabaseAdapter>` in axum
340// handlers, zero overhead via static dispatch) and dynamically (`Arc<dyn
341// DatabaseAdapter + Send + Sync>` in federation, heap-boxed future per call).
342//
343// `#[async_trait]` is required on:
344// - The trait definition (generates `Pin<Box<dyn Future + Send>>` return types)
345// - Every `impl DatabaseAdapter for ConcreteType` block (generates the boxing)
346// NOT required on callers (they see `Pin<Box<dyn Future + Send>>` from macro output).
347//
348// Why not native `async fn in trait` (Rust 1.75+)?
349// Native dyn async trait does NOT propagate `+ Send` on generated futures. Tokio
350// requires futures spawned with `tokio::spawn` to be `Send`. Until Return Type
351// Notation (RFC 3425, tracking: github.com/rust-lang/rust/issues/109417) stabilises,
352// `async_trait` is the only ergonomic path to `dyn DatabaseAdapter + Send + Sync`.
353// Re-evaluate when Rust 1.90+ ships or when RTN is stabilised.
354//
355// MIGRATION TRACKING: async-trait → native async fn in trait
356//
357// Current status: BLOCKED on RFC 3425 (Return Type Notation)
358// See: https://github.com/rust-lang/rfcs/pull/3425
359//      https://github.com/rust-lang/rust/issues/109417
360//
361// Migration is safe when ALL of the following are true:
362// 1. RTN with `+ Send` bounds is stable on rustc (e.g. `fn foo() -> impl Future + Send`)
363// 2. FraiseQL MSRV is updated to that stabilising version
364// 3. tokio::spawn() works with native dyn async trait objects (futures must be Send)
365//
366// Scope when criteria are met: 68 files (grep -rn "#\[async_trait\]" crates/)
367// Effort: Medium (mostly mechanical — remove macro from impls, adjust trait defs)
368// dynosaur was evaluated and rejected: does not propagate + Send (incompatible with Tokio)
369#[async_trait]
370pub trait DatabaseAdapter: Send + Sync {
371    /// Execute a WHERE query against a view and return JSONB rows.
372    ///
373    /// # Arguments
374    ///
375    /// * `view` - View name (e.g., "v_user", "v_post")
376    /// * `where_clause` - Optional WHERE clause AST
377    /// * `limit` - Optional row limit (for pagination)
378    /// * `offset` - Optional row offset (for pagination)
379    /// * `security_context` - Optional security context for RLS and caching decisions
380    ///
381    /// # Returns
382    ///
383    /// Vec of JSONB values from the `data` column.
384    ///
385    /// # Errors
386    ///
387    /// Returns `FraiseQLError::Database` on query execution failure.
388    /// Returns `FraiseQLError::ConnectionPool` if connection pool is exhausted.
389    ///
390    /// # Example
391    ///
392    /// ```rust,no_run
393    /// # use fraiseql_db::DatabaseAdapter;
394    /// # async fn example(adapter: impl DatabaseAdapter) -> Result<(), Box<dyn std::error::Error>> {
395    /// // Simple query without WHERE clause
396    /// let all_users = adapter
397    ///     .execute_where_query("v_user", None, Some(10), Some(0), None)
398    ///     .await?;
399    /// # Ok(())
400    /// # }
401    /// ```
402    async fn execute_where_query(
403        &self,
404        view: &str,
405        where_clause: Option<&WhereClause>,
406        limit: Option<u32>,
407        offset: Option<u32>,
408        order_by: Option<&[OrderByClause]>,
409    ) -> Result<Vec<JsonbValue>>;
410
411    /// Execute a WHERE query with SQL field projection optimization.
412    ///
413    /// Projects only the requested fields at the database level, reducing network payload
414    /// and JSON deserialization overhead by **40-55%** based on production measurements.
415    ///
416    /// This is the primary query execution method for optimized GraphQL queries.
417    /// It automatically selects only the fields requested in the GraphQL query, avoiding
418    /// unnecessary network transfer and deserialization of unused fields.
419    ///
420    /// # Automatic Projection
421    ///
422    /// In most cases, you don't call this directly. The `Executor` automatically:
423    /// 1. Determines which fields the GraphQL query requests
424    /// 2. Generates a `SqlProjectionHint` using database-specific SQL
425    /// 3. Calls this method with the projection hint
426    ///
427    /// # Arguments
428    ///
429    /// * `view` - View name (e.g., "v_user", "v_post")
430    /// * `projection` - Optional SQL projection hint with field list
431    ///   - `Some(hint)`: Use projection to select only requested fields
432    ///   - `None`: Falls back to standard query (full JSONB column)
433    /// * `where_clause` - Optional WHERE clause AST for filtering
434    /// * `limit` - Optional row limit (for pagination)
435    ///
436    /// # Returns
437    ///
438    /// Vec of JSONB values, either:
439    /// - Full objects (when projection is None)
440    /// - Projected objects with only requested fields (when projection is Some)
441    ///
442    /// # Errors
443    ///
444    /// Returns `FraiseQLError::Database` on query execution failure, including:
445    /// - Connection pool exhaustion
446    /// - SQL execution errors
447    /// - Type mismatches
448    ///
449    /// # Performance Characteristics
450    ///
451    /// When projection is provided (recommended):
452    /// - **Latency**: 40-55% reduction vs full object fetch
453    /// - **Network**: 40-55% smaller payload (proportional to unused fields)
454    /// - **Throughput**: Maintains 250+ Kelem/s (elements per second)
455    /// - **Memory**: Proportional to projected fields only
456    ///
457    /// Improvement scales with:
458    /// - Percentage of unused fields (more unused = more improvement)
459    /// - Size of result set (larger sets benefit more)
460    /// - Network latency (network-bound queries benefit most)
461    ///
462    /// When projection is None:
463    /// - Behavior identical to `execute_where_query()`
464    /// - Returns full JSONB column
465    /// - Used for compatibility/debugging
466    ///
467    /// # Database Support
468    ///
469    /// | Database | Status | Implementation |
470    /// |----------|--------|-----------------|
471    /// | PostgreSQL | ✅ Optimized | `jsonb_build_object()` |
472    /// | MySQL | ⏳ Fallback | Server-side filtering (planned) |
473    /// | SQLite | ⏳ Fallback | Server-side filtering (planned) |
474    /// | SQL Server | ⏳ Fallback | Server-side filtering (planned) |
475    ///
476    /// # Example: Direct Usage (Advanced)
477    ///
478    /// ```no_run
479    /// // Requires: running PostgreSQL database and a DatabaseAdapter implementation.
480    /// use fraiseql_db::types::SqlProjectionHint;
481    /// use fraiseql_db::traits::DatabaseAdapter;
482    /// use fraiseql_db::DatabaseType;
483    ///
484    /// # async fn example(adapter: &impl DatabaseAdapter) -> Result<(), Box<dyn std::error::Error>> {
485    /// let projection = SqlProjectionHint::new(
486    ///     DatabaseType::PostgreSQL,
487    ///     "jsonb_build_object(\
488    ///         'id', data->>'id', \
489    ///         'name', data->>'name', \
490    ///         'email', data->>'email'\
491    ///     )".to_string(),
492    ///     75,
493    /// );
494    ///
495    /// let results = adapter
496    ///     .execute_with_projection("v_user", Some(&projection), None, Some(100), None, None)
497    ///     .await?;
498    ///
499    /// // results only contain id, name, email fields
500    /// // 75% smaller than fetching all fields
501    /// # Ok(())
502    /// # }
503    /// ```
504    ///
505    /// # Example: Fallback (No Projection)
506    ///
507    /// ```no_run
508    /// // Requires: running PostgreSQL database and a DatabaseAdapter implementation.
509    /// # use fraiseql_db::traits::DatabaseAdapter;
510    /// # async fn example(adapter: &impl DatabaseAdapter) -> Result<(), Box<dyn std::error::Error>> {
511    /// // For debugging or when projection not available
512    /// let results = adapter
513    ///     .execute_with_projection("v_user", None, None, Some(100), None, None)
514    ///     .await?;
515    ///
516    /// // Equivalent to execute_where_query() - returns full objects
517    /// # Ok(())
518    /// # }
519    /// ```
520    ///
521    /// # See Also
522    ///
523    /// - `execute_where_query()` - Standard query without projection
524    /// - `SqlProjectionHint` - Structure defining field projection
525    /// - [Projection Optimization Guide](https://docs.fraiseql.rs/performance/projection-optimization.md)
526    async fn execute_with_projection(
527        &self,
528        view: &str,
529        projection: Option<&SqlProjectionHint>,
530        where_clause: Option<&WhereClause>,
531        limit: Option<u32>,
532        offset: Option<u32>,
533        order_by: Option<&[OrderByClause]>,
534    ) -> Result<Vec<JsonbValue>>;
535
536    /// Like `execute_where_query` but returns the result wrapped in an `Arc`.
537    ///
538    /// The default implementation wraps the result of `execute_where_query` in a
539    /// fresh `Arc`. `CachedDatabaseAdapter` overrides this to return the cached `Arc`
540    /// directly — eliminating the full `Vec<JsonbValue>` clone that the non-`Arc`
541    /// path requires on every cache hit.
542    ///
543    /// Callers on the hot query path should prefer this variant and borrow from the
544    /// `Arc` via `&**arc` rather than taking ownership.
545    ///
546    /// # Errors
547    ///
548    /// Same errors as `execute_where_query`.
549    async fn execute_where_query_arc(
550        &self,
551        view: &str,
552        where_clause: Option<&WhereClause>,
553        limit: Option<u32>,
554        offset: Option<u32>,
555        order_by: Option<&[OrderByClause]>,
556    ) -> Result<Arc<Vec<JsonbValue>>> {
557        self.execute_where_query(view, where_clause, limit, offset, order_by)
558            .await
559            .map(Arc::new)
560    }
561
562    /// Like `execute_with_projection` but returns the result wrapped in an `Arc`.
563    ///
564    /// The default implementation wraps the result of `execute_with_projection` in a
565    /// fresh `Arc`. `CachedDatabaseAdapter` overrides this to return the cached `Arc`
566    /// directly — eliminating the full `Vec<JsonbValue>` clone that the non-`Arc`
567    /// path requires on every cache hit.
568    ///
569    /// Parameters are passed in a `ProjectionRequest` struct (F043) so adapters
570    /// and callers cannot misorder them.
571    ///
572    /// # Errors
573    ///
574    /// Same errors as `execute_with_projection`.
575    async fn execute_with_projection_arc(
576        &self,
577        request: &ProjectionRequest<'_>,
578    ) -> Result<Arc<Vec<JsonbValue>>> {
579        self.execute_with_projection(
580            request.view,
581            request.projection,
582            request.where_clause,
583            request.limit,
584            request.offset,
585            request.order_by,
586        )
587        .await
588        .map(Arc::new)
589    }
590
591    /// Get database type (for logging/metrics).
592    ///
593    /// Used to identify which database backend is in use.
594    fn database_type(&self) -> DatabaseType;
595
596    /// Health check - verify database connectivity.
597    ///
598    /// Executes a simple query (e.g., `SELECT 1`) to verify the database is reachable.
599    ///
600    /// # Errors
601    ///
602    /// Returns `FraiseQLError::Database` if health check fails.
603    async fn health_check(&self) -> Result<()>;
604
605    /// Get connection pool metrics.
606    ///
607    /// Returns current statistics about the connection pool:
608    /// - Total connections
609    /// - Idle connections
610    /// - Active connections
611    /// - Waiting requests
612    fn pool_metrics(&self) -> PoolMetrics;
613
614    /// Execute raw SQL query and return rows as JSON objects.
615    ///
616    /// Used for aggregation queries where we need full row data, not just JSONB column.
617    ///
618    /// # Security Warning
619    ///
620    /// This method executes arbitrary SQL. **NEVER** pass untrusted input directly to this method.
621    /// Always:
622    /// - Use parameterized queries with bound parameters
623    /// - Validate and sanitize SQL templates before execution
624    /// - Only execute SQL generated by the FraiseQL compiler
625    /// - Log SQL execution for audit trails
626    ///
627    /// # Arguments
628    ///
629    /// * `sql` - Raw SQL query to execute (must be safe/trusted)
630    ///
631    /// # Returns
632    ///
633    /// Vec of rows, where each row is a HashMap of column name to JSON value.
634    ///
635    /// # Errors
636    ///
637    /// Returns `FraiseQLError::Database` on query execution failure.
638    ///
639    /// # Example
640    ///
641    /// ```rust,no_run
642    /// # use fraiseql_db::DatabaseAdapter;
643    /// # async fn example(adapter: impl DatabaseAdapter) -> Result<(), Box<dyn std::error::Error>> {
644    /// // Safe: SQL generated by FraiseQL compiler
645    /// let sql = "SELECT category, SUM(revenue) as total FROM tf_sales GROUP BY category";
646    /// let rows = adapter.execute_raw_query(sql).await?;
647    /// for row in rows {
648    ///     println!("Category: {}, Total: {}", row["category"], row["total"]);
649    /// }
650    /// # Ok(())
651    /// # }
652    /// ```
653    async fn execute_raw_query(
654        &self,
655        sql: &str,
656    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>>;
657
658    /// Execute a row-shaped query against a view, returning typed column values.
659    ///
660    /// Used by the gRPC transport for protobuf encoding of query results.
661    /// The default implementation delegates to `execute_raw_query` and converts
662    /// JSON results to `ColumnValue` vectors.
663    ///
664    /// # Errors
665    ///
666    /// Returns `FraiseQLError::Database` if the adapter returns an error.
667    async fn execute_row_query(
668        &self,
669        view_name: &str,
670        columns: &[crate::types::ColumnSpec],
671        where_sql: Option<&str>,
672        order_by: Option<&str>,
673        limit: Option<u32>,
674        offset: Option<u32>,
675    ) -> Result<Vec<Vec<crate::types::ColumnValue>>> {
676        use crate::types::ColumnValue;
677
678        let mut sql = format!("SELECT * FROM \"{view_name}\"");
679        if let Some(w) = where_sql {
680            sql.push_str(" WHERE ");
681            sql.push_str(w);
682        }
683        if let Some(ob) = order_by {
684            sql.push_str(" ORDER BY ");
685            sql.push_str(ob);
686        }
687        if let Some(l) = limit {
688            use std::fmt::Write;
689            let _ = write!(sql, " LIMIT {l}");
690        }
691        if let Some(o) = offset {
692            use std::fmt::Write;
693            let _ = write!(sql, " OFFSET {o}");
694        }
695
696        let results = self.execute_raw_query(&sql).await?;
697
698        Ok(results
699            .iter()
700            .map(|row| {
701                columns
702                    .iter()
703                    .map(|col| {
704                        row.get(&col.name).map_or(ColumnValue::Null, |v| match v {
705                            serde_json::Value::Null => ColumnValue::Null,
706                            serde_json::Value::Bool(b) => ColumnValue::Boolean(*b),
707                            serde_json::Value::Number(n) => {
708                                if let Some(i) = n.as_i64() {
709                                    ColumnValue::Int64(i)
710                                } else if let Some(f) = n.as_f64() {
711                                    ColumnValue::Float64(f)
712                                } else {
713                                    ColumnValue::Text(n.to_string())
714                                }
715                            },
716                            serde_json::Value::String(s) => ColumnValue::Text(s.clone()),
717                            other => ColumnValue::Json(other.to_string()),
718                        })
719                    })
720                    .collect()
721            })
722            .collect())
723    }
724
725    /// Execute a parameterized aggregate SQL query (GROUP BY / HAVING / window).
726    ///
727    /// `sql` contains `$N` (PostgreSQL), `?` (MySQL / SQLite), or `@P1` (SQL Server)
728    /// placeholders for string and array values; numeric and NULL values may be inlined.
729    /// `params` are the corresponding values in placeholder order.
730    ///
731    /// Unlike `execute_raw_query`, this method accepts bind parameters so that
732    /// user-supplied filter values never appear as string literals in the SQL text,
733    /// eliminating the injection risk that `escape_sql_string` mitigated previously.
734    ///
735    /// # Arguments
736    ///
737    /// * `sql` - SQL with placeholders generated by
738    ///   `AggregationSqlGenerator::generate_parameterized`
739    /// * `params` - Bind parameters in placeholder order
740    ///
741    /// # Returns
742    ///
743    /// Vec of rows, where each row is a `HashMap` of column name to JSON value.
744    ///
745    /// # Errors
746    ///
747    /// Returns `FraiseQLError::Database` on execution failure.
748    /// Returns `FraiseQLError::Database` on adapters that do not support raw SQL
749    /// (e.g., `FraiseWireAdapter`).
750    async fn execute_parameterized_aggregate(
751        &self,
752        sql: &str,
753        params: &[serde_json::Value],
754    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>>;
755
756    /// Connection-affine variant of
757    /// [`execute_parameterized_aggregate`](Self::execute_parameterized_aggregate).
758    ///
759    /// Applies `session_vars` transaction-locally on the same connection that
760    /// runs the aggregate, so aggregate views backed by `current_setting()` RLS
761    /// observe the configured values (fixes #329 for the aggregate path). See
762    /// [`execute_function_call_with_session`](Self::execute_function_call_with_session)
763    /// for the non-PostgreSQL default behaviour.
764    ///
765    /// # Errors
766    ///
767    /// Same errors as [`execute_parameterized_aggregate`](Self::execute_parameterized_aggregate);
768    /// additionally returns `FraiseQLError::Database` if `set_config` fails.
769    async fn execute_parameterized_aggregate_with_session(
770        &self,
771        sql: &str,
772        params: &[serde_json::Value],
773        _session_vars: &[(&str, &str)],
774    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
775        self.execute_parameterized_aggregate(sql, params).await
776    }
777
778    /// Execute a database function call and return all columns as rows.
779    ///
780    /// Builds `SELECT * FROM {function_name}($1, $2, ...)` with one positional placeholder per
781    /// argument, executes it with the provided JSON values, and returns each result row as a
782    /// `HashMap<column_name, json_value>`.
783    ///
784    /// Used by the mutation execution pathway to call stored procedures that return the
785    /// `app.mutation_response` composite type
786    /// `(status, message, entity_id, entity_type, entity jsonb, updated_fields text[],
787    ///   cascade jsonb, metadata jsonb)`.
788    ///
789    /// # Arguments
790    ///
791    /// * `function_name` - Fully-qualified function name (e.g. `fn_create_machine`)
792    /// * `args` - Positional JSON arguments passed as `$1, $2, …` bind parameters
793    ///
794    /// # Errors
795    ///
796    /// Returns `FraiseQLError::Database` on query execution failure.
797    /// Returns `FraiseQLError::Unsupported` on adapters that do not support mutations
798    /// (default implementation — see [`SupportsMutations`]).
799    async fn execute_function_call(
800        &self,
801        function_name: &str,
802        _args: &[serde_json::Value],
803    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
804        Err(FraiseQLError::Unsupported {
805            message: format!(
806                "Mutations via function calls are not supported by this adapter. \
807                 Function '{function_name}' cannot be executed. \
808                 Use PostgreSQL, MySQL, or SQL Server for mutation support."
809            ),
810        })
811    }
812
813    /// Returns `true` if this adapter supports GraphQL mutation operations.
814    ///
815    /// **This is the authoritative mutation gate.** The executor checks this method
816    /// before dispatching any mutation. Adapters that return `false` will cause
817    /// mutations to fail with a clear `FraiseQLError::Validation` diagnostic instead
818    /// of silently calling the unsupported `execute_function_call` default.
819    ///
820    /// Override to return `false` for read-only adapters (e.g., `SqliteAdapter`,
821    /// `FraiseWireAdapter`). The compile-time [`SupportsMutations`] marker trait
822    /// complements this runtime check — see its documentation for the distinction.
823    ///
824    /// # Default
825    ///
826    /// Returns `true`. All adapters are assumed mutation-capable unless they override
827    /// this method.
828    fn supports_mutations(&self) -> bool {
829        true
830    }
831
832    /// Bump fact table version counters after a successful mutation.
833    ///
834    /// Called by the executor when a mutation definition declares
835    /// `invalidates_fact_tables`. For each listed table the version counter is
836    /// incremented so that subsequent aggregation queries miss the cache and
837    /// re-fetch fresh data.
838    ///
839    /// The default implementation is a **no-op**: adapters that are not cache-
840    /// aware (e.g. `PostgresAdapter`, `SqliteAdapter`) simply return `Ok(())`.
841    /// `CachedDatabaseAdapter` overrides this to call `bump_tf_version($1)` for
842    /// every `FactTableVersionStrategy::VersionTable` table and update the
843    /// in-process version cache.
844    ///
845    /// # Arguments
846    ///
847    /// * `tables` - Fact table names declared by the mutation (validated SQL identifiers; originate
848    ///   from `MutationDefinition.invalidates_fact_tables`)
849    ///
850    /// # Errors
851    ///
852    /// Returns `FraiseQLError::Database` if the version-bump SQL function fails.
853    async fn bump_fact_table_versions(&self, _tables: &[String]) -> Result<()> {
854        Ok(())
855    }
856
857    /// Invalidate cached query results for the specified views.
858    ///
859    /// Called by the executor after a mutation succeeds, so that stale cache
860    /// entries reading from modified views are evicted. The default
861    /// implementation is a no-op; `CachedDatabaseAdapter` overrides this.
862    ///
863    /// View names are passed as `&[ViewName]` so the wrapper's `Arc<str>`
864    /// backing is preserved across the call. Callers that hold a `String`
865    /// can convert in place with `ViewName::from(...)`.
866    ///
867    /// # Returns
868    ///
869    /// The number of cache entries evicted.
870    async fn invalidate_views(&self, _views: &[crate::ViewName]) -> Result<u64> {
871        Ok(0)
872    }
873
874    /// Evict cache entries that contain the given entity UUID.
875    ///
876    /// Called by the executor after a successful UPDATE or DELETE mutation when
877    /// the `mutation_response` includes an `entity_id`. Only cache entries whose
878    /// entity-ID index contains the given UUID are removed; unrelated entries
879    /// remain warm.
880    ///
881    /// The default implementation is a no-op. `CachedDatabaseAdapter` overrides
882    /// this to perform the selective eviction.
883    ///
884    /// # Returns
885    ///
886    /// The number of cache entries evicted.
887    async fn invalidate_by_entity(&self, _entity_type: &str, _entity_id: &str) -> Result<u64> {
888        Ok(0)
889    }
890
891    /// Evict only list (multi-row) cache entries for the given views.
892    ///
893    /// Called by the executor after a successful CREATE mutation. Unlike
894    /// `invalidate_views()`, this preserves single-entity point-lookup entries
895    /// that are unaffected by the newly created entity.
896    ///
897    /// The default implementation delegates to `invalidate_views()` (safe
898    /// fallback for adapters without a `list_index`).  `CachedDatabaseAdapter`
899    /// overrides this to use the dedicated `list_index` for precise eviction.
900    ///
901    /// # Returns
902    ///
903    /// The number of cache entries evicted.
904    async fn invalidate_list_queries(&self, views: &[crate::ViewName]) -> Result<u64> {
905        self.invalidate_views(views).await
906    }
907
908    /// Get database capabilities.
909    ///
910    /// Returns information about what features this database supports,
911    /// including collation strategies and limitations.
912    ///
913    /// # Returns
914    ///
915    /// `DatabaseCapabilities` describing supported features.
916    fn capabilities(&self) -> DatabaseCapabilities {
917        DatabaseCapabilities::from_database_type(self.database_type())
918    }
919
920    /// Run the database's `EXPLAIN` on a SQL statement without executing it.
921    ///
922    /// Returns a JSON representation of the query plan. The format is
923    /// database-specific (e.g. PostgreSQL returns JSON, SQLite returns rows).
924    ///
925    /// The default implementation returns `Unsupported`.
926    async fn explain_query(
927        &self,
928        _sql: &str,
929        _params: &[serde_json::Value],
930    ) -> Result<serde_json::Value> {
931        Err(fraiseql_error::FraiseQLError::Unsupported {
932            message: "EXPLAIN not available for this database adapter".to_string(),
933        })
934    }
935
936    /// Run `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)` against a view with the
937    /// same parameterized WHERE clause that `execute_where_query` would use.
938    ///
939    /// Unlike `explain_query`, this method uses **real bound parameters** and
940    /// **actually executes the query** (ANALYZE mode), so the plan reflects
941    /// PostgreSQL's runtime statistics for the given filter values.
942    ///
943    /// Only PostgreSQL supports this; other adapters return
944    /// `FraiseQLError::Unsupported` by default.
945    ///
946    /// # Arguments
947    ///
948    /// * `view` - View name (e.g., "v_user")
949    /// * `where_clause` - Optional filter (same as `execute_where_query`)
950    /// * `limit` - Optional row limit
951    /// * `offset` - Optional row offset
952    ///
953    /// # Errors
954    ///
955    /// Returns `FraiseQLError::Database` on execution failure.
956    /// Returns `FraiseQLError::Unsupported` for non-PostgreSQL adapters.
957    async fn explain_where_query(
958        &self,
959        _view: &str,
960        _where_clause: Option<&WhereClause>,
961        _limit: Option<u32>,
962        _offset: Option<u32>,
963    ) -> Result<serde_json::Value> {
964        Err(fraiseql_error::FraiseQLError::Unsupported {
965            message: "EXPLAIN ANALYZE is not available for this database adapter. \
966                      Only PostgreSQL supports explain_where_query."
967                .to_string(),
968        })
969    }
970
971    /// Returns the mutation strategy used by this adapter.
972    ///
973    /// The default is `FunctionCall` (stored procedures). Adapters that generate
974    /// direct SQL (e.g., SQLite) override this to return `DirectSql`.
975    fn mutation_strategy(&self) -> MutationStrategy {
976        MutationStrategy::FunctionCall
977    }
978
979    /// Execute a database function call after pinning session variables on the
980    /// **same connection** within the **same transaction** as the call.
981    ///
982    /// This is the connection-affine variant of
983    /// [`execute_function_call`](Self::execute_function_call): the `set_config(..., true)`
984    /// calls and the `SELECT * FROM fn(...)` call share one pooled connection inside one
985    /// transaction, so transaction-local GUCs are visible to the function body (fixes #329).
986    ///
987    /// Adapters that do not support session variables (MySQL, SQLite, SQL
988    /// Server, mocks) inherit the default implementation, which silently drops
989    /// `session_vars` and delegates to [`execute_function_call`](Self::execute_function_call) —
990    /// safe, because those backends never applied session variables in the first
991    /// place.
992    ///
993    /// # Arguments
994    ///
995    /// * `function_name` - Fully-qualified function name
996    /// * `args` - Positional JSON arguments passed as `$1, $2, …`
997    /// * `session_vars` - `(setting_name, value)` pairs applied with `SELECT set_config(name,
998    ///   value, true)` before the function call. Pass `&[]` when no session variables are
999    ///   configured.
1000    ///
1001    /// # Errors
1002    ///
1003    /// Same as [`execute_function_call`](Self::execute_function_call); additionally returns
1004    /// `FraiseQLError::Database` if `set_config` fails on any pair.
1005    async fn execute_function_call_with_session(
1006        &self,
1007        function_name: &str,
1008        args: &[serde_json::Value],
1009        _session_vars: &[(&str, &str)],
1010    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
1011        // Default: ignore session_vars and delegate. Safe for non-PostgreSQL
1012        // adapters, which never applied session variables in the first place.
1013        self.execute_function_call(function_name, args).await
1014    }
1015
1016    /// Connection-affine variant of
1017    /// [`execute_function_call_with_session`](Self::execute_function_call_with_session)
1018    /// that **also writes one `core.tb_entity_change_log` row in the same
1019    /// transaction** as the mutation function — the Change Spine transactional
1020    /// outbox.
1021    ///
1022    /// When `changelog` is `Some`, the framework owns the change-log write: a
1023    /// single statement runs the function and INSERTs the outbox row atomically
1024    /// on the same connection, so `fraiseql.started_at` (set txn-locally for the
1025    /// `duration_ms` computation) is visible and a crash leaves neither the
1026    /// mutation nor the log row. The changed-entity columns are read from the
1027    /// function's own `app.mutation_response` row; only the DML verb and a
1028    /// NOT-NULL `object_type` fallback are threaded in via [`ChangeLogWrite`].
1029    /// The row is written only for an effective change (`succeeded` AND
1030    /// `state_changed`).
1031    ///
1032    /// When `changelog` is `None`, behaviour is identical to
1033    /// [`execute_function_call_with_session`](Self::execute_function_call_with_session).
1034    ///
1035    /// PostgreSQL, MySQL, and SQL Server each override this with a real in-txn
1036    /// write. PostgreSQL runs one `MATERIALIZED` CTE that calls the function and
1037    /// INSERTs the outbox row atomically; MySQL and SQL Server cannot reference a
1038    /// `CALL`/`EXEC` result set in a following `INSERT … SELECT`, so they open a
1039    /// transaction, parse the `app.mutation_response` row in Rust, and INSERT the
1040    /// outbox row (via [`crate::changelog::build_changelog_insert_sql`]) on the same
1041    /// connection before commit. On those two dialects `duration_ms` / `started_at`
1042    /// are legitimately NULL (no request-scoped DB clock).
1043    ///
1044    /// SQLite (read-only) and mocks inherit the default below, which drops
1045    /// `changelog` and delegates — so those mutations still run, they just write no
1046    /// outbox row.
1047    ///
1048    /// # Errors
1049    ///
1050    /// Same as
1051    /// [`execute_function_call_with_session`](Self::execute_function_call_with_session);
1052    /// additionally returns `FraiseQLError::Database` if the outbox INSERT fails
1053    /// (e.g. the contract migration has not been applied).
1054    async fn execute_function_call_with_changelog(
1055        &self,
1056        function_name: &str,
1057        args: &[serde_json::Value],
1058        session_vars: &[(&str, &str)],
1059        _changelog: Option<&ChangeLogWrite<'_>>,
1060    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
1061        // Default: ignore the change-log write and delegate. SQLite (read-only) and
1062        // mocks keep this no-op; PostgreSQL / MySQL / SQL Server override it.
1063        self.execute_function_call_with_session(function_name, args, session_vars).await
1064    }
1065
1066    /// Connection-affine variant of [`execute_where_query_arc`](Self::execute_where_query_arc).
1067    ///
1068    /// Applies `session_vars` transaction-locally on the same connection that
1069    /// runs the read, so PostgreSQL Row-Level-Security policies backed by
1070    /// `current_setting()` see the configured values (fixes #329). See
1071    /// [`execute_function_call_with_session`](Self::execute_function_call_with_session) for the
1072    /// rationale and the non-PostgreSQL default behaviour.
1073    ///
1074    /// # Errors
1075    ///
1076    /// Same errors as [`execute_where_query_arc`](Self::execute_where_query_arc); additionally
1077    /// returns `FraiseQLError::Database` if `set_config` fails on any pair.
1078    async fn execute_where_query_arc_with_session(
1079        &self,
1080        view: &str,
1081        where_clause: Option<&WhereClause>,
1082        limit: Option<u32>,
1083        offset: Option<u32>,
1084        order_by: Option<&[OrderByClause]>,
1085        _session_vars: &[(&str, &str)],
1086    ) -> Result<Arc<Vec<JsonbValue>>> {
1087        self.execute_where_query_arc(view, where_clause, limit, offset, order_by).await
1088    }
1089
1090    /// Connection-affine variant of
1091    /// [`execute_with_projection_arc`](Self::execute_with_projection_arc).
1092    ///
1093    /// See [`execute_where_query_arc_with_session`](Self::execute_where_query_arc_with_session) for
1094    /// the rationale.
1095    ///
1096    /// # Errors
1097    ///
1098    /// Same errors as [`execute_with_projection_arc`](Self::execute_with_projection_arc);
1099    /// additionally returns `FraiseQLError::Database` if `set_config` fails on any pair.
1100    async fn execute_with_projection_arc_with_session(
1101        &self,
1102        request: &ProjectionRequest<'_>,
1103        _session_vars: &[(&str, &str)],
1104    ) -> Result<Arc<Vec<JsonbValue>>> {
1105        self.execute_with_projection_arc(request).await
1106    }
1107
1108    /// Execute a direct SQL mutation (INSERT/UPDATE/DELETE) and return the
1109    /// mutation response rows as JSON objects.
1110    ///
1111    /// Only adapters using `MutationStrategy::DirectSql` need to override this.
1112    /// The default implementation returns `Unsupported`.
1113    ///
1114    /// # Errors
1115    ///
1116    /// Returns `FraiseQLError::Unsupported` by default.
1117    /// Returns `FraiseQLError::Database` on SQL execution failure.
1118    /// Returns `FraiseQLError::Validation` on invalid mutation parameters.
1119    async fn execute_direct_mutation(
1120        &self,
1121        _ctx: &DirectMutationContext<'_>,
1122    ) -> Result<Vec<serde_json::Value>> {
1123        Err(FraiseQLError::Unsupported {
1124            message: "Direct SQL mutations are not supported by this adapter. \
1125                      Use execute_function_call for stored-procedure mutations."
1126                .to_string(),
1127        })
1128    }
1129
1130    /// Retrieve query performance statistics from the database.
1131    ///
1132    /// Returns the top-N queries ordered by total execution time (descending).
1133    /// The exact data source depends on the backend:
1134    /// - PostgreSQL: `pg_stat_statements` (requires extension)
1135    /// - MySQL: `performance_schema.events_statements_summary_by_digest`
1136    /// - SQL Server: `sys.dm_exec_query_stats`
1137    /// - SQLite / Wire: empty (no stats available)
1138    ///
1139    /// # Arguments
1140    ///
1141    /// * `limit` - Maximum number of entries to return.
1142    ///
1143    /// # Errors
1144    ///
1145    /// Returns `FraiseQLError::Database` if the stats query fails.
1146    async fn query_stats(&self, _limit: u32) -> Result<Vec<crate::types::QueryStatEntry>> {
1147        Ok(vec![])
1148    }
1149
1150    /// Retrieve statistics for a single query by its ID.
1151    ///
1152    /// The default implementation fetches up to 1000 entries via
1153    /// [`query_stats`](Self::query_stats) and filters client-side.
1154    /// Backends with efficient single-query lookup (PostgreSQL, SQL Server)
1155    /// should override with a `WHERE` clause.
1156    ///
1157    /// # Errors
1158    ///
1159    /// Returns `FraiseQLError::Database` if the underlying query fails.
1160    async fn query_stats_by_id(&self, id: &str) -> Result<Option<crate::types::QueryStatEntry>> {
1161        let stats = self.query_stats(1000).await?;
1162        Ok(stats.into_iter().find(|e| e.query_id == id))
1163    }
1164
1165    /// Reset query performance statistics.
1166    ///
1167    /// Only PostgreSQL supports this (via `pg_stat_statements_reset()`).
1168    /// All other adapters return `Unsupported`.
1169    ///
1170    /// # Errors
1171    ///
1172    /// Returns `FraiseQLError::Unsupported` for adapters that cannot reset stats.
1173    /// Returns `FraiseQLError::Database` if the reset command fails.
1174    async fn reset_query_stats(&self) -> Result<()> {
1175        Err(FraiseQLError::Unsupported {
1176            message: "Query stats reset is not supported by this database adapter".to_string(),
1177        })
1178    }
1179
1180    /// Notify the adapter that the schema has changed.
1181    ///
1182    /// Called during hot-reload after the new schema has been validated.
1183    /// Adapters that maintain schema-dependent state (e.g. cache keyed by schema
1184    /// version) should clear or rebuild that state here.
1185    ///
1186    /// The default implementation is a no-op.
1187    fn on_schema_reload(&self) {}
1188}
1189
1190#[cfg(test)]
1191mod tests;