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 aggregate_budget;
23mod aggregation;
24mod cycle;
25mod equality;
26mod field_types;
27pub use aggregation::MAX_AGGREGATE_BUCKETS;
28pub use aggregation::{
29 aggregate, aggregate_in_view, aggregate_to_rows, aggregate_to_rows_in_view,
30 aggregate_to_rows_in_view_with_budget, aggregate_with_cancel, aggregate_with_cancel_in_view,
31 aggregate_with_cancel_in_view_with_budget,
32};
33#[cfg(test)]
34use aggregation::{eval_row_expr, exact_f64, minmax};
35use field_types::type_held;
36mod page;
37pub use page::effective_budget;
38mod planned;
39mod planner;
40mod point_fields;
41pub use point_fields::PointFields;
42mod property_predicate;
43pub use hex::hex_decode_16;
44pub use index_access::explain_index;
45pub use planned::{planned_candidates, PlannedCandidates};
46use planner::index_candidates_with_budget;
47pub use planner::{
48 explain_cardinality, explain_range_selectivity, explain_walk, unindexed_walks_total,
49 UNINDEXED_WALK_THRESHOLD,
50};
51pub use property_predicate::{matches_properties, parse_property_predicate};
52pub mod datetime;
53pub mod document;
54mod hex;
55mod index_access;
56mod scan;
57mod stats_cache;
58pub use document::{ParsedQuery, QueryDocumentError};
59
60#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
62pub enum FieldPred {
63 Eq(String, Json),
65 Ne(String, Json),
67 Gt(String, f64),
69 Gte(String, f64),
71 Lt(String, f64),
73 Lte(String, f64),
75 Contains(String, String),
77 StartsWith(String, String),
79 Exists(String, bool),
81 IsNull(String, bool),
83 In(String, Vec<Json>),
85 Nin(String, Vec<Json>),
87 EndsWith(String, String),
89 Id(String),
91 DatetimeComponent {
94 field: String,
96 which: u8,
98 value: i64,
100 },
101 Type(String, String),
103}
104
105#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
107pub enum Expr {
108 And(Vec<Expr>),
110 Or(Vec<Expr>),
112 Xor(Box<Expr>, Box<Expr>),
114 Nor(Vec<Expr>),
116 Not(Box<Expr>),
118 Field(FieldPred),
120 Related(RelatedExpr),
123}
124
125#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
127pub struct RelatedExpr {
128 pub target_labels: Vec<String>,
130 pub alias: Option<String>,
132 pub rel_type: Option<String>,
134 pub direction: Direction,
136 pub hops: Option<(u64, u64)>,
138 pub cycle: bool,
140 pub filter: Option<Box<Expr>>,
143}
144
145#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
147pub struct RelatedSpec {
148 pub alias: Option<String>,
150 pub rel_type: Option<String>,
152 pub direction: Direction,
154 pub target_labels: Vec<String>,
156 pub steps: Vec<TraversalStep>,
162}
163
164#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
166pub struct TraversalStep {
167 pub target_labels: Vec<String>,
169 pub rel_type: Option<String>,
171 pub direction: Direction,
173 pub filter: Option<Expr>,
176}
177
178#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
189pub struct CollectSpec {
190 pub alias: String,
192 pub rel_type: Option<String>,
194 pub direction: Direction,
196 pub target_labels: Vec<String>,
198 pub steps: Vec<TraversalStep>,
202 pub fields: Vec<(String, String)>,
204 pub nested: Vec<CollectSpec>,
206 pub filter: Option<Expr>,
208 pub order_by: Vec<(String, bool)>,
210 pub unique: bool,
212 pub skip: u64,
214 pub limit: Option<u64>,
216}
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
220pub enum Direction {
221 Outgoing,
223 Incoming,
225 Both,
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
231pub enum TimeUnit {
232 Seconds,
234 Minutes,
236 Hours,
238 Days,
240 Weeks,
242 Months,
244 Quarters,
246 Years,
248}
249
250impl TimeUnit {
251 fn millis(self) -> i64 {
252 match self {
253 TimeUnit::Seconds => 1_000,
254 TimeUnit::Minutes => 60_000,
255 TimeUnit::Hours => 3_600_000,
256 TimeUnit::Days => 86_400_000,
257 TimeUnit::Weeks => 7 * 86_400_000,
258 TimeUnit::Months => 30 * 86_400_000,
259 TimeUnit::Quarters => 90 * 86_400_000,
260 TimeUnit::Years => 365 * 86_400_000,
261 }
262 }
263
264 pub fn from_name(name: &str) -> Result<TimeUnit, crate::QueryDocumentError> {
271 Ok(match name {
272 "second" | "seconds" => TimeUnit::Seconds,
273 "minute" | "minutes" => TimeUnit::Minutes,
274 "hour" | "hours" => TimeUnit::Hours,
275 "day" | "days" => TimeUnit::Days,
276 "week" | "weeks" => TimeUnit::Weeks,
277 "month" | "months" => TimeUnit::Months,
278 "quarter" => TimeUnit::Quarters,
279 "year" | "years" => TimeUnit::Years,
280 other => {
281 return Err(crate::QueryDocumentError::Parse(format!(
282 "unknown time unit `{other}`"
283 )));
284 }
285 })
286 }
287
288 pub(crate) fn unit_str(self) -> &'static str {
289 match self {
290 TimeUnit::Seconds => "seconds",
291 TimeUnit::Minutes => "minutes",
292 TimeUnit::Hours => "hours",
293 TimeUnit::Days => "days",
294 TimeUnit::Weeks => "weeks",
295 TimeUnit::Months => "months",
296 TimeUnit::Quarters => "quarters",
297 TimeUnit::Years => "years",
298 }
299 }
300}
301
302#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
305pub struct TimeBucketSpec {
306 pub field: String,
308 pub unit: TimeUnit,
310 pub size: u64,
312}
313
314pub const MAX_AVG_PRECISION: u32 = 15;
318
319impl TimeBucketSpec {
320 pub fn fixed(field: impl Into<String>, unit: TimeUnit) -> Self {
322 TimeBucketSpec {
323 field: field.into(),
324 unit,
325 size: 1,
326 }
327 }
328
329 pub fn sized(field: impl Into<String>, unit: TimeUnit, size: u64) -> Self {
335 assert!(size > 0, "timeBucket size must be positive");
336 TimeBucketSpec {
337 field: field.into(),
338 unit,
339 size,
340 }
341 }
342
343 #[expect(
344 clippy::cast_possible_truncation,
345 reason = "epoch-ms is a bounded timestamp; the saturating float-to-int cast matches the reference calendar flooring"
346 )]
347 fn floor_millis(&self, epoch_ms: f64) -> i64 {
348 let ms = epoch_ms as i64;
349 match self.unit {
353 TimeUnit::Months => rushdb_types::datetime::start_of_month(ms),
354 TimeUnit::Quarters => rushdb_types::datetime::start_of_quarter(ms),
355 TimeUnit::Years => rushdb_types::datetime::start_of_year(ms),
356 _ => {
357 let size = i64::try_from(self.size).unwrap_or(i64::MAX).max(1);
360 let width = self.unit.millis().saturating_mul(size).max(1);
361 ms.div_euclid(width).saturating_mul(width)
362 }
363 }
364 }
365}
366
367#[derive(Debug, Clone, Copy, Default, PartialEq, serde::Serialize, serde::Deserialize)]
369pub struct Pagination {
370 pub skip: u64,
372 pub limit: Option<u64>,
374}
375
376#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
378pub struct SearchRequest {
379 #[serde(default)]
381 pub labels: Vec<String>,
382 #[serde(default, rename = "where")]
384 pub where_: Option<Expr>,
385 #[serde(default)]
388 pub related: Vec<RelatedSpec>,
389 #[serde(default)]
391 pub collects: Vec<CollectSpec>,
392 #[serde(default)]
394 pub pagination: Pagination,
395 #[serde(default)]
397 pub order_by: Vec<(String, bool)>,
398 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
402 pub force_scan: bool,
403}
404
405#[derive(Debug, Error)]
407pub enum QueryError {
408 #[error("storage error: {0}")]
410 Storage(#[from] rushdb_storage::StorageError),
411 #[error("engine error: {0}")]
413 Engine(#[from] LmpgError),
414 #[error("invalid predicate on `{0}`")]
416 InvalidPredicate(String),
417 #[error("query cancelled")]
419 Cancelled,
420 #[error("resource exhausted: {0}")]
422 ResourceLimit(String),
423 #[error("unindexed walk refused: {0}")]
426 UnindexedWalk(String),
427}
428
429#[derive(Debug, Clone)]
431pub struct Page {
432 pub total_matched: u64,
434 pub records: Vec<Json>,
436}
437
438pub fn find_records(lmpg: &Lmpg, req: &SearchRequest) -> Result<Page, QueryError> {
446 find_records_with_cancel(lmpg, req, &|| false)
447}
448
449pub fn find_records_with_cancel(
458 lmpg: &Lmpg,
459 req: &SearchRequest,
460 is_cancelled: &dyn Fn() -> bool,
461) -> Result<Page, QueryError> {
462 let snap = Arc::new(lmpg.store.read_view()?);
463 find_records_in_view(lmpg, req, is_cancelled, snap)
464}
465
466#[expect(
480 clippy::needless_pass_by_value,
481 reason = "callers hand over a freshly pinned view, matching find_records_in_view"
482)]
483pub fn matching_ordinals(
484 lmpg: &Lmpg,
485 req: &SearchRequest,
486 is_cancelled: &dyn Fn() -> bool,
487 snap: QuerySnapshot,
488) -> Result<Vec<u64>, QueryError> {
489 let is_cancelled: &dyn Fn() -> bool = &cancel_probe(lmpg, is_cancelled);
490 if !snap.belongs_to(&lmpg.store) {
491 return Err(rushdb_storage::StorageError::InvalidRead(
492 "query view belongs to another Store".into(),
493 )
494 .into());
495 }
496 if is_cancelled() {
497 return Err(QueryError::Cancelled);
498 }
499 let mut full = req.clone();
500 full.pagination = Pagination::default();
501 Ok(run_matches(lmpg, &full, is_cancelled, &snap, usize::MAX, None)?.retained)
502}
503
504#[expect(
513 clippy::needless_pass_by_value,
514 reason = "callers hand over a freshly pinned view, matching find_records_in_view"
515)]
516pub fn matching_ordinals_first(
517 lmpg: &Lmpg,
518 req: &SearchRequest,
519 is_cancelled: &dyn Fn() -> bool,
520 snap: QuerySnapshot,
521 keep: usize,
522) -> Result<Vec<u64>, QueryError> {
523 let is_cancelled: &dyn Fn() -> bool = &cancel_probe(lmpg, is_cancelled);
524 if !snap.belongs_to(&lmpg.store) {
525 return Err(rushdb_storage::StorageError::InvalidRead(
526 "query view belongs to another Store".into(),
527 )
528 .into());
529 }
530 if is_cancelled() {
531 return Err(QueryError::Cancelled);
532 }
533 let mut full = req.clone();
534 full.pagination = Pagination::default();
535 Ok(run_matches(lmpg, &full, is_cancelled, &snap, keep, None)?.retained)
536}
537
538struct Matches {
540 retained: Vec<u64>,
542 total: u64,
544}
545
546fn run_matches(
558 lmpg: &Lmpg,
559 req: &SearchRequest,
560 is_cancelled: &dyn Fn() -> bool,
561 snap: &QuerySnapshot,
562 keep: usize,
563 known_total: Option<u64>,
564) -> Result<Matches, QueryError> {
565 check_walk_budget(lmpg, snap, req)?;
566 let mut working = WorkingSet {
567 limit: working_set_budget(lmpg).map(|bytes| u64::try_from(bytes).unwrap_or(u64::MAX)),
568 charged: 0,
569 };
570 let CandidateSource {
571 mut candidates,
572 exact,
573 known,
574 predicate,
575 } = candidate_source(lmpg, req, is_cancelled, snap, known_total, &mut working)?;
576 let ctx = EvalCtx {
577 lmpg,
578 snap: snap.clone(),
579 is_cancelled: Some(is_cancelled),
580 cycles: crate::cycle::CycleGraphs::default(),
581 declared_types: std::cell::RefCell::default(),
582 record_types: std::cell::RefCell::default(),
583 };
584 let mut session = reconstruct::ReconstructSession::new(snap, lmpg.tenant);
585 let order_fields = if req.order_by.is_empty() {
586 None
587 } else {
588 PointFields::resolve(
589 snap,
590 lmpg.tenant,
591 req.order_by.iter().map(|(f, _)| f.as_str()),
592 )?
593 };
594 let predicate_fields = match &predicate {
595 Some(expr) => PointFields::for_predicate(snap, lmpg.tenant, expr)?,
596 None => None,
597 };
598 let mut retained: Vec<u64> = Vec::new();
599 let mut ordered: Vec<OrderRow> = Vec::new();
602 let mut ordered_bytes: Vec<u64> = Vec::new();
603 let mut total: u64 = 0;
604 while let Some(ord) = candidates.next(is_cancelled)? {
605 if is_cancelled() {
606 return Err(QueryError::Cancelled);
607 }
608 if !exact
609 && !is_match(
610 &ctx,
611 req,
612 predicate.as_ref(),
613 predicate_fields.as_ref(),
614 &mut session,
615 &mut working,
616 ord,
617 )?
618 {
619 continue;
620 }
621 total += 1;
622 if req.order_by.is_empty() {
623 if retained.len() < keep {
624 working.charge(8, true)?;
625 retained.push(ord);
626 } else if known.is_some() {
627 break;
628 }
629 continue;
630 }
631 let row = read_row(order_fields.as_ref(), &mut session, snap, lmpg.tenant, ord)?;
632 let bytes =
633 serde_json::to_vec(&row).map_or(0, |v| u64::try_from(v.len()).unwrap_or(u64::MAX));
634 working.charge(bytes, true)?;
635 ordered.push((ord, row));
636 ordered_bytes.push(bytes);
637 if ordered.len() >= keep.saturating_mul(2).max(64) {
638 working.charged = working.charged.saturating_sub(cut_ordered(
639 &mut ordered,
640 &mut ordered_bytes,
641 req,
642 keep,
643 ));
644 }
645 }
646 if !req.order_by.is_empty() {
647 cut_ordered(&mut ordered, &mut ordered_bytes, req, keep);
648 retained = ordered.into_iter().map(|(ord, _)| ord).collect();
649 }
650 Ok(Matches {
651 retained,
652 total: known.unwrap_or(total),
653 })
654}
655
656struct WorkingSet {
658 limit: Option<u64>,
659 charged: u64,
660}
661
662impl WorkingSet {
663 fn charge(&mut self, bytes: u64, retained: bool) -> Result<(), QueryError> {
666 if let Some(limit) = self.limit {
667 if self.charged.saturating_add(bytes) > limit {
668 return Err(QueryError::ResourceLimit(format!(
669 "query working set exceeded {limit} bytes"
670 )));
671 }
672 if retained {
673 self.charged += bytes;
674 }
675 }
676 Ok(())
677 }
678}
679
680fn is_match(
683 ctx: &EvalCtx<'_>,
684 req: &SearchRequest,
685 predicate: Option<&Expr>,
686 fields: Option<&PointFields>,
687 session: &mut reconstruct::ReconstructSession<'_, QuerySnapshot>,
688 working: &mut WorkingSet,
689 ord: u64,
690) -> Result<bool, QueryError> {
691 let (snap, tenant) = (&ctx.snap, ctx.lmpg.tenant);
692 if access::record_by_ord(snap, tenant, ord)?.is_none() {
693 return Ok(false);
694 }
695 if let Some(expr) = predicate {
696 let obj = read_row(fields, session, snap, tenant, ord)?;
697 let bytes =
698 serde_json::to_vec(&obj).map_or(0, |v| u64::try_from(v.len()).unwrap_or(u64::MAX));
699 working.charge(bytes, false)?;
700 if !eval_expr_ctx(ctx, expr, &obj)? {
701 return Ok(false);
702 }
703 }
704 for spec in &req.related {
705 if !related_exists(snap, tenant, ord, spec)? {
706 return Ok(false);
707 }
708 }
709 Ok(true)
710}
711
712fn read_row(
714 fields: Option<&PointFields>,
715 session: &mut reconstruct::ReconstructSession<'_, QuerySnapshot>,
716 snap: &QuerySnapshot,
717 tenant: rushdb_types::ids::TenantId,
718 ord: u64,
719) -> Result<Map<String, Json>, QueryError> {
720 match fields {
721 Some(fields) => fields.row(snap, tenant, ord),
722 None => Ok(session
723 .reconstruct_record(ord)?
724 .and_then(|record| match record {
725 Json::Object(map) => Some(map),
726 _ => None,
727 })
728 .unwrap_or_default()),
729 }
730}
731
732struct CandidateSource<'v> {
734 candidates: scan::Candidates<'v>,
735 exact: bool,
738 known: Option<u64>,
740 predicate: Option<Expr>,
743}
744
745fn candidate_source<'v>(
748 lmpg: &Lmpg,
749 req: &SearchRequest,
750 is_cancelled: &dyn Fn() -> bool,
751 snap: &'v QuerySnapshot,
752 known_total: Option<u64>,
753 working: &mut WorkingSet,
754) -> Result<CandidateSource<'v>, QueryError> {
755 if let Some(plan) =
756 index_candidates_with_budget(lmpg, snap, req, working_set_budget(lmpg), is_cancelled)?
757 {
758 working.charge(
759 u64::try_from(plan.ords.serialized_size()).unwrap_or(u64::MAX),
760 true,
761 )?;
762 let known = known_total
763 .or(plan.known_total)
764 .or_else(|| plan.exact.then(|| plan.ords.len()));
765 return Ok(CandidateSource {
766 candidates: scan::Candidates::from_set(plan.ords),
767 exact: plan.exact,
768 known,
769 predicate: plan.residual,
770 });
771 }
772 let exact = req.where_.is_none() && req.related.is_empty();
773 let known = match known_total {
774 Some(total) => Some(total),
775 None if exact => Some(walk_total(lmpg, snap, &req.labels)?),
776 None => None,
777 };
778 Ok(CandidateSource {
779 candidates: scan::Candidates::walk(snap, lmpg.tenant, &req.labels)?,
780 exact,
781 known,
782 predicate: req.where_.clone(),
783 })
784}
785
786type OrderRow = (u64, Map<String, Json>);
788
789fn cut_ordered(
792 ordered: &mut Vec<OrderRow>,
793 bytes: &mut Vec<u64>,
794 req: &SearchRequest,
795 keep: usize,
796) -> u64 {
797 let mut rows: Vec<(OrderRow, u64)> = ordered.drain(..).zip(bytes.drain(..)).collect();
798 sort_ordered_rows(&mut rows, req);
802 let freed = rows.iter().skip(keep).map(|(_, b)| *b).sum();
803 rows.truncate(keep);
804 for (row, b) in rows {
805 ordered.push(row);
806 bytes.push(b);
807 }
808 freed
809}
810
811fn walk_total(lmpg: &Lmpg, snap: &QuerySnapshot, labels: &[String]) -> Result<u64, QueryError> {
813 if labels.is_empty() {
814 return Ok(rushdb_lmpg::derived::tenant_usage(snap.as_ref(), lmpg.tenant)?.records);
815 }
816 let distinct: std::collections::BTreeSet<&String> = labels.iter().collect();
817 let mut total = 0u64;
818 for label in distinct {
819 total = total.saturating_add(rushdb_lmpg::derived::label_count(
820 snap.as_ref(),
821 lmpg.tenant,
822 label,
823 )?);
824 }
825 Ok(total)
826}
827
828pub fn find_records_in_view(
838 lmpg: &Lmpg,
839 req: &SearchRequest,
840 is_cancelled: &dyn Fn() -> bool,
841 snap: QuerySnapshot,
842) -> Result<Page, QueryError> {
843 find_records_in_view_with_budget(lmpg, req, is_cancelled, snap, None)
844}
845
846fn check_walk_budget(
852 lmpg: &Lmpg,
853 snap: &QuerySnapshot,
854 req: &SearchRequest,
855) -> Result<(), QueryError> {
856 let (Some(limit), Some(expr)) = (lmpg.limits().unindexed_walk_max_owners(), &req.where_) else {
857 return Ok(());
858 };
859 if explain_index(snap.as_ref(), lmpg.tenant, &req.labels, expr)?.is_some() {
860 return Ok(());
861 }
862 let walk = explain_walk(snap.as_ref(), lmpg.tenant, &req.labels, expr)?;
863 let (property, owners, labels) = if let Some(walk) = walk {
864 walk
865 } else {
866 let owners = if req.labels.is_empty() {
868 rushdb_lmpg::derived::tenant_usage(snap.as_ref(), lmpg.tenant)?.records
869 } else {
870 let mut owners = 0u64;
871 for label in &req.labels {
872 owners = owners.saturating_add(rushdb_lmpg::derived::label_count(
873 snap.as_ref(),
874 lmpg.tenant,
875 label,
876 )?);
877 }
878 owners
879 };
880 (
881 "(no indexable condition)".to_owned(),
882 owners,
883 req.labels.clone(),
884 )
885 };
886 if owners <= limit {
887 return Ok(());
888 }
889 Err(QueryError::UnindexedWalk(format!(
890 "the filter on `{property}` would check {owners} records of [{}], over \
891 the unindexed-walk limit ({limit}); declare an ordered index on the property",
892 labels.join(", ")
893 )))
894}
895
896fn working_set_budget(lmpg: &Lmpg) -> Option<usize> {
903 lmpg.limits()
904 .query_memory_max_bytes()
905 .map(|bytes| usize::try_from(bytes).unwrap_or(usize::MAX))
906}
907
908pub(crate) fn cancel_probe<'a>(
911 lmpg: &Lmpg,
912 is_cancelled: &'a dyn Fn() -> bool,
913) -> impl Fn() -> bool + 'a {
914 let limits = lmpg.limits();
915 move || is_cancelled() || limits.expired()
916}
917
918#[allow(clippy::needless_pass_by_value)]
926pub fn find_records_in_view_with_budget(
927 lmpg: &Lmpg,
928 req: &SearchRequest,
929 is_cancelled: &dyn Fn() -> bool,
930 snap: QuerySnapshot,
931 work_budget: Option<usize>,
932) -> Result<Page, QueryError> {
933 find_page(lmpg, req, is_cancelled, &snap, work_budget, None)
934}
935
936pub fn find_records_streaming(
944 lmpg: &Lmpg,
945 req: &SearchRequest,
946 is_cancelled: &dyn Fn() -> bool,
947 snap: QuerySnapshot,
948) -> Result<Page, QueryError> {
949 find_records_streaming_with_total(lmpg, req, is_cancelled, snap, None)
950}
951
952#[expect(
963 clippy::needless_pass_by_value,
964 reason = "the snapshot is accepted by value as part of the public API contract so callers can hand over a freshly pinned view"
965)]
966pub fn find_records_streaming_with_total(
967 lmpg: &Lmpg,
968 req: &SearchRequest,
969 is_cancelled: &dyn Fn() -> bool,
970 snap: QuerySnapshot,
971 known_total: Option<u64>,
972) -> Result<Page, QueryError> {
973 find_page(lmpg, req, is_cancelled, &snap, None, known_total)
974}
975
976fn find_page(
979 lmpg: &Lmpg,
980 req: &SearchRequest,
981 is_cancelled: &dyn Fn() -> bool,
982 snap: &QuerySnapshot,
983 work_budget: Option<usize>,
984 known_total: Option<u64>,
985) -> Result<Page, QueryError> {
986 let is_cancelled: &dyn Fn() -> bool = &cancel_probe(lmpg, is_cancelled);
987 if !snap.belongs_to(&lmpg.store) {
988 return Err(rushdb_storage::StorageError::InvalidRead(
989 "query view belongs to another Store".into(),
990 )
991 .into());
992 }
993 if is_cancelled() {
994 return Err(QueryError::Cancelled);
995 }
996 let skip = usize::try_from(req.pagination.skip).unwrap_or(usize::MAX);
997 let limit = usize::try_from(req.pagination.limit.unwrap_or(u64::MAX)).unwrap_or(usize::MAX);
998 let matches = run_matches(
999 lmpg,
1000 req,
1001 is_cancelled,
1002 snap,
1003 skip.saturating_add(limit),
1004 known_total,
1005 )?;
1006 let page_roots: Vec<u64> = matches
1007 .retained
1008 .into_iter()
1009 .skip(skip)
1010 .take(limit)
1011 .collect();
1012 let budget = effective_budget(
1013 work_budget,
1014 lmpg.limits().query_memory_max_bytes().unwrap_or(0),
1015 );
1016 let records =
1017 page::reconstruct_page_with_budget(lmpg, req, is_cancelled, snap, page_roots, budget)?;
1018 Ok(Page {
1019 total_matched: matches.total,
1020 records,
1021 })
1022}
1023
1024fn sort_ordered_rows<T>(sortable: &mut [(OrderRow, T)], req: &SearchRequest) {
1027 for (field, desc) in req.order_by.iter().rev() {
1028 let field = normalize(field);
1029 sortable.sort_by(|((_, a), _), ((_, b), _)| {
1030 let va = a
1031 .iter()
1032 .find(|(k, _)| normalize(k) == field)
1033 .map(|(_, v)| v);
1034 let vb = b
1035 .iter()
1036 .find(|(k, _)| normalize(k) == field)
1037 .map(|(_, v)| v);
1038 let ord = match (va, vb) {
1039 (Some(x), Some(y)) => json_value_cmp(x, y),
1040 (None, Some(_)) => std::cmp::Ordering::Less,
1041 (Some(_), None) => std::cmp::Ordering::Greater,
1042 (None, None) => std::cmp::Ordering::Equal,
1043 };
1044 if *desc {
1045 ord.reverse()
1046 } else {
1047 ord
1048 }
1049 });
1050 }
1051}
1052
1053type RelatedItem = (u64, Map<String, Json>, Map<String, Json>);
1055
1056fn collect_neighbors(
1057 lmpg: &Lmpg,
1058 snap: &QuerySnapshot,
1059 root: u64,
1060 spec: &CollectSpec,
1061 budget: Option<usize>,
1062 is_cancelled: &dyn Fn() -> bool,
1063) -> Result<Vec<u64>, QueryError> {
1064 Ok(if spec.steps.is_empty() {
1065 let edges: Vec<(String, u64, u64)> = match spec.direction {
1066 Direction::Outgoing => {
1067 access::adj_out(snap, lmpg.tenant, root, spec.rel_type.as_deref())?
1068 }
1069 Direction::Incoming => {
1070 access::adj_in(snap, lmpg.tenant, root, spec.rel_type.as_deref())?
1071 }
1072 Direction::Both => {
1073 let mut all = access::adj_out(snap, lmpg.tenant, root, spec.rel_type.as_deref())?;
1074 all.extend(access::adj_in(
1075 snap,
1076 lmpg.tenant,
1077 root,
1078 spec.rel_type.as_deref(),
1079 )?);
1080 all
1081 }
1082 };
1083 edges.into_iter().map(|(_, other, _)| other).collect()
1084 } else {
1085 traverse_alias(
1086 lmpg,
1087 snap,
1088 lmpg.tenant,
1089 root,
1090 &spec.steps,
1091 budget,
1092 is_cancelled,
1093 )?
1094 })
1095}
1096
1097fn order_collected_items(items: &mut Vec<RelatedItem>, spec: &CollectSpec) {
1098 if spec.unique {
1100 items.sort_by_key(|(o, _, _)| *o);
1101 items.dedup_by(|a, b| a.1.get(ID_FIELD) == b.1.get(ID_FIELD));
1102 } else {
1103 items.sort_by_key(|(o, _, _)| *o);
1104 }
1105 if !spec.order_by.is_empty() {
1107 items.sort_by(|a, b| {
1108 for (field, desc) in &spec.order_by {
1109 let av = raw_field(&a.2, field).or_else(|| raw_field(&a.1, field));
1110 let bv = raw_field(&b.2, field).or_else(|| raw_field(&b.1, field));
1111 let ord = scalar_cmp(av, bv).unwrap_or(std::cmp::Ordering::Equal);
1112 if ord != std::cmp::Ordering::Equal {
1113 return if *desc { ord.reverse() } else { ord };
1114 }
1115 }
1116 std::cmp::Ordering::Equal
1117 });
1118 }
1119}
1120
1121fn attach_collects_with_budget(
1122 lmpg: &Lmpg,
1123 snap: &QuerySnapshot,
1124 root: u64,
1125 mut record: Json,
1126 collects: &[CollectSpec],
1127 budget: Option<usize>,
1128 is_cancelled: &dyn Fn() -> bool,
1129) -> Result<Json, QueryError> {
1130 if collects.is_empty() {
1131 return Ok(record);
1132 }
1133 let mut retained = aggregate_budget::value_bytes(&record);
1134 aggregate_budget::check(budget, retained)?;
1135 let ctx = EvalCtx {
1136 lmpg,
1137 snap: snap.clone(),
1138 is_cancelled: None,
1139 cycles: crate::cycle::CycleGraphs::default(),
1140 declared_types: std::cell::RefCell::default(),
1141 record_types: std::cell::RefCell::default(),
1142 };
1143 if let Some(obj) = record.as_object_mut() {
1144 for spec in collects {
1145 if is_cancelled() {
1149 return Err(QueryError::Cancelled);
1150 }
1151 let neighbors = collect_neighbors(
1152 lmpg,
1153 snap,
1154 root,
1155 spec,
1156 budget.map(|limit| limit.saturating_sub(retained)),
1157 is_cancelled,
1158 )?;
1159 let base_bytes = retained;
1161 retained = retained.saturating_add(neighbors.capacity().saturating_mul(8));
1162 aggregate_budget::check(budget, retained)?;
1163 let mut items: Vec<RelatedItem> = Vec::new();
1164 for other in neighbors {
1165 if is_cancelled() {
1166 return Err(QueryError::Cancelled);
1167 }
1168 if !spec.target_labels.is_empty()
1169 && !access::record_by_ord(snap, lmpg.tenant, other)?
1170 .is_some_and(|h| spec.target_labels.contains(&h.label))
1171 {
1172 continue;
1173 }
1174 let reader = aggregate_budget::RecordReader::new(
1175 snap,
1176 budget.map(|limit| limit.saturating_sub(retained)),
1177 );
1178 let Some(Json::Object(child)) =
1179 reconstruct::reconstruct_record(&reader, lmpg.tenant, other)?
1180 else {
1181 continue;
1182 };
1183 if let Some(filter) = &spec.filter {
1185 if !eval_expr_ctx(&ctx, filter, &child)? {
1186 continue;
1187 }
1188 }
1189 retained = retained
1190 .saturating_add(aggregate_budget::object_bytes(&child).saturating_mul(3));
1191 aggregate_budget::check(budget, retained)?;
1192 let projected = match project_fields_map(Json::Object(child.clone()), &spec.fields)
1193 {
1194 Json::Object(p) => p,
1195 _ => child.clone(),
1196 };
1197 items.push((other, child, projected));
1198 }
1199 order_collected_items(&mut items, spec);
1200 let start = usize::try_from(spec.skip)
1202 .unwrap_or(usize::MAX)
1203 .min(items.len());
1204 let end = spec.limit.map_or(items.len(), |l| {
1205 start
1206 .saturating_add(usize::try_from(l).unwrap_or(usize::MAX))
1207 .min(items.len())
1208 });
1209 let mut projected = Vec::new();
1210 for (other, raw, child) in items.into_iter().take(end).skip(start) {
1211 retained =
1212 retained.saturating_sub(aggregate_budget::object_bytes(&raw).saturating_mul(3));
1213 let child = attach_collects_with_budget(
1214 lmpg,
1215 snap,
1216 other,
1217 Json::Object(child),
1218 &spec.nested,
1219 budget.map(|limit| limit.saturating_sub(retained)),
1220 is_cancelled,
1221 )?;
1222 retained = retained.saturating_add(aggregate_budget::value_bytes(&child));
1223 aggregate_budget::check(budget, retained)?;
1224 projected.push(child);
1225 }
1226 let collected = Json::Array(projected);
1227 retained = base_bytes
1228 .saturating_add(spec.alias.len())
1229 .saturating_add(aggregate_budget::value_bytes(&collected));
1230 aggregate_budget::check(budget, retained)?;
1231 obj.insert(spec.alias.clone(), collected);
1232 }
1233 }
1234 Ok(record)
1235}
1236
1237fn project_fields_map(value: Json, fields: &[(String, String)]) -> Json {
1240 if fields.is_empty() {
1241 return value;
1242 }
1243 let mut out = Map::new();
1244 if let Json::Object(obj) = value {
1245 for (out_key, source) in fields {
1246 if let Some(v) = resolve_field(&obj, source) {
1247 out.insert(out_key.clone(), v);
1248 }
1249 }
1250 }
1251 Json::Object(out)
1252}
1253
1254fn scalar_cmp(a: Option<&Json>, b: Option<&Json>) -> Option<std::cmp::Ordering> {
1257 let (Some(a), Some(b)) = (a, b) else {
1258 return None;
1259 };
1260 match (a, b) {
1261 (Json::Number(_), Json::Number(_)) => {
1262 let left = infer_scalar(a).map(|(_, scalar)| scalar);
1263 let right = infer_scalar(b).map(|(_, scalar)| scalar);
1264 left.as_ref()
1265 .zip(right.as_ref())
1266 .and_then(|(left, right)| canonical_scalar_cmp(left, right))
1267 }
1268 (Json::String(x), Json::String(y)) => Some(x.cmp(y)),
1269 _ => None,
1270 }
1271}
1272
1273fn related_exists(
1274 snap: &QuerySnapshot,
1275 tenant: rushdb_types::ids::TenantId,
1276 ord: u64,
1277 spec: &RelatedSpec,
1278) -> Result<bool, QueryError> {
1279 let edges: Vec<(String, u64, u64)> = match spec.direction {
1280 Direction::Outgoing => access::adj_out(snap, tenant, ord, spec.rel_type.as_deref())?,
1281 Direction::Incoming => access::adj_in(snap, tenant, ord, spec.rel_type.as_deref())?,
1282 Direction::Both => {
1283 let mut all = access::adj_out(snap, tenant, ord, spec.rel_type.as_deref())?;
1284 all.extend(access::adj_in(snap, tenant, ord, spec.rel_type.as_deref())?);
1285 all
1286 }
1287 };
1288 if spec.target_labels.is_empty() {
1289 return Ok(!edges.is_empty());
1290 }
1291 for (_, other_ord, _) in edges {
1292 if access::record_by_ord(snap, tenant, other_ord)?
1293 .is_some_and(|h| spec.target_labels.contains(&h.label))
1294 {
1295 return Ok(true);
1296 }
1297 }
1298 Ok(false)
1299}
1300
1301fn count_related(
1307 lmpg: &Lmpg,
1308 snap: &QuerySnapshot,
1309 tenant: rushdb_types::ids::TenantId,
1310 ord: u64,
1311 specs: &[RelatedSpec],
1312 alias: &str,
1313 budget: Option<usize>,
1314) -> Result<Option<Vec<u64>>, QueryError> {
1315 let Some(spec) = specs.iter().find(|s| {
1316 s.alias
1317 .as_deref()
1318 .is_some_and(|a| a.trim_start_matches('$') == alias)
1319 }) else {
1320 return Ok(None);
1321 };
1322 Ok(Some(traverse_alias(
1323 lmpg,
1324 snap,
1325 tenant,
1326 ord,
1327 &spec.steps,
1328 budget,
1329 &|| false,
1330 )?))
1331}
1332
1333fn traverse_alias(
1337 lmpg: &Lmpg,
1338 snap: &QuerySnapshot,
1339 tenant: rushdb_types::ids::TenantId,
1340 ord: u64,
1341 steps: &[TraversalStep],
1342 budget: Option<usize>,
1343 is_cancelled: &dyn Fn() -> bool,
1344) -> Result<Vec<u64>, QueryError> {
1345 if steps.is_empty() {
1346 return Ok(Vec::new());
1347 }
1348 let ctx = EvalCtx {
1349 lmpg,
1350 snap: snap.clone(),
1351 is_cancelled: Some(is_cancelled),
1352 cycles: crate::cycle::CycleGraphs::default(),
1353 declared_types: std::cell::RefCell::default(),
1354 record_types: std::cell::RefCell::default(),
1355 };
1356 let mut cur = vec![ord];
1357 for step in steps {
1358 if is_cancelled() {
1359 return Err(QueryError::Cancelled);
1360 }
1361 let mut next: Vec<u64> = Vec::new();
1362 for o in &cur {
1363 if is_cancelled() {
1364 return Err(QueryError::Cancelled);
1365 }
1366 let edges: Vec<(String, u64, u64)> = match step.direction {
1367 Direction::Outgoing => access::adj_out(snap, tenant, *o, step.rel_type.as_deref())?,
1368 Direction::Incoming => access::adj_in(snap, tenant, *o, step.rel_type.as_deref())?,
1369 Direction::Both => {
1370 let mut all = access::adj_out(snap, tenant, *o, step.rel_type.as_deref())?;
1371 all.extend(access::adj_in(snap, tenant, *o, step.rel_type.as_deref())?);
1372 all
1373 }
1374 };
1375 for (_, other, _) in edges {
1376 if !step.target_labels.is_empty()
1377 && !access::record_by_ord(snap, tenant, other)?
1378 .is_some_and(|h| step.target_labels.contains(&h.label))
1379 {
1380 continue;
1381 }
1382 if let Some(pred) = &step.filter {
1383 let reader = aggregate_budget::RecordReader::new(
1384 snap,
1385 budget.map(|limit| {
1386 limit.saturating_sub(
1387 cur.len().saturating_add(next.len()).saturating_mul(32),
1388 )
1389 }),
1390 );
1391 let props = reconstruct::reconstruct_record(&reader, tenant, other)?;
1392 let obj = props
1393 .as_ref()
1394 .and_then(|p| p.as_object().cloned())
1395 .unwrap_or_default();
1396 if !eval_expr_ctx(&ctx, pred, &obj)? {
1397 continue;
1398 }
1399 }
1400 aggregate_budget::check(
1401 budget,
1402 cur.len()
1403 .saturating_add(next.len())
1404 .saturating_add(1)
1405 .saturating_mul(32),
1406 )?;
1407 next.push(other);
1408 }
1409 }
1410 next.sort_unstable();
1411 next.dedup();
1412 cur = next;
1413 }
1414 Ok(cur)
1415}
1416
1417fn related_specs_from_where(expr: Option<&Expr>) -> Vec<RelatedSpec> {
1421 fn split_level(filter: Option<&Expr>, children: &mut Vec<Expr>) -> Option<Expr> {
1424 let f = filter?;
1425 match f {
1426 Expr::And(items) => {
1427 let mut preds: Vec<Expr> = Vec::new();
1428 for item in items {
1429 match item {
1430 Expr::Related(_) => children.push(item.clone()),
1431 other => preds.push(other.clone()),
1432 }
1433 }
1434 match preds.len() {
1435 0 => None,
1436 1 => Some(preds.remove(0)),
1437 _ => Some(Expr::And(preds)),
1438 }
1439 }
1440 Expr::Related(_) => {
1441 children.push(f.clone());
1442 None
1443 }
1444 other => Some(other.clone()),
1445 }
1446 }
1447
1448 fn walk(expr: &Expr, path: &mut Vec<TraversalStep>, out: &mut Vec<RelatedSpec>) {
1449 match expr {
1450 Expr::And(items) | Expr::Or(items) | Expr::Nor(items) => {
1451 for e in items {
1452 walk(e, path, out);
1453 }
1454 }
1455 Expr::Xor(a, b) => {
1456 walk(a, path, out);
1457 walk(b, path, out);
1458 }
1459 Expr::Not(b) => {
1460 walk(b, path, out);
1461 }
1462 Expr::Related(rel) => {
1463 let mut children: Vec<Expr> = Vec::new();
1464 let filter = split_level(rel.filter.as_deref(), &mut children);
1465 let step = TraversalStep {
1466 target_labels: rel.target_labels.clone(),
1467 rel_type: rel.rel_type.clone(),
1468 direction: rel.direction,
1469 filter,
1470 };
1471 path.push(step);
1472 if let Some(alias) = &rel.alias {
1473 out.push(RelatedSpec {
1474 alias: Some(alias.trim_start_matches('$').to_string()),
1475 rel_type: rel.rel_type.clone(),
1476 direction: rel.direction,
1477 target_labels: rel.target_labels.clone(),
1478 steps: path.clone(),
1479 });
1480 }
1481 for child in &children {
1482 walk(child, path, out);
1483 }
1484 path.pop();
1485 }
1486 Expr::Field(_) => {}
1487 }
1488 }
1489 let mut specs = Vec::new();
1490 if let Some(e) = expr {
1491 let mut path: Vec<TraversalStep> = Vec::new();
1492 walk(e, &mut path, &mut specs);
1493 }
1494 specs
1495}
1496
1497fn alias_field_values(
1501 lmpg: &Lmpg,
1502 snap: &QuerySnapshot,
1503 tenant: rushdb_types::ids::TenantId,
1504 ord: u64,
1505 specs: &[RelatedSpec],
1506 field: &str,
1507 budget: Option<usize>,
1508) -> Result<Option<Vec<Json>>, QueryError> {
1509 let Some((alias, prop)) = field.split_once('.') else {
1510 return Ok(None);
1511 };
1512 let Some(related) = count_related(lmpg, snap, tenant, ord, specs, alias, budget)? else {
1513 return Ok(None);
1514 };
1515 let mut retained = related.capacity().saturating_mul(8);
1516 aggregate_budget::check(budget, retained)?;
1517 let mut values = Vec::new();
1518 for other in related {
1519 let reader = aggregate_budget::RecordReader::new(
1520 snap,
1521 budget.map(|limit| limit.saturating_sub(retained)),
1522 );
1523 if let Some(Json::Object(child)) = reconstruct::reconstruct_record(&reader, tenant, other)?
1524 {
1525 if let Some(v) = resolve_field(&child, prop) {
1526 retained = retained.saturating_add(aggregate_budget::value_bytes(&v));
1527 aggregate_budget::check(budget, retained)?;
1528 values.push(v);
1529 }
1530 }
1531 }
1532 Ok(Some(values))
1533}
1534
1535pub const MISSING_SENTINEL: &str = "__missing__";
1538
1539#[derive(Debug, Clone, Default, serde::Serialize)]
1542pub struct GroupBucket {
1543 pub keys: Map<String, Json>,
1546 pub count_roots: u64,
1548 pub sums: Map<String, Json>,
1550 pub counts: Map<String, Json>,
1552 pub mins: Map<String, Json>,
1554 pub maxs: Map<String, Json>,
1556 pub derived_outputs: std::collections::BTreeMap<String, Json>,
1558}
1559
1560#[derive(Debug, Clone)]
1563pub enum OutputExpr {
1564 Ref(String),
1566 Field(String),
1568 Add(Box<OutputExpr>, Box<OutputExpr>),
1570 Subtract(Box<OutputExpr>, Box<OutputExpr>),
1572 Multiply(Box<OutputExpr>, Box<OutputExpr>),
1574 Divide {
1576 dividend: Box<OutputExpr>,
1578 divisor: Box<OutputExpr>,
1580 precision: u32,
1582 },
1583 Literal(f64),
1585}
1586
1587#[derive(Debug, Clone, Default)]
1589pub struct AggregateRequest {
1590 pub labels: Vec<String>,
1592 pub where_: Option<Expr>,
1594 pub related: Vec<RelatedSpec>,
1596 pub group_by: Vec<String>,
1598 pub sums: Vec<(String, String)>,
1600 pub counts: Vec<(String, String)>,
1603 pub mins: Vec<(String, String)>,
1605 pub maxs: Vec<(String, String)>,
1607 pub time_bucket: Option<TimeBucketSpec>,
1609 pub outputs: Vec<(String, OutputExpr)>,
1611 pub expr_sums: Vec<(String, OutputExpr)>,
1613 pub expr_avgs: Vec<(String, OutputExpr)>,
1615 pub avg_terms: Vec<(String, String)>,
1617 pub avg_precision: std::collections::BTreeMap<String, u32>,
1619 pub time_bucket_alias: Option<String>,
1621 pub plain_fields: Vec<(String, String)>,
1623 pub collects: Vec<CollectSpec>,
1626 pub order_by: Vec<(String, bool )>,
1628 pub skip: u64,
1630 pub limit: Option<u64>,
1632 pub hidden_outputs: std::collections::BTreeSet<String>,
1634}
1635
1636pub struct EvalCtx<'a> {
1639 pub lmpg: &'a Lmpg,
1641 pub snap: QuerySnapshot,
1643 pub is_cancelled: Option<&'a dyn Fn() -> bool>,
1645 pub(crate) cycles: cycle::CycleGraphs,
1647 pub(crate) declared_types:
1651 std::cell::RefCell<std::collections::HashMap<String, Option<LogicalType>>>,
1652 pub(crate) record_types:
1656 std::cell::RefCell<std::collections::HashMap<String, Vec<(u64, LogicalType)>>>,
1657}
1658
1659impl<'a> EvalCtx<'a> {
1660 pub fn new(lmpg: &'a Lmpg) -> Result<Self, QueryError> {
1667 Ok(EvalCtx {
1668 lmpg,
1669 snap: Arc::new(lmpg.store.read_view()?),
1670 is_cancelled: None,
1671 cycles: cycle::CycleGraphs::default(),
1672 declared_types: std::cell::RefCell::default(),
1673 record_types: std::cell::RefCell::default(),
1674 })
1675 }
1676
1677 pub fn check_cancelled(&self) -> Result<(), QueryError> {
1683 if self.is_cancelled.is_some_and(|probe| probe()) {
1684 Err(QueryError::Cancelled)
1685 } else {
1686 Ok(())
1687 }
1688 }
1689}
1690
1691fn eval_expr_ctx(
1692 ctx: &EvalCtx<'_>,
1693 expr: &Expr,
1694 obj: &Map<String, Json>,
1695) -> Result<bool, QueryError> {
1696 match expr {
1697 Expr::And(items) => {
1698 for item in items {
1699 if !eval_expr_ctx(ctx, item, obj)? {
1700 return Ok(false);
1701 }
1702 }
1703 Ok(true)
1704 }
1705 Expr::Or(items) | Expr::Nor(items) => {
1706 let inverted = matches!(expr, Expr::Nor(_));
1707 for item in items {
1708 if eval_expr_ctx(ctx, item, obj)? {
1709 return Ok(!inverted);
1710 }
1711 }
1712 Ok(inverted)
1713 }
1714 Expr::Xor(a, b) => Ok(eval_expr_ctx(ctx, a, obj)? != eval_expr_ctx(ctx, b, obj)?),
1715 Expr::Not(inner) => Ok(!eval_expr_ctx(ctx, inner, obj)?),
1716 Expr::Field(pred) => eval_pred(&|name| type_held(ctx, name, obj), pred, obj),
1717 Expr::Related(rel) => eval_related(ctx, obj, rel),
1718 }
1719}
1720
1721fn eval_related(
1730 ctx: &EvalCtx<'_>,
1731 root: &Map<String, Json>,
1732 rel: &RelatedExpr,
1733) -> Result<bool, QueryError> {
1734 let root_id = root.get(ID_FIELD).and_then(|v| v.as_str()).unwrap_or("");
1735 let start_ord = root_ord_of(ctx, root_id)?;
1736 if start_ord == u64::MAX {
1737 return Ok(false);
1738 }
1739 let (min_hops, max_hops) = rel.hops.unwrap_or((1, 1));
1740
1741 let neighbors = |node: u64| -> Result<Vec<(String, u64, u64)>, QueryError> {
1742 Ok(match rel.direction {
1743 Direction::Outgoing => {
1744 access::adj_out(&ctx.snap, ctx.lmpg.tenant, node, rel.rel_type.as_deref())?
1745 }
1746 Direction::Incoming => {
1747 access::adj_in(&ctx.snap, ctx.lmpg.tenant, node, rel.rel_type.as_deref())?
1748 }
1749 Direction::Both => {
1750 let mut all =
1751 access::adj_out(&ctx.snap, ctx.lmpg.tenant, node, rel.rel_type.as_deref())?;
1752 all.extend(access::adj_in(
1753 &ctx.snap,
1754 ctx.lmpg.tenant,
1755 node,
1756 rel.rel_type.as_deref(),
1757 )?);
1758 all
1759 }
1760 })
1761 };
1762
1763 let endpoint_ok = |other: u64| -> Result<bool, QueryError> {
1765 if rel.target_labels.is_empty() {
1766 Ok(true)
1767 } else {
1768 Ok(access::record_by_ord(&ctx.snap, ctx.lmpg.tenant, other)?
1769 .is_some_and(|h| rel.target_labels.contains(&h.label)))
1770 }
1771 };
1772
1773 if rel.cycle {
1774 let cancel = || ctx.check_cancelled();
1775 return ctx
1776 .cycles
1777 .on_cycle(&ctx.snap, ctx.lmpg.tenant, rel, start_ord, &cancel);
1778 }
1779
1780 let mut frontier: Vec<u64> = vec![start_ord];
1782 let mut visited: std::collections::BTreeSet<u64> = std::collections::BTreeSet::new();
1783 visited.insert(start_ord);
1784 let mut depth = 0u64;
1785
1786 while !frontier.is_empty() && depth < max_hops {
1787 ctx.check_cancelled()?;
1788 depth += 1;
1789 let mut next: Vec<u64> = Vec::new();
1790 for node in &frontier {
1791 for (_ty, other, _ro) in neighbors(*node)? {
1792 if !endpoint_ok(other)? {
1793 continue;
1794 }
1795 if depth >= min_hops {
1796 if let Some(filter) = &rel.filter {
1797 if let Some(target_json) =
1798 reconstruct::reconstruct_record(&ctx.snap, ctx.lmpg.tenant, other)?
1799 {
1800 let target_obj = target_json.as_object().cloned().unwrap_or_default();
1801 if eval_expr_ctx(ctx, filter, &target_obj)? {
1802 return Ok(true);
1803 }
1804 }
1805 } else {
1806 return Ok(true);
1807 }
1808 }
1809 if depth < max_hops && visited.insert(other) {
1810 next.push(other);
1811 }
1812 }
1813 }
1814 frontier = next;
1815 }
1816 Ok(false)
1817}
1818
1819fn root_ord_of(ctx: &EvalCtx<'_>, id_hex: &str) -> Result<u64, QueryError> {
1820 let bytes = hex_decode_16(id_hex).unwrap_or([0u8; 16]);
1821 let id = rushdb_types::ids::RecordId(bytes);
1822 Ok(access::record_by_id(&ctx.snap, ctx.lmpg.tenant, &id)?.map_or(u64::MAX, |h| h.ordinal))
1823}
1824
1825pub(crate) type DeclaredType<'a> =
1827 dyn Fn(&str) -> Result<Option<rushdb_types::model::LogicalType>, QueryError> + 'a;
1828
1829#[expect(
1830 clippy::too_many_lines,
1831 reason = "one exhaustive match over every field predicate keeps the operator semantics readable in a single place"
1832)]
1833pub(crate) fn eval_pred(
1834 declared_type: &DeclaredType<'_>,
1835 pred: &FieldPred,
1836 obj: &Map<String, Json>,
1837) -> Result<bool, QueryError> {
1838 Ok(match pred {
1839 FieldPred::Exists(name, want) => {
1840 let present = raw_field(obj, name).is_some();
1841 present == *want
1842 }
1843 FieldPred::IsNull(name, want) => {
1844 let v = raw_field(obj, name);
1845 let is_null = matches!(v, Some(Json::Null));
1846 is_null == *want
1847 }
1848 FieldPred::Eq(name, val) => match (raw_field(obj, name), val) {
1849 (Some(_), Json::Null) => matches!(raw_field(obj, name), Some(Json::Null)),
1850 (Some(actual), expected) => {
1851 let datetime = is_datetime(declared_type, name)?;
1852 any_element(actual, |el| value_equals(el, expected, datetime))
1853 }
1854 (None, _) => false,
1855 },
1856 FieldPred::Ne(name, val) => {
1857 match raw_field(obj, name) {
1859 None => false,
1860 Some(actual) => {
1861 let datetime = is_datetime(declared_type, name)?;
1862 !any_element(actual, |el| value_equals(el, val, datetime))
1863 }
1864 }
1865 }
1866 FieldPred::In(name, vals) => match raw_field(obj, name) {
1867 None => false,
1868 Some(actual) => {
1869 let datetime = is_datetime(declared_type, name)?;
1870 any_element(actual, |el| {
1871 vals.iter().any(|v| value_equals(el, v, datetime))
1872 })
1873 }
1874 },
1875 FieldPred::Contains(name, sub) => raw_field(obj, name).is_some_and(|j| {
1876 let sub = sub.to_lowercase();
1877 any_element(j, |el| {
1878 el.as_str()
1879 .is_some_and(|st| st.to_lowercase().contains(sub.as_str()))
1880 })
1881 }),
1882 FieldPred::StartsWith(name, prefix) => raw_field(obj, name).is_some_and(|j| {
1883 let prefix = prefix.to_lowercase();
1884 any_element(j, |el| {
1885 el.as_str()
1886 .is_some_and(|st| st.to_lowercase().starts_with(prefix.as_str()))
1887 })
1888 }),
1889 FieldPred::Gt(name, bound) => numeric_cmp_any(raw_field(obj, name), *bound, |o| {
1890 o == std::cmp::Ordering::Greater
1891 }),
1892 FieldPred::Gte(name, bound) => numeric_cmp_any(raw_field(obj, name), *bound, |o| {
1893 o != std::cmp::Ordering::Less
1894 }),
1895 FieldPred::Lt(name, bound) => numeric_cmp_any(raw_field(obj, name), *bound, |o| {
1896 o == std::cmp::Ordering::Less
1897 }),
1898 FieldPred::Lte(name, bound) => numeric_cmp_any(raw_field(obj, name), *bound, |o| {
1899 o != std::cmp::Ordering::Greater
1900 }),
1901 FieldPred::Nin(name, vals) => {
1902 match raw_field(obj, name) {
1904 None => false,
1905 Some(actual) => {
1906 let datetime = is_datetime(declared_type, name)?;
1907 !any_element(actual, |el| {
1908 vals.iter().any(|v| value_equals(el, v, datetime))
1909 })
1910 }
1911 }
1912 }
1913 FieldPred::EndsWith(name, suffix) => raw_field(obj, name).is_some_and(|j| {
1914 let suffix = suffix.to_lowercase();
1915 any_element(j, |el| {
1916 el.as_str()
1917 .is_some_and(|st| st.to_lowercase().ends_with(suffix.as_str()))
1918 })
1919 }),
1920 FieldPred::Id(id) => obj
1921 .get(ID_FIELD)
1922 .and_then(|v| v.as_str())
1923 .is_some_and(|s| canonical_id(s) == canonical_id(id)),
1924 FieldPred::DatetimeComponent {
1925 field,
1926 which,
1927 value,
1928 } => raw_field(obj, field)
1929 .and_then(serde_json::Value::as_i64)
1930 .is_some_and(|ms| {
1931 let actual = crate::datetime::component_of(ms, *which);
1932 actual == *value
1933 }),
1934 FieldPred::Type(name, expected) => {
1935 let want = canonical_type_alias(&expected.to_lowercase());
1936 let (base_want, want_array) = match want.strip_suffix("[]") {
1937 Some(base) if !base.is_empty() => (Some(canonical_type_alias(base)), true),
1938 _ => (None, want == "array"),
1939 };
1940 let value = raw_field(obj, name);
1941 if value.is_none() {
1944 return Ok(want == "null");
1945 }
1946 let is_array = matches!(value, Some(Json::Array(_)));
1947
1948 if want == "array" {
1950 return Ok(is_array);
1951 }
1952
1953 if let Some(ty) = declared_type(name)? {
1957 let name_of = |t: &rushdb_types::model::LogicalType| -> &'static str {
1958 match t {
1959 rushdb_types::model::LogicalType::Boolean => "boolean",
1960 rushdb_types::model::LogicalType::Int64
1961 | rushdb_types::model::LogicalType::Float64 => "number",
1962 rushdb_types::model::LogicalType::Utf8 => "string",
1963 rushdb_types::model::LogicalType::DateTime => "datetime",
1964 }
1965 };
1966 if want_array {
1967 if is_array && name_of(&ty) == base_want.as_deref().unwrap_or("") {
1970 return Ok(true);
1971 }
1972 } else if name_of(&ty) == want {
1973 return Ok(true);
1976 }
1977 }
1978
1979 let shape_of = |v: &Json| -> &'static str {
1980 if v.is_string() {
1981 "string"
1982 } else if v.is_number() {
1983 "number"
1984 } else if v.is_boolean() {
1985 "boolean"
1986 } else if v.is_array() {
1987 "array"
1988 } else if v.is_object() {
1989 "object"
1990 } else {
1991 "null"
1992 }
1993 };
1994
1995 if want_array {
1996 let base = base_want.as_deref().unwrap_or("");
1999 return Ok(
2000 matches!(value, Some(Json::Array(items)) if items.iter().any(|el| shape_of(el) == base)),
2001 );
2002 }
2003
2004 let actual = value.map_or("null", shape_of);
2005 actual == want
2006 }
2007 })
2008}
2009
2010fn numeric_cmp(
2011 value: Option<&Json>,
2012 bound: f64,
2013 accept: impl Fn(std::cmp::Ordering) -> bool,
2014) -> bool {
2015 value
2016 .and_then(infer_scalar)
2017 .and_then(|(_, scalar)| match scalar {
2018 Scalar::Int64(_) | Scalar::Float64(_) => Some(scalar),
2019 _ => None,
2020 })
2021 .and_then(|value| canonical_scalar_cmp(&value, &Scalar::Float64(bound)))
2022 .is_some_and(accept)
2023}
2024
2025fn numeric_cmp_any(
2026 value: Option<&Json>,
2027 bound: f64,
2028 accept: impl Fn(std::cmp::Ordering) -> bool,
2029) -> bool {
2030 match value {
2031 Some(v) if v.is_array() => v
2032 .as_array()
2033 .is_some_and(|a| a.iter().any(|el| numeric_cmp(Some(el), bound, &accept))),
2034 other => numeric_cmp(other, bound, accept),
2035 }
2036}
2037
2038fn canonical_type_alias(t: &str) -> String {
2042 if t == "bool" {
2043 "boolean".to_string()
2044 } else {
2045 t.to_string()
2046 }
2047}
2048
2049fn json_value_cmp(a: &Json, b: &Json) -> std::cmp::Ordering {
2054 let numeric = |v: &Json| {
2055 infer_scalar(v).and_then(|(_, scalar)| match scalar {
2056 Scalar::Int64(_) | Scalar::Float64(_) => Some(scalar),
2057 _ => None,
2058 })
2059 };
2060 match (numeric(a), numeric(b)) {
2061 (Some(x), Some(y)) => canonical_scalar_cmp(&x, &y).unwrap_or(std::cmp::Ordering::Equal),
2062 (None, Some(_)) => std::cmp::Ordering::Less,
2064 (Some(_), None) => std::cmp::Ordering::Greater,
2065 (None, None) => match (a.as_str(), b.as_str()) {
2066 (Some(x), Some(y)) => x.cmp(y),
2067 _ => format!("{a}").cmp(&format!("{b}")),
2068 },
2069 }
2070}
2071
2072fn raw_field<'a>(obj: &'a Map<String, Json>, name: &str) -> Option<&'a Json> {
2073 let norm = normalize(name);
2074 obj.iter()
2075 .find(|(k, _)| normalize(k) == norm)
2076 .map(|(_, v)| v)
2077}
2078
2079fn canonical_id(id: &str) -> String {
2080 id.bytes()
2081 .filter(|byte| *byte != b'-')
2082 .map(|byte| (byte as char).to_ascii_lowercase())
2083 .collect()
2084}
2085
2086fn binding_roots_with_cancel(
2088 lmpg: &Lmpg,
2089 req: &SearchRequest,
2090 is_cancelled: &dyn Fn() -> bool,
2091 snap: &QuerySnapshot,
2092 budget: Option<usize>,
2093) -> Result<Vec<u64>, QueryError> {
2094 let plan = index_candidates_with_budget(lmpg, snap, req, budget, is_cancelled)?;
2099 let index_exact = plan.as_ref().is_some_and(|plan| plan.exact);
2100 let candidates: Vec<u64> = match plan {
2101 Some(plan) => plan.ords.into_iter().collect(),
2102 None => scan::Candidates::walk(snap, lmpg.tenant, &req.labels)?.collect(is_cancelled)?,
2103 };
2104 let candidate_bytes = candidates.capacity().saturating_mul(16);
2105 aggregate_budget::check(budget, candidate_bytes)?;
2106 if index_exact && req.order_by.is_empty() {
2107 return Ok(candidates);
2108 }
2109 let ctx = EvalCtx {
2110 lmpg,
2111 snap: snap.clone(),
2112 is_cancelled: Some(is_cancelled),
2113 cycles: crate::cycle::CycleGraphs::default(),
2114 declared_types: std::cell::RefCell::default(),
2115 record_types: std::cell::RefCell::default(),
2116 };
2117 let mut matched: Vec<u64> = Vec::new();
2118 for ord in candidates {
2119 if is_cancelled() {
2120 return Err(QueryError::Cancelled);
2121 }
2122 if access::record_by_ord(snap, lmpg.tenant, ord)?.is_none() {
2123 continue;
2124 }
2125 if let Some(expr) = &req.where_ {
2126 let reader = aggregate_budget::RecordReader::new(
2127 snap,
2128 budget.map(|limit| limit.saturating_sub(candidate_bytes)),
2129 );
2130 let mut session = reconstruct::ReconstructSession::new(&reader, lmpg.tenant);
2131 let props = session.reconstruct_record(ord)?;
2132 let obj = props
2133 .as_ref()
2134 .and_then(|p| p.as_object().cloned())
2135 .unwrap_or_default();
2136 if !eval_expr_ctx(&ctx, expr, &obj)? {
2137 continue;
2138 }
2139 }
2140 let mut all_branches_bound = true;
2141 for spec in &req.related {
2142 if !related_exists(snap, lmpg.tenant, ord, spec)? {
2143 all_branches_bound = false;
2144 break;
2145 }
2146 }
2147 if all_branches_bound {
2148 matched.push(ord);
2149 }
2150 }
2151 Ok(matched)
2152}
2153
2154#[must_use]
2156pub fn normalize(name: &str) -> String {
2157 rushdb_types::value::normalize_name(name)
2158}
2159
2160fn resolve_field(obj: &Map<String, Json>, field: &str) -> Option<Json> {
2161 let norm = normalize(field.strip_prefix("$record.").unwrap_or(field));
2162 obj.iter()
2163 .find(|(k, _)| normalize(k) == norm)
2164 .map(|(_, v)| v.clone())
2165}
2166
2167fn resolve_field_numeric(obj: &Map<String, Json>, field: &str) -> Option<Json> {
2168 resolve_field(obj, field).filter(serde_json::Value::is_number)
2169}
2170
2171#[cfg(test)]
2172mod index_planner_tests;