Skip to main content

fraiseql_core/graphql/stream_split/
mod.rs

1//! Nested `@stream`: splitting an already-resolved list into delivery chunks.
2//!
3//! # Why nested `@stream` is a delivery split and root `@stream` is not
4//!
5//! The two look like one feature and are two, because the thing they can page over
6//! is different.
7//!
8//! A **root** `@stream` names the query's own list. FraiseQL compiles that query to
9//! one SQL statement with `LIMIT`/`OFFSET` arguments it already accepts, so a batch
10//! is a real re-execution against the database: the server never holds more than one
11//! batch, and the delivery can be resumed from a row offset because the offset means
12//! something to the query.
13//!
14//! A **nested** `@stream` names a list *inside* a row — `users { posts @stream }`.
15//! That list is a JSONB array produced by the same single statement as the row it
16//! belongs to. There is no per-path pagination to push down: the array is already in
17//! memory by the time anything could page it, and asking the database for "the next
18//! 10 posts of user 3" would be a second statement, a second snapshot, and an
19//! alignment problem with no sound answer (see [`crate::graphql::defer`] for the same
20//! argument in full).
21//!
22//! So this module does what `@defer` does — splits the **delivery** of a result the
23//! server already holds. The honest consequences, stated rather than left to be
24//! inferred:
25//!
26//! - it does **not** reduce database work, and does not bound server memory: the whole array was
27//!   materialised before the split;
28//! - it is always correctly aligned, because there is only one snapshot;
29//! - it is **not** resumable — the chunk boundaries are positions in a value that no longer exists
30//!   once the response has been delivered, unlike a root `@stream`'s row offset, which is an
31//!   argument the query accepts.
32//!
33//! What it *is* good for is the same thing `@defer` is good for: a client that
34//! renders a large nested list incrementally gets its first rows without waiting for
35//! the serialisation of the last.
36
37#[cfg(test)]
38mod tests;
39
40use std::collections::HashMap;
41
42use serde_json::{Map, Value};
43
44use crate::graphql::{
45    types::{Directive, FieldSelection},
46    value_json,
47};
48
49/// One chunk of a streamed nested list, ready to become an `incremental` entry.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct StreamedChunk {
52    /// Response path of the **first item in this chunk**, as the GraphQL
53    /// incremental-delivery spec renders a `@stream` payload path: the path of the
54    /// list, with the item's index appended.
55    pub path:  Vec<Value>,
56    /// The `@stream(label:)` argument, when the client supplied one.
57    pub label: Option<String>,
58    /// The items this chunk delivers, in list order.
59    pub items: Vec<Value>,
60}
61
62/// A `@stream` on a field whose value cannot be streamed.
63///
64/// Refused rather than ignored: a directive that silently does nothing on a
65/// transport that negotiated incremental delivery reads to the client as
66/// "streaming worked", which is the one outcome worse than an error.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct NotStreamable {
69    /// Response key of the offending field.
70    pub field:  String,
71    /// What it was instead of a list.
72    pub reason: &'static str,
73}
74
75/// Whether any **nested** selection carries an enabled `@stream`.
76///
77/// Root-level `@stream` is excluded: that is real database paging and is planned
78/// elsewhere. Cheap pre-scan, so a document without one does not pay for the split.
79#[must_use]
80#[allow(clippy::implicit_hasher)]
81// Reason: the variables map is built by the runtime with the default hasher; a generic
82// parameter here would leak into every caller for no benefit (same treatment as
83// `defer::contains_defer`).
84pub fn contains_nested_stream(
85    selections: &[FieldSelection],
86    variables: &HashMap<String, Value>,
87) -> bool {
88    selections.iter().any(|s| contains_stream(&s.nested_fields, variables))
89}
90
91/// Whether any selection in `selections` — at this level or below — carries an
92/// enabled `@stream`.
93fn contains_stream(selections: &[FieldSelection], variables: &HashMap<String, Value>) -> bool {
94    selections.iter().any(|s| {
95        stream_directive(s, variables).is_some_and(|d| d.enabled)
96            || contains_stream(&s.nested_fields, variables)
97    })
98}
99
100/// Split every `@stream`-marked nested list in `data` into its initial slice and
101/// its continuation chunks.
102///
103/// `selections` must be the **effective** selection set — spreads expanded and
104/// `@skip`/`@include` applied — so a field the client never asked for cannot be
105/// streamed into existence. `data` is mutated in place: each streamed list is
106/// truncated to its `initialCount`, and the removed tail becomes the returned
107/// chunks, in document order.
108///
109/// `batch_size` is the maximum items per chunk and must be at least 1; `0` is
110/// treated as `1` rather than producing an infinite number of empty chunks.
111///
112/// # Errors
113///
114/// Returns [`NotStreamable`] if a `@stream` marks a field that resolved to
115/// something other than a list.
116#[allow(clippy::implicit_hasher)] // Reason: as `contains_nested_stream`.
117pub fn split(
118    selections: &[FieldSelection],
119    data: &mut Value,
120    variables: &HashMap<String, Value>,
121    batch_size: usize,
122) -> Result<Vec<StreamedChunk>, NotStreamable> {
123    let mut out = Vec::new();
124    let mut path = Vec::new();
125    walk(selections, data, &mut path, variables, batch_size.max(1), &mut out)?;
126    Ok(out)
127}
128
129/// A resolved `@stream` directive.
130struct StreamSpec {
131    /// `@stream(if:)`, defaulting to true.
132    enabled:       bool,
133    /// `@stream(initialCount:)`, defaulting to 0 — how many items stay in the
134    /// immediate payload.
135    initial_count: usize,
136    /// `@stream(label:)`, when supplied.
137    label:         Option<String>,
138}
139
140/// Resolve the first `@stream` on a selection.
141///
142/// The first is the outermost when a spread contributed the field, matching
143/// `@defer`'s rule: the outermost directive decides.
144fn stream_directive(
145    selection: &FieldSelection,
146    variables: &HashMap<String, Value>,
147) -> Option<StreamSpec> {
148    let directive = selection.directives.iter().find(|d| d.name == "stream")?;
149    Some(StreamSpec {
150        enabled:       directive_arg(directive, "if", variables)
151            .is_none_or(|v| v.as_bool().unwrap_or(true)),
152        initial_count: directive_arg(directive, "initialCount", variables)
153            .and_then(|v| v.as_u64())
154            .and_then(|n| usize::try_from(n).ok())
155            .unwrap_or(0),
156        label:         directive_arg(directive, "label", variables)
157            .and_then(|v| v.as_str().map(ToOwned::to_owned)),
158    })
159}
160
161/// Decode a directive argument, resolving a variable reference if present.
162fn directive_arg(
163    directive: &Directive,
164    name: &str,
165    variables: &HashMap<String, Value>,
166) -> Option<Value> {
167    let arg = directive.arguments.iter().find(|a| a.name == name)?;
168    let decoded = value_json::decode(&arg.value_json).ok()?;
169    Some(value_json::resolve_variables(decoded, variables))
170}
171
172/// Recursive split. `path` is the response path of `value`.
173fn walk(
174    selections: &[FieldSelection],
175    value: &mut Value,
176    path: &mut Vec<Value>,
177    variables: &HashMap<String, Value>,
178    batch_size: usize,
179    out: &mut Vec<StreamedChunk>,
180) -> Result<(), NotStreamable> {
181    // A list: the same selection set applies to every element, and each element is a
182    // distinct path — without this, a streamed list inside a list would be addressed
183    // by the outer list's path and every element would overwrite the last.
184    if let Value::Array(items) = value {
185        for (index, item) in items.iter_mut().enumerate() {
186            path.push(Value::from(index));
187            walk(selections, item, path, variables, batch_size, out)?;
188            path.pop();
189        }
190        return Ok(());
191    }
192
193    let Value::Object(object) = value else {
194        return Ok(());
195    };
196
197    for selection in selections {
198        let key = selection.response_key();
199        match stream_directive(selection, variables) {
200            Some(spec) if spec.enabled => {
201                let Some(field) = object.get_mut(key) else {
202                    // A field that resolved to nothing has nothing to deliver later
203                    // either — the same rule `@defer` applies.
204                    continue;
205                };
206                // A `null` list is a resolved absence, not a mistyped selection: the
207                // client asked to stream a field the row does not have.
208                if field.is_null() {
209                    continue;
210                }
211                let Value::Array(items) = field else {
212                    return Err(NotStreamable {
213                        field:  key.to_owned(),
214                        reason: "@stream requires a list field; this one resolved to a \
215                                 single value",
216                    });
217                };
218                path.push(Value::from(key));
219                take_tail(items, &spec, path, batch_size, out);
220                path.pop();
221            },
222            _ => {
223                if selection.nested_fields.is_empty() {
224                    continue;
225                }
226                if let Some(child) = object.get_mut(key) {
227                    path.push(Value::from(key));
228                    let result =
229                        walk(&selection.nested_fields, child, path, variables, batch_size, out);
230                    path.pop();
231                    result?;
232                }
233            },
234        }
235    }
236    Ok(())
237}
238
239/// Truncate `items` to `initial_count` and turn the removed tail into chunks.
240///
241/// `path` is the list's own response path; each chunk's path is that plus the index
242/// of its first item, which is what a client uses to splice the chunk back into the
243/// list it is rendering.
244fn take_tail(
245    items: &mut Vec<Value>,
246    spec: &StreamSpec,
247    path: &[Value],
248    batch_size: usize,
249    out: &mut Vec<StreamedChunk>,
250) {
251    if items.len() <= spec.initial_count {
252        return;
253    }
254    let tail = items.split_off(spec.initial_count);
255    for (offset, chunk) in tail.chunks(batch_size).enumerate() {
256        let start = spec.initial_count + offset * batch_size;
257        let mut chunk_path = path.to_vec();
258        chunk_path.push(Value::from(start));
259        out.push(StreamedChunk {
260            path:  chunk_path,
261            label: spec.label.clone(),
262            items: chunk.to_vec(),
263        });
264    }
265}
266
267/// Render a chunk as the `incremental` entry an incremental transport delivers.
268#[must_use]
269pub fn incremental_entry(chunk: StreamedChunk) -> Value {
270    let mut entry = Map::new();
271    entry.insert("items".to_owned(), Value::Array(chunk.items));
272    entry.insert("path".to_owned(), Value::Array(chunk.path));
273    if let Some(label) = chunk.label {
274        entry.insert("label".to_owned(), Value::String(label));
275    }
276    Value::Object(entry)
277}