1use rushdb_lmpg::reader::try_access as access;
9use rushdb_lmpg::reconstruct;
10use std::sync::Arc;
11pub type QuerySnapshot = Arc<rushdb_storage::ReadView>;
13use equality::{any_element, is_datetime, value_equals};
14use rushdb_lmpg::{Lmpg, LmpgError};
15use rushdb_types::model::{LogicalType, TypeState};
16use rushdb_types::value::{
17 infer_scalar, scalar_cmp as canonical_scalar_cmp, scalar_equal, Scalar, ID_FIELD,
18};
19use serde_json::{Map, Value as Json};
20use thiserror::Error;
21
22mod adjacency;
23mod aggregate_budget;
24mod aggregate_rows;
25mod aggregate_scan;
26mod budget;
27pub use budget::Budget;
28use budget::{Cap, WorkingSet};
29mod aggregate_catalog;
30mod aggregation;
31mod cycle;
32mod equality;
33mod expand;
34mod field_types;
35#[cfg(test)]
36use aggregation::exact_f64;
37pub use aggregation::MAX_AGGREGATE_BUCKETS;
38pub use aggregation::{
39 aggregate, aggregate_in_view, aggregate_to_rows, aggregate_to_rows_in_view,
40 aggregate_to_rows_in_view_with_budget, aggregate_with_cancel, aggregate_with_cancel_in_view,
41};
42use field_types::type_held;
43mod matches;
44mod order;
45mod page;
46mod planned;
47mod planner;
48mod point_fields;
49mod projection;
50pub use matches::{explain_order, OrderExplain};
51use matches::{filtered_row, is_match, run_matches, walk_total, OrderRow};
52pub use point_fields::PointFields;
53mod property_predicate;
54pub use hex::hex_decode_16;
55pub use index_access::explain_index;
56pub use planned::{planned_candidates, PlannedCandidates};
57use planner::index_candidates_with_budget;
58pub use planner::{
59 explain_cardinality, explain_expansion, explain_range_selectivity, explain_walk,
60 unindexed_walks_total, UNINDEXED_WALK_THRESHOLD,
61};
62pub use property_predicate::{matches_properties, parse_property_predicate};
63pub mod datetime;
64pub mod document;
65mod hex;
66mod index_access;
67mod scan;
68mod stats_cache;
69pub use document::{ParsedQuery, QueryDocumentError};
70
71#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
73pub enum FieldPred {
74 Eq(String, Json),
76 Ne(String, Json),
78 Gt(String, f64),
80 Gte(String, f64),
82 Lt(String, f64),
84 Lte(String, f64),
86 Contains(String, String),
88 StartsWith(String, String),
90 Exists(String, bool),
92 IsNull(String, bool),
94 In(String, Vec<Json>),
96 Nin(String, Vec<Json>),
98 EndsWith(String, String),
100 Id(String),
102 DatetimeComponent {
105 field: String,
107 which: u8,
109 value: i64,
111 },
112 Type(String, String),
114}
115
116#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
118pub enum Expr {
119 And(Vec<Expr>),
121 Or(Vec<Expr>),
123 Xor(Box<Expr>, Box<Expr>),
125 Nor(Vec<Expr>),
127 Not(Box<Expr>),
129 Field(FieldPred),
131 Related(RelatedExpr),
134}
135
136#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
138pub struct RelatedExpr {
139 pub target_labels: Vec<String>,
141 pub alias: Option<String>,
143 pub rel_type: Option<String>,
145 pub direction: Direction,
147 pub hops: Option<(u64, u64)>,
149 pub cycle: bool,
151 pub filter: Option<Box<Expr>>,
154}
155
156#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
158pub struct RelatedSpec {
159 pub alias: Option<String>,
161 pub rel_type: Option<String>,
163 pub direction: Direction,
165 pub target_labels: Vec<String>,
167 pub steps: Vec<TraversalStep>,
173}
174
175#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
177pub struct TraversalStep {
178 pub target_labels: Vec<String>,
180 pub rel_type: Option<String>,
182 pub direction: Direction,
184 pub filter: Option<Expr>,
188}
189
190#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
201pub struct CollectSpec {
202 pub alias: String,
204 pub rel_type: Option<String>,
206 pub direction: Direction,
208 pub target_labels: Vec<String>,
210 pub steps: Vec<TraversalStep>,
214 pub fields: Vec<(String, String)>,
216 pub nested: Vec<CollectSpec>,
218 pub filter: Option<Expr>,
220 pub order_by: Vec<(String, bool)>,
222 pub unique: bool,
224 pub skip: u64,
226 pub limit: Option<u64>,
228}
229
230#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
232pub enum Direction {
233 Outgoing,
235 Incoming,
237 Both,
239}
240
241#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
243pub enum TimeUnit {
244 Seconds,
246 Minutes,
248 Hours,
250 Days,
252 Weeks,
254 Months,
256 Quarters,
258 Years,
260}
261
262impl TimeUnit {
263 fn millis(self) -> i64 {
264 match self {
265 TimeUnit::Seconds => 1_000,
266 TimeUnit::Minutes => 60_000,
267 TimeUnit::Hours => 3_600_000,
268 TimeUnit::Days => 86_400_000,
269 TimeUnit::Weeks => 7 * 86_400_000,
270 TimeUnit::Months => 30 * 86_400_000,
271 TimeUnit::Quarters => 90 * 86_400_000,
272 TimeUnit::Years => 365 * 86_400_000,
273 }
274 }
275
276 pub fn from_name(name: &str) -> Result<TimeUnit, crate::QueryDocumentError> {
283 Ok(match name {
284 "second" | "seconds" => TimeUnit::Seconds,
285 "minute" | "minutes" => TimeUnit::Minutes,
286 "hour" | "hours" => TimeUnit::Hours,
287 "day" | "days" => TimeUnit::Days,
288 "week" | "weeks" => TimeUnit::Weeks,
289 "month" | "months" => TimeUnit::Months,
290 "quarter" => TimeUnit::Quarters,
291 "year" | "years" => TimeUnit::Years,
292 other => {
293 return Err(crate::QueryDocumentError::Parse(format!(
294 "unknown time unit `{other}`"
295 )));
296 }
297 })
298 }
299
300 pub(crate) fn unit_str(self) -> &'static str {
301 match self {
302 TimeUnit::Seconds => "seconds",
303 TimeUnit::Minutes => "minutes",
304 TimeUnit::Hours => "hours",
305 TimeUnit::Days => "days",
306 TimeUnit::Weeks => "weeks",
307 TimeUnit::Months => "months",
308 TimeUnit::Quarters => "quarters",
309 TimeUnit::Years => "years",
310 }
311 }
312}
313
314#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
317pub struct TimeBucketSpec {
318 pub field: String,
320 pub unit: TimeUnit,
322 pub size: u64,
324}
325
326pub const MAX_AVG_PRECISION: u32 = 15;
330
331impl TimeBucketSpec {
332 pub fn fixed(field: impl Into<String>, unit: TimeUnit) -> Self {
334 TimeBucketSpec {
335 field: field.into(),
336 unit,
337 size: 1,
338 }
339 }
340
341 pub fn sized(field: impl Into<String>, unit: TimeUnit, size: u64) -> Self {
347 assert!(size > 0, "timeBucket size must be positive");
348 TimeBucketSpec {
349 field: field.into(),
350 unit,
351 size,
352 }
353 }
354
355 #[expect(
356 clippy::cast_possible_truncation,
357 reason = "epoch-ms is a bounded timestamp; the saturating float-to-int cast matches the reference calendar flooring"
358 )]
359 fn floor_millis(&self, epoch_ms: f64) -> i64 {
360 let ms = epoch_ms as i64;
361 match self.unit {
365 TimeUnit::Months => rushdb_types::datetime::start_of_month(ms),
366 TimeUnit::Quarters => rushdb_types::datetime::start_of_quarter(ms),
367 TimeUnit::Years => rushdb_types::datetime::start_of_year(ms),
368 _ => {
369 let size = i64::try_from(self.size).unwrap_or(i64::MAX).max(1);
372 let width = self.unit.millis().saturating_mul(size).max(1);
373 ms.div_euclid(width).saturating_mul(width)
374 }
375 }
376 }
377}
378
379#[derive(Debug, Clone, Copy, Default, PartialEq, serde::Serialize, serde::Deserialize)]
381pub struct Pagination {
382 pub skip: u64,
384 pub limit: Option<u64>,
386}
387
388#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
390pub struct SearchRequest {
391 #[serde(default)]
393 pub labels: Vec<String>,
394 #[serde(default, rename = "where")]
396 pub where_: Option<Expr>,
397 #[serde(default)]
400 pub related: Vec<RelatedSpec>,
401 #[serde(default)]
403 pub collects: Vec<CollectSpec>,
404 #[serde(default, skip_serializing_if = "Vec::is_empty")]
407 pub projection: Vec<(String, String)>,
408 #[serde(default)]
410 pub pagination: Pagination,
411 #[serde(default)]
413 pub order_by: Vec<(String, bool)>,
414 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
418 pub force_scan: bool,
419}
420
421#[derive(Debug, Error)]
423pub enum QueryError {
424 #[error("storage error: {0}")]
426 Storage(#[from] rushdb_storage::StorageError),
427 #[error("engine error: {0}")]
429 Engine(#[from] LmpgError),
430 #[error("invalid predicate on `{0}`")]
432 InvalidPredicate(String),
433 #[error("query cancelled")]
435 Cancelled,
436 #[error("resource exhausted: {0}")]
438 ResourceLimit(String),
439 #[error("unindexed walk refused: {0}")]
442 UnindexedWalk(String),
443}
444
445#[derive(Debug, Clone)]
447pub struct Page {
448 pub total_matched: u64,
450 pub records: Vec<Json>,
452}
453
454pub fn find_records(lmpg: &Lmpg, req: &SearchRequest) -> Result<Page, QueryError> {
462 find_records_with_cancel(lmpg, req, &|| false)
463}
464
465pub fn find_records_with_cancel(
474 lmpg: &Lmpg,
475 req: &SearchRequest,
476 is_cancelled: &dyn Fn() -> bool,
477) -> Result<Page, QueryError> {
478 let snap = Arc::new(lmpg.store.read_view()?);
479 find_records_in_view(lmpg, req, is_cancelled, snap)
480}
481
482#[expect(
496 clippy::needless_pass_by_value,
497 reason = "callers hand over a freshly pinned view, matching find_records_in_view"
498)]
499pub fn matching_ordinals(
500 lmpg: &Lmpg,
501 req: &SearchRequest,
502 is_cancelled: &dyn Fn() -> bool,
503 snap: QuerySnapshot,
504) -> Result<Vec<u64>, QueryError> {
505 let is_cancelled: &dyn Fn() -> bool = &cancel_probe(lmpg, is_cancelled);
506 if !snap.belongs_to(&lmpg.store) {
507 return Err(rushdb_storage::StorageError::InvalidRead(
508 "query view belongs to another Store".into(),
509 )
510 .into());
511 }
512 if is_cancelled() {
513 return Err(QueryError::Cancelled);
514 }
515 let mut full = req.clone();
516 full.pagination = Pagination::default();
517 Ok(run_matches(lmpg, &full, is_cancelled, &snap, 0, usize::MAX, None)?.retained)
518}
519
520#[expect(
529 clippy::needless_pass_by_value,
530 reason = "callers hand over a freshly pinned view, matching find_records_in_view"
531)]
532pub fn matching_ordinals_first(
533 lmpg: &Lmpg,
534 req: &SearchRequest,
535 is_cancelled: &dyn Fn() -> bool,
536 snap: QuerySnapshot,
537 keep: usize,
538) -> Result<Vec<u64>, QueryError> {
539 let is_cancelled: &dyn Fn() -> bool = &cancel_probe(lmpg, is_cancelled);
540 if !snap.belongs_to(&lmpg.store) {
541 return Err(rushdb_storage::StorageError::InvalidRead(
542 "query view belongs to another Store".into(),
543 )
544 .into());
545 }
546 if is_cancelled() {
547 return Err(QueryError::Cancelled);
548 }
549 let mut full = req.clone();
550 full.pagination = Pagination::default();
551 Ok(run_matches(lmpg, &full, is_cancelled, &snap, 0, keep, None)?.retained)
552}
553
554pub fn find_records_in_view(
564 lmpg: &Lmpg,
565 req: &SearchRequest,
566 is_cancelled: &dyn Fn() -> bool,
567 snap: QuerySnapshot,
568) -> Result<Page, QueryError> {
569 find_records_in_view_with_budget(lmpg, req, is_cancelled, snap, None)
570}
571
572fn check_walk_budget(
578 lmpg: &Lmpg,
579 snap: &QuerySnapshot,
580 req: &SearchRequest,
581) -> Result<(), QueryError> {
582 let (Some(limit), Some(expr)) = (lmpg.limits().unindexed_walk_max_owners(), &req.where_) else {
583 return Ok(());
584 };
585 if explain_index(snap.as_ref(), lmpg.tenant, &req.labels, expr)?.is_some()
586 || explain_expansion(snap.as_ref(), lmpg.tenant, &req.labels, expr)?.is_some()
587 {
588 return Ok(());
589 }
590 let walk = explain_walk(snap.as_ref(), lmpg.tenant, &req.labels, expr)?;
591 let (property, owners, labels) = if let Some(walk) = walk {
592 walk
593 } else {
594 let owners = if req.labels.is_empty() {
596 rushdb_lmpg::derived::tenant_usage(snap.as_ref(), lmpg.tenant)?.records
597 } else {
598 let mut owners = 0u64;
599 for label in &req.labels {
600 owners = owners.saturating_add(rushdb_lmpg::derived::label_count(
601 snap.as_ref(),
602 lmpg.tenant,
603 label,
604 )?);
605 }
606 owners
607 };
608 (
609 "(no indexable condition)".to_owned(),
610 owners,
611 req.labels.clone(),
612 )
613 };
614 if owners <= limit {
615 return Ok(());
616 }
617 Err(QueryError::UnindexedWalk(format!(
618 "the filter on `{property}` would check {owners} records of [{}], over \
619 the unindexed-walk limit ({limit}); declare an ordered index on the property",
620 labels.join(", ")
621 )))
622}
623
624fn working_set_budget(lmpg: &Lmpg) -> Option<usize> {
631 lmpg.limits()
632 .query_memory_max_bytes()
633 .map(|bytes| usize::try_from(bytes).unwrap_or(usize::MAX))
634}
635
636pub(crate) fn cancel_probe<'a>(
639 lmpg: &Lmpg,
640 is_cancelled: &'a dyn Fn() -> bool,
641) -> impl Fn() -> bool + 'a {
642 let limits = lmpg.limits();
643 move || is_cancelled() || limits.expired()
644}
645
646#[allow(clippy::needless_pass_by_value)]
654pub fn find_records_in_view_with_budget(
655 lmpg: &Lmpg,
656 req: &SearchRequest,
657 is_cancelled: &dyn Fn() -> bool,
658 snap: QuerySnapshot,
659 work_budget: Option<usize>,
660) -> Result<Page, QueryError> {
661 find_page(lmpg, req, is_cancelled, &snap, work_budget, None)
662}
663
664pub fn find_records_streaming(
672 lmpg: &Lmpg,
673 req: &SearchRequest,
674 is_cancelled: &dyn Fn() -> bool,
675 snap: QuerySnapshot,
676) -> Result<Page, QueryError> {
677 find_records_streaming_with_total(lmpg, req, is_cancelled, snap, None)
678}
679
680#[expect(
691 clippy::needless_pass_by_value,
692 reason = "the snapshot is accepted by value as part of the public API contract so callers can hand over a freshly pinned view"
693)]
694pub fn find_records_streaming_with_total(
695 lmpg: &Lmpg,
696 req: &SearchRequest,
697 is_cancelled: &dyn Fn() -> bool,
698 snap: QuerySnapshot,
699 known_total: Option<u64>,
700) -> Result<Page, QueryError> {
701 find_page(lmpg, req, is_cancelled, &snap, None, known_total)
702}
703
704fn find_page(
707 lmpg: &Lmpg,
708 req: &SearchRequest,
709 is_cancelled: &dyn Fn() -> bool,
710 snap: &QuerySnapshot,
711 work_budget: Option<usize>,
712 known_total: Option<u64>,
713) -> Result<Page, QueryError> {
714 let is_cancelled: &dyn Fn() -> bool = &cancel_probe(lmpg, is_cancelled);
715 if !snap.belongs_to(&lmpg.store) {
716 return Err(rushdb_storage::StorageError::InvalidRead(
717 "query view belongs to another Store".into(),
718 )
719 .into());
720 }
721 if is_cancelled() {
722 return Err(QueryError::Cancelled);
723 }
724 let skip = usize::try_from(req.pagination.skip).unwrap_or(usize::MAX);
725 let limit = usize::try_from(req.pagination.limit.unwrap_or(u64::MAX)).unwrap_or(usize::MAX);
726 let matches = run_matches(lmpg, req, is_cancelled, snap, skip, limit, known_total)?;
727 let page_roots: Vec<u64> = matches
728 .retained
729 .into_iter()
730 .skip(skip.saturating_sub(matches.offset))
731 .take(limit)
732 .collect();
733 let budget = Cap::for_response(work_budget, lmpg.limits().query_memory_max_bytes());
734 let records =
735 page::reconstruct_page_with_budget(lmpg, req, is_cancelled, snap, page_roots, budget)?;
736 Ok(Page {
737 total_matched: matches.total,
738 records,
739 })
740}
741
742fn sort_ordered_rows<T>(sortable: &mut [(OrderRow, T)], req: &SearchRequest) {
745 for (field, desc) in req.order_by.iter().rev() {
746 let field = normalize(field);
747 sortable.sort_by(|((_, a), _), ((_, b), _)| {
748 let va = a
749 .iter()
750 .find(|(k, _)| normalize(k) == field)
751 .map(|(_, v)| v);
752 let vb = b
753 .iter()
754 .find(|(k, _)| normalize(k) == field)
755 .map(|(_, v)| v);
756 let ord = match (va, vb) {
757 (Some(x), Some(y)) => json_value_cmp(x, y),
758 (None, Some(_)) => std::cmp::Ordering::Less,
759 (Some(_), None) => std::cmp::Ordering::Greater,
760 (None, None) => std::cmp::Ordering::Equal,
761 };
762 if *desc {
763 ord.reverse()
764 } else {
765 ord
766 }
767 });
768 }
769}
770
771type RelatedItem = (u64, Map<String, Json>, Map<String, Json>);
773
774fn collect_neighbors(
775 lmpg: &Lmpg,
776 snap: &QuerySnapshot,
777 root: u64,
778 spec: &CollectSpec,
779 budget: Option<usize>,
780 is_cancelled: &dyn Fn() -> bool,
781) -> Result<Vec<u64>, QueryError> {
782 Ok(if spec.steps.is_empty() {
783 let edges: Vec<(String, u64, u64)> = match spec.direction {
784 Direction::Outgoing => {
785 access::adj_out(snap, lmpg.tenant, root, spec.rel_type.as_deref())?
786 }
787 Direction::Incoming => {
788 access::adj_in(snap, lmpg.tenant, root, spec.rel_type.as_deref())?
789 }
790 Direction::Both => {
791 let mut all = access::adj_out(snap, lmpg.tenant, root, spec.rel_type.as_deref())?;
792 all.extend(access::adj_in(
793 snap,
794 lmpg.tenant,
795 root,
796 spec.rel_type.as_deref(),
797 )?);
798 all
799 }
800 };
801 edges.into_iter().map(|(_, other, _)| other).collect()
802 } else {
803 traverse_alias(
804 lmpg,
805 snap,
806 lmpg.tenant,
807 root,
808 &spec.steps,
809 budget,
810 is_cancelled,
811 )?
812 })
813}
814
815fn order_collected_items(items: &mut Vec<RelatedItem>, spec: &CollectSpec) {
816 if spec.unique {
818 items.sort_by_key(|(o, _, _)| *o);
819 items.dedup_by(|a, b| a.1.get(ID_FIELD) == b.1.get(ID_FIELD));
820 } else {
821 items.sort_by_key(|(o, _, _)| *o);
822 }
823 if !spec.order_by.is_empty() {
825 items.sort_by(|a, b| {
826 for (field, desc) in &spec.order_by {
827 let av = raw_field(&a.2, field).or_else(|| raw_field(&a.1, field));
828 let bv = raw_field(&b.2, field).or_else(|| raw_field(&b.1, field));
829 let ord = scalar_cmp(av, bv).unwrap_or(std::cmp::Ordering::Equal);
830 if ord != std::cmp::Ordering::Equal {
831 return if *desc { ord.reverse() } else { ord };
832 }
833 }
834 std::cmp::Ordering::Equal
835 });
836 }
837}
838
839fn attach_collects_with_budget(
840 lmpg: &Lmpg,
841 snap: &QuerySnapshot,
842 root: u64,
843 mut record: Json,
844 collects: &[CollectSpec],
845 budget: Option<Cap>,
846 is_cancelled: &dyn Fn() -> bool,
847) -> Result<Json, QueryError> {
848 if collects.is_empty() {
849 return Ok(record);
850 }
851 let mut retained = aggregate_budget::value_bytes(&record);
852 Cap::check_opt(budget, retained)?;
853 let ctx = EvalCtx::at(lmpg, snap.clone(), None);
854 if let Some(obj) = record.as_object_mut() {
855 for spec in collects {
856 if is_cancelled() {
860 return Err(QueryError::Cancelled);
861 }
862 let neighbors = collect_neighbors(
863 lmpg,
864 snap,
865 root,
866 spec,
867 budget.map(|cap| cap.after(retained).remaining()),
868 is_cancelled,
869 )?;
870 let base_bytes = retained;
872 retained = retained.saturating_add(neighbors.capacity().saturating_mul(8));
873 Cap::check_opt(budget, retained)?;
874 let selected = point_fields::collect_selection(spec);
877 let mut items: Vec<RelatedItem> = Vec::new();
878 for other in neighbors {
879 if is_cancelled() {
880 return Err(QueryError::Cancelled);
881 }
882 if !has_target_label(snap, lmpg.tenant, other, &spec.target_labels)? {
883 continue;
884 }
885 let Some(child) = aggregate_budget::collected_child(
886 snap,
887 lmpg.tenant,
888 other,
889 selected.as_ref(),
890 budget.map(|cap| cap.after(retained).remaining()),
891 )?
892 else {
893 continue;
894 };
895 if let Some(filter) = &spec.filter {
897 if !eval_expr_ctx(&ctx, filter, &child, other)? {
898 continue;
899 }
900 }
901 retained = retained
902 .saturating_add(aggregate_budget::object_bytes(&child).saturating_mul(3));
903 Cap::check_opt(budget, retained)?;
904 let projected = match project_fields_map(Json::Object(child.clone()), &spec.fields)
905 {
906 Json::Object(p) => p,
907 _ => child.clone(),
908 };
909 items.push((other, child, projected));
910 }
911 order_collected_items(&mut items, spec);
912 let start = usize::try_from(spec.skip)
914 .unwrap_or(usize::MAX)
915 .min(items.len());
916 let end = spec.limit.map_or(items.len(), |l| {
917 start
918 .saturating_add(usize::try_from(l).unwrap_or(usize::MAX))
919 .min(items.len())
920 });
921 let mut projected = Vec::new();
922 for (other, raw, child) in items.into_iter().take(end).skip(start) {
923 retained =
924 retained.saturating_sub(aggregate_budget::object_bytes(&raw).saturating_mul(3));
925 let child = attach_collects_with_budget(
926 lmpg,
927 snap,
928 other,
929 Json::Object(child),
930 &spec.nested,
931 budget.map(|cap| cap.after(retained)),
932 is_cancelled,
933 )?;
934 retained = retained.saturating_add(aggregate_budget::value_bytes(&child));
935 Cap::check_opt(budget, retained)?;
936 projected.push(child);
937 }
938 let collected = Json::Array(projected);
939 retained = base_bytes
940 .saturating_add(spec.alias.len())
941 .saturating_add(aggregate_budget::value_bytes(&collected));
942 Cap::check_opt(budget, retained)?;
943 obj.insert(spec.alias.clone(), collected);
944 }
945 }
946 Ok(record)
947}
948
949fn has_target_label(
954 snap: &QuerySnapshot,
955 tenant: rushdb_types::ids::TenantId,
956 ordinal: u64,
957 target_labels: &[String],
958) -> Result<bool, QueryError> {
959 if target_labels.is_empty() {
960 return Ok(true);
961 }
962 Ok(access::record_by_ord(snap, tenant, ordinal)?
963 .is_some_and(|header| target_labels.contains(&header.label)))
964}
965
966fn project_fields_map(value: Json, fields: &[(String, String)]) -> Json {
967 if fields.is_empty() {
968 return value;
969 }
970 let mut out = Map::new();
971 if let Json::Object(obj) = value {
972 for (out_key, source) in fields {
973 if let Some(v) = resolve_field(&obj, source) {
974 out.insert(out_key.clone(), v);
975 }
976 }
977 }
978 Json::Object(out)
979}
980
981fn scalar_cmp(a: Option<&Json>, b: Option<&Json>) -> Option<std::cmp::Ordering> {
984 let (Some(a), Some(b)) = (a, b) else {
985 return None;
986 };
987 match (a, b) {
988 (Json::Number(_), Json::Number(_)) => {
989 let left = infer_scalar(a).map(|(_, scalar)| scalar);
990 let right = infer_scalar(b).map(|(_, scalar)| scalar);
991 left.as_ref()
992 .zip(right.as_ref())
993 .and_then(|(left, right)| canonical_scalar_cmp(left, right))
994 }
995 (Json::String(x), Json::String(y)) => Some(x.cmp(y)),
996 _ => None,
997 }
998}
999
1000fn related_exists(
1001 snap: &QuerySnapshot,
1002 tenant: rushdb_types::ids::TenantId,
1003 ord: u64,
1004 spec: &RelatedSpec,
1005) -> Result<bool, QueryError> {
1006 let edges: Vec<(String, u64, u64)> = match spec.direction {
1007 Direction::Outgoing => access::adj_out(snap, tenant, ord, spec.rel_type.as_deref())?,
1008 Direction::Incoming => access::adj_in(snap, tenant, ord, spec.rel_type.as_deref())?,
1009 Direction::Both => {
1010 let mut all = access::adj_out(snap, tenant, ord, spec.rel_type.as_deref())?;
1011 all.extend(access::adj_in(snap, tenant, ord, spec.rel_type.as_deref())?);
1012 all
1013 }
1014 };
1015 if spec.target_labels.is_empty() {
1016 return Ok(!edges.is_empty());
1017 }
1018 for (_, other_ord, _) in edges {
1019 if access::record_by_ord(snap, tenant, other_ord)?
1020 .is_some_and(|h| spec.target_labels.contains(&h.label))
1021 {
1022 return Ok(true);
1023 }
1024 }
1025 Ok(false)
1026}
1027
1028fn count_related(
1034 lmpg: &Lmpg,
1035 snap: &QuerySnapshot,
1036 tenant: rushdb_types::ids::TenantId,
1037 ord: u64,
1038 specs: &[RelatedSpec],
1039 alias: &str,
1040 budget: Option<usize>,
1041) -> Result<Option<Vec<u64>>, QueryError> {
1042 let Some(spec) = specs.iter().find(|s| {
1043 s.alias
1044 .as_deref()
1045 .is_some_and(|a| a.trim_start_matches('$') == alias)
1046 }) else {
1047 return Ok(None);
1048 };
1049 Ok(Some(traverse_alias(
1050 lmpg,
1051 snap,
1052 tenant,
1053 ord,
1054 &spec.steps,
1055 budget,
1056 &|| false,
1057 )?))
1058}
1059
1060fn traverse_alias(
1064 lmpg: &Lmpg,
1065 snap: &QuerySnapshot,
1066 tenant: rushdb_types::ids::TenantId,
1067 ord: u64,
1068 steps: &[TraversalStep],
1069 budget: Option<usize>,
1070 is_cancelled: &dyn Fn() -> bool,
1071) -> Result<Vec<u64>, QueryError> {
1072 if steps.is_empty() {
1073 return Ok(Vec::new());
1074 }
1075 let ctx = EvalCtx::at(lmpg, snap.clone(), Some(is_cancelled));
1076 let mut cur = vec![ord];
1077 for step in steps {
1078 if is_cancelled() {
1079 return Err(QueryError::Cancelled);
1080 }
1081 let mut next: Vec<u64> = Vec::new();
1082 for o in &cur {
1083 if is_cancelled() {
1084 return Err(QueryError::Cancelled);
1085 }
1086 let edges: Vec<(String, u64, u64)> = match step.direction {
1087 Direction::Outgoing => access::adj_out(snap, tenant, *o, step.rel_type.as_deref())?,
1088 Direction::Incoming => access::adj_in(snap, tenant, *o, step.rel_type.as_deref())?,
1089 Direction::Both => {
1090 let mut all = access::adj_out(snap, tenant, *o, step.rel_type.as_deref())?;
1091 all.extend(access::adj_in(snap, tenant, *o, step.rel_type.as_deref())?);
1092 all
1093 }
1094 };
1095 for (_, other, _) in edges {
1096 if !step.target_labels.is_empty()
1097 && !access::record_by_ord(snap, tenant, other)?
1098 .is_some_and(|h| step.target_labels.contains(&h.label))
1099 {
1100 continue;
1101 }
1102 if let Some(pred) = &step.filter {
1103 let reader = aggregate_budget::RecordReader::new(
1104 snap,
1105 budget.map(|limit| {
1106 limit.saturating_sub(
1107 cur.len().saturating_add(next.len()).saturating_mul(32),
1108 )
1109 }),
1110 );
1111 let obj = filtered_row(&reader, snap, tenant, pred, other)?;
1112 if !eval_expr_ctx(&ctx, pred, &obj, other)? {
1113 continue;
1114 }
1115 }
1116 Budget::WorkingSet.check(
1117 budget,
1118 cur.len()
1119 .saturating_add(next.len())
1120 .saturating_add(1)
1121 .saturating_mul(32),
1122 )?;
1123 next.push(other);
1124 }
1125 }
1126 next.sort_unstable();
1127 next.dedup();
1128 cur = next;
1129 }
1130 Ok(cur)
1131}
1132
1133fn related_specs_from_where(expr: Option<&Expr>) -> Vec<RelatedSpec> {
1136 expr.map(document::alias_paths)
1137 .unwrap_or_default()
1138 .into_iter()
1139 .filter_map(|(alias, steps)| {
1140 let last = steps.last()?.clone();
1141 Some(RelatedSpec {
1142 alias: Some(alias),
1143 rel_type: last.rel_type,
1144 direction: last.direction,
1145 target_labels: last.target_labels,
1146 steps,
1147 })
1148 })
1149 .collect()
1150}
1151
1152fn alias_field_values(
1156 lmpg: &Lmpg,
1157 snap: &QuerySnapshot,
1158 tenant: rushdb_types::ids::TenantId,
1159 ord: u64,
1160 specs: &[RelatedSpec],
1161 field: &str,
1162 budget: Option<usize>,
1163) -> Result<Option<Vec<Json>>, QueryError> {
1164 let Some((alias, prop)) = field.split_once('.') else {
1165 return Ok(None);
1166 };
1167 let Some(related) = count_related(lmpg, snap, tenant, ord, specs, alias, budget)? else {
1168 return Ok(None);
1169 };
1170 let mut retained = related.capacity().saturating_mul(8);
1171 Budget::WorkingSet.check(budget, retained)?;
1172 let mut values = Vec::new();
1173 let selected: std::collections::HashSet<String> =
1175 [prop.to_owned(), normalize(prop)].into_iter().collect();
1176 for other in related {
1177 let reader = aggregate_budget::RecordReader::new(
1178 snap,
1179 budget.map(|limit| limit.saturating_sub(retained)),
1180 );
1181 let mut session = reconstruct::ReconstructSession::new(&reader, tenant);
1182 if let Some(Json::Object(child)) = session.reconstruct_record_selected(other, &selected)? {
1183 if let Some(v) = resolve_field(&child, prop) {
1184 retained = retained.saturating_add(aggregate_budget::value_bytes(&v));
1185 Budget::WorkingSet.check(budget, retained)?;
1186 values.push(v);
1187 }
1188 }
1189 }
1190 Ok(Some(values))
1191}
1192
1193pub const MISSING_SENTINEL: &str = "__missing__";
1196
1197#[derive(Debug, Clone, Default, serde::Serialize)]
1200pub struct GroupBucket {
1201 pub keys: Map<String, Json>,
1204 pub count_roots: u64,
1206 pub sums: Map<String, Json>,
1208 pub counts: Map<String, Json>,
1210 pub mins: Map<String, Json>,
1212 pub maxs: Map<String, Json>,
1214 pub derived_outputs: std::collections::BTreeMap<String, Json>,
1216 #[serde(skip)]
1219 pub root: Option<u64>,
1220}
1221
1222#[derive(Debug, Clone)]
1225pub enum OutputExpr {
1226 Ref(String),
1228 Field(String),
1230 Add(Box<OutputExpr>, Box<OutputExpr>),
1232 Subtract(Box<OutputExpr>, Box<OutputExpr>),
1234 Multiply(Box<OutputExpr>, Box<OutputExpr>),
1236 Divide {
1238 dividend: Box<OutputExpr>,
1240 divisor: Box<OutputExpr>,
1242 precision: u32,
1244 },
1245 Literal(f64),
1247}
1248
1249#[derive(Debug, Clone, Default)]
1251pub struct AggregateRequest {
1252 pub labels: Vec<String>,
1254 pub where_: Option<Expr>,
1256 pub related: Vec<RelatedSpec>,
1258 pub group_by: Vec<String>,
1260 pub sums: Vec<(String, String)>,
1262 pub counts: Vec<(String, String)>,
1265 pub mins: Vec<(String, String)>,
1267 pub maxs: Vec<(String, String)>,
1269 pub time_bucket: Option<TimeBucketSpec>,
1271 pub outputs: Vec<(String, OutputExpr)>,
1273 pub expr_sums: Vec<(String, OutputExpr)>,
1275 pub expr_avgs: Vec<(String, OutputExpr)>,
1277 pub avg_terms: Vec<(String, String)>,
1279 pub avg_precision: std::collections::BTreeMap<String, u32>,
1281 pub time_bucket_alias: Option<String>,
1283 pub plain_fields: Vec<(String, String)>,
1285 pub collects: Vec<CollectSpec>,
1288 pub order_by: Vec<(String, bool )>,
1290 pub skip: u64,
1292 pub limit: Option<u64>,
1294 pub hidden_outputs: std::collections::BTreeSet<String>,
1296}
1297
1298pub struct EvalCtx<'a> {
1301 pub lmpg: &'a Lmpg,
1303 pub snap: QuerySnapshot,
1305 pub is_cancelled: Option<&'a dyn Fn() -> bool>,
1307 pub(crate) cycles: cycle::CycleGraphs,
1309 pub(crate) declared_types:
1313 std::cell::RefCell<std::collections::HashMap<String, Option<LogicalType>>>,
1314 pub(crate) record_types:
1318 std::cell::RefCell<std::collections::HashMap<String, Vec<(u64, LogicalType)>>>,
1319 adjacencies: std::cell::RefCell<AdjacencyByShape>,
1323}
1324
1325type AdjacencyByShape =
1326 std::collections::HashMap<(Option<String>, Direction), std::rc::Rc<adjacency::TypeAdjacency>>;
1327
1328impl<'a> EvalCtx<'a> {
1329 pub(crate) fn at(
1331 lmpg: &'a Lmpg,
1332 snap: QuerySnapshot,
1333 is_cancelled: Option<&'a dyn Fn() -> bool>,
1334 ) -> Self {
1335 EvalCtx {
1336 lmpg,
1337 snap,
1338 is_cancelled,
1339 cycles: cycle::CycleGraphs::default(),
1340 declared_types: std::cell::RefCell::default(),
1341 record_types: std::cell::RefCell::default(),
1342 adjacencies: std::cell::RefCell::default(),
1343 }
1344 }
1345
1346 fn adjacency(
1348 &self,
1349 rel_type: Option<&str>,
1350 direction: Direction,
1351 ) -> Result<std::rc::Rc<adjacency::TypeAdjacency>, QueryError> {
1352 let key = (rel_type.map(str::to_owned), direction);
1353 if let Some(open) = self.adjacencies.borrow().get(&key) {
1354 return Ok(std::rc::Rc::clone(open));
1355 }
1356 let cancel = || self.check_cancelled();
1357 let open = std::rc::Rc::new(adjacency::TypeAdjacency::open(
1358 &self.snap,
1359 self.lmpg.tenant,
1360 rel_type,
1361 direction,
1362 &cancel,
1363 )?);
1364 self.adjacencies
1365 .borrow_mut()
1366 .insert(key, std::rc::Rc::clone(&open));
1367 Ok(open)
1368 }
1369
1370 pub fn new(lmpg: &'a Lmpg) -> Result<Self, QueryError> {
1377 Ok(EvalCtx::at(lmpg, Arc::new(lmpg.store.read_view()?), None))
1378 }
1379
1380 pub fn check_cancelled(&self) -> Result<(), QueryError> {
1386 if self.is_cancelled.is_some_and(|probe| probe()) {
1387 Err(QueryError::Cancelled)
1388 } else {
1389 Ok(())
1390 }
1391 }
1392}
1393
1394fn eval_expr_ctx(
1397 ctx: &EvalCtx<'_>,
1398 expr: &Expr,
1399 obj: &Map<String, Json>,
1400 ord: u64,
1401) -> Result<bool, QueryError> {
1402 match expr {
1403 Expr::And(items) => {
1404 for item in items {
1405 if !eval_expr_ctx(ctx, item, obj, ord)? {
1406 return Ok(false);
1407 }
1408 }
1409 Ok(true)
1410 }
1411 Expr::Or(items) | Expr::Nor(items) => {
1412 let inverted = matches!(expr, Expr::Nor(_));
1413 for item in items {
1414 if eval_expr_ctx(ctx, item, obj, ord)? {
1415 return Ok(!inverted);
1416 }
1417 }
1418 Ok(inverted)
1419 }
1420 Expr::Xor(a, b) => Ok(eval_expr_ctx(ctx, a, obj, ord)? != eval_expr_ctx(ctx, b, obj, ord)?),
1421 Expr::Not(inner) => Ok(!eval_expr_ctx(ctx, inner, obj, ord)?),
1422 Expr::Field(pred) => eval_pred(&|name| type_held(ctx, name, ord), pred, obj),
1423 Expr::Related(rel) => expand::related_holds(ctx, ord, rel),
1424 }
1425}
1426
1427pub(crate) type DeclaredType<'a> =
1429 dyn Fn(&str) -> Result<Option<rushdb_types::model::LogicalType>, QueryError> + 'a;
1430
1431#[expect(
1432 clippy::too_many_lines,
1433 reason = "one exhaustive match over every field predicate keeps the operator semantics readable in a single place"
1434)]
1435pub(crate) fn eval_pred(
1436 declared_type: &DeclaredType<'_>,
1437 pred: &FieldPred,
1438 obj: &Map<String, Json>,
1439) -> Result<bool, QueryError> {
1440 Ok(match pred {
1441 FieldPred::Exists(name, want) => {
1442 let present = raw_field(obj, name).is_some();
1443 present == *want
1444 }
1445 FieldPred::IsNull(name, want) => {
1446 let v = raw_field(obj, name);
1447 let is_null = matches!(v, Some(Json::Null));
1448 is_null == *want
1449 }
1450 FieldPred::Eq(name, val) => match (raw_field(obj, name), val) {
1451 (Some(_), Json::Null) => matches!(raw_field(obj, name), Some(Json::Null)),
1452 (Some(actual), expected) => {
1453 let datetime = is_datetime(declared_type, name)?;
1454 any_element(actual, |el| value_equals(el, expected, datetime))
1455 }
1456 (None, _) => false,
1457 },
1458 FieldPred::Ne(name, val) => {
1459 match raw_field(obj, name) {
1461 None => false,
1462 Some(actual) => {
1463 let datetime = is_datetime(declared_type, name)?;
1464 !any_element(actual, |el| value_equals(el, val, datetime))
1465 }
1466 }
1467 }
1468 FieldPred::In(name, vals) => match raw_field(obj, name) {
1469 None => false,
1470 Some(actual) => {
1471 let datetime = is_datetime(declared_type, name)?;
1472 any_element(actual, |el| {
1473 vals.iter().any(|v| value_equals(el, v, datetime))
1474 })
1475 }
1476 },
1477 FieldPred::Contains(name, sub) => raw_field(obj, name).is_some_and(|j| {
1478 let sub = sub.to_lowercase();
1479 any_element(j, |el| {
1480 el.as_str()
1481 .is_some_and(|st| st.to_lowercase().contains(sub.as_str()))
1482 })
1483 }),
1484 FieldPred::StartsWith(name, prefix) => raw_field(obj, name).is_some_and(|j| {
1485 let prefix = prefix.to_lowercase();
1486 any_element(j, |el| {
1487 el.as_str()
1488 .is_some_and(|st| st.to_lowercase().starts_with(prefix.as_str()))
1489 })
1490 }),
1491 FieldPred::Gt(name, bound) => numeric_cmp_any(raw_field(obj, name), *bound, |o| {
1492 o == std::cmp::Ordering::Greater
1493 }),
1494 FieldPred::Gte(name, bound) => numeric_cmp_any(raw_field(obj, name), *bound, |o| {
1495 o != std::cmp::Ordering::Less
1496 }),
1497 FieldPred::Lt(name, bound) => numeric_cmp_any(raw_field(obj, name), *bound, |o| {
1498 o == std::cmp::Ordering::Less
1499 }),
1500 FieldPred::Lte(name, bound) => numeric_cmp_any(raw_field(obj, name), *bound, |o| {
1501 o != std::cmp::Ordering::Greater
1502 }),
1503 FieldPred::Nin(name, vals) => {
1504 match raw_field(obj, name) {
1506 None => false,
1507 Some(actual) => {
1508 let datetime = is_datetime(declared_type, name)?;
1509 !any_element(actual, |el| {
1510 vals.iter().any(|v| value_equals(el, v, datetime))
1511 })
1512 }
1513 }
1514 }
1515 FieldPred::EndsWith(name, suffix) => raw_field(obj, name).is_some_and(|j| {
1516 let suffix = suffix.to_lowercase();
1517 any_element(j, |el| {
1518 el.as_str()
1519 .is_some_and(|st| st.to_lowercase().ends_with(suffix.as_str()))
1520 })
1521 }),
1522 FieldPred::Id(id) => obj
1523 .get(ID_FIELD)
1524 .and_then(|v| v.as_str())
1525 .is_some_and(|s| canonical_id(s) == canonical_id(id)),
1526 FieldPred::DatetimeComponent {
1527 field,
1528 which,
1529 value,
1530 } => raw_field(obj, field)
1531 .and_then(serde_json::Value::as_i64)
1532 .is_some_and(|ms| {
1533 let actual = crate::datetime::component_of(ms, *which);
1534 actual == *value
1535 }),
1536 FieldPred::Type(name, expected) => {
1537 let want = canonical_type_alias(&expected.to_lowercase());
1538 let (base_want, want_array) = match want.strip_suffix("[]") {
1539 Some(base) if !base.is_empty() => (Some(canonical_type_alias(base)), true),
1540 _ => (None, want == "array"),
1541 };
1542 let value = raw_field(obj, name);
1543 if value.is_none() {
1546 return Ok(want == "null");
1547 }
1548 let is_array = matches!(value, Some(Json::Array(_)));
1549
1550 if want == "array" {
1552 return Ok(is_array);
1553 }
1554
1555 if let Some(ty) = declared_type(name)? {
1559 let name_of = |t: &rushdb_types::model::LogicalType| -> &'static str {
1560 match t {
1561 rushdb_types::model::LogicalType::Boolean => "boolean",
1562 rushdb_types::model::LogicalType::Int64
1563 | rushdb_types::model::LogicalType::Float64 => "number",
1564 rushdb_types::model::LogicalType::Utf8 => "string",
1565 rushdb_types::model::LogicalType::DateTime => "datetime",
1566 }
1567 };
1568 if want_array {
1569 if is_array && name_of(&ty) == base_want.as_deref().unwrap_or("") {
1572 return Ok(true);
1573 }
1574 } else if name_of(&ty) == want {
1575 return Ok(true);
1578 }
1579 }
1580
1581 let shape_of = |v: &Json| -> &'static str {
1582 if v.is_string() {
1583 "string"
1584 } else if v.is_number() {
1585 "number"
1586 } else if v.is_boolean() {
1587 "boolean"
1588 } else if v.is_array() {
1589 "array"
1590 } else if v.is_object() {
1591 "object"
1592 } else {
1593 "null"
1594 }
1595 };
1596
1597 if want_array {
1598 let base = base_want.as_deref().unwrap_or("");
1601 return Ok(
1602 matches!(value, Some(Json::Array(items)) if items.iter().any(|el| shape_of(el) == base)),
1603 );
1604 }
1605
1606 let actual = value.map_or("null", shape_of);
1607 actual == want
1608 }
1609 })
1610}
1611
1612fn numeric_cmp(
1613 value: Option<&Json>,
1614 bound: f64,
1615 accept: impl Fn(std::cmp::Ordering) -> bool,
1616) -> bool {
1617 value
1618 .and_then(infer_scalar)
1619 .and_then(|(_, scalar)| match scalar {
1620 Scalar::Int64(_) | Scalar::Float64(_) => Some(scalar),
1621 _ => None,
1622 })
1623 .and_then(|value| canonical_scalar_cmp(&value, &Scalar::Float64(bound)))
1624 .is_some_and(accept)
1625}
1626
1627fn numeric_cmp_any(
1628 value: Option<&Json>,
1629 bound: f64,
1630 accept: impl Fn(std::cmp::Ordering) -> bool,
1631) -> bool {
1632 match value {
1633 Some(v) if v.is_array() => v
1634 .as_array()
1635 .is_some_and(|a| a.iter().any(|el| numeric_cmp(Some(el), bound, &accept))),
1636 other => numeric_cmp(other, bound, accept),
1637 }
1638}
1639
1640fn canonical_type_alias(t: &str) -> String {
1644 if t == "bool" {
1645 "boolean".to_string()
1646 } else {
1647 t.to_string()
1648 }
1649}
1650
1651fn json_value_cmp(a: &Json, b: &Json) -> std::cmp::Ordering {
1656 let numeric = |v: &Json| {
1657 infer_scalar(v).and_then(|(_, scalar)| match scalar {
1658 Scalar::Int64(_) | Scalar::Float64(_) => Some(scalar),
1659 _ => None,
1660 })
1661 };
1662 match (numeric(a), numeric(b)) {
1663 (Some(x), Some(y)) => canonical_scalar_cmp(&x, &y).unwrap_or(std::cmp::Ordering::Equal),
1664 (None, Some(_)) => std::cmp::Ordering::Less,
1666 (Some(_), None) => std::cmp::Ordering::Greater,
1667 (None, None) => match (a.as_str(), b.as_str()) {
1668 (Some(x), Some(y)) => x.cmp(y),
1669 _ => format!("{a}").cmp(&format!("{b}")),
1670 },
1671 }
1672}
1673
1674fn raw_field<'a>(obj: &'a Map<String, Json>, name: &str) -> Option<&'a Json> {
1675 let norm = normalize(name);
1676 obj.iter()
1677 .find(|(k, _)| normalize(k) == norm)
1678 .map(|(_, v)| v)
1679}
1680
1681fn canonical_id(id: &str) -> String {
1682 id.bytes()
1683 .filter(|byte| *byte != b'-')
1684 .map(|byte| (byte as char).to_ascii_lowercase())
1685 .collect()
1686}
1687
1688#[must_use]
1690pub fn normalize(name: &str) -> String {
1691 rushdb_types::value::normalize_name(name)
1692}
1693
1694fn resolve_field(obj: &Map<String, Json>, field: &str) -> Option<Json> {
1695 let field = field.strip_prefix("$record.").unwrap_or(field);
1696 if let Some(value) = obj.get(field) {
1700 return Some(value.clone());
1701 }
1702 let norm = normalize(field);
1703 obj.iter()
1704 .find(|(k, _)| normalize(k) == norm)
1705 .map(|(_, v)| v.clone())
1706}
1707
1708#[cfg(test)]
1709mod index_planner_tests;
1710#[cfg(test)]
1711mod order_tests;