use std::collections::{BTreeMap, HashMap, HashSet};
use std::future::Future;
use jsonapi_core::{RelationshipData, Resource, ResourceObject, ResourceRelationship};
pub trait IncludeResolver {
type Error;
fn load(
&self,
type_name: &str,
ids: &[String],
) -> impl Future<Output = Result<Vec<Resource>, Self::Error>> + Send;
}
pub async fn resolve_includes<R: IncludeResolver>(
primary: &[Resource],
paths: &[&str],
resolver: &R,
) -> Result<Vec<Resource>, R::Error> {
let mut pool: HashMap<(String, String), Resource> = HashMap::new();
let mut included_order: Vec<(String, String)> = Vec::new();
let mut primary_keys: Vec<(String, String)> = Vec::new();
for resource in primary {
if let Some(id) = resource.resource_id() {
let key = (resource.r#type.clone(), id.to_string());
pool.entry(key.clone()).or_insert_with(|| resource.clone());
if !primary_keys.contains(&key) {
primary_keys.push(key);
}
}
}
for path in paths {
let path = path.trim();
if path.is_empty() {
continue;
}
let mut current = primary_keys.clone();
for segment in path.split('.') {
let mut refs: Vec<(String, String)> = Vec::new();
let mut refs_seen: HashSet<(String, String)> = HashSet::new();
for key in ¤t {
let Some(resource) = pool.get(key) else {
continue;
};
let Some(rel) = resource.relationships.get(segment) else {
continue;
};
for reference in linkage_refs(rel) {
if refs_seen.insert(reference.clone()) {
refs.push(reference);
}
}
}
let mut missing: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (type_name, id) in &refs {
if !pool.contains_key(&(type_name.clone(), id.clone())) {
missing
.entry(type_name.clone())
.or_default()
.push(id.clone());
}
}
for (type_name, ids) in missing {
for resource in resolver.load(&type_name, &ids).await? {
let Some(id) = resource.resource_id().map(str::to_string) else {
continue;
};
let key = (resource.r#type.clone(), id);
if pool.contains_key(&key) {
continue; }
included_order.push(key.clone());
pool.insert(key, resource);
}
}
current = refs
.into_iter()
.filter(|key| pool.contains_key(key))
.collect();
}
}
Ok(included_order
.into_iter()
.map(|key| {
pool.remove(&key)
.expect("every included key was inserted into the pool")
})
.collect())
}
fn linkage_refs(rel: &ResourceRelationship) -> Vec<(String, String)> {
let Some(data) = &rel.data else {
return Vec::new();
};
match data {
RelationshipData::ToOne(Some(rid)) => rid
.identity
.as_id()
.map(|id| vec![(rid.r#type.clone(), id.to_string())])
.unwrap_or_default(),
RelationshipData::ToOne(None) => Vec::new(),
RelationshipData::ToMany(rids) => rids
.iter()
.filter_map(|rid| {
rid.identity
.as_id()
.map(|id| (rid.r#type.clone(), id.to_string()))
})
.collect(),
_ => Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use jsonapi_core::model::{Identity, RelationshipData, ResourceIdentifier};
#[derive(Default)]
struct FakeStore {
data: HashMap<(String, String), Resource>,
calls: AtomicUsize,
fail: bool,
}
#[derive(Debug, PartialEq)]
struct LoadError(String);
impl FakeStore {
fn insert(&mut self, resource: Resource) {
let key = (
resource.r#type.clone(),
resource.id.clone().expect("fixture needs an id"),
);
self.data.insert(key, resource);
}
fn calls(&self) -> usize {
self.calls.load(Ordering::SeqCst)
}
}
impl IncludeResolver for FakeStore {
type Error = LoadError;
fn load(
&self,
type_name: &str,
ids: &[String],
) -> impl Future<Output = Result<Vec<Resource>, Self::Error>> + Send {
self.calls.fetch_add(1, Ordering::SeqCst);
let result = if self.fail {
Err(LoadError(format!("boom loading {type_name}")))
} else {
Ok(ids
.iter()
.filter_map(|id| self.data.get(&(type_name.to_string(), id.clone())).cloned())
.collect())
};
async move { result }
}
}
fn resource(type_: &str, id: &str) -> Resource {
Resource {
r#type: type_.into(),
id: Some(id.into()),
lid: None,
attributes: serde_json::json!({}),
relationships: BTreeMap::new(),
links: None,
meta: None,
}
}
fn to_one(rel_type: &str, id: &str) -> ResourceRelationship {
ResourceRelationship::new(RelationshipData::ToOne(Some(ResourceIdentifier {
r#type: rel_type.into(),
identity: Identity::Id(id.into()),
meta: None,
})))
}
fn to_one_lid(rel_type: &str, lid: &str) -> ResourceRelationship {
ResourceRelationship::new(RelationshipData::ToOne(Some(ResourceIdentifier {
r#type: rel_type.into(),
identity: Identity::Lid(lid.into()),
meta: None,
})))
}
fn to_many(rel_type: &str, ids: &[&str]) -> ResourceRelationship {
ResourceRelationship::new(RelationshipData::ToMany(
ids.iter()
.map(|id| ResourceIdentifier {
r#type: rel_type.into(),
identity: Identity::Id((*id).into()),
meta: None,
})
.collect(),
))
}
fn keys(included: &[Resource]) -> Vec<(String, String)> {
included
.iter()
.map(|r| (r.r#type.clone(), r.id.clone().unwrap()))
.collect()
}
#[test]
fn empty_include_makes_no_resolver_calls() {
pollster::block_on(async {
let store = FakeStore::default();
let primary = vec![resource("articles", "1")];
let included = resolve_includes(&primary, &[], &store).await.unwrap();
assert!(included.is_empty());
assert_eq!(store.calls(), 0);
});
}
#[test]
fn single_level_shared_author_appears_once() {
pollster::block_on(async {
let mut store = FakeStore::default();
store.insert(resource("people", "9"));
let mut a1 = resource("articles", "1");
a1.relationships
.insert("author".into(), to_one("people", "9"));
let mut a2 = resource("articles", "2");
a2.relationships
.insert("author".into(), to_one("people", "9"));
let included = resolve_includes(&[a1, a2], &["author"], &store)
.await
.unwrap();
assert_eq!(keys(&included), vec![("people".into(), "9".into())]);
assert_eq!(store.calls(), 1);
});
}
#[test]
fn transitive_path_loads_and_dedups_each_level() {
pollster::block_on(async {
let mut store = FakeStore::default();
let mut c1 = resource("comments", "5");
c1.relationships
.insert("author".into(), to_one("people", "9"));
let mut c2 = resource("comments", "6");
c2.relationships
.insert("author".into(), to_one("people", "9"));
store.insert(c1);
store.insert(c2);
store.insert(resource("people", "9"));
let mut article = resource("articles", "1");
article
.relationships
.insert("comments".into(), to_many("comments", &["5", "6"]));
let included = resolve_includes(&[article], &["comments.author"], &store)
.await
.unwrap();
let mut got = keys(&included);
got.sort();
assert_eq!(
got,
vec![
("comments".into(), "5".into()),
("comments".into(), "6".into()),
("people".into(), "9".into()),
]
);
assert_eq!(store.calls(), 2);
});
}
#[test]
fn lid_only_linkage_ref_is_skipped() {
pollster::block_on(async {
let store = FakeStore::default();
let mut article = resource("articles", "1");
article
.relationships
.insert("author".into(), to_one_lid("people", "local-1"));
let included = resolve_includes(&[article], &["author"], &store)
.await
.unwrap();
assert!(included.is_empty());
assert_eq!(store.calls(), 0);
});
}
#[test]
fn cyclic_linkage_terminates_without_duplicates() {
pollster::block_on(async {
let mut store = FakeStore::default();
let mut b = resource("nodes", "b");
b.relationships.insert("next".into(), to_one("nodes", "a"));
store.insert(b);
let mut a_in_store = resource("nodes", "a");
a_in_store
.relationships
.insert("next".into(), to_one("nodes", "b"));
store.insert(a_in_store);
let mut a = resource("nodes", "a");
a.relationships.insert("next".into(), to_one("nodes", "b"));
let included = resolve_includes(&[a], &["next.next.next.next"], &store)
.await
.unwrap();
assert_eq!(keys(&included), vec![("nodes".into(), "b".into())]);
});
}
#[test]
fn resolver_error_propagates_unchanged() {
pollster::block_on(async {
let store = FakeStore {
fail: true,
..FakeStore::default()
};
let mut article = resource("articles", "1");
article
.relationships
.insert("author".into(), to_one("people", "9"));
let err = resolve_includes(&[article], &["author"], &store)
.await
.unwrap_err();
assert_eq!(err, LoadError("boom loading people".into()));
});
}
}