fraiseql_core/runtime/executor/core.rs
1//! `Executor<A>` struct definition, constructors, and basic accessors.
2
3use std::{collections::HashMap, sync::Arc};
4
5use moka::sync::Cache as MokaCache;
6
7use super::{
8 context::ExecutorContext,
9 runners,
10 support::relay::{RelayDispatch, RelayDispatchImpl},
11};
12use crate::{
13 db::{RelayDatabaseAdapter, traits::DatabaseAdapter, types::PoolMetrics},
14 error::Result,
15 runtime::{QueryMatcher, QueryPlanner, RuntimeConfig, matcher::QueryMatch},
16 schema::{CompiledSchema, IntrospectionResponses},
17 security::SecurityContext,
18};
19
20/// Maximum number of distinct query strings whose parsed ASTs are cached in memory.
21///
22/// 1 024 entries covers the full distinct-query vocabulary of any realistic workload.
23/// Each entry holds an `Arc<(QueryType, Option<ParsedQuery>)>` — the AST is shared,
24/// not duplicated.
25const PARSE_CACHE_CAPACITY: u64 = 1_024;
26
27/// Query executor - executes compiled GraphQL queries.
28///
29/// This is the main entry point for runtime query execution.
30/// It coordinates matching, planning, execution, and projection.
31///
32/// # Type Parameters
33///
34/// * `A` - The database adapter type (implements `DatabaseAdapter` trait)
35///
36/// # Ownership and Lifetimes
37///
38/// The executor holds owned references to schema and runtime data, with no borrowed pointers:
39/// - `schema`: Owned `CompiledSchema` (immutable after construction)
40/// - `adapter`: Shared via `Arc<A>` to allow multiple executors/tasks to use the same connection
41/// pool
42/// - `introspection`: Owned cached GraphQL schema responses
43/// - `config`: Owned runtime configuration
44///
45/// **No explicit lifetimes required** - all data is either owned or wrapped in `Arc`,
46/// so the executor can be stored in long-lived structures without lifetime annotations or
47/// borrow-checker issues.
48///
49/// # Concurrency
50///
51/// `Executor<A>` is `Send + Sync` when `A` is `Send + Sync`. It can be safely shared across
52/// threads and tasks without cloning:
53/// ```no_run
54/// // Requires: a live database adapter.
55/// // See: tests/integration/ for runnable examples.
56/// # use std::sync::Arc;
57/// // let executor = Arc::new(Executor::new(schema, adapter));
58/// // Can be cloned into multiple tasks
59/// // let exec_clone = executor.clone();
60/// // tokio::spawn(async move {
61/// // let result = exec_clone.execute(query, vars).await;
62/// // });
63/// ```
64///
65/// # Query Timeout
66///
67/// Queries are protected by the `query_timeout_ms` configuration in `RuntimeConfig` (default: 30s).
68/// When a query exceeds this timeout, it returns `FraiseQLError::Timeout` without panicking.
69/// Set `query_timeout_ms` to 0 to disable timeout enforcement.
70pub struct Executor<A: DatabaseAdapter> {
71 /// All shared state — schema, adapter, config, caches, relay.
72 pub(super) ctx: Arc<ExecutorContext<A>>,
73}
74
75impl<A: DatabaseAdapter> Executor<A> {
76 /// Create new executor.
77 ///
78 /// # Arguments
79 ///
80 /// * `schema` - Compiled schema
81 /// * `adapter` - Database adapter
82 ///
83 /// # Example
84 ///
85 /// ```no_run
86 /// // Requires: a live PostgreSQL database.
87 /// // See: tests/integration/ for runnable examples.
88 /// # use fraiseql_core::schema::CompiledSchema;
89 /// # use fraiseql_core::db::postgres::PostgresAdapter;
90 /// # use fraiseql_core::runtime::Executor;
91 /// # use std::sync::Arc;
92 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
93 /// # let schema_json = r#"{"types":[],"queries":[]}"#;
94 /// # let connection_string = "postgresql://localhost/mydb";
95 /// let schema = CompiledSchema::from_json(schema_json, false)?;
96 /// let adapter = PostgresAdapter::new(connection_string).await?;
97 /// let executor = Executor::new(schema, Arc::new(adapter));
98 /// # Ok(()) }
99 /// ```
100 #[must_use]
101 pub fn new(schema: CompiledSchema, adapter: Arc<A>) -> Self {
102 Self::with_config(schema, adapter, RuntimeConfig::default())
103 }
104
105 /// Create new executor with custom configuration.
106 ///
107 /// # Arguments
108 ///
109 /// * `schema` - Compiled schema
110 /// * `adapter` - Database adapter
111 /// * `config` - Runtime configuration
112 #[must_use]
113 pub fn with_config(schema: CompiledSchema, adapter: Arc<A>, config: RuntimeConfig) -> Self {
114 let matcher = QueryMatcher::new(schema.clone());
115 let planner = QueryPlanner::new(config.cache_query_plans);
116 // Build introspection responses at startup (zero-cost at runtime)
117 // `mut` is required by the `#[cfg(feature = "federation")]` block below.
118 #[cfg_attr(not(feature = "federation"), allow(unused_mut))]
119 let mut introspection = IntrospectionResponses::build(&schema);
120
121 // Filter @inaccessible fields from introspection (DX defence-in-depth).
122 // Does NOT affect data responses or _entities — only __type/__schema.
123 #[cfg(feature = "federation")]
124 if let Some(fed_meta) = schema.federation_metadata() {
125 let inaccessible: HashMap<String, Vec<String>> = fed_meta
126 .types
127 .iter()
128 .filter(|t| !t.inaccessible_fields.is_empty())
129 .map(|t| (t.name.clone(), t.inaccessible_fields.clone()))
130 .collect();
131 introspection.filter_inaccessible(&inaccessible);
132 }
133
134 // Build O(1) node-type index: return_type → sql_source.
135 // The first query with a matching return_type and a non-None sql_source wins
136 // (consistent with the previous linear-scan behaviour).
137 let mut node_type_index: HashMap<String, Arc<str>> = HashMap::new();
138 for q in &schema.queries {
139 if let Some(src) = q.sql_source.as_deref() {
140 node_type_index.entry(q.return_type.clone()).or_insert_with(|| Arc::from(src));
141 }
142 }
143
144 // Compute the schema version (content hash) once — it is stamped onto
145 // every change-log outbox row and is too expensive to recompute per call.
146 let schema_version: Arc<str> = Arc::from(schema.content_hash());
147
148 let ctx = Arc::new(ExecutorContext {
149 schema,
150 schema_version,
151 adapter,
152 relay: None,
153 matcher,
154 planner,
155 config,
156 introspection,
157 node_type_index,
158 parse_cache: MokaCache::new(PARSE_CACHE_CAPACITY),
159 response_cache: None,
160 });
161
162 Self { ctx }
163 }
164
165 /// Return current connection pool metrics from the underlying database adapter.
166 ///
167 /// Values are sampled live on each call — not cached — so callers (e.g., the
168 /// `/metrics` endpoint) always observe up-to-date pool health.
169 #[must_use]
170 pub fn pool_metrics(&self) -> PoolMetrics {
171 self.ctx.pool_metrics()
172 }
173
174 /// Get the compiled schema.
175 #[must_use]
176 pub fn schema(&self) -> &CompiledSchema {
177 &self.ctx.schema
178 }
179
180 /// Get runtime configuration.
181 #[must_use]
182 pub fn config(&self) -> &RuntimeConfig {
183 &self.ctx.config
184 }
185
186 /// Get database adapter reference.
187 #[must_use]
188 pub fn adapter(&self) -> &Arc<A> {
189 &self.ctx.adapter
190 }
191
192 /// Return the number of entries currently held in the parsed-query AST cache.
193 ///
194 /// Exposed for testing only — callers outside `#[cfg(test)]` code should not
195 /// rely on the exact count, which may lag by one maintenance cycle in moka.
196 #[cfg(test)]
197 #[must_use]
198 pub fn parse_cache_entry_count(&self) -> u64 {
199 self.ctx.parse_cache.entry_count()
200 }
201
202 /// Attach an executor-level response cache.
203 ///
204 /// When enabled, the executor caches the final projected response
205 /// (after RBAC, projection, and envelope wrapping) to skip all
206 /// redundant work on cache hits.
207 ///
208 /// # Panics
209 ///
210 /// Panics if called after the internal `Arc<ExecutorContext>` has been shared
211 /// (i.e., after the executor has been cloned). Always call this immediately
212 /// after construction, before sharing the executor.
213 #[must_use]
214 pub fn with_response_cache(mut self, cache: Arc<crate::cache::ResponseCache>) -> Self {
215 Arc::get_mut(&mut self.ctx)
216 .expect("with_response_cache called after Arc was shared")
217 .response_cache = Some(cache);
218 self
219 }
220
221 /// Get response cache reference (if configured).
222 #[must_use]
223 pub fn response_cache(&self) -> Option<&Arc<crate::cache::ResponseCache>> {
224 self.ctx.response_cache.as_ref()
225 }
226
227 /// Construct a query runner on demand.
228 ///
229 /// Zero-cost: `Arc::clone` is one atomic increment, no allocation.
230 pub(super) fn query_runner(&self) -> runners::query::QueryRunner<A> {
231 runners::query::QueryRunner::new(Arc::clone(&self.ctx))
232 }
233
234 /// Construct an aggregate runner on demand.
235 ///
236 /// Zero-cost: `Arc::clone` is one atomic increment, no allocation.
237 pub(super) fn aggregate_runner(&self) -> runners::aggregate::AggregateRunner<A> {
238 runners::aggregate::AggregateRunner::new(Arc::clone(&self.ctx))
239 }
240
241 /// Execute an aggregate query directly.
242 ///
243 /// # Errors
244 ///
245 /// Returns error if query parsing, plan generation, SQL generation, database execution,
246 /// or result projection fails.
247 pub async fn execute_aggregate_query(
248 &self,
249 query_json: &serde_json::Value,
250 query_name: &str,
251 metadata: &crate::compiler::fact_table::FactTableMetadata,
252 ) -> Result<serde_json::Value> {
253 // #422: operation-level authorization for the public aggregate embedder entry
254 // (the GraphQL aggregate path is gated at the chokepoint, not here, to
255 // avoid double-gating). Fail-closed → 403.
256 if let Some(authorizer) = self.ctx.config.authorizer.as_ref() {
257 let ops = [(crate::security::OperationKind::Query, query_name.to_string())];
258 crate::security::authorizer::enforce_authz(
259 authorizer.as_ref(),
260 None,
261 &ops,
262 Some(query_json),
263 )?;
264 }
265 self.aggregate_runner()
266 .execute_aggregate_query(query_json, query_name, metadata, None)
267 .await
268 }
269
270 /// Execute a window query directly.
271 ///
272 /// # Errors
273 ///
274 /// Returns error if query parsing, plan generation, SQL generation, database execution,
275 /// or result projection fails.
276 pub async fn execute_window_query(
277 &self,
278 query_json: &serde_json::Value,
279 query_name: &str,
280 metadata: &crate::compiler::fact_table::FactTableMetadata,
281 ) -> Result<serde_json::Value> {
282 // #422: operation-level authorization for the public window embedder entry
283 // (the GraphQL window path is gated at the chokepoint, not here).
284 // Fail-closed → 403.
285 if let Some(authorizer) = self.ctx.config.authorizer.as_ref() {
286 let ops = [(crate::security::OperationKind::Query, query_name.to_string())];
287 crate::security::authorizer::enforce_authz(
288 authorizer.as_ref(),
289 None,
290 &ops,
291 Some(query_json),
292 )?;
293 }
294 self.aggregate_runner()
295 .execute_window_query(query_json, query_name, metadata, None)
296 .await
297 }
298
299 /// Count rows matching a query's filters.
300 ///
301 /// Delegates to `QueryRunner::count_rows`.
302 ///
303 /// # Errors
304 ///
305 /// Returns `FraiseQLError::Validation` if the query has no SQL source, or if
306 /// inject params are required but no security context is provided.
307 /// Returns `FraiseQLError::Database` if the adapter returns an error.
308 pub async fn count_rows(
309 &self,
310 query_match: &QueryMatch,
311 variables: Option<&serde_json::Value>,
312 security_context: Option<&SecurityContext>,
313 ) -> Result<u64> {
314 self.query_runner().count_rows(query_match, variables, security_context).await
315 }
316
317 /// Execute a pre-resolved query match directly, bypassing GraphQL parsing.
318 ///
319 /// Used by the REST transport after route resolution: the `QueryMatch` is
320 /// already computed from HTTP path/query parameters, so there is no need
321 /// to re-parse a GraphQL query string.
322 ///
323 /// # Errors
324 ///
325 /// Returns `FraiseQLError::Validation` if the query has no SQL source.
326 /// Returns `FraiseQLError::Database` if the adapter returns an error.
327 pub async fn execute_query_direct(
328 &self,
329 query_match: &QueryMatch,
330 variables: Option<&serde_json::Value>,
331 security_context: Option<&SecurityContext>,
332 ) -> Result<serde_json::Value> {
333 self.query_runner()
334 .execute_query_direct(query_match, variables, security_context)
335 .await
336 }
337}
338
339impl<A: DatabaseAdapter + RelayDatabaseAdapter + 'static> Executor<A> {
340 /// Create a new executor with relay cursor pagination enabled.
341 ///
342 /// Only callable when `A: RelayDatabaseAdapter`. The relay capability is
343 /// encoded once at construction time as a type-erased `Arc<dyn RelayDispatch>`,
344 /// so there is no per-query overhead beyond an `Option::is_some()` check.
345 ///
346 /// # Example
347 ///
348 /// ```no_run
349 /// // Requires: a live PostgreSQL database with relay support.
350 /// // See: tests/integration/ for runnable examples.
351 /// # use fraiseql_core::schema::CompiledSchema;
352 /// # use fraiseql_core::db::postgres::PostgresAdapter;
353 /// # use fraiseql_core::runtime::Executor;
354 /// # use std::sync::Arc;
355 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
356 /// # let connection_string = "postgresql://localhost/mydb";
357 /// # let schema: CompiledSchema = panic!("example");
358 /// let adapter = PostgresAdapter::new(connection_string).await?;
359 /// let executor = Executor::new_with_relay(schema, Arc::new(adapter));
360 /// # Ok(()) }
361 /// ```
362 #[must_use]
363 pub fn new_with_relay(schema: CompiledSchema, adapter: Arc<A>) -> Self {
364 Self::with_config_and_relay(schema, adapter, RuntimeConfig::default())
365 }
366
367 /// Create a new executor with relay support and custom configuration.
368 #[must_use]
369 pub fn with_config_and_relay(
370 schema: CompiledSchema,
371 adapter: Arc<A>,
372 config: RuntimeConfig,
373 ) -> Self {
374 let relay_dispatch: Arc<dyn RelayDispatch> =
375 Arc::new(RelayDispatchImpl(Arc::clone(&adapter)));
376 let matcher = QueryMatcher::new(schema.clone());
377 let planner = QueryPlanner::new(config.cache_query_plans);
378 let introspection = IntrospectionResponses::build(&schema);
379
380 let mut node_type_index: HashMap<String, Arc<str>> = HashMap::new();
381 for q in &schema.queries {
382 if let Some(src) = q.sql_source.as_deref() {
383 node_type_index.entry(q.return_type.clone()).or_insert_with(|| Arc::from(src));
384 }
385 }
386
387 // Compute the schema version (content hash) once — it is stamped onto
388 // every change-log outbox row and is too expensive to recompute per call.
389 let schema_version: Arc<str> = Arc::from(schema.content_hash());
390
391 let ctx = Arc::new(ExecutorContext {
392 schema,
393 schema_version,
394 adapter,
395 relay: Some(relay_dispatch),
396 matcher,
397 planner,
398 config,
399 introspection,
400 node_type_index,
401 parse_cache: MokaCache::new(PARSE_CACHE_CAPACITY),
402 response_cache: None,
403 });
404
405 Self { ctx }
406 }
407}