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 any hit's name equals the query's leaf exactly (case-insensitive)
79/// — the signal that the query *named* a specific entity rather than described
80/// one, so meaning-based ranking would only append lookalike filler.
81pub fn has_exact(query: &str, hits: &[Hit]) -> bool {
82    let (leaf, _) = score::parse_qualified(query);
83    hits.iter()
84        .any(|h| h.record.name.eq_ignore_ascii_case(leaf))
85}
86
87/// Fuzzy-search `records` for `query`, optionally restricted to one `kind`
88/// and/or one enclosing `parent` type. Returns every hit above the quality
89/// cutoff, best first — callers truncate to their own limit, so the length is
90/// the true match count.
91pub fn search<'a>(
92    query: &str,
93    records: &'a [SchemaRecord],
94    kind: Option<Kind>,
95    parent: Option<&str>,
96) -> Vec<Hit<'a>> {
97    use rayon::prelude::*;
98    // Records score independently, so scan them in parallel — the win shows
99    // on large schemas (tens of thousands of records), and rayon's overhead
100    // is microseconds on small ones.
101    let mut hits: Vec<Hit> = records
102        .par_iter()
103        .filter(|r| kind.is_none_or(|k| r.kind == k))
104        .filter(|r| {
105            parent.is_none_or(|p| {
106                r.parent
107                    .as_deref()
108                    .is_some_and(|rp| rp.eq_ignore_ascii_case(p))
109            })
110        })
111        .filter_map(|r| score::score(query, r).map(|score| Hit { record: r, score }))
112        .collect();
113
114    // highest score first; break ties toward the shorter path (the more
115    // "central" definition — `User` before `AdminUserAuditLogEntry`).
116    hits.sort_by(|a, b| {
117        b.score
118            .cmp(&a.score)
119            .then_with(|| a.record.path.len().cmp(&b.record.path.len()))
120    });
121    if let Some(top) = hits.first().map(|h| h.score) {
122        let floor = (top as f64 * TAIL_CUTOFF) as i64;
123        hits.retain(|h| h.score >= floor);
124    }
125    hits
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    fn rec(name: &str, parent: Option<&str>, kind: Kind) -> SchemaRecord {
133        let path = match parent {
134            Some(p) => format!("{p}.{name}"),
135            None => name.to_string(),
136        };
137        SchemaRecord {
138            path,
139            name: name.into(),
140            kind,
141            parent: parent.map(Into::into),
142            type_ref: None,
143            args: vec![],
144            description: None,
145            deprecated: None,
146            directives: vec![],
147        }
148    }
149
150    #[test]
151    fn parent_filter_resolves_a_real_type() {
152        let records = vec![
153            rec("employees", Some("Company"), Kind::Field),
154            rec("name", Some("CompanyProfile"), Kind::Field),
155        ];
156        // exact type name, any case — returns the schema's spelling
157        assert_eq!(parent_filter("Company.employe", &records), Some("Company"));
158        assert_eq!(parent_filter("company.employe", &records), Some("Company"));
159        // a mere prefix of a type, or an unqualified query → no filter
160        assert_eq!(parent_filter("Comp.employe", &records), None);
161        assert_eq!(parent_filter("employe", &records), None);
162    }
163
164    #[test]
165    fn parent_filter_snaps_a_misspelled_type_to_the_closest() {
166        let records = vec![
167            rec("employees", Some("Company"), Kind::Field),
168            rec("name", Some("CompanyProfile"), Kind::Field),
169        ];
170        // transposition: Compnay → Company (CompanyProfile is out of budget)
171        assert_eq!(parent_filter("Compnay.employe", &records), Some("Company"));
172        // nothing close enough → no filter
173        assert_eq!(parent_filter("Zebra.employe", &records), None);
174    }
175
176    #[test]
177    fn parent_filter_declines_an_ambiguous_misspelling() {
178        let records = vec![
179            rec("id", Some("Vser"), Kind::Field),
180            rec("id", Some("Usor"), Kind::Field),
181        ];
182        // `User` is distance 1 from both — ambiguous, so no filter
183        assert_eq!(parent_filter("User.id", &records), None);
184    }
185
186    #[test]
187    fn parent_filter_excludes_other_types() {
188        let records = vec![
189            rec("employees", Some("Company"), Kind::Field),
190            rec("employees", Some("CompanyProfile"), Kind::Field),
191            rec("employer", Some("CompanyMemberStats"), Kind::Field),
192        ];
193        let hits = search("Company.employe", &records, None, Some("Company"));
194        assert_eq!(hits.len(), 1);
195        assert_eq!(hits[0].record.path, "Company.employees");
196    }
197
198    #[test]
199    fn spaced_qualifier_rewrites_type_plus_word() {
200        let records = vec![
201            rec("name", Some("User"), Kind::Field),
202            rec("lastName", Some("User"), Kind::Field),
203        ];
204        assert_eq!(
205            spaced_qualifier("User name", &records).as_deref(),
206            Some("User.name")
207        );
208        assert_eq!(
209            spaced_qualifier("user name", &records).as_deref(),
210            Some("user.name")
211        );
212        // phrases, dots, and non-type first words stay untouched
213        assert_eq!(spaced_qualifier("cancel a subscription", &records), None);
214        assert_eq!(spaced_qualifier("User.name", &records), None);
215        assert_eq!(spaced_qualifier("employee summary", &records), None);
216    }
217
218    #[test]
219    fn has_exact_matches_the_leaf_name_only() {
220        let records = vec![
221            rec("name", Some("User"), Kind::Field),
222            rec("username", Some("Query"), Kind::Query),
223        ];
224        let hits = search("User.name", &records, None, None);
225        assert!(has_exact("User.name", &hits));
226        assert!(has_exact("user.NAME", &hits));
227        let near = search("User.nam", &records, None, None);
228        assert!(!has_exact("User.nam", &near));
229    }
230
231    #[test]
232    fn weak_tail_is_cut_when_a_strong_match_exists() {
233        let records = vec![
234            rec("user", Some("Query"), Kind::Query),
235            rec("userProfile", Some("Query"), Kind::Query),
236            // matches `user` only as a scattered subsequence
237            rec("uzszezr", Some("Query"), Kind::Query),
238        ];
239        let paths: Vec<&str> = search("user", &records, None, None)
240            .iter()
241            .map(|h| h.record.path.as_str())
242            .collect();
243        assert_eq!(paths, ["Query.user", "Query.userProfile"]);
244    }
245
246    #[test]
247    fn weak_matches_survive_when_nothing_stronger_exists() {
248        let records = vec![rec("uzszezr", Some("Query"), Kind::Query)];
249        assert_eq!(search("user", &records, None, None).len(), 1);
250    }
251
252    #[test]
253    fn search_returns_all_hits_above_the_cutoff() {
254        // no internal truncation — the caller applies its own limit
255        let records: Vec<SchemaRecord> = (0..50)
256            .map(|i| rec(&format!("user{i}"), Some("Query"), Kind::Query))
257            .collect();
258        assert_eq!(search("user", &records, None, None).len(), 50);
259    }
260}