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