qubit_metadata/filter/condition.rs
1// =============================================================================
2// Copyright (c) 2025 - 2026 Haixing Hu.
3//
4// SPDX-License-Identifier: Apache-2.0
5//
6// Licensed under the Apache License, Version 2.0.
7// =============================================================================
8//! A single comparison predicate against one metadata key.
9
10use std::cmp::Ordering;
11use std::fmt;
12
13use qubit_datatype::NumericComparisonPolicy;
14use qubit_redact::Redact;
15use qubit_redact::RedactionWriter;
16use qubit_redact::Redactor;
17use qubit_redact::Sensitivity;
18use qubit_value::Value;
19use qubit_value::ValueRef;
20use qubit_value::ValueWirePayloadRefV1;
21
22use super::internal::MatchOutcome;
23use crate::FilterLimitKind;
24use crate::FilterLimits;
25use crate::Metadata;
26use crate::MetadataError;
27use crate::MetadataResult;
28
29/// A single comparison operator applied to one metadata key.
30///
31/// Equality, range, and membership conditions require a concrete stored value.
32/// An absent key or [`Value::Unset`] produces an unknown outcome. Comparisons
33/// of concrete values can also be unknown when their types or operations are
34/// incompatible or a numeric comparison fails; compatible numeric types follow
35/// the configured [`NumericComparisonPolicy`], and values are not implicitly
36/// converted. Unknown remains unknown under logical negation, so `ne`, negated
37/// comparisons, and `not_in_set` cannot bypass an incomparable type.
38/// Existence conditions define presence in terms of a concrete value and return
39/// definite Boolean outcomes. Membership conditions compare candidates
40/// individually and prefer a definite result over an unknown candidate.
41///
42/// # Examples
43///
44/// ```
45/// use qubit_metadata::Condition;
46///
47/// let condition = Condition::Equal {
48/// key: "tenant".to_owned(),
49/// value: "acme".into(),
50/// };
51/// assert!(matches!(condition, Condition::Equal { .. }));
52/// ```
53#[derive(Clone, PartialEq)]
54#[must_use = "the constructed filter condition should be used"]
55#[non_exhaustive]
56pub enum Condition {
57 /// Key equals value.
58 Equal {
59 /// The metadata key.
60 key: String,
61 /// The expected value.
62 value: Value,
63 },
64 /// Key does not equal value.
65 NotEqual {
66 /// The metadata key.
67 key: String,
68 /// The value to compare against.
69 value: Value,
70 },
71 /// Key is less than value.
72 Less {
73 /// The metadata key.
74 key: String,
75 /// The upper bound (exclusive).
76 value: Value,
77 },
78 /// Key is less than or equal to value.
79 LessEqual {
80 /// The metadata key.
81 key: String,
82 /// The upper bound (inclusive).
83 value: Value,
84 },
85 /// Key is greater than value.
86 Greater {
87 /// The metadata key.
88 key: String,
89 /// The lower bound (exclusive).
90 value: Value,
91 },
92 /// Key is greater than or equal to value.
93 GreaterEqual {
94 /// The metadata key.
95 key: String,
96 /// The lower bound (inclusive).
97 value: Value,
98 },
99 /// The stored value is one of the listed candidates.
100 In {
101 /// The metadata key.
102 key: String,
103 /// The set of acceptable values.
104 values: Vec<Value>,
105 },
106 /// The stored value is not any of the listed candidates.
107 NotIn {
108 /// The metadata key.
109 key: String,
110 /// The set of excluded values.
111 values: Vec<Value>,
112 },
113 /// The key stores a concrete value.
114 ///
115 /// An absent key or a key storing [`Value::Unset`] does not exist for
116 /// filter matching.
117 Exists {
118 /// The metadata key.
119 key: String,
120 },
121 /// The key does not store a concrete value.
122 ///
123 /// An absent key or a key storing [`Value::Unset`] satisfies this
124 /// condition.
125 NotExists {
126 /// The metadata key.
127 key: String,
128 },
129}
130
131impl Condition {
132 /// Visits each value operand in wire order without allocating.
133 ///
134 /// # Type Parameters
135 ///
136 /// * `E` - Error type returned by the visitor callback.
137 ///
138 /// # Parameters
139 ///
140 /// * `visitor` - Callback invoked for each operand in wire order.
141 ///
142 /// # Errors
143 ///
144 /// Returns the first error produced by `visitor` and stops visiting.
145 #[cfg(feature = "json")]
146 pub(crate) fn visit_operands<E>(&self, visitor: &mut impl FnMut(&Value) -> Result<(), E>) -> Result<(), E> {
147 match self {
148 Self::Equal { value, .. }
149 | Self::NotEqual { value, .. }
150 | Self::Less { value, .. }
151 | Self::LessEqual { value, .. }
152 | Self::Greater { value, .. }
153 | Self::GreaterEqual { value, .. } => visitor(value),
154 Self::In { values, .. } | Self::NotIn { values, .. } => {
155 for value in values {
156 visitor(value)?;
157 }
158 Ok(())
159 }
160 Self::Exists { .. } | Self::NotExists { .. } => Ok(()),
161 }
162 }
163
164 /// Validates that every comparison operand has stable matching and wire
165 /// serialization semantics.
166 ///
167 /// # Errors
168 ///
169 /// Returns [`MetadataError::InvalidFilterOperand`] when an operand is
170 /// unset or cannot be represented by the V1 wire format.
171 pub(crate) fn validate_operands(&self) -> MetadataResult<()> {
172 match self {
173 Self::Equal { value, .. } => validate_operand("eq", value),
174 Self::NotEqual { value, .. } => validate_operand("ne", value),
175 Self::Less { value, .. } => validate_operand("lt", value),
176 Self::LessEqual { value, .. } => validate_operand("le", value),
177 Self::Greater { value, .. } => validate_operand("gt", value),
178 Self::GreaterEqual { value, .. } => validate_operand("ge", value),
179 Self::In { values, .. } => validate_operands("in_set", values),
180 Self::NotIn { values, .. } => validate_operands("not_in_set", values),
181 Self::Exists { .. } | Self::NotExists { .. } => Ok(()),
182 }
183 }
184
185 /// Validates this condition against resource limits.
186 ///
187 /// # Parameters
188 ///
189 /// * `limits` - Bounds to enforce.
190 ///
191 /// # Returns
192 ///
193 /// `Ok(())` when the key and membership values fit within `limits`.
194 ///
195 /// # Errors
196 ///
197 /// Returns [`MetadataError::FilterLimitExceeded`] when a key or membership
198 /// condition exceeds its configured bound.
199 pub(crate) fn validate_limits(&self, limits: FilterLimits) -> MetadataResult<()> {
200 let key = self.key();
201 if key.len() > limits.max_key_bytes() {
202 return Err(MetadataError::FilterLimitExceeded {
203 kind: FilterLimitKind::KeyBytes,
204 value: key.len(),
205 maximum: limits.max_key_bytes(),
206 });
207 }
208 let values = match self {
209 Self::In { values, .. } | Self::NotIn { values, .. } => values,
210 _ => return Ok(()),
211 };
212 if values.len() > limits.max_set_values() {
213 return Err(MetadataError::FilterLimitExceeded {
214 kind: FilterLimitKind::SetValues,
215 value: values.len(),
216 maximum: limits.max_set_values(),
217 });
218 }
219 Ok(())
220 }
221
222 /// Evaluates this condition against the supplied metadata.
223 ///
224 /// # Parameters
225 ///
226 /// * `meta` - Metadata object being matched.
227 /// * `numeric_comparison_policy` - Policy for mixed numeric comparisons.
228 ///
229 /// # Returns
230 ///
231 /// A definite outcome for concrete comparisons and existence predicates,
232 /// or [`MatchOutcome::Unknown`] when a comparison depends on an absent or
233 /// unset value.
234 pub(crate) fn evaluate(&self, meta: &Metadata, numeric_comparison_policy: NumericComparisonPolicy) -> MatchOutcome {
235 match self {
236 Condition::Equal { key, value } => evaluate_concrete(meta, key, |stored| {
237 values_equal(stored, value, numeric_comparison_policy)
238 .map_or(MatchOutcome::Unknown, MatchOutcome::from_bool)
239 }),
240 Condition::NotEqual { key, value } => evaluate_concrete(meta, key, |stored| {
241 values_equal(stored, value, numeric_comparison_policy)
242 .map_or(MatchOutcome::Unknown, |equal| MatchOutcome::from_bool(!equal))
243 }),
244 Condition::Less { key, value } => evaluate_concrete(meta, key, |stored| {
245 compare_values(stored, value, numeric_comparison_policy).map_or(MatchOutcome::Unknown, |ordering| {
246 MatchOutcome::from_bool(ordering == Ordering::Less)
247 })
248 }),
249 Condition::LessEqual { key, value } => evaluate_concrete(meta, key, |stored| {
250 compare_values(stored, value, numeric_comparison_policy).map_or(MatchOutcome::Unknown, |ordering| {
251 MatchOutcome::from_bool(matches!(ordering, Ordering::Less | Ordering::Equal))
252 })
253 }),
254 Condition::Greater { key, value } => evaluate_concrete(meta, key, |stored| {
255 compare_values(stored, value, numeric_comparison_policy).map_or(MatchOutcome::Unknown, |ordering| {
256 MatchOutcome::from_bool(ordering == Ordering::Greater)
257 })
258 }),
259 Condition::GreaterEqual { key, value } => evaluate_concrete(meta, key, |stored| {
260 compare_values(stored, value, numeric_comparison_policy).map_or(MatchOutcome::Unknown, |ordering| {
261 MatchOutcome::from_bool(matches!(ordering, Ordering::Greater | Ordering::Equal))
262 })
263 }),
264 Condition::In { key, values } => evaluate_concrete(meta, key, |stored| {
265 evaluate_membership(stored, values, numeric_comparison_policy, false)
266 }),
267 Condition::NotIn { key, values } => evaluate_concrete(meta, key, |stored| {
268 evaluate_membership(stored, values, numeric_comparison_policy, true)
269 }),
270 Condition::Exists { key } => MatchOutcome::from_bool(concrete_value(meta, key).is_some()),
271 Condition::NotExists { key } => MatchOutcome::from_bool(concrete_value(meta, key).is_none()),
272 }
273 }
274
275 /// Returns the metadata key referenced by this condition.
276 #[must_use]
277 #[inline]
278 fn key(&self) -> &str {
279 match self {
280 Self::Equal { key, .. }
281 | Self::NotEqual { key, .. }
282 | Self::Less { key, .. }
283 | Self::LessEqual { key, .. }
284 | Self::Greater { key, .. }
285 | Self::GreaterEqual { key, .. }
286 | Self::In { key, .. }
287 | Self::NotIn { key, .. }
288 | Self::Exists { key }
289 | Self::NotExists { key } => key,
290 }
291 }
292}
293
294/// Validates one filter comparison operand.
295///
296/// # Parameters
297///
298/// * `operator` - Stable name of the operator using the operand.
299/// * `value` - Operand to validate.
300///
301/// # Errors
302///
303/// Returns [`MetadataError::InvalidFilterOperand`] when `value` is unset or
304/// cannot be represented by the V1 wire format.
305pub(super) fn validate_operand(operator: &'static str, value: &Value) -> MetadataResult<()> {
306 if value.is_unset() {
307 return Err(MetadataError::InvalidFilterOperand {
308 operator,
309 data_type: value.data_type(),
310 message: "filter operands must be concrete values".to_owned(),
311 });
312 }
313 if ValueWirePayloadRefV1::try_from(value).is_err() {
314 return Err(MetadataError::InvalidFilterOperand {
315 operator,
316 data_type: value.data_type(),
317 message: "filter operands must be representable by the V1 wire format".to_owned(),
318 });
319 }
320 Ok(())
321}
322
323/// Validates every operand in a set-membership condition.
324///
325/// # Parameters
326///
327/// * `operator` - Stable name of the set operator.
328/// * `values` - Candidate values to validate.
329///
330/// # Errors
331///
332/// Returns the first invalid operand error.
333fn validate_operands(operator: &'static str, values: &[Value]) -> MetadataResult<()> {
334 values.iter().try_for_each(|value| validate_operand(operator, value))
335}
336
337impl Redact for Condition {
338 /// Writes a diagnostic condition representation under the active policy.
339 ///
340 /// The discriminant is inspected and an optional operand is borrowed
341 /// before the record node is entered. The record and synthetic operator
342 /// field consume node budget, as do admitted key and operand fields.
343 /// Field formatting is deferred until admission; opaque masking skips
344 /// operand formatting. A node-budget rejection adds a structural
345 /// truncation field and stops the branch before formatting that field.
346 ///
347 /// # Parameters
348 ///
349 /// * `writer` - Structured destination carrying the active policy and
350 /// cumulative domain budgets.
351 fn write_redacted(&self, writer: &mut RedactionWriter<'_>) {
352 let (operator, operand): (&str, Option<&dyn fmt::Debug>) = match self {
353 Self::Equal { value, .. } => ("equal", Some(value)),
354 Self::NotEqual { value, .. } => ("not_equal", Some(value)),
355 Self::Less { value, .. } => ("less", Some(value)),
356 Self::LessEqual { value, .. } => ("less_equal", Some(value)),
357 Self::Greater { value, .. } => ("greater", Some(value)),
358 Self::GreaterEqual { value, .. } => ("greater_equal", Some(value)),
359 Self::In { values, .. } => ("in", Some(values)),
360 Self::NotIn { values, .. } => ("not_in", Some(values)),
361 Self::Exists { .. } => ("exists", None),
362 Self::NotExists { .. } => ("not_exists", None),
363 };
364 writer.record("Condition", |fields| {
365 fields.unredacted("operator", || operator);
366 fields.unredacted("key", || self.key());
367 if let Some(operand) = operand {
368 fields.sensitive_at_least(Sensitivity::Secret, "value", || operand);
369 }
370 });
371 }
372}
373
374impl fmt::Debug for Condition {
375 /// Writes the strict-policy diagnostic representation.
376 /// # Parameters
377 ///
378 /// * `formatter` - Formatter that receives the output.
379 #[inline]
380 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
381 let output = Redactor::strict().redact_text(self);
382 let text = output.text_or_marker("<redaction incomplete>");
383 formatter.write_str(text.as_ref())
384 }
385}
386
387/// Evaluates a predicate that requires a concrete stored value.
388///
389/// # Type Parameters
390///
391/// * `F` - Type used by this operation.
392///
393/// # Parameters
394///
395/// * `meta` - Metadata object being matched.
396/// * `key` - Metadata key to inspect.
397/// * `predicate` - Comparison to apply to a concrete value.
398///
399/// # Returns
400///
401/// The predicate result, or [`MatchOutcome::Unknown`] when the key is absent
402/// or stores [`Value::Unset`].
403#[inline]
404fn evaluate_concrete<F>(meta: &Metadata, key: &str, predicate: F) -> MatchOutcome
405where
406 F: FnOnce(&Value) -> MatchOutcome,
407{
408 concrete_value(meta, key).map_or(MatchOutcome::Unknown, predicate)
409}
410
411/// Evaluates one membership condition while preserving unknown comparisons.
412///
413/// # Parameters
414///
415/// * `stored` - Concrete metadata value being matched.
416/// * `candidates` - Values accepted or excluded by the condition.
417/// * `numeric_comparison_policy` - Policy for mixed numeric comparisons.
418/// * `negated` - Whether a matching candidate means the condition is false.
419///
420/// # Returns
421///
422/// `Unknown` when no candidate matches but at least one candidate cannot be
423/// compared to `stored`; otherwise the normal inclusion or exclusion result.
424fn evaluate_membership(
425 stored: &Value,
426 candidates: &[Value],
427 numeric_comparison_policy: NumericComparisonPolicy,
428 negated: bool,
429) -> MatchOutcome {
430 let mut unknown = false;
431 for candidate in candidates {
432 match values_equal(stored, candidate, numeric_comparison_policy) {
433 Some(true) => return MatchOutcome::from_bool(!negated),
434 Some(false) => {}
435 None => unknown = true,
436 }
437 }
438 if unknown {
439 MatchOutcome::Unknown
440 } else {
441 MatchOutcome::from_bool(negated)
442 }
443}
444
445/// Returns the concrete metadata value stored under `key`.
446///
447/// # Parameters
448///
449/// * `meta` - Metadata object being matched.
450/// * `key` - Metadata key to inspect.
451///
452/// # Returns
453///
454/// The stored value when it is concrete, or `None` when the key is absent or
455/// stores [`Value::Unset`].
456#[inline]
457fn concrete_value<'a>(meta: &'a Metadata, key: &str) -> Option<&'a Value> {
458 meta.get_raw(key).filter(|value| !value.is_unset())
459}
460
461/// Compares two values for equality, treating numeric variants by numeric
462/// value.
463///
464/// # Parameters
465///
466/// * `left` - Left comparison operand.
467/// * `right` - Right comparison operand.
468/// * `numeric_comparison_policy` - Policy for mixed numeric variants.
469///
470/// # Returns
471///
472/// `Some(true)` when comparable values are equal, `Some(false)` when they are
473/// comparable but unequal, or `None` when concrete types are incompatible or
474/// numeric comparison fails. Callers interpret `None` as an unknown outcome.
475#[inline]
476fn values_equal(left: &Value, right: &Value, numeric_comparison_policy: NumericComparisonPolicy) -> Option<bool> {
477 if left.is_numeric() && right.is_numeric() {
478 return left
479 .numeric_cmp(right, numeric_comparison_policy)
480 .ok()
481 .map(|ordering| ordering == Ordering::Equal);
482 }
483 if left.data_type() != right.data_type() {
484 return None;
485 }
486 Some(left == right)
487}
488
489/// Compares two numeric values or two strings.
490///
491/// # Parameters
492///
493/// * `left` - Left comparison operand.
494/// * `right` - Right comparison operand.
495/// * `numeric_comparison_policy` - Policy for mixed numeric variants.
496///
497/// # Returns
498///
499/// The relative ordering, or `None` when the values cannot be compared.
500#[inline]
501fn compare_values(left: &Value, right: &Value, numeric_comparison_policy: NumericComparisonPolicy) -> Option<Ordering> {
502 if left.is_numeric() && right.is_numeric() {
503 return left.numeric_cmp(right, numeric_comparison_policy).ok();
504 }
505 match (left.view(), right.view()) {
506 (ValueRef::String(left), ValueRef::String(right)) => left.partial_cmp(right),
507 _ => None,
508 }
509}