jmap_types/query.rs
1//! RFC 8620 §5.5 generic filter types for JMAP `/query` methods.
2//!
3//! Provides [`Filter`], [`FilterOperator`], and [`Operator`].
4//! Object-specific filter conditions (e.g. `EmailFilterCondition`) are
5//! defined in their respective type crates.
6//!
7//! # Filter algebra is excluded from extras preservation
8//!
9//! The filter algebra defined in this module is **intentionally not extensible**
10//! via the workspace "extras preservation" policy. See [`Filter`],
11//! [`FilterOperator`], and [`Operator`] for details. The same exclusion applies
12//! to every per-object `FilterCondition` / `Comparator` / `ComparatorProperty`
13//! type in the downstream `jmap-*-types` crates (see workspace `AGENTS.md`,
14//! bd JMAP-lbdy "Decision: filter algebra excluded").
15
16use serde::{Deserialize, Serialize};
17
18/// Logical operator for combining filter conditions (RFC 8620 §5.5).
19///
20/// # Excluded from extras preservation
21///
22/// This enum is **out of scope** for the workspace extras-preservation policy:
23/// it carries no `Unknown(String)` catch-all variant, and backends must
24/// dispatch on its known variants (`AND`, `OR`, `NOT`) to evaluate a filter
25/// tree. An `Unknown` operator would be meaningless — a server that cannot
26/// interpret the operator cannot evaluate the filter, and silently round-
27/// tripping it back to the client would yield wrong query results.
28///
29/// More broadly, filter algebra (this enum and the per-object
30/// `FilterCondition` / `Comparator` types) is excluded because unrecognised
31/// filter clauses are a query-correctness hazard: silently dropping or
32/// round-tripping a clause the server does not understand can return the
33/// wrong set of records to the client without any error signal.
34///
35/// ## What to do instead
36///
37/// **IETF-track path.** Vendors who need both capability-level declaration
38/// and filterability for custom fields should use
39/// `draft-ietf-jmap-metadata` (capability URI
40/// `urn:ietf:params:jmap:metadata`), which defines a `Metadata` / `Annotation`
41/// companion object keyed by `(relatedType, relatedId)` with schema discovery
42/// via the capability's `metadataTypes` / `maxDepth` properties and a
43/// `Metadata/query` filter. Implemented in `jmap-metadata-types`,
44/// `jmap-metadata-server`, and `jmap-metadata-client` (bd JMAP-06zp).
45///
46/// **Pre-IETF escape.** Vendors who cannot wait for the metadata draft can
47/// either escape the filter tree to `serde_json::Value` or fork the
48/// per-crate `FilterCondition` type. See
49/// `crate-jmap-calendars-types/PLAN.md` for the hybrid sloppy-value
50/// pattern.
51///
52/// Cross-reference: bd JMAP-lbdy "Decision: filter algebra excluded".
53#[non_exhaustive]
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
56pub enum Operator {
57 /// Logical AND: all sub-filters must match (RFC 8620 §5.5).
58 And,
59 /// Logical OR: at least one sub-filter must match (RFC 8620 §5.5).
60 Or,
61 /// Logical NOT: none of the sub-filters may match (RFC 8620 §5.5).
62 Not,
63}
64
65/// A filter node: either a logical operator combining sub-filters, or a
66/// type-specific condition object (RFC 8620 §5.5).
67///
68/// Serializes as an untagged union. The presence of the `"operator"` key
69/// distinguishes `Filter::Operator` from `Filter::Condition`.
70///
71/// **Variant ordering is critical**: `Operator` is listed before `Condition`
72/// because serde untagged tries variants in declaration order.
73/// `FilterOperator<T>` requires an `"operator"` field and fails fast without
74/// it, allowing the deserializer to fall through to `Condition(T)`.
75///
76/// # Excluded from extras preservation
77///
78/// This type is **out of scope** for the workspace extras-preservation
79/// policy: it carries no flatten-extras `extra` field, and the per-object
80/// condition type `T` is also expected to be non-extensible. Filter clauses
81/// the server does not understand are a query-correctness hazard — silently
82/// preserving an unrecognised clause and round-tripping it back to the
83/// client can return the wrong set of records with no error signal.
84///
85/// ## What to do instead
86///
87/// **IETF-track path.** Vendors who need both capability-level declaration
88/// and filterability for custom fields should use
89/// `draft-ietf-jmap-metadata` (capability URI
90/// `urn:ietf:params:jmap:metadata`), which defines a filterable
91/// `Metadata` / `Annotation` companion object. Implemented in `jmap-metadata-types`,
92/// `jmap-metadata-server`, and `jmap-metadata-client` (bd JMAP-06zp).
93///
94/// **Pre-IETF escape.** Vendors who cannot wait for the metadata draft can
95/// either escape the filter tree to `serde_json::Value` or fork the
96/// per-crate `FilterCondition` type. See
97/// `crate-jmap-calendars-types/PLAN.md` for the hybrid sloppy-value
98/// pattern.
99///
100/// Cross-reference: bd JMAP-lbdy "Decision: filter algebra excluded".
101#[non_exhaustive]
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(untagged)]
104pub enum Filter<T> {
105 /// A logical combination of sub-filters.
106 Operator(FilterOperator<T>),
107 /// A type-specific condition object.
108 Condition(T),
109}
110
111/// Logical combination of filters (RFC 8620 §5.5).
112///
113/// # Excluded from extras preservation
114///
115/// This type is **out of scope** for the workspace extras-preservation
116/// policy: it carries no flatten-extras `extra` field, and its
117/// [`Operator`] field is a closed control enum that backends must dispatch
118/// on. See [`Operator`] and [`Filter`] for the rationale and for the two
119/// recommended paths (`draft-ietf-jmap-metadata`, bd JMAP-06zp; or the
120/// pre-IETF sloppy-value escape).
121///
122/// Cross-reference: bd JMAP-lbdy "Decision: filter algebra excluded".
123#[non_exhaustive]
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct FilterOperator<T> {
126 /// Logical operator: AND, OR, or NOT.
127 pub operator: Operator,
128 /// Sub-conditions to evaluate.
129 pub conditions: Vec<Filter<T>>,
130}
131
132impl<T> FilterOperator<T> {
133 /// Create a new [`FilterOperator`] with the given logical operator and conditions.
134 pub fn new(operator: Operator, conditions: Vec<Filter<T>>) -> Self {
135 Self {
136 operator,
137 conditions,
138 }
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 // FilterCondition stub used only within this test module.
147 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148 struct Cond {
149 #[serde(skip_serializing_if = "Option::is_none")]
150 pub has_keyword: Option<String>,
151 }
152
153 /// Oracle: exercises the Filter<T> generic with a local stub type.
154 /// Adapted from the RFC 8620 §5.5 Todo/query example; `has_keyword` uses
155 /// Rust snake_case because `Cond` is a test stub, not a real JMAP type.
156 #[test]
157 fn filter_operator_or_roundtrip() {
158 let json =
159 r#"{"operator":"OR","conditions":[{"has_keyword":"music"},{"has_keyword":"video"}]}"#;
160 let f: Filter<Cond> = serde_json::from_str(json).expect("must parse");
161 match &f {
162 Filter::Operator(op) => {
163 assert_eq!(op.operator, Operator::Or);
164 assert_eq!(op.conditions.len(), 2);
165 }
166 other => panic!("expected Operator, got {other:?}"),
167 }
168 let back = serde_json::to_string(&f).expect("must serialize");
169 let f2: Filter<Cond> = serde_json::from_str(&back).expect("roundtrip");
170 assert_eq!(f, f2);
171 }
172
173 /// Oracle: a bare condition object (no "operator" key) deserializes as
174 /// Filter::Condition.
175 #[test]
176 fn filter_condition_deserialization() {
177 let json = r#"{"has_keyword":"$seen"}"#;
178 let f: Filter<Cond> = serde_json::from_str(json).expect("must parse");
179 match &f {
180 Filter::Condition(c) => assert_eq!(c.has_keyword.as_deref(), Some("$seen")),
181 other => panic!("expected Condition, got {other:?}"),
182 }
183 }
184
185 /// Oracle: Operator enum serializes as SCREAMING_SNAKE_CASE per RFC 8620 §5.5.
186 #[test]
187 fn operator_serialization() {
188 assert_eq!(serde_json::to_string(&Operator::And).unwrap(), r#""AND""#);
189 assert_eq!(serde_json::to_string(&Operator::Or).unwrap(), r#""OR""#);
190 assert_eq!(serde_json::to_string(&Operator::Not).unwrap(), r#""NOT""#);
191 }
192
193 /// Oracle: nested AND(OR(...)) structure roundtrips correctly.
194 #[test]
195 fn nested_filter_roundtrip() {
196 let filter = Filter::Operator(FilterOperator {
197 operator: Operator::And,
198 conditions: vec![
199 Filter::Operator(FilterOperator {
200 operator: Operator::Or,
201 conditions: vec![
202 Filter::Condition(Cond {
203 has_keyword: Some("a".to_owned()),
204 }),
205 Filter::Condition(Cond {
206 has_keyword: Some("b".to_owned()),
207 }),
208 ],
209 }),
210 Filter::Condition(Cond {
211 has_keyword: Some("c".to_owned()),
212 }),
213 ],
214 });
215 let json = serde_json::to_string(&filter).expect("serialize");
216 let back: Filter<Cond> = serde_json::from_str(&json).expect("deserialize");
217 assert_eq!(filter, back);
218 }
219}