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}