use crate::error::{Error, Result};
use crate::schema_cache::{Relationship, SchemaCache, Table};
use std::collections::HashMap;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EmbedRendering {
Verbatim,
Normalised,
}
impl EmbedRendering {
fn cast(self) -> &'static str {
match self {
Self::Verbatim => "::json",
Self::Normalised => "::jsonb",
}
}
}
#[derive(Clone, Debug)]
pub struct EmbedPlan {
pub local_column: String,
pub foreign_column: String,
pub foreign_column_type: String,
pub foreign_schema: String,
pub foreign_table: String,
pub is_list: bool,
pub columns: Vec<(String, String)>,
pub junction: Option<EmbedJunction>,
pub function: Option<crate::api_request::QualifiedIdentifier>,
pub row_argument: Option<String>,
}
#[derive(Clone, Debug)]
pub struct EmbedJunction {
pub schema: String,
pub table: String,
pub parent_columns: Vec<(String, String)>,
pub child_columns: Vec<(String, String)>,
}
impl EmbedJunction {
fn child_join(&self, junction_alias: &str, child_alias: &str) -> String {
Self::equalities(&self.child_columns, junction_alias, child_alias, true)
}
fn parent_correlation(&self, junction_alias: &str, parent_alias: &str) -> String {
Self::equalities(&self.parent_columns, junction_alias, parent_alias, false)
}
fn equalities(
pairs: &[(String, String)],
junction_alias: &str,
other_alias: &str,
junction_first: bool,
) -> String {
pairs
.iter()
.map(|(left, right)| {
let (junction_column, other_column) = match junction_first {
true => (left, right),
false => (right, left),
};
format!(
"{}.{} = {}.{}",
postrust_sql::escape_ident(junction_alias),
postrust_sql::escape_ident(junction_column),
postrust_sql::escape_ident(other_alias),
postrust_sql::escape_ident(other_column),
)
})
.collect::<Vec<_>>()
.join(" AND ")
}
}
impl EmbedPlan {
pub fn resolve(relationship: &Relationship, schema_cache: &SchemaCache) -> Result<Self> {
let foreign_table_qi = relationship.foreign_table().clone();
if let Relationship::Computed {
function,
foreign_table,
to_one,
row_argument,
..
} = relationship
{
return Ok(Self {
local_column: String::new(),
foreign_column: String::new(),
foreign_column_type: String::new(),
foreign_schema: foreign_table.schema.clone(),
foreign_table: foreign_table.name.clone(),
is_list: !to_one,
columns: Vec::new(),
junction: None,
function: Some(function.clone()),
row_argument: row_argument.clone(),
});
}
if let Relationship::ForeignKey {
cardinality: crate::schema_cache::Cardinality::M2M(junction),
..
} = relationship
{
let (local_column, _) =
junction.source_columns.first().cloned().ok_or_else(|| {
Error::EmbeddingError("junction has no source columns".into())
})?;
let (_, foreign_column) =
junction.target_columns.first().cloned().ok_or_else(|| {
Error::EmbeddingError("junction has no target columns".into())
})?;
return Ok(Self {
local_column,
foreign_column,
foreign_column_type: String::new(),
foreign_schema: foreign_table_qi.schema.clone(),
foreign_table: foreign_table_qi.name.clone(),
is_list: true,
columns: Vec::new(),
row_argument: None,
junction: Some(EmbedJunction {
schema: junction.table.schema.clone(),
table: junction.table.name.clone(),
parent_columns: junction.source_columns.clone(),
child_columns: junction.target_columns.clone(),
}),
function: None,
});
}
let columns = match relationship {
Relationship::ForeignKey { cardinality, .. } => cardinality.columns(),
Relationship::Computed { .. } => unreachable!("handled above"),
};
let (local_column, foreign_column) = columns
.first()
.cloned()
.ok_or_else(|| Error::EmbeddingError("relationship joins on no columns".into()))?;
let foreign_table: &Table = schema_cache.get_table(&foreign_table_qi).ok_or_else(|| {
Error::EmbeddingError(format!(
"cannot embed \"{}\": it is not in an exposed schema",
foreign_table_qi
))
})?;
let foreign_column_type = foreign_table
.get_column(&foreign_column)
.map(|c| c.nominal_type.clone())
.ok_or_else(|| {
Error::EmbeddingError(format!(
"cannot embed \"{}\": join column \"{}\" not found",
foreign_table_qi, foreign_column
))
})?;
Ok(Self {
local_column,
foreign_column,
foreign_column_type,
foreign_schema: foreign_table_qi.schema.clone(),
foreign_table: foreign_table_qi.name.clone(),
is_list: !relationship.is_to_one(),
columns,
junction: None,
function: None,
row_argument: None,
})
}
fn correlation(&self, parent_alias: &str, child_alias: &str) -> String {
let pairs = if self.columns.is_empty() {
vec![(self.local_column.clone(), self.foreign_column.clone())]
} else {
self.columns.clone()
};
pairs
.iter()
.map(|(local, foreign)| {
format!(
"{}.{} = {}.{}",
postrust_sql::escape_ident(child_alias),
postrust_sql::escape_ident(foreign),
postrust_sql::escape_ident(parent_alias),
postrust_sql::escape_ident(local),
)
})
.collect::<Vec<_>>()
.join(" AND ")
}
pub fn children_sql(&self, limit: Option<i64>, columns: &[String]) -> Result<String> {
let type_name = castable_type_name(&self.foreign_column_type).ok_or_else(|| {
Error::EmbeddingError(format!(
"cannot embed \"{}\": join column type \"{}\" is not a plain type name",
self.foreign_table, self.foreign_column_type
))
})?;
let projection = self.projection(columns);
let mut inner = format!(
"SELECT {} FROM {}.{} WHERE {} = ANY($1::{}[])",
projection,
postrust_sql::escape_ident(&self.foreign_schema),
postrust_sql::escape_ident(&self.foreign_table),
postrust_sql::escape_ident(&self.foreign_column),
type_name
);
if let Some(limit) = limit {
inner.push_str(&format!(" LIMIT {}", limit));
}
Ok(format!("SELECT row_to_json(t) FROM ({}) t", inner))
}
pub fn children_grouped_sql(&self, limit: Option<i64>, columns: &[String]) -> Result<String> {
if self
.junction
.as_ref()
.is_some_and(|j| j.parent_columns.len() > 1)
{
return Err(Error::EmbeddingError(format!(
"embedding \"{}\" this way is not supported: it joins through a junction \
keyed on several columns, and grouping children by key needs a single one",
self.foreign_table,
)));
}
if self.columns.len() > 1 {
return Err(Error::EmbeddingError(format!(
"embedding \"{}\" this way is not supported: it joins on {} columns, and \
grouping children by key needs a single one",
self.foreign_table,
self.columns.len()
)));
}
let type_name = castable_type_name(&self.foreign_column_type).ok_or_else(|| {
Error::EmbeddingError(format!(
"cannot embed \"{}\": join column type \"{}\" is not a plain type name",
self.foreign_table, self.foreign_column_type
))
})?;
let key = postrust_sql::escape_ident(&self.foreign_column);
let mut inner = format!(
"SELECT {} FROM {}.{} WHERE {} = ANY($1::{}[])",
self.projection(columns),
postrust_sql::escape_ident(&self.foreign_schema),
postrust_sql::escape_ident(&self.foreign_table),
key,
type_name
);
if let Some(limit) = limit {
inner.push_str(&format!(" LIMIT {}", limit));
}
Ok(format!(
"SELECT to_jsonb(c.{key}) AS k, json_agg(row_to_json(c)) AS v \
FROM ({inner}) c GROUP BY c.{key}",
key = key,
inner = inner
))
}
#[allow(clippy::too_many_arguments)] pub fn correlated_rows(
&self,
parent_alias: &str,
parent_row: &str,
child_alias: &str,
inner_select: &str,
limit: Option<i64>,
offset: i64,
child_where: Option<&str>,
order_by: Option<&str>,
function_arguments: Option<&str>,
) -> Result<String> {
let mut inner = match &self.function {
Some(function) => format!(
"SELECT {} FROM {}.{}({}) AS {} WHERE true",
inner_select,
postrust_sql::escape_ident(&function.schema),
postrust_sql::escape_ident(&function.name),
function_arguments.unwrap_or(parent_row),
postrust_sql::escape_ident(child_alias),
),
None => match &self.junction {
Some(junction) => {
let junction_alias = format!("{}_j", child_alias);
format!(
"SELECT {} FROM {}.{} AS {} JOIN {}.{} AS {} ON {} WHERE {}",
inner_select,
postrust_sql::escape_ident(&self.foreign_schema),
postrust_sql::escape_ident(&self.foreign_table),
postrust_sql::escape_ident(child_alias),
postrust_sql::escape_ident(&junction.schema),
postrust_sql::escape_ident(&junction.table),
postrust_sql::escape_ident(&junction_alias),
junction.child_join(&junction_alias, child_alias),
junction.parent_correlation(&junction_alias, parent_alias),
)
}
None => format!(
"SELECT {} FROM {}.{} AS {} WHERE {}",
inner_select,
postrust_sql::escape_ident(&self.foreign_schema),
postrust_sql::escape_ident(&self.foreign_table),
postrust_sql::escape_ident(child_alias),
self.correlation(parent_alias, child_alias),
),
},
};
if let Some(child_where) = child_where {
inner.push_str(" AND ");
inner.push_str(child_where);
}
if let Some(order_by) = order_by {
inner.push_str(" ORDER BY ");
inner.push_str(order_by);
}
if let Some(limit) = limit {
inner.push_str(&format!(" LIMIT {}", limit));
} else if !self.is_list {
inner.push_str(" LIMIT 1");
}
if offset > 0 {
inner.push_str(&format!(" OFFSET {}", offset));
}
Ok(inner)
}
#[allow(clippy::too_many_arguments)] pub fn embed_expression(
&self,
parent_alias: &str,
parent_row: &str,
child_alias: &str,
inner_select: &str,
limit: Option<i64>,
offset: i64,
child_where: Option<&str>,
order_by: Option<&str>,
function_arguments: Option<&str>,
rendering: EmbedRendering,
) -> Result<String> {
let inner = self.correlated_rows(
parent_alias,
parent_row,
child_alias,
inner_select,
limit,
offset,
child_where,
order_by,
function_arguments,
)?;
let alias = postrust_sql::escape_ident(&format!("{}_j", child_alias));
Ok(if self.is_list {
format!(
"COALESCE((SELECT json_agg(row_to_json({alias})) FROM ({inner}) {alias}), '[]'::json){cast}",
alias = alias,
inner = inner,
cast = rendering.cast()
)
} else {
format!(
"(SELECT row_to_json({alias}) FROM ({inner}) {alias}){cast}",
alias = alias,
inner = inner,
cast = rendering.cast()
)
})
}
#[allow(clippy::too_many_arguments)] pub fn aggregate_expression(
&self,
parent_alias: &str,
parent_row: &str,
child_alias: &str,
aggregate_select: &str,
limit: Option<i64>,
offset: i64,
child_where: Option<&str>,
order_by: Option<&str>,
function_arguments: Option<&str>,
distinct_on: &str,
rendering: EmbedRendering,
) -> Result<String> {
let child = postrust_sql::escape_ident(child_alias);
let rows = self.correlated_rows(
parent_alias,
parent_row,
child_alias,
&format!("{}{}.*", distinct_on, child),
limit,
offset,
child_where,
order_by,
function_arguments,
)?;
let alias = postrust_sql::escape_ident(&format!("{}_a", child_alias));
Ok(format!(
"(SELECT row_to_json({alias}) FROM \
(SELECT {select} FROM ({rows}) AS {child}) {alias}){cast}",
alias = alias,
select = aggregate_select,
rows = rows,
child = child,
cast = rendering.cast()
))
}
pub fn order_expression(
&self,
parent_alias: &str,
parent_row: &str,
child_alias: &str,
column_sql: &str,
) -> String {
let source = match (&self.function, &self.junction) {
(Some(function), _) => format!(
"{}.{}({}) AS {} WHERE true",
postrust_sql::escape_ident(&function.schema),
postrust_sql::escape_ident(&function.name),
parent_row,
postrust_sql::escape_ident(child_alias),
),
(None, Some(junction)) => {
let junction_alias = format!("{}_j", child_alias);
format!(
"{}.{} AS {} JOIN {}.{} AS {} ON {} WHERE {}",
postrust_sql::escape_ident(&self.foreign_schema),
postrust_sql::escape_ident(&self.foreign_table),
postrust_sql::escape_ident(child_alias),
postrust_sql::escape_ident(&junction.schema),
postrust_sql::escape_ident(&junction.table),
postrust_sql::escape_ident(&junction_alias),
junction.child_join(&junction_alias, child_alias),
junction.parent_correlation(&junction_alias, parent_alias),
)
}
(None, None) => format!(
"{}.{} AS {} WHERE {}",
postrust_sql::escape_ident(&self.foreign_schema),
postrust_sql::escape_ident(&self.foreign_table),
postrust_sql::escape_ident(child_alias),
self.correlation(parent_alias, child_alias),
),
};
format!("(SELECT {} FROM {} LIMIT 1)", column_sql, source)
}
pub fn inner_join_predicate(
&self,
parent_alias: &str,
parent_row: &str,
child_alias: &str,
child_where: Option<&str>,
) -> String {
let mut predicate = match &self.function {
Some(function) => format!(
"EXISTS (SELECT 1 FROM {}.{}({}) AS {} WHERE true",
postrust_sql::escape_ident(&function.schema),
postrust_sql::escape_ident(&function.name),
parent_row,
postrust_sql::escape_ident(child_alias),
),
None => match &self.junction {
Some(junction) => {
let junction_alias = format!("{}_j", child_alias);
format!(
"EXISTS (SELECT 1 FROM {}.{} AS {} JOIN {}.{} AS {} ON {} WHERE {}",
postrust_sql::escape_ident(&self.foreign_schema),
postrust_sql::escape_ident(&self.foreign_table),
postrust_sql::escape_ident(child_alias),
postrust_sql::escape_ident(&junction.schema),
postrust_sql::escape_ident(&junction.table),
postrust_sql::escape_ident(&junction_alias),
junction.child_join(&junction_alias, child_alias),
junction.parent_correlation(&junction_alias, parent_alias),
)
}
None => format!(
"EXISTS (SELECT 1 FROM {}.{} AS {} WHERE {}",
postrust_sql::escape_ident(&self.foreign_schema),
postrust_sql::escape_ident(&self.foreign_table),
postrust_sql::escape_ident(child_alias),
self.correlation(parent_alias, child_alias),
),
},
};
if let Some(child_where) = child_where {
predicate.push_str(" AND ");
predicate.push_str(child_where);
}
predicate.push(')');
predicate
}
fn projection(&self, columns: &[String]) -> String {
if columns.is_empty() {
return "*".to_string();
}
let mut wanted: Vec<&str> = Vec::with_capacity(columns.len() + 1);
for column in columns {
if !wanted.contains(&column.as_str()) {
wanted.push(column);
}
}
if !wanted.contains(&self.foreign_column.as_str()) {
wanted.push(&self.foreign_column);
}
wanted
.into_iter()
.map(postrust_sql::escape_ident)
.collect::<Vec<_>>()
.join(", ")
}
}
fn castable_type_name(pg_type: &str) -> Option<&str> {
if pg_type.is_empty() {
return None;
}
if pg_type
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_')
{
Some(pg_type)
} else {
None
}
}
pub fn key_to_text(value: &serde_json::Value) -> Option<String> {
match value {
serde_json::Value::Null => None,
serde_json::Value::String(s) => Some(s.clone()),
other => Some(other.to_string()),
}
}
pub fn group_from_aggregated(
rows: Vec<(serde_json::Value, serde_json::Value)>,
) -> HashMap<String, Vec<serde_json::Value>> {
let mut grouped: HashMap<String, Vec<serde_json::Value>> = HashMap::with_capacity(rows.len());
for (key, children) in rows {
let key = key_to_text(&key).unwrap_or_default();
let children = match children {
serde_json::Value::Array(items) => items,
_ => Vec::new(),
};
grouped.entry(key).or_default().extend(children);
}
grouped
}
pub fn group_by_key(
children: Vec<serde_json::Value>,
foreign_column: &str,
) -> HashMap<String, Vec<serde_json::Value>> {
let mut grouped: HashMap<String, Vec<serde_json::Value>> = HashMap::new();
for child in children {
let key = child
.get(foreign_column)
.and_then(key_to_text)
.unwrap_or_default();
grouped.entry(key).or_default().push(child);
}
grouped
}
pub fn attach_to_parent(
parent: &mut serde_json::Value,
field_name: &str,
plan: &EmbedPlan,
grouped: &HashMap<String, Vec<serde_json::Value>>,
) {
let key = parent.get(&plan.local_column).and_then(key_to_text);
let matches = key
.as_ref()
.and_then(|k| grouped.get(k))
.cloned()
.unwrap_or_default();
let value = if plan.is_list {
serde_json::Value::Array(matches)
} else {
matches
.into_iter()
.next()
.unwrap_or(serde_json::Value::Null)
};
if let Some(object) = parent.as_object_mut() {
object.insert(field_name.to_string(), value);
}
}
pub fn spread_into_parent(
parent: &mut serde_json::Value,
plan: &EmbedPlan,
grouped: &HashMap<String, Vec<serde_json::Value>>,
columns: &[(String, String)],
) {
let key = parent.get(&plan.local_column).and_then(key_to_text);
let matches = key
.as_ref()
.and_then(|k| grouped.get(k))
.cloned()
.unwrap_or_default();
let Some(object) = parent.as_object_mut() else {
return;
};
for (source, output) in columns {
let value = if plan.is_list {
serde_json::Value::Array(
matches
.iter()
.map(|child| {
child
.get(source)
.cloned()
.unwrap_or(serde_json::Value::Null)
})
.collect(),
)
} else {
matches
.first()
.and_then(|child| child.get(source).cloned())
.unwrap_or(serde_json::Value::Null)
};
object.insert(output.clone(), value);
}
}
pub fn parent_keys(parents: &[serde_json::Value], local_column: &str) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut keys = Vec::new();
for parent in parents {
if let Some(key) = parent.get(local_column).and_then(key_to_text) {
if seen.insert(key.clone()) {
keys.push(key);
}
}
}
keys
}
#[cfg(test)]
mod tests {
use super::*;
fn plan(is_list: bool) -> EmbedPlan {
EmbedPlan {
row_argument: None,
local_column: "id".into(),
foreign_column: "user_id".into(),
foreign_column_type: "int4".into(),
foreign_schema: "public".into(),
foreign_table: "posts".into(),
is_list,
columns: vec![("id".into(), "user_id".into())],
junction: None,
function: None,
}
}
#[test]
fn children_sql_binds_keys_as_a_cast_array() {
let sql = plan(true).children_sql(None, &[]).unwrap();
assert_eq!(
sql,
"SELECT row_to_json(t) FROM (SELECT * FROM \"public\".\"posts\" \
WHERE \"user_id\" = ANY($1::int4[])) t"
);
}
#[test]
fn embed_expression_aggregates_a_to_many_relation() {
let sql = plan(true)
.embed_expression(
"p",
"\"p\"",
"posts",
r#""id", "title""#,
None,
0,
None,
None,
None,
EmbedRendering::Verbatim,
)
.unwrap();
assert!(sql.starts_with("COALESCE((SELECT json_agg("), "{}", sql);
assert!(sql.contains(r#""posts"."user_id" = "p"."id""#), "{}", sql);
assert!(
sql.contains(r#"AS "posts""#),
"the child table is aliased: {}",
sql
);
assert!(!sql.contains("ANY("), "{}", sql);
assert!(sql.contains("'[]'::json"), "{}", sql);
}
#[test]
fn embed_expression_takes_one_row_for_a_to_one_relation() {
let sql = plan(false)
.embed_expression(
"p",
"\"p\"",
"author",
r#""id""#,
None,
0,
None,
None,
None,
EmbedRendering::Verbatim,
)
.unwrap();
assert!(sql.contains("row_to_json"), "{}", sql);
assert!(!sql.contains("json_agg"), "{}", sql);
assert!(
sql.contains("LIMIT 1"),
"a to-one relation yields one row: {}",
sql
);
}
#[test]
fn embed_expression_limits_rows_per_parent() {
let sql = plan(true)
.embed_expression(
"p",
"\"p\"",
"posts",
r#""id""#,
Some(25),
0,
None,
None,
None,
EmbedRendering::Verbatim,
)
.unwrap();
assert!(sql.contains("LIMIT 25"), "{}", sql);
}
#[test]
fn children_sql_projects_only_the_requested_columns() {
let sql = plan(true)
.children_sql(None, &["title".to_string(), "body".to_string()])
.unwrap();
assert!(
sql.contains(r#"SELECT "title", "body", "user_id" FROM"#),
"{}",
sql
);
assert!(
!sql.contains("SELECT *"),
"an unrequested column should not be read at all: {}",
sql
);
}
#[test]
fn children_sql_always_includes_the_join_column() {
let sql = plan(true)
.children_sql(None, &["title".to_string()])
.unwrap();
assert!(sql.contains(r#""user_id""#), "{}", sql);
}
#[test]
fn children_sql_does_not_repeat_the_join_column() {
let sql = plan(true)
.children_sql(None, &["user_id".to_string(), "title".to_string()])
.unwrap();
assert_eq!(
sql.matches(r#""user_id""#).count(),
2,
"expected the column once in the projection and once in the WHERE: {}",
sql
);
}
#[test]
fn children_sql_escapes_column_names() {
let sql = plan(true)
.children_sql(None, &[r#"ev"il"#.to_string()])
.unwrap();
assert!(sql.contains(r#""ev""il""#), "{}", sql);
}
#[test]
fn children_sql_falls_back_to_every_column() {
let sql = plan(true).children_sql(None, &[]).unwrap();
assert!(sql.contains("SELECT * FROM"), "{}", sql);
}
#[test]
fn children_sql_applies_a_limit() {
let sql = plan(true).children_sql(Some(25), &[]).unwrap();
assert!(sql.contains("LIMIT 25"), "{}", sql);
}
#[test]
fn children_sql_rejects_a_non_plain_type_name() {
let mut p = plan(true);
p.foreign_column_type = "int4; DROP TABLE users".into();
assert!(p.children_sql(None, &[]).is_err());
}
#[test]
fn keys_are_rendered_without_json_quoting() {
assert_eq!(key_to_text(&serde_json::json!(7)), Some("7".to_string()));
assert_eq!(
key_to_text(&serde_json::json!("abc")),
Some("abc".to_string())
);
assert_eq!(key_to_text(&serde_json::Value::Null), None);
}
#[test]
fn parent_keys_are_distinct_and_skip_nulls() {
let parents = vec![
serde_json::json!({"id": 1}),
serde_json::json!({"id": 2}),
serde_json::json!({"id": 1}),
serde_json::json!({"id": null}),
];
assert_eq!(parent_keys(&parents, "id"), vec!["1", "2"]);
}
#[test]
fn to_many_attaches_an_array_and_empty_when_absent() {
let grouped = group_by_key(vec![serde_json::json!({"id": 10, "user_id": 1})], "user_id");
let mut matched = serde_json::json!({"id": 1});
attach_to_parent(&mut matched, "posts", &plan(true), &grouped);
assert_eq!(matched["posts"].as_array().map(|a| a.len()), Some(1));
let mut unmatched = serde_json::json!({"id": 2});
attach_to_parent(&mut unmatched, "posts", &plan(true), &grouped);
assert_eq!(
unmatched["posts"],
serde_json::json!([]),
"a to-many with no matches must still be an array"
);
}
#[test]
fn to_one_attaches_an_object_or_null() {
let grouped = group_by_key(vec![serde_json::json!({"id": 10, "user_id": 1})], "user_id");
let mut matched = serde_json::json!({"id": 1});
attach_to_parent(&mut matched, "author", &plan(false), &grouped);
assert_eq!(matched["author"]["id"], serde_json::json!(10));
let mut unmatched = serde_json::json!({"id": 2});
attach_to_parent(&mut unmatched, "author", &plan(false), &grouped);
assert_eq!(unmatched["author"], serde_json::Value::Null);
}
#[test]
fn spread_lifts_a_to_one_relation_into_the_parent() {
let mut grouped = HashMap::new();
grouped.insert(
"1".to_string(),
vec![serde_json::json!({"first_name": "Ada", "last_name": "L"})],
);
let mut parent = serde_json::json!({"id": 1, "title": "Notes"});
spread_into_parent(
&mut parent,
&plan(false),
&grouped,
&[
("first_name".into(), "first_name".into()),
("last_name".into(), "last_name".into()),
],
);
assert_eq!(parent["first_name"], "Ada");
assert_eq!(parent["last_name"], "L");
assert_eq!(parent["title"], "Notes");
assert!(parent.get("posts").is_none());
}
#[test]
fn spread_without_a_match_still_carries_the_requested_keys() {
let mut parent = serde_json::json!({"id": 9});
spread_into_parent(
&mut parent,
&plan(false),
&HashMap::new(),
&[("first_name".into(), "first_name".into())],
);
assert_eq!(parent["first_name"], serde_json::Value::Null);
}
#[test]
fn spread_of_a_to_many_relation_gives_a_column_per_array() {
let mut grouped = HashMap::new();
grouped.insert(
"1".to_string(),
vec![
serde_json::json!({"title": "a"}),
serde_json::json!({"title": "b"}),
],
);
let mut parent = serde_json::json!({"id": 1});
spread_into_parent(
&mut parent,
&plan(true),
&grouped,
&[("title".into(), "title".into())],
);
assert_eq!(parent["title"], serde_json::json!(["a", "b"]));
}
#[test]
fn spread_without_a_column_list_adds_nothing() {
let mut grouped = HashMap::new();
grouped.insert("1".to_string(), vec![serde_json::json!({"id": 7, "b": 2})]);
let mut parent = serde_json::json!({"id": 1});
spread_into_parent(&mut parent, &plan(false), &grouped, &[]);
assert_eq!(parent, serde_json::json!({"id": 1}));
}
}