Skip to main content

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 `Other(String)` catch-all variant, and backends must
24/// dispatch on its known variants (`AND`, `OR`, `NOT`) to evaluate a filter
25/// tree. An `Other` 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 adds namespace-keyed
41/// `metadata` / `privateMetadata` properties to each opted-in data type,
42/// with per-type schema discovery via the capability's `dataTypes`
43/// (`namespaces` / `supportsVendorNamespaces` / `maxDepth`) and
44/// `metadataExists` / `metadataTextContains` / `metadataTextEquals`
45/// conditions on that type's own `/query` filter
46/// (draft-ietf-jmap-metadata-02 §1.2.1, §3.5). Helpers live in
47/// `jmap-metadata-types`, `jmap-metadata-server`, and
48/// `jmap-metadata-client`.
49///
50/// **Pre-IETF escape.** Vendors who cannot wait for the metadata draft can
51/// either escape the filter tree to `serde_json::Value` or fork the
52/// per-crate `FilterCondition` type. See
53/// `crate-jmap-calendars-types/PLAN.md` for the hybrid sloppy-value
54/// pattern.
55///
56/// Cross-reference: bd JMAP-lbdy "Decision: filter algebra excluded".
57#[non_exhaustive]
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
60pub enum Operator {
61    /// Logical AND: all sub-filters must match (RFC 8620 §5.5).
62    And,
63    /// Logical OR: at least one sub-filter must match (RFC 8620 §5.5).
64    Or,
65    /// Logical NOT: none of the sub-filters may match (RFC 8620 §5.5).
66    Not,
67}
68
69/// A filter node: either a logical operator combining sub-filters, or a
70/// type-specific condition object (RFC 8620 §5.5).
71///
72/// Serializes as an untagged union.  The presence of the `"operator"` key
73/// distinguishes `Filter::Operator` from `Filter::Condition`.
74///
75/// **Variant ordering is critical**: `Operator` is listed before `Condition`
76/// because serde untagged tries variants in declaration order.
77/// `FilterOperator<T>` requires an `"operator"` field and fails fast without
78/// it, allowing the deserializer to fall through to `Condition(T)`.
79///
80/// # Unknown keys are rejected, not dropped
81///
82/// [`FilterOperator`] and every in-tree per-crate `FilterCondition` type
83/// carry `#[serde(deny_unknown_fields)]`, which composes correctly with
84/// `#[serde(untagged)]`: a node that fails both variants fails the whole
85/// decode. A misspelt `operator` key (`{"opperator":"AND","conditions":[]}`),
86/// an extra key on an operator node, or an unknown condition property at
87/// any depth is therefore a deserialization error, which servers map to
88/// RFC 8620 §5.5 `unsupportedFilter`. Without the attribute, such a node
89/// would decode as `T::default()` (all fields `None`), which matches every
90/// record.
91///
92/// A condition type `T` defined outside this workspace must carry
93/// `#[serde(deny_unknown_fields)]` too, or it reintroduces that silent
94/// match-all (pinned by
95/// `filter_with_typoed_operator_silently_decodes_as_match_all_condition`).
96/// Object types whose `Filter` is `serde_json::Value` get no such check.
97///
98/// # Excluded from extras preservation
99///
100/// This type is **out of scope** for the workspace extras-preservation
101/// policy: it carries no flatten-extras `extra` field, and the per-object
102/// condition type `T` is also expected to be non-extensible. Filter clauses
103/// the server does not understand are a query-correctness hazard — silently
104/// preserving an unrecognised clause and round-tripping it back to the
105/// client can return the wrong set of records with no error signal.
106///
107/// ## What to do instead
108///
109/// **IETF-track path.** Vendors who need both capability-level declaration
110/// and filterability for custom fields should use
111/// `draft-ietf-jmap-metadata` (capability URI
112/// `urn:ietf:params:jmap:metadata`), which adds namespace-keyed
113/// `metadata` / `privateMetadata` properties to each opted-in data type,
114/// filterable through `metadata*` conditions on that type's own `/query`
115/// (draft-ietf-jmap-metadata-02 §2, §3.5). Helpers live in
116/// `jmap-metadata-types`, `jmap-metadata-server`, and
117/// `jmap-metadata-client`.
118///
119/// **Pre-IETF escape.** Vendors who cannot wait for the metadata draft can
120/// either escape the filter tree to `serde_json::Value` or fork the
121/// per-crate `FilterCondition` type. See
122/// `crate-jmap-calendars-types/PLAN.md` for the hybrid sloppy-value
123/// pattern.
124///
125/// Cross-reference: bd JMAP-lbdy "Decision: filter algebra excluded".
126#[non_exhaustive]
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(untagged)]
129pub enum Filter<T> {
130    /// A logical combination of sub-filters.
131    Operator(FilterOperator<T>),
132    /// A type-specific condition object.
133    Condition(T),
134}
135
136/// Logical combination of filters (RFC 8620 §5.5).
137///
138/// # Excluded from extras preservation
139///
140/// This type is **out of scope** for the workspace extras-preservation
141/// policy: it carries no flatten-extras `extra` field, and its
142/// [`Operator`] field is a closed control enum that backends must dispatch
143/// on. See [`Operator`] and [`Filter`] for the rationale and for the two
144/// recommended paths (`draft-ietf-jmap-metadata`, bd JMAP-ua37; or the
145/// pre-IETF sloppy-value escape).
146///
147/// Cross-reference: bd JMAP-lbdy "Decision: filter algebra excluded".
148#[non_exhaustive]
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(deny_unknown_fields)]
151pub struct FilterOperator<T> {
152    /// Logical operator: AND, OR, or NOT.
153    pub operator: Operator,
154    /// Sub-conditions to evaluate.
155    pub conditions: Vec<Filter<T>>,
156}
157
158impl<T> FilterOperator<T> {
159    /// Create a new [`FilterOperator`] with the given logical operator and conditions.
160    pub fn new(operator: Operator, conditions: Vec<Filter<T>>) -> Self {
161        Self {
162            operator,
163            conditions,
164        }
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    // FilterCondition stub used only within this test module.
173    #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174    struct Cond {
175        #[serde(skip_serializing_if = "Option::is_none")]
176        pub has_keyword: Option<String>,
177    }
178
179    /// Oracle: exercises the Filter<T> generic with a local stub type.
180    /// Adapted from the RFC 8620 §5.5 Todo/query example; `has_keyword` uses
181    /// Rust snake_case because `Cond` is a test stub, not a real JMAP type.
182    #[test]
183    fn filter_operator_or_roundtrip() {
184        let json =
185            r#"{"operator":"OR","conditions":[{"has_keyword":"music"},{"has_keyword":"video"}]}"#;
186        let f: Filter<Cond> = serde_json::from_str(json).expect("must parse");
187        match &f {
188            Filter::Operator(op) => {
189                assert_eq!(op.operator, Operator::Or);
190                assert_eq!(op.conditions.len(), 2);
191            }
192            other => panic!("expected Operator, got {other:?}"),
193        }
194        let back = serde_json::to_string(&f).expect("must serialize");
195        let f2: Filter<Cond> = serde_json::from_str(&back).expect("roundtrip");
196        assert_eq!(f, f2);
197    }
198
199    /// Oracle: a bare condition object (no "operator" key) deserializes as
200    /// Filter::Condition.
201    #[test]
202    fn filter_condition_deserialization() {
203        let json = r#"{"has_keyword":"$seen"}"#;
204        let f: Filter<Cond> = serde_json::from_str(json).expect("must parse");
205        match &f {
206            Filter::Condition(c) => assert_eq!(c.has_keyword.as_deref(), Some("$seen")),
207            other => panic!("expected Condition, got {other:?}"),
208        }
209    }
210
211    /// Oracle: Operator enum serializes as SCREAMING_SNAKE_CASE per RFC 8620 §5.5.
212    #[test]
213    fn operator_serialization() {
214        assert_eq!(serde_json::to_string(&Operator::And).unwrap(), r#""AND""#);
215        assert_eq!(serde_json::to_string(&Operator::Or).unwrap(), r#""OR""#);
216        assert_eq!(serde_json::to_string(&Operator::Not).unwrap(), r#""NOT""#);
217    }
218
219    /// Oracle: pins the malformed-operator silent-fallthrough hazard
220    /// documented on [`Filter`]. A client clause with a misspelled
221    /// `operator` field key (here `opperator`) fails to deserialize as
222    /// `Filter::Operator(FilterOperator)` (the key does not match the
223    /// typed field name) and falls through to `Filter::Condition(T)`.
224    /// Because `Cond` is a struct of all-`Option` fields without
225    /// `#[serde(deny_unknown_fields)]`, serde silently drops the
226    /// unknown `opperator` and `conditions` keys and deserializes the
227    /// clause as `Cond { has_keyword: None }` — semantically "match
228    /// any record".
229    ///
230    /// This test is a regression marker for `bd:JMAP-6xs8.7`: it pins
231    /// why every condition type must carry `deny_unknown_fields` (see
232    /// "Unknown keys are rejected, not dropped" on [`Filter`]);
233    /// `filter_with_strict_condition_rejects_unknown_keys` is the
234    /// counterpart with a strict condition type.
235    #[test]
236    fn filter_with_typoed_operator_silently_decodes_as_match_all_condition() {
237        // Variant 1: lower-case `o` typoed as `opperator`.
238        let raw = r#"{"opperator":"AND","conditions":[]}"#;
239        let f: Filter<Cond> = serde_json::from_str(raw).expect(
240            "untagged enum must accept the typo as Filter::Condition — \
241             this is the silent-fallthrough hazard",
242        );
243        match f {
244            Filter::Condition(c) => assert!(
245                c.has_keyword.is_none(),
246                "fallthrough Condition must be the all-None default \
247                 'match-all' shape; got: {c:?}"
248            ),
249            Filter::Operator(_) => panic!(
250                "expected silent fallthrough to Condition, got Operator \
251                 — serde untagged behavior changed?"
252            ),
253        }
254
255        // Variant 2: capitalised `O` typoed as `Operator`.
256        let raw_capitalised = r#"{"Operator":"AND","conditions":[]}"#;
257        let f2: Filter<Cond> =
258            serde_json::from_str(raw_capitalised).expect("capitalised typo must also fall through");
259        match f2 {
260            Filter::Condition(c) => assert!(c.has_keyword.is_none()),
261            Filter::Operator(_) => panic!("expected silent fallthrough to Condition, got Operator"),
262        }
263    }
264
265    // Condition stub with the same strictness as the in-tree condition types.
266    #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
267    #[serde(rename_all = "camelCase", deny_unknown_fields)]
268    struct StrictCond {
269        #[serde(skip_serializing_if = "Option::is_none")]
270        has_keyword: Option<String>,
271    }
272
273    /// Oracle: RFC 8620 §5.5 defines a FilterOperator as exactly
274    /// `operator` ("AND" | "OR" | "NOT") plus `conditions`, and
275    /// `unsupportedFilter` as "The "filter" is syntactically valid, but the
276    /// server cannot process it." Hand-written JSON: well-formed trees
277    /// must decode; trees with an unknown key at any node must fail.
278    #[test]
279    fn filter_with_strict_condition_rejects_unknown_keys() {
280        let accepted = [
281            r#"{"hasKeyword":"$seen"}"#,
282            "{}",
283            r#"{"operator":"AND","conditions":[{"hasKeyword":"a"}]}"#,
284            r#"{"operator":"OR","conditions":[]}"#,
285            r#"{"operator":"AND","conditions":[{"operator":"NOT","conditions":[{"hasKeyword":"b"}]}]}"#,
286        ];
287        for raw in accepted {
288            serde_json::from_str::<Filter<StrictCond>>(raw)
289                .unwrap_or_else(|e| panic!("{raw} must decode: {e}"));
290        }
291        let rejected = [
292            // snake_case typo of a known property
293            r#"{"has_keyword":"$seen"}"#,
294            // unknown leaf key next to a known one
295            r#"{"hasKeyword":"$seen","bogus":1}"#,
296            // misspelt operator key
297            r#"{"opperator":"AND","conditions":[]}"#,
298            // unknown key on an operator node
299            r#"{"operator":"AND","conditions":[],"extra":true}"#,
300            // operator value outside AND/OR/NOT
301            r#"{"operator":"XOR","conditions":[]}"#,
302            // unknown leaf key nested two levels down
303            r#"{"operator":"AND","conditions":[{"operator":"OR","conditions":[{"bogus":1}]}]}"#,
304        ];
305        for raw in rejected {
306            assert!(
307                serde_json::from_str::<Filter<StrictCond>>(raw).is_err(),
308                "{raw} must be rejected"
309            );
310        }
311    }
312
313    /// Oracle: nested AND(OR(...)) structure roundtrips correctly.
314    #[test]
315    fn nested_filter_roundtrip() {
316        let filter = Filter::Operator(FilterOperator {
317            operator: Operator::And,
318            conditions: vec![
319                Filter::Operator(FilterOperator {
320                    operator: Operator::Or,
321                    conditions: vec![
322                        Filter::Condition(Cond {
323                            has_keyword: Some("a".to_owned()),
324                        }),
325                        Filter::Condition(Cond {
326                            has_keyword: Some("b".to_owned()),
327                        }),
328                    ],
329                }),
330                Filter::Condition(Cond {
331                    has_keyword: Some("c".to_owned()),
332                }),
333            ],
334        });
335        let json = serde_json::to_string(&filter).expect("serialize");
336        let back: Filter<Cond> = serde_json::from_str(&json).expect("deserialize");
337        assert_eq!(filter, back);
338    }
339}