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