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/// Which records a search may consider, independent of the query itself: a
102/// kind, an enclosing type, a return type. Grouped so every search path takes
103/// one argument, and so adding a filter doesn't ripple through six signatures.
104#[derive(Default, Clone, Copy)]
105pub struct Filters<'a> {
106    /// Only this kind of entity (`-k`).
107    pub kind: Option<Kind>,
108    /// Only members of this enclosing type (a resolved `Type.field` qualifier).
109    pub parent: Option<&'a str>,
110    /// Only fields whose type is this, wrappers peeled (`--returns`). Wildcards
111    /// allowed; a plain name is an exact, case-insensitive match.
112    pub returns: Option<&'a str>,
113}
114
115impl Filters<'_> {
116    /// Compile once, then test many records — this is where the `returns`
117    /// pattern gets parsed, rather than per record.
118    pub fn compile(&self) -> Predicate<'_> {
119        Predicate {
120            kind: self.kind,
121            parent: self.parent,
122            returns: self.returns.map(glob::Pattern::new),
123        }
124    }
125}
126
127/// A compiled [`Filters`], ready to test records.
128pub struct Predicate<'a> {
129    kind: Option<Kind>,
130    parent: Option<&'a str>,
131    returns: Option<glob::Pattern>,
132}
133
134impl Predicate<'_> {
135    /// Whether `r` passes every filter. A record with no type never satisfies
136    /// a `returns` filter — asking what returns a `User` is asking about
137    /// fields, not about the types themselves.
138    pub fn accepts(&self, r: &SchemaRecord) -> bool {
139        self.kind.is_none_or(|k| r.kind == k)
140            && self.parent.is_none_or(|p| {
141                r.parent
142                    .as_deref()
143                    .is_some_and(|rp| rp.eq_ignore_ascii_case(p))
144            })
145            && self
146                .returns
147                .as_ref()
148                .is_none_or(|pat| r.base_type().is_some_and(|t| pat.matches(t)))
149    }
150}
151
152/// Search `records` for `query` within `filters`. A wildcard query enumerates
153/// exact matches; anything else is fuzzy-scored and its weak tail cut. Returns
154/// every surviving hit, best first — callers truncate to their own limit, so
155/// the length is the true match count.
156pub fn search<'a>(query: &str, records: &'a [SchemaRecord], filters: Filters<'_>) -> Vec<Hit<'a>> {
157    let predicate = filters.compile();
158    if glob::is_pattern(query) {
159        return glob_search(query, records, &predicate);
160    }
161    fuzzy_search(query, records, &predicate)
162}
163
164/// Enumerate the records a wildcard pattern matches. A pattern containing `.`
165/// matches the qualified path (`User.*`, `*.email`); a bare one matches the
166/// leaf name (`get*`). Every match is exact by construction, so there's no
167/// quality tail to cut and no fuzzy score to rank by — order by kind (roots
168/// and types before leaves), then alphabetically, so listings read predictably.
169fn glob_search<'a>(
170    pattern: &str,
171    records: &'a [SchemaRecord],
172    predicate: &Predicate<'_>,
173) -> Vec<Hit<'a>> {
174    use rayon::prelude::*;
175    // Parsed once, then tested against every record.
176    let pattern = glob::Pattern::new(pattern);
177    let against_path = pattern.targets_path();
178    let mut hits: Vec<Hit> = records
179        .par_iter()
180        .filter(|r| predicate.accepts(r))
181        .filter(|r| {
182            let text = if against_path { &r.path } else { &r.name };
183            pattern.matches(text)
184        })
185        .map(|r| Hit {
186            record: r,
187            score: r.kind.weight(),
188        })
189        .collect();
190    hits.sort_by(|a, b| {
191        b.score
192            .cmp(&a.score)
193            .then_with(|| a.record.path.cmp(&b.record.path))
194    });
195    hits
196}
197
198fn fuzzy_search<'a>(
199    query: &str,
200    records: &'a [SchemaRecord],
201    predicate: &Predicate<'_>,
202) -> Vec<Hit<'a>> {
203    use rayon::prelude::*;
204    // Records score independently, so scan them in parallel — the win shows
205    // on large schemas (tens of thousands of records), and rayon's overhead
206    // is microseconds on small ones.
207    let mut hits: Vec<Hit> = records
208        .par_iter()
209        .filter(|r| predicate.accepts(r))
210        .filter_map(|r| score::score(query, r).map(|score| Hit { record: r, score }))
211        .collect();
212
213    // highest score first; break ties toward the shorter path (the more
214    // "central" definition — `User` before `AdminUserAuditLogEntry`).
215    hits.sort_by(|a, b| {
216        b.score
217            .cmp(&a.score)
218            .then_with(|| a.record.path.len().cmp(&b.record.path.len()))
219    });
220    if let Some(top) = hits.first().map(|h| h.score) {
221        let floor = (top as f64 * TAIL_CUTOFF) as i64;
222        hits.retain(|h| h.score >= floor);
223    }
224    hits
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    fn rec(name: &str, parent: Option<&str>, kind: Kind) -> SchemaRecord {
232        typed_rec(name, parent, kind, None)
233    }
234
235    fn typed_rec(
236        name: &str,
237        parent: Option<&str>,
238        kind: Kind,
239        type_ref: Option<&str>,
240    ) -> SchemaRecord {
241        let path = match parent {
242            Some(p) => format!("{p}.{name}"),
243            None => name.to_string(),
244        };
245        SchemaRecord {
246            path,
247            name: name.into(),
248            kind,
249            parent: parent.map(Into::into),
250            type_ref: type_ref.map(Into::into),
251            args: vec![],
252            description: None,
253            deprecated: None,
254            directives: vec![],
255            possible_types: vec![],
256        }
257    }
258
259    #[test]
260    fn parent_filter_resolves_a_real_type() {
261        let records = vec![
262            rec("employees", Some("Company"), Kind::Field),
263            rec("name", Some("CompanyProfile"), Kind::Field),
264        ];
265        // exact type name, any case — returns the schema's spelling
266        assert_eq!(parent_filter("Company.employe", &records), Some("Company"));
267        assert_eq!(parent_filter("company.employe", &records), Some("Company"));
268        // a mere prefix of a type, or an unqualified query → no filter
269        assert_eq!(parent_filter("Comp.employe", &records), None);
270        assert_eq!(parent_filter("employe", &records), None);
271    }
272
273    #[test]
274    fn parent_filter_snaps_a_misspelled_type_to_the_closest() {
275        let records = vec![
276            rec("employees", Some("Company"), Kind::Field),
277            rec("name", Some("CompanyProfile"), Kind::Field),
278        ];
279        // transposition: Compnay → Company (CompanyProfile is out of budget)
280        assert_eq!(parent_filter("Compnay.employe", &records), Some("Company"));
281        // nothing close enough → no filter
282        assert_eq!(parent_filter("Zebra.employe", &records), None);
283    }
284
285    #[test]
286    fn parent_filter_declines_an_ambiguous_misspelling() {
287        let records = vec![
288            rec("id", Some("Vser"), Kind::Field),
289            rec("id", Some("Usor"), Kind::Field),
290        ];
291        // `User` is distance 1 from both — ambiguous, so no filter
292        assert_eq!(parent_filter("User.id", &records), None);
293    }
294
295    #[test]
296    fn parent_filter_excludes_other_types() {
297        let records = vec![
298            rec("employees", Some("Company"), Kind::Field),
299            rec("employees", Some("CompanyProfile"), Kind::Field),
300            rec("employer", Some("CompanyMemberStats"), Kind::Field),
301        ];
302        let hits = search(
303            "Company.employe",
304            &records,
305            Filters {
306                parent: Some("Company"),
307                ..Default::default()
308            },
309        );
310        assert_eq!(hits.len(), 1);
311        assert_eq!(hits[0].record.path, "Company.employees");
312    }
313
314    #[test]
315    fn spaced_qualifier_rewrites_type_plus_word() {
316        let records = vec![
317            rec("name", Some("User"), Kind::Field),
318            rec("lastName", Some("User"), Kind::Field),
319        ];
320        assert_eq!(
321            spaced_qualifier("User name", &records).as_deref(),
322            Some("User.name")
323        );
324        assert_eq!(
325            spaced_qualifier("user name", &records).as_deref(),
326            Some("user.name")
327        );
328        // phrases, dots, and non-type first words stay untouched
329        assert_eq!(spaced_qualifier("cancel a subscription", &records), None);
330        assert_eq!(spaced_qualifier("User.name", &records), None);
331        assert_eq!(spaced_qualifier("employee summary", &records), None);
332    }
333
334    #[test]
335    fn named_hit_accepts_exact_and_boundary_words() {
336        let records = vec![rec("name", Some("User"), Kind::Field)];
337        let hits = search("User.name", &records, Default::default());
338        assert!(named_hit("User.name", &hits));
339        assert!(named_hit("user.NAME", &hits));
340
341        // the leaf appearing whole at a word boundary also counts —
342        // `User.name` against a schema with only lastName/fullName
343        let variants = vec![
344            rec("lastName", Some("User"), Kind::Field),
345            rec("fullName", Some("User"), Kind::Field),
346        ];
347        let hits = search(
348            "User.name",
349            &variants,
350            Filters {
351                parent: Some("User"),
352                ..Default::default()
353            },
354        );
355        assert!(named_hit("User.name", &hits));
356    }
357
358    #[test]
359    fn named_hit_rejects_mid_word_and_scattered_matches() {
360        let records = vec![rec("accountant", Some("User"), Kind::Field)];
361        // `count` sits mid-word in accountant — a fuzzy match, not a naming
362        let hits = search("count", &records, Default::default());
363        assert!(!hits.is_empty());
364        assert!(!named_hit("count", &hits));
365    }
366
367    #[test]
368    fn wildcard_enumerates_a_types_members() {
369        let records = vec![
370            rec("email", Some("User"), Kind::Field),
371            rec("id", Some("User"), Kind::Field),
372            rec("email", Some("UserProfile"), Kind::Field),
373            rec("User", None, Kind::Object),
374        ];
375        let paths: Vec<&str> = search("User.*", &records, Default::default())
376            .iter()
377            .map(|h| h.record.path.as_str())
378            .collect();
379        // only User's members, alphabetical — not UserProfile's, not User itself
380        assert_eq!(paths, ["User.email", "User.id"]);
381    }
382
383    #[test]
384    fn brace_alternation_enumerates_each_branch() {
385        let records = vec![
386            rec("firstName", Some("User"), Kind::Field),
387            rec("lastName", Some("User"), Kind::Field),
388            rec("middleName", Some("User"), Kind::Field),
389        ];
390        let paths: Vec<&str> = search("User.{first,last}Name", &records, Default::default())
391            .iter()
392            .map(|h| h.record.path.as_str())
393            .collect();
394        assert_eq!(paths, ["User.firstName", "User.lastName"]);
395    }
396
397    #[test]
398    fn wildcard_without_a_dot_matches_leaf_names() {
399        let records = vec![
400            rec("getUser", Some("Query"), Kind::Query),
401            rec("getCompany", Some("Query"), Kind::Query),
402            rec("forget", Some("User"), Kind::Field),
403        ];
404        let paths: Vec<&str> = search("get*", &records, Default::default())
405            .iter()
406            .map(|h| h.record.path.as_str())
407            .collect();
408        // anchored, so `forget` is out; roots outrank fields, then alphabetical
409        assert_eq!(paths, ["Query.getCompany", "Query.getUser"]);
410    }
411
412    #[test]
413    fn wildcard_keeps_every_match_regardless_of_kind_weight() {
414        // the fuzzy tail cutoff would drop a field (weight 20) under a root
415        // (60); enumeration must not lose members that way
416        let records = vec![
417            rec("user", Some("Query"), Kind::Query),
418            rec("name", Some("User"), Kind::Field),
419        ];
420        assert_eq!(search("*", &records, Default::default()).len(), 2);
421    }
422
423    #[test]
424    fn returns_filter_finds_fields_by_return_type() {
425        let records = vec![
426            // the case a name search can't reach: the field isn't called Company
427            typed_rec("myEmployer", Some("Query"), Kind::Query, Some("Company")),
428            typed_rec(
429                "employees",
430                Some("Company"),
431                Kind::Field,
432                Some("[Employee!]!"),
433            ),
434            typed_rec("name", Some("Company"), Kind::Field, Some("String!")),
435            typed_rec("Company", None, Kind::Object, None),
436        ];
437        let paths: Vec<&str> = search(
438            "*",
439            &records,
440            Filters {
441                returns: Some("Company"),
442                ..Default::default()
443            },
444        )
445        .iter()
446        .map(|h| h.record.path.as_str())
447        .collect();
448        // the field returning Company — not the Company type itself
449        assert_eq!(paths, ["Query.myEmployer"]);
450
451        // wrappers are peeled: [Employee!]! matches Employee
452        let paths: Vec<&str> = search(
453            "*",
454            &records,
455            Filters {
456                returns: Some("Employee"),
457                ..Default::default()
458            },
459        )
460        .iter()
461        .map(|h| h.record.path.as_str())
462        .collect();
463        assert_eq!(paths, ["Company.employees"]);
464    }
465
466    #[test]
467    fn returns_filter_accepts_wildcards_and_composes() {
468        let records = vec![
469            typed_rec("a", Some("Mutation"), Kind::Mutation, Some("APayload!")),
470            typed_rec("b", Some("Mutation"), Kind::Mutation, Some("BPayload!")),
471            typed_rec("c", Some("Mutation"), Kind::Mutation, Some("String")),
472            typed_rec("d", Some("Type"), Kind::Field, Some("APayload")),
473        ];
474        // wildcard return type, narrowed by kind
475        let paths: Vec<&str> = search(
476            "*",
477            &records,
478            Filters {
479                kind: Some(Kind::Mutation),
480                returns: Some("*Payload"),
481                ..Default::default()
482            },
483        )
484        .iter()
485        .map(|h| h.record.path.as_str())
486        .collect();
487        assert_eq!(paths, ["Mutation.a", "Mutation.b"]);
488    }
489
490    #[test]
491    fn weak_tail_is_cut_when_a_strong_match_exists() {
492        let records = vec![
493            rec("user", Some("Query"), Kind::Query),
494            rec("userProfile", Some("Query"), Kind::Query),
495            // matches `user` only as a scattered subsequence
496            rec("uzszezr", Some("Query"), Kind::Query),
497        ];
498        let paths: Vec<&str> = search("user", &records, Default::default())
499            .iter()
500            .map(|h| h.record.path.as_str())
501            .collect();
502        assert_eq!(paths, ["Query.user", "Query.userProfile"]);
503    }
504
505    #[test]
506    fn weak_matches_survive_when_nothing_stronger_exists() {
507        let records = vec![rec("uzszezr", Some("Query"), Kind::Query)];
508        assert_eq!(search("user", &records, Default::default()).len(), 1);
509    }
510
511    #[test]
512    fn search_returns_all_hits_above_the_cutoff() {
513        // no internal truncation — the caller applies its own limit
514        let records: Vec<SchemaRecord> = (0..50)
515            .map(|i| rec(&format!("user{i}"), Some("Query"), Kind::Query))
516            .collect();
517        assert_eq!(search("user", &records, Default::default()).len(), 50);
518    }
519}