fraiseql_core/runtime/query_function.rs
1//! Resolution of a function-backed root query field (#1329).
2//!
3//! The engine half of the `request:query` trigger. A query that declares
4//! [`function = "<name>"`](crate::schema::QueryDefinition::function) is answered by a
5//! sandboxed function instead of by reading a relation, and this is the seam through
6//! which the engine reaches one — parallel to
7//! [`BeforeMutationGate`](crate::security::BeforeMutationGate), and for the same
8//! reason: `fraiseql-core` knows nothing about function runtimes, so it holds a
9//! trait object the application supplies. In `fraiseql-server` that object dispatches
10//! into the compiled schema's `functions` section.
11//!
12//! # What the engine keeps
13//!
14//! Everything except the value. The resolver is asked for the field's *data*; the
15//! engine still:
16//!
17//! - enforces `requires_role` and `requires_actor`, before asking;
18//! - applies field-level RBAC to what comes back, so a function-backed field is not a hole in the
19//! scope gates every other field passes through;
20//! - projects the selection set, recases, and stamps `__typename` with the same projector the SQL
21//! path uses.
22//!
23//! That list is the argument for option A in #1329. A field that resolved outside
24//! the engine would have to re-implement every one of them, and would be wrong about
25//! one of them within a release.
26//!
27//! # Root fields only
28//!
29//! There is no nested-field equivalent and there will not be one. A nested resolver
30//! runs once per row, so a function there is an N+1 measured in V8 isolates —
31//! ~5–8 ms each, against a documented cold read of ~5–15 ms for the whole query.
32//! Root-only makes "one invocation per query" a property of the shape, and the
33//! compiler refuses the nested spelling rather than documenting against it.
34
35use std::{future::Future, pin::Pin, sync::Arc};
36
37use crate::{
38 error::Result,
39 security::{GuestQueryBridge, SecurityContext},
40};
41
42/// The field a [`QueryFunctionResolver`] is being asked to answer.
43#[non_exhaustive]
44pub struct QueryFunctionRequest<'a> {
45 /// The declared function name, from the compiled query's `function` key.
46 ///
47 /// Never the field name: one function may back several fields, and the compiler
48 /// checks the pairing in both directions, so this is the only name the dispatcher
49 /// needs to look anything up by.
50 pub function: &'a str,
51 /// The root query field being answered.
52 ///
53 /// Passed so the guest can tell which field it was invoked for when one function
54 /// backs several, and so a failure names the field rather than the module.
55 pub field: &'a str,
56 /// The field's resolved arguments: request variables merged with the root field's
57 /// inline literals, nested `$var` references already substituted. An empty object
58 /// when the field takes none.
59 pub arguments: &'a serde_json::Value,
60 /// The requesting principal, or `None` for an anonymous request.
61 pub principal: Option<&'a SecurityContext>,
62 /// The caller-scoped, read-only GraphQL bridge (#1328).
63 ///
64 /// The same bridge a `before:mutation` hook reads through, built from this
65 /// request's own executor context and principal — deliberately not a second one.
66 /// A function-backed field that reads sees exactly what its caller could have
67 /// read, and cannot write.
68 pub reader: Arc<dyn GuestQueryBridge>,
69}
70
71/// Answers a function-backed root query field.
72///
73/// Registered on [`RuntimeConfig`](crate::runtime::RuntimeConfig) via
74/// [`with_query_function_resolver`](crate::runtime::RuntimeConfig::with_query_function_resolver),
75/// parallel to
76/// [`with_before_mutation_gate`](crate::runtime::RuntimeConfig::with_before_mutation_gate).
77///
78/// # The return value is the field's data, not a response
79///
80/// For a single-item field: the entity document, or `null`. For a list field: an
81/// array of them. The engine projects the selection set over it, so the guest
82/// returns whatever the type declares and never a GraphQL envelope — which also
83/// means a guest cannot invent a field the schema does not declare, or return one
84/// the caller's scopes deny.
85pub trait QueryFunctionResolver: Send + Sync {
86 /// Invoke the function that backs this field.
87 ///
88 /// # Errors
89 ///
90 /// Any error refuses the field. A resolver is expected to map its own failures —
91 /// a missing module, a guest exception, a timeout — to an error that names the
92 /// field, because by the time the engine sees it the only other thing it could
93 /// say is "the query failed".
94 fn resolve<'a>(
95 &'a self,
96 request: QueryFunctionRequest<'a>,
97 ) -> Pin<Box<dyn Future<Output = Result<serde_json::Value>> + Send + 'a>>;
98}