Skip to main content

flusso_query/handles/
map.rs

1//! Map field handles: dynamic-key objects whose values share one leaf kind.
2//!
3//! A `map` field (e.g. translations `{"en": …, "it": …}`) has runtime-determined
4//! keys but a compile-time-known value kind. That split is the whole point:
5//! `.key(runtime_str)` returns a **fully-typed** leaf handle of the declared kind
6//! — [`Text`] for a [`TextMap`], [`Keyword`] for a [`KeywordMap`], [`Number`]
7//! for a [`NumberMap`], [`Date`] for a [`DateMap`] — so a specific key is queried
8//! with full type safety while keys stay open-ended.
9//!
10//! Three operators are shared by every map handle:
11//!
12//! - [`key`](TextMap::key) — a specific key → a typed leaf handle.
13//! - [`has_key`](TextMap::has_key) — a presence check on one key.
14//! - [`exists`](TextMap::exists) — a presence check on the whole field.
15//!
16//! [`TextMap`] additionally offers [`search`](TextMap::search): full-text across
17//! *every* key at once, with optional per-key preference — the common
18//! cross-language case, without enumerating keys or silently missing one.
19//!
20//! ```
21//! use flusso_query::{AsQuery, Root, TextMap};
22//!
23//! // A specific key — a fully-typed `Text` leaf.
24//! let q = TextMap::<Root>::at("title").key("it").matches("ciao").to_value();
25//! assert_eq!(q["match"]["title.it"], serde_json::json!("ciao"));
26//!
27//! // Cross-key search, preferring Italian then English.
28//! let q = TextMap::<Root>::at("title")
29//!     .search("ciao")
30//!     .prefer("it", 3.0)
31//!     .prefer("en", 2.0)
32//!     .to_value();
33//! assert_eq!(q["multi_match"]["type"], serde_json::json!("best_fields"));
34//! ```
35
36use std::marker::PhantomData;
37
38use serde_json::{Map, Value};
39
40use super::sort::MapSortValueKind;
41use super::{
42    Common, Date, Fuzziness, Keyword, MapKey, MapKeySort, MinimumShouldMatch, Number, Operator,
43    Text, common_opts, exists_q, wrap,
44};
45use crate::query::{AsQuery, Query, Root};
46
47/// Define a concrete map handle. Each carries a field path and scope `S` and
48/// exposes the key-agnostic `has_key`/`exists`; per-handle `key` (a fully-typed
49/// leaf) and `sort_key` (a key-fallback [`MapKeySort`]) are defined alongside.
50macro_rules! map_handle {
51    ($(#[$meta:meta])* $Name:ident) => {
52        $(#[$meta])*
53        #[derive(Debug, Clone)]
54        pub struct $Name<S = Root> {
55            path: String,
56            _scope: PhantomData<fn() -> S>,
57        }
58
59        impl<S> $Name<S> {
60            pub fn at(path: impl Into<String>) -> Self {
61                Self {
62                    path: path.into(),
63                    _scope: PhantomData,
64                }
65            }
66
67            /// The map holds the given key with a non-null value.
68            pub fn has_key(&self, key: impl AsRef<str>) -> Query<S> {
69                exists_q(&format!("{}.{}", self.path, key.as_ref()))
70            }
71
72            /// The map field itself is present (has at least one key).
73            pub fn exists(&self) -> Query<S> {
74                exists_q(&self.path)
75            }
76        }
77    };
78}
79
80map_handle!(
81    /// A dynamic-key object whose values are analyzed full text (`map` with a
82    /// `text`/`identifier` value kind). [`key`](Self::key) yields a [`Text`]
83    /// leaf; [`search`](Self::search) runs full text across every key;
84    /// [`sort_key`](Self::sort_key) orders by a key, with `.or(..)` fallback.
85    TextMap
86);
87map_handle!(
88    /// A dynamic-key object whose values are exact strings (`map` with a
89    /// `keyword`/`enum`/`uuid` value kind). [`key`](Self::key) yields a
90    /// [`Keyword`] leaf for exact match. No `search` — exact-match maps use
91    /// `key(..).eq(..)` / `has_key(..)`, consistent with the leaf split.
92    /// [`sort_key`](Self::sort_key) orders by a key, with `.or(..)` fallback.
93    KeywordMap
94);
95map_handle!(
96    /// A dynamic-key object whose values are dates (`map` with a
97    /// `date`/`timestamp` value kind). [`key`](Self::key) yields a [`Date`]
98    /// leaf for range/exact operators (`gte`/`between`/`eq`/…).
99    /// [`sort_key`](Self::sort_key) orders by a key, with `.or(..)` fallback.
100    DateMap
101);
102
103impl<S> TextMap<S> {
104    /// A specific runtime key → a fully-typed [`Text`] leaf, queried like any
105    /// other text field. It carries the [`MapKey`] marker, so it is **not**
106    /// directly sortable — order a text map by key with
107    /// [`sort_key`](Self::sort_key), which is correct at query time and supports
108    /// key fallback.
109    pub fn key(&self, key: impl AsRef<str>) -> Text<S, MapKey> {
110        Text::map_key(format!("{}.{}", self.path, key.as_ref()))
111    }
112
113    /// Sort by this key, with optional fallback — `sort_key("it").or("en")`
114    /// orders by `it`, else `en` (language fallback). Returns a [`MapKeySort`];
115    /// pass it to [`SortBuilder::by`](crate::SortBuilder::by) or `.asc()`/`.desc()`.
116    pub fn sort_key(&self, key: impl Into<String>) -> MapKeySort<S> {
117        MapKeySort::new(self.path.clone(), key, MapSortValueKind::String)
118    }
119}
120
121impl<S> KeywordMap<S> {
122    /// A specific runtime key → a fully-typed [`Keyword`] leaf for exact match.
123    /// It carries the [`MapKey`] marker, so it is **not** directly sortable —
124    /// order a keyword map by key with [`sort_key`](Self::sort_key).
125    pub fn key(&self, key: impl AsRef<str>) -> Keyword<S, MapKey> {
126        Keyword::map_key(format!("{}.{}", self.path, key.as_ref()))
127    }
128
129    /// Sort by this key, with optional fallback (`sort_key("a").or("b")`).
130    /// Returns a [`MapKeySort`]; see it for the rendered shape.
131    pub fn sort_key(&self, key: impl Into<String>) -> MapKeySort<S> {
132        MapKeySort::new(self.path.clone(), key, MapSortValueKind::String)
133    }
134}
135
136impl<S> DateMap<S> {
137    /// A specific runtime key → a fully-typed [`Date`] leaf for range/exact
138    /// operators. A `date` map key is doc-valued on its bare path, so the leaf
139    /// sorts directly; [`sort_key`](Self::sort_key) adds ordered key fallback.
140    pub fn key(&self, key: impl AsRef<str>) -> Date<S> {
141        Date::at(format!("{}.{}", self.path, key.as_ref()))
142    }
143
144    /// Sort by this key, with optional fallback (`sort_key("eu").or("us")`), by
145    /// epoch millis. Returns a [`MapKeySort`]; see it for the rendered shape.
146    pub fn sort_key(&self, key: impl Into<String>) -> MapKeySort<S> {
147        MapKeySort::new(self.path.clone(), key, MapSortValueKind::Date)
148    }
149}
150
151/// A dynamic-key object whose values are numbers (`map` with a numeric value
152/// kind — `short`…`double`, `decimal`). [`key`](Self::key) yields a [`Number`]
153/// leaf for range/exact operators (`gt`/`between`/`eq`/…). `has_key`/`exists`
154/// are presence checks. No `search` — numbers aren't full text.
155#[derive(Debug, Clone)]
156pub struct NumberMap<K, S = Root> {
157    path: String,
158    _marker: PhantomData<fn() -> (K, S)>,
159}
160
161impl<K, S> NumberMap<K, S> {
162    pub fn at(path: impl Into<String>) -> Self {
163        Self {
164            path: path.into(),
165            _marker: PhantomData,
166        }
167    }
168
169    /// A specific runtime key → a [`Number`] leaf handle of value kind `K`,
170    /// queried like any other numeric field. A numeric map key is doc-valued on
171    /// its bare path, so the leaf sorts directly; [`sort_key`](Self::sort_key)
172    /// adds ordered key fallback.
173    pub fn key(&self, key: impl AsRef<str>) -> Number<K, S> {
174        Number::at(format!("{}.{}", self.path, key.as_ref()))
175    }
176
177    /// Sort by this key, with optional fallback (`sort_key("usd").or("eur")`).
178    /// Returns a [`MapKeySort`]; see it for the rendered shape.
179    pub fn sort_key(&self, key: impl Into<String>) -> MapKeySort<S> {
180        MapKeySort::new(self.path.clone(), key, MapSortValueKind::Number)
181    }
182
183    /// The map holds the given key with a non-null value.
184    pub fn has_key(&self, key: impl AsRef<str>) -> Query<S> {
185        exists_q(&format!("{}.{}", self.path, key.as_ref()))
186    }
187
188    /// The map field itself is present (has at least one key).
189    pub fn exists(&self) -> Query<S> {
190        exists_q(&self.path)
191    }
192}
193
194impl<S> TextMap<S> {
195    /// Full-text search across *every* key at once, with optional per-key
196    /// preference. Returns a [`MapSearch`] builder; add [`prefer`](MapSearch::prefer)
197    /// to weight a key (e.g. the user's locale).
198    pub fn search(&self, query: impl Into<String>) -> MapSearch<S> {
199        MapSearch::new(&self.path, query.into())
200    }
201}
202
203/// A cross-key full-text query over a [`TextMap`]: one analyzed `query` matched
204/// against every key, with optional per-key preference. Renders a `multi_match`
205/// of `type: best_fields` over the preferred keys (each `field^weight`) plus the
206/// wildcard `path.*` fallback, so the best-scoring key wins without
207/// double-counting. [`only_preferred`](Self::only_preferred) drops the fallback.
208#[derive(Debug, Clone)]
209pub struct MapSearch<S = Root> {
210    path: String,
211    query: String,
212    preferred: Vec<String>,
213    include_all: bool,
214    opts: Map<String, Value>,
215    common: Common,
216    _scope: PhantomData<fn() -> S>,
217}
218
219impl<S> MapSearch<S> {
220    fn new(path: &str, query: String) -> Self {
221        Self {
222            path: path.to_string(),
223            query,
224            preferred: Vec::new(),
225            include_all: true,
226            opts: Map::new(),
227            common: Common::default(),
228            _scope: PhantomData,
229        }
230    }
231
232    /// Prefer a key, weighting its score by `weight` (`path.key^weight`). Add
233    /// several to rank keys (e.g. the user's locale highest).
234    #[must_use]
235    pub fn prefer(mut self, key: impl AsRef<str>, weight: f32) -> Self {
236        self.preferred
237            .push(format!("{}.{}^{weight}", self.path, key.as_ref()));
238        self
239    }
240
241    /// Search only the preferred keys — drop the `path.*` fallback that
242    /// otherwise also searches every other key.
243    #[must_use]
244    pub fn only_preferred(mut self) -> Self {
245        self.include_all = false;
246        self
247    }
248
249    fn set(mut self, key: &str, value: Value) -> Self {
250        self.opts.insert(key.to_string(), value);
251        self
252    }
253
254    /// Combine analyzed terms with [`Operator::And`] or [`Operator::Or`]
255    /// (default `Or`).
256    #[must_use]
257    pub fn operator(self, operator: Operator) -> Self {
258        self.set("operator", Value::String(operator.as_str().to_string()))
259    }
260
261    /// Edit distance for analyzed terms ([`Fuzziness::Auto`] is the usual choice).
262    #[must_use]
263    pub fn fuzziness(self, fuzziness: Fuzziness) -> Self {
264        self.set("fuzziness", fuzziness.to_value())
265    }
266
267    /// How many of the analyzed terms must match
268    /// (e.g. `2`, `MinimumShouldMatch::percent(75)`).
269    #[must_use]
270    pub fn minimum_should_match(self, value: impl Into<MinimumShouldMatch>) -> Self {
271        self.set("minimum_should_match", value.into().to_value())
272    }
273
274    common_opts!(common);
275}
276
277impl<S> AsQuery<S> for MapSearch<S> {
278    fn into_query(self) -> Option<Query<S>> {
279        let mut fields: Vec<Value> = self.preferred.iter().cloned().map(Value::String).collect();
280        if self.include_all {
281            fields.push(Value::String(format!("{}.*", self.path)));
282        }
283        let mut body = self.opts;
284        body.insert("query".to_string(), Value::String(self.query));
285        body.insert("fields".to_string(), Value::Array(fields));
286        // `best_fields` takes the max score per field, so the same term matching
287        // several keys isn't double-counted.
288        body.entry("type")
289            .or_insert_with(|| Value::String("best_fields".to_string()));
290        self.common.write(&mut body);
291        Some(wrap("multi_match", body))
292    }
293}