Skip to main content

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}