Skip to main content

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}