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}