fraiseql-core 2.15.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
//! Query pattern matching - matches incoming GraphQL queries to compiled templates.

use std::collections::HashMap;

use crate::{
    error::{FraiseQLError, Result},
    graphql::{FieldSelection, ParsedQuery, parse_query_with_operation_name, selection_set},
    runtime::{argument_validation, argument_value_validation},
    schema::{CompiledSchema, QueryDefinition},
};

/// A matched query with extracted information.
#[derive(Debug, Clone)]
pub struct QueryMatch {
    /// The matched query definition from compiled schema.
    pub query_def: QueryDefinition,

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

    /// Parsed and processed field selections (after fragment/directive resolution).
    pub selections: Vec<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.).
    pub parsed_query: ParsedQuery,
}

impl QueryMatch {
    /// 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 `project_nested_lists` 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: QueryDefinition,
        fields: Vec<String>,
        arguments: HashMap<String, serde_json::Value>,
        _type_def: Option<&crate::schema::TypeDefinition>,
    ) -> Result<Self> {
        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,
            arguments,
            operation_name: None,
            scope_where: None,
            search_relevance: None,
            parsed_query,
        })
    }

    /// 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,
}

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);
        Self { schema, max_depth }
    }

    /// 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
    }

    /// 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> {
        // 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))?;

        // 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);

        // 3. Reduce the document to the fields the client asked for: expand fragment spreads, then
        //    evaluate `@skip`/`@include`. Shared with the multi-root fan-out, `node(id:)` and
        //    mutations so no entry point can answer this question differently (#826, #827). Kept as
        //    two steps rather than `resolve_and_filter` so the *written* set survives: § 5.3.3
        //    below 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.
        let written_selections =
            selection_set::resolve(&parsed.selections, &parsed.fragments, self.max_depth)?;
        let final_selections = selection_set::filter(&written_selections, &variables_map)?;

        // 5. Find matching query definition using root field
        let query_def = self
            .schema
            .find_query(&parsed.root_field)
            .ok_or_else(|| {
                let display_names: Vec<String> =
                    self.schema.queries.iter().map(|q| self.schema.display_name(&q.name)).collect();
                let candidate_refs: Vec<&str> = display_names.iter().map(String::as_str).collect();
                let suggestion = suggest_similar(&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,
                }
            })?
            .clone();

        // 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.
        if !query_def.relay && !query_def.returns_count {
            if let Some(root) = final_selections.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) = final_selections.first() {
            let field_label = format!("Query.{}", self.schema.display_name(&query_def.name));
            argument_validation::validate_argument_names(
                &field_label,
                &query_def.accepted_argument_names(&self.schema),
                &root.arguments,
            )?;
            argument_value_validation::validate_argument_values(
                &field_label,
                &query_def.graphql_arguments(&self.schema),
                &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,
                &query_def.graphql_arguments(&self.schema),
                &root.arguments,
            )?;
        }
        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,
        )?;

        // 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);
                    }
                }
            }
        }

        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,
        })
    }

    /// 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
    }
}

// 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;