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

use std::collections::HashMap;

use crate::{
    error::{FraiseQLError, Result},
    graphql::{DirectiveEvaluator, FieldSelection, FragmentResolver, ParsedQuery, parse_query},
    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 parsed query (for access to fragments, variables, etc.).
    pub parsed_query: ParsedQuery,
}

impl QueryMatch {
    /// 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.
    ///
    /// # 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 selections = fields
            .iter()
            .map(|f| FieldSelection {
                name:          f.clone(),
                alias:         None,
                arguments:     Vec::new(),
                nested_fields: Vec::new(),
                directives:    Vec::new(),
            })
            .collect();

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

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

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

    /// 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> {
        // 1. Parse GraphQL query using proper parser
        let parsed = parse_query(query).map_err(|e| FraiseQLError::Parse {
            message:  e.to_string(),
            location: "query".to_string(),
        })?;

        // 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. Resolve fragment spreads
        let resolver = FragmentResolver::new(&parsed.fragments);
        let resolved_selections = resolver.resolve_spreads(&parsed.selections).map_err(|e| {
            FraiseQLError::Validation {
                message: e.to_string(),
                path:    Some("fragments".to_string()),
            }
        })?;

        // 4. Evaluate directives (@skip, @include) and filter selections
        let final_selections =
            DirectiveEvaluator::filter_selections(&resolved_selections, &variables_map).map_err(
                |e| FraiseQLError::Validation {
                    message: e.to_string(),
                    path:    Some("directives".to_string()),
                },
            )?;

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

        // 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(),
            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 `IMPROVEMENTS.md`).
    fn variables_to_map(
        variables: Option<&serde_json::Value>,
    ) -> HashMap<String, serde_json::Value> {
        if let Some(serde_json::Value::Object(map)) = variables {
            map.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
        } else {
            HashMap::new()
        }
    }

    /// 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` → `value_json = "3"`) and
    /// variable references (`limit: $limit` → `value_json = "\"$limit\""`),
    /// looking up the latter in the already-extracted variables map.
    ///
    /// Variable references are serialized by the parser as JSON-quoted strings
    /// (e.g. `Variable("myLimit")` → `"\"$myLimit\""`), so we must parse the
    /// JSON first and then check for the `$` prefix on the inner string.
    pub(crate) fn resolve_inline_arg(
        arg: &crate::graphql::GraphQLArgument,
        variables: &HashMap<String, serde_json::Value>,
    ) -> Option<serde_json::Value> {
        // Try raw `$varName` first (defensive, in case any code path produces unquoted refs)
        if let Some(var_name) = arg.value_json.strip_prefix('$') {
            return variables.get(var_name).cloned();
        }
        // Parse the JSON value
        let parsed: serde_json::Value = serde_json::from_str(&arg.value_json).ok()?;
        // Check if the WHOLE value is a string variable reference (`field: $var`).
        // Preserve the existing semantics: an undefined whole-arg variable drops
        // the argument (returns None) rather than inserting null.
        if let Some(s) = parsed.as_str() {
            if let Some(var_name) = s.strip_prefix('$') {
                return variables.get(var_name).cloned();
            }
            // Plain literal string.
            return Some(parsed);
        }
        // Object / list literal: variables can appear NESTED inside it (e.g.
        // `where: { field: { eq: $var } }` or `input: { f: $var }`). The parser
        // serializes each nested `Variable("v")` AST node to the JSON string
        // `"$v"`, so walk the value and substitute those placeholders.
        Some(Self::resolve_nested_variables(parsed, variables, 0))
    }

    /// Recursively substitute `"$var"` placeholder strings inside a parsed
    /// argument value with their concrete values from the `variables` map.
    ///
    /// The GraphQL parser flattens each `Variable("v")` AST node to the JSON
    /// string `"$v"` (see `serialize_value_inner`), so a variable can appear at
    /// any depth inside an object or list argument literal. This walks objects
    /// and arrays and replaces those placeholders. An unknown variable resolves
    /// to JSON `null` — matching GraphQL's treatment of an omitted nullable.
    /// Depth is bounded (mirroring the parser's serialization limit) to guard
    /// against pathological nesting.
    pub(crate) fn resolve_nested_variables(
        value: serde_json::Value,
        variables: &HashMap<String, serde_json::Value>,
        depth: usize,
    ) -> serde_json::Value {
        use serde_json::Value;
        const MAX_VAR_DEPTH: usize = 64;
        if depth >= MAX_VAR_DEPTH {
            return value;
        }
        match value {
            Value::String(s) => match s.strip_prefix('$') {
                Some(var_name) => variables.get(var_name).cloned().unwrap_or(Value::Null),
                None => Value::String(s),
            },
            Value::Array(items) => Value::Array(
                items
                    .into_iter()
                    .map(|v| Self::resolve_nested_variables(v, variables, depth + 1))
                    .collect(),
            ),
            Value::Object(map) => Value::Object(
                map.into_iter()
                    .map(|(k, v)| (k, Self::resolve_nested_variables(v, variables, depth + 1)))
                    .collect(),
            ),
            other => other,
        }
    }

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

/// Return candidates from `haystack` whose edit distance to `needle` is ≤ 2.
///
/// Uses a simple iterative Levenshtein implementation with a `2 * threshold`
/// early-exit so cost stays proportional to the length of the candidates rather
/// than `O(n * m)` for every comparison. At most three suggestions are returned,
/// ordered by increasing edit distance.
#[must_use]
pub fn suggest_similar<'a>(needle: &str, haystack: &[&'a str]) -> Vec<&'a str> {
    const MAX_DISTANCE: usize = 2;
    const MAX_SUGGESTIONS: usize = 3;

    let mut ranked: Vec<(usize, &str)> = haystack
        .iter()
        .filter_map(|&candidate| {
            let d = levenshtein(needle, candidate);
            if d <= MAX_DISTANCE {
                Some((d, candidate))
            } else {
                None
            }
        })
        .collect();

    ranked.sort_unstable_by_key(|&(d, _)| d);
    ranked.into_iter().take(MAX_SUGGESTIONS).map(|(_, s)| s).collect()
}

/// Compute the Levenshtein edit distance between two strings.
pub fn levenshtein(a: &str, b: &str) -> usize {
    let a: Vec<char> = a.chars().collect();
    let b: Vec<char> = b.chars().collect();
    let m = a.len();
    let n = b.len();

    // Early exit: length difference alone exceeds threshold.
    if m.abs_diff(n) > 2 {
        return m.abs_diff(n);
    }

    let mut prev: Vec<usize> = (0..=n).collect();
    let mut curr = vec![0usize; n + 1];

    for i in 1..=m {
        curr[0] = i;
        for j in 1..=n {
            curr[j] = if a[i - 1] == b[j - 1] {
                prev[j - 1]
            } else {
                1 + prev[j - 1].min(prev[j]).min(curr[j - 1])
            };
        }
        std::mem::swap(&mut prev, &mut curr);
    }

    prev[n]
}