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 WHOLE value is a string variable reference (`field: $var`).
290 // Preserve the existing semantics: an undefined whole-arg variable drops
291 // the argument (returns None) rather than inserting null.
292 if let Some(s) = parsed.as_str() {
293 if let Some(var_name) = s.strip_prefix('$') {
294 return variables.get(var_name).cloned();
295 }
296 // Plain literal string.
297 return Some(parsed);
298 }
299 // Object / list literal: variables can appear NESTED inside it (e.g.
300 // `where: { field: { eq: $var } }` or `input: { f: $var }`). The parser
301 // serializes each nested `Variable("v")` AST node to the JSON string
302 // `"$v"`, so walk the value and substitute those placeholders.
303 Some(Self::resolve_nested_variables(parsed, variables, 0))
304 }
305
306 /// Recursively substitute `"$var"` placeholder strings inside a parsed
307 /// argument value with their concrete values from the `variables` map.
308 ///
309 /// The GraphQL parser flattens each `Variable("v")` AST node to the JSON
310 /// string `"$v"` (see `serialize_value_inner`), so a variable can appear at
311 /// any depth inside an object or list argument literal. This walks objects
312 /// and arrays and replaces those placeholders. An unknown variable resolves
313 /// to JSON `null` — matching GraphQL's treatment of an omitted nullable.
314 /// Depth is bounded (mirroring the parser's serialization limit) to guard
315 /// against pathological nesting.
316 pub(crate) fn resolve_nested_variables(
317 value: serde_json::Value,
318 variables: &HashMap<String, serde_json::Value>,
319 depth: usize,
320 ) -> serde_json::Value {
321 use serde_json::Value;
322 const MAX_VAR_DEPTH: usize = 64;
323 if depth >= MAX_VAR_DEPTH {
324 return value;
325 }
326 match value {
327 Value::String(s) => match s.strip_prefix('$') {
328 Some(var_name) => variables.get(var_name).cloned().unwrap_or(Value::Null),
329 None => Value::String(s),
330 },
331 Value::Array(items) => Value::Array(
332 items
333 .into_iter()
334 .map(|v| Self::resolve_nested_variables(v, variables, depth + 1))
335 .collect(),
336 ),
337 Value::Object(map) => Value::Object(
338 map.into_iter()
339 .map(|(k, v)| (k, Self::resolve_nested_variables(v, variables, depth + 1)))
340 .collect(),
341 ),
342 other => other,
343 }
344 }
345
346 /// Get the compiled schema.
347 #[must_use]
348 pub const fn schema(&self) -> &CompiledSchema {
349 &self.schema
350 }
351}
352
353/// Return candidates from `haystack` whose edit distance to `needle` is ≤ 2.
354///
355/// Uses a simple iterative Levenshtein implementation with a `2 * threshold`
356/// early-exit so cost stays proportional to the length of the candidates rather
357/// than `O(n * m)` for every comparison. At most three suggestions are returned,
358/// ordered by increasing edit distance.
359#[must_use]
360pub fn suggest_similar<'a>(needle: &str, haystack: &[&'a str]) -> Vec<&'a str> {
361 const MAX_DISTANCE: usize = 2;
362 const MAX_SUGGESTIONS: usize = 3;
363
364 let mut ranked: Vec<(usize, &str)> = haystack
365 .iter()
366 .filter_map(|&candidate| {
367 let d = levenshtein(needle, candidate);
368 if d <= MAX_DISTANCE {
369 Some((d, candidate))
370 } else {
371 None
372 }
373 })
374 .collect();
375
376 ranked.sort_unstable_by_key(|&(d, _)| d);
377 ranked.into_iter().take(MAX_SUGGESTIONS).map(|(_, s)| s).collect()
378}
379
380/// Compute the Levenshtein edit distance between two strings.
381pub fn levenshtein(a: &str, b: &str) -> usize {
382 let a: Vec<char> = a.chars().collect();
383 let b: Vec<char> = b.chars().collect();
384 let m = a.len();
385 let n = b.len();
386
387 // Early exit: length difference alone exceeds threshold.
388 if m.abs_diff(n) > 2 {
389 return m.abs_diff(n);
390 }
391
392 let mut prev: Vec<usize> = (0..=n).collect();
393 let mut curr = vec![0usize; n + 1];
394
395 for i in 1..=m {
396 curr[0] = i;
397 for j in 1..=n {
398 curr[j] = if a[i - 1] == b[j - 1] {
399 prev[j - 1]
400 } else {
401 1 + prev[j - 1].min(prev[j]).min(curr[j - 1])
402 };
403 }
404 std::mem::swap(&mut prev, &mut curr);
405 }
406
407 prev[n]
408}