Skip to main content

fraiseql_core/runtime/
mod.rs

1//! Runtime query executor - executes compiled queries.
2//!
3//! # Architecture
4//!
5//! The runtime loads a `CompiledSchema` and executes incoming GraphQL queries by:
6//! 1. Parsing the GraphQL query
7//! 2. Matching it to a compiled query template
8//! 3. Binding variables
9//! 4. Executing the pre-compiled SQL
10//! 5. Projecting JSONB results to GraphQL response
11//!
12//! # Key Concepts
13//!
14//! - **Zero runtime compilation**: All SQL is pre-compiled
15//! - **Pattern matching**: Match incoming query structure to templates
16//! - **Variable binding**: Safe parameter substitution
17//! - **Result projection**: JSONB → GraphQL JSON transformation
18//!
19//! # Example
20//!
21//! ```no_run
22//! // Requires: a compiled schema file and a live PostgreSQL database.
23//! // See: tests/integration/ for runnable examples.
24//! use fraiseql_core::runtime::Executor;
25//! use fraiseql_core::schema::CompiledSchema;
26//! use fraiseql_core::db::postgres::PostgresAdapter;
27//! use std::sync::Arc;
28//!
29//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
30//! # let schema_json = r#"{"types":[],"queries":[]}"#;
31//! // Load compiled schema
32//! let schema = CompiledSchema::from_json(schema_json, false)?;
33//!
34//! // Create executor with a concrete adapter implementation
35//! let adapter = Arc::new(PostgresAdapter::new("postgresql://localhost/mydb").await?);
36//! let executor = Executor::new(schema, adapter);
37//!
38//! // Execute GraphQL query
39//! let query = r#"query { users { id name } }"#;
40//! let result = executor.execute(query, None).await?;
41//!
42//! println!("{}", result);
43//! # Ok(())
44//! # }
45//! ```
46
47mod aggregate_parser;
48mod aggregate_projector;
49pub mod aggregation;
50pub mod cascade;
51mod executor;
52pub mod executor_adapter;
53mod explain;
54pub mod field_filter;
55pub mod input_validator;
56pub mod jsonb_strategy;
57mod matcher;
58pub mod mutation_result;
59pub(crate) mod native_columns;
60pub mod partial_period;
61mod planner;
62mod projection;
63pub mod query_tracing;
64pub mod relay;
65pub mod sql_logger;
66pub mod subscription;
67pub mod tenant_enforcer;
68pub mod window;
69mod window_parser;
70mod window_projector;
71
72use std::sync::Arc;
73
74pub use aggregate_parser::AggregateQueryParser;
75pub use aggregate_projector::AggregationProjector;
76pub use aggregation::{AggregationSqlGenerator, ParameterizedAggregationSql};
77pub use executor::{
78    Executor,
79    pipeline::{extract_root_field_names, is_multi_root, multi_root_queries_total},
80};
81pub use executor_adapter::ExecutorAdapter;
82pub use explain::{ExplainPlan, ExplainResult};
83pub use field_filter::{FieldAccessResult, can_access_field, classify_field_access, filter_fields};
84pub use jsonb_strategy::{JsonbOptimizationOptions, JsonbStrategy};
85pub use matcher::{QueryMatch, QueryMatcher, suggest_similar};
86pub use planner::{ExecutionPlan, QueryPlanner};
87pub use projection::{FieldMapping, ProjectionMapper, ResultProjector, project_entity};
88pub use query_tracing::{
89    QueryExecutionTrace, QueryPhaseSpan, QueryTraceBuilder, create_phase_span, create_query_span,
90};
91pub use sql_logger::{SqlOperation, SqlQueryLog, SqlQueryLogBuilder, create_sql_span};
92pub use subscription::{
93    ActiveSubscription, DeliveryResult, KafkaAdapter, KafkaConfig, KafkaMessage, SubscriptionError,
94    SubscriptionEvent, SubscriptionId, SubscriptionManager, SubscriptionOperation,
95    SubscriptionPayload, TransportAdapter, TransportManager, WebhookAdapter, WebhookConfig,
96    WebhookPayload, extract_rls_conditions, protocol,
97};
98pub use tenant_enforcer::TenantEnforcer;
99
100/// Result of a bulk REST operation (collection-level PATCH/DELETE).
101#[derive(Debug, Clone)]
102pub struct BulkResult {
103    /// Number of rows affected.
104    pub affected_rows: u64,
105    /// Entities returned when `Prefer: return=representation` is set.
106    pub entities:      Option<Vec<serde_json::Value>>,
107}
108pub use window::{WindowSql, WindowSqlGenerator};
109pub use window_parser::WindowQueryParser;
110pub use window_projector::WindowProjector;
111
112use crate::security::{
113    Authorizer, FieldAuthorizer, FieldFilter, FieldFilterConfig, QueryValidatorConfig, RLSPolicy,
114};
115
116/// Runtime configuration for the FraiseQL query executor.
117///
118/// Controls safety limits, security policies, and performance tuning. All settings
119/// have production-safe defaults and can be overridden via the builder-style methods.
120///
121/// # Defaults
122///
123/// | Field | Default | Notes |
124/// |-------|---------|-------|
125/// | `cache_query_plans` | `true` | Caches parsed query plans for repeated queries |
126/// | `max_query_depth` | `10` | Prevents stack overflow on recursive GraphQL |
127/// | `max_query_complexity` | `1000` | Rough cost model; tune per workload |
128/// | `enable_tracing` | `false` | Emit `OpenTelemetry` spans for each query |
129/// | `query_timeout_ms` | `30 000` | Hard limit; 0 disables the timeout |
130/// | `field_filter` | `None` | No field-level access control |
131/// | `rls_policy` | `None` | No row-level security |
132/// | `authorizer` | `None` | No operation-level authorization |
133///
134/// # Example
135///
136/// ```
137/// use fraiseql_core::runtime::RuntimeConfig;
138/// use fraiseql_core::security::FieldFilterConfig;
139///
140/// let config = RuntimeConfig {
141///     max_query_depth: 5,
142///     max_query_complexity: 500,
143///     enable_tracing: true,
144///     query_timeout_ms: 5_000,
145///     ..RuntimeConfig::default()
146/// }
147/// .with_field_filter(
148///     FieldFilterConfig::new()
149///         .protect_field("User", "salary")
150///         .protect_field("User", "ssn"),
151/// );
152/// ```
153pub struct RuntimeConfig {
154    /// Enable query plan caching.
155    pub cache_query_plans: bool,
156
157    /// Maximum query depth (prevents deeply nested queries).
158    pub max_query_depth: usize,
159
160    /// Maximum query complexity score.
161    pub max_query_complexity: usize,
162
163    /// Maximum number of rows a top-level `first`/`last`/`limit` argument may
164    /// request, guarding against unbounded-pagination denial of service (#421):
165    /// the top-level row count is the one knob that sizes the database result set
166    /// and the serialized response. A request exceeding this is rejected with a
167    /// [`crate::FraiseQLError::Validation`]. `None` disables the ceiling. Default
168    /// `Some(1000)`.
169    pub max_page_size: Option<u32>,
170
171    /// Enable performance tracing.
172    pub enable_tracing: bool,
173
174    /// Optional field filter for access control.
175    /// When set, validates that users have required scopes to access fields.
176    pub field_filter: Option<FieldFilter>,
177
178    /// Optional row-level security (RLS) policy.
179    /// When set, evaluates access rules based on `SecurityContext` to determine
180    /// what rows a user can access (e.g., tenant isolation, owner-based access).
181    pub rls_policy: Option<Arc<dyn RLSPolicy>>,
182
183    /// Optional dynamic field-level authorizer.
184    ///
185    /// When set, fields marked policy-gated in the compiled schema
186    /// ([`FieldDefinition::authorize`](crate::schema::FieldDefinition)) are passed to
187    /// this authorizer per row, which returns an allow/deny decision based on the
188    /// principal, the parent row, and the field arguments. Composes as a logical AND
189    /// with the static `requires_scope` gate and is fail-closed (any error denies).
190    /// See [`FieldAuthorizer`].
191    pub field_authorizer: Option<Arc<dyn FieldAuthorizer>>,
192
193    /// Optional dynamic operation-level authorizer.
194    ///
195    /// When set, every operation (query, mutation, subscription) is passed to this
196    /// authorizer before dispatch, which returns an allow/deny decision based on the
197    /// principal (or `None` when anonymous), the operation kind and name, and the
198    /// request input. Composes as a logical AND with the static `requires_role` gate
199    /// and is fail-closed (any error or raise denies with HTTP 403 / `FORBIDDEN`).
200    /// See [`Authorizer`].
201    pub authorizer: Option<Arc<dyn Authorizer>>,
202
203    /// Query timeout in milliseconds (0 = no timeout).
204    pub query_timeout_ms: u64,
205
206    /// JSONB field optimization strategy options
207    pub jsonb_optimization: JsonbOptimizationOptions,
208
209    /// Optional query validation config.
210    ///
211    /// When `Some`, `QueryValidator::validate()` runs at the start of every
212    /// `Executor::execute()` call, before any parsing or SQL dispatch.
213    /// This provides `DoS` protection for direct `fraiseql-core` embedders that
214    /// do not route through `fraiseql-server` (which already runs `RequestValidator`
215    /// at the HTTP layer). Enforces: query size, depth, complexity, and alias count
216    /// (alias amplification protection).
217    ///
218    /// Set `None` to disable (default) — useful when the caller applies
219    /// validation at a higher layer, or when `fraiseql-server` is in use.
220    pub query_validation: Option<QueryValidatorConfig>,
221
222    /// Emit structured `tracing` events for every successfully-executed mutation.
223    ///
224    /// When `true`, a `tracing::info!` event with target `"fraiseql::mutation_audit"` is
225    /// emitted at the end of every successful `execute_mutation_query_with_security()` call.
226    /// The event carries fields: `mutation_name`, `entity_type`, `operation`, `tenant_id`.
227    ///
228    /// **Zero-cost when disabled**: the guard `if !self.config.audit_mutations { return }`
229    /// short-circuits before any string formatting or allocation occurs.
230    ///
231    /// Set to `true` when `audit_logging_enabled = true` in the compiled schema's
232    /// `[security.enterprise]` section (threaded through `Server::new()` at startup).
233    pub audit_mutations: bool,
234
235    /// Global switch for the Change-Spine change-log outbox write (default `true`).
236    ///
237    /// When `true`, every successful state-changing mutation writes one
238    /// `core.tb_entity_change_log` row in-transaction (the framework owns the
239    /// write). Set `false` to disable the outbox **globally** — e.g. for an
240    /// application that does not consume the Change Spine — so no mutation pays
241    /// the write. The per-mutation
242    /// [`MutationDefinition.changelog`](crate::schema::MutationDefinition) flag
243    /// composes as a logical AND on top of this: a row is written only when the
244    /// global switch is on **and** the mutation is not individually opted out.
245    ///
246    /// Sourced from `[changelog] enabled` in `fraiseql.toml`, overridable at
247    /// runtime by `FRAISEQL_CHANGELOG_ENABLED`.
248    pub changelog_enabled: bool,
249}
250
251impl std::fmt::Debug for RuntimeConfig {
252    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
253        f.debug_struct("RuntimeConfig")
254            .field("cache_query_plans", &self.cache_query_plans)
255            .field("max_query_depth", &self.max_query_depth)
256            .field("max_query_complexity", &self.max_query_complexity)
257            .field("max_page_size", &self.max_page_size)
258            .field("enable_tracing", &self.enable_tracing)
259            .field("field_filter", &self.field_filter.is_some())
260            .field("rls_policy", &self.rls_policy.is_some())
261            .field("field_authorizer", &self.field_authorizer.is_some())
262            .field("authorizer", &self.authorizer.is_some())
263            .field("query_timeout_ms", &self.query_timeout_ms)
264            .field("jsonb_optimization", &self.jsonb_optimization)
265            .field("query_validation", &self.query_validation)
266            .field("audit_mutations", &self.audit_mutations)
267            .field("changelog_enabled", &self.changelog_enabled)
268            .finish()
269    }
270}
271
272impl Default for RuntimeConfig {
273    fn default() -> Self {
274        Self {
275            cache_query_plans:    true,
276            max_query_depth:      10,
277            max_query_complexity: 1000,
278            max_page_size:        Some(1000),
279            enable_tracing:       false,
280            field_filter:         None,
281            rls_policy:           None,
282            field_authorizer:     None,
283            authorizer:           None,
284            query_timeout_ms:     30_000, // 30 second default timeout
285            jsonb_optimization:   JsonbOptimizationOptions::default(),
286            query_validation:     None,
287            audit_mutations:      false,
288            changelog_enabled:    true,
289        }
290    }
291}
292
293impl RuntimeConfig {
294    /// Create a new runtime config with a field filter.
295    ///
296    /// # Example
297    ///
298    /// ```
299    /// use fraiseql_core::runtime::RuntimeConfig;
300    /// use fraiseql_core::security::FieldFilterConfig;
301    ///
302    /// let config = RuntimeConfig::default()
303    ///     .with_field_filter(
304    ///         FieldFilterConfig::new()
305    ///             .protect_field("User", "salary")
306    ///             .protect_field("User", "ssn")
307    ///     );
308    /// ```
309    #[must_use = "builder method returns modified builder"]
310    pub fn with_field_filter(mut self, config: FieldFilterConfig) -> Self {
311        self.field_filter = Some(FieldFilter::new(config));
312        self
313    }
314
315    /// Configure row-level security (RLS) policy for access control.
316    ///
317    /// When set, the executor will evaluate the RLS policy before executing queries,
318    /// applying WHERE clause filters based on the user's `SecurityContext`.
319    ///
320    /// # Example
321    ///
322    /// ```rust
323    /// use fraiseql_core::runtime::RuntimeConfig;
324    /// use fraiseql_core::security::DefaultRLSPolicy;
325    /// use std::sync::Arc;
326    ///
327    /// let config = RuntimeConfig::default()
328    ///     .with_rls_policy(Arc::new(DefaultRLSPolicy::new()));
329    /// ```
330    #[must_use = "builder method returns modified builder"]
331    pub fn with_rls_policy(mut self, policy: Arc<dyn RLSPolicy>) -> Self {
332        self.rls_policy = Some(policy);
333        self
334    }
335
336    /// Configure a dynamic field-level authorizer.
337    ///
338    /// When set, fields marked policy-gated in the compiled schema
339    /// ([`FieldDefinition::authorize`](crate::schema::FieldDefinition)) are evaluated
340    /// per row by this authorizer. The decision composes as a logical AND with the
341    /// static `requires_scope` gate and is fail-closed (any error or raise denies
342    /// with HTTP 403 / `FORBIDDEN`). Parallel to [`with_rls_policy`](Self::with_rls_policy).
343    ///
344    /// # Example
345    ///
346    /// ```rust
347    /// use fraiseql_core::runtime::RuntimeConfig;
348    /// use fraiseql_core::security::{
349    ///     FieldAuthorizer, FieldAuthzRequest, FieldAuthzDecision,
350    /// };
351    /// use fraiseql_core::error::Result;
352    /// use std::sync::Arc;
353    ///
354    /// struct AllowAll;
355    /// impl FieldAuthorizer for AllowAll {
356    ///     fn authorize_field(&self, _req: &FieldAuthzRequest<'_>) -> Result<FieldAuthzDecision> {
357    ///         Ok(FieldAuthzDecision::Allow)
358    ///     }
359    /// }
360    ///
361    /// let config = RuntimeConfig::default().with_field_authorizer(Arc::new(AllowAll));
362    /// ```
363    #[must_use = "builder method returns modified builder"]
364    pub fn with_field_authorizer(mut self, authorizer: Arc<dyn FieldAuthorizer>) -> Self {
365        self.field_authorizer = Some(authorizer);
366        self
367    }
368
369    /// Configure a dynamic operation-level authorizer.
370    ///
371    /// When set, every operation (query, mutation, subscription) is passed to this
372    /// authorizer before dispatch. The decision composes as a logical AND with the
373    /// static `requires_role` gate and is fail-closed (any error or raise denies with
374    /// HTTP 403 / `FORBIDDEN`). Parallel to
375    /// [`with_field_authorizer`](Self::with_field_authorizer) and
376    /// [`with_rls_policy`](Self::with_rls_policy).
377    ///
378    /// # Example
379    ///
380    /// ```rust
381    /// use fraiseql_core::runtime::RuntimeConfig;
382    /// use fraiseql_core::security::{Authorizer, AuthzRequest, AuthzDecision};
383    /// use fraiseql_core::error::Result;
384    /// use std::sync::Arc;
385    ///
386    /// struct AllowAll;
387    /// impl Authorizer for AllowAll {
388    ///     fn authorize(&self, _req: &AuthzRequest<'_>) -> Result<AuthzDecision> {
389    ///         Ok(AuthzDecision::Allow)
390    ///     }
391    /// }
392    ///
393    /// let config = RuntimeConfig::default().with_authorizer(Arc::new(AllowAll));
394    /// ```
395    #[must_use = "builder method returns modified builder"]
396    pub fn with_authorizer(mut self, authorizer: Arc<dyn Authorizer>) -> Self {
397        self.authorizer = Some(authorizer);
398        self
399    }
400
401    /// Build a [`RuntimeConfig`] from a compiled schema, applying every
402    /// schema-derived runtime setting that an executor must honor.
403    ///
404    /// This is the **single seam** every server entry point routes through so
405    /// the config can never drift by constructor (H16): `Server::new`,
406    /// `with_relay_pagination`, and `with_flight_service` previously built the
407    /// executor with [`RuntimeConfig::default`], silently dropping the
408    /// compiled audit-logging flag, the #421 page-size ceiling, and the
409    /// change-log toggle, and skipping the schema-format-version check.
410    ///
411    /// Applied in order:
412    /// 1. **Schema-format-version validation** — a legacy schema (no version) warns; an
413    ///    incompatible future version is rejected. Coupling the check into the constructor means a
414    ///    caller cannot obtain a config while skipping the validation.
415    /// 2. **Audit logging** — `audit_mutations` from the compiled `[security.enterprise]
416    ///    audit_logging_enabled`.
417    /// 3. **Page-size ceiling (#421)** — `FRAISEQL_MAX_PAGE_SIZE` overrides the compiled
418    ///    `[validation] max_page_size`, which overrides the default.
419    /// 4. **Change-log outbox toggle** — `FRAISEQL_CHANGELOG_ENABLED` overrides the compiled
420    ///    `[changelog] write_enabled` (default `true`).
421    ///
422    /// # Errors
423    ///
424    /// Returns the validation message when the schema's `schema_format_version`
425    /// is incompatible with this runtime.
426    pub fn from_compiled_schema(schema: &crate::schema::CompiledSchema) -> Result<Self, String> {
427        if schema.schema_format_version.is_none() {
428            tracing::warn!(
429                "Loaded schema has no schema_format_version (pre-v2.1 format). \
430                 Re-compile with the current fraiseql-cli for version compatibility checking."
431            );
432        }
433        schema.validate_format_version()?;
434
435        // Audit logging: security.additional["enterprise"]["audit_logging_enabled"].
436        let audit_mutations = schema
437            .security
438            .as_ref()
439            .and_then(|s| s.additional.get("enterprise"))
440            .and_then(|e| e.get("audit_logging_enabled"))
441            .and_then(serde_json::Value::as_bool)
442            .unwrap_or(false);
443        if audit_mutations {
444            tracing::info!("Mutation audit logging enabled (target: fraiseql::mutation_audit)");
445        }
446
447        // #421: FRAISEQL_MAX_PAGE_SIZE > compiled [validation] max_page_size > default.
448        let max_page_size = page_size_precedence(
449            std::env::var("FRAISEQL_MAX_PAGE_SIZE").ok().as_deref(),
450            schema.validation_config.as_ref().and_then(|v| v.max_page_size),
451        );
452
453        // Change-Spine outbox write toggle (default on): FRAISEQL_CHANGELOG_ENABLED
454        // overrides the compiled [changelog] write_enabled.
455        let changelog_enabled = std::env::var("FRAISEQL_CHANGELOG_ENABLED")
456            .ok()
457            .map(|v| {
458                !matches!(v.trim().to_ascii_lowercase().as_str(), "false" | "0" | "no" | "off")
459            })
460            .or_else(|| schema.changelog.as_ref().map(|c| c.write_enabled))
461            .unwrap_or(true);
462        if !changelog_enabled {
463            tracing::info!(
464                "Change-log outbox write disabled (FRAISEQL_CHANGELOG_ENABLED / [changelog] write_enabled)"
465            );
466        }
467
468        Ok(Self {
469            max_page_size,
470            audit_mutations,
471            changelog_enabled,
472            ..Self::default()
473        })
474    }
475}
476
477/// Resolve the top-level page-size ceiling (#421) by precedence.
478///
479/// `env` is the raw `FRAISEQL_MAX_PAGE_SIZE` value when set (a positive integer,
480/// or `"0"`/`"none"` to disable the ceiling). It overrides `compiled` (the
481/// `[validation] max_page_size` from the compiled schema), which overrides the
482/// runtime default (1000). Returns `None` only when explicitly disabled.
483#[must_use]
484pub fn page_size_precedence(env: Option<&str>, compiled: Option<u32>) -> Option<u32> {
485    if let Some(raw) = env {
486        let trimmed = raw.trim();
487        if trimmed.eq_ignore_ascii_case("none") || trimmed == "0" {
488            return None;
489        }
490        if let Ok(n) = trimmed.parse::<u32>() {
491            return Some(n);
492        }
493        // Unparseable env value: ignore it and fall through to compiled/default.
494    }
495    compiled.or(RuntimeConfig::default().max_page_size)
496}
497
498/// Execution context for query cancellation support.
499///
500/// This struct provides a mechanism for gracefully cancelling long-running queries
501/// via cancellation tokens, enabling proper cleanup and error reporting when:
502/// - A client connection closes
503/// - A user explicitly cancels a query
504/// - A system shutdown is initiated
505///
506/// # Example
507///
508/// ```no_run
509/// // Requires: a running tokio runtime and an Executor with a live database adapter.
510/// // See: tests/integration/ for runnable examples.
511/// use fraiseql_core::runtime::ExecutionContext;
512/// use std::time::Duration;
513///
514/// let ctx = ExecutionContext::new("query-123".to_string());
515///
516/// // Spawn a task that cancels after 5 seconds
517/// let cancel_token = ctx.cancellation_token().clone();
518/// tokio::spawn(async move {
519///     tokio::time::sleep(Duration::from_secs(5)).await;
520///     cancel_token.cancel();
521/// });
522///
523/// // Execute query with cancellation support
524/// // let result = executor.execute_with_context(query, None, &ctx).await;
525/// ```
526#[derive(Debug, Clone)]
527pub struct ExecutionContext {
528    /// Unique identifier for tracking the query execution
529    query_id: String,
530
531    /// Cancellation token for gracefully stopping the query
532    /// When cancelled, ongoing query execution should stop and return a Cancelled error
533    token: tokio_util::sync::CancellationToken,
534}
535
536impl ExecutionContext {
537    /// Create a new execution context with a cancellation token.
538    ///
539    /// # Arguments
540    ///
541    /// * `query_id` - Unique identifier for this query execution
542    ///
543    /// # Example
544    ///
545    /// ```rust
546    /// # use fraiseql_core::runtime::ExecutionContext;
547    /// let ctx = ExecutionContext::new("user-query-001".to_string());
548    /// assert_eq!(ctx.query_id(), "user-query-001");
549    /// ```
550    #[must_use]
551    pub fn new(query_id: String) -> Self {
552        Self {
553            query_id,
554            token: tokio_util::sync::CancellationToken::new(),
555        }
556    }
557
558    /// Get the query ID.
559    #[must_use]
560    pub fn query_id(&self) -> &str {
561        &self.query_id
562    }
563
564    /// Get a reference to the cancellation token.
565    ///
566    /// The returned token can be used to:
567    /// - Clone and pass to background tasks
568    /// - Check if cancellation was requested
569    /// - Propagate cancellation through the call stack
570    #[must_use]
571    pub const fn cancellation_token(&self) -> &tokio_util::sync::CancellationToken {
572        &self.token
573    }
574
575    /// Check if cancellation has been requested.
576    #[must_use]
577    pub fn is_cancelled(&self) -> bool {
578        self.token.is_cancelled()
579    }
580}
581
582#[cfg(test)]
583mod tests;