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::{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/// 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    /// The bucket is a [`TimeBucket`], so this surface means the same thing
80    /// as `time_bucket()` in SQL and as a rollup tier — all three ask the
81    /// same type where a bucket starts and where the next one does. It used
82    /// to take a `Duration`, which cannot be a calendar bucket at all: a day
83    /// in a zone is 23 or 25 hours across a transition and a month is not a
84    /// span, so `1d` here quietly meant 86 400 seconds of UTC while `1d` in
85    /// SQL meant the zone's day.
86    Downsample {
87        /// The underlying scan plan.
88        source: Box<QueryPlan>,
89        /// The bucket, fixed-width or calendar, and the zone it is read in.
90        bucket: TimeBucket,
91        /// Aggregation function per bucket.
92        function: AggFn,
93    },
94    /// Limit the number of returned rows, optionally skipping some.
95    Limit {
96        /// The underlying plan.
97        source: Box<QueryPlan>,
98        /// Maximum number of rows to return.
99        limit: usize,
100        /// Number of rows to skip before returning.
101        offset: usize,
102    },
103    /// Apply window functions that produce one output row per input row.
104    Window {
105        /// The underlying plan.
106        source: Box<QueryPlan>,
107        /// Window functions to compute.
108        functions: Vec<WindowFn>,
109        /// Columns to partition by (each partition is independent).
110        partition_by: Vec<String>,
111        /// The value column to apply window functions to.
112        value_column: String,
113    },
114}
115
116/// Fluent query builder.
117///
118/// # Example
119///
120/// ```ignore
121/// let plan = QueryBuilder::new()
122///     .measurement("cpu")
123///     .tag("host", "server-01")
124///     .field("usage_idle")
125///     .range(start_ts, end_ts)
126///     .build()?;
127/// ```
128#[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    /// Maximum number of series (segments) to scan.
143    /// Namespace scope for tenant isolation.
144    namespace_id: Option<String>,
145    /// Estimated group-key cardinality from segment stats.
146    estimated_cardinality: Option<usize>,
147}
148
149impl QueryBuilder {
150    /// Create a new empty query builder.
151    #[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    /// Set the measurement to query.
172    #[must_use]
173    pub fn measurement(mut self, name: impl Into<String>) -> Self {
174        self.measurement = Some(name.into());
175        self
176    }
177
178    /// Add a tag equality filter.
179    #[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    /// Add multiple tag equality filters.
189    #[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    /// Add a field to the projection (select only these columns).
201    #[must_use]
202    pub fn field(mut self, name: impl Into<String>) -> Self {
203        self.fields.push(name.into());
204        self
205    }
206
207    /// Add multiple fields to the projection.
208    #[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    /// Set the time range filter.
217    #[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    /// Set the time range to the last `duration` from now.
224    ///
225    /// Uses the current system time as the end point.
226    #[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    /// Add an aggregation function.
242    #[must_use]
243    pub fn aggregate(mut self, function: AggFn) -> Self {
244        self.aggregate_fns.push(function);
245        self
246    }
247
248    /// Set the group-by columns for aggregation.
249    #[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    /// Set the estimated group-key cardinality.
256    ///
257    /// When set, the query engine selects between hash-based and sort-based
258    /// aggregation strategies. Low cardinality (≤ 1024) uses sort-based
259    /// grouping for better cache locality and sorted output.
260    #[must_use]
261    pub fn estimated_cardinality(mut self, cardinality: usize) -> Self {
262        self.estimated_cardinality = Some(cardinality);
263        self
264    }
265
266    /// Bucket results by `bucket`, and aggregate each bucket with `function`.
267    ///
268    /// The bucket carries its own calendar, so all three of these mean what
269    /// they say:
270    ///
271    /// ```ignore
272    /// // A fixed five minutes, aligned to the Unix epoch.
273    /// .downsample(TimeBucket::fixed(Duration::from_secs(300)), AggFn::Avg)
274    /// // A calendar day in Berlin — 23 hours on the spring transition.
275    /// .downsample(TimeBucket::parse("1d", Some("Europe/Berlin"))?, AggFn::Max)
276    /// // A calendar month, which is not a span of anything.
277    /// .downsample(TimeBucket::parse("1mo", None)?, AggFn::Sum)
278    /// ```
279    #[must_use]
280    pub fn downsample(mut self, bucket: TimeBucket, function: AggFn) -> Self {
281        self.downsample = Some((bucket, function));
282        self
283    }
284
285    /// Limit the number of returned rows.
286    #[must_use]
287    pub fn limit(mut self, n: usize) -> Self {
288        self.limit = Some(n);
289        self
290    }
291
292    /// Skip the first `n` rows before returning results.
293    ///
294    /// Must be used together with [`limit()`](Self::limit).
295    #[must_use]
296    pub fn offset(mut self, n: usize) -> Self {
297        self.offset = Some(n);
298        self
299    }
300
301    /// Add a window function.
302    #[must_use]
303    pub fn window(mut self, func: WindowFn) -> Self {
304        self.window_fns.push(func);
305        self
306    }
307
308    /// Set the partition-by columns for window functions.
309    #[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    /// Set the value column for window functions.
316    ///
317    /// If not set, the first numeric field column is used by default.
318    #[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    /// Set the namespace scope for tenant isolation.
325    ///
326    /// This embeds the `NamespaceId` structurally in the query plan AND
327    /// injects the `__namespace__` tag filter, ensuring data isolation
328    /// even if the HTTP layer is bypassed.
329    #[must_use]
330    pub fn namespace(mut self, ns: impl Into<String>) -> Self {
331        let ns = ns.into();
332        // Inject the namespace tag filter for data-level filtering.
333        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    /// Apply an optional namespace scope.
342    ///
343    /// `None` means "no tenancy" — the single-tenant server and the embedded
344    /// API, where points carry no namespace tag and a filter on one would
345    /// match nothing. Call sites use this rather than branching, so that
346    /// scoping a read is one uniform call instead of an `if` each of them can
347    /// forget.
348    #[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    /// Build and validate the query plan.
357    ///
358    /// # Errors
359    ///
360    /// Returns [`QueryError::Validation`] if:
361    /// No measurement is specified
362    /// Time range is invalid (start > end)
363    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        // Reject ambiguous combination of downsample + aggregate.
396        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        // Wrap in downsample if requested
403        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        // Wrap in Window if requested
421        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        // Wrap in Limit if requested
436        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/// Extract the scan parameters from a query plan.
457///
458/// Works through `Aggregate` and `Downsample` wrappers to find the
459/// underlying `Scan` plan.
460#[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/// Extract the namespace scope from the underlying `Scan` plan.
478///
479/// Returns `Some(&str)` if the query was built with `.namespace()`,
480/// `None` otherwise. Used for defense-in-depth validation at the
481/// execution layer.
482#[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/// Extract zone-map field predicates from the underlying `Scan` plan.
494#[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
507/// Set zone-map field predicates on the underlying `Scan` plan.
508pub 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) // start > end
615            .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        // offset without limit is ambiguous and must be rejected.
771        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        // Structural namespace_id is set
814        assert_eq!(extract_namespace(&plan), Some("tenant-a"));
815
816        // __namespace__ tag filter is injected
817        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}