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