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<T>`]
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::{Common, Date, Keyword, Number, Text, common_opts, exists_q, wrap};
41use crate::query::{AsQuery, Query, Root};
42
43/// Define a concrete map handle over leaf kind `$Leaf`. Each carries a field
44/// path and scope `S`, exposes `key`/`has_key`/`exists`, and `key` returns a
45/// fully-typed `$Leaf<S>` leaf handle.
46macro_rules! map_handle {
47    ($(#[$meta:meta])* $Name:ident => $Leaf:ident, $kind:literal) => {
48        $(#[$meta])*
49        #[derive(Debug, Clone)]
50        pub struct $Name<S = Root> {
51            path: String,
52            _scope: PhantomData<fn() -> S>,
53        }
54
55        impl<S> $Name<S> {
56            pub fn at(path: impl Into<String>) -> Self {
57                Self {
58                    path: path.into(),
59                    _scope: PhantomData,
60                }
61            }
62
63            #[doc = concat!("A specific runtime key → a fully-typed `", $kind, "` leaf handle, \
64                queried like any other ", $kind, " field.")]
65            pub fn key(&self, key: impl AsRef<str>) -> $Leaf<S> {
66                $Leaf::at(format!("{}.{}", self.path, key.as_ref()))
67            }
68
69            /// The map holds the given key with a non-null value.
70            pub fn has_key(&self, key: impl AsRef<str>) -> Query<S> {
71                exists_q(&format!("{}.{}", self.path, key.as_ref()))
72            }
73
74            /// The map field itself is present (has at least one key).
75            pub fn exists(&self) -> Query<S> {
76                exists_q(&self.path)
77            }
78        }
79    };
80}
81
82map_handle!(
83    /// A dynamic-key object whose values are analyzed full text (`map` with a
84    /// `text`/`identifier` value kind). [`key`](Self::key) yields a [`Text`]
85    /// leaf; [`search`](Self::search) runs full text across every key.
86    TextMap => Text, "text"
87);
88map_handle!(
89    /// A dynamic-key object whose values are exact strings (`map` with a
90    /// `keyword`/`enum`/`uuid` value kind). [`key`](Self::key) yields a
91    /// [`Keyword`] leaf for exact match. No `search` — exact-match maps use
92    /// `key(..).eq(..)` / `has_key(..)`, consistent with the leaf split.
93    KeywordMap => Keyword, "keyword"
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    DateMap => Date, "date"
100);
101
102/// A dynamic-key object whose values are numbers (`map` with a numeric value
103/// kind — `short`…`double`, `decimal`). [`key`](Self::key) yields a
104/// [`Number<T>`] leaf for range/exact operators (`gt`/`between`/`eq`/…); `T` is
105/// the numeric type the schema's value kind implies (e.g. `i64` for `long`,
106/// `f64` for `double`). `has_key`/`exists` are presence checks. No `search` —
107/// numbers aren't full text.
108#[derive(Debug, Clone)]
109pub struct NumberMap<T, S = Root> {
110    path: String,
111    _marker: PhantomData<fn() -> (T, S)>,
112}
113
114impl<T, S> NumberMap<T, S> {
115    pub fn at(path: impl Into<String>) -> Self {
116        Self {
117            path: path.into(),
118            _marker: PhantomData,
119        }
120    }
121
122    /// The map holds the given key with a non-null value.
123    pub fn has_key(&self, key: impl AsRef<str>) -> Query<S> {
124        exists_q(&format!("{}.{}", self.path, key.as_ref()))
125    }
126
127    /// The map field itself is present (has at least one key).
128    pub fn exists(&self) -> Query<S> {
129        exists_q(&self.path)
130    }
131}
132
133impl<T, S> NumberMap<T, S>
134where
135    T: Into<serde_json::Value> + Copy,
136{
137    /// A specific runtime key → a fully-typed [`Number<T>`] leaf handle,
138    /// queried like any other numeric field.
139    pub fn key(&self, key: impl AsRef<str>) -> Number<T, S> {
140        Number::at(format!("{}.{}", self.path, key.as_ref()))
141    }
142}
143
144impl<S> TextMap<S> {
145    /// Full-text search across *every* key at once, with optional per-key
146    /// preference. Returns a [`MapSearch`] builder; add [`prefer`](MapSearch::prefer)
147    /// to weight a key (e.g. the user's locale).
148    pub fn search(&self, query: impl Into<String>) -> MapSearch<S> {
149        MapSearch::new(&self.path, query.into())
150    }
151}
152
153/// A cross-key full-text query over a [`TextMap`]: one analyzed `query` matched
154/// against every key, with optional per-key preference. Renders a `multi_match`
155/// of `type: best_fields` over the preferred keys (each `field^weight`) plus the
156/// wildcard `path.*` fallback, so the best-scoring key wins without
157/// double-counting. [`only_preferred`](Self::only_preferred) drops the fallback.
158#[derive(Debug, Clone)]
159pub struct MapSearch<S = Root> {
160    path: String,
161    query: String,
162    preferred: Vec<String>,
163    include_all: bool,
164    opts: Map<String, Value>,
165    common: Common,
166    _scope: PhantomData<fn() -> S>,
167}
168
169impl<S> MapSearch<S> {
170    fn new(path: &str, query: String) -> Self {
171        Self {
172            path: path.to_string(),
173            query,
174            preferred: Vec::new(),
175            include_all: true,
176            opts: Map::new(),
177            common: Common::default(),
178            _scope: PhantomData,
179        }
180    }
181
182    /// Prefer a key, weighting its score by `weight` (`path.key^weight`). Add
183    /// several to rank keys (e.g. the user's locale highest).
184    #[must_use]
185    pub fn prefer(mut self, key: impl AsRef<str>, weight: f32) -> Self {
186        self.preferred
187            .push(format!("{}.{}^{weight}", self.path, key.as_ref()));
188        self
189    }
190
191    /// Search only the preferred keys — drop the `path.*` fallback that
192    /// otherwise also searches every other key.
193    #[must_use]
194    pub fn only_preferred(mut self) -> Self {
195        self.include_all = false;
196        self
197    }
198
199    fn set(mut self, key: &str, value: Value) -> Self {
200        self.opts.insert(key.to_string(), value);
201        self
202    }
203
204    /// Combine analyzed terms with `"AND"` or `"OR"` (default `"OR"`).
205    #[must_use]
206    pub fn operator(self, operator: impl Into<String>) -> Self {
207        self.set("operator", Value::String(operator.into()))
208    }
209
210    /// Edit distance for analyzed terms — `"AUTO"` or an integer-as-string.
211    #[must_use]
212    pub fn fuzziness(self, fuzziness: impl Into<String>) -> Self {
213        self.set("fuzziness", Value::String(fuzziness.into()))
214    }
215
216    /// How many of the analyzed terms must match (e.g. `"75%"`, `"2"`).
217    #[must_use]
218    pub fn minimum_should_match(self, value: impl Into<String>) -> Self {
219        self.set("minimum_should_match", Value::String(value.into()))
220    }
221
222    common_opts!(common);
223}
224
225impl<S> AsQuery<S> for MapSearch<S> {
226    fn into_query(self) -> Option<Query<S>> {
227        let mut fields: Vec<Value> = self.preferred.iter().cloned().map(Value::String).collect();
228        if self.include_all {
229            fields.push(Value::String(format!("{}.*", self.path)));
230        }
231        let mut body = self.opts;
232        body.insert("query".to_string(), Value::String(self.query));
233        body.insert("fields".to_string(), Value::Array(fields));
234        // `best_fields` takes the max score per field, so the same term matching
235        // several keys isn't double-counted.
236        body.entry("type")
237            .or_insert_with(|| Value::String("best_fields".to_string()));
238        self.common.write(&mut body);
239        Some(wrap("multi_match", body))
240    }
241}