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/// 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        let ctx = Arc::new(ExecutorContext {
145            schema,
146            adapter,
147            relay: None,
148            matcher,
149            planner,
150            config,
151            introspection,
152            node_type_index,
153            parse_cache: MokaCache::new(PARSE_CACHE_CAPACITY),
154            response_cache: None,
155        });
156
157        Self { ctx }
158    }
159
160    /// Return current connection pool metrics from the underlying database adapter.
161    ///
162    /// Values are sampled live on each call — not cached — so callers (e.g., the
163    /// `/metrics` endpoint) always observe up-to-date pool health.
164    #[must_use]
165    pub fn pool_metrics(&self) -> PoolMetrics {
166        self.ctx.pool_metrics()
167    }
168
169    /// Get the compiled schema.
170    #[must_use]
171    pub fn schema(&self) -> &CompiledSchema {
172        &self.ctx.schema
173    }
174
175    /// Get runtime configuration.
176    #[must_use]
177    pub fn config(&self) -> &RuntimeConfig {
178        &self.ctx.config
179    }
180
181    /// Get database adapter reference.
182    #[must_use]
183    pub fn adapter(&self) -> &Arc<A> {
184        &self.ctx.adapter
185    }
186
187    /// Return the number of entries currently held in the parsed-query AST cache.
188    ///
189    /// Exposed for testing only — callers outside `#[cfg(test)]` code should not
190    /// rely on the exact count, which may lag by one maintenance cycle in moka.
191    #[cfg(test)]
192    #[must_use]
193    pub fn parse_cache_entry_count(&self) -> u64 {
194        self.ctx.parse_cache.entry_count()
195    }
196
197    /// Attach an executor-level response cache.
198    ///
199    /// When enabled, the executor caches the final projected response
200    /// (after RBAC, projection, and envelope wrapping) to skip all
201    /// redundant work on cache hits.
202    ///
203    /// # Panics
204    ///
205    /// Panics if called after the internal `Arc<ExecutorContext>` has been shared
206    /// (i.e., after the executor has been cloned).  Always call this immediately
207    /// after construction, before sharing the executor.
208    #[must_use]
209    pub fn with_response_cache(mut self, cache: Arc<crate::cache::ResponseCache>) -> Self {
210        Arc::get_mut(&mut self.ctx)
211            .expect("with_response_cache called after Arc was shared")
212            .response_cache = Some(cache);
213        self
214    }
215
216    /// Get response cache reference (if configured).
217    #[must_use]
218    pub fn response_cache(&self) -> Option<&Arc<crate::cache::ResponseCache>> {
219        self.ctx.response_cache.as_ref()
220    }
221
222    /// Construct a query runner on demand.
223    ///
224    /// Zero-cost: `Arc::clone` is one atomic increment, no allocation.
225    pub(super) fn query_runner(&self) -> runners::query::QueryRunner<A> {
226        runners::query::QueryRunner::new(Arc::clone(&self.ctx))
227    }
228
229    /// Construct an aggregate runner on demand.
230    ///
231    /// Zero-cost: `Arc::clone` is one atomic increment, no allocation.
232    pub(super) fn aggregate_runner(&self) -> runners::aggregate::AggregateRunner<A> {
233        runners::aggregate::AggregateRunner::new(Arc::clone(&self.ctx))
234    }
235
236    /// Execute an aggregate query directly.
237    ///
238    /// # Errors
239    ///
240    /// Returns error if query parsing, plan generation, SQL generation, database execution,
241    /// or result projection fails.
242    pub async fn execute_aggregate_query(
243        &self,
244        query_json: &serde_json::Value,
245        query_name: &str,
246        metadata: &crate::compiler::fact_table::FactTableMetadata,
247    ) -> Result<serde_json::Value> {
248        // #422: operation-level authorization for the public aggregate embedder entry
249        //       (the GraphQL aggregate path is gated at the chokepoint, not here, to
250        //       avoid double-gating). Fail-closed → 403.
251        if let Some(authorizer) = self.ctx.config.authorizer.as_ref() {
252            let ops = [(crate::security::OperationKind::Query, query_name.to_string())];
253            crate::security::authorizer::enforce_authz(
254                authorizer.as_ref(),
255                None,
256                &ops,
257                Some(query_json),
258            )?;
259        }
260        self.aggregate_runner()
261            .execute_aggregate_query(query_json, query_name, metadata, None)
262            .await
263    }
264
265    /// Execute a window query directly.
266    ///
267    /// # Errors
268    ///
269    /// Returns error if query parsing, plan generation, SQL generation, database execution,
270    /// or result projection fails.
271    pub async fn execute_window_query(
272        &self,
273        query_json: &serde_json::Value,
274        query_name: &str,
275        metadata: &crate::compiler::fact_table::FactTableMetadata,
276    ) -> Result<serde_json::Value> {
277        // #422: operation-level authorization for the public window embedder entry
278        //       (the GraphQL window path is gated at the chokepoint, not here).
279        //       Fail-closed → 403.
280        if let Some(authorizer) = self.ctx.config.authorizer.as_ref() {
281            let ops = [(crate::security::OperationKind::Query, query_name.to_string())];
282            crate::security::authorizer::enforce_authz(
283                authorizer.as_ref(),
284                None,
285                &ops,
286                Some(query_json),
287            )?;
288        }
289        self.aggregate_runner()
290            .execute_window_query(query_json, query_name, metadata, None)
291            .await
292    }
293
294    /// Count rows matching a query's filters.
295    ///
296    /// Delegates to `QueryRunner::count_rows`.
297    ///
298    /// # Errors
299    ///
300    /// Returns `FraiseQLError::Validation` if the query has no SQL source, or if
301    /// inject params are required but no security context is provided.
302    /// Returns `FraiseQLError::Database` if the adapter returns an error.
303    pub async fn count_rows(
304        &self,
305        query_match: &QueryMatch,
306        variables: Option<&serde_json::Value>,
307        security_context: Option<&SecurityContext>,
308    ) -> Result<u64> {
309        self.query_runner().count_rows(query_match, variables, security_context).await
310    }
311
312    /// Execute a pre-resolved query match directly, bypassing GraphQL parsing.
313    ///
314    /// Used by the REST transport after route resolution: the `QueryMatch` is
315    /// already computed from HTTP path/query parameters, so there is no need
316    /// to re-parse a GraphQL query string.
317    ///
318    /// # Errors
319    ///
320    /// Returns `FraiseQLError::Validation` if the query has no SQL source.
321    /// Returns `FraiseQLError::Database` if the adapter returns an error.
322    pub async fn execute_query_direct(
323        &self,
324        query_match: &QueryMatch,
325        variables: Option<&serde_json::Value>,
326        security_context: Option<&SecurityContext>,
327    ) -> Result<serde_json::Value> {
328        self.query_runner()
329            .execute_query_direct(query_match, variables, security_context)
330            .await
331    }
332}
333
334impl<A: DatabaseAdapter + RelayDatabaseAdapter + 'static> Executor<A> {
335    /// Create a new executor with relay cursor pagination enabled.
336    ///
337    /// Only callable when `A: RelayDatabaseAdapter`.  The relay capability is
338    /// encoded once at construction time as a type-erased `Arc<dyn RelayDispatch>`,
339    /// so there is no per-query overhead beyond an `Option::is_some()` check.
340    ///
341    /// # Example
342    ///
343    /// ```no_run
344    /// // Requires: a live PostgreSQL database with relay support.
345    /// // See: tests/integration/ for runnable examples.
346    /// # use fraiseql_core::schema::CompiledSchema;
347    /// # use fraiseql_core::db::postgres::PostgresAdapter;
348    /// # use fraiseql_core::runtime::Executor;
349    /// # use std::sync::Arc;
350    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
351    /// # let connection_string = "postgresql://localhost/mydb";
352    /// # let schema: CompiledSchema = panic!("example");
353    /// let adapter = PostgresAdapter::new(connection_string).await?;
354    /// let executor = Executor::new_with_relay(schema, Arc::new(adapter));
355    /// # Ok(()) }
356    /// ```
357    #[must_use]
358    pub fn new_with_relay(schema: CompiledSchema, adapter: Arc<A>) -> Self {
359        Self::with_config_and_relay(schema, adapter, RuntimeConfig::default())
360    }
361
362    /// Create a new executor with relay support and custom configuration.
363    #[must_use]
364    pub fn with_config_and_relay(
365        schema: CompiledSchema,
366        adapter: Arc<A>,
367        config: RuntimeConfig,
368    ) -> Self {
369        let relay_dispatch: Arc<dyn RelayDispatch> =
370            Arc::new(RelayDispatchImpl(Arc::clone(&adapter)));
371        let matcher = QueryMatcher::new(schema.clone());
372        let planner = QueryPlanner::new(config.cache_query_plans);
373        let introspection = IntrospectionResponses::build(&schema);
374
375        let mut node_type_index: HashMap<String, Arc<str>> = HashMap::new();
376        for q in &schema.queries {
377            if let Some(src) = q.sql_source.as_deref() {
378                node_type_index.entry(q.return_type.clone()).or_insert_with(|| Arc::from(src));
379            }
380        }
381
382        let ctx = Arc::new(ExecutorContext {
383            schema,
384            adapter,
385            relay: Some(relay_dispatch),
386            matcher,
387            planner,
388            config,
389            introspection,
390            node_type_index,
391            parse_cache: MokaCache::new(PARSE_CACHE_CAPACITY),
392            response_cache: None,
393        });
394
395        Self { ctx }
396    }
397}