#[cfg(test)]
mod tests;
use std::collections::HashMap;
use serde_json::{Map, Value};
use crate::graphql::{
types::{Directive, FieldSelection},
value_json,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StreamedChunk {
pub path: Vec<Value>,
pub label: Option<String>,
pub items: Vec<Value>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NotStreamable {
pub field: String,
pub reason: &'static str,
}
#[must_use]
#[allow(clippy::implicit_hasher)]
pub fn contains_nested_stream(
selections: &[FieldSelection],
variables: &HashMap<String, Value>,
) -> bool {
selections.iter().any(|s| contains_stream(&s.nested_fields, variables))
}
fn contains_stream(selections: &[FieldSelection], variables: &HashMap<String, Value>) -> bool {
selections.iter().any(|s| {
stream_directive(s, variables).is_some_and(|d| d.enabled)
|| contains_stream(&s.nested_fields, variables)
})
}
#[allow(clippy::implicit_hasher)] pub fn split(
selections: &[FieldSelection],
data: &mut Value,
variables: &HashMap<String, Value>,
batch_size: usize,
) -> Result<Vec<StreamedChunk>, NotStreamable> {
let mut out = Vec::new();
let mut path = Vec::new();
walk(selections, data, &mut path, variables, batch_size.max(1), &mut out)?;
Ok(out)
}
struct StreamSpec {
enabled: bool,
initial_count: usize,
label: Option<String>,
}
fn stream_directive(
selection: &FieldSelection,
variables: &HashMap<String, Value>,
) -> Option<StreamSpec> {
let directive = selection.directives.iter().find(|d| d.name == "stream")?;
Some(StreamSpec {
enabled: directive_arg(directive, "if", variables)
.is_none_or(|v| v.as_bool().unwrap_or(true)),
initial_count: directive_arg(directive, "initialCount", variables)
.and_then(|v| v.as_u64())
.and_then(|n| usize::try_from(n).ok())
.unwrap_or(0),
label: directive_arg(directive, "label", variables)
.and_then(|v| v.as_str().map(ToOwned::to_owned)),
})
}
fn directive_arg(
directive: &Directive,
name: &str,
variables: &HashMap<String, Value>,
) -> Option<Value> {
let arg = directive.arguments.iter().find(|a| a.name == name)?;
let decoded = value_json::decode(&arg.value_json).ok()?;
Some(value_json::resolve_variables(decoded, variables))
}
fn walk(
selections: &[FieldSelection],
value: &mut Value,
path: &mut Vec<Value>,
variables: &HashMap<String, Value>,
batch_size: usize,
out: &mut Vec<StreamedChunk>,
) -> Result<(), NotStreamable> {
if let Value::Array(items) = value {
for (index, item) in items.iter_mut().enumerate() {
path.push(Value::from(index));
walk(selections, item, path, variables, batch_size, out)?;
path.pop();
}
return Ok(());
}
let Value::Object(object) = value else {
return Ok(());
};
for selection in selections {
let key = selection.response_key();
match stream_directive(selection, variables) {
Some(spec) if spec.enabled => {
let Some(field) = object.get_mut(key) else {
continue;
};
if field.is_null() {
continue;
}
let Value::Array(items) = field else {
return Err(NotStreamable {
field: key.to_owned(),
reason: "@stream requires a list field; this one resolved to a \
single value",
});
};
path.push(Value::from(key));
take_tail(items, &spec, path, batch_size, out);
path.pop();
},
_ => {
if selection.nested_fields.is_empty() {
continue;
}
if let Some(child) = object.get_mut(key) {
path.push(Value::from(key));
let result =
walk(&selection.nested_fields, child, path, variables, batch_size, out);
path.pop();
result?;
}
},
}
}
Ok(())
}
fn take_tail(
items: &mut Vec<Value>,
spec: &StreamSpec,
path: &[Value],
batch_size: usize,
out: &mut Vec<StreamedChunk>,
) {
if items.len() <= spec.initial_count {
return;
}
let tail = items.split_off(spec.initial_count);
for (offset, chunk) in tail.chunks(batch_size).enumerate() {
let start = spec.initial_count + offset * batch_size;
let mut chunk_path = path.to_vec();
chunk_path.push(Value::from(start));
out.push(StreamedChunk {
path: chunk_path,
label: spec.label.clone(),
items: chunk.to_vec(),
});
}
}
#[must_use]
pub fn incremental_entry(chunk: StreamedChunk) -> Value {
let mut entry = Map::new();
entry.insert("items".to_owned(), Value::Array(chunk.items));
entry.insert("path".to_owned(), Value::Array(chunk.path));
if let Some(label) = chunk.label {
entry.insert("label".to_owned(), Value::String(label));
}
Value::Object(entry)
}