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;
62pub(crate) mod 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::{
88 FieldMapping, ProjectionMapper, ResultProjector, project_entity, project_nested_lists,
89};
90pub use query_tracing::{
91 QueryExecutionTrace, QueryPhaseSpan, QueryTraceBuilder, create_phase_span, create_query_span,
92};
93pub use sql_logger::{SqlOperation, SqlQueryLog, SqlQueryLogBuilder, create_sql_span};
94pub use subscription::{
95 ActiveSubscription, DeliveryResult, KafkaAdapter, KafkaConfig, KafkaMessage, SubscriptionError,
96 SubscriptionEvent, SubscriptionId, SubscriptionManager, SubscriptionOperation,
97 SubscriptionPayload, TransportAdapter, TransportManager, WebhookAdapter, WebhookConfig,
98 WebhookPayload, extract_rls_conditions, protocol,
99};
100pub use tenant_enforcer::TenantEnforcer;
101
102/// Result of a bulk REST operation (collection-level PATCH/DELETE).
103#[derive(Debug, Clone)]
104pub struct BulkResult {
105 /// Number of rows affected.
106 pub affected_rows: u64,
107 /// Entities returned when `Prefer: return=representation` is set.
108 pub entities: Option<Vec<serde_json::Value>>,
109}
110pub use window::{WindowSql, WindowSqlGenerator};
111pub use window_parser::WindowQueryParser;
112pub use window_projector::WindowProjector;
113
114use crate::security::{
115 Authorizer, FieldAuthorizer, FieldFilter, FieldFilterConfig, QueryValidatorConfig, RLSPolicy,
116};
117
118/// Runtime configuration for the FraiseQL query executor.
119///
120/// Controls safety limits, security policies, and performance tuning. All settings
121/// have production-safe defaults and can be overridden via the builder-style methods.
122///
123/// # Defaults
124///
125/// | Field | Default | Notes |
126/// |-------|---------|-------|
127/// | `cache_query_plans` | `true` | Caches parsed query plans for repeated queries |
128/// | `max_query_depth` | `10` | Prevents stack overflow on recursive GraphQL |
129/// | `max_query_complexity` | `1000` | Rough cost model; tune per workload |
130/// | `enable_tracing` | `false` | Emit `OpenTelemetry` spans for each query |
131/// | `query_timeout_ms` | `30 000` | Hard limit; 0 disables the timeout |
132/// | `field_filter` | `None` | No field-level access control |
133/// | `rls_policy` | `None` | No row-level security |
134/// | `authorizer` | `None` | No operation-level authorization |
135///
136/// # Example
137///
138/// ```
139/// use fraiseql_core::runtime::RuntimeConfig;
140/// use fraiseql_core::security::FieldFilterConfig;
141///
142/// let config = RuntimeConfig {
143/// max_query_depth: 5,
144/// max_query_complexity: 500,
145/// enable_tracing: true,
146/// query_timeout_ms: 5_000,
147/// ..RuntimeConfig::default()
148/// }
149/// .with_field_filter(
150/// FieldFilterConfig::new()
151/// .protect_field("User", "salary")
152/// .protect_field("User", "ssn"),
153/// );
154/// ```
155pub struct RuntimeConfig {
156 /// Enable query plan caching.
157 pub cache_query_plans: bool,
158
159 /// Maximum query depth (prevents deeply nested queries).
160 pub max_query_depth: usize,
161
162 /// Maximum query complexity score.
163 pub max_query_complexity: usize,
164
165 /// Maximum number of rows a top-level `first`/`last`/`limit` argument may
166 /// request, guarding against unbounded-pagination denial of service (#421):
167 /// the top-level row count is the one knob that sizes the database result set
168 /// and the serialized response. A request exceeding this is rejected with a
169 /// [`crate::FraiseQLError::Validation`]. `None` disables the ceiling. Default
170 /// `Some(1000)`.
171 pub max_page_size: Option<u32>,
172
173 /// Enable performance tracing.
174 pub enable_tracing: bool,
175
176 /// Optional field filter for access control.
177 /// When set, validates that users have required scopes to access fields.
178 pub field_filter: Option<FieldFilter>,
179
180 /// Optional row-level security (RLS) policy.
181 /// When set, evaluates access rules based on `SecurityContext` to determine
182 /// what rows a user can access (e.g., tenant isolation, owner-based access).
183 pub rls_policy: Option<Arc<dyn RLSPolicy>>,
184
185 /// Optional dynamic field-level authorizer.
186 ///
187 /// When set, fields marked policy-gated in the compiled schema
188 /// ([`FieldDefinition::authorize`](crate::schema::FieldDefinition)) are passed to
189 /// this authorizer per row, which returns an allow/deny decision based on the
190 /// principal, the parent row, and the field arguments. Composes as a logical AND
191 /// with the static `requires_scope` gate and is fail-closed (any error denies).
192 /// See [`FieldAuthorizer`].
193 pub field_authorizer: Option<Arc<dyn FieldAuthorizer>>,
194
195 /// Optional dynamic operation-level authorizer.
196 ///
197 /// When set, every operation (query, mutation, subscription) is passed to this
198 /// authorizer before dispatch, which returns an allow/deny decision based on the
199 /// principal (or `None` when anonymous), the operation kind and name, and the
200 /// request input. Composes as a logical AND with the static `requires_role` gate
201 /// and is fail-closed (any error or raise denies with HTTP 403 / `FORBIDDEN`).
202 /// See [`Authorizer`].
203 pub authorizer: Option<Arc<dyn Authorizer>>,
204
205 /// Query timeout in milliseconds (0 = no timeout).
206 pub query_timeout_ms: u64,
207
208 /// JSONB field optimization strategy options
209 pub jsonb_optimization: JsonbOptimizationOptions,
210
211 /// Optional query validation config.
212 ///
213 /// When `Some`, `QueryValidator::validate()` runs at the start of every
214 /// `Executor::execute()` call, before any parsing or SQL dispatch.
215 /// This provides `DoS` protection for direct `fraiseql-core` embedders that
216 /// do not route through `fraiseql-server` (which already runs `RequestValidator`
217 /// at the HTTP layer). Enforces: query size, depth, complexity, and alias count
218 /// (alias amplification protection).
219 ///
220 /// Set `None` to disable (default) — useful when the caller applies
221 /// validation at a higher layer, or when `fraiseql-server` is in use.
222 pub query_validation: Option<QueryValidatorConfig>,
223
224 /// Emit structured `tracing` events for every successfully-executed mutation.
225 ///
226 /// When `true`, a `tracing::info!` event with target `"fraiseql::mutation_audit"` is
227 /// emitted at the end of every successful `execute_mutation_query_with_security()` call.
228 /// The event carries fields: `mutation_name`, `entity_type`, `operation`, `tenant_id`.
229 ///
230 /// **Zero-cost when disabled**: the guard `if !self.config.audit_mutations { return }`
231 /// short-circuits before any string formatting or allocation occurs.
232 ///
233 /// Set to `true` when `audit_logging_enabled = true` in the compiled schema's
234 /// `[security.enterprise]` section (threaded through `Server::new()` at startup).
235 pub audit_mutations: bool,
236
237 /// Global switch for the Change-Spine change-log outbox write (default `true`).
238 ///
239 /// When `true`, every successful state-changing mutation writes one
240 /// `core.tb_entity_change_log` row in-transaction (the framework owns the
241 /// write). Set `false` to disable the outbox **globally** — e.g. for an
242 /// application that does not consume the Change Spine — so no mutation pays
243 /// the write. The per-mutation
244 /// [`MutationDefinition.changelog`](crate::schema::MutationDefinition) flag
245 /// composes as a logical AND on top of this: a row is written only when the
246 /// global switch is on **and** the mutation is not individually opted out.
247 ///
248 /// Sourced from `[changelog] enabled` in `fraiseql.toml`, overridable at
249 /// runtime by `FRAISEQL_CHANGELOG_ENABLED`.
250 pub changelog_enabled: bool,
251
252 /// Validate-bind-without-commit mode for mutations (default `false`).
253 ///
254 /// When `true`, every state-changing mutation is executed inside a database
255 /// transaction that is **rolled back** instead of committed: the function
256 /// binds and runs (so constraints, triggers, and the `mutation_response`
257 /// shape are all validated) but no writes persist and no change-log row is
258 /// emitted. Powers the `fraiseql query --dry-run` CLI smoke check and the
259 /// `doctor --runtime` mutation probes.
260 ///
261 /// Currently honoured only by the PostgreSQL adapter; other adapters return
262 /// a `Validation` error from `execute_function_call_dry_run` rather than
263 /// silently committing. Queries are unaffected (they never commit).
264 pub dry_run_mutations: bool,
265
266 /// Response-size guards for the typed cascade surface (graphql-cascade
267 /// `16_security`). A cascade mutation returning more affected entities than
268 /// [`CascadeLimits::max_updated_entities`] is truncated with `truncated`
269 /// metadata; one exceeding [`CascadeLimits::max_response_size_mb`] is
270 /// rejected. Same DoS-guard family as [`max_page_size`](Self::max_page_size).
271 pub cascade_limits: CascadeLimits,
272}
273
274/// Response-size limits for the typed cascade surface, per the graphql-cascade
275/// spec's security requirements (`specification/16_security.md`).
276///
277/// Defaults are the spec's: depth 3, 500 affected entities, 5 `MiB`.
278#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
279pub struct CascadeLimits {
280 /// Maximum relationship depth a cascade may traverse (spec default `3`).
281 pub max_depth: usize,
282 /// Maximum number of affected entities (`updated` + `deleted`) before the
283 /// cascade is truncated and flagged `truncated` in its metadata (spec
284 /// default `500`).
285 pub max_updated_entities: usize,
286 /// Maximum serialized cascade size in `MiB` before the mutation is rejected
287 /// (spec default `5`). A value of `0` **disables** the size ceiling — set it
288 /// only to intentionally lift the limit, never as an "unbounded" value reached
289 /// by accident.
290 pub max_response_size_mb: usize,
291}
292
293impl Default for CascadeLimits {
294 fn default() -> Self {
295 Self {
296 max_depth: 3,
297 max_updated_entities: 500,
298 max_response_size_mb: 5,
299 }
300 }
301}
302
303impl std::fmt::Debug for RuntimeConfig {
304 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
305 f.debug_struct("RuntimeConfig")
306 .field("cache_query_plans", &self.cache_query_plans)
307 .field("max_query_depth", &self.max_query_depth)
308 .field("max_query_complexity", &self.max_query_complexity)
309 .field("max_page_size", &self.max_page_size)
310 .field("enable_tracing", &self.enable_tracing)
311 .field("field_filter", &self.field_filter.is_some())
312 .field("rls_policy", &self.rls_policy.is_some())
313 .field("field_authorizer", &self.field_authorizer.is_some())
314 .field("authorizer", &self.authorizer.is_some())
315 .field("query_timeout_ms", &self.query_timeout_ms)
316 .field("jsonb_optimization", &self.jsonb_optimization)
317 .field("query_validation", &self.query_validation)
318 .field("audit_mutations", &self.audit_mutations)
319 .field("changelog_enabled", &self.changelog_enabled)
320 .field("dry_run_mutations", &self.dry_run_mutations)
321 .field("cascade_limits", &self.cascade_limits)
322 .finish()
323 }
324}
325
326impl Default for RuntimeConfig {
327 fn default() -> Self {
328 Self {
329 cache_query_plans: true,
330 max_query_depth: 10,
331 max_query_complexity: 1000,
332 max_page_size: Some(1000),
333 enable_tracing: false,
334 field_filter: None,
335 rls_policy: None,
336 field_authorizer: None,
337 authorizer: None,
338 query_timeout_ms: 30_000, // 30 second default timeout
339 jsonb_optimization: JsonbOptimizationOptions::default(),
340 query_validation: None,
341 audit_mutations: false,
342 changelog_enabled: true,
343 dry_run_mutations: false,
344 cascade_limits: CascadeLimits::default(),
345 }
346 }
347}
348
349impl RuntimeConfig {
350 /// Create a new runtime config with a field filter.
351 ///
352 /// # Example
353 ///
354 /// ```
355 /// use fraiseql_core::runtime::RuntimeConfig;
356 /// use fraiseql_core::security::FieldFilterConfig;
357 ///
358 /// let config = RuntimeConfig::default()
359 /// .with_field_filter(
360 /// FieldFilterConfig::new()
361 /// .protect_field("User", "salary")
362 /// .protect_field("User", "ssn")
363 /// );
364 /// ```
365 #[must_use = "builder method returns modified builder"]
366 pub fn with_field_filter(mut self, config: FieldFilterConfig) -> Self {
367 self.field_filter = Some(FieldFilter::new(config));
368 self
369 }
370
371 /// Configure row-level security (RLS) policy for access control.
372 ///
373 /// When set, the executor will evaluate the RLS policy before executing queries,
374 /// applying WHERE clause filters based on the user's `SecurityContext`.
375 ///
376 /// # Example
377 ///
378 /// ```rust
379 /// use fraiseql_core::runtime::RuntimeConfig;
380 /// use fraiseql_core::security::DefaultRLSPolicy;
381 /// use std::sync::Arc;
382 ///
383 /// let config = RuntimeConfig::default()
384 /// .with_rls_policy(Arc::new(DefaultRLSPolicy::new()));
385 /// ```
386 #[must_use = "builder method returns modified builder"]
387 pub fn with_rls_policy(mut self, policy: Arc<dyn RLSPolicy>) -> Self {
388 self.rls_policy = Some(policy);
389 self
390 }
391
392 /// Configure a dynamic field-level authorizer.
393 ///
394 /// When set, fields marked policy-gated in the compiled schema
395 /// ([`FieldDefinition::authorize`](crate::schema::FieldDefinition)) are evaluated
396 /// per row by this authorizer. The decision composes as a logical AND with the
397 /// static `requires_scope` gate and is fail-closed (any error or raise denies
398 /// with HTTP 403 / `FORBIDDEN`). Parallel to [`with_rls_policy`](Self::with_rls_policy).
399 ///
400 /// # Example
401 ///
402 /// ```rust
403 /// use fraiseql_core::runtime::RuntimeConfig;
404 /// use fraiseql_core::security::{
405 /// FieldAuthorizer, FieldAuthzRequest, FieldAuthzDecision,
406 /// };
407 /// use fraiseql_core::error::Result;
408 /// use std::sync::Arc;
409 ///
410 /// struct AllowAll;
411 /// impl FieldAuthorizer for AllowAll {
412 /// fn authorize_field(&self, _req: &FieldAuthzRequest<'_>) -> Result<FieldAuthzDecision> {
413 /// Ok(FieldAuthzDecision::Allow)
414 /// }
415 /// }
416 ///
417 /// let config = RuntimeConfig::default().with_field_authorizer(Arc::new(AllowAll));
418 /// ```
419 #[must_use = "builder method returns modified builder"]
420 pub fn with_field_authorizer(mut self, authorizer: Arc<dyn FieldAuthorizer>) -> Self {
421 self.field_authorizer = Some(authorizer);
422 self
423 }
424
425 /// Override the cascade response-size limits (graphql-cascade `16_security`).
426 #[must_use = "builder method returns modified builder"]
427 pub const fn with_cascade_limits(mut self, limits: CascadeLimits) -> Self {
428 self.cascade_limits = limits;
429 self
430 }
431
432 /// Configure a dynamic operation-level authorizer.
433 ///
434 /// When set, every operation (query, mutation, subscription) is passed to this
435 /// authorizer before dispatch. The decision composes as a logical AND with the
436 /// static `requires_role` gate and is fail-closed (any error or raise denies with
437 /// HTTP 403 / `FORBIDDEN`). Parallel to
438 /// [`with_field_authorizer`](Self::with_field_authorizer) and
439 /// [`with_rls_policy`](Self::with_rls_policy).
440 ///
441 /// # Example
442 ///
443 /// ```rust
444 /// use fraiseql_core::runtime::RuntimeConfig;
445 /// use fraiseql_core::security::{Authorizer, AuthzRequest, AuthzDecision};
446 /// use fraiseql_core::error::Result;
447 /// use std::sync::Arc;
448 ///
449 /// struct AllowAll;
450 /// impl Authorizer for AllowAll {
451 /// fn authorize(&self, _req: &AuthzRequest<'_>) -> Result<AuthzDecision> {
452 /// Ok(AuthzDecision::Allow)
453 /// }
454 /// }
455 ///
456 /// let config = RuntimeConfig::default().with_authorizer(Arc::new(AllowAll));
457 /// ```
458 #[must_use = "builder method returns modified builder"]
459 pub fn with_authorizer(mut self, authorizer: Arc<dyn Authorizer>) -> Self {
460 self.authorizer = Some(authorizer);
461 self
462 }
463
464 /// Build a [`RuntimeConfig`] from a compiled schema, applying every
465 /// schema-derived runtime setting that an executor must honor.
466 ///
467 /// This is the **single seam** every server entry point routes through so
468 /// the config can never drift by constructor (H16): `Server::new`,
469 /// `with_relay_pagination`, and `with_flight_service` previously built the
470 /// executor with [`RuntimeConfig::default`], silently dropping the
471 /// compiled audit-logging flag, the #421 page-size ceiling, and the
472 /// change-log toggle, and skipping the schema-format-version check.
473 ///
474 /// Applied in order:
475 /// 1. **Schema-format-version validation** — a legacy schema (no version) warns; an
476 /// incompatible future version is rejected. Coupling the check into the constructor means a
477 /// caller cannot obtain a config while skipping the validation.
478 /// 2. **Audit logging** — `audit_mutations` from the compiled `[security.enterprise]
479 /// audit_logging_enabled`.
480 /// 3. **Page-size ceiling (#421)** — `FRAISEQL_MAX_PAGE_SIZE` overrides the compiled
481 /// `[validation] max_page_size`, which overrides the default.
482 /// 4. **Change-log outbox toggle** — `FRAISEQL_CHANGELOG_ENABLED` overrides the compiled
483 /// `[changelog] write_enabled` (default `true`).
484 ///
485 /// # Errors
486 ///
487 /// Returns the validation message when the schema's `schema_format_version`
488 /// is incompatible with this runtime.
489 pub fn from_compiled_schema(schema: &crate::schema::CompiledSchema) -> Result<Self, String> {
490 if schema.schema_format_version.is_none() {
491 tracing::warn!(
492 "Loaded schema has no schema_format_version (pre-v2.1 format). \
493 Re-compile with the current fraiseql-cli for version compatibility checking."
494 );
495 }
496 schema.validate_format_version()?;
497
498 // Audit logging: security.additional["enterprise"]["audit_logging_enabled"].
499 let audit_mutations = schema
500 .security
501 .as_ref()
502 .and_then(|s| s.additional.get("enterprise"))
503 .and_then(|e| e.get("audit_logging_enabled"))
504 .and_then(serde_json::Value::as_bool)
505 .unwrap_or(false);
506 if audit_mutations {
507 tracing::info!("Mutation audit logging enabled (target: fraiseql::mutation_audit)");
508 }
509
510 // #421: FRAISEQL_MAX_PAGE_SIZE > compiled [validation] max_page_size > default.
511 let max_page_size = page_size_precedence(
512 std::env::var("FRAISEQL_MAX_PAGE_SIZE").ok().as_deref(),
513 schema.validation_config.as_ref().and_then(|v| v.max_page_size),
514 );
515
516 // Change-Spine outbox write toggle (default on): FRAISEQL_CHANGELOG_ENABLED
517 // overrides the compiled [changelog] write_enabled.
518 let changelog_enabled = std::env::var("FRAISEQL_CHANGELOG_ENABLED")
519 .ok()
520 .map(|v| {
521 !matches!(v.trim().to_ascii_lowercase().as_str(), "false" | "0" | "no" | "off")
522 })
523 .or_else(|| schema.changelog.as_ref().map(|c| c.write_enabled))
524 .unwrap_or(true);
525 if !changelog_enabled {
526 tracing::info!(
527 "Change-log outbox write disabled (FRAISEQL_CHANGELOG_ENABLED / [changelog] write_enabled)"
528 );
529 }
530
531 Ok(Self {
532 max_page_size,
533 audit_mutations,
534 changelog_enabled,
535 ..Self::default()
536 })
537 }
538}
539
540/// Resolve the top-level page-size ceiling (#421) by precedence.
541///
542/// `env` is the raw `FRAISEQL_MAX_PAGE_SIZE` value when set (a positive integer,
543/// or `"0"`/`"none"` to disable the ceiling). It overrides `compiled` (the
544/// `[validation] max_page_size` from the compiled schema), which overrides the
545/// runtime default (1000). Returns `None` only when explicitly disabled.
546#[must_use]
547pub fn page_size_precedence(env: Option<&str>, compiled: Option<u32>) -> Option<u32> {
548 if let Some(raw) = env {
549 let trimmed = raw.trim();
550 if trimmed.eq_ignore_ascii_case("none") || trimmed == "0" {
551 return None;
552 }
553 if let Ok(n) = trimmed.parse::<u32>() {
554 return Some(n);
555 }
556 // Unparseable env value: ignore it and fall through to compiled/default.
557 }
558 compiled.or(RuntimeConfig::default().max_page_size)
559}
560
561/// Execution context for query cancellation support.
562///
563/// This struct provides a mechanism for gracefully cancelling long-running queries
564/// via cancellation tokens, enabling proper cleanup and error reporting when:
565/// - A client connection closes
566/// - A user explicitly cancels a query
567/// - A system shutdown is initiated
568///
569/// # Example
570///
571/// ```no_run
572/// // Requires: a running tokio runtime and an Executor with a live database adapter.
573/// // See: tests/integration/ for runnable examples.
574/// use fraiseql_core::runtime::ExecutionContext;
575/// use std::time::Duration;
576///
577/// let ctx = ExecutionContext::new("query-123".to_string());
578///
579/// // Spawn a task that cancels after 5 seconds
580/// let cancel_token = ctx.cancellation_token().clone();
581/// tokio::spawn(async move {
582/// tokio::time::sleep(Duration::from_secs(5)).await;
583/// cancel_token.cancel();
584/// });
585///
586/// // Execute query with cancellation support
587/// // let result = executor.execute_with_context(query, None, &ctx).await;
588/// ```
589#[derive(Debug, Clone)]
590pub struct ExecutionContext {
591 /// Unique identifier for tracking the query execution
592 query_id: String,
593
594 /// Cancellation token for gracefully stopping the query
595 /// When cancelled, ongoing query execution should stop and return a Cancelled error
596 token: tokio_util::sync::CancellationToken,
597}
598
599impl ExecutionContext {
600 /// Create a new execution context with a cancellation token.
601 ///
602 /// # Arguments
603 ///
604 /// * `query_id` - Unique identifier for this query execution
605 ///
606 /// # Example
607 ///
608 /// ```rust
609 /// # use fraiseql_core::runtime::ExecutionContext;
610 /// let ctx = ExecutionContext::new("user-query-001".to_string());
611 /// assert_eq!(ctx.query_id(), "user-query-001");
612 /// ```
613 #[must_use]
614 pub fn new(query_id: String) -> Self {
615 Self {
616 query_id,
617 token: tokio_util::sync::CancellationToken::new(),
618 }
619 }
620
621 /// Get the query ID.
622 #[must_use]
623 pub fn query_id(&self) -> &str {
624 &self.query_id
625 }
626
627 /// Get a reference to the cancellation token.
628 ///
629 /// The returned token can be used to:
630 /// - Clone and pass to background tasks
631 /// - Check if cancellation was requested
632 /// - Propagate cancellation through the call stack
633 #[must_use]
634 pub const fn cancellation_token(&self) -> &tokio_util::sync::CancellationToken {
635 &self.token
636 }
637
638 /// Check if cancellation has been requested.
639 #[must_use]
640 pub fn is_cancelled(&self) -> bool {
641 self.token.is_cancelled()
642 }
643}
644
645#[cfg(test)]
646mod tests;