use serde_json::Value as Json;
use crate::query::error::QueryError;
#[derive(Debug, Clone, PartialEq)]
pub struct QuerySpec {
pub source: String,
pub filter: Option<Json>,
pub fields: Vec<String>,
pub sort: Vec<SortKey>,
pub limit: Option<u64>,
pub skip: Option<u64>,
pub include: Vec<IncludeSpec>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct IncludeSpec {
pub relation: String,
pub fields: Vec<String>,
pub sort: Vec<SortKey>,
pub limit: Option<u64>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SortKey {
pub field: String,
pub dir: SortDir,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortDir {
Asc,
Desc,
}
fn invalid(msg: impl Into<String>) -> QueryError {
QueryError::InvalidEnvelope(msg.into())
}
const ENVELOPE_KEYS: [&str; 7] = [
"source", "filter", "fields", "sort", "limit", "skip", "include",
];
pub fn parse(query: &Json) -> Result<QuerySpec, QueryError> {
let obj = query
.as_object()
.ok_or_else(|| invalid("query must be a JSON object"))?;
if let Some(unknown) = obj.keys().find(|k| !ENVELOPE_KEYS.contains(&k.as_str())) {
return Err(invalid(format!(
"unknown key '{unknown}' in query envelope (expected \
source/filter/fields/sort/limit/skip/include)"
)));
}
let source = obj
.get("source")
.and_then(|v| v.as_str())
.ok_or_else(|| invalid("missing required string field 'source'"))?
.to_string();
if source.is_empty() {
return Err(invalid("'source' must not be empty"));
}
let filter = obj.get("filter").filter(|v| !v.is_null()).cloned();
let fields = match obj.get("fields") {
None | Some(Json::Null) => Vec::new(),
Some(Json::Array(arr)) => {
let mut out = Vec::with_capacity(arr.len());
for (i, f) in arr.iter().enumerate() {
let s = f
.as_str()
.ok_or_else(|| invalid(format!("fields[{i}] must be a string")))?;
out.push(s.to_string());
}
out
}
Some(_) => return Err(invalid("'fields' must be an array of strings")),
};
let sort = parse_sort(obj.get("sort"), "sort")?;
let limit = parse_u64(obj.get("limit"), "limit")?;
let skip = parse_u64(obj.get("skip"), "skip")?;
let include = parse_include(obj.get("include"))?;
Ok(QuerySpec {
source,
filter,
fields,
sort,
limit,
skip,
include,
})
}
fn parse_include(v: Option<&Json>) -> Result<Vec<IncludeSpec>, QueryError> {
let obj = match v {
None | Some(Json::Null) => return Ok(Vec::new()),
Some(Json::Object(m)) => m,
Some(_) => {
return Err(invalid(
"'include' must be an object of relation → selection",
));
}
};
let mut out = Vec::with_capacity(obj.len());
for (relation, sel) in obj {
let sel = sel
.as_object()
.ok_or_else(|| invalid(format!("include.{relation} must be an object")))?;
if let Some(unknown) = sel
.keys()
.find(|k| !matches!(k.as_str(), "fields" | "sort" | "limit"))
{
return Err(invalid(format!(
"unknown key '{unknown}' in include.{relation} (expected fields/sort/limit)"
)));
}
let fields = match sel.get("fields") {
None | Some(Json::Null) => Vec::new(),
Some(Json::Array(arr)) => {
let mut fs = Vec::with_capacity(arr.len());
for (i, f) in arr.iter().enumerate() {
let s = f.as_str().ok_or_else(|| {
invalid(format!("include.{relation}.fields[{i}] must be a string"))
})?;
fs.push(s.to_string());
}
fs
}
Some(_) => {
return Err(invalid(format!(
"include.{relation}.fields must be an array"
)));
}
};
let sort = parse_sort(sel.get("sort"), &format!("include.{relation}.sort"))?;
let limit = parse_u64(sel.get("limit"), &format!("include.{relation}.limit"))?;
out.push(IncludeSpec {
relation: relation.clone(),
fields,
sort,
limit,
});
}
Ok(out)
}
fn parse_sort(v: Option<&Json>, at: &str) -> Result<Vec<SortKey>, QueryError> {
let arr = match v {
None | Some(Json::Null) => return Ok(Vec::new()),
Some(Json::Array(a)) => a,
Some(_) => return Err(invalid(format!("'{at}' must be an array"))),
};
let mut out = Vec::with_capacity(arr.len());
for (i, entry) in arr.iter().enumerate() {
let obj = entry.as_object().ok_or_else(|| {
invalid(format!(
"{at}[{i}] must be an object like {{\"field\":\"asc\"}}"
))
})?;
if obj.len() != 1 {
return Err(invalid(format!("{at}[{i}] must have exactly one key")));
}
let (field, dirv) = obj.iter().next().expect("map has exactly one entry");
let dir = match dirv.as_str() {
Some("asc") => SortDir::Asc,
Some("desc") => SortDir::Desc,
_ => {
return Err(invalid(format!(
"{at}[{i}].{field} must be \"asc\" or \"desc\""
)));
}
};
out.push(SortKey {
field: field.clone(),
dir,
});
}
Ok(out)
}
fn parse_u64(v: Option<&Json>, name: &str) -> Result<Option<u64>, QueryError> {
match v {
None | Some(Json::Null) => Ok(None),
Some(n) => {
let u = n
.as_u64()
.ok_or_else(|| invalid(format!("'{name}' must be a non-negative integer")))?;
Ok(Some(u))
}
}
}
impl QuerySpec {
pub fn resolve_names(mut self, reg: &crate::query::EntityRegistry) -> Result<Self, QueryError> {
if self.fields.is_empty() {
self.fields = reg.default_projection(&self.source, "fields")?;
} else {
for (i, field) in self.fields.iter_mut().enumerate() {
*field = reg
.resolve_field(&self.source, field, &format!("fields[{i}]"))?
.physical;
}
}
for (i, key) in self.sort.iter_mut().enumerate() {
key.field = reg
.resolve_field(&self.source, &key.field, &format!("sort[{i}]"))?
.physical;
}
for inc in self.include.iter_mut() {
let target = reg
.resolve_relation(&self.source, &inc.relation, "include")?
.1;
if inc.fields.is_empty() {
inc.fields =
reg.default_projection(&target, &format!("include.{}.fields", inc.relation))?;
} else {
for (i, field) in inc.fields.iter_mut().enumerate() {
*field = reg
.resolve_field(
&target,
field,
&format!("include.{}.fields[{i}]", inc.relation),
)?
.physical;
}
}
for (i, key) in inc.sort.iter_mut().enumerate() {
key.field = reg
.resolve_field(
&target,
&key.field,
&format!("include.{}.sort[{i}]", inc.relation),
)?
.physical;
}
}
Ok(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn unknown_envelope_keys_are_rejected_naming_the_key() {
for bad in ["fileds", "lmit", "orderby"] {
let mut query = json!({ "source": "users" });
query[bad] = json!(5000);
let err = parse(&query).expect_err("unknown key must be rejected");
assert!(err.to_string().contains(bad), "{err}");
assert!(matches!(err, QueryError::InvalidEnvelope(_)), "{err}");
}
}
#[test]
fn unknown_include_selection_keys_are_rejected() {
let err = parse(&json!({
"source": "users",
"include": { "orders": { "fields": ["id"], "limt": 5 } }
}))
.expect_err("unknown include key must be rejected");
assert!(err.to_string().contains("limt"), "{err}");
assert!(err.to_string().contains("include.orders"), "{err}");
}
#[test]
fn an_unordered_include_parses_and_is_left_to_the_backend() {
let spec = parse(&json!({
"source": "users",
"include": { "orders": { "fields": ["id"], "limit": 5 } }
}))
.expect("parsing is backend-neutral");
assert!(spec.include[0].sort.is_empty());
}
#[test]
fn the_full_envelope_still_parses() {
let spec = parse(&json!({
"source": "users",
"filter": { "==": [{ "field": "id" }, 1] },
"fields": ["id", "name"],
"sort": [{ "name": "asc" }],
"limit": 10,
"skip": 20,
"include": { "orders": { "fields": ["total"], "sort": [{ "id": "asc" }], "limit": 5 } }
}))
.expect("every documented key must be known");
assert_eq!(spec.source, "users");
assert_eq!(spec.limit, Some(10));
assert_eq!(spec.skip, Some(20));
assert_eq!(spec.include.len(), 1);
}
}