Skip to main content

liminal/routing/
predicate.rs

1/// Comparison operators supported by declarative routing predicates.
2#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3pub enum ComparisonOp {
4    /// Field value must equal the comparison value.
5    Eq,
6    /// Field value must not equal the comparison value.
7    Ne,
8    /// Field value must be greater than the comparison value.
9    Gt,
10    /// Field value must be less than the comparison value.
11    Lt,
12    /// Field value must be greater than or equal to the comparison value.
13    Gte,
14    /// Field value must be less than or equal to the comparison value.
15    Lte,
16}
17
18/// Literal value that can be compared by declarative routing predicates.
19#[derive(Clone, Debug, PartialEq)]
20pub enum FieldValue {
21    /// UTF-8 text value.
22    Text(String),
23    /// Signed integer value.
24    Integer(i64),
25    /// Floating-point value.
26    Float(f64),
27    /// Boolean value.
28    Boolean(bool),
29    /// Explicit null value.
30    Null,
31}
32
33/// Dot-notation path to a message field.
34#[derive(Clone, Debug, PartialEq, Eq)]
35pub struct FieldPath(String);
36
37impl FieldPath {
38    /// Creates a field path from a dot-notation string.
39    #[must_use]
40    pub fn new(path: impl Into<String>) -> Self {
41        Self(path.into())
42    }
43
44    /// Returns borrowed field path segments separated by dots.
45    pub fn segments(&self) -> impl Iterator<Item = &str> + '_ {
46        self.0.split('.')
47    }
48}
49
50/// Declarative routing predicate shape.
51///
52/// Predicate evaluation is implemented by later routing briefs. When evaluated,
53/// any predicate that references a missing message field must evaluate to false
54/// without returning an error.
55#[derive(Clone, Debug, PartialEq)]
56pub enum Predicate {
57    /// Compare a field with a literal value using a comparison operator.
58    Comparison {
59        /// Message field path to compare.
60        field: FieldPath,
61        /// Comparison operator to apply.
62        op: ComparisonOp,
63        /// Literal comparison value.
64        value: FieldValue,
65    },
66    /// Logical AND over zero or more child predicates.
67    And(Vec<Self>),
68    /// Logical OR over zero or more child predicates.
69    Or(Vec<Self>),
70    /// Logical negation of a single child predicate.
71    Not(Box<Self>),
72    /// Inclusive range check for numeric or text values.
73    Range {
74        /// Message field path to compare.
75        field: FieldPath,
76        /// Inclusive lower bound.
77        lower: FieldValue,
78        /// Inclusive upper bound.
79        upper: FieldValue,
80    },
81    /// Test whether a field is present in the message.
82    Exists {
83        /// Message field path to check.
84        field: FieldPath,
85    },
86}
87
88#[cfg(test)]
89mod tests {
90    use super::{ComparisonOp, FieldPath, FieldValue, Predicate};
91
92    #[test]
93    fn comparison_predicate_constructs() {
94        let predicate = Predicate::Comparison {
95            field: FieldPath::new("amount"),
96            op: ComparisonOp::Gt,
97            value: FieldValue::Integer(1_000),
98        };
99
100        assert_eq!(
101            predicate,
102            Predicate::Comparison {
103                field: FieldPath::new("amount"),
104                op: ComparisonOp::Gt,
105                value: FieldValue::Integer(1_000),
106            }
107        );
108    }
109
110    #[test]
111    fn comparison_debug_round_trips_all_variants() {
112        assert_eq!(format!("{:?}", ComparisonOp::Eq), "Eq");
113        assert_eq!(format!("{:?}", ComparisonOp::Ne), "Ne");
114        assert_eq!(format!("{:?}", ComparisonOp::Gt), "Gt");
115        assert_eq!(format!("{:?}", ComparisonOp::Lt), "Lt");
116        assert_eq!(format!("{:?}", ComparisonOp::Gte), "Gte");
117        assert_eq!(format!("{:?}", ComparisonOp::Lte), "Lte");
118    }
119
120    #[test]
121    fn boolean_combinators_construct() {
122        let amount = Predicate::Comparison {
123            field: FieldPath::new("amount"),
124            op: ComparisonOp::Gt,
125            value: FieldValue::Integer(1_000),
126        };
127        let region = Predicate::Comparison {
128            field: FieldPath::new("region"),
129            op: ComparisonOp::Eq,
130            value: FieldValue::Text(String::from("eu")),
131        };
132
133        let and = Predicate::And(vec![amount.clone(), region.clone()]);
134        let or = Predicate::Or(vec![amount.clone(), region.clone()]);
135        let not = Predicate::Not(Box::new(region.clone()));
136        let nested = Predicate::And(vec![amount.clone(), Predicate::Or(vec![region.clone()])]);
137
138        assert_eq!(and, Predicate::And(vec![amount.clone(), region.clone()]));
139        assert_eq!(or, Predicate::Or(vec![amount.clone(), region.clone()]));
140        assert_eq!(not, Predicate::Not(Box::new(region.clone())));
141        assert_eq!(
142            nested,
143            Predicate::And(vec![amount, Predicate::Or(vec![region])])
144        );
145    }
146
147    #[test]
148    fn empty_boolean_combinators_construct() {
149        assert_eq!(Predicate::And(Vec::new()), Predicate::And(Vec::new()));
150        assert_eq!(Predicate::Or(Vec::new()), Predicate::Or(Vec::new()));
151    }
152
153    #[test]
154    fn range_and_exists_predicates_construct() {
155        let integer_range = Predicate::Range {
156            field: FieldPath::new("amount"),
157            lower: FieldValue::Integer(100),
158            upper: FieldValue::Integer(200),
159        };
160        let text_range = Predicate::Range {
161            field: FieldPath::new("name"),
162            lower: FieldValue::Text(String::from("a")),
163            upper: FieldValue::Text(String::from("z")),
164        };
165        let exists = Predicate::Exists {
166            field: FieldPath::new("region"),
167        };
168
169        assert_eq!(
170            integer_range,
171            Predicate::Range {
172                field: FieldPath::new("amount"),
173                lower: FieldValue::Integer(100),
174                upper: FieldValue::Integer(200),
175            }
176        );
177        assert_eq!(
178            text_range,
179            Predicate::Range {
180                field: FieldPath::new("name"),
181                lower: FieldValue::Text(String::from("a")),
182                upper: FieldValue::Text(String::from("z")),
183            }
184        );
185        assert_eq!(
186            exists,
187            Predicate::Exists {
188                field: FieldPath::new("region"),
189            }
190        );
191    }
192
193    #[test]
194    fn field_path_segments_are_borrowed_dot_parts() {
195        let nested = FieldPath::new("user.address.city");
196        let nested_segments: Vec<_> = nested.segments().collect();
197        let single = FieldPath::new("name");
198        let single_segments: Vec<_> = single.segments().collect();
199
200        assert_eq!(nested_segments, ["user", "address", "city"]);
201        assert_eq!(single_segments, ["name"]);
202    }
203
204    #[test]
205    fn routing_root_re_exports_predicate_types() {
206        use crate::routing::{
207            ComparisonOp as RootComparisonOp, FieldPath as RootFieldPath,
208            FieldValue as RootFieldValue, Predicate as RootPredicate,
209        };
210
211        let predicate = RootPredicate::Comparison {
212            field: RootFieldPath::new("amount"),
213            op: RootComparisonOp::Gte,
214            value: RootFieldValue::Integer(100),
215        };
216
217        assert_eq!(
218            predicate,
219            RootPredicate::Comparison {
220                field: RootFieldPath::new("amount"),
221                op: RootComparisonOp::Gte,
222                value: RootFieldValue::Integer(100),
223            }
224        );
225    }
226}