fraiseql-core 2.19.0

Core execution engine for FraiseQL v2 - Compiled GraphQL over SQL
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
//! Query pattern matching - matches incoming GraphQL queries to compiled templates.

use std::{collections::HashMap, sync::Arc};

use ahash::AHashMap;

use crate::{
    error::{FraiseQLError, Result},
    graphql::{
        FieldSelection, ParsedQuery, parse_query_with_operation_name,
        selection_set::{self, FieldsCheck},
    },
    runtime::{argument_validation, argument_value_validation},
    schema::{ArgumentDefinition, CompiledSchema, QueryDefinition},
    security::{SecurityContext, role_gate::caller_holds},
};

/// A matched query with extracted information.
#[derive(Debug, Clone)]
pub struct QueryMatch {
    /// The matched query definition from compiled schema, shared with the matcher that
    /// found it rather than copied per request.
    pub query_def: Arc<QueryDefinition>,

    /// Requested fields (selection set) - now includes full field info.
    pub fields: Vec<String>,

    /// Parsed and processed field selections (after fragment/directive resolution),
    /// shared with every request of the same document that collected the same fields.
    pub selections: Arc<[FieldSelection]>,

    /// Query arguments/variables.
    pub arguments: HashMap<String, serde_json::Value>,

    /// Query operation name (if provided).
    pub operation_name: Option<String>,

    /// The **server's own** row-scoping predicate, in the same
    /// `{field: {op: value}}` shape a client `where` uses, or `None` when the
    /// caller is a plain GraphQL document.
    ///
    /// Kept apart from `arguments["where"]` because the two are not the same
    /// kind of thing and must not share a fate (#1170). REST resource embedding
    /// used to build its parent-scoping join predicate into `arguments["where"]`
    /// and dispatch it through the ordinary read path — which composes that
    /// argument only when the target query's `auto_params.has_where` is set.
    /// `has_where` governs the **client-facing filter surface**; a project that
    /// turns it off is saying "clients may not filter this", not "and relations
    /// that embed it may go unscoped". Turning it off silently discarded the
    /// predicate and reported the target's *entire* result set as the relation's
    /// contents — every row under every parent, and, on the `ManyToOne` branch,
    /// the wrong parent attributed outright.
    ///
    /// Composed unconditionally alongside RLS and `inject_params`, which is
    /// where server-side scoping belongs, and never read from client input: a
    /// GraphQL document has no syntax that reaches this field.
    pub scope_where: Option<serde_json::Value>,

    /// The **server's own** relevance ordering, derived from a `?search=`
    /// request, or `None` when the read was not a full-text search (#1284).
    ///
    /// Kept apart from `arguments["orderBy"]` for the same reason
    /// [`scope_where`](Self::scope_where) is kept apart from
    /// `arguments["where"]`: the argument map is the *client's* surface, and
    /// this is not something a client can spell. The previous attempt did put it
    /// there — as `[{"_relevance": "desc"}]` — and it type-checked at the
    /// handler, at `serde_json`, and at the argument map, failing only in the
    /// ORDER BY parser three layers down, which is how the documented default
    /// path of full-text search answered `400` on every representation.
    ///
    /// The transport sets this only when the client named no sort of its own, so
    /// an explicit `?sort=` always wins — which is what the generated `OpenAPI`
    /// document promises ("ranked by relevance unless `sort` is specified").
    pub search_relevance: Option<crate::backend::RelevanceOrder>,

    /// The parsed query (for access to fragments, variables, etc.), shared with the
    /// classification that parsed it.
    pub parsed_query: Arc<ParsedQuery>,

    /// What the document's collected fields compiled to, shared with every request that
    /// collected them, or `None` when the selections were built for this request alone.
    /// Read only through [`QueryMatch::prepared`], which answers it while it is still
    /// `selections`' own set, so a rewritten selection never reads another's entry.
    pub collected: Option<Arc<selection_set::Collected>>,
}

impl QueryMatch {
    /// What this request's fields compiled to, when they are the document's collected set
    /// unchanged.
    #[must_use]
    pub fn prepared(&self) -> Option<&selection_set::Collected> {
        self.collected
            .as_deref()
            .filter(|collected| Arc::ptr_eq(collected.fields(), &self.selections))
    }

    /// The key this query's result appears under in `data`.
    ///
    /// The document's alias when it supplies one, otherwise the field name as
    /// written. The envelope used to be keyed by the *compiled* query
    /// definition's name, so `{ a: users { id } }` answered under `users` and two
    /// aliased selections of one query collapsed into a single key.
    ///
    /// Falls back to the definition name when there is no selection to read —
    /// the REST transport builds a `QueryMatch` with no GraphQL document behind
    /// it.
    #[must_use]
    pub fn response_key(&self) -> &str {
        self.selections
            .first()
            .map_or(self.query_def.name.as_str(), FieldSelection::response_key)
    }

    /// Build a `QueryMatch` directly from a query definition and arguments,
    /// bypassing GraphQL string parsing.
    ///
    /// Used by the REST transport to construct sub-queries for resource embedding
    /// and bulk operations without synthesising a GraphQL query string.
    ///
    /// The result has the **same shape** as [`QueryMatcher::match_query`]'s: a single
    /// root selection named for the query, carrying the requested fields as its
    /// `nested_fields`. Every consumer of a `QueryMatch` reads the requested field set
    /// as `selections.first().nested_fields` — the planner's projection extraction, the
    /// `#423` field-authorization gate, and `shape_projected` all do — so a *flat*
    /// list of leaf selections reads to all of them as "no fields requested".
    ///
    /// That was `#886`: this constructor built the flat shape, so every REST read
    /// projected zero fields (`{"data":[{},{},{}]}`) and the field-authorization gate
    /// was handed an empty slice and never fired. The two defects masked each other —
    /// no gated value leaked only because no value was served at all.
    ///
    /// # Errors
    ///
    /// Returns `FraiseQLError::Validation` if the query definition has no SQL source.
    pub fn from_operation(
        query_def: impl Into<Arc<QueryDefinition>>,
        fields: Vec<String>,
        arguments: HashMap<String, serde_json::Value>,
        _type_def: Option<&crate::schema::TypeDefinition>,
    ) -> Result<Self> {
        let query_def = query_def.into();
        let leaf = |name: &String| FieldSelection {
            name:          name.clone(),
            alias:         None,
            arguments:     Vec::new(),
            nested_fields: Vec::new(),
            directives:    Vec::new(),
        };
        let selections = vec![FieldSelection {
            name:          query_def.name.clone(),
            alias:         None,
            arguments:     Vec::new(),
            nested_fields: fields.iter().map(leaf).collect(),
            directives:    Vec::new(),
        }];

        let parsed_query = ParsedQuery {
            operation_type: "query".to_string(),
            operation_name: Some(query_def.name.clone()),
            root_field:     query_def.name.clone(),
            selections:     Vec::new(),
            variables:      Vec::new(),
            fragments:      Vec::new(),
            source:         std::sync::Arc::from(""),
        };

        Ok(Self {
            query_def,
            fields,
            selections: selections.into(),
            arguments,
            operation_name: None,
            scope_where: None,
            search_relevance: None,
            parsed_query: Arc::new(parsed_query),
            collected: None,
        })
    }

    /// Take the arguments as a client's, as [`QueryMatcher::match_query`] does for a GraphQL
    /// document: refuse a null item at a `[T!]` position (#1614), then rewrite every enum
    /// member named in them to the value it is stored as (#1580). A transport that builds a
    /// `QueryMatch` from its own request with [`from_operation`](Self::from_operation) calls
    /// this before executing it; a sub-query built from values the server read needs nothing.
    ///
    /// # Errors
    ///
    /// Returns [`FraiseQLError::Validation`] naming the path to a null item of a `[T!]` list.
    pub fn with_client_arguments(mut self, schema: &CompiledSchema) -> Result<Self> {
        let published = self.query_def.graphql_arguments(schema);
        crate::runtime::validate_argument_list_items(
            schema,
            || format!("Query.{}", schema.display_name(&self.query_def.name)),
            &published,
            |name| self.arguments.get(name),
        )?;
        crate::runtime::coerce_enum_arguments(schema, &published, &mut self.arguments);
        Ok(self)
    }

    /// Attach the server's own scoping predicate to a `QueryMatch` built by a
    /// non-GraphQL transport.
    ///
    /// See [`scope_where`](Self::scope_where) for why this is not simply another
    /// entry in `arguments`.
    #[must_use]
    pub fn with_scope_where(mut self, scope_where: serde_json::Value) -> Self {
        self.scope_where = Some(scope_where);
        self
    }

    /// Attach the relevance ordering a `?search=` request implies (#1284).
    ///
    /// See [`search_relevance`](Self::search_relevance) for why this is not
    /// another entry in `arguments`.
    #[must_use]
    pub fn with_search_relevance(mut self, relevance: crate::backend::RelevanceOrder) -> Self {
        self.search_relevance = Some(relevance);
        self
    }
}

/// Query pattern matcher.
///
/// Matches incoming GraphQL queries against the compiled schema to determine
/// which pre-compiled SQL template to execute.
pub struct QueryMatcher {
    schema:      CompiledSchema,
    /// How deep a selection may nest: the schema's declared `max_query_depth`, else
    /// [`DEFAULT_MAX_QUERY_DEPTH`](crate::schema::DEFAULT_MAX_QUERY_DEPTH). The executor
    /// replaces it with its effective one.
    max_depth:   u32,
    /// Each query's arguments, by query name: derived from the schema once, here, because
    /// every request validates against them and the derivation is not cheap (a typed
    /// `orderBy` compares the sort keys of every query on its entity, #1159, #1568).
    arguments:   AHashMap<String, QueryArguments>,
    /// Each query's definition, by query name. A request only reads the definition it
    /// matches, so every [`QueryMatch`] shares this one instead of copying it.
    definitions: AHashMap<String, Arc<QueryDefinition>>,
    /// Whether a refused query or argument name carries a "Did you mean" hint (#1717).
    /// The executor replaces it with its
    /// [`RuntimeConfig::suggestions`](super::RuntimeConfig::suggestions).
    suggestions: bool,
}

/// The arguments a query's field takes, as the matcher validates a document against them.
struct QueryArguments {
    /// What introspection publishes, typed: [`QueryDefinition::graphql_arguments`].
    published: Vec<ArgumentDefinition>,
    /// What the runtime reads, wider and untyped:
    /// [`QueryDefinition::accepted_argument_names`].
    accepted:  Vec<String>,
}

impl QueryArguments {
    fn derive(query: &QueryDefinition, schema: &CompiledSchema) -> Self {
        Self {
            published: query.graphql_arguments(schema),
            accepted:  query.accepted_argument_names(schema),
        }
    }
}

impl QueryMatcher {
    /// Create new query matcher.
    ///
    /// Indexes are (re)built at construction time so that `match_query`
    /// works correctly regardless of whether `build_indexes()` was called
    /// on the schema before passing it here.
    #[must_use]
    pub fn new(mut schema: CompiledSchema) -> Self {
        schema.build_indexes();
        let max_depth = schema
            .validation_config
            .as_ref()
            .and_then(|v| v.max_query_depth)
            .unwrap_or(crate::schema::DEFAULT_MAX_QUERY_DEPTH);
        let arguments = schema
            .queries
            .iter()
            .map(|query| (query.name.clone(), QueryArguments::derive(query, &schema)))
            .collect();
        let definitions = schema
            .queries
            .iter()
            .map(|query| (query.name.clone(), Arc::new(query.clone())))
            .collect();
        Self {
            schema,
            max_depth,
            arguments,
            definitions,
            suggestions: true,
        }
    }

    /// The definition of the query `name` reads, shared rather than copied, resolving
    /// `name` as [`CompiledSchema::find_query`] does. A transport that resolves its own
    /// definition here validates the request against the object the match executes.
    #[must_use]
    pub fn definition(&self, name: &str) -> Option<Arc<QueryDefinition>> {
        // Query names are unique (a schema with a duplicate is refused at load), so the
        // definition `find_query` resolves is the one stored under its name.
        let found = self.schema.find_query(name)?;
        self.definitions.get(&found.name).map(Arc::clone)
    }

    /// The same matcher, refusing a selection nested deeper than `max_depth`.
    #[must_use]
    pub const fn with_max_depth(mut self, max_depth: u32) -> Self {
        self.max_depth = max_depth;
        self
    }

    /// The same matcher, with "Did you mean" hints on a refused name on or off (#1717).
    #[must_use]
    pub const fn with_suggestions(mut self, enabled: bool) -> Self {
        self.suggestions = enabled;
        self
    }

    /// Match a GraphQL query to a compiled template.
    ///
    /// # Arguments
    ///
    /// * `query` - GraphQL query string
    /// * `variables` - Query variables (optional)
    ///
    /// # Returns
    ///
    /// `QueryMatch` with query definition and extracted information
    ///
    /// # Errors
    ///
    /// Returns error if:
    /// - Query syntax is invalid
    /// - Query references undefined operation
    /// - Query structure doesn't match schema
    /// - Fragment resolution fails
    /// - Directive evaluation fails
    ///
    /// # Example
    ///
    /// ```no_run
    /// // Requires: compiled schema.
    /// // See: tests/integration/ for runnable examples.
    /// # use fraiseql_core::schema::CompiledSchema;
    /// # use fraiseql_core::runtime::QueryMatcher;
    /// # use fraiseql_error::Result;
    /// # fn example() -> Result<()> {
    /// # let schema: CompiledSchema = panic!("example");
    /// let matcher = QueryMatcher::new(schema);
    /// let query = "query { users { id name } }";
    /// let matched = matcher.match_query(query, None)?;
    /// assert_eq!(matched.query_def.name, "users");
    /// # Ok(())
    /// # }
    /// ```
    pub fn match_query(
        &self,
        query: &str,
        variables: Option<&serde_json::Value>,
    ) -> Result<QueryMatch> {
        self.match_query_with_operation_name(query, variables, None)
    }

    /// Match a document against the compiled schema, selecting the operation
    /// named by `operation_name` (GraphQL § 6.1).
    ///
    /// [`match_query`](Self::match_query) is this with `None`, which requires
    /// the document to define exactly one operation.
    ///
    /// The executor threads the request's name here as well as into
    /// classification: this function re-parses the document, so leaving it to
    /// take the first operation would classify one operation and then match a
    /// different one.
    ///
    /// # Errors
    ///
    /// Returns [`FraiseQLError::Parse`] if the document does not parse, names
    /// no such operation, or is ambiguous; [`FraiseQLError::NotFound`] if the
    /// root field matches no compiled query.
    pub fn match_query_with_operation_name(
        &self,
        query: &str,
        variables: Option<&serde_json::Value>,
        operation_name: Option<&str>,
    ) -> Result<QueryMatch> {
        self.match_query_for(query, variables, operation_name, None)
    }

    /// [`match_query_with_operation_name`](Self::match_query_with_operation_name) for
    /// `caller`: an unknown root field is answered with the names of the queries `caller`
    /// may run, so a `requires_role` query refused to it as absent is not suggested
    /// either. `None` is an anonymous caller, never "every query".
    ///
    /// # Errors
    ///
    /// As [`match_query_with_operation_name`](Self::match_query_with_operation_name).
    pub fn match_query_for(
        &self,
        query: &str,
        variables: Option<&serde_json::Value>,
        operation_name: Option<&str>,
        caller: Option<&SecurityContext>,
    ) -> Result<QueryMatch> {
        // 1. Parse GraphQL query using proper parser
        let parsed = parse_query_with_operation_name(query, operation_name)
            .map_err(|e| crate::graphql::operation_selection_error(&e))?;

        // 3a. Expand fragment spreads. Kept apart from evaluating `@skip`/`@include` so the
        //     *written* set survives: § 5.3.3 is anti-monotone under field removal and has to
        //     see the document as written, while § 5.3.1 and everything after it want the
        //     filtered set. Shared with the multi-root fan-out, `node(id:)` and mutations so no
        //     entry point can answer this question differently (#826, #827).
        let written_selections =
            selection_set::resolve(&parsed.selections, &parsed.fragments, self.max_depth)?;
        let selections = selection_set::CollectedFields::new(written_selections);
        self.match_resolved_for(Arc::new(parsed), &selections, variables, caller)
    }

    /// [`match_query_with_operation_name`](Self::match_query_with_operation_name) for an
    /// operation already parsed and resolved: `parsed` as
    /// [`parse_query_with_operation_name`] returns it, and `selections` its root selection
    /// set with fragment spreads expanded to this matcher's depth.
    ///
    /// Both depend on the document alone, so a caller that keeps them per document matches
    /// a repeated one without parsing or resolving it again, and `selections` collects its
    /// fields once per set of `@skip`/`@include` outcomes. Everything else that depends on
    /// `variables` (argument values, their validation) runs here, on every call.
    ///
    /// # Errors
    ///
    /// As [`match_query_with_operation_name`](Self::match_query_with_operation_name), after
    /// parsing and fragment expansion.
    pub fn match_resolved(
        &self,
        parsed: Arc<ParsedQuery>,
        selections: &selection_set::CollectedFields,
        variables: Option<&serde_json::Value>,
    ) -> Result<QueryMatch> {
        self.match_resolved_for(parsed, selections, variables, None)
    }

    /// [`match_resolved`](Self::match_resolved) for `caller`, which decides the names an
    /// unknown root field is answered with, as in [`match_query_for`](Self::match_query_for).
    ///
    /// # Errors
    ///
    /// As [`match_resolved`](Self::match_resolved).
    pub fn match_resolved_for(
        &self,
        parsed: Arc<ParsedQuery>,
        selections: &selection_set::CollectedFields,
        variables: Option<&serde_json::Value>,
        caller: Option<&SecurityContext>,
    ) -> Result<QueryMatch> {
        // 2. Build the variables map once. The same map is used for `@skip`/`@include` directive
        //    evaluation (by reference) and then moved onto the returned `QueryMatch` as
        //    `arguments`, so we never pay for a second clone of the JSON tree.
        let variables_map = Self::variables_to_map(variables);

        // 3b. Reduce the written set to the fields the client asked for: evaluate
        //     `@skip`/`@include` against this request's variables.
        let written_selections = selections.written();
        let collected = selections.collect(&variables_map)?;
        let mut final_selections = Arc::clone(collected.fields());

        // 5. Find matching query definition using root field
        let query_def = self.definition(&parsed.root_field).ok_or_else(|| {
            let display_names: Vec<String> = self
                .schema
                .queries
                .iter()
                .filter(|q| caller_holds(q.requires_role.as_deref(), caller))
                .map(|q| self.schema.display_name(&q.name))
                .collect();
            let candidate_refs: Vec<&str> = display_names.iter().map(String::as_str).collect();
            let suggestion = suggestions_for(self.suggestions, &parsed.root_field, &candidate_refs);
            let message = match suggestion.as_slice() {
                [s] => format!(
                    "Query '{}' not found in schema. Did you mean '{s}'?",
                    parsed.root_field
                ),
                [a, b] => format!(
                    "Query '{}' not found in schema. Did you mean '{a}' or '{b}'?",
                    parsed.root_field
                ),
                [a, b, c, ..] => format!(
                    "Query '{}' not found in schema. Did you mean '{a}', '{b}', or '{c}'?",
                    parsed.root_field
                ),
                _ => format!("Query '{}' not found in schema", parsed.root_field),
            };
            FraiseQLError::Validation {
                message,
                path: None,
            }
        })?;

        // 5b. #939: the selected fields must exist on the type being queried
        //     (GraphQL § 5.3.1). An undeclared name used to be lowered into the
        //     projection as `data->>'phantom_field'`, which is SQL NULL and
        //     serialises as a legitimate-looking `null` under a 200 — a client
        //     typo shipped silently. Spreads are already expanded above, so a
        //     field contributed by one validates like any other.
        //
        //     Relay connections are exempt: `query_def.return_type` is the *node*
        //     type, while the selection set is scoped to the generated
        //     `XxxConnection` (`edges`/`pageInfo`/`totalCount`). Validating the
        //     one against the other would reject every relay query.
        //     Count siblings (#938) are exempt for the same reason in the other
        //     direction: `return_type` is the entity type, but the field is a
        //     scalar `Int!` with no sub-selection at all, so there is nothing to
        //     validate against it.
        //     None of 5b-5e reads the request, only the document: they run once per
        //     collected set, and a warm request runs none of them. A refusal is
        //     not kept, so a refused document is refused on every request.
        collected.check_once(FieldsCheck::Document, |fields| -> Result<()> {
            #[cfg(any(test, feature = "test-utils"))]
            DOCUMENT_CHECKS_ON_THIS_THREAD.with(|n| n.set(n.get() + 1));
            if !query_def.relay && !query_def.returns_count {
                if let Some(root) = fields.first() {
                    crate::graphql::validate_selection_set(
                        &self.schema,
                        &query_def.return_type,
                        &root.nested_fields,
                    )?;
                }
                // 5b-ii. #1357: § 5.3.3, on the set as **written**. `{ users }` was
                //        answered with one empty object per row under a 200 with no
                //        `errors` — the fabricated success #1076's AI adapters reported
                //        to models. Nothing leaks on this path (the SQL projection is
                //        built *from* the selection set, so an empty one selects
                //        nothing), which is why the shape read as a formatting quirk;
                //        the write path's twin of it is a policy bypass.
                if let Some(root) = written_selections.first() {
                    crate::graphql::validate_leaf_field_selections(
                        &self.schema,
                        &query_def.return_type,
                        &root.nested_fields,
                    )?;
                }
            }

            // 5c. #1154: the arguments written on the root field must be defined on it
            //     (GraphQL § 5.4.1). An undeclared argument used to be dropped —
            //     only declared arguments become WHERE conditions and only the
            //     auto-wired names reach the pagination paths — so a client filter
            //     the schema no longer carries returned the *unfiltered* set under a
            //     200. Unlike 5b this applies to relay and count queries too: their
            //     argument surfaces differ from a plain list query's, and
            //     `accepted_argument_names` is what encodes each one.
            //
            //     5d. #1197: and the *value* written against each of those names must
            //     have that name's type (GraphQL § 5.6.1, § 5.8.5), as must every
            //     supplied variable against its own declaration (§ 6.1.2). The same
            //     failure mode one level down: `limit: "2"` was read with `as_u64()`,
            //     answered `None`, and `None` is indistinguishable from "the client
            //     did not paginate" — so a request for two rows returned the table.
            //     Argument *types* come from `graphql_arguments`, which is where the
            //     auto-wired `limit`/`offset` acquire theirs; `accepted_argument_names`
            //     above is deliberately wider and carries no types.
            if let Some(root) = fields.first() {
                let field_label = format!("Query.{}", self.schema.display_name(&query_def.name));
                let derived;
                let arguments = if let Some(arguments) = self.arguments.get(&query_def.name) {
                    arguments
                } else {
                    derived = QueryArguments::derive(&query_def, &self.schema);
                    &derived
                };
                argument_validation::validate_argument_names(
                    &field_label,
                    &arguments.accepted,
                    &root.arguments,
                    self.suggestions,
                )?;
                argument_value_validation::validate_argument_values(
                    &field_label,
                    &arguments.published,
                    &root.arguments,
                    &parsed.variables,
                )?;
                //     5e. #1362: and an enum written at one of those names must name a
                //     member the schema declares. Separate from 5d because that check
                //     adjudicates the ten built-in scalars and needs no schema, while an
                //     enum's value space is only knowable from one — and because 5d stops
                //     at the argument, where this walks into `where:` and every other
                //     input object to reach the enums inside them.
                argument_value_validation::validate_enum_argument_literals(
                    &self.schema,
                    &field_label,
                    &arguments.published,
                    &root.arguments,
                )?;
            }
            Ok(())
        })?;
        argument_value_validation::validate_variable_values(
            parsed.operation_name.as_deref(),
            &parsed.variables,
            variables,
        )?;
        argument_value_validation::validate_enum_variable_values(
            &self.schema,
            parsed.operation_name.as_deref(),
            &parsed.variables,
            variables,
        )?;

        // 5f. #1513: a localized field's `locale:` names an allowed locale, checked here
        //     before any statement and rewritten to that tag for the projectors.
        //     Without a `locale:` argument the pass rewrites nothing and its verdict
        //     depends on the collected fields alone, so it runs once per collected set,
        //     on a copy it discards.
        if !query_def.returns_count {
            let resolve = |fields: &mut [FieldSelection]| match fields.first_mut() {
                Some(root) => crate::runtime::resolve_locale_arguments(
                    &self.schema,
                    &query_def.return_type,
                    &mut root.nested_fields,
                    &variables_map,
                    query_def.relay,
                ),
                None => Ok(()),
            };
            let rewrites = final_selections
                .first()
                .is_some_and(|root| crate::runtime::mentions_locale_argument(&root.nested_fields));
            if rewrites {
                let mut fields = final_selections.to_vec();
                resolve(&mut fields)?;
                final_selections = fields.into();
            } else {
                collected
                    .check_once(FieldsCheck::Locale, |fields| resolve(&mut fields.to_vec()))?;
            }
        }

        // 6. Extract field names for backward compatibility
        let fields = self.extract_field_names(&final_selections);

        // 7. Take ownership of the variables map for `QueryMatch.arguments`. `variables_map` was
        //    built once at step 2 and only borrowed by the directive evaluator; no additional clone
        //    needed.
        let mut arguments = variables_map;

        // 8. Merge inline arguments from root field selection (e.g., `posts(limit: 3)`). Variables
        //    take precedence over inline arguments when both are provided.
        if let Some(root) = final_selections.first() {
            for arg in &root.arguments {
                if !arguments.contains_key(&arg.name) {
                    if let Some(val) = Self::resolve_inline_arg(arg, &arguments)? {
                        arguments.insert(arg.name.clone(), val);
                    }
                }
            }
        }

        let derived;
        let published = if let Some(published) = self.arguments.get(&query_def.name) {
            &published.published
        } else {
            derived = QueryArguments::derive(&query_def, &self.schema);
            &derived.published
        };

        // 9. #1614: a null item at a `[T!]` position, however it was written: a literal, a whole
        //    variable, or one nested in a literal.
        crate::runtime::validate_argument_list_items(
            &self.schema,
            || format!("Query.{}", self.schema.display_name(&query_def.name)),
            published,
            |name| arguments.get(name),
        )?;

        // 10. #1580: an enum member named in an argument becomes the value it is stored as, the
        //     value SQL compares. After 5e, so every name here is a member.
        crate::runtime::coerce_enum_arguments(&self.schema, published, &mut arguments);

        Ok(QueryMatch {
            query_def,
            fields,
            selections: final_selections,
            arguments,
            operation_name: parsed.operation_name.clone(),
            // A GraphQL document cannot carry one: server scoping is attached by
            // the transport that owns it, never parsed from client input.
            scope_where: None,
            // Nor a relevance ordering: `?search=` is a REST parameter, and the
            // ranking it implies is derived by that transport (#1284).
            search_relevance: None,
            parsed_query: parsed,
            collected: Some(collected),
        })
    }

    /// Convert the optional `variables` JSON object into an owned
    /// `HashMap<String, Value>` suitable for both `@skip`/`@include` directive
    /// evaluation and the public `QueryMatch::arguments` field.
    ///
    /// This used to be two separate helpers (`build_variables_map` and
    /// `extract_arguments`) that walked the same JSON object and cloned every
    /// key-value pair twice per request. Folding them into a single conversion
    /// halves the per-request allocation cost for variable-heavy mutations
    /// (see F005, F024 in `docs/history/IMPROVEMENTS.md`).
    fn variables_to_map(
        variables: Option<&serde_json::Value>,
    ) -> HashMap<String, serde_json::Value> {
        selection_set::variables_map(variables)
    }

    /// Extract field names from selections (for backward compatibility).
    fn extract_field_names(&self, selections: &[FieldSelection]) -> Vec<String> {
        selections.iter().map(|s| s.name.clone()).collect()
    }

    /// Build the variables map exposed on [`QueryMatch::arguments`] from the
    /// raw GraphQL `variables` JSON payload.
    ///
    /// This is the public entry point used by tests; internally the
    /// `match_query` hot path now constructs the same map exactly once
    /// via a private helper.
    #[must_use]
    pub fn extract_arguments(
        variables: Option<&serde_json::Value>,
    ) -> HashMap<String, serde_json::Value> {
        Self::variables_to_map(variables)
    }

    /// Resolve an inline GraphQL argument to a JSON value.
    ///
    /// Handles both literal values (`limit: 3`) and variable references
    /// (`limit: $limit`), at the top level and nested inside object or list
    /// literals (`where: { field: { eq: $var } }`). The encoding is owned by
    /// [`crate::graphql::value_json`].
    ///
    /// `Ok(None)` means the argument names a variable the request did not
    /// supply, which drops it — GraphQL's treatment of an omitted nullable
    /// argument. A *malformed* stored value is an error, not a drop: dropping a
    /// `where:` argument widens the result set instead of narrowing it (#719).
    ///
    /// # Errors
    ///
    /// Returns `FraiseQLError::Internal` if `value_json` is not valid JSON.
    pub(crate) fn resolve_inline_arg(
        arg: &crate::graphql::GraphQLArgument,
        variables: &HashMap<String, serde_json::Value>,
    ) -> Result<Option<serde_json::Value>> {
        let parsed = crate::graphql::value_json::decode(&arg.value_json)?;
        // A whole-argument variable that the request omitted drops the argument
        // rather than binding null, so `limit: $limit` with no `limit` variable
        // falls back to the query's default instead of forcing `LIMIT NULL`.
        if let Some(var_name) = crate::graphql::value_json::variable_name(&parsed) {
            return Ok(variables.get(var_name).cloned());
        }
        Ok(Some(crate::graphql::value_json::resolve_variables(parsed, variables)))
    }

    /// Get the compiled schema.
    #[must_use]
    pub const fn schema(&self) -> &CompiledSchema {
        &self.schema
    }
}

#[cfg(any(test, feature = "test-utils"))]
thread_local! {
    static DOCUMENT_CHECKS_ON_THIS_THREAD: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}

/// How many times the matcher has run the checks that depend on the document alone
/// (§ 5.3.1, § 5.3.3, § 5.4.1 and the argument literals) on the calling thread.
///
/// Test-only: a repeated document is checked once, so a request driven on a
/// current-thread runtime can assert that a warm one ran none of them.
#[cfg(any(test, feature = "test-utils"))]
#[must_use]
pub fn document_checks_on_this_thread() -> usize {
    DOCUMENT_CHECKS_ON_THIS_THREAD.with(std::cell::Cell::get)
}

// Re-exported so `crate::runtime::suggest_similar` keeps its call sites while the one
// implementation lives in `fraiseql-db` — the WHERE parser needs it too, and `fraiseql-core`
// depends on `fraiseql-db`, not the reverse.
pub use fraiseql_db::utils::{suggest_similar, suggestions_for};