Skip to main content

fraiseql_core/runtime/
matcher.rs

1//! Query pattern matching - matches incoming GraphQL queries to compiled templates.
2
3use std::collections::HashMap;
4
5use crate::{
6    error::{FraiseQLError, Result},
7    graphql::{DirectiveEvaluator, FieldSelection, FragmentResolver, ParsedQuery, parse_query},
8    schema::{CompiledSchema, QueryDefinition},
9};
10
11/// A matched query with extracted information.
12#[derive(Debug, Clone)]
13pub struct QueryMatch {
14    /// The matched query definition from compiled schema.
15    pub query_def: QueryDefinition,
16
17    /// Requested fields (selection set) - now includes full field info.
18    pub fields: Vec<String>,
19
20    /// Parsed and processed field selections (after fragment/directive resolution).
21    pub selections: Vec<FieldSelection>,
22
23    /// Query arguments/variables.
24    pub arguments: HashMap<String, serde_json::Value>,
25
26    /// Query operation name (if provided).
27    pub operation_name: Option<String>,
28
29    /// The parsed query (for access to fragments, variables, etc.).
30    pub parsed_query: ParsedQuery,
31}
32
33impl QueryMatch {
34    /// Build a `QueryMatch` directly from a query definition and arguments,
35    /// bypassing GraphQL string parsing.
36    ///
37    /// Used by the REST transport to construct sub-queries for resource embedding
38    /// and bulk operations without synthesising a GraphQL query string.
39    ///
40    /// # Errors
41    ///
42    /// Returns `FraiseQLError::Validation` if the query definition has no SQL source.
43    pub fn from_operation(
44        query_def: QueryDefinition,
45        fields: Vec<String>,
46        arguments: HashMap<String, serde_json::Value>,
47        _type_def: Option<&crate::schema::TypeDefinition>,
48    ) -> Result<Self> {
49        let selections = fields
50            .iter()
51            .map(|f| FieldSelection {
52                name:          f.clone(),
53                alias:         None,
54                arguments:     Vec::new(),
55                nested_fields: Vec::new(),
56                directives:    Vec::new(),
57            })
58            .collect();
59
60        let parsed_query = ParsedQuery {
61            operation_type: "query".to_string(),
62            operation_name: Some(query_def.name.clone()),
63            root_field:     query_def.name.clone(),
64            selections:     Vec::new(),
65            variables:      Vec::new(),
66            fragments:      Vec::new(),
67            source:         String::new(),
68        };
69
70        Ok(Self {
71            query_def,
72            fields,
73            selections,
74            arguments,
75            operation_name: None,
76            parsed_query,
77        })
78    }
79}
80
81/// Query pattern matcher.
82///
83/// Matches incoming GraphQL queries against the compiled schema to determine
84/// which pre-compiled SQL template to execute.
85pub struct QueryMatcher {
86    schema: CompiledSchema,
87}
88
89impl QueryMatcher {
90    /// Create new query matcher.
91    ///
92    /// Indexes are (re)built at construction time so that `match_query`
93    /// works correctly regardless of whether `build_indexes()` was called
94    /// on the schema before passing it here.
95    #[must_use]
96    pub fn new(mut schema: CompiledSchema) -> Self {
97        schema.build_indexes();
98        Self { schema }
99    }
100
101    /// Match a GraphQL query to a compiled template.
102    ///
103    /// # Arguments
104    ///
105    /// * `query` - GraphQL query string
106    /// * `variables` - Query variables (optional)
107    ///
108    /// # Returns
109    ///
110    /// `QueryMatch` with query definition and extracted information
111    ///
112    /// # Errors
113    ///
114    /// Returns error if:
115    /// - Query syntax is invalid
116    /// - Query references undefined operation
117    /// - Query structure doesn't match schema
118    /// - Fragment resolution fails
119    /// - Directive evaluation fails
120    ///
121    /// # Example
122    ///
123    /// ```no_run
124    /// // Requires: compiled schema.
125    /// // See: tests/integration/ for runnable examples.
126    /// # use fraiseql_core::schema::CompiledSchema;
127    /// # use fraiseql_core::runtime::QueryMatcher;
128    /// # use fraiseql_error::Result;
129    /// # fn example() -> Result<()> {
130    /// # let schema: CompiledSchema = panic!("example");
131    /// let matcher = QueryMatcher::new(schema);
132    /// let query = "query { users { id name } }";
133    /// let matched = matcher.match_query(query, None)?;
134    /// assert_eq!(matched.query_def.name, "users");
135    /// # Ok(())
136    /// # }
137    /// ```
138    pub fn match_query(
139        &self,
140        query: &str,
141        variables: Option<&serde_json::Value>,
142    ) -> Result<QueryMatch> {
143        // 1. Parse GraphQL query using proper parser
144        let parsed = parse_query(query).map_err(|e| FraiseQLError::Parse {
145            message:  e.to_string(),
146            location: "query".to_string(),
147        })?;
148
149        // 2. Build variables map for directive evaluation
150        let variables_map = self.build_variables_map(variables);
151
152        // 3. Resolve fragment spreads
153        let resolver = FragmentResolver::new(&parsed.fragments);
154        let resolved_selections = resolver.resolve_spreads(&parsed.selections).map_err(|e| {
155            FraiseQLError::Validation {
156                message: e.to_string(),
157                path:    Some("fragments".to_string()),
158            }
159        })?;
160
161        // 4. Evaluate directives (@skip, @include) and filter selections
162        let final_selections =
163            DirectiveEvaluator::filter_selections(&resolved_selections, &variables_map).map_err(
164                |e| FraiseQLError::Validation {
165                    message: e.to_string(),
166                    path:    Some("directives".to_string()),
167                },
168            )?;
169
170        // 5. Find matching query definition using root field
171        let query_def = self
172            .schema
173            .find_query(&parsed.root_field)
174            .ok_or_else(|| {
175                let display_names: Vec<String> =
176                    self.schema.queries.iter().map(|q| self.schema.display_name(&q.name)).collect();
177                let candidate_refs: Vec<&str> = display_names.iter().map(String::as_str).collect();
178                let suggestion = suggest_similar(&parsed.root_field, &candidate_refs);
179                let message = match suggestion.as_slice() {
180                    [s] => format!(
181                        "Query '{}' not found in schema. Did you mean '{s}'?",
182                        parsed.root_field
183                    ),
184                    [a, b] => format!(
185                        "Query '{}' not found in schema. Did you mean '{a}' or '{b}'?",
186                        parsed.root_field
187                    ),
188                    [a, b, c, ..] => format!(
189                        "Query '{}' not found in schema. Did you mean '{a}', '{b}', or '{c}'?",
190                        parsed.root_field
191                    ),
192                    _ => format!("Query '{}' not found in schema", parsed.root_field),
193                };
194                FraiseQLError::Validation {
195                    message,
196                    path: None,
197                }
198            })?
199            .clone();
200
201        // 6. Extract field names for backward compatibility
202        let fields = self.extract_field_names(&final_selections);
203
204        // 7. Extract arguments from variables
205        let mut arguments = self.extract_arguments(variables);
206
207        // 8. Merge inline arguments from root field selection (e.g., `posts(limit: 3)`). Variables
208        //    take precedence over inline arguments when both are provided.
209        if let Some(root) = final_selections.first() {
210            for arg in &root.arguments {
211                if !arguments.contains_key(&arg.name) {
212                    if let Some(val) = Self::resolve_inline_arg(arg, &arguments) {
213                        arguments.insert(arg.name.clone(), val);
214                    }
215                }
216            }
217        }
218
219        Ok(QueryMatch {
220            query_def,
221            fields,
222            selections: final_selections,
223            arguments,
224            operation_name: parsed.operation_name.clone(),
225            parsed_query: parsed,
226        })
227    }
228
229    /// Build a variables map from JSON value for directive evaluation.
230    fn build_variables_map(
231        &self,
232        variables: Option<&serde_json::Value>,
233    ) -> HashMap<String, serde_json::Value> {
234        if let Some(serde_json::Value::Object(map)) = variables {
235            map.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
236        } else {
237            HashMap::new()
238        }
239    }
240
241    /// Extract field names from selections (for backward compatibility).
242    fn extract_field_names(&self, selections: &[FieldSelection]) -> Vec<String> {
243        selections.iter().map(|s| s.name.clone()).collect()
244    }
245
246    /// Extract arguments from variables.
247    fn extract_arguments(
248        &self,
249        variables: Option<&serde_json::Value>,
250    ) -> HashMap<String, serde_json::Value> {
251        if let Some(serde_json::Value::Object(map)) = variables {
252            map.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
253        } else {
254            HashMap::new()
255        }
256    }
257
258    /// Resolve an inline GraphQL argument to a JSON value.
259    ///
260    /// Handles both literal values (`limit: 3` → `value_json = "3"`) and
261    /// variable references (`limit: $limit` → `value_json = "\"$limit\""`),
262    /// looking up the latter in the already-extracted variables map.
263    ///
264    /// Variable references are serialized by the parser as JSON-quoted strings
265    /// (e.g. `Variable("myLimit")` → `"\"$myLimit\""`), so we must parse the
266    /// JSON first and then check for the `$` prefix on the inner string.
267    fn resolve_inline_arg(
268        arg: &crate::graphql::GraphQLArgument,
269        variables: &HashMap<String, serde_json::Value>,
270    ) -> Option<serde_json::Value> {
271        // Try raw `$varName` first (defensive, in case any code path produces unquoted refs)
272        if let Some(var_name) = arg.value_json.strip_prefix('$') {
273            return variables.get(var_name).cloned();
274        }
275        // Parse the JSON value
276        let parsed: serde_json::Value = serde_json::from_str(&arg.value_json).ok()?;
277        // Check if the parsed value is a string starting with "$" (variable reference)
278        if let Some(s) = parsed.as_str() {
279            if let Some(var_name) = s.strip_prefix('$') {
280                return variables.get(var_name).cloned();
281            }
282        }
283        // Literal value (number, boolean, string, object, array, null)
284        Some(parsed)
285    }
286
287    /// Get the compiled schema.
288    #[must_use]
289    pub const fn schema(&self) -> &CompiledSchema {
290        &self.schema
291    }
292}
293
294/// Return candidates from `haystack` whose edit distance to `needle` is ≤ 2.
295///
296/// Uses a simple iterative Levenshtein implementation with a `2 * threshold`
297/// early-exit so cost stays proportional to the length of the candidates rather
298/// than `O(n * m)` for every comparison. At most three suggestions are returned,
299/// ordered by increasing edit distance.
300pub fn suggest_similar<'a>(needle: &str, haystack: &[&'a str]) -> Vec<&'a str> {
301    const MAX_DISTANCE: usize = 2;
302    const MAX_SUGGESTIONS: usize = 3;
303
304    let mut ranked: Vec<(usize, &str)> = haystack
305        .iter()
306        .filter_map(|&candidate| {
307            let d = levenshtein(needle, candidate);
308            if d <= MAX_DISTANCE {
309                Some((d, candidate))
310            } else {
311                None
312            }
313        })
314        .collect();
315
316    ranked.sort_unstable_by_key(|&(d, _)| d);
317    ranked.into_iter().take(MAX_SUGGESTIONS).map(|(_, s)| s).collect()
318}
319
320/// Compute the Levenshtein edit distance between two strings.
321fn levenshtein(a: &str, b: &str) -> usize {
322    let a: Vec<char> = a.chars().collect();
323    let b: Vec<char> = b.chars().collect();
324    let m = a.len();
325    let n = b.len();
326
327    // Early exit: length difference alone exceeds threshold.
328    if m.abs_diff(n) > 2 {
329        return m.abs_diff(n);
330    }
331
332    let mut prev: Vec<usize> = (0..=n).collect();
333    let mut curr = vec![0usize; n + 1];
334
335    for i in 1..=m {
336        curr[0] = i;
337        for j in 1..=n {
338            curr[j] = if a[i - 1] == b[j - 1] {
339                prev[j - 1]
340            } else {
341                1 + prev[j - 1].min(prev[j]).min(curr[j - 1])
342            };
343        }
344        std::mem::swap(&mut prev, &mut curr);
345    }
346
347    prev[n]
348}
349
350#[cfg(test)]
351mod tests {
352    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
353
354    use indexmap::IndexMap;
355
356    use super::*;
357    use crate::schema::CursorType;
358
359    fn test_schema() -> CompiledSchema {
360        let mut schema = CompiledSchema::new();
361        schema.queries.push(QueryDefinition {
362            name:                "users".to_string(),
363            return_type:         "User".to_string(),
364            returns_list:        true,
365            nullable:            false,
366            arguments:           Vec::new(),
367            sql_source:          Some("v_user".to_string()),
368            description:         None,
369            auto_params:         crate::schema::AutoParams::default(),
370            deprecation:         None,
371            jsonb_column:        "data".to_string(),
372            relay:               false,
373            relay_cursor_column: None,
374            relay_cursor_type:   CursorType::default(),
375            inject_params:       IndexMap::default(),
376            cache_ttl_seconds:   None,
377            additional_views:    vec![],
378            requires_role:       None,
379            rest_path:           None,
380            rest_method:         None,
381            native_columns:      HashMap::new(),
382        });
383        schema
384    }
385
386    #[test]
387    fn test_matcher_new() {
388        let schema = test_schema();
389        let matcher = QueryMatcher::new(schema);
390        assert_eq!(matcher.schema().queries.len(), 1);
391    }
392
393    #[test]
394    fn test_match_simple_query() {
395        let schema = test_schema();
396        let matcher = QueryMatcher::new(schema);
397
398        let query = "{ users { id name } }";
399        let result = matcher.match_query(query, None).unwrap();
400
401        assert_eq!(result.query_def.name, "users");
402        assert_eq!(result.fields.len(), 1); // "users" is the root field
403        assert!(result.selections[0].nested_fields.len() >= 2); // id, name
404    }
405
406    #[test]
407    fn test_match_query_with_operation_name() {
408        let schema = test_schema();
409        let matcher = QueryMatcher::new(schema);
410
411        let query = "query GetUsers { users { id name } }";
412        let result = matcher.match_query(query, None).unwrap();
413
414        assert_eq!(result.query_def.name, "users");
415        assert_eq!(result.operation_name, Some("GetUsers".to_string()));
416    }
417
418    #[test]
419    fn test_match_query_with_fragment() {
420        let schema = test_schema();
421        let matcher = QueryMatcher::new(schema);
422
423        let query = r"
424            fragment UserFields on User {
425                id
426                name
427            }
428            query { users { ...UserFields } }
429        ";
430        let result = matcher.match_query(query, None).unwrap();
431
432        assert_eq!(result.query_def.name, "users");
433        // Fragment should be resolved - nested fields should contain id, name
434        let root_selection = &result.selections[0];
435        assert!(root_selection.nested_fields.iter().any(|f| f.name == "id"));
436        assert!(root_selection.nested_fields.iter().any(|f| f.name == "name"));
437    }
438
439    #[test]
440    fn test_match_query_with_skip_directive() {
441        let schema = test_schema();
442        let matcher = QueryMatcher::new(schema);
443
444        let query = r"{ users { id name @skip(if: true) } }";
445        let result = matcher.match_query(query, None).unwrap();
446
447        assert_eq!(result.query_def.name, "users");
448        // "name" should be skipped due to @skip(if: true)
449        let root_selection = &result.selections[0];
450        assert!(root_selection.nested_fields.iter().any(|f| f.name == "id"));
451        assert!(!root_selection.nested_fields.iter().any(|f| f.name == "name"));
452    }
453
454    #[test]
455    fn test_match_query_with_include_directive_variable() {
456        let schema = test_schema();
457        let matcher = QueryMatcher::new(schema);
458
459        let query =
460            r"query($includeEmail: Boolean!) { users { id email @include(if: $includeEmail) } }";
461        let variables = serde_json::json!({ "includeEmail": false });
462        let result = matcher.match_query(query, Some(&variables)).unwrap();
463
464        assert_eq!(result.query_def.name, "users");
465        // "email" should be excluded because $includeEmail is false
466        let root_selection = &result.selections[0];
467        assert!(root_selection.nested_fields.iter().any(|f| f.name == "id"));
468        assert!(!root_selection.nested_fields.iter().any(|f| f.name == "email"));
469    }
470
471    #[test]
472    fn test_match_query_unknown_query() {
473        let schema = test_schema();
474        let matcher = QueryMatcher::new(schema);
475
476        let query = "{ unknown { id } }";
477        let result = matcher.match_query(query, None);
478
479        assert!(
480            matches!(result, Err(FraiseQLError::Validation { .. })),
481            "expected Validation error for unknown query, got: {result:?}"
482        );
483    }
484
485    #[test]
486    fn test_extract_arguments_none() {
487        let schema = test_schema();
488        let matcher = QueryMatcher::new(schema);
489
490        let args = matcher.extract_arguments(None);
491        assert!(args.is_empty());
492    }
493
494    #[test]
495    fn test_extract_arguments_some() {
496        let schema = test_schema();
497        let matcher = QueryMatcher::new(schema);
498
499        let variables = serde_json::json!({
500            "id": "123",
501            "limit": 10
502        });
503
504        let args = matcher.extract_arguments(Some(&variables));
505        assert_eq!(args.len(), 2);
506        assert_eq!(args.get("id"), Some(&serde_json::json!("123")));
507        assert_eq!(args.get("limit"), Some(&serde_json::json!(10)));
508    }
509
510    // =========================================================================
511    // suggest_similar / levenshtein tests
512    // =========================================================================
513
514    #[test]
515    fn test_suggest_similar_exact_typo() {
516        let suggestions = suggest_similar("userr", &["users", "posts", "comments"]);
517        assert_eq!(suggestions, vec!["users"]);
518    }
519
520    #[test]
521    fn test_suggest_similar_transposition() {
522        let suggestions = suggest_similar("suers", &["users", "posts"]);
523        assert_eq!(suggestions, vec!["users"]);
524    }
525
526    #[test]
527    fn test_suggest_similar_no_match() {
528        // "zzz" is far from everything — no suggestion expected.
529        let suggestions = suggest_similar("zzz", &["users", "posts", "comments"]);
530        assert!(suggestions.is_empty());
531    }
532
533    #[test]
534    fn test_suggest_similar_capped_at_three() {
535        // All four candidates are within distance 2 of "us".
536        let suggestions =
537            suggest_similar("us", &["users", "user", "uses", "usher", "something_far"]);
538        assert!(suggestions.len() <= 3);
539    }
540
541    #[test]
542    fn test_levenshtein_identical() {
543        assert_eq!(levenshtein("foo", "foo"), 0);
544    }
545
546    #[test]
547    fn test_levenshtein_insertion() {
548        assert_eq!(levenshtein("foo", "fooo"), 1);
549    }
550
551    #[test]
552    fn test_levenshtein_deletion() {
553        assert_eq!(levenshtein("fooo", "foo"), 1);
554    }
555
556    #[test]
557    fn test_levenshtein_substitution() {
558        assert_eq!(levenshtein("foo", "bar"), 3);
559    }
560
561    #[test]
562    fn test_uzer_typo_suggests_user() {
563        let mut schema = CompiledSchema::new();
564        schema.queries.push(QueryDefinition {
565            name:                "user".to_string(),
566            return_type:         "User".to_string(),
567            returns_list:        false,
568            nullable:            true,
569            arguments:           Vec::new(),
570            sql_source:          Some("v_user".to_string()),
571            description:         None,
572            auto_params:         crate::schema::AutoParams::default(),
573            deprecation:         None,
574            jsonb_column:        "data".to_string(),
575            relay:               false,
576            relay_cursor_column: None,
577            relay_cursor_type:   CursorType::default(),
578            inject_params:       IndexMap::default(),
579            cache_ttl_seconds:   None,
580            additional_views:    vec![],
581            requires_role:       None,
582            rest_path:           None,
583            rest_method:         None,
584            native_columns:      HashMap::new(),
585        });
586        let matcher = QueryMatcher::new(schema);
587
588        // "uzer" is one edit away from "user" — should suggest it.
589        let result = matcher.match_query("{ uzer { id } }", None);
590        let err = result.expect_err("expected Err for typo'd query name");
591        let msg = err.to_string();
592        assert!(msg.contains("Did you mean"), "expected 'Did you mean' suggestion in: {msg}");
593    }
594
595    #[test]
596    fn test_unknown_query_error_includes_suggestion() {
597        let mut schema = CompiledSchema::new();
598        schema.queries.push(QueryDefinition {
599            name:                "users".to_string(),
600            return_type:         "User".to_string(),
601            returns_list:        true,
602            nullable:            false,
603            arguments:           Vec::new(),
604            sql_source:          Some("v_user".to_string()),
605            description:         None,
606            auto_params:         crate::schema::AutoParams::default(),
607            deprecation:         None,
608            jsonb_column:        "data".to_string(),
609            relay:               false,
610            relay_cursor_column: None,
611            relay_cursor_type:   CursorType::default(),
612            inject_params:       IndexMap::default(),
613            cache_ttl_seconds:   None,
614            additional_views:    vec![],
615            requires_role:       None,
616            rest_path:           None,
617            rest_method:         None,
618            native_columns:      HashMap::new(),
619        });
620        let matcher = QueryMatcher::new(schema);
621
622        // "userr" is one edit away from "users" — should suggest it.
623        let result = matcher.match_query("{ userr { id } }", None);
624        let err = result.expect_err("expected Err for typo'd query name");
625        let msg = err.to_string();
626        assert!(msg.contains("Did you mean 'users'?"), "expected suggestion in: {msg}");
627    }
628
629    // =========================================================================
630    // resolve_inline_arg tests (C11)
631    // =========================================================================
632
633    #[test]
634    fn test_resolve_inline_arg_literal_integer() {
635        let arg = crate::graphql::GraphQLArgument {
636            name:       "limit".to_string(),
637            value_json: "3".to_string(),
638            value_type: "int".to_string(),
639        };
640        let vars = HashMap::new();
641        let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
642        assert_eq!(result, Some(serde_json::json!(3)));
643    }
644
645    #[test]
646    fn test_resolve_inline_arg_literal_string() {
647        let arg = crate::graphql::GraphQLArgument {
648            name:       "status".to_string(),
649            value_json: "\"active\"".to_string(),
650            value_type: "string".to_string(),
651        };
652        let vars = HashMap::new();
653        let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
654        assert_eq!(result, Some(serde_json::json!("active")));
655    }
656
657    #[test]
658    fn test_resolve_inline_arg_literal_boolean() {
659        let arg = crate::graphql::GraphQLArgument {
660            name:       "active".to_string(),
661            value_json: "true".to_string(),
662            value_type: "boolean".to_string(),
663        };
664        let vars = HashMap::new();
665        let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
666        assert_eq!(result, Some(serde_json::json!(true)));
667    }
668
669    #[test]
670    fn test_resolve_inline_arg_literal_null() {
671        let arg = crate::graphql::GraphQLArgument {
672            name:       "limit".to_string(),
673            value_json: "null".to_string(),
674            value_type: "null".to_string(),
675        };
676        let vars = HashMap::new();
677        let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
678        assert_eq!(result, Some(serde_json::Value::Null));
679    }
680
681    #[test]
682    fn test_resolve_inline_arg_variable_reference_json_quoted() {
683        // Parser serializes Variable("myLimit") as "\"$myLimit\""
684        let arg = crate::graphql::GraphQLArgument {
685            name:       "limit".to_string(),
686            value_json: "\"$myLimit\"".to_string(),
687            value_type: "variable".to_string(),
688        };
689        let mut vars = HashMap::new();
690        vars.insert("myLimit".to_string(), serde_json::json!(5));
691        let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
692        assert_eq!(result, Some(serde_json::json!(5)));
693    }
694
695    #[test]
696    fn test_resolve_inline_arg_variable_reference_raw() {
697        // Defensive: unquoted $var format
698        let arg = crate::graphql::GraphQLArgument {
699            name:       "limit".to_string(),
700            value_json: "$limit".to_string(),
701            value_type: "variable".to_string(),
702        };
703        let mut vars = HashMap::new();
704        vars.insert("limit".to_string(), serde_json::json!(10));
705        let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
706        assert_eq!(result, Some(serde_json::json!(10)));
707    }
708
709    #[test]
710    fn test_resolve_inline_arg_variable_not_found() {
711        let arg = crate::graphql::GraphQLArgument {
712            name:       "limit".to_string(),
713            value_json: "\"$missing\"".to_string(),
714            value_type: "variable".to_string(),
715        };
716        let vars = HashMap::new();
717        let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
718        assert_eq!(result, None);
719    }
720
721    #[test]
722    fn test_resolve_inline_arg_object() {
723        let arg = crate::graphql::GraphQLArgument {
724            name:       "where".to_string(),
725            value_json: r#"{"status":{"eq":"active"}}"#.to_string(),
726            value_type: "object".to_string(),
727        };
728        let vars = HashMap::new();
729        let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
730        assert_eq!(result, Some(serde_json::json!({"status": {"eq": "active"}})));
731    }
732
733    #[test]
734    fn test_resolve_inline_arg_list() {
735        let arg = crate::graphql::GraphQLArgument {
736            name:       "ids".to_string(),
737            value_json: "[1,2,3]".to_string(),
738            value_type: "list".to_string(),
739        };
740        let vars = HashMap::new();
741        let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
742        assert_eq!(result, Some(serde_json::json!([1, 2, 3])));
743    }
744}