Skip to main content

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