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