fraiseql_core/runtime/matcher.rs
1//! Query pattern matching - matches incoming GraphQL queries to compiled templates.
2
3use std::collections::HashMap;
4
5use crate::{
6 error::{FraiseQLError, Result},
7 graphql::{DirectiveEvaluator, FieldSelection, FragmentResolver, ParsedQuery, parse_query},
8 schema::{CompiledSchema, QueryDefinition},
9};
10
11/// A matched query with extracted information.
12#[derive(Debug, Clone)]
13pub struct QueryMatch {
14 /// The matched query definition from compiled schema.
15 pub query_def: QueryDefinition,
16
17 /// Requested fields (selection set) - now includes full field info.
18 pub fields: Vec<String>,
19
20 /// Parsed and processed field selections (after fragment/directive resolution).
21 pub selections: Vec<FieldSelection>,
22
23 /// Query arguments/variables.
24 pub arguments: HashMap<String, serde_json::Value>,
25
26 /// Query operation name (if provided).
27 pub operation_name: Option<String>,
28
29 /// The parsed query (for access to fragments, variables, etc.).
30 pub parsed_query: ParsedQuery,
31}
32
33impl QueryMatch {
34 /// Build a `QueryMatch` directly from a query definition and arguments,
35 /// bypassing GraphQL string parsing.
36 ///
37 /// Used by the REST transport to construct sub-queries for resource embedding
38 /// and bulk operations without synthesising a GraphQL query string.
39 ///
40 /// # Errors
41 ///
42 /// Returns `FraiseQLError::Validation` if the query definition has no SQL source.
43 pub fn from_operation(
44 query_def: QueryDefinition,
45 fields: Vec<String>,
46 arguments: HashMap<String, serde_json::Value>,
47 _type_def: Option<&crate::schema::TypeDefinition>,
48 ) -> Result<Self> {
49 let selections = fields
50 .iter()
51 .map(|f| FieldSelection {
52 name: f.clone(),
53 alias: None,
54 arguments: Vec::new(),
55 nested_fields: Vec::new(),
56 directives: Vec::new(),
57 })
58 .collect();
59
60 let parsed_query = ParsedQuery {
61 operation_type: "query".to_string(),
62 operation_name: Some(query_def.name.clone()),
63 root_field: query_def.name.clone(),
64 selections: Vec::new(),
65 variables: Vec::new(),
66 fragments: Vec::new(),
67 source: std::sync::Arc::from(""),
68 };
69
70 Ok(Self {
71 query_def,
72 fields,
73 selections,
74 arguments,
75 operation_name: None,
76 parsed_query,
77 })
78 }
79}
80
81/// Query pattern matcher.
82///
83/// Matches incoming GraphQL queries against the compiled schema to determine
84/// which pre-compiled SQL template to execute.
85pub struct QueryMatcher {
86 schema: CompiledSchema,
87}
88
89impl QueryMatcher {
90 /// Create new query matcher.
91 ///
92 /// Indexes are (re)built at construction time so that `match_query`
93 /// works correctly regardless of whether `build_indexes()` was called
94 /// on the schema before passing it here.
95 #[must_use]
96 pub fn new(mut schema: CompiledSchema) -> Self {
97 schema.build_indexes();
98 Self { schema }
99 }
100
101 /// Match a GraphQL query to a compiled template.
102 ///
103 /// # Arguments
104 ///
105 /// * `query` - GraphQL query string
106 /// * `variables` - Query variables (optional)
107 ///
108 /// # Returns
109 ///
110 /// `QueryMatch` with query definition and extracted information
111 ///
112 /// # Errors
113 ///
114 /// Returns error if:
115 /// - Query syntax is invalid
116 /// - Query references undefined operation
117 /// - Query structure doesn't match schema
118 /// - Fragment resolution fails
119 /// - Directive evaluation fails
120 ///
121 /// # Example
122 ///
123 /// ```no_run
124 /// // Requires: compiled schema.
125 /// // See: tests/integration/ for runnable examples.
126 /// # use fraiseql_core::schema::CompiledSchema;
127 /// # use fraiseql_core::runtime::QueryMatcher;
128 /// # use fraiseql_error::Result;
129 /// # fn example() -> Result<()> {
130 /// # let schema: CompiledSchema = panic!("example");
131 /// let matcher = QueryMatcher::new(schema);
132 /// let query = "query { users { id name } }";
133 /// let matched = matcher.match_query(query, None)?;
134 /// assert_eq!(matched.query_def.name, "users");
135 /// # Ok(())
136 /// # }
137 /// ```
138 pub fn match_query(
139 &self,
140 query: &str,
141 variables: Option<&serde_json::Value>,
142 ) -> Result<QueryMatch> {
143 // 1. Parse GraphQL query using proper parser
144 let parsed = parse_query(query).map_err(|e| FraiseQLError::Parse {
145 message: e.to_string(),
146 location: "query".to_string(),
147 })?;
148
149 // 2. Build the variables map once. The same map is used for `@skip`/`@include` directive
150 // evaluation (by reference) and then moved onto the returned `QueryMatch` as
151 // `arguments`, so we never pay for a second clone of the JSON tree.
152 let variables_map = Self::variables_to_map(variables);
153
154 // 3. Resolve fragment spreads
155 let resolver = FragmentResolver::new(&parsed.fragments);
156 let resolved_selections = resolver.resolve_spreads(&parsed.selections).map_err(|e| {
157 FraiseQLError::Validation {
158 message: e.to_string(),
159 path: Some("fragments".to_string()),
160 }
161 })?;
162
163 // 4. Evaluate directives (@skip, @include) and filter selections
164 let final_selections =
165 DirectiveEvaluator::filter_selections(&resolved_selections, &variables_map).map_err(
166 |e| FraiseQLError::Validation {
167 message: e.to_string(),
168 path: Some("directives".to_string()),
169 },
170 )?;
171
172 // 5. Find matching query definition using root field
173 let query_def = self
174 .schema
175 .find_query(&parsed.root_field)
176 .ok_or_else(|| {
177 let display_names: Vec<String> =
178 self.schema.queries.iter().map(|q| self.schema.display_name(&q.name)).collect();
179 let candidate_refs: Vec<&str> = display_names.iter().map(String::as_str).collect();
180 let suggestion = suggest_similar(&parsed.root_field, &candidate_refs);
181 let message = match suggestion.as_slice() {
182 [s] => format!(
183 "Query '{}' not found in schema. Did you mean '{s}'?",
184 parsed.root_field
185 ),
186 [a, b] => format!(
187 "Query '{}' not found in schema. Did you mean '{a}' or '{b}'?",
188 parsed.root_field
189 ),
190 [a, b, c, ..] => format!(
191 "Query '{}' not found in schema. Did you mean '{a}', '{b}', or '{c}'?",
192 parsed.root_field
193 ),
194 _ => format!("Query '{}' not found in schema", parsed.root_field),
195 };
196 FraiseQLError::Validation {
197 message,
198 path: None,
199 }
200 })?
201 .clone();
202
203 // 6. Extract field names for backward compatibility
204 let fields = self.extract_field_names(&final_selections);
205
206 // 7. Take ownership of the variables map for `QueryMatch.arguments`. `variables_map` was
207 // built once at step 2 and only borrowed by the directive evaluator; no additional clone
208 // needed.
209 let mut arguments = variables_map;
210
211 // 8. Merge inline arguments from root field selection (e.g., `posts(limit: 3)`). Variables
212 // take precedence over inline arguments when both are provided.
213 if let Some(root) = final_selections.first() {
214 for arg in &root.arguments {
215 if !arguments.contains_key(&arg.name) {
216 if let Some(val) = Self::resolve_inline_arg(arg, &arguments) {
217 arguments.insert(arg.name.clone(), val);
218 }
219 }
220 }
221 }
222
223 Ok(QueryMatch {
224 query_def,
225 fields,
226 selections: final_selections,
227 arguments,
228 operation_name: parsed.operation_name.clone(),
229 parsed_query: parsed,
230 })
231 }
232
233 /// Convert the optional `variables` JSON object into an owned
234 /// `HashMap<String, Value>` suitable for both `@skip`/`@include` directive
235 /// evaluation and the public `QueryMatch::arguments` field.
236 ///
237 /// This used to be two separate helpers (`build_variables_map` and
238 /// `extract_arguments`) that walked the same JSON object and cloned every
239 /// key-value pair twice per request. Folding them into a single conversion
240 /// halves the per-request allocation cost for variable-heavy mutations
241 /// (see F005, F024 in `IMPROVEMENTS.md`).
242 fn variables_to_map(
243 variables: Option<&serde_json::Value>,
244 ) -> HashMap<String, serde_json::Value> {
245 if let Some(serde_json::Value::Object(map)) = variables {
246 map.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
247 } else {
248 HashMap::new()
249 }
250 }
251
252 /// Extract field names from selections (for backward compatibility).
253 fn extract_field_names(&self, selections: &[FieldSelection]) -> Vec<String> {
254 selections.iter().map(|s| s.name.clone()).collect()
255 }
256
257 /// Build the variables map exposed on [`QueryMatch::arguments`] from the
258 /// raw GraphQL `variables` JSON payload.
259 ///
260 /// This is the public entry point used by tests; internally the
261 /// `match_query` hot path now constructs the same map exactly once
262 /// via a private helper.
263 #[must_use]
264 pub fn extract_arguments(
265 variables: Option<&serde_json::Value>,
266 ) -> HashMap<String, serde_json::Value> {
267 Self::variables_to_map(variables)
268 }
269
270 /// Resolve an inline GraphQL argument to a JSON value.
271 ///
272 /// Handles both literal values (`limit: 3` → `value_json = "3"`) and
273 /// variable references (`limit: $limit` → `value_json = "\"$limit\""`),
274 /// looking up the latter in the already-extracted variables map.
275 ///
276 /// Variable references are serialized by the parser as JSON-quoted strings
277 /// (e.g. `Variable("myLimit")` → `"\"$myLimit\""`), so we must parse the
278 /// JSON first and then check for the `$` prefix on the inner string.
279 pub(crate) fn resolve_inline_arg(
280 arg: &crate::graphql::GraphQLArgument,
281 variables: &HashMap<String, serde_json::Value>,
282 ) -> Option<serde_json::Value> {
283 // Try raw `$varName` first (defensive, in case any code path produces unquoted refs)
284 if let Some(var_name) = arg.value_json.strip_prefix('$') {
285 return variables.get(var_name).cloned();
286 }
287 // Parse the JSON value
288 let parsed: serde_json::Value = serde_json::from_str(&arg.value_json).ok()?;
289 // Check if the parsed value is a string starting with "$" (variable reference)
290 if let Some(s) = parsed.as_str() {
291 if let Some(var_name) = s.strip_prefix('$') {
292 return variables.get(var_name).cloned();
293 }
294 }
295 // Literal value (number, boolean, string, object, array, null)
296 Some(parsed)
297 }
298
299 /// Get the compiled schema.
300 #[must_use]
301 pub const fn schema(&self) -> &CompiledSchema {
302 &self.schema
303 }
304}
305
306/// Return candidates from `haystack` whose edit distance to `needle` is ≤ 2.
307///
308/// Uses a simple iterative Levenshtein implementation with a `2 * threshold`
309/// early-exit so cost stays proportional to the length of the candidates rather
310/// than `O(n * m)` for every comparison. At most three suggestions are returned,
311/// ordered by increasing edit distance.
312#[must_use]
313pub fn suggest_similar<'a>(needle: &str, haystack: &[&'a str]) -> Vec<&'a str> {
314 const MAX_DISTANCE: usize = 2;
315 const MAX_SUGGESTIONS: usize = 3;
316
317 let mut ranked: Vec<(usize, &str)> = haystack
318 .iter()
319 .filter_map(|&candidate| {
320 let d = levenshtein(needle, candidate);
321 if d <= MAX_DISTANCE {
322 Some((d, candidate))
323 } else {
324 None
325 }
326 })
327 .collect();
328
329 ranked.sort_unstable_by_key(|&(d, _)| d);
330 ranked.into_iter().take(MAX_SUGGESTIONS).map(|(_, s)| s).collect()
331}
332
333/// Compute the Levenshtein edit distance between two strings.
334pub fn levenshtein(a: &str, b: &str) -> usize {
335 let a: Vec<char> = a.chars().collect();
336 let b: Vec<char> = b.chars().collect();
337 let m = a.len();
338 let n = b.len();
339
340 // Early exit: length difference alone exceeds threshold.
341 if m.abs_diff(n) > 2 {
342 return m.abs_diff(n);
343 }
344
345 let mut prev: Vec<usize> = (0..=n).collect();
346 let mut curr = vec![0usize; n + 1];
347
348 for i in 1..=m {
349 curr[0] = i;
350 for j in 1..=n {
351 curr[j] = if a[i - 1] == b[j - 1] {
352 prev[j - 1]
353 } else {
354 1 + prev[j - 1].min(prev[j]).min(curr[j - 1])
355 };
356 }
357 std::mem::swap(&mut prev, &mut curr);
358 }
359
360 prev[n]
361}