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 glob;
6pub mod score;
7
8pub struct Hit<'a> {
9    pub record: &'a SchemaRecord,
10    pub score: i64,
11}
12
13/// Drop hits scoring below this fraction of the top hit. The scorer's tiers
14/// (exact ≈1000, prefix ≈700, subsequence ≤600, typo ≤260) make the ratio
15/// meaningful: a strong match present means the long subsequence tail is
16/// noise; with only weak matches, everything in the same tier survives.
17const TAIL_CUTOFF: f64 = 0.4;
18
19/// Resolve a `Type.field` query's qualifier to a schema type: an exact
20/// (case-insensitive) parent name, or failing that the unique closest
21/// misspelling within the scorer's typo budget (`Compnay.employe` →
22/// `Company`). Returns the schema's own spelling of the type — the caller
23/// hard-filters to its members instead of fuzzy-matching types that merely
24/// share the prefix (`Company.employe` stays out of
25/// `CompanyProfileAndIntent`). `None` (unqualified, no type close enough, or
26/// a distance tie) falls back to plain fuzzy matching.
27pub fn parent_filter<'a>(query: &str, records: &'a [SchemaRecord]) -> Option<&'a str> {
28    let (_, Some(qualifier)) = score::parse_qualified(query) else {
29        return None;
30    };
31    let parents: std::collections::HashSet<&str> =
32        records.iter().filter_map(|r| r.parent.as_deref()).collect();
33    if let Some(exact) = parents.iter().find(|p| p.eq_ignore_ascii_case(qualifier)) {
34        return Some(exact);
35    }
36    // Misspelling fallback: the unique closest type wins; a tie is ambiguous.
37    let q = qualifier.to_ascii_lowercase();
38    let mut best: Option<(usize, &str)> = None;
39    let mut tied = false;
40    for p in parents {
41        let Some(d) = score::typo_distance(&q, &p.to_ascii_lowercase()) else {
42            continue;
43        };
44        match best {
45            Some((bd, _)) if d > bd => {}
46            Some((bd, _)) if d == bd => tied = true,
47            _ => {
48                best = Some((d, p));
49                tied = false;
50            }
51        }
52    }
53    match (best, tied) {
54        (Some((_, p)), false) => Some(p),
55        _ => None,
56    }
57}
58
59/// Rewrite a two-word query whose first word exactly names a schema type into
60/// the qualified form: `User name` → `User.name`. The space (vs the dot) is
61/// kept as an intent signal by the caller — it means "around this", so the
62/// semantic combine stays on where a dot-typed exact hit would skip it.
63/// `None` for anything else (phrases, dots, no matching type).
64pub fn spaced_qualifier(query: &str, records: &[SchemaRecord]) -> Option<String> {
65    if query.contains('.') {
66        return None;
67    }
68    let mut words = query.split_whitespace();
69    let (Some(first), Some(second), None) = (words.next(), words.next(), words.next()) else {
70        return None;
71    };
72    records
73        .iter()
74        .filter_map(|r| r.parent.as_deref())
75        .any(|p| p.eq_ignore_ascii_case(first))
76        .then(|| format!("{first}.{second}"))
77}
78
79/// Whether some hit *names* the query's leaf: equal to it, or containing it
80/// whole at a camelCase/underscore word boundary (`name` → `lastName`,
81/// `User.name` → `User.fullName`). That's the scorer's strongest tier short
82/// of exact — the signal that the user typed a word that really exists, so
83/// meaning-based ranking would only append lookalike filler below it.
84/// (GraphQL names are ASCII by spec, so byte and char indices agree.)
85pub fn named_hit(query: &str, hits: &[Hit]) -> bool {
86    let (leaf, _) = score::parse_qualified(query);
87    let leaf = leaf.to_ascii_lowercase();
88    if leaf.is_empty() {
89        return false;
90    }
91    hits.iter().any(|h| {
92        let lower = h.record.name.to_ascii_lowercase();
93        let chars: Vec<char> = h.record.name.chars().collect();
94        let boundary = score::boundaries(&chars);
95        lower
96            .match_indices(&leaf)
97            .any(|(i, _)| boundary.get(i).copied().unwrap_or(false))
98    })
99}
100
101/// Fuzzy-search `records` for `query`, optionally restricted to one `kind`
102/// and/or one enclosing `parent` type. Returns every hit above the quality
103/// cutoff, best first — callers truncate to their own limit, so the length is
104/// the true match count.
105pub fn search<'a>(
106    query: &str,
107    records: &'a [SchemaRecord],
108    kind: Option<Kind>,
109    parent: Option<&str>,
110    returns: Option<&str>,
111) -> Vec<Hit<'a>> {
112    // Built once here rather than per record; a plain type name matches
113    // exactly (case-insensitive), and wildcards work for free.
114    let returns = returns.map(glob::Pattern::new);
115    let returns = returns.as_ref();
116    if glob::is_pattern(query) {
117        return glob_search(query, records, kind, returns);
118    }
119    fuzzy_search(query, records, kind, parent, returns)
120}
121
122/// Whether a record's return type matches the `--returns` pattern. A record
123/// with no type (a type definition, a directive) never matches — asking what
124/// returns a `User` is asking about fields, not about types themselves.
125pub fn matches_returns(r: &SchemaRecord, returns: Option<&glob::Pattern>) -> bool {
126    match returns {
127        None => true,
128        Some(p) => r.base_type().is_some_and(|t| p.matches(t)),
129    }
130}
131
132/// Enumerate the records a wildcard pattern matches. A pattern containing `.`
133/// matches the qualified path (`User.*`, `*.email`); a bare one matches the
134/// leaf name (`get*`). Every match is exact by construction, so there's no
135/// quality tail to cut and no fuzzy score to rank by — order by kind (roots
136/// and types before leaves), then alphabetically, so listings read predictably.
137fn glob_search<'a>(
138    pattern: &str,
139    records: &'a [SchemaRecord],
140    kind: Option<Kind>,
141    returns: Option<&glob::Pattern>,
142) -> Vec<Hit<'a>> {
143    use rayon::prelude::*;
144    // Parsed once, then tested against every record.
145    let pattern = glob::Pattern::new(pattern);
146    let against_path = pattern.targets_path();
147    let mut hits: Vec<Hit> = records
148        .par_iter()
149        .filter(|r| kind.is_none_or(|k| r.kind == k))
150        .filter(|r| matches_returns(r, returns))
151        .filter(|r| {
152            let text = if against_path { &r.path } else { &r.name };
153            pattern.matches(text)
154        })
155        .map(|r| Hit {
156            record: r,
157            score: r.kind.weight(),
158        })
159        .collect();
160    hits.sort_by(|a, b| {
161        b.score
162            .cmp(&a.score)
163            .then_with(|| a.record.path.cmp(&b.record.path))
164    });
165    hits
166}
167
168fn fuzzy_search<'a>(
169    query: &str,
170    records: &'a [SchemaRecord],
171    kind: Option<Kind>,
172    parent: Option<&str>,
173    returns: Option<&glob::Pattern>,
174) -> Vec<Hit<'a>> {
175    use rayon::prelude::*;
176    // Records score independently, so scan them in parallel — the win shows
177    // on large schemas (tens of thousands of records), and rayon's overhead
178    // is microseconds on small ones.
179    let mut hits: Vec<Hit> = records
180        .par_iter()
181        .filter(|r| kind.is_none_or(|k| r.kind == k))
182        .filter(|r| matches_returns(r, returns))
183        .filter(|r| {
184            parent.is_none_or(|p| {
185                r.parent
186                    .as_deref()
187                    .is_some_and(|rp| rp.eq_ignore_ascii_case(p))
188            })
189        })
190        .filter_map(|r| score::score(query, r).map(|score| Hit { record: r, score }))
191        .collect();
192
193    // highest score first; break ties toward the shorter path (the more
194    // "central" definition — `User` before `AdminUserAuditLogEntry`).
195    hits.sort_by(|a, b| {
196        b.score
197            .cmp(&a.score)
198            .then_with(|| a.record.path.len().cmp(&b.record.path.len()))
199    });
200    if let Some(top) = hits.first().map(|h| h.score) {
201        let floor = (top as f64 * TAIL_CUTOFF) as i64;
202        hits.retain(|h| h.score >= floor);
203    }
204    hits
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    fn rec(name: &str, parent: Option<&str>, kind: Kind) -> SchemaRecord {
212        typed_rec(name, parent, kind, None)
213    }
214
215    fn typed_rec(
216        name: &str,
217        parent: Option<&str>,
218        kind: Kind,
219        type_ref: Option<&str>,
220    ) -> SchemaRecord {
221        let path = match parent {
222            Some(p) => format!("{p}.{name}"),
223            None => name.to_string(),
224        };
225        SchemaRecord {
226            path,
227            name: name.into(),
228            kind,
229            parent: parent.map(Into::into),
230            type_ref: type_ref.map(Into::into),
231            args: vec![],
232            description: None,
233            deprecated: None,
234            directives: vec![],
235        }
236    }
237
238    #[test]
239    fn parent_filter_resolves_a_real_type() {
240        let records = vec![
241            rec("employees", Some("Company"), Kind::Field),
242            rec("name", Some("CompanyProfile"), Kind::Field),
243        ];
244        // exact type name, any case — returns the schema's spelling
245        assert_eq!(parent_filter("Company.employe", &records), Some("Company"));
246        assert_eq!(parent_filter("company.employe", &records), Some("Company"));
247        // a mere prefix of a type, or an unqualified query → no filter
248        assert_eq!(parent_filter("Comp.employe", &records), None);
249        assert_eq!(parent_filter("employe", &records), None);
250    }
251
252    #[test]
253    fn parent_filter_snaps_a_misspelled_type_to_the_closest() {
254        let records = vec![
255            rec("employees", Some("Company"), Kind::Field),
256            rec("name", Some("CompanyProfile"), Kind::Field),
257        ];
258        // transposition: Compnay → Company (CompanyProfile is out of budget)
259        assert_eq!(parent_filter("Compnay.employe", &records), Some("Company"));
260        // nothing close enough → no filter
261        assert_eq!(parent_filter("Zebra.employe", &records), None);
262    }
263
264    #[test]
265    fn parent_filter_declines_an_ambiguous_misspelling() {
266        let records = vec![
267            rec("id", Some("Vser"), Kind::Field),
268            rec("id", Some("Usor"), Kind::Field),
269        ];
270        // `User` is distance 1 from both — ambiguous, so no filter
271        assert_eq!(parent_filter("User.id", &records), None);
272    }
273
274    #[test]
275    fn parent_filter_excludes_other_types() {
276        let records = vec![
277            rec("employees", Some("Company"), Kind::Field),
278            rec("employees", Some("CompanyProfile"), Kind::Field),
279            rec("employer", Some("CompanyMemberStats"), Kind::Field),
280        ];
281        let hits = search("Company.employe", &records, None, Some("Company"), None);
282        assert_eq!(hits.len(), 1);
283        assert_eq!(hits[0].record.path, "Company.employees");
284    }
285
286    #[test]
287    fn spaced_qualifier_rewrites_type_plus_word() {
288        let records = vec![
289            rec("name", Some("User"), Kind::Field),
290            rec("lastName", Some("User"), Kind::Field),
291        ];
292        assert_eq!(
293            spaced_qualifier("User name", &records).as_deref(),
294            Some("User.name")
295        );
296        assert_eq!(
297            spaced_qualifier("user name", &records).as_deref(),
298            Some("user.name")
299        );
300        // phrases, dots, and non-type first words stay untouched
301        assert_eq!(spaced_qualifier("cancel a subscription", &records), None);
302        assert_eq!(spaced_qualifier("User.name", &records), None);
303        assert_eq!(spaced_qualifier("employee summary", &records), None);
304    }
305
306    #[test]
307    fn named_hit_accepts_exact_and_boundary_words() {
308        let records = vec![rec("name", Some("User"), Kind::Field)];
309        let hits = search("User.name", &records, None, None, None);
310        assert!(named_hit("User.name", &hits));
311        assert!(named_hit("user.NAME", &hits));
312
313        // the leaf appearing whole at a word boundary also counts —
314        // `User.name` against a schema with only lastName/fullName
315        let variants = vec![
316            rec("lastName", Some("User"), Kind::Field),
317            rec("fullName", Some("User"), Kind::Field),
318        ];
319        let hits = search("User.name", &variants, None, Some("User"), None);
320        assert!(named_hit("User.name", &hits));
321    }
322
323    #[test]
324    fn named_hit_rejects_mid_word_and_scattered_matches() {
325        let records = vec![rec("accountant", Some("User"), Kind::Field)];
326        // `count` sits mid-word in accountant — a fuzzy match, not a naming
327        let hits = search("count", &records, None, None, None);
328        assert!(!hits.is_empty());
329        assert!(!named_hit("count", &hits));
330    }
331
332    #[test]
333    fn wildcard_enumerates_a_types_members() {
334        let records = vec![
335            rec("email", Some("User"), Kind::Field),
336            rec("id", Some("User"), Kind::Field),
337            rec("email", Some("UserProfile"), Kind::Field),
338            rec("User", None, Kind::Object),
339        ];
340        let paths: Vec<&str> = search("User.*", &records, None, None, None)
341            .iter()
342            .map(|h| h.record.path.as_str())
343            .collect();
344        // only User's members, alphabetical — not UserProfile's, not User itself
345        assert_eq!(paths, ["User.email", "User.id"]);
346    }
347
348    #[test]
349    fn brace_alternation_enumerates_each_branch() {
350        let records = vec![
351            rec("firstName", Some("User"), Kind::Field),
352            rec("lastName", Some("User"), Kind::Field),
353            rec("middleName", Some("User"), Kind::Field),
354        ];
355        let paths: Vec<&str> = search("User.{first,last}Name", &records, None, None, None)
356            .iter()
357            .map(|h| h.record.path.as_str())
358            .collect();
359        assert_eq!(paths, ["User.firstName", "User.lastName"]);
360    }
361
362    #[test]
363    fn wildcard_without_a_dot_matches_leaf_names() {
364        let records = vec![
365            rec("getUser", Some("Query"), Kind::Query),
366            rec("getCompany", Some("Query"), Kind::Query),
367            rec("forget", Some("User"), Kind::Field),
368        ];
369        let paths: Vec<&str> = search("get*", &records, None, None, None)
370            .iter()
371            .map(|h| h.record.path.as_str())
372            .collect();
373        // anchored, so `forget` is out; roots outrank fields, then alphabetical
374        assert_eq!(paths, ["Query.getCompany", "Query.getUser"]);
375    }
376
377    #[test]
378    fn wildcard_keeps_every_match_regardless_of_kind_weight() {
379        // the fuzzy tail cutoff would drop a field (weight 20) under a root
380        // (60); enumeration must not lose members that way
381        let records = vec![
382            rec("user", Some("Query"), Kind::Query),
383            rec("name", Some("User"), Kind::Field),
384        ];
385        assert_eq!(search("*", &records, None, None, None).len(), 2);
386    }
387
388    #[test]
389    fn returns_filter_finds_fields_by_return_type() {
390        let records = vec![
391            // the case a name search can't reach: the field isn't called Company
392            typed_rec("myEmployer", Some("Query"), Kind::Query, Some("Company")),
393            typed_rec(
394                "employees",
395                Some("Company"),
396                Kind::Field,
397                Some("[Employee!]!"),
398            ),
399            typed_rec("name", Some("Company"), Kind::Field, Some("String!")),
400            typed_rec("Company", None, Kind::Object, None),
401        ];
402        let paths: Vec<&str> = search("*", &records, None, None, Some("Company"))
403            .iter()
404            .map(|h| h.record.path.as_str())
405            .collect();
406        // the field returning Company — not the Company type itself
407        assert_eq!(paths, ["Query.myEmployer"]);
408
409        // wrappers are peeled: [Employee!]! matches Employee
410        let paths: Vec<&str> = search("*", &records, None, None, Some("Employee"))
411            .iter()
412            .map(|h| h.record.path.as_str())
413            .collect();
414        assert_eq!(paths, ["Company.employees"]);
415    }
416
417    #[test]
418    fn returns_filter_accepts_wildcards_and_composes() {
419        let records = vec![
420            typed_rec("a", Some("Mutation"), Kind::Mutation, Some("APayload!")),
421            typed_rec("b", Some("Mutation"), Kind::Mutation, Some("BPayload!")),
422            typed_rec("c", Some("Mutation"), Kind::Mutation, Some("String")),
423            typed_rec("d", Some("Type"), Kind::Field, Some("APayload")),
424        ];
425        // wildcard return type, narrowed by kind
426        let paths: Vec<&str> = search("*", &records, Some(Kind::Mutation), None, Some("*Payload"))
427            .iter()
428            .map(|h| h.record.path.as_str())
429            .collect();
430        assert_eq!(paths, ["Mutation.a", "Mutation.b"]);
431    }
432
433    #[test]
434    fn weak_tail_is_cut_when_a_strong_match_exists() {
435        let records = vec![
436            rec("user", Some("Query"), Kind::Query),
437            rec("userProfile", Some("Query"), Kind::Query),
438            // matches `user` only as a scattered subsequence
439            rec("uzszezr", Some("Query"), Kind::Query),
440        ];
441        let paths: Vec<&str> = search("user", &records, None, None, None)
442            .iter()
443            .map(|h| h.record.path.as_str())
444            .collect();
445        assert_eq!(paths, ["Query.user", "Query.userProfile"]);
446    }
447
448    #[test]
449    fn weak_matches_survive_when_nothing_stronger_exists() {
450        let records = vec![rec("uzszezr", Some("Query"), Kind::Query)];
451        assert_eq!(search("user", &records, None, None, None).len(), 1);
452    }
453
454    #[test]
455    fn search_returns_all_hits_above_the_cutoff() {
456        // no internal truncation — the caller applies its own limit
457        let records: Vec<SchemaRecord> = (0..50)
458            .map(|i| rec(&format!("user{i}"), Some("Query"), Kind::Query))
459            .collect();
460        assert_eq!(search("user", &records, None, None, None).len(), 50);
461    }
462}