Skip to main content

fraiseql_core/graphql/defer/
mod.rs

1//! `@defer`: splitting a resolved response into its immediate and deferred parts.
2//!
3//! # Why this is a response split, not a second query
4//!
5//! In a resolver-per-field server, `@defer` exists to stop an expensive resolver from
6//! holding up the rest of the response. FraiseQL has no per-field resolvers: a query
7//! compiles to **one** SQL statement over a JSONB view, and every requested field is
8//! produced by that one statement. There are exactly two ways to honour `@defer` here:
9//!
10//! 1. Remove the deferred fields from the projection and run a **second** statement for them. That
11//!    genuinely saves work in the first statement — and for a list it is unsound, because the two
12//!    statements are separate snapshots. Aligning the second result to the first positionally
13//!    attaches one row's deferred fields to another row whenever a concurrent write shifts the
14//!    window, silently. Aligning by key would require the non-deferred selection to carry an
15//!    identity field, which the client is under no obligation to request.
16//! 2. Run the one statement and split its **delivery**. Always correct — one snapshot, so alignment
17//!    is not a question that can be got wrong — and it is what an incrementally-rendering client
18//!    actually consumes.
19//!
20//! FraiseQL does (2). The honest consequence, stated here so no one infers otherwise:
21//! **`@defer` does not reduce database work.** It changes when bytes reach the client,
22//! which is a real benefit when the deferred part is large (a big nested list) and
23//! close to nothing when it is small.
24//!
25//! Over a buffered transport `@defer` remains a no-op — a server may always deliver
26//! the full result — so this splitting runs only on an incremental transport.
27
28#[cfg(test)]
29mod tests;
30
31use std::collections::HashMap;
32
33use serde_json::{Map, Value};
34
35use crate::graphql::{
36    types::{Directive, FieldSelection},
37    value_json,
38};
39
40/// One deferred fragment's payload, ready to become an `incremental` entry.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct DeferredPayload {
43    /// Response path of the object these fields belong to, as the GraphQL
44    /// incremental-delivery spec renders it: field keys and list indices.
45    /// Empty for fields deferred at the root of `data`.
46    pub path:  Vec<Value>,
47    /// The `@defer(label:)` argument, when the client supplied one. Clients use it
48    /// to route a payload without pattern-matching on the path.
49    pub label: Option<String>,
50    /// The deferred fields, keyed exactly as they would have appeared in `data`.
51    pub data:  Map<String, Value>,
52}
53
54/// Whether any selection in the tree carries an enabled `@defer`.
55///
56/// Cheap pre-scan: a document without `@defer` must not pay for the split, and must
57/// not change shape.
58#[must_use]
59#[allow(clippy::implicit_hasher)]
60// Reason: the variables map is built by the runtime with the default hasher; a generic
61// parameter here would leak into every caller for no benefit (same treatment as
62// `selection_set::filter` and `value_json::resolve_variables`).
63pub fn contains_defer(selections: &[FieldSelection], variables: &HashMap<String, Value>) -> bool {
64    selections.iter().any(|s| {
65        defer_directive(s, variables).is_some_and(|d| d.enabled)
66            || contains_defer(&s.nested_fields, variables)
67    })
68}
69
70/// Remove every `@defer`-marked field from `data` and return them grouped by the
71/// object they were removed from.
72///
73/// `selections` must be the **effective** selection set — spreads expanded and
74/// `@skip`/`@include` applied — so that a field the client never asked for cannot be
75/// deferred into existence. `data` is the response envelope's `data` object; it is
76/// mutated in place into the immediate payload.
77///
78/// Grouping is by `(path, label)` in first-seen order, so two fields deferred by the
79/// same fragment arrive in one payload rather than one each.
80///
81/// A `@defer` on a field with no matching key in `data` contributes nothing: a field
82/// that resolved to nothing has nothing to deliver later either.
83#[must_use]
84#[allow(clippy::implicit_hasher)] // Reason: as `contains_defer`.
85pub fn split(
86    selections: &[FieldSelection],
87    data: &mut Value,
88    variables: &HashMap<String, Value>,
89) -> Vec<DeferredPayload> {
90    let mut out = Vec::new();
91    let mut path = Vec::new();
92    walk(selections, data, &mut path, variables, &mut out);
93    out
94}
95
96/// A resolved `@defer` directive.
97struct DeferSpec {
98    /// `@defer(if:)`, defaulting to true. A disabled `@defer` is not a defer at all.
99    enabled: bool,
100    /// `@defer(label:)`, when supplied.
101    label:   Option<String>,
102}
103
104/// Resolve the first enabled-or-not `@defer` on a selection.
105///
106/// A field can carry more than one: flattening a spread prepends the spread's
107/// directives onto each contributed field, so `...Outer @defer` wrapping
108/// `...Inner @defer(label: "inner")` leaves both on the leaf. The first is the
109/// outermost, and the outermost is the one that decides — a nested `@defer` cannot
110/// un-defer what an enclosing one deferred, exactly as a nested `@include` cannot
111/// resurrect a field an enclosing `@skip` withheld.
112fn defer_directive(
113    selection: &FieldSelection,
114    variables: &HashMap<String, Value>,
115) -> Option<DeferSpec> {
116    let directive = selection.directives.iter().find(|d| d.name == "defer")?;
117    Some(DeferSpec {
118        enabled: directive_arg(directive, "if", variables)
119            .is_none_or(|v| v.as_bool().unwrap_or(true)),
120        label:   directive_arg(directive, "label", variables)
121            .and_then(|v| v.as_str().map(ToOwned::to_owned)),
122    })
123}
124
125/// Decode a directive argument, resolving a variable reference if present.
126fn directive_arg(
127    directive: &Directive,
128    name: &str,
129    variables: &HashMap<String, Value>,
130) -> Option<Value> {
131    let arg = directive.arguments.iter().find(|a| a.name == name)?;
132    let decoded = value_json::decode(&arg.value_json).ok()?;
133    Some(value_json::resolve_variables(decoded, variables))
134}
135
136/// Recursive split. `path` is the response path of `value`.
137fn walk(
138    selections: &[FieldSelection],
139    value: &mut Value,
140    path: &mut Vec<Value>,
141    variables: &HashMap<String, Value>,
142    out: &mut Vec<DeferredPayload>,
143) {
144    // A list: the same selection set applies to every element, and each element is a
145    // distinct path. Without this, a deferred field inside a list would be addressed
146    // by the list's own path and every element would overwrite the last.
147    if let Value::Array(items) = value {
148        for (index, item) in items.iter_mut().enumerate() {
149            path.push(Value::from(index));
150            walk(selections, item, path, variables, out);
151            path.pop();
152        }
153        return;
154    }
155
156    let Value::Object(object) = value else {
157        return;
158    };
159
160    for selection in selections {
161        let key = selection.response_key();
162        match defer_directive(selection, variables) {
163            Some(spec) if spec.enabled => {
164                if let Some(field) = object.remove(key) {
165                    push_deferred(out, path, spec.label, key, field);
166                }
167            },
168            _ => {
169                if selection.nested_fields.is_empty() {
170                    continue;
171                }
172                if let Some(child) = object.get_mut(key) {
173                    path.push(Value::from(key));
174                    walk(&selection.nested_fields, child, path, variables, out);
175                    path.pop();
176                }
177            },
178        }
179    }
180}
181
182/// Append a deferred field to its `(path, label)` group, creating it if new.
183fn push_deferred(
184    out: &mut Vec<DeferredPayload>,
185    path: &[Value],
186    label: Option<String>,
187    key: &str,
188    field: Value,
189) {
190    if let Some(existing) = out.iter_mut().find(|p| p.path == path && p.label == label) {
191        existing.data.insert(key.to_owned(), field);
192        return;
193    }
194    let mut data = Map::new();
195    data.insert(key.to_owned(), field);
196    out.push(DeferredPayload {
197        path: path.to_vec(),
198        label,
199        data,
200    });
201}