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/// Fuzzy-search `records` for `query`, optionally restricted to one `kind`
59/// and/or one enclosing `parent` type. Returns every hit above the quality
60/// cutoff, best first — callers truncate to their own limit, so the length is
61/// the true match count.
62pub fn search<'a>(
63    query: &str,
64    records: &'a [SchemaRecord],
65    kind: Option<Kind>,
66    parent: Option<&str>,
67) -> Vec<Hit<'a>> {
68    use rayon::prelude::*;
69    // Records score independently, so scan them in parallel — the win shows
70    // on large schemas (tens of thousands of records), and rayon's overhead
71    // is microseconds on small ones.
72    let mut hits: Vec<Hit> = records
73        .par_iter()
74        .filter(|r| kind.is_none_or(|k| r.kind == k))
75        .filter(|r| {
76            parent.is_none_or(|p| {
77                r.parent
78                    .as_deref()
79                    .is_some_and(|rp| rp.eq_ignore_ascii_case(p))
80            })
81        })
82        .filter_map(|r| score::score(query, r).map(|score| Hit { record: r, score }))
83        .collect();
84
85    // highest score first; break ties toward the shorter path (the more
86    // "central" definition — `User` before `AdminUserAuditLogEntry`).
87    hits.sort_by(|a, b| {
88        b.score
89            .cmp(&a.score)
90            .then_with(|| a.record.path.len().cmp(&b.record.path.len()))
91    });
92    if let Some(top) = hits.first().map(|h| h.score) {
93        let floor = (top as f64 * TAIL_CUTOFF) as i64;
94        hits.retain(|h| h.score >= floor);
95    }
96    hits
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    fn rec(name: &str, parent: Option<&str>, kind: Kind) -> SchemaRecord {
104        let path = match parent {
105            Some(p) => format!("{p}.{name}"),
106            None => name.to_string(),
107        };
108        SchemaRecord {
109            path,
110            name: name.into(),
111            kind,
112            parent: parent.map(Into::into),
113            type_ref: None,
114            args: vec![],
115            description: None,
116            deprecated: None,
117            directives: vec![],
118        }
119    }
120
121    #[test]
122    fn parent_filter_resolves_a_real_type() {
123        let records = vec![
124            rec("employees", Some("Company"), Kind::Field),
125            rec("name", Some("CompanyProfile"), Kind::Field),
126        ];
127        // exact type name, any case — returns the schema's spelling
128        assert_eq!(parent_filter("Company.employe", &records), Some("Company"));
129        assert_eq!(parent_filter("company.employe", &records), Some("Company"));
130        // a mere prefix of a type, or an unqualified query → no filter
131        assert_eq!(parent_filter("Comp.employe", &records), None);
132        assert_eq!(parent_filter("employe", &records), None);
133    }
134
135    #[test]
136    fn parent_filter_snaps_a_misspelled_type_to_the_closest() {
137        let records = vec![
138            rec("employees", Some("Company"), Kind::Field),
139            rec("name", Some("CompanyProfile"), Kind::Field),
140        ];
141        // transposition: Compnay → Company (CompanyProfile is out of budget)
142        assert_eq!(parent_filter("Compnay.employe", &records), Some("Company"));
143        // nothing close enough → no filter
144        assert_eq!(parent_filter("Zebra.employe", &records), None);
145    }
146
147    #[test]
148    fn parent_filter_declines_an_ambiguous_misspelling() {
149        let records = vec![
150            rec("id", Some("Vser"), Kind::Field),
151            rec("id", Some("Usor"), Kind::Field),
152        ];
153        // `User` is distance 1 from both — ambiguous, so no filter
154        assert_eq!(parent_filter("User.id", &records), None);
155    }
156
157    #[test]
158    fn parent_filter_excludes_other_types() {
159        let records = vec![
160            rec("employees", Some("Company"), Kind::Field),
161            rec("employees", Some("CompanyProfile"), Kind::Field),
162            rec("employer", Some("CompanyMemberStats"), Kind::Field),
163        ];
164        let hits = search("Company.employe", &records, None, Some("Company"));
165        assert_eq!(hits.len(), 1);
166        assert_eq!(hits[0].record.path, "Company.employees");
167    }
168
169    #[test]
170    fn weak_tail_is_cut_when_a_strong_match_exists() {
171        let records = vec![
172            rec("user", Some("Query"), Kind::Query),
173            rec("userProfile", Some("Query"), Kind::Query),
174            // matches `user` only as a scattered subsequence
175            rec("uzszezr", Some("Query"), Kind::Query),
176        ];
177        let paths: Vec<&str> = search("user", &records, None, None)
178            .iter()
179            .map(|h| h.record.path.as_str())
180            .collect();
181        assert_eq!(paths, ["Query.user", "Query.userProfile"]);
182    }
183
184    #[test]
185    fn weak_matches_survive_when_nothing_stronger_exists() {
186        let records = vec![rec("uzszezr", Some("Query"), Kind::Query)];
187        assert_eq!(search("user", &records, None, None).len(), 1);
188    }
189
190    #[test]
191    fn search_returns_all_hits_above_the_cutoff() {
192        // no internal truncation — the caller applies its own limit
193        let records: Vec<SchemaRecord> = (0..50)
194            .map(|i| rec(&format!("user{i}"), Some("Query"), Kind::Query))
195            .collect();
196        assert_eq!(search("user", &records, None, None).len(), 50);
197    }
198}