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    Downsample {
79        /// The underlying scan plan.
80        source: Box<QueryPlan>,
81        /// Bucket interval.
82        interval: Duration,
83        /// Aggregation function per bucket.
84        function: AggFn,
85    },
86    /// Limit the number of returned rows, optionally skipping some.
87    Limit {
88        /// The underlying plan.
89        source: Box<QueryPlan>,
90        /// Maximum number of rows to return.
91        limit: usize,
92        /// Number of rows to skip before returning.
93        offset: usize,
94    },
95    /// Apply window functions that produce one output row per input row.
96    Window {
97        /// The underlying plan.
98        source: Box<QueryPlan>,
99        /// Window functions to compute.
100        functions: Vec<WindowFn>,
101        /// Columns to partition by (each partition is independent).
102        partition_by: Vec<String>,
103        /// The value column to apply window functions to.
104        value_column: String,
105    },
106}
107
108/// Fluent query builder.
109///
110/// # Example
111///
112/// ```ignore
113/// let plan = QueryBuilder::new()
114///     .measurement("cpu")
115///     .tag("host", "server-01")
116///     .field("usage_idle")
117///     .range(start_ts, end_ts)
118///     .build()?;
119/// ```
120#[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    /// Maximum number of series (segments) to scan.
135    /// Namespace scope for tenant isolation.
136    namespace_id: Option<String>,
137    /// Estimated group-key cardinality from segment stats.
138    estimated_cardinality: Option<usize>,
139}
140
141impl QueryBuilder {
142    /// Create a new empty query builder.
143    #[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    /// Set the measurement to query.
164    #[must_use]
165    pub fn measurement(mut self, name: impl Into<String>) -> Self {
166        self.measurement = Some(name.into());
167        self
168    }
169
170    /// Add a tag equality filter.
171    #[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    /// Add multiple tag equality filters.
181    #[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    /// Add a field to the projection (select only these columns).
193    #[must_use]
194    pub fn field(mut self, name: impl Into<String>) -> Self {
195        self.fields.push(name.into());
196        self
197    }
198
199    /// Add multiple fields to the projection.
200    #[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    /// Set the time range filter.
209    #[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    /// Set the time range to the last `duration` from now.
216    ///
217    /// Uses the current system time as the end point.
218    #[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    /// Add an aggregation function.
234    #[must_use]
235    pub fn aggregate(mut self, function: AggFn) -> Self {
236        self.aggregate_fns.push(function);
237        self
238    }
239
240    /// Set the group-by columns for aggregation.
241    #[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    /// Set the estimated group-key cardinality.
248    ///
249    /// When set, the query engine selects between hash-based and sort-based
250    /// aggregation strategies. Low cardinality (≤ 1024) uses sort-based
251    /// grouping for better cache locality and sorted output.
252    #[must_use]
253    pub fn estimated_cardinality(mut self, cardinality: usize) -> Self {
254        self.estimated_cardinality = Some(cardinality);
255        self
256    }
257
258    /// Set downsampling parameters.
259    #[must_use]
260    pub fn downsample(mut self, interval: Duration, function: AggFn) -> Self {
261        self.downsample = Some((interval, function));
262        self
263    }
264
265    /// Limit the number of returned rows.
266    #[must_use]
267    pub fn limit(mut self, n: usize) -> Self {
268        self.limit = Some(n);
269        self
270    }
271
272    /// Skip the first `n` rows before returning results.
273    ///
274    /// Must be used together with [`limit()`](Self::limit).
275    #[must_use]
276    pub fn offset(mut self, n: usize) -> Self {
277        self.offset = Some(n);
278        self
279    }
280
281    /// Add a window function.
282    #[must_use]
283    pub fn window(mut self, func: WindowFn) -> Self {
284        self.window_fns.push(func);
285        self
286    }
287
288    /// Set the partition-by columns for window functions.
289    #[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    /// Set the value column for window functions.
296    ///
297    /// If not set, the first numeric field column is used by default.
298    #[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    /// Set the namespace scope for tenant isolation.
305    ///
306    /// This embeds the `NamespaceId` structurally in the query plan AND
307    /// injects the `__namespace__` tag filter, ensuring data isolation
308    /// even if the HTTP layer is bypassed.
309    #[must_use]
310    pub fn namespace(mut self, ns: impl Into<String>) -> Self {
311        let ns = ns.into();
312        // Inject the namespace tag filter for data-level filtering.
313        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    /// Apply an optional namespace scope.
322    ///
323    /// `None` means "no tenancy" — the single-tenant server and the embedded
324    /// API, where points carry no namespace tag and a filter on one would
325    /// match nothing. Call sites use this rather than branching, so that
326    /// scoping a read is one uniform call instead of an `if` each of them can
327    /// forget.
328    #[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    /// Build and validate the query plan.
337    ///
338    /// # Errors
339    ///
340    /// Returns [`QueryError::Validation`] if:
341    /// No measurement is specified
342    /// Time range is invalid (start > end)
343    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        // Reject ambiguous combination of downsample + aggregate.
376        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        // Wrap in downsample if requested
383        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        // Wrap in Window if requested
401        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        // Wrap in Limit if requested
416        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/// Extract the scan parameters from a query plan.
437///
438/// Works through `Aggregate` and `Downsample` wrappers to find the
439/// underlying `Scan` plan.
440#[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/// Extract the namespace scope from the underlying `Scan` plan.
458///
459/// Returns `Some(&str)` if the query was built with `.namespace()`,
460/// `None` otherwise. Used for defense-in-depth validation at the
461/// execution layer.
462#[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/// Extract zone-map field predicates from the underlying `Scan` plan.
474#[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
487/// Set zone-map field predicates on the underlying `Scan` plan.
488pub 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) // start > end
595            .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        // offset without limit is ambiguous and must be rejected.
751        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        // Structural namespace_id is set
794        assert_eq!(extract_namespace(&plan), Some("tenant-a"));
795
796        // __namespace__ tag filter is injected
797        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}