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;