fraiseql_core/runtime/executor/core.rs
1//! `Executor<A>` struct definition, constructors, and basic accessors.
2
3use std::{collections::HashMap, sync::Arc};
4
5use super::relay::{RelayDispatch, RelayDispatchImpl};
6use crate::{
7 db::{RelayDatabaseAdapter, traits::DatabaseAdapter, types::PoolMetrics},
8 runtime::{QueryMatcher, QueryPlanner, RuntimeConfig},
9 schema::{CompiledSchema, IntrospectionResponses},
10};
11
12/// Query executor - executes compiled GraphQL queries.
13///
14/// This is the main entry point for runtime query execution.
15/// It coordinates matching, planning, execution, and projection.
16///
17/// # Type Parameters
18///
19/// * `A` - The database adapter type (implements `DatabaseAdapter` trait)
20///
21/// # Ownership and Lifetimes
22///
23/// The executor holds owned references to schema and runtime data, with no borrowed pointers:
24/// - `schema`: Owned `CompiledSchema` (immutable after construction)
25/// - `adapter`: Shared via `Arc<A>` to allow multiple executors/tasks to use the same connection
26/// pool
27/// - `introspection`: Owned cached GraphQL schema responses
28/// - `config`: Owned runtime configuration
29///
30/// **No explicit lifetimes required** - all data is either owned or wrapped in `Arc`,
31/// so the executor can be stored in long-lived structures without lifetime annotations or
32/// borrow-checker issues.
33///
34/// # Concurrency
35///
36/// `Executor<A>` is `Send + Sync` when `A` is `Send + Sync`. It can be safely shared across
37/// threads and tasks without cloning:
38/// ```no_run
39/// // Requires: a live database adapter.
40/// // See: tests/integration/ for runnable examples.
41/// # use std::sync::Arc;
42/// // let executor = Arc::new(Executor::new(schema, adapter));
43/// // Can be cloned into multiple tasks
44/// // let exec_clone = executor.clone();
45/// // tokio::spawn(async move {
46/// // let result = exec_clone.execute(query, vars).await;
47/// // });
48/// ```
49///
50/// # Query Timeout
51///
52/// Queries are protected by the `query_timeout_ms` configuration in `RuntimeConfig` (default: 30s).
53/// When a query exceeds this timeout, it returns `FraiseQLError::Timeout` without panicking.
54/// Set `query_timeout_ms` to 0 to disable timeout enforcement.
55pub struct Executor<A: DatabaseAdapter> {
56 /// Compiled schema with optimized SQL templates
57 pub(super) schema: CompiledSchema,
58
59 /// Shared database adapter for query execution
60 /// Wrapped in Arc to allow multiple executors to use the same connection pool
61 pub(super) adapter: Arc<A>,
62
63 /// Type-erased relay capability slot.
64 ///
65 /// `Some` when the executor was constructed via `new_with_relay` (requires
66 /// `A: RelayDatabaseAdapter`). `None` causes relay queries to return a
67 /// `FraiseQLError::Validation` — no `unreachable!()`, no capability flag.
68 pub(super) relay: Option<Arc<dyn RelayDispatch>>,
69
70 /// Query matching engine (stateless)
71 pub(super) matcher: QueryMatcher,
72
73 /// Query execution planner (stateless)
74 pub(super) planner: QueryPlanner,
75
76 /// Runtime configuration (timeouts, complexity limits, etc.)
77 pub(super) config: RuntimeConfig,
78
79 /// Pre-built introspection responses cached for `__schema` and `__type` queries
80 /// Avoids recomputing schema introspection on every request
81 pub(super) introspection: IntrospectionResponses,
82
83 /// O(1) lookup index for Relay `node(id)` queries: maps `return_type → sql_source`.
84 ///
85 /// Built once at `Executor::with_config()` from the compiled schema, so every
86 /// `execute_node_query()` call is a single `HashMap::get()` rather than an O(N)
87 /// linear scan over `schema.queries`.
88 pub(super) node_type_index: HashMap<String, Arc<str>>,
89}
90
91impl<A: DatabaseAdapter> Executor<A> {
92 /// Create new executor.
93 ///
94 /// # Arguments
95 ///
96 /// * `schema` - Compiled schema
97 /// * `adapter` - Database adapter
98 ///
99 /// # Example
100 ///
101 /// ```no_run
102 /// // Requires: a live PostgreSQL database.
103 /// // See: tests/integration/ for runnable examples.
104 /// # use fraiseql_core::schema::CompiledSchema;
105 /// # use fraiseql_core::db::postgres::PostgresAdapter;
106 /// # use fraiseql_core::runtime::Executor;
107 /// # use std::sync::Arc;
108 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
109 /// # let schema_json = r#"{"types":[],"queries":[]}"#;
110 /// # let connection_string = "postgresql://localhost/mydb";
111 /// let schema = CompiledSchema::from_json(schema_json)?;
112 /// let adapter = PostgresAdapter::new(connection_string).await?;
113 /// let executor = Executor::new(schema, Arc::new(adapter));
114 /// # Ok(()) }
115 /// ```
116 #[must_use]
117 pub fn new(schema: CompiledSchema, adapter: Arc<A>) -> Self {
118 Self::with_config(schema, adapter, RuntimeConfig::default())
119 }
120
121 /// Create new executor with custom configuration.
122 ///
123 /// # Arguments
124 ///
125 /// * `schema` - Compiled schema
126 /// * `adapter` - Database adapter
127 /// * `config` - Runtime configuration
128 #[must_use]
129 pub fn with_config(schema: CompiledSchema, adapter: Arc<A>, config: RuntimeConfig) -> Self {
130 let matcher = QueryMatcher::new(schema.clone());
131 let planner = QueryPlanner::new(config.cache_query_plans);
132 // Build introspection responses at startup (zero-cost at runtime)
133 let introspection = IntrospectionResponses::build(&schema);
134
135 // Build O(1) node-type index: return_type → sql_source.
136 // The first query with a matching return_type and a non-None sql_source wins
137 // (consistent with the previous linear-scan behaviour).
138 let mut node_type_index: HashMap<String, Arc<str>> = HashMap::new();
139 for q in &schema.queries {
140 if let Some(src) = q.sql_source.as_deref() {
141 node_type_index.entry(q.return_type.clone()).or_insert_with(|| Arc::from(src));
142 }
143 }
144
145 Self {
146 schema,
147 adapter,
148 relay: None,
149 matcher,
150 planner,
151 config,
152 introspection,
153 node_type_index,
154 }
155 }
156
157 /// Return current connection pool metrics from the underlying database adapter.
158 ///
159 /// Values are sampled live on each call — not cached — so callers (e.g., the
160 /// `/metrics` endpoint) always observe up-to-date pool health.
161 pub fn pool_metrics(&self) -> PoolMetrics {
162 self.adapter.pool_metrics()
163 }
164
165 /// Get the compiled schema.
166 #[must_use]
167 pub const fn schema(&self) -> &CompiledSchema {
168 &self.schema
169 }
170
171 /// Get runtime configuration.
172 #[must_use]
173 pub const fn config(&self) -> &RuntimeConfig {
174 &self.config
175 }
176
177 /// Get database adapter reference.
178 #[must_use]
179 pub const fn adapter(&self) -> &Arc<A> {
180 &self.adapter
181 }
182}
183
184impl<A: DatabaseAdapter + RelayDatabaseAdapter + 'static> Executor<A> {
185 /// Create a new executor with relay cursor pagination enabled.
186 ///
187 /// Only callable when `A: RelayDatabaseAdapter`. The relay capability is
188 /// encoded once at construction time as a type-erased `Arc<dyn RelayDispatch>`,
189 /// so there is no per-query overhead beyond an `Option::is_some()` check.
190 ///
191 /// # Example
192 ///
193 /// ```no_run
194 /// // Requires: a live PostgreSQL database with relay support.
195 /// // See: tests/integration/ for runnable examples.
196 /// # use fraiseql_core::schema::CompiledSchema;
197 /// # use fraiseql_core::db::postgres::PostgresAdapter;
198 /// # use fraiseql_core::runtime::Executor;
199 /// # use std::sync::Arc;
200 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
201 /// # let connection_string = "postgresql://localhost/mydb";
202 /// # let schema: CompiledSchema = panic!("example");
203 /// let adapter = PostgresAdapter::new(connection_string).await?;
204 /// let executor = Executor::new_with_relay(schema, Arc::new(adapter));
205 /// # Ok(()) }
206 /// ```
207 #[must_use]
208 pub fn new_with_relay(schema: CompiledSchema, adapter: Arc<A>) -> Self {
209 Self::with_config_and_relay(schema, adapter, RuntimeConfig::default())
210 }
211
212 /// Create a new executor with relay support and custom configuration.
213 #[must_use]
214 pub fn with_config_and_relay(
215 schema: CompiledSchema,
216 adapter: Arc<A>,
217 config: RuntimeConfig,
218 ) -> Self {
219 let relay: Arc<dyn RelayDispatch> = Arc::new(RelayDispatchImpl(adapter.clone()));
220 let matcher = QueryMatcher::new(schema.clone());
221 let planner = QueryPlanner::new(config.cache_query_plans);
222 let introspection = IntrospectionResponses::build(&schema);
223
224 let mut node_type_index: HashMap<String, Arc<str>> = HashMap::new();
225 for q in &schema.queries {
226 if let Some(src) = q.sql_source.as_deref() {
227 node_type_index.entry(q.return_type.clone()).or_insert_with(|| Arc::from(src));
228 }
229 }
230
231 Self {
232 schema,
233 adapter,
234 relay: Some(relay),
235 matcher,
236 planner,
237 config,
238 introspection,
239 node_type_index,
240 }
241 }
242}