Skip to main content

laterite_core/
search.rs

1//! Text folding and matching for lookups.
2//!
3//! A [`SearchProfile`] composes three small seams: [`Normalizer`]s fold text,
4//! [`QueryExpander`]s widen a folded query into alternatives, and a [`Matcher`]
5//! turns those candidates into a `sea_query` condition.
6//!
7//! **Folding runs here, in Rust, applied identically to the query and to any
8//! stored value.** NFKD and punctuation folding cannot be reimplemented
9//! identically across Postgres, MySQL and SQLite, so doing it in collations,
10//! triggers or generated columns lets the write side and the query side drift
11//! apart, which breaks matching silently. Everything else in this module follows
12//! from that constraint.
13//!
14//! ```
15//! use laterite_core::search::SearchProfile;
16//!
17//! let profile = SearchProfile::folding();
18//! // Case and diacritics fold, so the query reaches the stored spelling.
19//! assert_eq!(profile.fold("Ávila"), "avila");
20//! ```
21
22use std::sync::Arc;
23
24use sea_query::{Alias, Condition, Expr, LikeExpr};
25use unicode_normalization::UnicodeNormalization;
26
27/// The most candidates a single query fans out to. Each candidate becomes one
28/// OR'd `LIKE`, and a leading-wildcard `LIKE` is a scan, so the fan-out is
29/// capped rather than left to the expanders.
30pub const MAX_CANDIDATES: usize = 8;
31
32/// One text fold, applied to both the query and any stored value.
33///
34/// `key` names the fold in a chain's [`fingerprint`](NormalizerChain::fingerprint),
35/// so it must stay stable once a deployment has folded data with it.
36pub trait Normalizer: Send + Sync + 'static {
37    fn key(&self) -> &'static str;
38    fn fold(&self, text: &str) -> String;
39}
40
41/// Unicode-aware lowercase.
42pub struct Lowercase;
43impl Normalizer for Lowercase {
44    fn key(&self) -> &'static str {
45        "case"
46    }
47    fn fold(&self, text: &str) -> String {
48        text.to_lowercase()
49    }
50}
51
52/// Strips diacritics: NFKD, then drop the combining marks, so `Ávila` folds to
53/// `avila` and `fi` to `fi`.
54pub struct Diacritics;
55impl Normalizer for Diacritics {
56    fn key(&self) -> &'static str {
57        "diacritics"
58    }
59    fn fold(&self, text: &str) -> String {
60        text.nfkd()
61            .filter(|c| !is_combining_mark(*c))
62            .collect::<String>()
63    }
64}
65
66/// Whether `c` is a Unicode combining mark (general category M*), the class NFKD
67/// splits a diacritic into.
68fn is_combining_mark(c: char) -> bool {
69    matches!(c as u32,
70        0x0300..=0x036F      // combining diacritical marks
71        | 0x0483..=0x0489
72        | 0x0591..=0x05BD
73        | 0x0610..=0x061A
74        | 0x064B..=0x065F
75        | 0x0670
76        | 0x06D6..=0x06DC
77        | 0x0711
78        | 0x0730..=0x074A
79        | 0x07A6..=0x07B0
80        | 0x0900..=0x0903    // Indic combining marks
81        | 0x093A..=0x094F
82        | 0x0951..=0x0957
83        | 0x0962..=0x0963
84        | 0x0981..=0x0983
85        | 0x09BC..=0x09CD
86        | 0x0A01..=0x0A03
87        | 0x0A3C..=0x0A4D
88        | 0x0C00..=0x0C04    // Telugu
89        | 0x0C3E..=0x0C56
90        | 0x0C81..=0x0C83    // Kannada
91        | 0x0CBC..=0x0CD6
92        | 0x1AB0..=0x1AFF
93        | 0x1DC0..=0x1DFF
94        | 0x20D0..=0x20F0
95        | 0xFE20..=0xFE2F)
96}
97
98/// Folds apostrophes, hyphens and other punctuation away, so `watson's` and
99/// `watsons` fold alike.
100pub struct Punctuation;
101impl Normalizer for Punctuation {
102    fn key(&self) -> &'static str {
103        "punctuation"
104    }
105    fn fold(&self, text: &str) -> String {
106        text.chars()
107            .filter(|c| !c.is_ascii_punctuation() && !matches!(c, '\u{2018}'..='\u{201F}'))
108            .collect()
109    }
110}
111
112/// Collapses runs of whitespace to one space and trims the ends.
113pub struct Whitespace;
114impl Normalizer for Whitespace {
115    fn key(&self) -> &'static str {
116        "whitespace"
117    }
118    fn fold(&self, text: &str) -> String {
119        text.split_whitespace().collect::<Vec<_>>().join(" ")
120    }
121}
122
123/// An ordered set of folds, applied left to right.
124///
125/// [`fingerprint`](Self::fingerprint) joins the keys. A deployment that maintains
126/// a folded column stores the fingerprint beside it: when the chain changes, the
127/// stored values were folded by the old chain and need a backfill, and comparing
128/// fingerprints is how that is noticed rather than silently mismatching.
129#[derive(Clone, Default)]
130pub struct NormalizerChain {
131    folds: Vec<Arc<dyn Normalizer>>,
132}
133
134impl NormalizerChain {
135    pub fn new() -> Self {
136        Self::default()
137    }
138
139    pub fn with(mut self, fold: impl Normalizer) -> Self {
140        self.folds.push(Arc::new(fold));
141        self
142    }
143
144    /// Case, diacritics, punctuation, whitespace: the fold a lookup wants unless
145    /// it has a reason to differ.
146    pub fn standard() -> Self {
147        Self::new()
148            .with(Lowercase)
149            .with(Diacritics)
150            .with(Punctuation)
151            .with(Whitespace)
152    }
153
154    pub fn fold(&self, text: &str) -> String {
155        self.folds
156            .iter()
157            .fold(text.to_string(), |acc, f| f.fold(&acc))
158    }
159
160    /// The chain's identity: its keys in order. Store it beside folded data to
161    /// detect a chain change that invalidates it.
162    pub fn fingerprint(&self) -> String {
163        self.folds
164            .iter()
165            .map(|f| f.key())
166            .collect::<Vec<_>>()
167            .join("+")
168    }
169
170    pub fn is_empty(&self) -> bool {
171        self.folds.is_empty()
172    }
173}
174
175/// Widens a folded query into alternative spellings. Query-side only: an expander
176/// never runs against stored values, so adding one needs no backfill.
177pub trait QueryExpander: Send + Sync + 'static {
178    /// The alternatives for `folded_q`, excluding the query itself.
179    fn expand(&self, folded_q: &str) -> Vec<String>;
180}
181
182/// Maps whole folded queries to alternatives, both ways. The mechanism is here;
183/// the data belongs to whoever owns the vocabulary (a plugin, or a table).
184#[derive(Default)]
185pub struct Aliases {
186    pairs: Vec<(String, String)>,
187}
188
189impl Aliases {
190    pub fn new() -> Self {
191        Self::default()
192    }
193
194    /// Both spellings reach each other. Values are folded by the caller's chain
195    /// before being added, or a folded query never matches them.
196    pub fn pair(mut self, a: &str, b: &str) -> Self {
197        self.pairs.push((a.to_string(), b.to_string()));
198        self
199    }
200}
201
202impl QueryExpander for Aliases {
203    fn expand(&self, folded_q: &str) -> Vec<String> {
204        let mut out = Vec::new();
205        for (a, b) in &self.pairs {
206            if a == folded_q {
207                out.push(b.clone());
208            } else if b == folded_q {
209                out.push(a.clone());
210            }
211        }
212        out
213    }
214}
215
216/// Swaps English number words for digits and back, so `7 rivers` and
217/// `seven rivers` reach each other.
218pub struct Numerals;
219
220const NUMBER_WORDS: [(&str, &str); 20] = [
221    ("zero", "0"),
222    ("one", "1"),
223    ("two", "2"),
224    ("three", "3"),
225    ("four", "4"),
226    ("five", "5"),
227    ("six", "6"),
228    ("seven", "7"),
229    ("eight", "8"),
230    ("nine", "9"),
231    ("ten", "10"),
232    ("eleven", "11"),
233    ("twelve", "12"),
234    ("thirteen", "13"),
235    ("fourteen", "14"),
236    ("fifteen", "15"),
237    ("sixteen", "16"),
238    ("seventeen", "17"),
239    ("eighteen", "18"),
240    ("nineteen", "19"),
241];
242
243impl QueryExpander for Numerals {
244    fn expand(&self, folded_q: &str) -> Vec<String> {
245        let mut out = Vec::new();
246        for (word, digit) in NUMBER_WORDS {
247            // Whole tokens only: a substring swap would turn "money" into
248            // "m1y" and "10th" into "tenth" inside unrelated words.
249            let swapped_to_digit = swap_token(folded_q, word, digit);
250            if swapped_to_digit != folded_q {
251                out.push(swapped_to_digit);
252            }
253            let swapped_to_word = swap_token(folded_q, digit, word);
254            if swapped_to_word != folded_q {
255                out.push(swapped_to_word);
256            }
257        }
258        out
259    }
260}
261
262/// Replaces whole space-separated tokens equal to `from` with `to`.
263fn swap_token(text: &str, from: &str, to: &str) -> String {
264    text.split(' ')
265        .map(|t| if t == from { to } else { t })
266        .collect::<Vec<_>>()
267        .join(" ")
268}
269
270/// What a matcher is given: the column to match and the candidate terms, already
271/// folded, expanded and escaped.
272pub struct MatchCx<'a> {
273    pub column: &'a str,
274    pub candidates: &'a [String],
275}
276
277/// Builds the SQL condition for a set of candidates.
278///
279/// Synchronous by design: `sea_query`'s expression tree is not `Send`, so a
280/// matcher runs inside the scoped build block rather than across an await.
281pub trait Matcher: Send + Sync + 'static {
282    fn key(&self) -> &'static str;
283    /// Whether this matcher can run here. A matcher needing a database extension
284    /// checks the capability set and stands down when it is absent, so one
285    /// profile stays portable across the three databases.
286    fn applies(&self, caps: &crate::capabilities::CapabilitySet) -> bool {
287        let _ = caps;
288        true
289    }
290    fn condition(&self, cx: &MatchCx<'_>) -> Condition;
291}
292
293/// Case-folded substring matching: the portable default, `LIKE '%term%'` OR'd
294/// across candidates with an explicit escape (SQLite requires one).
295pub struct LikeMatcher;
296
297impl Matcher for LikeMatcher {
298    fn key(&self) -> &'static str {
299        "like"
300    }
301    fn condition(&self, cx: &MatchCx<'_>) -> Condition {
302        let mut any = Condition::any();
303        for term in cx.candidates {
304            any = any.add(
305                Expr::expr(sea_query::Func::lower(Expr::col(Alias::new(cx.column))))
306                    .like(LikeExpr::new(format!("%{term}%")).escape('\\')),
307            );
308        }
309        any
310    }
311}
312
313/// Escapes the `LIKE` metacharacters so a user's `%` or `_` matches literally.
314pub fn like_escape(text: &str) -> String {
315    let mut out = String::with_capacity(text.len());
316    for c in text.chars() {
317        if matches!(c, '\\' | '%' | '_') {
318            out.push('\\');
319        }
320        out.push(c);
321    }
322    out
323}
324
325/// A composed search behaviour: how text folds, how a query widens, and how the
326/// result becomes SQL. A lookup attaches one.
327pub struct SearchProfile {
328    chain: NormalizerChain,
329    expanders: Vec<Arc<dyn QueryExpander>>,
330    matchers: Vec<Arc<dyn Matcher>>,
331}
332
333impl Default for SearchProfile {
334    fn default() -> Self {
335        Self::new()
336    }
337}
338
339impl SearchProfile {
340    /// Case-folding only, matched with `LIKE`. What a lookup gets without asking.
341    pub fn new() -> Self {
342        Self {
343            chain: NormalizerChain::new().with(Lowercase),
344            expanders: Vec::new(),
345            matchers: vec![Arc::new(LikeMatcher)],
346        }
347    }
348
349    /// The standard fold (case, diacritics, punctuation, whitespace).
350    ///
351    /// The fold applies to the query always. It reaches stored values only where
352    /// the lookup matches against a folded column; against a raw column a folded
353    /// query still misses a stored `Ávila`, which is what the folded column is
354    /// for.
355    pub fn folding() -> Self {
356        Self::new().with_chain(NormalizerChain::standard())
357    }
358
359    pub fn with_chain(mut self, chain: NormalizerChain) -> Self {
360        self.chain = chain;
361        self
362    }
363
364    pub fn with_expander(mut self, expander: impl QueryExpander) -> Self {
365        self.expanders.push(Arc::new(expander));
366        self
367    }
368
369    /// Adds a matcher ahead of the default, for [`Self::matcher`] to pick when
370    /// the deployment supports it.
371    pub fn with_matcher(mut self, matcher: impl Matcher) -> Self {
372        self.matchers.insert(0, Arc::new(matcher));
373        self
374    }
375
376    pub fn chain(&self) -> &NormalizerChain {
377        &self.chain
378    }
379
380    pub fn fold(&self, text: &str) -> String {
381        self.chain.fold(text)
382    }
383
384    /// The terms to match: the folded query plus its expansions, escaped for
385    /// `LIKE` and capped at [`MAX_CANDIDATES`]. Empty when the query folds away
386    /// to nothing, which a caller reads as "match nothing".
387    pub fn candidates(&self, q: &str) -> Vec<String> {
388        // Trim before folding: a chain without `Whitespace` would otherwise turn
389        // a run of spaces into a real term matching nothing.
390        let folded = self.chain.fold(q.trim());
391        if folded.is_empty() {
392            return Vec::new();
393        }
394        let mut terms = vec![folded.clone()];
395        for expander in &self.expanders {
396            for alt in expander.expand(&folded) {
397                if !alt.is_empty() && !terms.contains(&alt) {
398                    terms.push(alt);
399                }
400            }
401        }
402        terms.truncate(MAX_CANDIDATES);
403        terms.iter().map(|t| like_escape(t)).collect()
404    }
405
406    /// The first matcher this deployment supports, falling back to `LIKE`. A
407    /// matcher that cannot run is skipped, never an error, so one profile works
408    /// on all three databases.
409    pub fn matcher(&self, caps: &crate::capabilities::CapabilitySet) -> &dyn Matcher {
410        for matcher in &self.matchers {
411            if matcher.applies(caps) {
412                return matcher.as_ref();
413            }
414        }
415        // `new()` always seeds `LikeMatcher`, which applies everywhere.
416        self.matchers
417            .last()
418            .expect("a profile always carries a matcher")
419            .as_ref()
420    }
421
422    /// The condition matching `q` against `column`.
423    ///
424    /// `None` means the query carried no terms (it was blank, or folded away to
425    /// nothing). What that means is the caller's to decide: a lookup that opens
426    /// as a dropdown adds no clause and lists the first rows, which is the usual
427    /// reading. It is never "match nothing" here.
428    pub fn condition(
429        &self,
430        caps: &crate::capabilities::CapabilitySet,
431        column: &str,
432        q: &str,
433    ) -> Option<Condition> {
434        let candidates = self.candidates(q);
435        if candidates.is_empty() {
436            return None;
437        }
438        Some(self.matcher(caps).condition(&MatchCx {
439            column,
440            candidates: &candidates,
441        }))
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    #[test]
450    fn case_folds() {
451        assert_eq!(Lowercase.fold("ÁVILA Straße"), "ávila straße");
452    }
453
454    #[test]
455    fn diacritics_fold_to_their_base_letters() {
456        assert_eq!(Diacritics.fold("Ávila"), "Avila");
457        assert_eq!(Diacritics.fold("café"), "cafe");
458        assert_eq!(Diacritics.fold("Ünïcôdé"), "Unicode");
459    }
460
461    /// NFKD is compatibility decomposition, so a ligature splits too.
462    #[test]
463    fn compatibility_forms_decompose() {
464        assert_eq!(Diacritics.fold("file"), "file");
465        assert_eq!(Diacritics.fold("½"), "1⁄2");
466    }
467
468    /// A script whose combining marks carry meaning still folds without losing
469    /// its base characters.
470    #[test]
471    fn indic_text_keeps_its_base_characters() {
472        let kannada = "ಬೆಂಗಳೂರು";
473        let folded = Diacritics.fold(kannada);
474        assert!(folded.starts_with('ಬ'), "base letters survive: {folded}");
475        assert!(folded.chars().count() < kannada.chars().count());
476    }
477
478    #[test]
479    fn punctuation_folds_away() {
480        assert_eq!(Punctuation.fold("watson's"), "watsons");
481        assert_eq!(Punctuation.fold("jean-luc"), "jeanluc");
482        assert_eq!(Punctuation.fold("o\u{2019}brien"), "obrien");
483    }
484
485    #[test]
486    fn whitespace_collapses() {
487        assert_eq!(Whitespace.fold("  two   words \n"), "two words");
488    }
489
490    #[test]
491    fn the_standard_chain_applies_every_fold() {
492        let chain = NormalizerChain::standard();
493        assert_eq!(chain.fold("  Ávila's   Café "), "avilas cafe");
494    }
495
496    #[test]
497    fn a_fingerprint_names_the_chain_in_order() {
498        assert_eq!(
499            NormalizerChain::standard().fingerprint(),
500            "case+diacritics+punctuation+whitespace"
501        );
502        // A different order is a different fingerprint: the folds do not commute.
503        let reversed = NormalizerChain::new().with(Punctuation).with(Lowercase);
504        assert_eq!(reversed.fingerprint(), "punctuation+case");
505    }
506
507    #[test]
508    fn numerals_swap_whole_tokens_both_ways() {
509        let out = Numerals.expand("7 rivers");
510        assert!(out.contains(&"seven rivers".to_string()));
511        let out = Numerals.expand("seven rivers");
512        assert!(out.contains(&"7 rivers".to_string()));
513    }
514
515    /// A substring swap would rewrite unrelated words; only whole tokens move.
516    #[test]
517    fn numerals_leave_words_containing_a_number_word_alone() {
518        assert!(Numerals.expand("money").is_empty());
519        assert!(Numerals.expand("nineteenth").is_empty());
520        assert!(Numerals.expand("someone").is_empty());
521    }
522
523    #[test]
524    fn aliases_expand_both_directions() {
525        let aliases = Aliases::new().pair("bengaluru", "bangalore");
526        assert_eq!(aliases.expand("bengaluru"), ["bangalore"]);
527        assert_eq!(aliases.expand("bangalore"), ["bengaluru"]);
528        assert!(aliases.expand("mysuru").is_empty());
529    }
530
531    #[test]
532    fn like_metacharacters_are_escaped() {
533        assert_eq!(like_escape("100%"), "100\\%");
534        assert_eq!(like_escape("a_b"), "a\\_b");
535        assert_eq!(like_escape("back\\slash"), "back\\\\slash");
536    }
537
538    #[test]
539    fn candidates_fold_expand_and_escape() {
540        let profile = SearchProfile::folding().with_expander(Numerals);
541        let out = profile.candidates("  7 Rívers ");
542        assert_eq!(out[0], "7 rivers", "the folded query comes first");
543        assert!(out.contains(&"seven rivers".to_string()));
544    }
545
546    #[test]
547    fn candidates_escape_a_wildcard_in_the_query() {
548        let out = SearchProfile::new().candidates("100%");
549        assert_eq!(out, ["100\\%"]);
550    }
551
552    #[test]
553    fn an_empty_query_yields_no_candidates() {
554        assert!(SearchProfile::folding().candidates("   ").is_empty());
555        assert!(SearchProfile::folding().candidates("").is_empty());
556        // Punctuation-only folds away to nothing, which is not a match-everything.
557        assert!(SearchProfile::folding().candidates("!!!").is_empty());
558        // Whitespace-only too, even on a profile whose chain does not fold it.
559        assert!(SearchProfile::new().candidates("   ").is_empty());
560    }
561
562    #[test]
563    fn candidates_are_capped() {
564        // An expander that always returns more alternatives than the cap allows.
565        struct Noisy;
566        impl QueryExpander for Noisy {
567            fn expand(&self, _q: &str) -> Vec<String> {
568                (0..50).map(|i| format!("alt{i}")).collect()
569            }
570        }
571        let out = SearchProfile::new().with_expander(Noisy).candidates("q");
572        assert_eq!(out.len(), MAX_CANDIDATES);
573    }
574
575    #[test]
576    fn duplicate_expansions_are_dropped() {
577        struct Same;
578        impl QueryExpander for Same {
579            fn expand(&self, q: &str) -> Vec<String> {
580                vec![q.to_string(), "other".to_string()]
581            }
582        }
583        let out = SearchProfile::new().with_expander(Same).candidates("q");
584        assert_eq!(out, ["q", "other"]);
585    }
586
587    #[test]
588    fn the_default_matcher_is_like() {
589        let caps = crate::capabilities::CapabilitySet::default();
590        assert_eq!(SearchProfile::new().matcher(&caps).key(), "like");
591    }
592
593    #[test]
594    fn a_matcher_that_does_not_apply_is_skipped() {
595        struct NeedsExtension;
596        impl Matcher for NeedsExtension {
597            fn key(&self) -> &'static str {
598                "trigram"
599            }
600            fn applies(&self, caps: &crate::capabilities::CapabilitySet) -> bool {
601                caps.has("pg_trgm")
602            }
603            fn condition(&self, _cx: &MatchCx<'_>) -> Condition {
604                Condition::any()
605            }
606        }
607        let profile = SearchProfile::new().with_matcher(NeedsExtension);
608        let bare = crate::capabilities::CapabilitySet::default();
609        assert_eq!(
610            profile.matcher(&bare).key(),
611            "like",
612            "falls back, not errors"
613        );
614    }
615
616    #[test]
617    fn no_condition_for_a_query_that_folds_away() {
618        let caps = crate::capabilities::CapabilitySet::default();
619        assert!(SearchProfile::folding()
620            .condition(&caps, "name", "  ")
621            .is_none());
622    }
623}