use graph_storage_sdk::models::{
EdgeSpec, IngestOptions, IngestRequest, ItemFamily, MigrationSpec, MigrationStep,
NeighborhoodRequest, NodeSpec, ReplaceScope, SearchRequest, TraverseRequest, TypeQuery,
TypeRegistration,
};
use crate::config::GraphStorageConfig;
use crate::domain::error::DomainError;
fn exceeded(what: impl Into<String>) -> DomainError {
DomainError::LimitExceeded { what: what.into() }
}
pub fn admit_identifier(
cfg: &GraphStorageConfig,
what: &str,
value: &str,
) -> Result<(), DomainError> {
if value.len() > cfg.identifier_max_bytes as usize {
return Err(exceeded(format!(
"{what} is {} bytes; identifier_max_bytes is {}",
value.len(),
cfg.identifier_max_bytes
)));
}
Ok(())
}
pub fn admit_ingest(cfg: &GraphStorageConfig, request: &IngestRequest) -> Result<(), DomainError> {
let IngestRequest {
nodes,
edges,
options:
IngestOptions {
create_phantoms: _,
report_per_item: _,
embed: _,
},
replace_scope,
idempotency_key,
} = request;
if nodes.len() > cfg.ingest_max_nodes as usize {
return Err(exceeded(format!(
"batch carries {} nodes; ingest_max_nodes is {}",
nodes.len(),
cfg.ingest_max_nodes
)));
}
if edges.len() > cfg.ingest_max_edges as usize {
return Err(exceeded(format!(
"batch carries {} edges; ingest_max_edges is {}",
edges.len(),
cfg.ingest_max_edges
)));
}
if let Some(key) = idempotency_key {
admit_identifier(cfg, "idempotency_key", key)?;
}
for (index, node) in nodes.iter().enumerate() {
let NodeSpec {
node_key,
type_id,
name,
payload,
expected_version: _,
} = node;
admit_identifier(cfg, &format!("node[{index}] node_key"), node_key)?;
admit_identifier(cfg, &format!("node[{index}] type_id"), type_id)?;
if let Some(name) = name {
admit_identifier(cfg, &format!("node[{index}] name"), name)?;
}
if let Some(payload) = payload {
let bytes = serde_json::to_vec(payload).map_or(usize::MAX, |v| v.len());
if bytes > cfg.payload_max_bytes as usize {
return Err(exceeded(format!(
"node[{index}] payload is {bytes} bytes; payload_max_bytes is {}",
cfg.payload_max_bytes
)));
}
}
}
for (index, edge) in edges.iter().enumerate() {
let EdgeSpec {
type_id,
src_node_key,
dst_node_key,
discriminator,
payload,
} = edge;
admit_identifier(cfg, &format!("edge[{index}] type_id"), type_id)?;
admit_identifier(cfg, &format!("edge[{index}] src_node_key"), src_node_key)?;
admit_identifier(cfg, &format!("edge[{index}] dst_node_key"), dst_node_key)?;
if let Some(discriminator) = discriminator {
admit_identifier(cfg, &format!("edge[{index}] discriminator"), discriminator)?;
}
if let Some(payload) = payload {
let bytes = serde_json::to_vec(payload).map_or(usize::MAX, |v| v.len());
if bytes > cfg.payload_max_bytes as usize {
return Err(exceeded(format!(
"edge[{index}] payload is {bytes} bytes; payload_max_bytes is {}",
cfg.payload_max_bytes
)));
}
}
}
if let Some(ReplaceScope {
attribute,
value,
generation: _,
}) = replace_scope
{
admit_identifier(cfg, "replace_scope.attribute", attribute)?;
admit_identifier(cfg, "replace_scope.value", value)?;
}
let mut total: u64 = replace_scope.as_ref().map_or(0, |replace| {
(replace.attribute.len() as u64).saturating_add(replace.value.len() as u64)
});
for (family, index, bytes) in nodes
.iter()
.enumerate()
.map(|(index, node)| (ItemFamily::Node, index, node_bytes(node)))
.chain(
edges
.iter()
.enumerate()
.map(|(index, edge)| (ItemFamily::Edge, index, edge_bytes(edge))),
)
{
if bytes > u64::from(cfg.item_max_bytes) {
return Err(exceeded(format!(
"{family:?}[{index}] is {bytes} bytes; item_max_bytes is {}",
cfg.item_max_bytes
)));
}
total = total.saturating_add(bytes);
if total > cfg.ingest_max_bytes {
return Err(exceeded(format!(
"the batch is at least {total} bytes; ingest_max_bytes is {}",
cfg.ingest_max_bytes
)));
}
}
Ok(())
}
fn node_bytes(node: &graph_storage_sdk::models::NodeSpec) -> u64 {
let payload = node.payload.as_ref().map_or(0, json_bytes);
payload
.saturating_add(node.node_key.len() as u64)
.saturating_add(node.type_id.len() as u64)
.saturating_add(node.name.as_ref().map_or(0, |name| name.len() as u64))
}
fn edge_bytes(edge: &graph_storage_sdk::models::EdgeSpec) -> u64 {
let payload = edge.payload.as_ref().map_or(0, json_bytes);
payload
.saturating_add(edge.src_node_key.len() as u64)
.saturating_add(edge.dst_node_key.len() as u64)
.saturating_add(edge.type_id.len() as u64)
.saturating_add(
edge.discriminator
.as_ref()
.map_or(0, |value| value.len() as u64),
)
}
fn json_bytes(value: &serde_json::Value) -> u64 {
serde_json::to_vec(value).map_or(u64::MAX, |bytes| bytes.len() as u64)
}
pub fn admit_search(cfg: &GraphStorageConfig, request: &SearchRequest) -> Result<(), DomainError> {
let SearchRequest {
mode: _,
query,
arm_limit,
limit,
type_patterns,
} = request;
let (arm_limit, limit) = (*arm_limit, *limit);
if arm_limit == 0 || arm_limit > cfg.search_max_arm_limit {
return Err(exceeded(format!(
"arm_limit {arm_limit} is outside 1..={}",
cfg.search_max_arm_limit
)));
}
if limit == 0 || limit > cfg.search_max_arm_limit * 2 {
return Err(exceeded(format!(
"limit {limit} is outside 1..={}",
cfg.search_max_arm_limit * 2
)));
}
if query.as_deref().is_none_or(str::is_empty) {
return Err(DomainError::limit_combination(
"this search mode requires `query`",
));
}
if let Some(query) = query
&& query.len() > cfg.search_query_max_bytes as usize
{
return Err(exceeded(format!(
"query is {} bytes; search_query_max_bytes is {}",
query.len(),
cfg.search_query_max_bytes
)));
}
for (index, pattern) in type_patterns.iter().enumerate() {
admit_identifier(cfg, &format!("type_patterns[{index}]"), pattern)?;
}
Ok(())
}
pub fn admit_type_query(cfg: &GraphStorageConfig, query: &TypeQuery) -> Result<(), DomainError> {
let TypeQuery {
kind: _,
pattern,
top,
cursor,
} = query;
if let Some(top) = top
&& (*top == 0 || *top > cfg.projection_max_page)
{
return Err(exceeded(format!(
"limit {top} is outside 1..={}",
cfg.projection_max_page
)));
}
if let Some(pattern) = pattern {
admit_identifier(cfg, "pattern", pattern)?;
}
if let Some(cursor) = cursor {
admit_identifier(cfg, "cursor", cursor)?;
}
Ok(())
}
pub fn admit_traverse(
cfg: &GraphStorageConfig,
request: &TraverseRequest,
) -> Result<(), DomainError> {
let TraverseRequest {
seeds,
depth,
edge_type_patterns,
node_type_patterns,
max_nodes,
} = request;
let depth = *depth;
if seeds.is_empty() {
return Err(DomainError::limit_combination(
"traversal requires at least one seed",
));
}
if depth == 0 || depth > cfg.traversal_max_depth {
return Err(exceeded(format!(
"depth {depth} is outside 1..={}",
cfg.traversal_max_depth
)));
}
let max_nodes = max_nodes.unwrap_or(cfg.traversal_max_nodes);
if max_nodes == 0 || max_nodes > cfg.traversal_max_nodes {
return Err(exceeded(format!(
"max_nodes {max_nodes} is outside 1..={}",
cfg.traversal_max_nodes
)));
}
let distinct: std::collections::BTreeSet<&str> = seeds.iter().map(String::as_str).collect();
if distinct.len() > max_nodes as usize {
return Err(exceeded(format!(
"{} distinct seeds exceed the node budget {max_nodes}; seeds always survive \
truncation",
distinct.len()
)));
}
for (index, seed) in seeds.iter().enumerate() {
admit_identifier(cfg, &format!("seeds[{index}]"), seed)?;
}
for (index, pattern) in edge_type_patterns.iter().enumerate() {
admit_identifier(cfg, &format!("edge_type_patterns[{index}]"), pattern)?;
}
for (index, pattern) in node_type_patterns.iter().enumerate() {
admit_identifier(cfg, &format!("node_type_patterns[{index}]"), pattern)?;
}
Ok(())
}
pub fn admit_neighborhood(
cfg: &GraphStorageConfig,
request: &NeighborhoodRequest,
) -> Result<(), DomainError> {
let NeighborhoodRequest {
root,
depth,
node_budget,
include_phantoms: _,
} = request;
let depth = *depth;
if depth == 0 || depth > cfg.traversal_max_depth {
return Err(exceeded(format!(
"neighborhood depth {depth} is outside 1..={}",
cfg.traversal_max_depth
)));
}
let budget = node_budget.unwrap_or(cfg.traversal_max_nodes);
if budget == 0 || budget > cfg.traversal_max_nodes {
return Err(exceeded(format!(
"node_budget {budget} is outside 1..={}",
cfg.traversal_max_nodes
)));
}
admit_identifier(cfg, "root", root)
}
pub fn admit_projection(
cfg: &GraphStorageConfig,
type_patterns: &[String],
query: &toolkit_odata::ODataQuery,
) -> Result<(), DomainError> {
for (index, pattern) in type_patterns.iter().enumerate() {
admit_identifier(cfg, &format!("type_patterns[{index}]"), pattern)?;
}
let toolkit_odata::ODataQuery {
filter,
order,
limit,
cursor: _,
filter_hash: _,
select,
} = query;
if let Some(limit) = limit
&& (*limit == 0 || *limit > u64::from(cfg.projection_max_page))
{
return Err(exceeded(format!(
"$top {limit} is outside 1..={}",
cfg.projection_max_page
)));
}
if let Some(filter) = filter {
let (bytes, nodes) = filter_shape(filter);
if bytes > toolkit::api::odata::MAX_FILTER_LEN {
return Err(exceeded(format!(
"$filter carries {bytes} bytes of names and values; the bound is {}",
toolkit::api::odata::MAX_FILTER_LEN
)));
}
if nodes > toolkit::api::odata::MAX_NODES {
return Err(exceeded(format!(
"$filter has {nodes} nodes; the bound is {}",
toolkit::api::odata::MAX_NODES
)));
}
}
if order.0.len() > toolkit::api::odata::MAX_ORDER_FIELDS {
return Err(exceeded(format!(
"$orderby names {} fields; the bound is {}",
order.0.len(),
toolkit::api::odata::MAX_ORDER_FIELDS
)));
}
let order_bytes: usize = order.0.iter().map(|key| key.field.len()).sum();
if order_bytes > toolkit::api::odata::MAX_ORDERBY_LEN {
return Err(exceeded(format!(
"$orderby names {order_bytes} bytes of fields; the bound is {}",
toolkit::api::odata::MAX_ORDERBY_LEN
)));
}
if let Some(select) = select {
if select.len() > toolkit::api::odata::MAX_SELECT_FIELDS {
return Err(exceeded(format!(
"$select names {} fields; the bound is {}",
select.len(),
toolkit::api::odata::MAX_SELECT_FIELDS
)));
}
let select_bytes: usize = select.iter().map(String::len).sum();
if select_bytes > toolkit::api::odata::MAX_SELECT_LEN {
return Err(exceeded(format!(
"$select names {select_bytes} bytes of fields; the bound is {}",
toolkit::api::odata::MAX_SELECT_LEN
)));
}
}
Ok(())
}
fn filter_shape(root: &toolkit_odata::ast::Expr) -> (usize, usize) {
use toolkit_odata::ast::{Expr, Value};
let (mut bytes, mut nodes) = (0usize, 0usize);
let mut stack = vec![root];
while let Some(expr) = stack.pop() {
nodes = nodes.saturating_add(1);
match expr {
Expr::And(left, right) | Expr::Or(left, right) | Expr::Compare(left, _, right) => {
bytes = bytes.saturating_add(1);
stack.push(left);
stack.push(right);
}
Expr::Not(inner) => {
bytes = bytes.saturating_add(1);
stack.push(inner);
}
Expr::In(subject, items) => {
bytes = bytes.saturating_add(1);
stack.push(subject);
stack.extend(items.iter());
}
Expr::Function(name, args) => {
bytes = bytes.saturating_add(1).saturating_add(name.len());
stack.extend(args.iter());
}
Expr::Identifier(name) => bytes = bytes.saturating_add(name.len()),
Expr::Value(Value::String(text)) => bytes = bytes.saturating_add(text.len()),
Expr::Value(_) => bytes = bytes.saturating_add(1),
}
}
(bytes, nodes)
}
pub fn admit_registration(
cfg: &GraphStorageConfig,
batch: &[TypeRegistration],
migrations: &[MigrationSpec],
) -> Result<(), DomainError> {
for (index, registration) in batch.iter().enumerate() {
let TypeRegistration { type_id, schema: _ } = registration;
admit_identifier(cfg, &format!("types[{index}].type_id"), type_id)?;
}
for (index, migration) in migrations.iter().enumerate() {
let MigrationSpec { type_id, steps } = migration;
admit_identifier(cfg, &format!("migrations[{index}].type_id"), type_id)?;
for (step_index, step) in steps.iter().enumerate() {
let what = |field: &str| format!("migrations[{index}].steps[{step_index}].{field}");
match step {
MigrationStep::Rename { from, to } => {
admit_identifier(cfg, &what("from"), from)?;
admit_identifier(cfg, &what("to"), to)?;
}
MigrationStep::Default { path, value: _ } | MigrationStep::Drop { path } => {
admit_identifier(cfg, &what("path"), path)?;
}
}
}
}
Ok(())
}
pub fn admit_adjacency_limit(
cfg: &GraphStorageConfig,
requested: Option<u32>,
) -> Result<u32, DomainError> {
let limit = requested.unwrap_or(cfg.node_read_max_adjacency);
if limit == 0 || limit > cfg.node_read_max_adjacency {
return Err(exceeded(format!(
"adjacency_limit {limit} is outside 1..={}",
cfg.node_read_max_adjacency
)));
}
Ok(limit)
}
#[cfg(test)]
mod tests {
use graph_storage_sdk::models::NeighborhoodRequest;
use super::{
GraphStorageConfig, admit_ingest, admit_neighborhood, admit_projection, admit_registration,
admit_traverse, admit_type_query,
};
fn neighborhood(depth: u8) -> NeighborhoodRequest {
NeighborhoodRequest {
root: "root".to_owned(),
depth,
node_budget: None,
include_phantoms: true,
}
}
#[test]
fn the_scope_a_replacement_declares_is_bounded_like_any_other_identifier() {
use graph_storage_sdk::models::{IngestOptions, IngestRequest, ReplaceScope};
let cfg = GraphStorageConfig::default();
let batch = |attribute: String, value: String| IngestRequest {
nodes: Vec::new(),
edges: Vec::new(),
options: IngestOptions::default(),
replace_scope: Some(ReplaceScope {
attribute,
value,
generation: 1,
}),
idempotency_key: None,
};
let fits = "a".repeat(cfg.identifier_max_bytes as usize);
let over = "a".repeat(cfg.identifier_max_bytes as usize + 1);
admit_ingest(&cfg, &batch(fits.clone(), fits.clone())).expect("at the ceiling is admitted");
for (attribute, value, named) in [
(over.clone(), fits.clone(), "replace_scope.attribute"),
(fits, over, "replace_scope.value"),
] {
let refused = admit_ingest(&cfg, &batch(attribute, value))
.expect_err("a key the database will index is not unbounded");
assert!(
refused.to_string().contains(named),
"the refusal names the field: {refused}"
);
}
}
#[test]
fn the_scopes_own_bytes_count_against_the_batch_budget() {
use graph_storage_sdk::models::{IngestOptions, IngestRequest, NodeSpec, ReplaceScope};
let cfg = GraphStorageConfig {
ingest_max_bytes: u64::from(GraphStorageConfig::default().identifier_max_bytes) * 2 + 8,
..GraphStorageConfig::default()
};
let filler = "a".repeat(cfg.identifier_max_bytes as usize);
let request = IngestRequest {
nodes: vec![NodeSpec {
node_key: "k".repeat(16),
type_id: "t".repeat(16),
..NodeSpec::default()
}],
edges: Vec::new(),
options: IngestOptions::default(),
replace_scope: Some(ReplaceScope {
attribute: filler.clone(),
value: filler,
generation: 1,
}),
idempotency_key: None,
};
let refused = admit_ingest(&cfg, &request)
.expect_err("the scope fills the budget, so the node does not fit after it");
assert!(
refused.to_string().contains("ingest_max_bytes"),
"the refusal is the batch budget: {refused}"
);
}
#[test]
fn the_configured_depth_ceiling_governs_neighborhood_too() {
let tightened = GraphStorageConfig {
traversal_max_depth: 2,
..GraphStorageConfig::default()
};
admit_neighborhood(&tightened, &neighborhood(2)).expect("at the ceiling is admitted");
let refused = admit_neighborhood(&tightened, &neighborhood(3))
.expect_err("a lowered ceiling has to bind neighborhood as well");
assert!(
refused.to_string().contains("1..=2"),
"the refusal names the configured ceiling: {refused}"
);
let loosened = GraphStorageConfig {
traversal_max_depth: 6,
..GraphStorageConfig::default()
};
admit_neighborhood(&loosened, &neighborhood(6))
.expect("a raised ceiling admits what it says it admits");
admit_neighborhood(&loosened, &neighborhood(7)).expect_err("and still refuses past it");
admit_neighborhood(&loosened, &neighborhood(0)).expect_err("depth 0 is not a walk");
}
#[test]
fn neighborhood_and_traversal_refuse_the_same_depths() {
use graph_storage_sdk::models::TraverseRequest;
let cfg = GraphStorageConfig {
traversal_max_depth: 4,
..GraphStorageConfig::default()
};
let walk = |depth: u8| TraverseRequest {
seeds: vec!["root".to_owned()],
depth,
edge_type_patterns: Vec::new(),
node_type_patterns: Vec::new(),
max_nodes: None,
};
for depth in 0..=8u8 {
assert_eq!(
admit_traverse(&cfg, &walk(depth)).is_ok(),
admit_neighborhood(&cfg, &neighborhood(depth)).is_ok(),
"depth {depth} is admitted by one and not the other"
);
}
}
#[test]
fn every_identifier_field_of_every_request_is_bounded() {
use graph_storage_sdk::models::{
EdgeSpec, IngestOptions, IngestRequest, MigrationSpec, MigrationStep, NodeSpec,
TypeQuery, TypeRegistration,
};
let cfg = GraphStorageConfig::default();
let fits = "x".repeat(cfg.identifier_max_bytes as usize);
let over = "x".repeat(cfg.identifier_max_bytes as usize + 1);
let ingest = |nodes: Vec<NodeSpec>, edges: Vec<EdgeSpec>| IngestRequest {
nodes,
edges,
options: IngestOptions::default(),
replace_scope: None,
idempotency_key: None,
};
let node = |type_id: &str| NodeSpec {
node_key: "k".to_owned(),
type_id: type_id.to_owned(),
..NodeSpec::default()
};
let edge = |type_id: &str| EdgeSpec {
type_id: type_id.to_owned(),
src_node_key: "a".to_owned(),
dst_node_key: "b".to_owned(),
..EdgeSpec::default()
};
let registration = |type_id: &str| TypeRegistration {
type_id: type_id.to_owned(),
schema: serde_json::json!({}),
};
let migration = |type_id: &str, step: MigrationStep| MigrationSpec {
type_id: type_id.to_owned(),
steps: vec![step],
};
let rename = |from: &str, to: &str| MigrationStep::Rename {
from: from.to_owned(),
to: to.to_owned(),
};
let default = |path: &str| MigrationStep::Default {
path: path.to_owned(),
value: serde_json::json!(1),
};
let drop = |path: &str| MigrationStep::Drop {
path: path.to_owned(),
};
let type_query = |pattern: Option<&str>, cursor: Option<&str>| TypeQuery {
kind: None,
pattern: pattern.map(str::to_owned),
top: None,
cursor: cursor.map(str::to_owned),
};
let admitted = [
admit_ingest(&cfg, &ingest(vec![node(&fits)], vec![edge(&fits)])),
admit_type_query(&cfg, &type_query(Some(&fits), Some(&fits))),
admit_projection(
&cfg,
std::slice::from_ref(&fits),
&toolkit_odata::ODataQuery::default(),
),
admit_registration(
&cfg,
&[registration(&fits)],
&[
migration(&fits, rename(&fits, &fits)),
migration(&fits, default(&fits)),
migration(&fits, drop(&fits)),
],
),
];
for outcome in admitted {
outcome.expect("at the ceiling every identifier is admitted");
}
let refused = [
(
"node[0] type_id",
admit_ingest(&cfg, &ingest(vec![node(&over)], Vec::new())),
),
(
"edge[0] type_id",
admit_ingest(&cfg, &ingest(Vec::new(), vec![edge(&over)])),
),
(
"cursor",
admit_type_query(&cfg, &type_query(None, Some(&over))),
),
(
"pattern",
admit_type_query(&cfg, &type_query(Some(&over), None)),
),
(
"type_patterns[0]",
admit_projection(
&cfg,
std::slice::from_ref(&over),
&toolkit_odata::ODataQuery::default(),
),
),
(
"types[0].type_id",
admit_registration(&cfg, &[registration(&over)], &[]),
),
(
"migrations[0].type_id",
admit_registration(
&cfg,
&[],
&[migration(&over, rename("/payload/a", "/payload/b"))],
),
),
(
"migrations[0].steps[0].from",
admit_registration(&cfg, &[], &[migration("t", rename(&over, "/payload/b"))]),
),
(
"migrations[0].steps[0].to",
admit_registration(&cfg, &[], &[migration("t", rename("/payload/a", &over))]),
),
(
"migrations[0].steps[0].path",
admit_registration(&cfg, &[], &[migration("t", default(&over))]),
),
(
"migrations[0].steps[0].path",
admit_registration(&cfg, &[], &[migration("t", drop(&over))]),
),
];
for (field, outcome) in refused {
let refusal = outcome.expect_err("one byte over the ceiling is refused");
assert!(
refusal.to_string().contains(field),
"the refusal names `{field}`: {refusal}"
);
}
}
#[test]
fn an_in_process_projection_query_is_bounded_like_a_rest_one() {
use toolkit_odata::ast::Expr;
let cfg = GraphStorageConfig::default();
let wide = Expr::Identifier("f".repeat(toolkit::api::odata::MAX_FILTER_LEN + 1));
let query = toolkit_odata::ODataQuery::default().with_filter(wide);
let refused = admit_projection(&cfg, &[], &query)
.expect_err("a filter wider than the REST budget is refused here too");
assert!(refused.to_string().contains("$filter"), "{refused}");
let narrow = Expr::Identifier("name".to_owned());
admit_projection(
&cfg,
&[],
&toolkit_odata::ODataQuery::default().with_filter(narrow),
)
.expect("a filter REST would admit is admitted");
let mut deep = Expr::Identifier("a".to_owned());
for _ in 0..toolkit::api::odata::MAX_NODES.div_euclid(2) {
deep = deep.and(Expr::Identifier("a".to_owned()));
}
let refused = admit_projection(
&cfg,
&[],
&toolkit_odata::ODataQuery::default().with_filter(deep),
)
.expect_err("a filter with more nodes than REST admits is refused");
assert!(refused.to_string().contains("nodes"), "{refused}");
let many_keys = toolkit_odata::ODataOrderBy(
(0..=toolkit::api::odata::MAX_ORDER_FIELDS)
.map(|_| toolkit_odata::OrderKey {
field: "f".to_owned(),
dir: toolkit_odata::SortDir::Asc,
})
.collect(),
);
let refused = admit_projection(
&cfg,
&[],
&toolkit_odata::ODataQuery::default().with_order(many_keys),
)
.expect_err("more order fields than REST admits are refused");
assert!(refused.to_string().contains("$orderby names"), "{refused}");
let many_fields: Vec<String> = (0..=toolkit::api::odata::MAX_SELECT_FIELDS)
.map(|_| "s".to_owned())
.collect();
let query = toolkit_odata::ODataQuery {
select: Some(many_fields),
..toolkit_odata::ODataQuery::default()
};
let refused = admit_projection(&cfg, &[], &query)
.expect_err("more select fields than REST admits are refused");
assert!(refused.to_string().contains("$select names"), "{refused}");
let mut at_limit = Expr::Identifier("a".to_owned());
for _ in 1..toolkit::api::odata::MAX_NODES {
at_limit = !at_limit;
}
admit_projection(
&cfg,
&[],
&toolkit_odata::ODataQuery::default().with_filter(at_limit),
)
.expect("a filter with exactly MAX_NODES nodes is admitted");
let keys_at_limit = toolkit_odata::ODataOrderBy(
(0..toolkit::api::odata::MAX_ORDER_FIELDS)
.map(|_| toolkit_odata::OrderKey {
field: "f".to_owned(),
dir: toolkit_odata::SortDir::Asc,
})
.collect(),
);
admit_projection(
&cfg,
&[],
&toolkit_odata::ODataQuery::default().with_order(keys_at_limit),
)
.expect("exactly MAX_ORDER_FIELDS order keys are admitted");
let fields_at_limit: Vec<String> = (0..toolkit::api::odata::MAX_SELECT_FIELDS)
.map(|_| "s".to_owned())
.collect();
admit_projection(
&cfg,
&[],
&toolkit_odata::ODataQuery {
select: Some(fields_at_limit),
..toolkit_odata::ODataQuery::default()
},
)
.expect("exactly MAX_SELECT_FIELDS select fields are admitted");
}
}