promql/functions/
vector_matching.rs1use datafusion::arrow::array::{Array, Int64Array};
18use datafusion::arrow::util::display::array_value_to_string;
19use datafusion::common::{DataFusionError, Result as DfResult};
20use datafusion::logical_expr::{ScalarUDF, Volatility};
21use datafusion::physical_plan::ColumnarValue;
22use datafusion_common::ScalarValue;
23use datafusion_expr::{ScalarFunctionArgs, ScalarUDFImpl, Signature};
24use datatypes::arrow::datatypes::DataType;
25
26use crate::functions::extract_array;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
30pub enum MatchGroupViolation {
31 DuplicateOnOneSide { one_side_is_left: bool },
33 ImplicitManyToOne,
35 AmbiguousGroupLabels,
37 DuplicateLabelSet,
39}
40
41impl MatchGroupViolation {
42 fn message(&self, group: &str) -> String {
43 match self {
44 Self::DuplicateOnOneSide { one_side_is_left } => {
45 let side = if *one_side_is_left { "left" } else { "right" };
46 format!(
47 "found duplicate series for the match group {group} on the {side} hand-side \
48 of the operation; many-to-many matching not allowed: matching labels must \
49 be unique on one side"
50 )
51 }
52 Self::ImplicitManyToOne => format!(
53 "multiple matches for labels {group}: many-to-one matching must be explicit \
54 (group_left/group_right)"
55 ),
56 Self::AmbiguousGroupLabels => format!(
57 "multiple matches for labels {group}: grouping labels must ensure unique matches"
58 ),
59 Self::DuplicateLabelSet => {
60 "vector cannot contain metrics with the same labelset".to_string()
61 }
62 }
63 }
64}
65
66pub struct UniqueMatchGroup;
72
73impl UniqueMatchGroup {
74 pub const fn name() -> &'static str {
75 "prom_assert_unique_match_group"
76 }
77
78 pub fn scalar_udf(labels: Vec<String>, violation: MatchGroupViolation) -> ScalarUDF {
79 ScalarUDF::new_from_impl(AssertUniqueMatchGroup {
80 signature: Signature::variadic_any(Volatility::Volatile),
81 labels,
82 violation,
83 })
84 }
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Hash)]
88struct AssertUniqueMatchGroup {
89 signature: Signature,
90 labels: Vec<String>,
92 violation: MatchGroupViolation,
93}
94
95impl AssertUniqueMatchGroup {
96 fn render_group(&self, args: &[ColumnarValue], row: usize) -> DfResult<String> {
97 let mut rendered = Vec::with_capacity(self.labels.len());
98 for (label, arg) in self.labels.iter().zip(args) {
99 let array = extract_array(arg)?;
100 let row = if array.len() == 1 { 0 } else { row };
102 if row >= array.len() || array.is_null(row) {
103 continue;
104 }
105 let value = array_value_to_string(&array, row)
106 .map_err(|e| DataFusionError::ArrowError(Box::new(e), None))?;
107 if value.is_empty() {
108 continue;
109 }
110 rendered.push(format!("{label}=\"{value}\""));
111 }
112 Ok(format!("{{{}}}", rendered.join(", ")))
113 }
114}
115
116impl ScalarUDFImpl for AssertUniqueMatchGroup {
117 fn name(&self) -> &str {
118 UniqueMatchGroup::name()
119 }
120
121 fn signature(&self) -> &Signature {
122 &self.signature
123 }
124
125 fn return_type(&self, _arg_types: &[DataType]) -> DfResult<DataType> {
126 Ok(DataType::Boolean)
127 }
128
129 fn invoke_with_args(&self, args: ScalarFunctionArgs) -> DfResult<ColumnarValue> {
130 let Some((counts, labels)) = args.args.split_first() else {
131 return Err(DataFusionError::Execution(format!(
132 "{} expects the match group row count as first argument",
133 UniqueMatchGroup::name()
134 )));
135 };
136 let counts = extract_array(counts)?;
137 let counts = counts
138 .as_any()
139 .downcast_ref::<Int64Array>()
140 .ok_or_else(|| {
141 DataFusionError::Execution(format!(
142 "{} expects an Int64 match group row count, found {}",
143 UniqueMatchGroup::name(),
144 counts.data_type()
145 ))
146 })?;
147
148 if let Some(row) =
149 (0..counts.len()).find(|row| !counts.is_null(*row) && counts.value(*row) > 1)
150 {
151 let group = self.render_group(labels, row)?;
152 return Err(DataFusionError::Execution(self.violation.message(&group)));
153 }
154
155 Ok(ColumnarValue::Scalar(ScalarValue::Boolean(Some(true))))
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 use std::sync::Arc;
162
163 use datafusion::arrow::array::StringArray;
164 use datafusion::arrow::datatypes::Field;
165
166 use super::*;
167
168 fn invoke(counts: Vec<i64>, hosts: Vec<Option<&str>>) -> DfResult<ColumnarValue> {
169 let udf = UniqueMatchGroup::scalar_udf(
170 vec!["host".to_string()],
171 MatchGroupViolation::ImplicitManyToOne,
172 );
173 let number_rows = counts.len();
174 udf.invoke_with_args(ScalarFunctionArgs {
175 args: vec![
176 ColumnarValue::Array(Arc::new(Int64Array::from(counts))),
177 ColumnarValue::Array(Arc::new(StringArray::from(hosts))),
178 ],
179 arg_fields: vec![
180 Arc::new(Field::new("count", DataType::Int64, true)),
181 Arc::new(Field::new("host", DataType::Utf8, true)),
182 ],
183 number_rows,
184 return_field: Arc::new(Field::new("assert", DataType::Boolean, false)),
185 config_options: Arc::new(Default::default()),
186 })
187 }
188
189 #[test]
190 fn unique_groups_pass() {
191 let result = invoke(vec![1, 1], vec![Some("a"), Some("b")]).unwrap();
192 assert!(matches!(
193 result,
194 ColumnarValue::Scalar(ScalarValue::Boolean(Some(true)))
195 ));
196 }
197
198 #[test]
199 fn duplicate_group_reports_its_labels() {
200 let err = invoke(vec![1, 2], vec![Some("a"), Some("b")]).unwrap_err();
201 assert!(
202 err.to_string()
203 .contains("multiple matches for labels {host=\"b\"}"),
204 "{err}"
205 );
206 }
207
208 #[test]
209 fn null_label_is_omitted_from_the_group() {
210 let err = invoke(vec![2], vec![None]).unwrap_err();
211 assert!(err.to_string().contains("labels {}"), "{err}");
212 }
213}