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