use rushdb_lmpg::reader::try_access as access;
use rushdb_lmpg::reconstruct;
use std::sync::Arc;
pub type QuerySnapshot = Arc<rushdb_storage::ReadView>;
use equality::{any_element, is_datetime, value_equals};
use rushdb_lmpg::{Lmpg, LmpgError};
use rushdb_types::model::{LogicalType, TypeState};
use rushdb_types::value::{
infer_scalar, scalar_cmp as canonical_scalar_cmp, scalar_equal, Scalar, ID_FIELD,
};
use serde_json::{Map, Value as Json};
use thiserror::Error;
mod aggregate_budget;
mod aggregation;
mod cycle;
mod equality;
mod field_types;
pub use aggregation::MAX_AGGREGATE_BUCKETS;
pub use aggregation::{
aggregate, aggregate_in_view, aggregate_to_rows, aggregate_to_rows_in_view,
aggregate_to_rows_in_view_with_budget, aggregate_with_cancel, aggregate_with_cancel_in_view,
aggregate_with_cancel_in_view_with_budget,
};
#[cfg(test)]
use aggregation::{eval_row_expr, exact_f64, minmax};
use field_types::type_held;
mod page;
pub use page::effective_budget;
mod planned;
mod planner;
mod point_fields;
pub use point_fields::PointFields;
mod property_predicate;
pub use hex::hex_decode_16;
pub use index_access::explain_index;
pub use planned::{planned_candidates, PlannedCandidates};
use planner::index_candidates_with_budget;
pub use planner::{
explain_cardinality, explain_range_selectivity, explain_walk, unindexed_walks_total,
UNINDEXED_WALK_THRESHOLD,
};
pub use property_predicate::{matches_properties, parse_property_predicate};
pub mod datetime;
pub mod document;
mod hex;
mod index_access;
mod scan;
mod stats_cache;
pub use document::{ParsedQuery, QueryDocumentError};
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum FieldPred {
Eq(String, Json),
Ne(String, Json),
Gt(String, f64),
Gte(String, f64),
Lt(String, f64),
Lte(String, f64),
Contains(String, String),
StartsWith(String, String),
Exists(String, bool),
IsNull(String, bool),
In(String, Vec<Json>),
Nin(String, Vec<Json>),
EndsWith(String, String),
Id(String),
DatetimeComponent {
field: String,
which: u8,
value: i64,
},
Type(String, String),
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum Expr {
And(Vec<Expr>),
Or(Vec<Expr>),
Xor(Box<Expr>, Box<Expr>),
Nor(Vec<Expr>),
Not(Box<Expr>),
Field(FieldPred),
Related(RelatedExpr),
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct RelatedExpr {
pub target_labels: Vec<String>,
pub alias: Option<String>,
pub rel_type: Option<String>,
pub direction: Direction,
pub hops: Option<(u64, u64)>,
pub cycle: bool,
pub filter: Option<Box<Expr>>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct RelatedSpec {
pub alias: Option<String>,
pub rel_type: Option<String>,
pub direction: Direction,
pub target_labels: Vec<String>,
pub steps: Vec<TraversalStep>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct TraversalStep {
pub target_labels: Vec<String>,
pub rel_type: Option<String>,
pub direction: Direction,
pub filter: Option<Expr>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct CollectSpec {
pub alias: String,
pub rel_type: Option<String>,
pub direction: Direction,
pub target_labels: Vec<String>,
pub steps: Vec<TraversalStep>,
pub fields: Vec<(String, String)>,
pub nested: Vec<CollectSpec>,
pub filter: Option<Expr>,
pub order_by: Vec<(String, bool)>,
pub unique: bool,
pub skip: u64,
pub limit: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Direction {
Outgoing,
Incoming,
Both,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum TimeUnit {
Seconds,
Minutes,
Hours,
Days,
Weeks,
Months,
Quarters,
Years,
}
impl TimeUnit {
fn millis(self) -> i64 {
match self {
TimeUnit::Seconds => 1_000,
TimeUnit::Minutes => 60_000,
TimeUnit::Hours => 3_600_000,
TimeUnit::Days => 86_400_000,
TimeUnit::Weeks => 7 * 86_400_000,
TimeUnit::Months => 30 * 86_400_000,
TimeUnit::Quarters => 90 * 86_400_000,
TimeUnit::Years => 365 * 86_400_000,
}
}
pub fn from_name(name: &str) -> Result<TimeUnit, crate::QueryDocumentError> {
Ok(match name {
"second" | "seconds" => TimeUnit::Seconds,
"minute" | "minutes" => TimeUnit::Minutes,
"hour" | "hours" => TimeUnit::Hours,
"day" | "days" => TimeUnit::Days,
"week" | "weeks" => TimeUnit::Weeks,
"month" | "months" => TimeUnit::Months,
"quarter" => TimeUnit::Quarters,
"year" | "years" => TimeUnit::Years,
other => {
return Err(crate::QueryDocumentError::Parse(format!(
"unknown time unit `{other}`"
)));
}
})
}
pub(crate) fn unit_str(self) -> &'static str {
match self {
TimeUnit::Seconds => "seconds",
TimeUnit::Minutes => "minutes",
TimeUnit::Hours => "hours",
TimeUnit::Days => "days",
TimeUnit::Weeks => "weeks",
TimeUnit::Months => "months",
TimeUnit::Quarters => "quarters",
TimeUnit::Years => "years",
}
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct TimeBucketSpec {
pub field: String,
pub unit: TimeUnit,
pub size: u64,
}
pub const MAX_AVG_PRECISION: u32 = 15;
impl TimeBucketSpec {
pub fn fixed(field: impl Into<String>, unit: TimeUnit) -> Self {
TimeBucketSpec {
field: field.into(),
unit,
size: 1,
}
}
pub fn sized(field: impl Into<String>, unit: TimeUnit, size: u64) -> Self {
assert!(size > 0, "timeBucket size must be positive");
TimeBucketSpec {
field: field.into(),
unit,
size,
}
}
#[expect(
clippy::cast_possible_truncation,
reason = "epoch-ms is a bounded timestamp; the saturating float-to-int cast matches the reference calendar flooring"
)]
fn floor_millis(&self, epoch_ms: f64) -> i64 {
let ms = epoch_ms as i64;
match self.unit {
TimeUnit::Months => rushdb_types::datetime::start_of_month(ms),
TimeUnit::Quarters => rushdb_types::datetime::start_of_quarter(ms),
TimeUnit::Years => rushdb_types::datetime::start_of_year(ms),
_ => {
let size = i64::try_from(self.size).unwrap_or(i64::MAX).max(1);
let width = self.unit.millis().saturating_mul(size).max(1);
ms.div_euclid(width).saturating_mul(width)
}
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Pagination {
pub skip: u64,
pub limit: Option<u64>,
}
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct SearchRequest {
#[serde(default)]
pub labels: Vec<String>,
#[serde(default, rename = "where")]
pub where_: Option<Expr>,
#[serde(default)]
pub related: Vec<RelatedSpec>,
#[serde(default)]
pub collects: Vec<CollectSpec>,
#[serde(default)]
pub pagination: Pagination,
#[serde(default)]
pub order_by: Vec<(String, bool)>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub force_scan: bool,
}
#[derive(Debug, Error)]
pub enum QueryError {
#[error("storage error: {0}")]
Storage(#[from] rushdb_storage::StorageError),
#[error("engine error: {0}")]
Engine(#[from] LmpgError),
#[error("invalid predicate on `{0}`")]
InvalidPredicate(String),
#[error("query cancelled")]
Cancelled,
#[error("resource exhausted: {0}")]
ResourceLimit(String),
#[error("unindexed walk refused: {0}")]
UnindexedWalk(String),
}
#[derive(Debug, Clone)]
pub struct Page {
pub total_matched: u64,
pub records: Vec<Json>,
}
pub fn find_records(lmpg: &Lmpg, req: &SearchRequest) -> Result<Page, QueryError> {
find_records_with_cancel(lmpg, req, &|| false)
}
pub fn find_records_with_cancel(
lmpg: &Lmpg,
req: &SearchRequest,
is_cancelled: &dyn Fn() -> bool,
) -> Result<Page, QueryError> {
let snap = Arc::new(lmpg.store.read_view()?);
find_records_in_view(lmpg, req, is_cancelled, snap)
}
#[expect(
clippy::needless_pass_by_value,
reason = "callers hand over a freshly pinned view, matching find_records_in_view"
)]
pub fn matching_ordinals(
lmpg: &Lmpg,
req: &SearchRequest,
is_cancelled: &dyn Fn() -> bool,
snap: QuerySnapshot,
) -> Result<Vec<u64>, QueryError> {
let is_cancelled: &dyn Fn() -> bool = &cancel_probe(lmpg, is_cancelled);
if !snap.belongs_to(&lmpg.store) {
return Err(rushdb_storage::StorageError::InvalidRead(
"query view belongs to another Store".into(),
)
.into());
}
if is_cancelled() {
return Err(QueryError::Cancelled);
}
let mut full = req.clone();
full.pagination = Pagination::default();
Ok(run_matches(lmpg, &full, is_cancelled, &snap, usize::MAX, None)?.retained)
}
#[expect(
clippy::needless_pass_by_value,
reason = "callers hand over a freshly pinned view, matching find_records_in_view"
)]
pub fn matching_ordinals_first(
lmpg: &Lmpg,
req: &SearchRequest,
is_cancelled: &dyn Fn() -> bool,
snap: QuerySnapshot,
keep: usize,
) -> Result<Vec<u64>, QueryError> {
let is_cancelled: &dyn Fn() -> bool = &cancel_probe(lmpg, is_cancelled);
if !snap.belongs_to(&lmpg.store) {
return Err(rushdb_storage::StorageError::InvalidRead(
"query view belongs to another Store".into(),
)
.into());
}
if is_cancelled() {
return Err(QueryError::Cancelled);
}
let mut full = req.clone();
full.pagination = Pagination::default();
Ok(run_matches(lmpg, &full, is_cancelled, &snap, keep, None)?.retained)
}
struct Matches {
retained: Vec<u64>,
total: u64,
}
fn run_matches(
lmpg: &Lmpg,
req: &SearchRequest,
is_cancelled: &dyn Fn() -> bool,
snap: &QuerySnapshot,
keep: usize,
known_total: Option<u64>,
) -> Result<Matches, QueryError> {
check_walk_budget(lmpg, snap, req)?;
let mut working = WorkingSet {
limit: working_set_budget(lmpg).map(|bytes| u64::try_from(bytes).unwrap_or(u64::MAX)),
charged: 0,
};
let CandidateSource {
mut candidates,
exact,
known,
predicate,
} = candidate_source(lmpg, req, is_cancelled, snap, known_total, &mut working)?;
let ctx = EvalCtx {
lmpg,
snap: snap.clone(),
is_cancelled: Some(is_cancelled),
cycles: crate::cycle::CycleGraphs::default(),
declared_types: std::cell::RefCell::default(),
record_types: std::cell::RefCell::default(),
};
let mut session = reconstruct::ReconstructSession::new(snap, lmpg.tenant);
let order_fields = if req.order_by.is_empty() {
None
} else {
PointFields::resolve(
snap,
lmpg.tenant,
req.order_by.iter().map(|(f, _)| f.as_str()),
)?
};
let predicate_fields = match &predicate {
Some(expr) => PointFields::for_predicate(snap, lmpg.tenant, expr)?,
None => None,
};
let mut retained: Vec<u64> = Vec::new();
let mut ordered: Vec<OrderRow> = Vec::new();
let mut ordered_bytes: Vec<u64> = Vec::new();
let mut total: u64 = 0;
while let Some(ord) = candidates.next(is_cancelled)? {
if is_cancelled() {
return Err(QueryError::Cancelled);
}
if !exact
&& !is_match(
&ctx,
req,
predicate.as_ref(),
predicate_fields.as_ref(),
&mut session,
&mut working,
ord,
)?
{
continue;
}
total += 1;
if req.order_by.is_empty() {
if retained.len() < keep {
working.charge(8, true)?;
retained.push(ord);
} else if known.is_some() {
break;
}
continue;
}
let row = read_row(order_fields.as_ref(), &mut session, snap, lmpg.tenant, ord)?;
let bytes =
serde_json::to_vec(&row).map_or(0, |v| u64::try_from(v.len()).unwrap_or(u64::MAX));
working.charge(bytes, true)?;
ordered.push((ord, row));
ordered_bytes.push(bytes);
if ordered.len() >= keep.saturating_mul(2).max(64) {
working.charged = working.charged.saturating_sub(cut_ordered(
&mut ordered,
&mut ordered_bytes,
req,
keep,
));
}
}
if !req.order_by.is_empty() {
cut_ordered(&mut ordered, &mut ordered_bytes, req, keep);
retained = ordered.into_iter().map(|(ord, _)| ord).collect();
}
Ok(Matches {
retained,
total: known.unwrap_or(total),
})
}
struct WorkingSet {
limit: Option<u64>,
charged: u64,
}
impl WorkingSet {
fn charge(&mut self, bytes: u64, retained: bool) -> Result<(), QueryError> {
if let Some(limit) = self.limit {
if self.charged.saturating_add(bytes) > limit {
return Err(QueryError::ResourceLimit(format!(
"query working set exceeded {limit} bytes"
)));
}
if retained {
self.charged += bytes;
}
}
Ok(())
}
}
fn is_match(
ctx: &EvalCtx<'_>,
req: &SearchRequest,
predicate: Option<&Expr>,
fields: Option<&PointFields>,
session: &mut reconstruct::ReconstructSession<'_, QuerySnapshot>,
working: &mut WorkingSet,
ord: u64,
) -> Result<bool, QueryError> {
let (snap, tenant) = (&ctx.snap, ctx.lmpg.tenant);
if access::record_by_ord(snap, tenant, ord)?.is_none() {
return Ok(false);
}
if let Some(expr) = predicate {
let obj = read_row(fields, session, snap, tenant, ord)?;
let bytes =
serde_json::to_vec(&obj).map_or(0, |v| u64::try_from(v.len()).unwrap_or(u64::MAX));
working.charge(bytes, false)?;
if !eval_expr_ctx(ctx, expr, &obj)? {
return Ok(false);
}
}
for spec in &req.related {
if !related_exists(snap, tenant, ord, spec)? {
return Ok(false);
}
}
Ok(true)
}
fn read_row(
fields: Option<&PointFields>,
session: &mut reconstruct::ReconstructSession<'_, QuerySnapshot>,
snap: &QuerySnapshot,
tenant: rushdb_types::ids::TenantId,
ord: u64,
) -> Result<Map<String, Json>, QueryError> {
match fields {
Some(fields) => fields.row(snap, tenant, ord),
None => Ok(session
.reconstruct_record(ord)?
.and_then(|record| match record {
Json::Object(map) => Some(map),
_ => None,
})
.unwrap_or_default()),
}
}
struct CandidateSource<'v> {
candidates: scan::Candidates<'v>,
exact: bool,
known: Option<u64>,
predicate: Option<Expr>,
}
fn candidate_source<'v>(
lmpg: &Lmpg,
req: &SearchRequest,
is_cancelled: &dyn Fn() -> bool,
snap: &'v QuerySnapshot,
known_total: Option<u64>,
working: &mut WorkingSet,
) -> Result<CandidateSource<'v>, QueryError> {
if let Some(plan) =
index_candidates_with_budget(lmpg, snap, req, working_set_budget(lmpg), is_cancelled)?
{
working.charge(
u64::try_from(plan.ords.serialized_size()).unwrap_or(u64::MAX),
true,
)?;
let known = known_total
.or(plan.known_total)
.or_else(|| plan.exact.then(|| plan.ords.len()));
return Ok(CandidateSource {
candidates: scan::Candidates::from_set(plan.ords),
exact: plan.exact,
known,
predicate: plan.residual,
});
}
let exact = req.where_.is_none() && req.related.is_empty();
let known = match known_total {
Some(total) => Some(total),
None if exact => Some(walk_total(lmpg, snap, &req.labels)?),
None => None,
};
Ok(CandidateSource {
candidates: scan::Candidates::walk(snap, lmpg.tenant, &req.labels)?,
exact,
known,
predicate: req.where_.clone(),
})
}
type OrderRow = (u64, Map<String, Json>);
fn cut_ordered(
ordered: &mut Vec<OrderRow>,
bytes: &mut Vec<u64>,
req: &SearchRequest,
keep: usize,
) -> u64 {
let mut rows: Vec<(OrderRow, u64)> = ordered.drain(..).zip(bytes.drain(..)).collect();
sort_ordered_rows(&mut rows, req);
let freed = rows.iter().skip(keep).map(|(_, b)| *b).sum();
rows.truncate(keep);
for (row, b) in rows {
ordered.push(row);
bytes.push(b);
}
freed
}
fn walk_total(lmpg: &Lmpg, snap: &QuerySnapshot, labels: &[String]) -> Result<u64, QueryError> {
if labels.is_empty() {
return Ok(rushdb_lmpg::derived::tenant_usage(snap.as_ref(), lmpg.tenant)?.records);
}
let distinct: std::collections::BTreeSet<&String> = labels.iter().collect();
let mut total = 0u64;
for label in distinct {
total = total.saturating_add(rushdb_lmpg::derived::label_count(
snap.as_ref(),
lmpg.tenant,
label,
)?);
}
Ok(total)
}
pub fn find_records_in_view(
lmpg: &Lmpg,
req: &SearchRequest,
is_cancelled: &dyn Fn() -> bool,
snap: QuerySnapshot,
) -> Result<Page, QueryError> {
find_records_in_view_with_budget(lmpg, req, is_cancelled, snap, None)
}
fn check_walk_budget(
lmpg: &Lmpg,
snap: &QuerySnapshot,
req: &SearchRequest,
) -> Result<(), QueryError> {
let (Some(limit), Some(expr)) = (lmpg.limits().unindexed_walk_max_owners(), &req.where_) else {
return Ok(());
};
if explain_index(snap.as_ref(), lmpg.tenant, &req.labels, expr)?.is_some() {
return Ok(());
}
let walk = explain_walk(snap.as_ref(), lmpg.tenant, &req.labels, expr)?;
let (property, owners, labels) = if let Some(walk) = walk {
walk
} else {
let owners = if req.labels.is_empty() {
rushdb_lmpg::derived::tenant_usage(snap.as_ref(), lmpg.tenant)?.records
} else {
let mut owners = 0u64;
for label in &req.labels {
owners = owners.saturating_add(rushdb_lmpg::derived::label_count(
snap.as_ref(),
lmpg.tenant,
label,
)?);
}
owners
};
(
"(no indexable condition)".to_owned(),
owners,
req.labels.clone(),
)
};
if owners <= limit {
return Ok(());
}
Err(QueryError::UnindexedWalk(format!(
"the filter on `{property}` would check {owners} records of [{}], over \
the unindexed-walk limit ({limit}); declare an ordered index on the property",
labels.join(", ")
)))
}
fn working_set_budget(lmpg: &Lmpg) -> Option<usize> {
lmpg.limits()
.query_memory_max_bytes()
.map(|bytes| usize::try_from(bytes).unwrap_or(usize::MAX))
}
pub(crate) fn cancel_probe<'a>(
lmpg: &Lmpg,
is_cancelled: &'a dyn Fn() -> bool,
) -> impl Fn() -> bool + 'a {
let limits = lmpg.limits();
move || is_cancelled() || limits.expired()
}
#[allow(clippy::needless_pass_by_value)]
pub fn find_records_in_view_with_budget(
lmpg: &Lmpg,
req: &SearchRequest,
is_cancelled: &dyn Fn() -> bool,
snap: QuerySnapshot,
work_budget: Option<usize>,
) -> Result<Page, QueryError> {
find_page(lmpg, req, is_cancelled, &snap, work_budget, None)
}
pub fn find_records_streaming(
lmpg: &Lmpg,
req: &SearchRequest,
is_cancelled: &dyn Fn() -> bool,
snap: QuerySnapshot,
) -> Result<Page, QueryError> {
find_records_streaming_with_total(lmpg, req, is_cancelled, snap, None)
}
#[expect(
clippy::needless_pass_by_value,
reason = "the snapshot is accepted by value as part of the public API contract so callers can hand over a freshly pinned view"
)]
pub fn find_records_streaming_with_total(
lmpg: &Lmpg,
req: &SearchRequest,
is_cancelled: &dyn Fn() -> bool,
snap: QuerySnapshot,
known_total: Option<u64>,
) -> Result<Page, QueryError> {
find_page(lmpg, req, is_cancelled, &snap, None, known_total)
}
fn find_page(
lmpg: &Lmpg,
req: &SearchRequest,
is_cancelled: &dyn Fn() -> bool,
snap: &QuerySnapshot,
work_budget: Option<usize>,
known_total: Option<u64>,
) -> Result<Page, QueryError> {
let is_cancelled: &dyn Fn() -> bool = &cancel_probe(lmpg, is_cancelled);
if !snap.belongs_to(&lmpg.store) {
return Err(rushdb_storage::StorageError::InvalidRead(
"query view belongs to another Store".into(),
)
.into());
}
if is_cancelled() {
return Err(QueryError::Cancelled);
}
let skip = usize::try_from(req.pagination.skip).unwrap_or(usize::MAX);
let limit = usize::try_from(req.pagination.limit.unwrap_or(u64::MAX)).unwrap_or(usize::MAX);
let matches = run_matches(
lmpg,
req,
is_cancelled,
snap,
skip.saturating_add(limit),
known_total,
)?;
let page_roots: Vec<u64> = matches
.retained
.into_iter()
.skip(skip)
.take(limit)
.collect();
let budget = effective_budget(
work_budget,
lmpg.limits().query_memory_max_bytes().unwrap_or(0),
);
let records =
page::reconstruct_page_with_budget(lmpg, req, is_cancelled, snap, page_roots, budget)?;
Ok(Page {
total_matched: matches.total,
records,
})
}
fn sort_ordered_rows<T>(sortable: &mut [(OrderRow, T)], req: &SearchRequest) {
for (field, desc) in req.order_by.iter().rev() {
let field = normalize(field);
sortable.sort_by(|((_, a), _), ((_, b), _)| {
let va = a
.iter()
.find(|(k, _)| normalize(k) == field)
.map(|(_, v)| v);
let vb = b
.iter()
.find(|(k, _)| normalize(k) == field)
.map(|(_, v)| v);
let ord = match (va, vb) {
(Some(x), Some(y)) => json_value_cmp(x, y),
(None, Some(_)) => std::cmp::Ordering::Less,
(Some(_), None) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
};
if *desc {
ord.reverse()
} else {
ord
}
});
}
}
type RelatedItem = (u64, Map<String, Json>, Map<String, Json>);
fn collect_neighbors(
lmpg: &Lmpg,
snap: &QuerySnapshot,
root: u64,
spec: &CollectSpec,
budget: Option<usize>,
is_cancelled: &dyn Fn() -> bool,
) -> Result<Vec<u64>, QueryError> {
Ok(if spec.steps.is_empty() {
let edges: Vec<(String, u64, u64)> = match spec.direction {
Direction::Outgoing => {
access::adj_out(snap, lmpg.tenant, root, spec.rel_type.as_deref())?
}
Direction::Incoming => {
access::adj_in(snap, lmpg.tenant, root, spec.rel_type.as_deref())?
}
Direction::Both => {
let mut all = access::adj_out(snap, lmpg.tenant, root, spec.rel_type.as_deref())?;
all.extend(access::adj_in(
snap,
lmpg.tenant,
root,
spec.rel_type.as_deref(),
)?);
all
}
};
edges.into_iter().map(|(_, other, _)| other).collect()
} else {
traverse_alias(
lmpg,
snap,
lmpg.tenant,
root,
&spec.steps,
budget,
is_cancelled,
)?
})
}
fn order_collected_items(items: &mut Vec<RelatedItem>, spec: &CollectSpec) {
if spec.unique {
items.sort_by_key(|(o, _, _)| *o);
items.dedup_by(|a, b| a.1.get(ID_FIELD) == b.1.get(ID_FIELD));
} else {
items.sort_by_key(|(o, _, _)| *o);
}
if !spec.order_by.is_empty() {
items.sort_by(|a, b| {
for (field, desc) in &spec.order_by {
let av = raw_field(&a.2, field).or_else(|| raw_field(&a.1, field));
let bv = raw_field(&b.2, field).or_else(|| raw_field(&b.1, field));
let ord = scalar_cmp(av, bv).unwrap_or(std::cmp::Ordering::Equal);
if ord != std::cmp::Ordering::Equal {
return if *desc { ord.reverse() } else { ord };
}
}
std::cmp::Ordering::Equal
});
}
}
fn attach_collects_with_budget(
lmpg: &Lmpg,
snap: &QuerySnapshot,
root: u64,
mut record: Json,
collects: &[CollectSpec],
budget: Option<usize>,
is_cancelled: &dyn Fn() -> bool,
) -> Result<Json, QueryError> {
if collects.is_empty() {
return Ok(record);
}
let mut retained = aggregate_budget::value_bytes(&record);
aggregate_budget::check(budget, retained)?;
let ctx = EvalCtx {
lmpg,
snap: snap.clone(),
is_cancelled: None,
cycles: crate::cycle::CycleGraphs::default(),
declared_types: std::cell::RefCell::default(),
record_types: std::cell::RefCell::default(),
};
if let Some(obj) = record.as_object_mut() {
for spec in collects {
if is_cancelled() {
return Err(QueryError::Cancelled);
}
let neighbors = collect_neighbors(
lmpg,
snap,
root,
spec,
budget.map(|limit| limit.saturating_sub(retained)),
is_cancelled,
)?;
let base_bytes = retained;
retained = retained.saturating_add(neighbors.capacity().saturating_mul(8));
aggregate_budget::check(budget, retained)?;
let mut items: Vec<RelatedItem> = Vec::new();
for other in neighbors {
if is_cancelled() {
return Err(QueryError::Cancelled);
}
if !spec.target_labels.is_empty()
&& !access::record_by_ord(snap, lmpg.tenant, other)?
.is_some_and(|h| spec.target_labels.contains(&h.label))
{
continue;
}
let reader = aggregate_budget::RecordReader::new(
snap,
budget.map(|limit| limit.saturating_sub(retained)),
);
let Some(Json::Object(child)) =
reconstruct::reconstruct_record(&reader, lmpg.tenant, other)?
else {
continue;
};
if let Some(filter) = &spec.filter {
if !eval_expr_ctx(&ctx, filter, &child)? {
continue;
}
}
retained = retained
.saturating_add(aggregate_budget::object_bytes(&child).saturating_mul(3));
aggregate_budget::check(budget, retained)?;
let projected = match project_fields_map(Json::Object(child.clone()), &spec.fields)
{
Json::Object(p) => p,
_ => child.clone(),
};
items.push((other, child, projected));
}
order_collected_items(&mut items, spec);
let start = usize::try_from(spec.skip)
.unwrap_or(usize::MAX)
.min(items.len());
let end = spec.limit.map_or(items.len(), |l| {
start
.saturating_add(usize::try_from(l).unwrap_or(usize::MAX))
.min(items.len())
});
let mut projected = Vec::new();
for (other, raw, child) in items.into_iter().take(end).skip(start) {
retained =
retained.saturating_sub(aggregate_budget::object_bytes(&raw).saturating_mul(3));
let child = attach_collects_with_budget(
lmpg,
snap,
other,
Json::Object(child),
&spec.nested,
budget.map(|limit| limit.saturating_sub(retained)),
is_cancelled,
)?;
retained = retained.saturating_add(aggregate_budget::value_bytes(&child));
aggregate_budget::check(budget, retained)?;
projected.push(child);
}
let collected = Json::Array(projected);
retained = base_bytes
.saturating_add(spec.alias.len())
.saturating_add(aggregate_budget::value_bytes(&collected));
aggregate_budget::check(budget, retained)?;
obj.insert(spec.alias.clone(), collected);
}
}
Ok(record)
}
fn project_fields_map(value: Json, fields: &[(String, String)]) -> Json {
if fields.is_empty() {
return value;
}
let mut out = Map::new();
if let Json::Object(obj) = value {
for (out_key, source) in fields {
if let Some(v) = resolve_field(&obj, source) {
out.insert(out_key.clone(), v);
}
}
}
Json::Object(out)
}
fn scalar_cmp(a: Option<&Json>, b: Option<&Json>) -> Option<std::cmp::Ordering> {
let (Some(a), Some(b)) = (a, b) else {
return None;
};
match (a, b) {
(Json::Number(_), Json::Number(_)) => {
let left = infer_scalar(a).map(|(_, scalar)| scalar);
let right = infer_scalar(b).map(|(_, scalar)| scalar);
left.as_ref()
.zip(right.as_ref())
.and_then(|(left, right)| canonical_scalar_cmp(left, right))
}
(Json::String(x), Json::String(y)) => Some(x.cmp(y)),
_ => None,
}
}
fn related_exists(
snap: &QuerySnapshot,
tenant: rushdb_types::ids::TenantId,
ord: u64,
spec: &RelatedSpec,
) -> Result<bool, QueryError> {
let edges: Vec<(String, u64, u64)> = match spec.direction {
Direction::Outgoing => access::adj_out(snap, tenant, ord, spec.rel_type.as_deref())?,
Direction::Incoming => access::adj_in(snap, tenant, ord, spec.rel_type.as_deref())?,
Direction::Both => {
let mut all = access::adj_out(snap, tenant, ord, spec.rel_type.as_deref())?;
all.extend(access::adj_in(snap, tenant, ord, spec.rel_type.as_deref())?);
all
}
};
if spec.target_labels.is_empty() {
return Ok(!edges.is_empty());
}
for (_, other_ord, _) in edges {
if access::record_by_ord(snap, tenant, other_ord)?
.is_some_and(|h| spec.target_labels.contains(&h.label))
{
return Ok(true);
}
}
Ok(false)
}
fn count_related(
lmpg: &Lmpg,
snap: &QuerySnapshot,
tenant: rushdb_types::ids::TenantId,
ord: u64,
specs: &[RelatedSpec],
alias: &str,
budget: Option<usize>,
) -> Result<Option<Vec<u64>>, QueryError> {
let Some(spec) = specs.iter().find(|s| {
s.alias
.as_deref()
.is_some_and(|a| a.trim_start_matches('$') == alias)
}) else {
return Ok(None);
};
Ok(Some(traverse_alias(
lmpg,
snap,
tenant,
ord,
&spec.steps,
budget,
&|| false,
)?))
}
fn traverse_alias(
lmpg: &Lmpg,
snap: &QuerySnapshot,
tenant: rushdb_types::ids::TenantId,
ord: u64,
steps: &[TraversalStep],
budget: Option<usize>,
is_cancelled: &dyn Fn() -> bool,
) -> Result<Vec<u64>, QueryError> {
if steps.is_empty() {
return Ok(Vec::new());
}
let ctx = EvalCtx {
lmpg,
snap: snap.clone(),
is_cancelled: Some(is_cancelled),
cycles: crate::cycle::CycleGraphs::default(),
declared_types: std::cell::RefCell::default(),
record_types: std::cell::RefCell::default(),
};
let mut cur = vec![ord];
for step in steps {
if is_cancelled() {
return Err(QueryError::Cancelled);
}
let mut next: Vec<u64> = Vec::new();
for o in &cur {
if is_cancelled() {
return Err(QueryError::Cancelled);
}
let edges: Vec<(String, u64, u64)> = match step.direction {
Direction::Outgoing => access::adj_out(snap, tenant, *o, step.rel_type.as_deref())?,
Direction::Incoming => access::adj_in(snap, tenant, *o, step.rel_type.as_deref())?,
Direction::Both => {
let mut all = access::adj_out(snap, tenant, *o, step.rel_type.as_deref())?;
all.extend(access::adj_in(snap, tenant, *o, step.rel_type.as_deref())?);
all
}
};
for (_, other, _) in edges {
if !step.target_labels.is_empty()
&& !access::record_by_ord(snap, tenant, other)?
.is_some_and(|h| step.target_labels.contains(&h.label))
{
continue;
}
if let Some(pred) = &step.filter {
let reader = aggregate_budget::RecordReader::new(
snap,
budget.map(|limit| {
limit.saturating_sub(
cur.len().saturating_add(next.len()).saturating_mul(32),
)
}),
);
let props = reconstruct::reconstruct_record(&reader, tenant, other)?;
let obj = props
.as_ref()
.and_then(|p| p.as_object().cloned())
.unwrap_or_default();
if !eval_expr_ctx(&ctx, pred, &obj)? {
continue;
}
}
aggregate_budget::check(
budget,
cur.len()
.saturating_add(next.len())
.saturating_add(1)
.saturating_mul(32),
)?;
next.push(other);
}
}
next.sort_unstable();
next.dedup();
cur = next;
}
Ok(cur)
}
fn related_specs_from_where(expr: Option<&Expr>) -> Vec<RelatedSpec> {
fn split_level(filter: Option<&Expr>, children: &mut Vec<Expr>) -> Option<Expr> {
let f = filter?;
match f {
Expr::And(items) => {
let mut preds: Vec<Expr> = Vec::new();
for item in items {
match item {
Expr::Related(_) => children.push(item.clone()),
other => preds.push(other.clone()),
}
}
match preds.len() {
0 => None,
1 => Some(preds.remove(0)),
_ => Some(Expr::And(preds)),
}
}
Expr::Related(_) => {
children.push(f.clone());
None
}
other => Some(other.clone()),
}
}
fn walk(expr: &Expr, path: &mut Vec<TraversalStep>, out: &mut Vec<RelatedSpec>) {
match expr {
Expr::And(items) | Expr::Or(items) | Expr::Nor(items) => {
for e in items {
walk(e, path, out);
}
}
Expr::Xor(a, b) => {
walk(a, path, out);
walk(b, path, out);
}
Expr::Not(b) => {
walk(b, path, out);
}
Expr::Related(rel) => {
let mut children: Vec<Expr> = Vec::new();
let filter = split_level(rel.filter.as_deref(), &mut children);
let step = TraversalStep {
target_labels: rel.target_labels.clone(),
rel_type: rel.rel_type.clone(),
direction: rel.direction,
filter,
};
path.push(step);
if let Some(alias) = &rel.alias {
out.push(RelatedSpec {
alias: Some(alias.trim_start_matches('$').to_string()),
rel_type: rel.rel_type.clone(),
direction: rel.direction,
target_labels: rel.target_labels.clone(),
steps: path.clone(),
});
}
for child in &children {
walk(child, path, out);
}
path.pop();
}
Expr::Field(_) => {}
}
}
let mut specs = Vec::new();
if let Some(e) = expr {
let mut path: Vec<TraversalStep> = Vec::new();
walk(e, &mut path, &mut specs);
}
specs
}
fn alias_field_values(
lmpg: &Lmpg,
snap: &QuerySnapshot,
tenant: rushdb_types::ids::TenantId,
ord: u64,
specs: &[RelatedSpec],
field: &str,
budget: Option<usize>,
) -> Result<Option<Vec<Json>>, QueryError> {
let Some((alias, prop)) = field.split_once('.') else {
return Ok(None);
};
let Some(related) = count_related(lmpg, snap, tenant, ord, specs, alias, budget)? else {
return Ok(None);
};
let mut retained = related.capacity().saturating_mul(8);
aggregate_budget::check(budget, retained)?;
let mut values = Vec::new();
for other in related {
let reader = aggregate_budget::RecordReader::new(
snap,
budget.map(|limit| limit.saturating_sub(retained)),
);
if let Some(Json::Object(child)) = reconstruct::reconstruct_record(&reader, tenant, other)?
{
if let Some(v) = resolve_field(&child, prop) {
retained = retained.saturating_add(aggregate_budget::value_bytes(&v));
aggregate_budget::check(budget, retained)?;
values.push(v);
}
}
}
Ok(Some(values))
}
pub const MISSING_SENTINEL: &str = "__missing__";
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct GroupBucket {
pub keys: Map<String, Json>,
pub count_roots: u64,
pub sums: Map<String, Json>,
pub counts: Map<String, Json>,
pub mins: Map<String, Json>,
pub maxs: Map<String, Json>,
pub derived_outputs: std::collections::BTreeMap<String, Json>,
}
#[derive(Debug, Clone)]
pub enum OutputExpr {
Ref(String),
Field(String),
Add(Box<OutputExpr>, Box<OutputExpr>),
Subtract(Box<OutputExpr>, Box<OutputExpr>),
Multiply(Box<OutputExpr>, Box<OutputExpr>),
Divide {
dividend: Box<OutputExpr>,
divisor: Box<OutputExpr>,
precision: u32,
},
Literal(f64),
}
#[derive(Debug, Clone, Default)]
pub struct AggregateRequest {
pub labels: Vec<String>,
pub where_: Option<Expr>,
pub related: Vec<RelatedSpec>,
pub group_by: Vec<String>,
pub sums: Vec<(String, String)>,
pub counts: Vec<(String, String)>,
pub mins: Vec<(String, String)>,
pub maxs: Vec<(String, String)>,
pub time_bucket: Option<TimeBucketSpec>,
pub outputs: Vec<(String, OutputExpr)>,
pub expr_sums: Vec<(String, OutputExpr)>,
pub expr_avgs: Vec<(String, OutputExpr)>,
pub avg_terms: Vec<(String, String)>,
pub avg_precision: std::collections::BTreeMap<String, u32>,
pub time_bucket_alias: Option<String>,
pub plain_fields: Vec<(String, String)>,
pub collects: Vec<CollectSpec>,
pub order_by: Vec<(String, bool /* desc */)>,
pub skip: u64,
pub limit: Option<u64>,
pub hidden_outputs: std::collections::BTreeSet<String>,
}
pub struct EvalCtx<'a> {
pub lmpg: &'a Lmpg,
pub snap: QuerySnapshot,
pub is_cancelled: Option<&'a dyn Fn() -> bool>,
pub(crate) cycles: cycle::CycleGraphs,
pub(crate) declared_types:
std::cell::RefCell<std::collections::HashMap<String, Option<LogicalType>>>,
pub(crate) record_types:
std::cell::RefCell<std::collections::HashMap<String, Vec<(u64, LogicalType)>>>,
}
impl<'a> EvalCtx<'a> {
pub fn new(lmpg: &'a Lmpg) -> Result<Self, QueryError> {
Ok(EvalCtx {
lmpg,
snap: Arc::new(lmpg.store.read_view()?),
is_cancelled: None,
cycles: cycle::CycleGraphs::default(),
declared_types: std::cell::RefCell::default(),
record_types: std::cell::RefCell::default(),
})
}
pub fn check_cancelled(&self) -> Result<(), QueryError> {
if self.is_cancelled.is_some_and(|probe| probe()) {
Err(QueryError::Cancelled)
} else {
Ok(())
}
}
}
fn eval_expr_ctx(
ctx: &EvalCtx<'_>,
expr: &Expr,
obj: &Map<String, Json>,
) -> Result<bool, QueryError> {
match expr {
Expr::And(items) => {
for item in items {
if !eval_expr_ctx(ctx, item, obj)? {
return Ok(false);
}
}
Ok(true)
}
Expr::Or(items) | Expr::Nor(items) => {
let inverted = matches!(expr, Expr::Nor(_));
for item in items {
if eval_expr_ctx(ctx, item, obj)? {
return Ok(!inverted);
}
}
Ok(inverted)
}
Expr::Xor(a, b) => Ok(eval_expr_ctx(ctx, a, obj)? != eval_expr_ctx(ctx, b, obj)?),
Expr::Not(inner) => Ok(!eval_expr_ctx(ctx, inner, obj)?),
Expr::Field(pred) => eval_pred(&|name| type_held(ctx, name, obj), pred, obj),
Expr::Related(rel) => eval_related(ctx, obj, rel),
}
}
fn eval_related(
ctx: &EvalCtx<'_>,
root: &Map<String, Json>,
rel: &RelatedExpr,
) -> Result<bool, QueryError> {
let root_id = root.get(ID_FIELD).and_then(|v| v.as_str()).unwrap_or("");
let start_ord = root_ord_of(ctx, root_id)?;
if start_ord == u64::MAX {
return Ok(false);
}
let (min_hops, max_hops) = rel.hops.unwrap_or((1, 1));
let neighbors = |node: u64| -> Result<Vec<(String, u64, u64)>, QueryError> {
Ok(match rel.direction {
Direction::Outgoing => {
access::adj_out(&ctx.snap, ctx.lmpg.tenant, node, rel.rel_type.as_deref())?
}
Direction::Incoming => {
access::adj_in(&ctx.snap, ctx.lmpg.tenant, node, rel.rel_type.as_deref())?
}
Direction::Both => {
let mut all =
access::adj_out(&ctx.snap, ctx.lmpg.tenant, node, rel.rel_type.as_deref())?;
all.extend(access::adj_in(
&ctx.snap,
ctx.lmpg.tenant,
node,
rel.rel_type.as_deref(),
)?);
all
}
})
};
let endpoint_ok = |other: u64| -> Result<bool, QueryError> {
if rel.target_labels.is_empty() {
Ok(true)
} else {
Ok(access::record_by_ord(&ctx.snap, ctx.lmpg.tenant, other)?
.is_some_and(|h| rel.target_labels.contains(&h.label)))
}
};
if rel.cycle {
let cancel = || ctx.check_cancelled();
return ctx
.cycles
.on_cycle(&ctx.snap, ctx.lmpg.tenant, rel, start_ord, &cancel);
}
let mut frontier: Vec<u64> = vec![start_ord];
let mut visited: std::collections::BTreeSet<u64> = std::collections::BTreeSet::new();
visited.insert(start_ord);
let mut depth = 0u64;
while !frontier.is_empty() && depth < max_hops {
ctx.check_cancelled()?;
depth += 1;
let mut next: Vec<u64> = Vec::new();
for node in &frontier {
for (_ty, other, _ro) in neighbors(*node)? {
if !endpoint_ok(other)? {
continue;
}
if depth >= min_hops {
if let Some(filter) = &rel.filter {
if let Some(target_json) =
reconstruct::reconstruct_record(&ctx.snap, ctx.lmpg.tenant, other)?
{
let target_obj = target_json.as_object().cloned().unwrap_or_default();
if eval_expr_ctx(ctx, filter, &target_obj)? {
return Ok(true);
}
}
} else {
return Ok(true);
}
}
if depth < max_hops && visited.insert(other) {
next.push(other);
}
}
}
frontier = next;
}
Ok(false)
}
fn root_ord_of(ctx: &EvalCtx<'_>, id_hex: &str) -> Result<u64, QueryError> {
let bytes = hex_decode_16(id_hex).unwrap_or([0u8; 16]);
let id = rushdb_types::ids::RecordId(bytes);
Ok(access::record_by_id(&ctx.snap, ctx.lmpg.tenant, &id)?.map_or(u64::MAX, |h| h.ordinal))
}
pub(crate) type DeclaredType<'a> =
dyn Fn(&str) -> Result<Option<rushdb_types::model::LogicalType>, QueryError> + 'a;
#[expect(
clippy::too_many_lines,
reason = "one exhaustive match over every field predicate keeps the operator semantics readable in a single place"
)]
pub(crate) fn eval_pred(
declared_type: &DeclaredType<'_>,
pred: &FieldPred,
obj: &Map<String, Json>,
) -> Result<bool, QueryError> {
Ok(match pred {
FieldPred::Exists(name, want) => {
let present = raw_field(obj, name).is_some();
present == *want
}
FieldPred::IsNull(name, want) => {
let v = raw_field(obj, name);
let is_null = matches!(v, Some(Json::Null));
is_null == *want
}
FieldPred::Eq(name, val) => match (raw_field(obj, name), val) {
(Some(_), Json::Null) => matches!(raw_field(obj, name), Some(Json::Null)),
(Some(actual), expected) => {
let datetime = is_datetime(declared_type, name)?;
any_element(actual, |el| value_equals(el, expected, datetime))
}
(None, _) => false,
},
FieldPred::Ne(name, val) => {
match raw_field(obj, name) {
None => false,
Some(actual) => {
let datetime = is_datetime(declared_type, name)?;
!any_element(actual, |el| value_equals(el, val, datetime))
}
}
}
FieldPred::In(name, vals) => match raw_field(obj, name) {
None => false,
Some(actual) => {
let datetime = is_datetime(declared_type, name)?;
any_element(actual, |el| {
vals.iter().any(|v| value_equals(el, v, datetime))
})
}
},
FieldPred::Contains(name, sub) => raw_field(obj, name).is_some_and(|j| {
let sub = sub.to_lowercase();
any_element(j, |el| {
el.as_str()
.is_some_and(|st| st.to_lowercase().contains(sub.as_str()))
})
}),
FieldPred::StartsWith(name, prefix) => raw_field(obj, name).is_some_and(|j| {
let prefix = prefix.to_lowercase();
any_element(j, |el| {
el.as_str()
.is_some_and(|st| st.to_lowercase().starts_with(prefix.as_str()))
})
}),
FieldPred::Gt(name, bound) => numeric_cmp_any(raw_field(obj, name), *bound, |o| {
o == std::cmp::Ordering::Greater
}),
FieldPred::Gte(name, bound) => numeric_cmp_any(raw_field(obj, name), *bound, |o| {
o != std::cmp::Ordering::Less
}),
FieldPred::Lt(name, bound) => numeric_cmp_any(raw_field(obj, name), *bound, |o| {
o == std::cmp::Ordering::Less
}),
FieldPred::Lte(name, bound) => numeric_cmp_any(raw_field(obj, name), *bound, |o| {
o != std::cmp::Ordering::Greater
}),
FieldPred::Nin(name, vals) => {
match raw_field(obj, name) {
None => false,
Some(actual) => {
let datetime = is_datetime(declared_type, name)?;
!any_element(actual, |el| {
vals.iter().any(|v| value_equals(el, v, datetime))
})
}
}
}
FieldPred::EndsWith(name, suffix) => raw_field(obj, name).is_some_and(|j| {
let suffix = suffix.to_lowercase();
any_element(j, |el| {
el.as_str()
.is_some_and(|st| st.to_lowercase().ends_with(suffix.as_str()))
})
}),
FieldPred::Id(id) => obj
.get(ID_FIELD)
.and_then(|v| v.as_str())
.is_some_and(|s| canonical_id(s) == canonical_id(id)),
FieldPred::DatetimeComponent {
field,
which,
value,
} => raw_field(obj, field)
.and_then(serde_json::Value::as_i64)
.is_some_and(|ms| {
let actual = crate::datetime::component_of(ms, *which);
actual == *value
}),
FieldPred::Type(name, expected) => {
let want = canonical_type_alias(&expected.to_lowercase());
let (base_want, want_array) = match want.strip_suffix("[]") {
Some(base) if !base.is_empty() => (Some(canonical_type_alias(base)), true),
_ => (None, want == "array"),
};
let value = raw_field(obj, name);
if value.is_none() {
return Ok(want == "null");
}
let is_array = matches!(value, Some(Json::Array(_)));
if want == "array" {
return Ok(is_array);
}
if let Some(ty) = declared_type(name)? {
let name_of = |t: &rushdb_types::model::LogicalType| -> &'static str {
match t {
rushdb_types::model::LogicalType::Boolean => "boolean",
rushdb_types::model::LogicalType::Int64
| rushdb_types::model::LogicalType::Float64 => "number",
rushdb_types::model::LogicalType::Utf8 => "string",
rushdb_types::model::LogicalType::DateTime => "datetime",
}
};
if want_array {
if is_array && name_of(&ty) == base_want.as_deref().unwrap_or("") {
return Ok(true);
}
} else if name_of(&ty) == want {
return Ok(true);
}
}
let shape_of = |v: &Json| -> &'static str {
if v.is_string() {
"string"
} else if v.is_number() {
"number"
} else if v.is_boolean() {
"boolean"
} else if v.is_array() {
"array"
} else if v.is_object() {
"object"
} else {
"null"
}
};
if want_array {
let base = base_want.as_deref().unwrap_or("");
return Ok(
matches!(value, Some(Json::Array(items)) if items.iter().any(|el| shape_of(el) == base)),
);
}
let actual = value.map_or("null", shape_of);
actual == want
}
})
}
fn numeric_cmp(
value: Option<&Json>,
bound: f64,
accept: impl Fn(std::cmp::Ordering) -> bool,
) -> bool {
value
.and_then(infer_scalar)
.and_then(|(_, scalar)| match scalar {
Scalar::Int64(_) | Scalar::Float64(_) => Some(scalar),
_ => None,
})
.and_then(|value| canonical_scalar_cmp(&value, &Scalar::Float64(bound)))
.is_some_and(accept)
}
fn numeric_cmp_any(
value: Option<&Json>,
bound: f64,
accept: impl Fn(std::cmp::Ordering) -> bool,
) -> bool {
match value {
Some(v) if v.is_array() => v
.as_array()
.is_some_and(|a| a.iter().any(|el| numeric_cmp(Some(el), bound, &accept))),
other => numeric_cmp(other, bound, accept),
}
}
fn canonical_type_alias(t: &str) -> String {
if t == "bool" {
"boolean".to_string()
} else {
t.to_string()
}
}
fn json_value_cmp(a: &Json, b: &Json) -> std::cmp::Ordering {
let numeric = |v: &Json| {
infer_scalar(v).and_then(|(_, scalar)| match scalar {
Scalar::Int64(_) | Scalar::Float64(_) => Some(scalar),
_ => None,
})
};
match (numeric(a), numeric(b)) {
(Some(x), Some(y)) => canonical_scalar_cmp(&x, &y).unwrap_or(std::cmp::Ordering::Equal),
(None, Some(_)) => std::cmp::Ordering::Less,
(Some(_), None) => std::cmp::Ordering::Greater,
(None, None) => match (a.as_str(), b.as_str()) {
(Some(x), Some(y)) => x.cmp(y),
_ => format!("{a}").cmp(&format!("{b}")),
},
}
}
fn raw_field<'a>(obj: &'a Map<String, Json>, name: &str) -> Option<&'a Json> {
let norm = normalize(name);
obj.iter()
.find(|(k, _)| normalize(k) == norm)
.map(|(_, v)| v)
}
fn canonical_id(id: &str) -> String {
id.bytes()
.filter(|byte| *byte != b'-')
.map(|byte| (byte as char).to_ascii_lowercase())
.collect()
}
fn binding_roots_with_cancel(
lmpg: &Lmpg,
req: &SearchRequest,
is_cancelled: &dyn Fn() -> bool,
snap: &QuerySnapshot,
budget: Option<usize>,
) -> Result<Vec<u64>, QueryError> {
let plan = index_candidates_with_budget(lmpg, snap, req, budget, is_cancelled)?;
let index_exact = plan.as_ref().is_some_and(|plan| plan.exact);
let candidates: Vec<u64> = match plan {
Some(plan) => plan.ords.into_iter().collect(),
None => scan::Candidates::walk(snap, lmpg.tenant, &req.labels)?.collect(is_cancelled)?,
};
let candidate_bytes = candidates.capacity().saturating_mul(16);
aggregate_budget::check(budget, candidate_bytes)?;
if index_exact && req.order_by.is_empty() {
return Ok(candidates);
}
let ctx = EvalCtx {
lmpg,
snap: snap.clone(),
is_cancelled: Some(is_cancelled),
cycles: crate::cycle::CycleGraphs::default(),
declared_types: std::cell::RefCell::default(),
record_types: std::cell::RefCell::default(),
};
let mut matched: Vec<u64> = Vec::new();
for ord in candidates {
if is_cancelled() {
return Err(QueryError::Cancelled);
}
if access::record_by_ord(snap, lmpg.tenant, ord)?.is_none() {
continue;
}
if let Some(expr) = &req.where_ {
let reader = aggregate_budget::RecordReader::new(
snap,
budget.map(|limit| limit.saturating_sub(candidate_bytes)),
);
let mut session = reconstruct::ReconstructSession::new(&reader, lmpg.tenant);
let props = session.reconstruct_record(ord)?;
let obj = props
.as_ref()
.and_then(|p| p.as_object().cloned())
.unwrap_or_default();
if !eval_expr_ctx(&ctx, expr, &obj)? {
continue;
}
}
let mut all_branches_bound = true;
for spec in &req.related {
if !related_exists(snap, lmpg.tenant, ord, spec)? {
all_branches_bound = false;
break;
}
}
if all_branches_bound {
matched.push(ord);
}
}
Ok(matched)
}
#[must_use]
pub fn normalize(name: &str) -> String {
rushdb_types::value::normalize_name(name)
}
fn resolve_field(obj: &Map<String, Json>, field: &str) -> Option<Json> {
let norm = normalize(field.strip_prefix("$record.").unwrap_or(field));
obj.iter()
.find(|(k, _)| normalize(k) == norm)
.map(|(_, v)| v.clone())
}
fn resolve_field_numeric(obj: &Map<String, Json>, field: &str) -> Option<Json> {
resolve_field(obj, field).filter(serde_json::Value::is_number)
}
#[cfg(test)]
mod index_planner_tests;