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        // 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}