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