1use std::time::Duration;
8
9use chronix_core::{TimeBucket, Timestamp};
10
11use crate::aggregate::AggFn;
12use crate::error::{QueryError, Result};
13use crate::window::WindowFn;
14
15pub use chronix_engine::segment::{FieldPredicate, StatsOp};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct TimeRange {
20 pub start: Timestamp,
22 pub end: Timestamp,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct TagFilter {
29 pub key: String,
31 pub value: String,
33}
34
35#[derive(Debug, Clone)]
40pub enum QueryPlan {
41 Scan {
43 measurement: String,
45 tag_filters: Vec<TagFilter>,
47 projection: Vec<String>,
49 time_range: TimeRange,
51 field_predicates: Vec<FieldPredicate>,
56 namespace_id: Option<String>,
62 },
63 Aggregate {
65 source: Box<QueryPlan>,
67 functions: Vec<AggFn>,
69 group_by: Vec<String>,
71 estimated_cardinality: Option<usize>,
76 },
77 Downsample {
87 source: Box<QueryPlan>,
89 bucket: TimeBucket,
91 function: AggFn,
93 },
94 Limit {
96 source: Box<QueryPlan>,
98 limit: usize,
100 offset: usize,
102 },
103 Window {
105 source: Box<QueryPlan>,
107 functions: Vec<WindowFn>,
109 partition_by: Vec<String>,
111 value_column: String,
113 },
114}
115
116#[derive(Debug, Clone)]
129pub struct QueryBuilder {
130 measurement: Option<String>,
131 tag_filters: Vec<TagFilter>,
132 fields: Vec<String>,
133 time_range: Option<TimeRange>,
134 aggregate_fns: Vec<AggFn>,
135 group_by: Vec<String>,
136 downsample: Option<(TimeBucket, AggFn)>,
137 limit: Option<usize>,
138 offset: Option<usize>,
139 window_fns: Vec<WindowFn>,
140 window_partition_by: Vec<String>,
141 window_value_column: Option<String>,
142 namespace_id: Option<String>,
145 estimated_cardinality: Option<usize>,
147}
148
149impl QueryBuilder {
150 #[must_use]
152 pub fn new() -> Self {
153 Self {
154 measurement: None,
155 tag_filters: Vec::new(),
156 fields: Vec::new(),
157 time_range: None,
158 aggregate_fns: Vec::new(),
159 group_by: Vec::new(),
160 downsample: None,
161 limit: None,
162 offset: None,
163 window_fns: Vec::new(),
164 window_partition_by: Vec::new(),
165 window_value_column: None,
166 namespace_id: None,
167 estimated_cardinality: None,
168 }
169 }
170
171 #[must_use]
173 pub fn measurement(mut self, name: impl Into<String>) -> Self {
174 self.measurement = Some(name.into());
175 self
176 }
177
178 #[must_use]
180 pub fn tag(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
181 self.tag_filters.push(TagFilter {
182 key: key.into(),
183 value: value.into(),
184 });
185 self
186 }
187
188 #[must_use]
190 pub fn tags(mut self, tags: &[(&str, &str)]) -> Self {
191 for (key, value) in tags {
192 self.tag_filters.push(TagFilter {
193 key: (*key).to_string(),
194 value: (*value).to_string(),
195 });
196 }
197 self
198 }
199
200 #[must_use]
202 pub fn field(mut self, name: impl Into<String>) -> Self {
203 self.fields.push(name.into());
204 self
205 }
206
207 #[must_use]
209 pub fn fields(mut self, names: &[&str]) -> Self {
210 for name in names {
211 self.fields.push((*name).to_string());
212 }
213 self
214 }
215
216 #[must_use]
218 pub fn range(mut self, start: Timestamp, end: Timestamp) -> Self {
219 self.time_range = Some(TimeRange { start, end });
220 self
221 }
222
223 #[must_use]
227 #[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
228 pub fn last(mut self, duration: Duration) -> Self {
229 let now_ns = i64::try_from(
230 std::time::SystemTime::now()
231 .duration_since(std::time::UNIX_EPOCH)
232 .unwrap_or_default()
233 .as_nanos(),
234 )
235 .unwrap_or(i64::MAX);
236 let start = now_ns.saturating_sub(i64::try_from(duration.as_nanos()).unwrap_or(i64::MAX));
237 self.time_range = Some(TimeRange { start, end: now_ns });
238 self
239 }
240
241 #[must_use]
243 pub fn aggregate(mut self, function: AggFn) -> Self {
244 self.aggregate_fns.push(function);
245 self
246 }
247
248 #[must_use]
250 pub fn group_by(mut self, columns: &[&str]) -> Self {
251 self.group_by = columns.iter().map(|s| (*s).to_string()).collect();
252 self
253 }
254
255 #[must_use]
261 pub fn estimated_cardinality(mut self, cardinality: usize) -> Self {
262 self.estimated_cardinality = Some(cardinality);
263 self
264 }
265
266 #[must_use]
280 pub fn downsample(mut self, bucket: TimeBucket, function: AggFn) -> Self {
281 self.downsample = Some((bucket, function));
282 self
283 }
284
285 #[must_use]
287 pub fn limit(mut self, n: usize) -> Self {
288 self.limit = Some(n);
289 self
290 }
291
292 #[must_use]
296 pub fn offset(mut self, n: usize) -> Self {
297 self.offset = Some(n);
298 self
299 }
300
301 #[must_use]
303 pub fn window(mut self, func: WindowFn) -> Self {
304 self.window_fns.push(func);
305 self
306 }
307
308 #[must_use]
310 pub fn window_partition_by(mut self, columns: &[&str]) -> Self {
311 self.window_partition_by = columns.iter().map(|s| (*s).to_string()).collect();
312 self
313 }
314
315 #[must_use]
319 pub fn window_value_column(mut self, column: impl Into<String>) -> Self {
320 self.window_value_column = Some(column.into());
321 self
322 }
323
324 #[must_use]
330 pub fn namespace(mut self, ns: impl Into<String>) -> Self {
331 let ns = ns.into();
332 self.tag_filters.push(TagFilter {
334 key: chronix_core::NAMESPACE_TAG.to_string(),
335 value: ns.clone(),
336 });
337 self.namespace_id = Some(ns);
338 self
339 }
340
341 #[must_use]
349 pub fn namespace_scope(self, ns: Option<&str>) -> Self {
350 match ns {
351 Some(ns) => self.namespace(ns),
352 None => self,
353 }
354 }
355
356 pub fn build(self) -> Result<QueryPlan> {
364 let measurement = self
365 .measurement
366 .ok_or_else(|| QueryError::Validation("measurement is required".into()))?;
367
368 let time_range = self.time_range.unwrap_or(TimeRange {
369 start: i64::MIN,
370 end: i64::MAX,
371 });
372
373 if time_range.start > time_range.end {
374 return Err(QueryError::InvalidTimeRange {
375 start: time_range.start,
376 end: time_range.end,
377 });
378 }
379
380 if self.offset.is_some() && self.limit.is_none() {
381 return Err(QueryError::Validation(
382 "offset requires a limit; set limit() before offset()".into(),
383 ));
384 }
385
386 let scan = QueryPlan::Scan {
387 measurement,
388 tag_filters: self.tag_filters,
389 projection: self.fields,
390 time_range,
391 field_predicates: Vec::new(),
392 namespace_id: self.namespace_id,
393 };
394
395 if self.downsample.is_some() && !self.aggregate_fns.is_empty() {
397 return Err(QueryError::Validation(
398 "cannot combine downsample with aggregate functions; use one or the other".into(),
399 ));
400 }
401
402 let plan = if let Some((bucket, function)) = self.downsample {
404 QueryPlan::Downsample {
405 source: Box::new(scan),
406 bucket,
407 function,
408 }
409 } else if !self.aggregate_fns.is_empty() {
410 QueryPlan::Aggregate {
411 source: Box::new(scan),
412 functions: self.aggregate_fns,
413 group_by: self.group_by,
414 estimated_cardinality: self.estimated_cardinality,
415 }
416 } else {
417 scan
418 };
419
420 let plan = if !self.window_fns.is_empty() {
422 let value_column = self
423 .window_value_column
424 .unwrap_or_else(|| "value".to_string());
425 QueryPlan::Window {
426 source: Box::new(plan),
427 functions: self.window_fns,
428 partition_by: self.window_partition_by,
429 value_column,
430 }
431 } else {
432 plan
433 };
434
435 let plan = if let Some(limit) = self.limit {
437 QueryPlan::Limit {
438 source: Box::new(plan),
439 limit,
440 offset: self.offset.unwrap_or(0),
441 }
442 } else {
443 plan
444 };
445
446 Ok(plan)
447 }
448}
449
450impl Default for QueryBuilder {
451 fn default() -> Self {
452 Self::new()
453 }
454}
455
456#[must_use]
461pub fn extract_scan(plan: &QueryPlan) -> Option<(&str, &[TagFilter], &[String], &TimeRange)> {
462 match plan {
463 QueryPlan::Scan {
464 measurement,
465 tag_filters,
466 projection,
467 time_range,
468 ..
469 } => Some((measurement, tag_filters, projection, time_range)),
470 QueryPlan::Aggregate { source, .. }
471 | QueryPlan::Downsample { source, .. }
472 | QueryPlan::Limit { source, .. }
473 | QueryPlan::Window { source, .. } => extract_scan(source),
474 }
475}
476
477#[must_use]
483pub fn extract_namespace(plan: &QueryPlan) -> Option<&str> {
484 match plan {
485 QueryPlan::Scan { namespace_id, .. } => namespace_id.as_deref(),
486 QueryPlan::Aggregate { source, .. }
487 | QueryPlan::Downsample { source, .. }
488 | QueryPlan::Limit { source, .. }
489 | QueryPlan::Window { source, .. } => extract_namespace(source),
490 }
491}
492
493#[must_use]
495pub fn extract_field_predicates(plan: &QueryPlan) -> &[FieldPredicate] {
496 match plan {
497 QueryPlan::Scan {
498 field_predicates, ..
499 } => field_predicates,
500 QueryPlan::Aggregate { source, .. }
501 | QueryPlan::Downsample { source, .. }
502 | QueryPlan::Limit { source, .. }
503 | QueryPlan::Window { source, .. } => extract_field_predicates(source),
504 }
505}
506
507pub fn set_field_predicates(plan: &mut QueryPlan, preds: Vec<FieldPredicate>) {
509 match plan {
510 QueryPlan::Scan {
511 field_predicates, ..
512 } => *field_predicates = preds,
513 QueryPlan::Aggregate { source, .. }
514 | QueryPlan::Downsample { source, .. }
515 | QueryPlan::Limit { source, .. }
516 | QueryPlan::Window { source, .. } => set_field_predicates(source, preds),
517 }
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523
524 #[test]
525 fn build_simple_scan() {
526 let plan = QueryBuilder::new()
527 .measurement("cpu")
528 .range(100, 200)
529 .build()
530 .unwrap();
531
532 match plan {
533 QueryPlan::Scan {
534 measurement,
535 time_range,
536 ..
537 } => {
538 assert_eq!(measurement, "cpu");
539 assert_eq!(
540 time_range,
541 TimeRange {
542 start: 100,
543 end: 200
544 }
545 );
546 }
547 _ => panic!("expected Scan plan"),
548 }
549 }
550
551 #[test]
552 fn build_with_tags_and_fields() {
553 let plan = QueryBuilder::new()
554 .measurement("cpu")
555 .tag("host", "srv1")
556 .tag("dc", "eu")
557 .field("usage_idle")
558 .field("usage_system")
559 .range(100, 200)
560 .build()
561 .unwrap();
562
563 match plan {
564 QueryPlan::Scan {
565 tag_filters,
566 projection,
567 ..
568 } => {
569 assert_eq!(tag_filters.len(), 2);
570 assert_eq!(tag_filters[0].key, "host");
571 assert_eq!(tag_filters[0].value, "srv1");
572 assert_eq!(projection.len(), 2);
573 assert_eq!(projection[0], "usage_idle");
574 }
575 _ => panic!("expected Scan plan"),
576 }
577 }
578
579 #[test]
580 fn build_aggregate() {
581 let plan = QueryBuilder::new()
582 .measurement("cpu")
583 .range(100, 200)
584 .aggregate(AggFn::Avg)
585 .group_by(&["host"])
586 .build()
587 .unwrap();
588
589 assert!(matches!(plan, QueryPlan::Aggregate { .. }));
590 }
591
592 #[test]
593 fn build_downsample() {
594 let plan = QueryBuilder::new()
595 .measurement("cpu")
596 .range(0, 3_600_000_000_000)
597 .downsample(TimeBucket::fixed_ns(60 * 1_000_000_000), AggFn::Avg)
598 .build()
599 .unwrap();
600
601 assert!(matches!(plan, QueryPlan::Downsample { .. }));
602 }
603
604 #[test]
605 fn missing_measurement_fails() {
606 let err = QueryBuilder::new().range(0, 100).build().unwrap_err();
607 assert!(matches!(err, QueryError::Validation(_)));
608 }
609
610 #[test]
611 fn invalid_time_range_fails() {
612 let err = QueryBuilder::new()
613 .measurement("cpu")
614 .range(200, 100) .build()
616 .unwrap_err();
617 assert!(matches!(err, QueryError::InvalidTimeRange { .. }));
618 }
619
620 #[test]
621 fn default_time_range_is_all() {
622 let plan = QueryBuilder::new().measurement("cpu").build().unwrap();
623
624 match plan {
625 QueryPlan::Scan { time_range, .. } => {
626 assert_eq!(time_range.start, i64::MIN);
627 assert_eq!(time_range.end, i64::MAX);
628 }
629 _ => panic!("expected Scan"),
630 }
631 }
632
633 #[test]
634 fn extract_scan_from_aggregate() {
635 let plan = QueryBuilder::new()
636 .measurement("cpu")
637 .range(100, 200)
638 .aggregate(AggFn::Sum)
639 .build()
640 .unwrap();
641
642 let (m, _, _, tr) = extract_scan(&plan).unwrap();
643 assert_eq!(m, "cpu");
644 assert_eq!(tr.start, 100);
645 }
646
647 #[test]
648 fn tags_helper() {
649 let plan = QueryBuilder::new()
650 .measurement("cpu")
651 .tags(&[("host", "srv1"), ("dc", "eu")])
652 .range(0, 100)
653 .build()
654 .unwrap();
655
656 match plan {
657 QueryPlan::Scan { tag_filters, .. } => {
658 assert_eq!(tag_filters.len(), 2);
659 }
660 _ => panic!("expected Scan"),
661 }
662 }
663
664 #[test]
665 fn fields_helper() {
666 let plan = QueryBuilder::new()
667 .measurement("cpu")
668 .fields(&["a", "b", "c"])
669 .range(0, 100)
670 .build()
671 .unwrap();
672
673 match plan {
674 QueryPlan::Scan { projection, .. } => {
675 assert_eq!(projection, vec!["a", "b", "c"]);
676 }
677 _ => panic!("expected Scan"),
678 }
679 }
680
681 #[test]
682 fn combined_downsample_and_aggregate_rejected() {
683 let err = QueryBuilder::new()
684 .measurement("cpu")
685 .range(0, 100)
686 .downsample(TimeBucket::fixed_ns(60 * 1_000_000_000), AggFn::Max)
687 .aggregate(AggFn::Avg)
688 .build()
689 .unwrap_err();
690 assert!(matches!(err, QueryError::Validation(_)));
691 }
692
693 #[test]
694 fn build_with_limit() {
695 let plan = QueryBuilder::new()
696 .measurement("cpu")
697 .range(0, 100)
698 .limit(10)
699 .build()
700 .unwrap();
701
702 match plan {
703 QueryPlan::Limit { limit, offset, .. } => {
704 assert_eq!(limit, 10);
705 assert_eq!(offset, 0);
706 }
707 _ => panic!("expected Limit plan"),
708 }
709 }
710
711 #[test]
712 fn build_with_limit_and_offset() {
713 let plan = QueryBuilder::new()
714 .measurement("cpu")
715 .range(0, 100)
716 .limit(20)
717 .offset(5)
718 .build()
719 .unwrap();
720
721 match plan {
722 QueryPlan::Limit {
723 limit,
724 offset,
725 source,
726 } => {
727 assert_eq!(limit, 20);
728 assert_eq!(offset, 5);
729 assert!(matches!(*source, QueryPlan::Scan { .. }));
730 }
731 _ => panic!("expected Limit plan"),
732 }
733 }
734
735 #[test]
736 fn limit_wraps_aggregate() {
737 let plan = QueryBuilder::new()
738 .measurement("cpu")
739 .range(0, 100)
740 .aggregate(AggFn::Avg)
741 .limit(5)
742 .build()
743 .unwrap();
744
745 match plan {
746 QueryPlan::Limit { source, limit, .. } => {
747 assert_eq!(limit, 5);
748 assert!(matches!(*source, QueryPlan::Aggregate { .. }));
749 }
750 _ => panic!("expected Limit wrapping Aggregate"),
751 }
752 }
753
754 #[test]
755 fn extract_scan_from_limit() {
756 let plan = QueryBuilder::new()
757 .measurement("cpu")
758 .range(100, 200)
759 .limit(10)
760 .build()
761 .unwrap();
762
763 let (m, _, _, tr) = extract_scan(&plan).unwrap();
764 assert_eq!(m, "cpu");
765 assert_eq!(tr.start, 100);
766 }
767
768 #[test]
769 fn offset_without_limit_rejected() {
770 let err = QueryBuilder::new()
772 .measurement("cpu")
773 .range(0, 100)
774 .offset(5)
775 .build()
776 .unwrap_err();
777
778 assert!(
779 matches!(err, QueryError::Validation(ref msg) if msg.contains("offset requires a limit")),
780 "unexpected error: {err:?}",
781 );
782 }
783
784 #[test]
785 fn offset_with_limit_accepted() {
786 let plan = QueryBuilder::new()
787 .measurement("cpu")
788 .range(0, 100)
789 .limit(10)
790 .offset(5)
791 .build()
792 .unwrap();
793
794 assert!(matches!(
795 plan,
796 QueryPlan::Limit {
797 offset: 5,
798 limit: 10,
799 ..
800 }
801 ));
802 }
803
804 #[test]
805 fn namespace_sets_id_and_tag_filter() {
806 let plan = QueryBuilder::new()
807 .measurement("cpu")
808 .range(0, 100)
809 .namespace("tenant-a")
810 .build()
811 .unwrap();
812
813 assert_eq!(extract_namespace(&plan), Some("tenant-a"));
815
816 match &plan {
818 QueryPlan::Scan {
819 tag_filters,
820 namespace_id,
821 ..
822 } => {
823 assert_eq!(namespace_id.as_deref(), Some("tenant-a"));
824 assert!(
825 tag_filters
826 .iter()
827 .any(|f| f.key == "__namespace__" && f.value == "tenant-a")
828 );
829 }
830 _ => panic!("expected Scan"),
831 }
832 }
833
834 #[test]
835 fn namespace_none_by_default() {
836 let plan = QueryBuilder::new()
837 .measurement("cpu")
838 .range(0, 100)
839 .build()
840 .unwrap();
841
842 assert_eq!(extract_namespace(&plan), None);
843 }
844
845 #[test]
846 fn namespace_through_aggregate() {
847 let plan = QueryBuilder::new()
848 .measurement("cpu")
849 .range(0, 100)
850 .namespace("ns-x")
851 .aggregate(AggFn::Sum)
852 .group_by(&["host"])
853 .build()
854 .unwrap();
855
856 assert_eq!(extract_namespace(&plan), Some("ns-x"));
857 }
858}