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::{FieldSelection, ParsedQuery, parse_query_with_operation_name, selection_set},
8    runtime::{argument_validation, argument_value_validation},
9    schema::{CompiledSchema, QueryDefinition},
10};
11
12/// A matched query with extracted information.
13#[derive(Debug, Clone)]
14pub struct QueryMatch {
15    /// The matched query definition from compiled schema.
16    pub query_def: QueryDefinition,
17
18    /// Requested fields (selection set) - now includes full field info.
19    pub fields: Vec<String>,
20
21    /// Parsed and processed field selections (after fragment/directive resolution).
22    pub selections: Vec<FieldSelection>,
23
24    /// Query arguments/variables.
25    pub arguments: HashMap<String, serde_json::Value>,
26
27    /// Query operation name (if provided).
28    pub operation_name: Option<String>,
29
30    /// The **server's own** row-scoping predicate, in the same
31    /// `{field: {op: value}}` shape a client `where` uses, or `None` when the
32    /// caller is a plain GraphQL document.
33    ///
34    /// Kept apart from `arguments["where"]` because the two are not the same
35    /// kind of thing and must not share a fate (#1170). REST resource embedding
36    /// used to build its parent-scoping join predicate into `arguments["where"]`
37    /// and dispatch it through the ordinary read path — which composes that
38    /// argument only when the target query's `auto_params.has_where` is set.
39    /// `has_where` governs the **client-facing filter surface**; a project that
40    /// turns it off is saying "clients may not filter this", not "and relations
41    /// that embed it may go unscoped". Turning it off silently discarded the
42    /// predicate and reported the target's *entire* result set as the relation's
43    /// contents — every row under every parent, and, on the `ManyToOne` branch,
44    /// the wrong parent attributed outright.
45    ///
46    /// Composed unconditionally alongside RLS and `inject_params`, which is
47    /// where server-side scoping belongs, and never read from client input: a
48    /// GraphQL document has no syntax that reaches this field.
49    pub scope_where: Option<serde_json::Value>,
50
51    /// The **server's own** relevance ordering, derived from a `?search=`
52    /// request, or `None` when the read was not a full-text search (#1284).
53    ///
54    /// Kept apart from `arguments["orderBy"]` for the same reason
55    /// [`scope_where`](Self::scope_where) is kept apart from
56    /// `arguments["where"]`: the argument map is the *client's* surface, and
57    /// this is not something a client can spell. The previous attempt did put it
58    /// there — as `[{"_relevance": "desc"}]` — and it type-checked at the
59    /// handler, at `serde_json`, and at the argument map, failing only in the
60    /// ORDER BY parser three layers down, which is how the documented default
61    /// path of full-text search answered `400` on every representation.
62    ///
63    /// The transport sets this only when the client named no sort of its own, so
64    /// an explicit `?sort=` always wins — which is what the generated `OpenAPI`
65    /// document promises ("ranked by relevance unless `sort` is specified").
66    pub search_relevance: Option<crate::backend::RelevanceOrder>,
67
68    /// The parsed query (for access to fragments, variables, etc.).
69    pub parsed_query: ParsedQuery,
70}
71
72impl QueryMatch {
73    /// The key this query's result appears under in `data`.
74    ///
75    /// The document's alias when it supplies one, otherwise the field name as
76    /// written. The envelope used to be keyed by the *compiled* query
77    /// definition's name, so `{ a: users { id } }` answered under `users` and two
78    /// aliased selections of one query collapsed into a single key.
79    ///
80    /// Falls back to the definition name when there is no selection to read —
81    /// the REST transport builds a `QueryMatch` with no GraphQL document behind
82    /// it.
83    #[must_use]
84    pub fn response_key(&self) -> &str {
85        self.selections
86            .first()
87            .map_or(self.query_def.name.as_str(), FieldSelection::response_key)
88    }
89
90    /// Build a `QueryMatch` directly from a query definition and arguments,
91    /// bypassing GraphQL string parsing.
92    ///
93    /// Used by the REST transport to construct sub-queries for resource embedding
94    /// and bulk operations without synthesising a GraphQL query string.
95    ///
96    /// The result has the **same shape** as [`QueryMatcher::match_query`]'s: a single
97    /// root selection named for the query, carrying the requested fields as its
98    /// `nested_fields`. Every consumer of a `QueryMatch` reads the requested field set
99    /// as `selections.first().nested_fields` — the planner's projection extraction, the
100    /// `#423` field-authorization gate, and `project_nested_lists` all do — so a *flat*
101    /// list of leaf selections reads to all of them as "no fields requested".
102    ///
103    /// That was `#886`: this constructor built the flat shape, so every REST read
104    /// projected zero fields (`{"data":[{},{},{}]}`) and the field-authorization gate
105    /// was handed an empty slice and never fired. The two defects masked each other —
106    /// no gated value leaked only because no value was served at all.
107    ///
108    /// # Errors
109    ///
110    /// Returns `FraiseQLError::Validation` if the query definition has no SQL source.
111    pub fn from_operation(
112        query_def: QueryDefinition,
113        fields: Vec<String>,
114        arguments: HashMap<String, serde_json::Value>,
115        _type_def: Option<&crate::schema::TypeDefinition>,
116    ) -> Result<Self> {
117        let leaf = |name: &String| FieldSelection {
118            name:          name.clone(),
119            alias:         None,
120            arguments:     Vec::new(),
121            nested_fields: Vec::new(),
122            directives:    Vec::new(),
123        };
124        let selections = vec![FieldSelection {
125            name:          query_def.name.clone(),
126            alias:         None,
127            arguments:     Vec::new(),
128            nested_fields: fields.iter().map(leaf).collect(),
129            directives:    Vec::new(),
130        }];
131
132        let parsed_query = ParsedQuery {
133            operation_type: "query".to_string(),
134            operation_name: Some(query_def.name.clone()),
135            root_field:     query_def.name.clone(),
136            selections:     Vec::new(),
137            variables:      Vec::new(),
138            fragments:      Vec::new(),
139            source:         std::sync::Arc::from(""),
140        };
141
142        Ok(Self {
143            query_def,
144            fields,
145            selections,
146            arguments,
147            operation_name: None,
148            scope_where: None,
149            search_relevance: None,
150            parsed_query,
151        })
152    }
153
154    /// Attach the server's own scoping predicate to a `QueryMatch` built by a
155    /// non-GraphQL transport.
156    ///
157    /// See [`scope_where`](Self::scope_where) for why this is not simply another
158    /// entry in `arguments`.
159    #[must_use]
160    pub fn with_scope_where(mut self, scope_where: serde_json::Value) -> Self {
161        self.scope_where = Some(scope_where);
162        self
163    }
164
165    /// Attach the relevance ordering a `?search=` request implies (#1284).
166    ///
167    /// See [`search_relevance`](Self::search_relevance) for why this is not
168    /// another entry in `arguments`.
169    #[must_use]
170    pub fn with_search_relevance(mut self, relevance: crate::backend::RelevanceOrder) -> Self {
171        self.search_relevance = Some(relevance);
172        self
173    }
174}
175
176/// Query pattern matcher.
177///
178/// Matches incoming GraphQL queries against the compiled schema to determine
179/// which pre-compiled SQL template to execute.
180pub struct QueryMatcher {
181    schema:    CompiledSchema,
182    /// How deep a selection may nest: the schema's declared `max_query_depth`, else
183    /// [`DEFAULT_MAX_QUERY_DEPTH`](crate::schema::DEFAULT_MAX_QUERY_DEPTH). The executor
184    /// replaces it with its effective one.
185    max_depth: u32,
186}
187
188impl QueryMatcher {
189    /// Create new query matcher.
190    ///
191    /// Indexes are (re)built at construction time so that `match_query`
192    /// works correctly regardless of whether `build_indexes()` was called
193    /// on the schema before passing it here.
194    #[must_use]
195    pub fn new(mut schema: CompiledSchema) -> Self {
196        schema.build_indexes();
197        let max_depth = schema
198            .validation_config
199            .as_ref()
200            .and_then(|v| v.max_query_depth)
201            .unwrap_or(crate::schema::DEFAULT_MAX_QUERY_DEPTH);
202        Self { schema, max_depth }
203    }
204
205    /// The same matcher, refusing a selection nested deeper than `max_depth`.
206    #[must_use]
207    pub const fn with_max_depth(mut self, max_depth: u32) -> Self {
208        self.max_depth = max_depth;
209        self
210    }
211
212    /// Match a GraphQL query to a compiled template.
213    ///
214    /// # Arguments
215    ///
216    /// * `query` - GraphQL query string
217    /// * `variables` - Query variables (optional)
218    ///
219    /// # Returns
220    ///
221    /// `QueryMatch` with query definition and extracted information
222    ///
223    /// # Errors
224    ///
225    /// Returns error if:
226    /// - Query syntax is invalid
227    /// - Query references undefined operation
228    /// - Query structure doesn't match schema
229    /// - Fragment resolution fails
230    /// - Directive evaluation fails
231    ///
232    /// # Example
233    ///
234    /// ```no_run
235    /// // Requires: compiled schema.
236    /// // See: tests/integration/ for runnable examples.
237    /// # use fraiseql_core::schema::CompiledSchema;
238    /// # use fraiseql_core::runtime::QueryMatcher;
239    /// # use fraiseql_error::Result;
240    /// # fn example() -> Result<()> {
241    /// # let schema: CompiledSchema = panic!("example");
242    /// let matcher = QueryMatcher::new(schema);
243    /// let query = "query { users { id name } }";
244    /// let matched = matcher.match_query(query, None)?;
245    /// assert_eq!(matched.query_def.name, "users");
246    /// # Ok(())
247    /// # }
248    /// ```
249    pub fn match_query(
250        &self,
251        query: &str,
252        variables: Option<&serde_json::Value>,
253    ) -> Result<QueryMatch> {
254        self.match_query_with_operation_name(query, variables, None)
255    }
256
257    /// Match a document against the compiled schema, selecting the operation
258    /// named by `operation_name` (GraphQL § 6.1).
259    ///
260    /// [`match_query`](Self::match_query) is this with `None`, which requires
261    /// the document to define exactly one operation.
262    ///
263    /// The executor threads the request's name here as well as into
264    /// classification: this function re-parses the document, so leaving it to
265    /// take the first operation would classify one operation and then match a
266    /// different one.
267    ///
268    /// # Errors
269    ///
270    /// Returns [`FraiseQLError::Parse`] if the document does not parse, names
271    /// no such operation, or is ambiguous; [`FraiseQLError::NotFound`] if the
272    /// root field matches no compiled query.
273    pub fn match_query_with_operation_name(
274        &self,
275        query: &str,
276        variables: Option<&serde_json::Value>,
277        operation_name: Option<&str>,
278    ) -> Result<QueryMatch> {
279        // 1. Parse GraphQL query using proper parser
280        let parsed = parse_query_with_operation_name(query, operation_name)
281            .map_err(|e| crate::graphql::operation_selection_error(&e))?;
282
283        // 2. Build the variables map once. The same map is used for `@skip`/`@include` directive
284        //    evaluation (by reference) and then moved onto the returned `QueryMatch` as
285        //    `arguments`, so we never pay for a second clone of the JSON tree.
286        let variables_map = Self::variables_to_map(variables);
287
288        // 3. Reduce the document to the fields the client asked for: expand fragment spreads, then
289        //    evaluate `@skip`/`@include`. Shared with the multi-root fan-out, `node(id:)` and
290        //    mutations so no entry point can answer this question differently (#826, #827). Kept as
291        //    two steps rather than `resolve_and_filter` so the *written* set survives: § 5.3.3
292        //    below is anti-monotone under field removal and has to see the document as written,
293        //    while § 5.3.1 and everything after it want the filtered set.
294        let written_selections =
295            selection_set::resolve(&parsed.selections, &parsed.fragments, self.max_depth)?;
296        let final_selections = selection_set::filter(&written_selections, &variables_map)?;
297
298        // 5. Find matching query definition using root field
299        let query_def = self
300            .schema
301            .find_query(&parsed.root_field)
302            .ok_or_else(|| {
303                let display_names: Vec<String> =
304                    self.schema.queries.iter().map(|q| self.schema.display_name(&q.name)).collect();
305                let candidate_refs: Vec<&str> = display_names.iter().map(String::as_str).collect();
306                let suggestion = suggest_similar(&parsed.root_field, &candidate_refs);
307                let message = match suggestion.as_slice() {
308                    [s] => format!(
309                        "Query '{}' not found in schema. Did you mean '{s}'?",
310                        parsed.root_field
311                    ),
312                    [a, b] => format!(
313                        "Query '{}' not found in schema. Did you mean '{a}' or '{b}'?",
314                        parsed.root_field
315                    ),
316                    [a, b, c, ..] => format!(
317                        "Query '{}' not found in schema. Did you mean '{a}', '{b}', or '{c}'?",
318                        parsed.root_field
319                    ),
320                    _ => format!("Query '{}' not found in schema", parsed.root_field),
321                };
322                FraiseQLError::Validation {
323                    message,
324                    path: None,
325                }
326            })?
327            .clone();
328
329        // 5b. #939: the selected fields must exist on the type being queried
330        //     (GraphQL § 5.3.1). An undeclared name used to be lowered into the
331        //     projection as `data->>'phantom_field'`, which is SQL NULL and
332        //     serialises as a legitimate-looking `null` under a 200 — a client
333        //     typo shipped silently. Spreads are already expanded above, so a
334        //     field contributed by one validates like any other.
335        //
336        //     Relay connections are exempt: `query_def.return_type` is the *node*
337        //     type, while the selection set is scoped to the generated
338        //     `XxxConnection` (`edges`/`pageInfo`/`totalCount`). Validating the
339        //     one against the other would reject every relay query.
340        //     Count siblings (#938) are exempt for the same reason in the other
341        //     direction: `return_type` is the entity type, but the field is a
342        //     scalar `Int!` with no sub-selection at all, so there is nothing to
343        //     validate against it.
344        if !query_def.relay && !query_def.returns_count {
345            if let Some(root) = final_selections.first() {
346                crate::graphql::validate_selection_set(
347                    &self.schema,
348                    &query_def.return_type,
349                    &root.nested_fields,
350                )?;
351            }
352            // 5b-ii. #1357: § 5.3.3, on the set as **written**. `{ users }` was
353            //        answered with one empty object per row under a 200 with no
354            //        `errors` — the fabricated success #1076's AI adapters reported
355            //        to models. Nothing leaks on this path (the SQL projection is
356            //        built *from* the selection set, so an empty one selects
357            //        nothing), which is why the shape read as a formatting quirk;
358            //        the write path's twin of it is a policy bypass.
359            if let Some(root) = written_selections.first() {
360                crate::graphql::validate_leaf_field_selections(
361                    &self.schema,
362                    &query_def.return_type,
363                    &root.nested_fields,
364                )?;
365            }
366        }
367
368        // 5c. #1154: the arguments written on the root field must be defined on it
369        //     (GraphQL § 5.4.1). An undeclared argument used to be dropped —
370        //     only declared arguments become WHERE conditions and only the
371        //     auto-wired names reach the pagination paths — so a client filter
372        //     the schema no longer carries returned the *unfiltered* set under a
373        //     200. Unlike 5b this applies to relay and count queries too: their
374        //     argument surfaces differ from a plain list query's, and
375        //     `accepted_argument_names` is what encodes each one.
376        //
377        //     5d. #1197: and the *value* written against each of those names must
378        //     have that name's type (GraphQL § 5.6.1, § 5.8.5), as must every
379        //     supplied variable against its own declaration (§ 6.1.2). The same
380        //     failure mode one level down: `limit: "2"` was read with `as_u64()`,
381        //     answered `None`, and `None` is indistinguishable from "the client
382        //     did not paginate" — so a request for two rows returned the table.
383        //     Argument *types* come from `graphql_arguments`, which is where the
384        //     auto-wired `limit`/`offset` acquire theirs; `accepted_argument_names`
385        //     above is deliberately wider and carries no types.
386        if let Some(root) = final_selections.first() {
387            let field_label = format!("Query.{}", self.schema.display_name(&query_def.name));
388            argument_validation::validate_argument_names(
389                &field_label,
390                &query_def.accepted_argument_names(&self.schema),
391                &root.arguments,
392            )?;
393            argument_value_validation::validate_argument_values(
394                &field_label,
395                &query_def.graphql_arguments(&self.schema),
396                &root.arguments,
397                &parsed.variables,
398            )?;
399            //     5e. #1362: and an enum written at one of those names must name a
400            //     member the schema declares. Separate from 5d because that check
401            //     adjudicates the ten built-in scalars and needs no schema, while an
402            //     enum's value space is only knowable from one — and because 5d stops
403            //     at the argument, where this walks into `where:` and every other
404            //     input object to reach the enums inside them.
405            argument_value_validation::validate_enum_argument_literals(
406                &self.schema,
407                &field_label,
408                &query_def.graphql_arguments(&self.schema),
409                &root.arguments,
410            )?;
411        }
412        argument_value_validation::validate_variable_values(
413            parsed.operation_name.as_deref(),
414            &parsed.variables,
415            variables,
416        )?;
417        argument_value_validation::validate_enum_variable_values(
418            &self.schema,
419            parsed.operation_name.as_deref(),
420            &parsed.variables,
421            variables,
422        )?;
423
424        // 6. Extract field names for backward compatibility
425        let fields = self.extract_field_names(&final_selections);
426
427        // 7. Take ownership of the variables map for `QueryMatch.arguments`. `variables_map` was
428        //    built once at step 2 and only borrowed by the directive evaluator; no additional clone
429        //    needed.
430        let mut arguments = variables_map;
431
432        // 8. Merge inline arguments from root field selection (e.g., `posts(limit: 3)`). Variables
433        //    take precedence over inline arguments when both are provided.
434        if let Some(root) = final_selections.first() {
435            for arg in &root.arguments {
436                if !arguments.contains_key(&arg.name) {
437                    if let Some(val) = Self::resolve_inline_arg(arg, &arguments)? {
438                        arguments.insert(arg.name.clone(), val);
439                    }
440                }
441            }
442        }
443
444        Ok(QueryMatch {
445            query_def,
446            fields,
447            selections: final_selections,
448            arguments,
449            operation_name: parsed.operation_name.clone(),
450            // A GraphQL document cannot carry one: server scoping is attached by
451            // the transport that owns it, never parsed from client input.
452            scope_where: None,
453            // Nor a relevance ordering: `?search=` is a REST parameter, and the
454            // ranking it implies is derived by that transport (#1284).
455            search_relevance: None,
456            parsed_query: parsed,
457        })
458    }
459
460    /// Convert the optional `variables` JSON object into an owned
461    /// `HashMap<String, Value>` suitable for both `@skip`/`@include` directive
462    /// evaluation and the public `QueryMatch::arguments` field.
463    ///
464    /// This used to be two separate helpers (`build_variables_map` and
465    /// `extract_arguments`) that walked the same JSON object and cloned every
466    /// key-value pair twice per request. Folding them into a single conversion
467    /// halves the per-request allocation cost for variable-heavy mutations
468    /// (see F005, F024 in `docs/history/IMPROVEMENTS.md`).
469    fn variables_to_map(
470        variables: Option<&serde_json::Value>,
471    ) -> HashMap<String, serde_json::Value> {
472        selection_set::variables_map(variables)
473    }
474
475    /// Extract field names from selections (for backward compatibility).
476    fn extract_field_names(&self, selections: &[FieldSelection]) -> Vec<String> {
477        selections.iter().map(|s| s.name.clone()).collect()
478    }
479
480    /// Build the variables map exposed on [`QueryMatch::arguments`] from the
481    /// raw GraphQL `variables` JSON payload.
482    ///
483    /// This is the public entry point used by tests; internally the
484    /// `match_query` hot path now constructs the same map exactly once
485    /// via a private helper.
486    #[must_use]
487    pub fn extract_arguments(
488        variables: Option<&serde_json::Value>,
489    ) -> HashMap<String, serde_json::Value> {
490        Self::variables_to_map(variables)
491    }
492
493    /// Resolve an inline GraphQL argument to a JSON value.
494    ///
495    /// Handles both literal values (`limit: 3`) and variable references
496    /// (`limit: $limit`), at the top level and nested inside object or list
497    /// literals (`where: { field: { eq: $var } }`). The encoding is owned by
498    /// [`crate::graphql::value_json`].
499    ///
500    /// `Ok(None)` means the argument names a variable the request did not
501    /// supply, which drops it — GraphQL's treatment of an omitted nullable
502    /// argument. A *malformed* stored value is an error, not a drop: dropping a
503    /// `where:` argument widens the result set instead of narrowing it (#719).
504    ///
505    /// # Errors
506    ///
507    /// Returns `FraiseQLError::Internal` if `value_json` is not valid JSON.
508    pub(crate) fn resolve_inline_arg(
509        arg: &crate::graphql::GraphQLArgument,
510        variables: &HashMap<String, serde_json::Value>,
511    ) -> Result<Option<serde_json::Value>> {
512        let parsed = crate::graphql::value_json::decode(&arg.value_json)?;
513        // A whole-argument variable that the request omitted drops the argument
514        // rather than binding null, so `limit: $limit` with no `limit` variable
515        // falls back to the query's default instead of forcing `LIMIT NULL`.
516        if let Some(var_name) = crate::graphql::value_json::variable_name(&parsed) {
517            return Ok(variables.get(var_name).cloned());
518        }
519        Ok(Some(crate::graphql::value_json::resolve_variables(parsed, variables)))
520    }
521
522    /// Get the compiled schema.
523    #[must_use]
524    pub const fn schema(&self) -> &CompiledSchema {
525        &self.schema
526    }
527}
528
529// Re-exported so `crate::runtime::suggest_similar` keeps its call sites while the one
530// implementation lives in `fraiseql-db` — the WHERE parser needs it too, and `fraiseql-core`
531// depends on `fraiseql-db`, not the reverse.
532pub use fraiseql_db::utils::suggest_similar;