Skip to main content

maplibre_expr/
filter.rs

1//! Converting legacy MapLibre *filters* into modern expressions.
2//!
3//! Before expressions existed, layer filters were written as nested arrays with
4//! a bare property name in the operand slot — `["==", "class", "primary"]`,
5//! `["in", "type", "a", "b"]`, `["all", …]`. MapLibre still accepts them,
6//! converting each to the equivalent boolean expression
7//! (`["==", ["get", "class"], "primary"]`, …) before compiling. This module is
8//! a port of maplibre-style-spec's `src/feature_filter/convert.ts` (the
9//! conversion body) and the `isExpressionFilter` discriminator from
10//! `src/feature_filter/index.ts`, so the produced expressions match the
11//! reference implementation.
12//!
13//! Two entry points:
14//! - [`is_expression_filter`] — is this filter already a modern expression (so
15//!   it needs no conversion), or a legacy filter?
16//! - [`convert_legacy_filter`] — convert a legacy filter to the equivalent
17//!   modern expression, returned as raw JSON (ready for [`parse`](crate::parse)
18//!   or to be embedded verbatim in a style). An input that already *is* an
19//!   expression is returned unchanged.
20//!
21//! # Legacy comparison semantics
22//!
23//! Legacy comparisons are strictly typed with no implicit conversion: when a
24//! property's runtime type differs from the compared value's type, the filter
25//! simply yields `false`. The modern `==`/`<`/… operators instead type-check
26//! (and may error or coerce), so a naive `["==", ["get", k], v]` would not
27//! reproduce legacy behavior inside an `any`. Following `convert.ts`, each
28//! `any` term is guarded with a preflight `typeof` check (see
29//! [`convert_legacy_filter`] for the worked example) so a type mismatch
30//! short-circuits to `false` instead of erroring out the whole filter.
31
32use serde_json::{json, Value as Json};
33
34/// A legacy filter that could not be converted to an expression.
35#[derive(Debug, Clone, PartialEq)]
36pub enum FilterError {
37    /// A property operand of a legacy comparison/`in`/`has` was not a string
38    /// (legacy filters name the property with a bare string). `op` is the
39    /// offending operator.
40    PropertyNotString { op: String },
41}
42
43impl std::fmt::Display for FilterError {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        match self {
46            FilterError::PropertyNotString { op } => write!(
47                f,
48                "legacy filter operator {op:?} expects a string property name"
49            ),
50        }
51    }
52}
53
54impl std::error::Error for FilterError {}
55
56/// Whether `filter` is already a modern expression filter (as opposed to a
57/// legacy filter needing conversion). A direct port of `isExpressionFilter`.
58///
59/// The rule errs toward reporting *legacy* for the ambiguous shapes an old
60/// style might carry (e.g. `["in", "color", "red"]`) so they are converted;
61/// authors can force expression interpretation with a `["literal", …]` operand.
62pub fn is_expression_filter(filter: &Json) -> bool {
63    if filter.is_boolean() {
64        return true;
65    }
66    let Some(arr) = filter.as_array() else {
67        return false;
68    };
69    if arr.is_empty() {
70        return false;
71    }
72
73    // A non-string operator slot (nested array, number, …) is not a legacy
74    // operator, so the whole thing is treated as an expression (JS `default`).
75    let op = arr[0].as_str();
76    let is_string = |v: Option<&Json>| v.is_some_and(Json::is_string);
77    let is_array = |v: Option<&Json>| v.is_some_and(Json::is_array);
78
79    match op {
80        Some("has") => arr.len() >= 2 && !matches!(arr[1].as_str(), Some("$id") | Some("$type")),
81
82        Some("in") => arr.len() >= 3 && (!is_string(arr.get(1)) || is_array(arr.get(2))),
83
84        Some("!in") | Some("!has") => false,
85
86        Some("==") | Some("!=") | Some(">") | Some(">=") | Some("<") | Some("<=") => {
87            arr.len() != 3 || is_array(arr.get(1)) || is_array(arr.get(2))
88        }
89
90        Some("none") => {
91            // An expression only if some child is definitely an expression.
92            for f in &arr[1..] {
93                if f.is_boolean() {
94                    continue;
95                }
96                if is_expression_filter(f) {
97                    return true;
98                }
99            }
100            false
101        }
102
103        Some("any") | Some("all") => {
104            // An expression unless a child is definitely legacy.
105            let mut has_legacy = false;
106            for f in &arr[1..] {
107                if f.is_boolean() {
108                    continue;
109                }
110                if is_expression_filter(f) {
111                    return true;
112                }
113                has_legacy = true;
114            }
115            !has_legacy
116        }
117
118        _ => true,
119    }
120}
121
122/// Convert a legacy MapLibre filter to the equivalent modern expression.
123///
124/// Supported legacy operators: `==` `!=` `<` `<=` `>` `>=` `in` `!in` `has`
125/// `!has` `all` `any` `none`. The special keys `"$type"` and `"$id"` map to
126/// `["geometry-type"]` and `["id"]`. A filter that already
127/// [`is_expression_filter`] is returned unchanged.
128///
129/// The result is raw JSON — a boolean expression ready for
130/// [`parse`](crate::parse) or to embed directly in a style. Returns
131/// [`FilterError`] only for structurally malformed legacy filters (a
132/// non-string property name).
133///
134/// # Type-mismatch semantics
135///
136/// Legacy filters treat a property whose runtime type differs from the compared
137/// value as simply not matching, rather than as an error. Inside `any`, that
138/// matters: the reference converts
139///
140/// ```text
141/// ["any", ["all", [">", "y", 0], [">", "z", 0]], [">", "x", 0]]
142/// ```
143///
144/// by prefixing each disjunct with a `typeof` preflight, so a mistyped `y`/`z`
145/// does not abort evaluation of the whole `any`:
146///
147/// ```text
148/// ["any",
149///   ["case",
150///     ["all", ["==", ["typeof", ["get", "y"]], "number"],
151///             ["==", ["typeof", ["get", "z"]], "number"]],
152///     ["all", [">", ["get", "y"], 0], [">", ["get", "z"], 0]],
153///     false],
154///   ["case",
155///     ["==", ["typeof", ["get", "x"]], "number"],
156///     [">", ["get", "x"], 0],
157///     false]]
158/// ```
159pub fn convert_legacy_filter(filter: &Json) -> Result<Json, FilterError> {
160    let mut expected = ExpectedTypes::new();
161    convert(filter, &mut expected)
162}
163
164/// Insertion-ordered map of property name -> expected `typeof` string, used to
165/// build the preflight type checks for `any`. Mirrors JS object semantics:
166/// assigning an existing key overwrites its value but keeps its position.
167struct ExpectedTypes {
168    entries: Vec<(String, &'static str)>,
169}
170
171impl ExpectedTypes {
172    fn new() -> ExpectedTypes {
173        ExpectedTypes {
174            entries: Vec::new(),
175        }
176    }
177
178    fn set(&mut self, property: &str, ty: &'static str) {
179        if let Some(slot) = self.entries.iter_mut().find(|(k, _)| k == property) {
180            slot.1 = ty;
181        } else {
182            self.entries.push((property.to_string(), ty));
183        }
184    }
185}
186
187fn convert(filter: &Json, expected: &mut ExpectedTypes) -> Result<Json, FilterError> {
188    if is_expression_filter(filter) {
189        return Ok(filter.clone());
190    }
191    // Falsy filters (`null`) mean "match everything".
192    if filter.is_null() {
193        return Ok(json!(true));
194    }
195    let Some(arr) = filter.as_array() else {
196        // Not an array and not falsy: the reference falls through to `true`.
197        return Ok(json!(true));
198    };
199
200    let op = arr[0].as_str();
201    if arr.len() <= 1 {
202        // `["all"]`/`["foo"]` -> true, `["any"]` -> false.
203        return Ok(json!(op != Some("any")));
204    }
205
206    match op {
207        Some(cmp @ ("==" | "!=" | "<" | ">" | "<=" | ">=")) => {
208            convert_comparison_op(&arr[1], &arr[2], cmp, expected)
209        }
210        Some("any") => {
211            let mut children = vec![json!("any")];
212            for f in &arr[1..] {
213                let mut types = ExpectedTypes::new();
214                let child = convert(f, &mut types)?;
215                let checks = runtime_type_checks(&types);
216                if checks == json!(true) {
217                    children.push(child);
218                } else {
219                    children.push(json!(["case", checks, child, false]));
220                }
221            }
222            Ok(Json::Array(children))
223        }
224        Some("all") => {
225            let mut children = Vec::with_capacity(arr.len() - 1);
226            for f in &arr[1..] {
227                children.push(convert(f, expected)?);
228            }
229            if children.len() > 1 {
230                let mut out = vec![json!("all")];
231                out.extend(children);
232                Ok(Json::Array(out))
233            } else {
234                // `all` with a single child collapses to that child.
235                Ok(children.into_iter().next().unwrap())
236            }
237        }
238        Some("none") => {
239            // none(…) == !any(…), evaluated with its own (discarded) type map.
240            let mut any = vec![json!("any")];
241            any.extend(arr[1..].iter().cloned());
242            let mut types = ExpectedTypes::new();
243            let inner = convert(&Json::Array(any), &mut types)?;
244            Ok(json!(["!", inner]))
245        }
246        Some("in") => convert_in_op(&arr[1], &arr[2..], false),
247        Some("!in") => convert_in_op(&arr[1], &arr[2..], true),
248        Some("has") => convert_has_op(&arr[1]),
249        Some("!has") => Ok(json!(["!", convert_has_op(&arr[1])?])),
250        _ => Ok(json!(true)),
251    }
252}
253
254fn runtime_type_checks(expected: &ExpectedTypes) -> Json {
255    let mut conditions: Vec<Json> = Vec::new();
256    for (property, ty) in &expected.entries {
257        let get = if property == "$id" {
258            json!(["id"])
259        } else {
260            json!(["get", property])
261        };
262        conditions.push(json!(["==", ["typeof", get], ty]));
263    }
264    match conditions.len() {
265        0 => json!(true),
266        1 => conditions.into_iter().next().unwrap(),
267        _ => {
268            let mut out = vec![json!("all")];
269            out.extend(conditions);
270            Json::Array(out)
271        }
272    }
273}
274
275fn convert_comparison_op(
276    property: &Json,
277    value: &Json,
278    op: &str,
279    expected: &mut ExpectedTypes,
280) -> Result<Json, FilterError> {
281    // `$type` compares the geometry type and takes no null special-casing.
282    if property.as_str() == Some("$type") {
283        return Ok(json!([op, ["geometry-type"], value]));
284    }
285
286    let is_id = property.as_str() == Some("$id");
287    let get = if is_id {
288        json!(["id"])
289    } else {
290        json!(["get", property_str(property, op)?])
291    };
292
293    // Record the expected type so an enclosing `any` can guard on it. `$id`
294    // records under its own key (looked up as `["id"]` above).
295    if !value.is_null() {
296        let key = if is_id {
297            "$id"
298        } else {
299            property_str(property, op)?
300        };
301        expected.set(key, js_typeof(value));
302    }
303
304    // A missing property is not `null` for legacy filters, so `== null` also
305    // requires the property to be present (and `!= null` accepts its absence).
306    if op == "==" && !is_id && value.is_null() {
307        let p = property_str(property, op)?;
308        return Ok(json!(["all", ["has", p], ["==", get, Json::Null]]));
309    }
310    if op == "!=" && !is_id && value.is_null() {
311        let p = property_str(property, op)?;
312        return Ok(json!(["any", ["!", ["has", p]], ["!=", get, Json::Null]]));
313    }
314
315    Ok(json!([op, get, value]))
316}
317
318fn convert_in_op(property: &Json, values: &[Json], negate: bool) -> Result<Json, FilterError> {
319    if values.is_empty() {
320        return Ok(json!(negate));
321    }
322
323    let op = if negate { "!in" } else { "in" };
324    let get = match property.as_str() {
325        Some("$type") => json!(["geometry-type"]),
326        Some("$id") => json!(["id"]),
327        _ => json!(["get", property_str(property, op)?]),
328    };
329
330    // A homogeneous list of strings or numbers can use one `match` rather than
331    // a chain of `==`/`!=`.
332    let type0 = js_typeof(&values[0]);
333    let uniform = values.iter().all(|v| js_typeof(v) == type0);
334    if uniform && (type0 == "string" || type0 == "number") {
335        let unique = sort_and_dedupe(values);
336        return Ok(json!(["match", get, unique, !negate, negate]));
337    }
338
339    let (combiner, cmp) = if negate { ("all", "!=") } else { ("any", "==") };
340    let mut out = vec![json!(combiner)];
341    for v in values {
342        out.push(json!([cmp, get, v]));
343    }
344    Ok(Json::Array(out))
345}
346
347fn convert_has_op(property: &Json) -> Result<Json, FilterError> {
348    match property.as_str() {
349        Some("$type") => Ok(json!(true)),
350        Some("$id") => Ok(json!(["!=", ["id"], Json::Null])),
351        _ => Ok(json!(["has", property_str(property, "has")?])),
352    }
353}
354
355/// The property operand as a string, or a [`FilterError`] — legacy filters
356/// always name properties with a bare string.
357fn property_str<'a>(property: &'a Json, op: &str) -> Result<&'a str, FilterError> {
358    property
359        .as_str()
360        .ok_or_else(|| FilterError::PropertyNotString { op: op.to_string() })
361}
362
363/// The JS `typeof` of a JSON value, matching the strings the `typeof`
364/// expression produces.
365fn js_typeof(v: &Json) -> &'static str {
366    match v {
367        Json::Bool(_) => "boolean",
368        Json::Number(_) => "number",
369        Json::String(_) => "string",
370        // JS: `typeof null`, `typeof []`, `typeof {}` are all "object".
371        _ => "object",
372    }
373}
374
375/// Sort like JS `Array.prototype.sort()` (lexicographic by string form) and
376/// drop adjacent duplicates, so a `match` gets unique branch labels.
377fn sort_and_dedupe(values: &[Json]) -> Vec<Json> {
378    let mut sorted = values.to_vec();
379    sorted.sort_by_cached_key(js_string);
380    let mut unique: Vec<Json> = Vec::with_capacity(sorted.len());
381    for v in sorted {
382        if unique.last() != Some(&v) {
383            unique.push(v);
384        }
385    }
386    unique
387}
388
389/// The JS `String(v)` form used as the default sort key.
390fn js_string(v: &Json) -> String {
391    match v {
392        Json::String(s) => s.clone(),
393        Json::Number(n) => n.to_string(),
394        Json::Bool(b) => b.to_string(),
395        Json::Null => "null".to_string(),
396        other => other.to_string(),
397    }
398}