Skip to main content

gqls/search/
mod.rs

1//! The read path: score every record against the query, rank, cut the weak tail.
2
3use crate::model::{Kind, SchemaRecord};
4
5pub mod score;
6
7pub struct Hit<'a> {
8    pub record: &'a SchemaRecord,
9    pub score: i64,
10}
11
12/// Drop hits scoring below this fraction of the top hit. The scorer's tiers
13/// (exact ≈1000, prefix ≈700, subsequence ≤600, typo ≤260) make the ratio
14/// meaningful: a strong match present means the long subsequence tail is
15/// noise; with only weak matches, everything in the same tier survives.
16const TAIL_CUTOFF: f64 = 0.4;
17
18/// Resolve a `Type.field` query's qualifier to a schema type: an exact
19/// (case-insensitive) parent name, or failing that the unique closest
20/// misspelling within the scorer's typo budget (`Compnay.employe` →
21/// `Company`). Returns the schema's own spelling of the type — the caller
22/// hard-filters to its members instead of fuzzy-matching types that merely
23/// share the prefix (`Company.employe` stays out of
24/// `CompanyProfileAndIntent`). `None` (unqualified, no type close enough, or
25/// a distance tie) falls back to plain fuzzy matching.
26pub fn parent_filter<'a>(query: &str, records: &'a [SchemaRecord]) -> Option<&'a str> {
27    let (_, Some(qualifier)) = score::parse_qualified(query) else {
28        return None;
29    };
30    let parents: std::collections::HashSet<&str> =
31        records.iter().filter_map(|r| r.parent.as_deref()).collect();
32    if let Some(exact) = parents.iter().find(|p| p.eq_ignore_ascii_case(qualifier)) {
33        return Some(exact);
34    }
35    // Misspelling fallback: the unique closest type wins; a tie is ambiguous.
36    let q = qualifier.to_ascii_lowercase();
37    let mut best: Option<(usize, &str)> = None;
38    let mut tied = false;
39    for p in parents {
40        let Some(d) = score::typo_distance(&q, &p.to_ascii_lowercase()) else {
41            continue;
42        };
43        match best {
44            Some((bd, _)) if d > bd => {}
45            Some((bd, _)) if d == bd => tied = true,
46            _ => {
47                best = Some((d, p));
48                tied = false;
49            }
50        }
51    }
52    match (best, tied) {
53        (Some((_, p)), false) => Some(p),
54        _ => None,
55    }
56}
57
58/// Rewrite a two-word query whose first word exactly names a schema type into
59/// the qualified form: `User name` → `User.name`. The space (vs the dot) is
60/// kept as an intent signal by the caller — it means "around this", so the
61/// semantic combine stays on where a dot-typed exact hit would skip it.
62/// `None` for anything else (phrases, dots, no matching type).
63pub fn spaced_qualifier(query: &str, records: &[SchemaRecord]) -> Option<String> {
64    if query.contains('.') {
65        return None;
66    }
67    let mut words = query.split_whitespace();
68    let (Some(first), Some(second), None) = (words.next(), words.next(), words.next()) else {
69        return None;
70    };
71    records
72        .iter()
73        .filter_map(|r| r.parent.as_deref())
74        .any(|p| p.eq_ignore_ascii_case(first))
75        .then(|| format!("{first}.{second}"))
76}
77
78/// Whether some hit *names* the query's leaf: equal to it, or containing it
79/// whole at a camelCase/underscore word boundary (`name` → `lastName`,
80/// `User.name` → `User.fullName`). That's the scorer's strongest tier short
81/// of exact — the signal that the user typed a word that really exists, so
82/// meaning-based ranking would only append lookalike filler below it.
83/// (GraphQL names are ASCII by spec, so byte and char indices agree.)
84pub fn named_hit(query: &str, hits: &[Hit]) -> bool {
85    let (leaf, _) = score::parse_qualified(query);
86    let leaf = leaf.to_ascii_lowercase();
87    if leaf.is_empty() {
88        return false;
89    }
90    hits.iter().any(|h| {
91        let lower = h.record.name.to_ascii_lowercase();
92        let chars: Vec<char> = h.record.name.chars().collect();
93        let boundary = score::boundaries(&chars);
94        lower
95            .match_indices(&leaf)
96            .any(|(i, _)| boundary.get(i).copied().unwrap_or(false))
97    })
98}
99
100/// Fuzzy-search `records` for `query`, optionally restricted to one `kind`
101/// and/or one enclosing `parent` type. Returns every hit above the quality
102/// cutoff, best first — callers truncate to their own limit, so the length is
103/// the true match count.
104pub fn search<'a>(
105    query: &str,
106    records: &'a [SchemaRecord],
107    kind: Option<Kind>,
108    parent: Option<&str>,
109) -> Vec<Hit<'a>> {
110    use rayon::prelude::*;
111    // Records score independently, so scan them in parallel — the win shows
112    // on large schemas (tens of thousands of records), and rayon's overhead
113    // is microseconds on small ones.
114    let mut hits: Vec<Hit> = records
115        .par_iter()
116        .filter(|r| kind.is_none_or(|k| r.kind == k))
117        .filter(|r| {
118            parent.is_none_or(|p| {
119                r.parent
120                    .as_deref()
121                    .is_some_and(|rp| rp.eq_ignore_ascii_case(p))
122            })
123        })
124        .filter_map(|r| score::score(query, r).map(|score| Hit { record: r, score }))
125        .collect();
126
127    // highest score first; break ties toward the shorter path (the more
128    // "central" definition — `User` before `AdminUserAuditLogEntry`).
129    hits.sort_by(|a, b| {
130        b.score
131            .cmp(&a.score)
132            .then_with(|| a.record.path.len().cmp(&b.record.path.len()))
133    });
134    if let Some(top) = hits.first().map(|h| h.score) {
135        let floor = (top as f64 * TAIL_CUTOFF) as i64;
136        hits.retain(|h| h.score >= floor);
137    }
138    hits
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    fn rec(name: &str, parent: Option<&str>, kind: Kind) -> SchemaRecord {
146        let path = match parent {
147            Some(p) => format!("{p}.{name}"),
148            None => name.to_string(),
149        };
150        SchemaRecord {
151            path,
152            name: name.into(),
153            kind,
154            parent: parent.map(Into::into),
155            type_ref: None,
156            args: vec![],
157            description: None,
158            deprecated: None,
159            directives: vec![],
160        }
161    }
162
163    #[test]
164    fn parent_filter_resolves_a_real_type() {
165        let records = vec![
166            rec("employees", Some("Company"), Kind::Field),
167            rec("name", Some("CompanyProfile"), Kind::Field),
168        ];
169        // exact type name, any case — returns the schema's spelling
170        assert_eq!(parent_filter("Company.employe", &records), Some("Company"));
171        assert_eq!(parent_filter("company.employe", &records), Some("Company"));
172        // a mere prefix of a type, or an unqualified query → no filter
173        assert_eq!(parent_filter("Comp.employe", &records), None);
174        assert_eq!(parent_filter("employe", &records), None);
175    }
176
177    #[test]
178    fn parent_filter_snaps_a_misspelled_type_to_the_closest() {
179        let records = vec![
180            rec("employees", Some("Company"), Kind::Field),
181            rec("name", Some("CompanyProfile"), Kind::Field),
182        ];
183        // transposition: Compnay → Company (CompanyProfile is out of budget)
184        assert_eq!(parent_filter("Compnay.employe", &records), Some("Company"));
185        // nothing close enough → no filter
186        assert_eq!(parent_filter("Zebra.employe", &records), None);
187    }
188
189    #[test]
190    fn parent_filter_declines_an_ambiguous_misspelling() {
191        let records = vec![
192            rec("id", Some("Vser"), Kind::Field),
193            rec("id", Some("Usor"), Kind::Field),
194        ];
195        // `User` is distance 1 from both — ambiguous, so no filter
196        assert_eq!(parent_filter("User.id", &records), None);
197    }
198
199    #[test]
200    fn parent_filter_excludes_other_types() {
201        let records = vec![
202            rec("employees", Some("Company"), Kind::Field),
203            rec("employees", Some("CompanyProfile"), Kind::Field),
204            rec("employer", Some("CompanyMemberStats"), Kind::Field),
205        ];
206        let hits = search("Company.employe", &records, None, Some("Company"));
207        assert_eq!(hits.len(), 1);
208        assert_eq!(hits[0].record.path, "Company.employees");
209    }
210
211    #[test]
212    fn spaced_qualifier_rewrites_type_plus_word() {
213        let records = vec![
214            rec("name", Some("User"), Kind::Field),
215            rec("lastName", Some("User"), Kind::Field),
216        ];
217        assert_eq!(
218            spaced_qualifier("User name", &records).as_deref(),
219            Some("User.name")
220        );
221        assert_eq!(
222            spaced_qualifier("user name", &records).as_deref(),
223            Some("user.name")
224        );
225        // phrases, dots, and non-type first words stay untouched
226        assert_eq!(spaced_qualifier("cancel a subscription", &records), None);
227        assert_eq!(spaced_qualifier("User.name", &records), None);
228        assert_eq!(spaced_qualifier("employee summary", &records), None);
229    }
230
231    #[test]
232    fn named_hit_accepts_exact_and_boundary_words() {
233        let records = vec![rec("name", Some("User"), Kind::Field)];
234        let hits = search("User.name", &records, None, None);
235        assert!(named_hit("User.name", &hits));
236        assert!(named_hit("user.NAME", &hits));
237
238        // the leaf appearing whole at a word boundary also counts —
239        // `User.name` against a schema with only lastName/fullName
240        let variants = vec![
241            rec("lastName", Some("User"), Kind::Field),
242            rec("fullName", Some("User"), Kind::Field),
243        ];
244        let hits = search("User.name", &variants, None, Some("User"));
245        assert!(named_hit("User.name", &hits));
246    }
247
248    #[test]
249    fn named_hit_rejects_mid_word_and_scattered_matches() {
250        let records = vec![rec("accountant", Some("User"), Kind::Field)];
251        // `count` sits mid-word in accountant — a fuzzy match, not a naming
252        let hits = search("count", &records, None, None);
253        assert!(!hits.is_empty());
254        assert!(!named_hit("count", &hits));
255    }
256
257    #[test]
258    fn weak_tail_is_cut_when_a_strong_match_exists() {
259        let records = vec![
260            rec("user", Some("Query"), Kind::Query),
261            rec("userProfile", Some("Query"), Kind::Query),
262            // matches `user` only as a scattered subsequence
263            rec("uzszezr", Some("Query"), Kind::Query),
264        ];
265        let paths: Vec<&str> = search("user", &records, None, None)
266            .iter()
267            .map(|h| h.record.path.as_str())
268            .collect();
269        assert_eq!(paths, ["Query.user", "Query.userProfile"]);
270    }
271
272    #[test]
273    fn weak_matches_survive_when_nothing_stronger_exists() {
274        let records = vec![rec("uzszezr", Some("Query"), Kind::Query)];
275        assert_eq!(search("user", &records, None, None).len(), 1);
276    }
277
278    #[test]
279    fn search_returns_all_hits_above_the_cutoff() {
280        // no internal truncation — the caller applies its own limit
281        let records: Vec<SchemaRecord> = (0..50)
282            .map(|i| rec(&format!("user{i}"), Some("Query"), Kind::Query))
283            .collect();
284        assert_eq!(search("user", &records, None, None).len(), 50);
285    }
286}