Skip to main content

chronix_query/
plan.rs

1//! Query planning and fluent builder API.
2//!
3//! The [`QueryBuilder`] provides an ergonomic fluent API for constructing
4//! queries. Queries are validated at build time and compiled into a
5//! [`QueryPlan`] for execution.
6
7use 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/// A time range for query filtering.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct TimeRange {
20    /// Start timestamp (inclusive).
21    pub start: Timestamp,
22    /// End timestamp (inclusive).
23    pub end: Timestamp,
24}
25
26/// A tag filter predicate.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct TagFilter {
29    /// Tag key to filter on.
30    pub key: String,
31    /// Tag value to match (equality).
32    pub value: String,
33}
34
35/// A logical query plan.
36///
37/// Describes what data to read and how to process it, without specifying
38/// the physical execution strategy.
39#[derive(Debug, Clone)]
40pub enum QueryPlan {
41    /// Scan a measurement with optional filters and projection.
42    Scan {
43        /// Measurement name to query.
44        measurement: String,
45        /// Tag equality filters.
46        tag_filters: Vec<TagFilter>,
47        /// Columns to return (empty = all fields).
48        projection: Vec<String>,
49        /// Time range filter.
50        time_range: TimeRange,
51        /// Zone-map field predicates for late-materialisation row-group
52        /// pruning.  Each predicate compares a numeric column against a
53        /// threshold; row groups whose per-block min/max stats rule out
54        /// any match are skipped entirely.
55        field_predicates: Vec<FieldPredicate>,
56        /// Structural namespace scope.
57        ///
58        /// When set, the query engine can validate that every scanned
59        /// segment belongs to this namespace — providing defense-in-depth
60        /// even if the tag filter injection is bypassed.
61        namespace_id: Option<String>,
62    },
63    /// Aggregate results from a scan.
64    Aggregate {
65        /// The underlying scan plan.
66        source: Box<QueryPlan>,
67        /// Aggregation functions to apply.
68        functions: Vec<AggFn>,
69        /// Columns to group by.
70        group_by: Vec<String>,
71        /// Estimated group-key cardinality from segment
72        /// column statistics. When available, the engine chooses hash
73        /// aggregation for high cardinality and sort-based grouping for
74        /// low cardinality. `None` means unknown (hash is the default).
75        estimated_cardinality: Option<usize>,
76    },
77    /// Downsample results into time buckets.
78    ///
79    /// **Fixed-width buckets only.** A `Duration` is a span of nanoseconds,
80    /// so this cannot express a calendar bucket — a day in a time zone is 23
81    /// or 25 hours across a transition, and a month is not a span at all. The
82    /// type says so rather than pretending: the two surfaces that *do* offer
83    /// calendar buckets are `time_bucket()` in SQL and a rollup tier, and both
84    /// go through `chronix::timebucket::TimeBucket`. Threading that through
85    /// here would put the tz database inside the query engine for a surface
86    /// nobody has asked it of.
87    Downsample {
88        /// The underlying scan plan.
89        source: Box<QueryPlan>,
90        /// Bucket interval — a fixed span, aligned to the Unix epoch in UTC.
91        interval: Duration,
92        /// Aggregation function per bucket.
93        function: AggFn,
94    },
95    /// Limit the number of returned rows, optionally skipping some.
96    Limit {
97        /// The underlying plan.
98        source: Box<QueryPlan>,
99        /// Maximum number of rows to return.
100        limit: usize,
101        /// Number of rows to skip before returning.
102        offset: usize,
103    },
104    /// Apply window functions that produce one output row per input row.
105    Window {
106        /// The underlying plan.
107        source: Box<QueryPlan>,
108        /// Window functions to compute.
109        functions: Vec<WindowFn>,
110        /// Columns to partition by (each partition is independent).
111        partition_by: Vec<String>,
112        /// The value column to apply window functions to.
113        value_column: String,
114    },
115}
116
117/// Fluent query builder.
118///
119/// # Example
120///
121/// ```ignore
122/// let plan = QueryBuilder::new()
123///     .measurement("cpu")
124///     .tag("host", "server-01")
125///     .field("usage_idle")
126///     .range(start_ts, end_ts)
127///     .build()?;
128/// ```
129#[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    /// Maximum number of series (segments) to scan.
144    /// Namespace scope for tenant isolation.
145    namespace_id: Option<String>,
146    /// Estimated group-key cardinality from segment stats.
147    estimated_cardinality: Option<usize>,
148}
149
150impl QueryBuilder {
151    /// Create a new empty query builder.
152    #[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    /// Set the measurement to query.
173    #[must_use]
174    pub fn measurement(mut self, name: impl Into<String>) -> Self {
175        self.measurement = Some(name.into());
176        self
177    }
178
179    /// Add a tag equality filter.
180    #[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    /// Add multiple tag equality filters.
190    #[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    /// Add a field to the projection (select only these columns).
202    #[must_use]
203    pub fn field(mut self, name: impl Into<String>) -> Self {
204        self.fields.push(name.into());
205        self
206    }
207
208    /// Add multiple fields to the projection.
209    #[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    /// Set the time range filter.
218    #[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    /// Set the time range to the last `duration` from now.
225    ///
226    /// Uses the current system time as the end point.
227    #[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    /// Add an aggregation function.
243    #[must_use]
244    pub fn aggregate(mut self, function: AggFn) -> Self {
245        self.aggregate_fns.push(function);
246        self
247    }
248
249    /// Set the group-by columns for aggregation.
250    #[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    /// Set the estimated group-key cardinality.
257    ///
258    /// When set, the query engine selects between hash-based and sort-based
259    /// aggregation strategies. Low cardinality (≤ 1024) uses sort-based
260    /// grouping for better cache locality and sorted output.
261    #[must_use]
262    pub fn estimated_cardinality(mut self, cardinality: usize) -> Self {
263        self.estimated_cardinality = Some(cardinality);
264        self
265    }
266
267    /// Set downsampling parameters.
268    #[must_use]
269    /// Bucket results into fixed spans of `interval`, aligned to the Unix
270    /// epoch in UTC.
271    ///
272    /// For a bucket that follows a **calendar** — local midnight to local
273    /// midnight, or a calendar month — use `time_bucket()` in SQL or declare
274    /// a rollup tier; see `chronix::timebucket`. A `Duration` cannot be
275    /// either, and this API does not pretend it can.
276    pub fn downsample(mut self, interval: Duration, function: AggFn) -> Self {
277        self.downsample = Some((interval, function));
278        self
279    }
280
281    /// Limit the number of returned rows.
282    #[must_use]
283    pub fn limit(mut self, n: usize) -> Self {
284        self.limit = Some(n);
285        self
286    }
287
288    /// Skip the first `n` rows before returning results.
289    ///
290    /// Must be used together with [`limit()`](Self::limit).
291    #[must_use]
292    pub fn offset(mut self, n: usize) -> Self {
293        self.offset = Some(n);
294        self
295    }
296
297    /// Add a window function.
298    #[must_use]
299    pub fn window(mut self, func: WindowFn) -> Self {
300        self.window_fns.push(func);
301        self
302    }
303
304    /// Set the partition-by columns for window functions.
305    #[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    /// Set the value column for window functions.
312    ///
313    /// If not set, the first numeric field column is used by default.
314    #[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    /// Set the namespace scope for tenant isolation.
321    ///
322    /// This embeds the `NamespaceId` structurally in the query plan AND
323    /// injects the `__namespace__` tag filter, ensuring data isolation
324    /// even if the HTTP layer is bypassed.
325    #[must_use]
326    pub fn namespace(mut self, ns: impl Into<String>) -> Self {
327        let ns = ns.into();
328        // Inject the namespace tag filter for data-level filtering.
329        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    /// Apply an optional namespace scope.
338    ///
339    /// `None` means "no tenancy" — the single-tenant server and the embedded
340    /// API, where points carry no namespace tag and a filter on one would
341    /// match nothing. Call sites use this rather than branching, so that
342    /// scoping a read is one uniform call instead of an `if` each of them can
343    /// forget.
344    #[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    /// Build and validate the query plan.
353    ///
354    /// # Errors
355    ///
356    /// Returns [`QueryError::Validation`] if:
357    /// No measurement is specified
358    /// Time range is invalid (start > end)
359    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        // Reject ambiguous combination of downsample + aggregate.
392        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        // Wrap in downsample if requested
399        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        // Wrap in Window if requested
417        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        // Wrap in Limit if requested
432        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/// Extract the scan parameters from a query plan.
453///
454/// Works through `Aggregate` and `Downsample` wrappers to find the
455/// underlying `Scan` plan.
456#[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/// Extract the namespace scope from the underlying `Scan` plan.
474///
475/// Returns `Some(&str)` if the query was built with `.namespace()`,
476/// `None` otherwise. Used for defense-in-depth validation at the
477/// execution layer.
478#[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/// Extract zone-map field predicates from the underlying `Scan` plan.
490#[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
503/// Set zone-map field predicates on the underlying `Scan` plan.
504pub 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) // start > end
611            .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        // offset without limit is ambiguous and must be rejected.
767        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        // Structural namespace_id is set
810        assert_eq!(extract_namespace(&plan), Some("tenant-a"));
811
812        // __namespace__ tag filter is injected
813        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}