Skip to main content

nmbrs_metrics/queryapi/
shapes.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Query-result and selector shapes for the metrics query API
5//! ([`crate::queryapi`]).
6//!
7//! These are the **native result shapes of the metrics access
8//! library** (SRD-86 §"The metric-reader surface"): a query reads a
9//! [`Vector`] — multiple [`Series`], each with one or more [`Sample`]
10//! points. The MetricsQL engine evaluates *over* these shapes; it owns
11//! no result types of its own. Labels are carried as ordered
12//! `(key, value)` pairs (the canonical query-result representation that
13//! aggregation / `without` / binary-op label matching manipulate),
14//! with `__name__` (the metric family name) carried as a label per
15//! PromQL convention.
16
17/// One observation: a value at a point in time (Unix epoch ms).
18#[derive(Debug, Clone, Copy, PartialEq)]
19pub struct Sample {
20    pub timestamp_ms: i64,
21    pub value: f64,
22}
23
24/// One time series: an identifying label set plus its observed
25/// samples (ascending by timestamp). `__name__` lives in `labels`.
26#[derive(Debug, Clone, PartialEq)]
27pub struct Series {
28    pub labels: Vec<(String, String)>,
29    pub samples: Vec<Sample>,
30}
31
32/// A vector result: zero or more series, each with one or more sample
33/// points. The *content* distinguishes the MetricsQL result shapes —
34///
35/// - **instant vector** — one sample per series (a value at an instant);
36/// - **range vector** — many samples per series (a window of history);
37/// - **scalar** — a single label-less series with one sample.
38///
39/// The shape an accessor promises is asserted by the metricsql
40/// projector; the access library returns this one general container.
41#[derive(Debug, Clone, Default, PartialEq)]
42pub struct Vector(pub Vec<Series>);
43
44impl Vector {
45    /// Wrap a series list.
46    pub fn new(series: Vec<Series>) -> Self {
47        Self(series)
48    }
49    /// The contained series.
50    pub fn series(&self) -> &[Series] {
51        &self.0
52    }
53    /// Consume into the series list.
54    pub fn into_series(self) -> Vec<Series> {
55        self.0
56    }
57    pub fn len(&self) -> usize {
58        self.0.len()
59    }
60    pub fn is_empty(&self) -> bool {
61        self.0.is_empty()
62    }
63}
64
65impl From<Vec<Series>> for Vector {
66    fn from(series: Vec<Series>) -> Self {
67        Self(series)
68    }
69}
70
71/// Deref to the series slice so a `Vector` reads like the `&[Series]`
72/// it wraps (`.len()`, indexing, `.iter()`), without exposing mutation.
73impl std::ops::Deref for Vector {
74    type Target = [Series];
75    fn deref(&self) -> &[Series] {
76        &self.0
77    }
78}
79
80impl IntoIterator for Vector {
81    type Item = Series;
82    type IntoIter = std::vec::IntoIter<Series>;
83    fn into_iter(self) -> Self::IntoIter {
84        self.0.into_iter()
85    }
86}
87
88impl FromIterator<Series> for Vector {
89    fn from_iter<I: IntoIterator<Item = Series>>(iter: I) -> Self {
90        Self(iter.into_iter().collect())
91    }
92}
93
94/// How a [`Matcher`] compares a label's value. Mirrors the four
95/// MetricsQL label-filter operators.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum MatchOp {
98    /// `key="v"`
99    Eq,
100    /// `key!="v"`
101    Ne,
102    /// `key=~"re"` — anchored full-value regex.
103    EqRegex,
104    /// `key!~"re"` — negated anchored full-value regex.
105    NeRegex,
106}
107
108/// A single label matcher in a selector — one MetricsQL label filter.
109#[derive(Debug, Clone, PartialEq)]
110pub struct Matcher {
111    pub label: String,
112    pub op: MatchOp,
113    pub value: String,
114}
115
116impl Matcher {
117    /// `label="value"`.
118    pub fn eq(label: impl Into<String>, value: impl Into<String>) -> Self {
119        Self {
120            label: label.into(),
121            op: MatchOp::Eq,
122            value: value.into(),
123        }
124    }
125    /// `label!="value"`.
126    pub fn ne(label: impl Into<String>, value: impl Into<String>) -> Self {
127        Self {
128            label: label.into(),
129            op: MatchOp::Ne,
130            value: value.into(),
131        }
132    }
133    /// `label=~"pattern"`.
134    pub fn eq_regex(label: impl Into<String>, value: impl Into<String>) -> Self {
135        Self {
136            label: label.into(),
137            op: MatchOp::EqRegex,
138            value: value.into(),
139        }
140    }
141
142    /// Test this matcher against a series label set. A missing label
143    /// reads as the empty string; regex ops anchor the pattern to the
144    /// full value (`^(?:pat)$`); an uncompilable pattern fails closed
145    /// (`EqRegex` → no match) and open (`NeRegex` → match).
146    pub fn matches(&self, labels: &[(String, String)]) -> bool {
147        let v = labels
148            .iter()
149            .find(|(k, _)| k == &self.label)
150            .map(|(_, v)| v.as_str())
151            .unwrap_or("");
152        match self.op {
153            MatchOp::Eq => v == self.value,
154            MatchOp::Ne => v != self.value,
155            MatchOp::EqRegex => regex_full_match(&self.value, v).unwrap_or(false),
156            MatchOp::NeRegex => !regex_full_match(&self.value, v).unwrap_or(true),
157        }
158    }
159}
160
161/// Anchored full-value regex match (`^(?:pat)$`). `None` on an
162/// uncompilable pattern.
163fn regex_full_match(pattern: &str, value: &str) -> Option<bool> {
164    let anchored = format!("^(?:{pattern})$");
165    regex::Regex::new(&anchored)
166        .ok()
167        .map(|re| re.is_match(value))
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    fn labels(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
175        pairs
176            .iter()
177            .map(|(k, v)| (k.to_string(), v.to_string()))
178            .collect()
179    }
180
181    #[test]
182    fn matcher_covers_all_four_ops() {
183        let ls = labels(&[("__name__", "errors_total"), ("phase", "saturate")]);
184        assert!(Matcher::eq("phase", "saturate").matches(&ls));
185        assert!(!Matcher::eq("phase", "rampup").matches(&ls));
186        assert!(Matcher::ne("phase", "rampup").matches(&ls));
187        assert!(!Matcher::ne("phase", "saturate").matches(&ls));
188        assert!(Matcher::eq_regex("phase", "sat.*").matches(&ls));
189        assert!(!Matcher::eq_regex("phase", "ramp.*").matches(&ls));
190        let ne_re = Matcher {
191            label: "phase".into(),
192            op: MatchOp::NeRegex,
193            value: "ramp.*".into(),
194        };
195        assert!(ne_re.matches(&ls));
196    }
197
198    #[test]
199    fn missing_label_is_empty_and_regex_is_anchored() {
200        let ls = labels(&[("__name__", "errors_total")]);
201        assert!(Matcher::eq("phase", "").matches(&ls));
202        assert!(!Matcher::eq("phase", "saturate").matches(&ls));
203        let ls2 = labels(&[("phase", "saturate")]);
204        // Anchored: "sat" must not match the full value "saturate".
205        assert!(!Matcher::eq_regex("phase", "sat").matches(&ls2));
206        assert!(Matcher::eq_regex("phase", "saturate").matches(&ls2));
207    }
208}