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