fraiseql_core/runtime/executor/core.rs
1//! `Executor` 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 backend::{
14 AdminSqlOutcome, AdminSqlRequest, RelayDatabaseAdapter, ResultCacheStats,
15 traits::{DatabaseAdapter, Writer},
16 types::{DatabaseType, PoolMetrics, QueryStatEntry},
17 },
18 cache::ViewName,
19 error::Result,
20 runtime::{QueryMatcher, QueryPlanner, RuntimeConfig, matcher::QueryMatch},
21 schema::{CompiledSchema, IntrospectionResponses},
22 security::SecurityContext,
23};
24
25/// Build the pre-computed introspection responses for a schema, with federation
26/// `@inaccessible` fields filtered out of `__type`/`__schema`.
27///
28/// Shared by [`Executor::with_config`] and [`Executor::with_config_and_relay`] so the
29/// relay-enabled and non-relay constructors apply identical introspection filtering — a
30/// relay executor must never expose a field in introspection that the non-relay path
31/// would hide (L-relay-inaccessible). The filter only affects `__type`/`__schema`; it does
32/// not touch data responses or `_entities` resolution.
33fn build_introspection(schema: &CompiledSchema) -> IntrospectionResponses {
34 // `mut` is required by the `#[cfg(feature = "federation")]` block below.
35 #[cfg_attr(not(feature = "federation"), allow(unused_mut))]
36 let mut introspection = IntrospectionResponses::build(schema);
37
38 #[cfg(feature = "federation")]
39 if let Some(fed_meta) = schema.federation_metadata() {
40 let inaccessible: HashMap<String, Vec<String>> = fed_meta
41 .types
42 .iter()
43 .filter(|t| !t.inaccessible_fields.is_empty())
44 .map(|t| (t.name.clone(), t.inaccessible_fields.clone()))
45 .collect();
46 introspection.filter_inaccessible(&inaccessible);
47 }
48
49 introspection
50}
51
52/// Resolve the GATE-1 validator for an executor (#379).
53///
54/// The embedder-installed `RuntimeConfig::query_validation` wins (it is the
55/// programmatic API, preserved across hot-reloads). Otherwise the compiled
56/// schema's declared `[validation]` depth and complexity limits are derived into
57/// a gate.
58///
59/// # Which transports this binds on
60///
61/// Every transport that executes a **GraphQL document** — `/graphql`, MCP, the
62/// functions bridge, direct embedders — because the gate is applied by `run_gate1`
63/// and `run_gate1` takes a query string. The `/graphql` HTTP stage applies the
64/// same limits independently, so a document reaching the executor by any other
65/// route is scored here rather than not at all.
66///
67/// It does **not** bind on a read that never had a document: the REST direct-read
68/// surface (the GET resolver, the three exports, the embedding sub-query, the bulk
69/// row selection) and both gRPC read arms resolve through
70/// `QueryRunner::resolve_direct_read`, which does not call `run_gate1` and cannot
71/// — there is no query string to score.
72///
73/// This doc previously said the derived gate binds on "every transport that
74/// reaches the executor". It does not, it never did, and the claim is the reason
75/// #1351 recorded the engine read path as covered when no direct read could reach
76/// the gate at all. What follows is the written answer to "which transports does
77/// this control bind on", so the next reader does not have to re-derive it.
78///
79/// | control | GraphQL document | REST direct read | gRPC row read |
80/// |---|---|---|---|
81/// | `[validation] max_query_depth` / `max_query_complexity` | yes — `run_gate1` | no | no |
82/// | `[security.cost_budget] per_request_max` | yes — `run_gate1`, whole document | yes — `resolve_direct_read`, summed over the request's reads | yes — via `resolve_direct_read` |
83/// | `[validation] max_page_size` | yes — `enforce_max_page_size` | yes | yes |
84/// | `[validation] max_response_bytes` | yes — charged on the returned rows | yes | yes, per frame when streamed |
85/// | `[rest] max_embedding_depth` | n/a | yes — `parse_select_with_embeddings` | n/a |
86///
87/// The two "no" cells are not gaps to be closed by routing more paths through
88/// `run_gate1`. Depth and complexity score a document because a document is where
89/// runtime-resolved work is described; a direct read of a materialised view is one
90/// row fetch whatever its nesting, and what bounds it is the page size and the
91/// bytes it returns. Scoring it by the document's multiply-per-level arithmetic
92/// would model an execution engine this framework does not have. See
93/// [`ResponseBudget`](crate::security::ResponseBudget).
94///
95/// The two per-request ceilings — `per_request_max` and `max_response_bytes` — are
96/// charged against **one budget per request**, which a transport lends to every read it
97/// issues for that request. See [`RequestBudget`](crate::security::RequestBudget).
98///
99/// A REST `?select=` embed is **one** read: its embedded levels are composed into the
100/// parent's statement (`execute_query_composed`), and the statement is scored as the tree
101/// it is — a nested `DirectReadProjection`, which charges exactly what the old
102/// one-sub-read-per-parent-row fan-out accumulated — before it is sent. So the per-read
103/// rows above bound an embed whole, and the aggregate control the fan-out needed
104/// (`[rest] max_embedded_reads`, a tally of sub-reads issued) is gone with the fan-out.
105/// An embedded `.count` is part of that statement and is charged with it.
106///
107/// The `Prefer: count=exact` total is the one read of a REST request that carries
108/// neither ceiling: it goes through `count_rows`, a second chokepoint that has never had
109/// a cost gate at all.
110///
111/// Derivation enforces what the schema declares, with one default: an undeclared
112/// depth is [`DEFAULT_MAX_QUERY_DEPTH`](crate::schema::DEFAULT_MAX_QUERY_DEPTH), so
113/// every executor has a depth gate. The projectors follow the selection to any depth,
114/// and without a bound the selection is whatever the client sends. An undeclared
115/// complexity stays unbounded. An embedder that must disable validation can install
116/// an explicit all-`usize::MAX` config.
117fn resolve_gate1(
118 config: &RuntimeConfig,
119 schema: &CompiledSchema,
120) -> crate::security::QueryValidator {
121 let effective = config.query_validation.clone().unwrap_or_else(|| {
122 let declared = schema.validation_config.as_ref();
123 let depth = declared
124 .and_then(|v| v.max_query_depth)
125 .unwrap_or(crate::schema::DEFAULT_MAX_QUERY_DEPTH);
126 crate::security::QueryValidatorConfig {
127 max_depth: depth as usize,
128 max_complexity: declared
129 .and_then(|v| v.max_query_complexity)
130 .map_or(usize::MAX, |c| c as usize),
131 max_size_bytes: usize::MAX,
132 max_aliases: usize::MAX,
133 }
134 });
135 crate::security::QueryValidator::from_config(effective)
136}
137
138/// Maximum number of distinct query strings whose parsed ASTs are cached in memory.
139///
140/// 1 024 entries covers the full distinct-query vocabulary of any realistic workload.
141/// Each entry holds an `Arc<(QueryType, Option<ParsedQuery>)>` — the AST is shared,
142/// not duplicated.
143const PARSE_CACHE_CAPACITY: u64 = 1_024;
144
145/// Distinct introspection selection-set shapes to memoise.
146///
147/// Small on purpose: the realistic population is one shape per client tool.
148const INTROSPECTION_PROJECTION_CAPACITY: u64 = 64;
149
150/// PostgreSQL's identifier length limit (`NAMEDATALEN - 1`). A longer schema name is
151/// silently truncated by the server, so a `DROP` built from one would target a
152/// *different* schema than the caller named.
153const MAX_PG_IDENTIFIER_LEN: usize = 63;
154
155/// Query executor - executes compiled GraphQL queries.
156///
157/// This is the main entry point for runtime query execution.
158/// It coordinates matching, planning, execution, and projection.
159///
160/// # The adapter has no name here
161///
162/// `Executor` is not generic over its adapter. The concrete type is consumed by the
163/// constructor and stored erased, as `Arc<dyn DatabaseAdapter>`, so a holder of an
164/// `Executor` cannot name the adapter, cannot recover it, and cannot call anything on
165/// it that the engine does not offer. That is the whole point: a transport handed an
166/// executor is handed the engine's surface, not a database handle it can go around the
167/// engine with.
168///
169/// The capability that the type parameter used to carry is carried by a value instead.
170/// A constructor bounded on [`Writer`] holds the adapter as its write handle; everything
171/// else holds `None` and refuses every write. See [`Executor::new`] and
172/// [`Executor::read_only`].
173///
174/// # Ownership and Lifetimes
175///
176/// The executor holds owned references to schema and runtime data, with no borrowed pointers:
177/// - `schema`: Owned `CompiledSchema` (immutable after construction)
178/// - `adapter`: Shared via `Arc<dyn DatabaseAdapter>` so multiple executors and tasks use the same
179/// connection pool
180/// - `introspection`: Owned cached GraphQL schema responses
181/// - `config`: Owned runtime configuration
182///
183/// **No explicit lifetimes required** - all data is either owned or wrapped in `Arc`,
184/// so the executor can be stored in long-lived structures without lifetime annotations or
185/// borrow-checker issues.
186///
187/// # Concurrency
188///
189/// `Executor` is `Send + Sync` — `DatabaseAdapter` requires both of every implementor, so
190/// erasing the adapter cannot lose them. It can be safely shared across threads and tasks
191/// without cloning:
192/// ```no_run
193/// // Requires: a live database adapter.
194/// // See: tests/integration/ for runnable examples.
195/// # use std::sync::Arc;
196/// // let executor = Arc::new(Executor::new(schema, adapter));
197/// // Can be cloned into multiple tasks
198/// // let exec_clone = executor.clone();
199/// // tokio::spawn(async move {
200/// // let result = exec_clone.execute(query, vars).await;
201/// // });
202/// ```
203///
204/// # Query Timeout
205///
206/// Queries are protected by the `query_timeout_ms` configuration in `RuntimeConfig` (default: 30s).
207/// When a query exceeds this timeout, it returns `FraiseQLError::Timeout` without panicking.
208/// Set `query_timeout_ms` to 0 to disable timeout enforcement.
209pub struct Executor {
210 /// All shared state — schema, adapter, config, caches, relay.
211 pub(super) ctx: Arc<ExecutorContext>,
212}
213
214/// Compile-time enforcement lives here, on the **constructor**.
215///
216/// The bound on this block is what keeps a read-only adapter from ever being handed a
217/// write handle. Its witness is `FraiseWireAdapter`, which implements `DatabaseAdapter`
218/// and deliberately not `Writer`.
219///
220/// ⚠ **The two blocks below are a pair, and only the pair is the assertion.** A
221/// `compile_fail` block is satisfied by *any* compile error, including one with nothing
222/// to do with the rule — the previous version of this pair named `SqliteAdapter`, and
223/// when #374 deleted that adapter the block passed for a full release while proving
224/// nothing. The two differ by exactly the constructor called. If the witness ever stops
225/// resolving, the *first* block goes red rather than the second going quietly green.
226///
227/// `FraiseWireAdapter` needs `--all-features`; every `--doc` invocation in this
228/// repository passes it (`Makefile`, `.dagger/main.go`).
229///
230/// The witness resolves, and the read-only constructor accepts it:
231///
232/// ```
233/// use fraiseql_core::{db::FraiseWireAdapter, runtime::Executor, schema::CompiledSchema};
234/// use std::sync::Arc;
235/// fn _read_only_is_available(schema: CompiledSchema, adapter: Arc<FraiseWireAdapter>) {
236/// let _ = Executor::read_only(schema, adapter);
237/// }
238/// ```
239///
240/// …and the write-capable constructor does not compile for it:
241///
242/// ```compile_fail
243/// use fraiseql_core::{db::FraiseWireAdapter, runtime::Executor, schema::CompiledSchema};
244/// use std::sync::Arc;
245/// fn _wont_compile(schema: CompiledSchema, adapter: Arc<FraiseWireAdapter>) {
246/// let _ = Executor::new(schema, adapter);
247/// }
248/// ```
249impl Executor {
250 /// Create a new write-capable executor.
251 ///
252 /// Available only for adapters that implement [`Writer`] — implementing its one
253 /// method is the write capability (ruling AA 6). For one that does not — or one whose
254 /// write capability you do not want to grant — use [`read_only`](Executor::read_only),
255 /// which is bounded only on `DatabaseAdapter`.
256 ///
257 /// # Arguments
258 ///
259 /// * `schema` - Compiled schema
260 /// * `adapter` - Database adapter
261 ///
262 /// # Example
263 ///
264 /// ```no_run
265 /// // Requires: a live PostgreSQL database.
266 /// // See: tests/integration/ for runnable examples.
267 /// # use fraiseql_core::schema::CompiledSchema;
268 /// # use fraiseql_core::db::postgres::PostgresAdapter;
269 /// # use fraiseql_core::runtime::Executor;
270 /// # use std::sync::Arc;
271 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
272 /// # let schema_json = r#"{"types":[],"queries":[]}"#;
273 /// # let connection_string = "postgresql://localhost/mydb";
274 /// let schema = CompiledSchema::from_json(schema_json, false)?;
275 /// let adapter = PostgresAdapter::new(connection_string).await?;
276 /// let executor = Executor::new(schema, Arc::new(adapter));
277 /// # Ok(()) }
278 /// ```
279 #[must_use]
280 pub fn new<A: Writer>(schema: CompiledSchema, adapter: Arc<A>) -> Self {
281 Self::with_config(schema, adapter, RuntimeConfig::default())
282 }
283
284 /// Create a new write-capable executor with custom configuration.
285 ///
286 /// # Arguments
287 ///
288 /// * `schema` - Compiled schema
289 /// * `adapter` - Database adapter
290 /// * `config` - Runtime configuration
291 #[must_use]
292 pub fn with_config<A: Writer>(
293 schema: CompiledSchema,
294 adapter: Arc<A>,
295 config: RuntimeConfig,
296 ) -> Self {
297 let writer: Arc<dyn Writer> = Arc::clone(&adapter) as Arc<dyn Writer>;
298 let adapter: Arc<dyn DatabaseAdapter> = adapter;
299 Self::build(schema, adapter, std::any::type_name::<A>(), config, None, Some(writer))
300 }
301
302 /// Create a new executor that cannot write.
303 ///
304 /// The entry for an adapter that is not a [`Writer`] — a read-replica handle,
305 /// `FraiseWireAdapter`, a read-only test double — and for a write-capable adapter you
306 /// want to expose read-only. Mutations are refused on every transport, because the
307 /// refusal is the absence of a handle rather than a check somebody has to remember.
308 ///
309 /// # Arguments
310 ///
311 /// * `schema` - Compiled schema
312 /// * `adapter` - Database adapter
313 #[must_use]
314 pub fn read_only<A: DatabaseAdapter>(schema: CompiledSchema, adapter: Arc<A>) -> Self {
315 Self::read_only_with_config(schema, adapter, RuntimeConfig::default())
316 }
317
318 /// Create a new read-only executor with custom configuration.
319 ///
320 /// # Arguments
321 ///
322 /// * `schema` - Compiled schema
323 /// * `adapter` - Database adapter
324 /// * `config` - Runtime configuration
325 #[must_use]
326 pub fn read_only_with_config<A: DatabaseAdapter>(
327 schema: CompiledSchema,
328 adapter: Arc<A>,
329 config: RuntimeConfig,
330 ) -> Self {
331 let adapter: Arc<dyn DatabaseAdapter> = adapter;
332 Self::build(schema, adapter, std::any::type_name::<A>(), config, None, None)
333 }
334
335 /// The one construction path. Every public constructor and every rebuild funnels
336 /// through here, so a new executor cannot differ from the others by an omitted
337 /// field — which is the drift #750 set out to prevent by recording a closure.
338 ///
339 /// `relay` is passed in rather than built here because constructing a
340 /// `RelayDispatchImpl` needs a `RelayDatabaseAdapter` bound that this impl block
341 /// deliberately does not carry. `adapter_name` is the adapter's type, named by the
342 /// constructor that still knows it, for the one diagnostic that has to say which
343 /// adapter it means.
344 ///
345 /// `writer` is the write handle: `Some` exactly when a constructor bounded on [`Writer`]
346 /// built the executor.
347 fn build(
348 schema: CompiledSchema,
349 adapter: Arc<dyn DatabaseAdapter>,
350 adapter_name: &'static str,
351 config: RuntimeConfig,
352 relay: Option<Arc<dyn RelayDispatch>>,
353 writer: Option<Arc<dyn Writer>>,
354 ) -> Self {
355 let gate1 = resolve_gate1(&config, &schema);
356 // One depth bound: the resolver refuses at the gate's depth, so a document the
357 // gate admits is never refused by a second, fixed limit — and one it refuses is
358 // refused by the resolver too on the paths that classify before the gate runs.
359 let max_depth = u32::try_from(gate1.config().max_depth).unwrap_or(u32::MAX);
360 let matcher = QueryMatcher::new(schema.clone()).with_max_depth(max_depth);
361 let planner = QueryPlanner::new(config.cache_query_plans);
362 // Build introspection responses at startup (zero-cost at runtime),
363 // with `@inaccessible` fields filtered out. Shared with the relay
364 // constructor so both paths apply the identical filtering (L-relay-inaccessible).
365 let introspection = build_introspection(&schema);
366
367 // Build O(1) node-type index: return_type → sql_source.
368 // The first query with a matching return_type and a non-None sql_source wins
369 // (consistent with the previous linear-scan behaviour).
370 let mut node_type_index: HashMap<String, Arc<str>> = HashMap::new();
371 for q in &schema.queries {
372 if let Some(src) = q.sql_source.as_deref() {
373 node_type_index.entry(q.return_type.clone()).or_insert_with(|| Arc::from(src));
374 }
375 }
376
377 // Compute the schema version (content hash) once — it is stamped onto
378 // every change-log outbox row and is too expensive to recompute per call.
379 let schema_version: Arc<str> = Arc::from(schema.content_hash());
380
381 // Rulings X 2 and Z 1: every write commits only once its response is built, so
382 // every write needs the commit-gated write. An adapter without it gets no
383 // mutations, decided here and said once, rather than a refusal per request or a
384 // write committed before it was adjudicated.
385 let nested_row_gates = super::runners::query_nested::NestedRowGates::build(
386 &schema,
387 config.rls_policy.as_deref(),
388 );
389 let ctx = Arc::new(ExecutorContext {
390 schema,
391 schema_version,
392 nested_row_gates,
393 adapter,
394 adapter_name,
395 writer,
396 relay,
397 matcher,
398 planner,
399 config,
400 introspection,
401 node_type_index,
402 gate1,
403 parse_cache: MokaCache::new(PARSE_CACHE_CAPACITY),
404 introspection_projections: MokaCache::new(INTROSPECTION_PROJECTION_CAPACITY),
405 });
406
407 Self { ctx }
408 }
409
410 /// Return current connection pool metrics from the underlying database adapter.
411 ///
412 /// Values are sampled live on each call — not cached — so callers (e.g., the
413 /// `/metrics` endpoint) always observe up-to-date pool health.
414 #[must_use]
415 pub fn pool_metrics(&self) -> PoolMetrics {
416 self.ctx.pool_metrics()
417 }
418
419 /// How deep a selection may nest on this executor: its depth gate's
420 /// `max_query_depth` — declared, installed, or `DEFAULT_MAX_QUERY_DEPTH`. Every
421 /// resolution of a document's selection set refuses past it, including the
422 /// transport's own (the SSE planner), so no path answers depth differently.
423 #[must_use]
424 pub fn max_query_depth(&self) -> u32 {
425 u32::try_from(self.ctx.gate1.config().max_depth).unwrap_or(u32::MAX)
426 }
427
428 /// Get the compiled schema.
429 #[must_use]
430 pub fn schema(&self) -> &CompiledSchema {
431 &self.ctx.schema
432 }
433
434 /// Get runtime configuration.
435 #[must_use]
436 pub fn config(&self) -> &RuntimeConfig {
437 &self.ctx.config
438 }
439
440 /// Whether this executor can dispatch relay (cursor) pagination.
441 ///
442 /// `true` only for executors built by [`Executor::new_with_relay`] or
443 /// [`Executor::with_config_and_relay`]; a relay query issued against any
444 /// other executor returns a `Validation` error. Exposed so a rebuild of the
445 /// executor — a hot-reload, a per-tenant provision — can assert it preserved
446 /// the capability rather than silently downgrading it (#750).
447 #[must_use]
448 pub fn relay_enabled(&self) -> bool {
449 self.ctx.relay.is_some()
450 }
451
452 /// Which backend this executor is bound to.
453 #[must_use]
454 pub fn database_type(&self) -> DatabaseType {
455 self.ctx.database_type()
456 }
457
458 /// Whether the backend is reachable.
459 ///
460 /// # Errors
461 ///
462 /// Returns the backend's own error when the probe fails.
463 pub async fn health_check(&self) -> Result<()> {
464 self.ctx.health_check().await
465 }
466
467 /// The slowest `limit` statements the backend is willing to report.
468 ///
469 /// # Errors
470 ///
471 /// Returns `FraiseQLError::Unsupported` on a backend with no statement-stats
472 /// facility, or the backend's own error when the read fails.
473 pub async fn query_stats(&self, limit: u32) -> Result<Vec<QueryStatEntry>> {
474 self.ctx.query_stats(limit).await
475 }
476
477 /// One statement's stats by backend-assigned id.
478 ///
479 /// # Errors
480 ///
481 /// As [`Executor::query_stats`].
482 pub async fn query_stats_by_id(&self, id: &str) -> Result<Option<QueryStatEntry>> {
483 self.ctx.query_stats_by_id(id).await
484 }
485
486 /// Discard the backend's accumulated statement statistics.
487 ///
488 /// # Errors
489 ///
490 /// As [`Executor::query_stats`].
491 pub async fn reset_query_stats(&self) -> Result<()> {
492 self.ctx.reset_query_stats().await
493 }
494
495 /// Adapter-level result-cache counters, or `None` when no cache is active.
496 #[must_use]
497 pub fn result_cache_stats(&self) -> Option<ResultCacheStats> {
498 self.ctx.result_cache_stats()
499 }
500
501 /// Evict every entry from the adapter-level result cache.
502 ///
503 /// `Ok(None)` means the backend has no such cache to clear.
504 ///
505 /// # Errors
506 ///
507 /// Returns the backend's own error when eviction fails.
508 pub async fn clear_result_cache(&self) -> Result<Option<usize>> {
509 self.ctx.clear_result_cache().await
510 }
511
512 /// Evict adapter-level result-cache entries derived from the given views.
513 ///
514 /// Returns the number of entries removed; `0` on a backend with no such cache.
515 ///
516 /// # Errors
517 ///
518 /// Returns the backend's own error when eviction fails.
519 pub async fn invalidate_views(&self, views: &[ViewName]) -> Result<u64> {
520 self.ctx.invalidate_views(views).await
521 }
522
523 /// The backend's plan for a statement, as its own JSON shape.
524 ///
525 /// # Errors
526 ///
527 /// Returns `FraiseQLError::Unsupported` on a backend with no `EXPLAIN`, or the
528 /// backend's own error when planning fails.
529 pub async fn explain_query(
530 &self,
531 sql: &str,
532 params: &[serde_json::Value],
533 ) -> Result<serde_json::Value> {
534 self.ctx.explain_query(sql, params).await
535 }
536
537 /// Run the admin-SQL route's bounded statement.
538 ///
539 /// # Errors
540 ///
541 /// Returns `FraiseQLError::Unsupported` on a backend that declines admin SQL, or
542 /// the backend's own error when the statement fails.
543 pub async fn execute_admin_sql(&self, request: &AdminSqlRequest) -> Result<AdminSqlOutcome> {
544 self.ctx.execute_admin_sql(request).await
545 }
546
547 /// Tell the backend the compiled schema changed, so it can drop anything it
548 /// derived from the old one — a result cache keyed by the old views, say.
549 ///
550 /// Call this *before* [`Executor::rebuild_with`]: the backend is shared between
551 /// the old executor and the new one, so anything stale it holds would otherwise
552 /// outlive the swap.
553 pub fn on_schema_reload(&self) {
554 self.ctx.on_schema_reload();
555 }
556
557 /// Refuse `schema` when this executor's reads go to hot standbys and a source of it
558 /// depends on an UNLOGGED or temporary table (#1390).
559 ///
560 /// The boot-time check, re-run by a hot reload before it swaps: a query added by a
561 /// reload reads through the same replicas the server was started with.
562 ///
563 /// # Errors
564 ///
565 /// See [`crate::schema::refuse_standby_unreadable_sources`].
566 pub async fn refuse_standby_unreadable_sources(&self, schema: &CompiledSchema) -> Result<()> {
567 self.ctx.refuse_standby_unreadable_sources(schema).await
568 }
569
570 /// Drop a tenant's PostgreSQL schema and everything in it.
571 ///
572 /// Takes the schema *name*, not a statement: the engine composes the DDL, so no
573 /// caller-supplied SQL reaches the backend through this door. Deleting
574 /// `Executor::adapter` removed the transports' general raw-SQL reach, and this is
575 /// deliberately not a replacement for it — it does one thing.
576 ///
577 /// The name is re-validated here even though `fraiseql-server` validates the
578 /// tenant key before deriving it. This is the interpolation site, so it is the
579 /// site that has to be safe on its own; a guard that holds only while every
580 /// caller remembers to validate is not a guard.
581 ///
582 /// # Errors
583 ///
584 /// Returns `FraiseQLError::Validation` if `schema_name` is not a bare identifier
585 /// (ASCII alphanumeric and underscore, non-empty, at most 63 bytes), and the
586 /// backend's own error if the DDL fails.
587 pub async fn drop_tenant_schema(&self, schema_name: &str) -> Result<()> {
588 if schema_name.is_empty()
589 || schema_name.len() > MAX_PG_IDENTIFIER_LEN
590 || !schema_name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
591 {
592 return Err(crate::error::FraiseQLError::validation(format!(
593 "refusing to drop schema '{schema_name}': not a bare identifier"
594 )));
595 }
596 self.ctx
597 .execute_ddl(&format!("DROP SCHEMA IF EXISTS {schema_name} CASCADE"))
598 .await
599 }
600
601 /// Build a new executor over *this* executor's backend, for a hot-reload or a
602 /// re-provision.
603 ///
604 /// Supersedes the recorded-rebuilder closure of #750. That closure existed
605 /// because relay dispatch needs a `RelayDatabaseAdapter` bound that is only in
606 /// scope at the relay constructor, so a rebuild had to re-run the constructor
607 /// that had it — and the recording could be wrong, which is what #750 guarded
608 /// against by hand. Nothing needs re-running: `RelayDispatchImpl` holds the
609 /// adapter and nothing schema-derived, so the *same* dispatch object is still
610 /// correct for the new schema and is carried over as-is. A rebuild cannot
611 /// downgrade a relay executor to a non-relay one, because there is no longer a
612 /// step that could omit it.
613 ///
614 /// The write grant is carried over for the same reason and in the same way. It is
615 /// resolved from the adapter, and the adapter is unchanged, so recomputing it could
616 /// only ever agree — but a rebuild that *recomputed* a capability is precisely the
617 /// step #750 showed can omit one. Carrying it makes a downgrade unrepresentable
618 /// rather than merely unlikely. The refusal beside it is recomputed from the same
619 /// adapter, so it agrees with the boot (ruling Z 1).
620 #[must_use]
621 pub fn rebuild_with(&self, schema: CompiledSchema, config: RuntimeConfig) -> Self {
622 Self::build(
623 schema,
624 Arc::clone(&self.ctx.adapter),
625 self.ctx.adapter_name,
626 config,
627 self.ctx.relay.clone(),
628 self.ctx.writer.clone(),
629 )
630 }
631
632 /// Return the number of entries currently held in the parsed-query AST cache.
633 ///
634 /// Exposed for testing only — callers outside `#[cfg(test)]` code should not
635 /// rely on the exact count, which may lag by one maintenance cycle in moka.
636 #[cfg(test)]
637 #[must_use]
638 pub fn parse_cache_entry_count(&self) -> u64 {
639 self.ctx.parse_cache.entry_count()
640 }
641
642 /// Rebuild an executor view over an already-shared context.
643 ///
644 /// `Executor` *is* its `Arc<ExecutorContext>`, so this is one atomic
645 /// increment — the same zero-cost move the runner accessors make. It exists so
646 /// a component holding only the context (the `before:mutation` read bridge,
647 /// which is built inside `execute_mutation_impl`) can reach the read entry
648 /// points, which are `impl Executor`.
649 pub(super) const fn from_ctx(ctx: Arc<ExecutorContext>) -> Self {
650 Self { ctx }
651 }
652
653 /// Construct a query runner on demand.
654 ///
655 /// Zero-cost: `Arc::clone` is one atomic increment, no allocation.
656 pub(super) fn query_runner(&self) -> runners::query::QueryRunner {
657 runners::query::QueryRunner::new(Arc::clone(&self.ctx))
658 }
659
660 /// Construct an aggregate runner on demand.
661 ///
662 /// Zero-cost: `Arc::clone` is one atomic increment, no allocation.
663 pub(super) fn aggregate_runner(&self) -> runners::aggregate::AggregateRunner {
664 runners::aggregate::AggregateRunner::new(Arc::clone(&self.ctx))
665 }
666
667 /// Execute an aggregate query directly.
668 ///
669 /// # Errors
670 ///
671 /// Returns error if query parsing, plan generation, SQL generation, database execution,
672 /// or result projection fails.
673 pub async fn execute_aggregate_query(
674 &self,
675 query_json: &serde_json::Value,
676 query_name: &str,
677 metadata: &crate::compiler::fact_table::FactTableMetadata,
678 ) -> Result<serde_json::Value> {
679 // #422: operation-level authorization for the public aggregate embedder entry
680 // (the GraphQL aggregate path is gated at the chokepoint, not here, to
681 // avoid double-gating). Fail-closed → 403.
682 if let Some(authorizer) = self.ctx.config.authorizer.as_ref() {
683 let ops = [super::support::authz::root_operation(
684 &self.ctx.schema,
685 crate::security::OperationKind::Query,
686 query_name,
687 )];
688 crate::security::authorizer::enforce_authz(
689 authorizer.as_ref(),
690 None,
691 &ops,
692 Some(query_json),
693 )?;
694 }
695 self.aggregate_runner()
696 .execute_aggregate_query(query_json, query_name, metadata, None)
697 .await
698 }
699
700 /// Execute a window query directly.
701 ///
702 /// # Errors
703 ///
704 /// Returns error if query parsing, plan generation, SQL generation, database execution,
705 /// or result projection fails.
706 pub async fn execute_window_query(
707 &self,
708 query_json: &serde_json::Value,
709 query_name: &str,
710 metadata: &crate::compiler::fact_table::FactTableMetadata,
711 ) -> Result<serde_json::Value> {
712 // #422: operation-level authorization for the public window embedder entry
713 // (the GraphQL window path is gated at the chokepoint, not here).
714 // Fail-closed → 403.
715 if let Some(authorizer) = self.ctx.config.authorizer.as_ref() {
716 let ops = [super::support::authz::root_operation(
717 &self.ctx.schema,
718 crate::security::OperationKind::Query,
719 query_name,
720 )];
721 crate::security::authorizer::enforce_authz(
722 authorizer.as_ref(),
723 None,
724 &ops,
725 Some(query_json),
726 )?;
727 }
728 self.aggregate_runner()
729 .execute_window_query(query_json, query_name, metadata, None)
730 .await
731 }
732
733 /// Count rows matching a query's filters.
734 ///
735 /// Delegates to `QueryRunner::count_rows`.
736 ///
737 /// # Errors
738 ///
739 /// Returns `FraiseQLError::Validation` if the query has no SQL source, or if
740 /// inject params are required but no security context is provided.
741 /// Returns `FraiseQLError::Database` if the adapter returns an error.
742 pub async fn count_rows(
743 &self,
744 query_match: &QueryMatch,
745 variables: Option<&serde_json::Value>,
746 security_context: Option<&SecurityContext>,
747 ) -> Result<u64> {
748 // #1336 backstop: REST reads enter here rather than through the GraphQL
749 // document path, so the guard cannot live in `execute_with_timeout` alone.
750 crate::runtime::executor::support::security::enforce_enrichment_resolved(
751 &self.ctx.schema,
752 security_context,
753 )?;
754
755 self.query_runner().count_rows(query_match, variables, security_context).await
756 }
757
758 /// Execute a pre-resolved query match directly, bypassing GraphQL parsing.
759 ///
760 /// Used by the REST transport after route resolution: the `QueryMatch` is
761 /// already computed from HTTP path/query parameters, so there is no need
762 /// to re-parse a GraphQL query string.
763 ///
764 /// # Errors
765 ///
766 /// Returns `FraiseQLError::Validation` if the query has no SQL source.
767 /// Returns `FraiseQLError::Database` if the adapter returns an error.
768 pub async fn execute_query_direct(
769 &self,
770 query_match: &QueryMatch,
771 variables: Option<&serde_json::Value>,
772 security_context: Option<&SecurityContext>,
773 request_budget: Option<&crate::security::RequestBudget>,
774 ) -> Result<serde_json::Value> {
775 // #1336 backstop: REST reads enter here rather than through the GraphQL
776 // document path, so the guard cannot live in `execute_with_timeout` alone.
777 crate::runtime::executor::support::security::enforce_enrichment_resolved(
778 &self.ctx.schema,
779 security_context,
780 )?;
781
782 self.query_runner()
783 .execute_query_direct(query_match, variables, security_context, request_budget)
784 .await
785 }
786
787 /// Whether this executor's database adapter can compose related resources into a read
788 /// ([`execute_query_composed`](Self::execute_query_composed)).
789 ///
790 /// The REST mount reads it to warn at boot; `execute_query_composed` refuses from it with
791 /// `501`. One answer for both, so the boot and the request cannot disagree.
792 #[must_use]
793 pub fn supports_composed_reads(&self) -> bool {
794 self.ctx.adapter.supports_composed_reads()
795 }
796
797 /// Execute a pre-resolved query match with related resources composed into it —
798 /// the REST `?select=` embed.
799 ///
800 /// One statement, not one read per parent row per level: each embedded level is a
801 /// correlated subquery joined into the parent's, and every level is gated as the read
802 /// of its own target it replaces — the target query's authorization and role gates,
803 /// its RLS predicate, and field-level RBAC against the target type, all decided before
804 /// anything is sent. See `runners::query_composed`.
805 ///
806 /// `request_budget` is charged exactly as by
807 /// [`execute_query_direct`](Self::execute_query_direct): the cost of the whole tree
808 /// once, before the statement, and the bytes of what it returns.
809 ///
810 /// # Errors
811 ///
812 /// Everything [`execute_query_direct`](Self::execute_query_direct) returns, for the
813 /// root and for every embedded level; `FraiseQLError::Validation` for a relationship
814 /// the parent type does not declare; `FraiseQLError::Unsupported` from a database
815 /// adapter that cannot compose a read.
816 pub async fn execute_query_composed(
817 &self,
818 query_match: &QueryMatch,
819 embeds: &[crate::runtime::EmbedSelection],
820 counts: &[crate::runtime::CountSelection],
821 variables: Option<&serde_json::Value>,
822 security_context: Option<&SecurityContext>,
823 request_budget: Option<&crate::security::RequestBudget>,
824 ) -> Result<serde_json::Value> {
825 // #1336 backstop, as on every direct-read entry.
826 crate::runtime::executor::support::security::enforce_enrichment_resolved(
827 &self.ctx.schema,
828 security_context,
829 )?;
830
831 self.query_runner()
832 .execute_query_composed(
833 query_match,
834 embeds,
835 counts,
836 variables,
837 security_context,
838 request_budget,
839 )
840 .await
841 }
842
843 /// The allowances **one request** holds, to be shared by every read that request
844 /// issues.
845 ///
846 /// A transport that answers one request with several reads — the REST `?select=`
847 /// embed, which resolves one sub-read per parent row per level — builds one of these
848 /// and passes it by reference to each
849 /// [`execute_query_direct`](Self::execute_query_direct). Without it each sub-read
850 /// gets allowances of its own, and then `[validation] max_response_bytes` bounds
851 /// each sub-read rather than the response they add up to, and `[security.cost_budget]
852 /// per_request_max` scores each sub-read rather than the request they are all part
853 /// of — two ceilings named for a request and enforced on something smaller.
854 ///
855 /// Built here rather than in the transport so the ceilings are read from the compiled
856 /// configuration in the one place that owns them, and a transport cannot supply a
857 /// budget with ceilings of its own choosing.
858 ///
859 /// Always a budget, never an `Option`: the two ceilings are declared independently
860 /// and either may be absent, which is a distinction
861 /// [`RequestBudget`](crate::security::RequestBudget) keeps internally rather than
862 /// collapsing into "no budget".
863 #[must_use]
864 pub fn request_budget(&self) -> crate::security::RequestBudget {
865 crate::security::RequestBudget::new(
866 self.ctx.config.max_response_bytes,
867 self.ctx.config.max_operation_cost,
868 )
869 }
870
871 /// The same read as [`execute_query_direct`](Self::execute_query_direct),
872 /// delivered as a stream of projected rows (#958).
873 ///
874 /// The export representations — NDJSON, CSV, XLSX — consume rows, not a
875 /// response envelope, and used to obtain them by re-executing
876 /// `execute_query_direct` with a walking `OFFSET`. That is `O(offset)` per page
877 /// and gives each page its own snapshot, so a concurrent write can move a row
878 /// across a page boundary and the export duplicates or drops it. One statement
879 /// has neither problem.
880 ///
881 /// The read resolves through the same authorization, RLS, `inject_params` and
882 /// field-RBAC path as the buffered call — see
883 /// `QueryRunner::resolve_direct_read`.
884 ///
885 /// ⚠ On PostgreSQL the returned stream holds a pooled connection until it is
886 /// dropped. Consume it promptly and drop it when the response ends.
887 ///
888 /// # Errors
889 ///
890 /// Returns `FraiseQLError::Authorization` if the operation or a selected field is
891 /// refused, `FraiseQLError::Validation` if the query has no SQL source or a
892 /// required principal is absent, and `FraiseQLError::Database` if the read fails
893 /// before its first row.
894 pub async fn stream_query_direct(
895 &self,
896 query_match: QueryMatch,
897 variables: Option<serde_json::Value>,
898 security_context: Option<SecurityContext>,
899 ) -> Result<crate::runtime::JsonRowStream> {
900 // #1336 backstop — the streaming twin of `execute_query_direct`.
901 crate::runtime::executor::support::security::enforce_enrichment_resolved(
902 &self.ctx.schema,
903 security_context.as_ref(),
904 )?;
905
906 self.query_runner()
907 .stream_query_direct(query_match, variables, security_context)
908 .await
909 }
910
911 /// Execute a row-shaped read — the same read as
912 /// [`execute_query_direct`](Self::execute_query_direct), answered as typed
913 /// column values rather than GraphQL-shaped JSON (#1351).
914 ///
915 /// This is the gRPC transport's read entry. gRPC answers **row-shaped** results
916 /// projected through protobuf `ColumnSpec`s built from the message descriptor,
917 /// and there is no selection set on the wire — so it cannot use the JSON entry
918 /// without a round-trip through a shape it chose this transport to avoid. It
919 /// used to resolve its own predicate and call
920 /// [`DatabaseAdapter::execute_row_query`] directly instead, which made it a
921 /// second read implementation: the operation `Authorizer` (#422), the
922 /// `requires_role` gate (#1122), the actor allow-list (#966), the field gate
923 /// (#423) and the compiled page-size ceiling (#421) applied to every transport
924 /// but that one.
925 ///
926 /// The caller must project with [`RowRead::columns`](crate::runtime::RowRead::columns),
927 /// not with the columns it
928 /// passed in — see that field for why.
929 ///
930 /// # Errors
931 ///
932 /// Returns `FraiseQLError::Authorization` when the operation, the role gate, the
933 /// actor gate or a selected gated field is refused; `FraiseQLError::Validation`
934 /// when the query has no SQL source or a configured policy has no principal to
935 /// evaluate for (fail closed, #784); `FraiseQLError::Unsupported` for a resolved
936 /// shape the row path cannot carry; and `FraiseQLError::Database` if the read
937 /// fails.
938 pub async fn execute_row_read(
939 &self,
940 query_match: &QueryMatch,
941 variables: Option<&serde_json::Value>,
942 security_context: Option<&SecurityContext>,
943 columns: &[fraiseql_db::types::ColumnSpec],
944 ) -> Result<super::RowRead> {
945 // #1336 backstop: a gRPC read enters here rather than through the GraphQL
946 // document path, so the guard cannot live in `execute_with_timeout` alone.
947 crate::runtime::executor::support::security::enforce_enrichment_resolved(
948 &self.ctx.schema,
949 security_context,
950 )?;
951
952 self.query_runner()
953 .execute_row_read(query_match, variables, security_context, columns)
954 .await
955 }
956
957 /// The same read as [`execute_row_read`](Self::execute_row_read), delivered one
958 /// row at a time (#1351).
959 ///
960 /// The gRPC server-streaming arm's source. Both arms resolve through one
961 /// function, because the two have already drifted apart once: #1348 found the
962 /// unary arm fail-closed and the streaming arm fail-open on the same RLS
963 /// evaluation failure.
964 ///
965 /// ⚠ On PostgreSQL the returned stream holds a pooled connection until it is
966 /// dropped. Consume it promptly and drop it when the response ends.
967 ///
968 /// # Errors
969 ///
970 /// The same as [`execute_row_read`](Self::execute_row_read).
971 pub async fn stream_row_read(
972 &self,
973 query_match: &QueryMatch,
974 variables: Option<&serde_json::Value>,
975 security_context: Option<&SecurityContext>,
976 columns: &[fraiseql_db::types::ColumnSpec],
977 ) -> Result<super::StreamedRowRead> {
978 // #1336 backstop — the streaming twin of `execute_row_read`.
979 crate::runtime::executor::support::security::enforce_enrichment_resolved(
980 &self.ctx.schema,
981 security_context,
982 )?;
983
984 self.query_runner()
985 .stream_row_read(query_match, variables, security_context, columns)
986 .await
987 }
988}
989
990impl Executor {
991 /// Create a new executor with relay cursor pagination enabled.
992 ///
993 /// Only callable when `A: RelayDatabaseAdapter`. The relay capability is
994 /// encoded once at construction time as a type-erased `Arc<dyn RelayDispatch>`,
995 /// so there is no per-query overhead beyond an `Option::is_some()` check.
996 ///
997 /// # Example
998 ///
999 /// ```no_run
1000 /// // Requires: a live PostgreSQL database with relay support.
1001 /// // See: tests/integration/ for runnable examples.
1002 /// # use fraiseql_core::schema::CompiledSchema;
1003 /// # use fraiseql_core::db::postgres::PostgresAdapter;
1004 /// # use fraiseql_core::runtime::Executor;
1005 /// # use std::sync::Arc;
1006 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1007 /// # let connection_string = "postgresql://localhost/mydb";
1008 /// # let schema: CompiledSchema = panic!("example");
1009 /// let adapter = PostgresAdapter::new(connection_string).await?;
1010 /// let executor = Executor::new_with_relay(schema, Arc::new(adapter));
1011 /// # Ok(()) }
1012 /// ```
1013 #[must_use]
1014 pub fn new_with_relay<A: Writer + RelayDatabaseAdapter + 'static>(
1015 schema: CompiledSchema,
1016 adapter: Arc<A>,
1017 ) -> Self {
1018 Self::with_config_and_relay(schema, adapter, RuntimeConfig::default())
1019 }
1020
1021 /// Create a new executor with relay support and custom configuration.
1022 #[must_use]
1023 pub fn with_config_and_relay<A: Writer + RelayDatabaseAdapter + 'static>(
1024 schema: CompiledSchema,
1025 adapter: Arc<A>,
1026 config: RuntimeConfig,
1027 ) -> Self {
1028 let relay_dispatch: Arc<dyn RelayDispatch> =
1029 Arc::new(RelayDispatchImpl(Arc::clone(&adapter)));
1030 let writer: Arc<dyn Writer> = Arc::clone(&adapter) as Arc<dyn Writer>;
1031 let adapter: Arc<dyn DatabaseAdapter> = adapter;
1032 Self::build(
1033 schema,
1034 adapter,
1035 std::any::type_name::<A>(),
1036 config,
1037 Some(relay_dispatch),
1038 Some(writer),
1039 )
1040 }
1041}