Skip to main content

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}