1use 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#[derive(Debug, Clone)]
13pub struct QueryMatch {
14 pub query_def: QueryDefinition,
16
17 pub fields: Vec<String>,
19
20 pub selections: Vec<FieldSelection>,
22
23 pub arguments: HashMap<String, serde_json::Value>,
25
26 pub operation_name: Option<String>,
28
29 pub parsed_query: ParsedQuery,
31}
32
33impl QueryMatch {
34 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
81pub struct QueryMatcher {
86 schema: CompiledSchema,
87}
88
89impl QueryMatcher {
90 #[must_use]
96 pub fn new(mut schema: CompiledSchema) -> Self {
97 schema.build_indexes();
98 Self { schema }
99 }
100
101 pub fn match_query(
139 &self,
140 query: &str,
141 variables: Option<&serde_json::Value>,
142 ) -> Result<QueryMatch> {
143 let parsed = parse_query(query).map_err(|e| FraiseQLError::Parse {
145 message: e.to_string(),
146 location: "query".to_string(),
147 })?;
148
149 let variables_map = self.build_variables_map(variables);
151
152 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 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 let query_def = self
172 .schema
173 .find_query(&parsed.root_field)
174 .ok_or_else(|| {
175 let candidates: Vec<&str> =
176 self.schema.queries.iter().map(|q| q.name.as_str()).collect();
177 let suggestion = suggest_similar(&parsed.root_field, &candidates);
178 let message = match suggestion.as_slice() {
179 [s] => format!(
180 "Query '{}' not found in schema. Did you mean '{s}'?",
181 parsed.root_field
182 ),
183 [a, b] => format!(
184 "Query '{}' not found in schema. Did you mean '{a}' or '{b}'?",
185 parsed.root_field
186 ),
187 [a, b, c, ..] => format!(
188 "Query '{}' not found in schema. Did you mean '{a}', '{b}', or '{c}'?",
189 parsed.root_field
190 ),
191 _ => format!("Query '{}' not found in schema", parsed.root_field),
192 };
193 FraiseQLError::Validation {
194 message,
195 path: None,
196 }
197 })?
198 .clone();
199
200 let fields = self.extract_field_names(&final_selections);
202
203 let mut arguments = self.extract_arguments(variables);
205
206 if let Some(root) = final_selections.first() {
209 for arg in &root.arguments {
210 if !arguments.contains_key(&arg.name) {
211 if let Some(val) = Self::resolve_inline_arg(arg, &arguments) {
212 arguments.insert(arg.name.clone(), val);
213 }
214 }
215 }
216 }
217
218 Ok(QueryMatch {
219 query_def,
220 fields,
221 selections: final_selections,
222 arguments,
223 operation_name: parsed.operation_name.clone(),
224 parsed_query: parsed,
225 })
226 }
227
228 fn build_variables_map(
230 &self,
231 variables: Option<&serde_json::Value>,
232 ) -> HashMap<String, serde_json::Value> {
233 if let Some(serde_json::Value::Object(map)) = variables {
234 map.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
235 } else {
236 HashMap::new()
237 }
238 }
239
240 fn extract_field_names(&self, selections: &[FieldSelection]) -> Vec<String> {
242 selections.iter().map(|s| s.name.clone()).collect()
243 }
244
245 fn extract_arguments(
247 &self,
248 variables: Option<&serde_json::Value>,
249 ) -> HashMap<String, serde_json::Value> {
250 if let Some(serde_json::Value::Object(map)) = variables {
251 map.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
252 } else {
253 HashMap::new()
254 }
255 }
256
257 fn resolve_inline_arg(
267 arg: &crate::graphql::GraphQLArgument,
268 variables: &HashMap<String, serde_json::Value>,
269 ) -> Option<serde_json::Value> {
270 if let Some(var_name) = arg.value_json.strip_prefix('$') {
272 return variables.get(var_name).cloned();
273 }
274 let parsed: serde_json::Value = serde_json::from_str(&arg.value_json).ok()?;
276 if let Some(s) = parsed.as_str() {
278 if let Some(var_name) = s.strip_prefix('$') {
279 return variables.get(var_name).cloned();
280 }
281 }
282 Some(parsed)
284 }
285
286 #[must_use]
288 pub const fn schema(&self) -> &CompiledSchema {
289 &self.schema
290 }
291}
292
293pub fn suggest_similar<'a>(needle: &str, haystack: &[&'a str]) -> Vec<&'a str> {
300 const MAX_DISTANCE: usize = 2;
301 const MAX_SUGGESTIONS: usize = 3;
302
303 let mut ranked: Vec<(usize, &str)> = haystack
304 .iter()
305 .filter_map(|&candidate| {
306 let d = levenshtein(needle, candidate);
307 if d <= MAX_DISTANCE {
308 Some((d, candidate))
309 } else {
310 None
311 }
312 })
313 .collect();
314
315 ranked.sort_unstable_by_key(|&(d, _)| d);
316 ranked.into_iter().take(MAX_SUGGESTIONS).map(|(_, s)| s).collect()
317}
318
319fn levenshtein(a: &str, b: &str) -> usize {
321 let a: Vec<char> = a.chars().collect();
322 let b: Vec<char> = b.chars().collect();
323 let m = a.len();
324 let n = b.len();
325
326 if m.abs_diff(n) > 2 {
328 return m.abs_diff(n);
329 }
330
331 let mut prev: Vec<usize> = (0..=n).collect();
332 let mut curr = vec![0usize; n + 1];
333
334 for i in 1..=m {
335 curr[0] = i;
336 for j in 1..=n {
337 curr[j] = if a[i - 1] == b[j - 1] {
338 prev[j - 1]
339 } else {
340 1 + prev[j - 1].min(prev[j]).min(curr[j - 1])
341 };
342 }
343 std::mem::swap(&mut prev, &mut curr);
344 }
345
346 prev[n]
347}
348
349#[cfg(test)]
350mod tests {
351 #![allow(clippy::unwrap_used)] use indexmap::IndexMap;
354
355 use super::*;
356 use crate::schema::CursorType;
357
358 fn test_schema() -> CompiledSchema {
359 let mut schema = CompiledSchema::new();
360 schema.queries.push(QueryDefinition {
361 name: "users".to_string(),
362 return_type: "User".to_string(),
363 returns_list: true,
364 nullable: false,
365 arguments: Vec::new(),
366 sql_source: Some("v_user".to_string()),
367 description: None,
368 auto_params: crate::schema::AutoParams::default(),
369 deprecation: None,
370 jsonb_column: "data".to_string(),
371 relay: false,
372 relay_cursor_column: None,
373 relay_cursor_type: CursorType::default(),
374 inject_params: IndexMap::default(),
375 cache_ttl_seconds: None,
376 additional_views: vec![],
377 requires_role: None,
378 rest_path: None,
379 rest_method: None,
380 native_columns: HashMap::new(),
381 });
382 schema
383 }
384
385 #[test]
386 fn test_matcher_new() {
387 let schema = test_schema();
388 let matcher = QueryMatcher::new(schema);
389 assert_eq!(matcher.schema().queries.len(), 1);
390 }
391
392 #[test]
393 fn test_match_simple_query() {
394 let schema = test_schema();
395 let matcher = QueryMatcher::new(schema);
396
397 let query = "{ users { id name } }";
398 let result = matcher.match_query(query, None).unwrap();
399
400 assert_eq!(result.query_def.name, "users");
401 assert_eq!(result.fields.len(), 1); assert!(result.selections[0].nested_fields.len() >= 2); }
404
405 #[test]
406 fn test_match_query_with_operation_name() {
407 let schema = test_schema();
408 let matcher = QueryMatcher::new(schema);
409
410 let query = "query GetUsers { users { id name } }";
411 let result = matcher.match_query(query, None).unwrap();
412
413 assert_eq!(result.query_def.name, "users");
414 assert_eq!(result.operation_name, Some("GetUsers".to_string()));
415 }
416
417 #[test]
418 fn test_match_query_with_fragment() {
419 let schema = test_schema();
420 let matcher = QueryMatcher::new(schema);
421
422 let query = r"
423 fragment UserFields on User {
424 id
425 name
426 }
427 query { users { ...UserFields } }
428 ";
429 let result = matcher.match_query(query, None).unwrap();
430
431 assert_eq!(result.query_def.name, "users");
432 let root_selection = &result.selections[0];
434 assert!(root_selection.nested_fields.iter().any(|f| f.name == "id"));
435 assert!(root_selection.nested_fields.iter().any(|f| f.name == "name"));
436 }
437
438 #[test]
439 fn test_match_query_with_skip_directive() {
440 let schema = test_schema();
441 let matcher = QueryMatcher::new(schema);
442
443 let query = r"{ users { id name @skip(if: true) } }";
444 let result = matcher.match_query(query, None).unwrap();
445
446 assert_eq!(result.query_def.name, "users");
447 let root_selection = &result.selections[0];
449 assert!(root_selection.nested_fields.iter().any(|f| f.name == "id"));
450 assert!(!root_selection.nested_fields.iter().any(|f| f.name == "name"));
451 }
452
453 #[test]
454 fn test_match_query_with_include_directive_variable() {
455 let schema = test_schema();
456 let matcher = QueryMatcher::new(schema);
457
458 let query =
459 r"query($includeEmail: Boolean!) { users { id email @include(if: $includeEmail) } }";
460 let variables = serde_json::json!({ "includeEmail": false });
461 let result = matcher.match_query(query, Some(&variables)).unwrap();
462
463 assert_eq!(result.query_def.name, "users");
464 let root_selection = &result.selections[0];
466 assert!(root_selection.nested_fields.iter().any(|f| f.name == "id"));
467 assert!(!root_selection.nested_fields.iter().any(|f| f.name == "email"));
468 }
469
470 #[test]
471 fn test_match_query_unknown_query() {
472 let schema = test_schema();
473 let matcher = QueryMatcher::new(schema);
474
475 let query = "{ unknown { id } }";
476 let result = matcher.match_query(query, None);
477
478 assert!(
479 matches!(result, Err(FraiseQLError::Validation { .. })),
480 "expected Validation error for unknown query, got: {result:?}"
481 );
482 }
483
484 #[test]
485 fn test_extract_arguments_none() {
486 let schema = test_schema();
487 let matcher = QueryMatcher::new(schema);
488
489 let args = matcher.extract_arguments(None);
490 assert!(args.is_empty());
491 }
492
493 #[test]
494 fn test_extract_arguments_some() {
495 let schema = test_schema();
496 let matcher = QueryMatcher::new(schema);
497
498 let variables = serde_json::json!({
499 "id": "123",
500 "limit": 10
501 });
502
503 let args = matcher.extract_arguments(Some(&variables));
504 assert_eq!(args.len(), 2);
505 assert_eq!(args.get("id"), Some(&serde_json::json!("123")));
506 assert_eq!(args.get("limit"), Some(&serde_json::json!(10)));
507 }
508
509 #[test]
514 fn test_suggest_similar_exact_typo() {
515 let suggestions = suggest_similar("userr", &["users", "posts", "comments"]);
516 assert_eq!(suggestions, vec!["users"]);
517 }
518
519 #[test]
520 fn test_suggest_similar_transposition() {
521 let suggestions = suggest_similar("suers", &["users", "posts"]);
522 assert_eq!(suggestions, vec!["users"]);
523 }
524
525 #[test]
526 fn test_suggest_similar_no_match() {
527 let suggestions = suggest_similar("zzz", &["users", "posts", "comments"]);
529 assert!(suggestions.is_empty());
530 }
531
532 #[test]
533 fn test_suggest_similar_capped_at_three() {
534 let suggestions =
536 suggest_similar("us", &["users", "user", "uses", "usher", "something_far"]);
537 assert!(suggestions.len() <= 3);
538 }
539
540 #[test]
541 fn test_levenshtein_identical() {
542 assert_eq!(levenshtein("foo", "foo"), 0);
543 }
544
545 #[test]
546 fn test_levenshtein_insertion() {
547 assert_eq!(levenshtein("foo", "fooo"), 1);
548 }
549
550 #[test]
551 fn test_levenshtein_deletion() {
552 assert_eq!(levenshtein("fooo", "foo"), 1);
553 }
554
555 #[test]
556 fn test_levenshtein_substitution() {
557 assert_eq!(levenshtein("foo", "bar"), 3);
558 }
559
560 #[test]
561 fn test_uzer_typo_suggests_user() {
562 let mut schema = CompiledSchema::new();
563 schema.queries.push(QueryDefinition {
564 name: "user".to_string(),
565 return_type: "User".to_string(),
566 returns_list: false,
567 nullable: true,
568 arguments: Vec::new(),
569 sql_source: Some("v_user".to_string()),
570 description: None,
571 auto_params: crate::schema::AutoParams::default(),
572 deprecation: None,
573 jsonb_column: "data".to_string(),
574 relay: false,
575 relay_cursor_column: None,
576 relay_cursor_type: CursorType::default(),
577 inject_params: IndexMap::default(),
578 cache_ttl_seconds: None,
579 additional_views: vec![],
580 requires_role: None,
581 rest_path: None,
582 rest_method: None,
583 native_columns: HashMap::new(),
584 });
585 let matcher = QueryMatcher::new(schema);
586
587 let result = matcher.match_query("{ uzer { id } }", None);
589 let err = result.expect_err("expected Err for typo'd query name");
590 let msg = err.to_string();
591 assert!(msg.contains("Did you mean"), "expected 'Did you mean' suggestion in: {msg}");
592 }
593
594 #[test]
595 fn test_unknown_query_error_includes_suggestion() {
596 let mut schema = CompiledSchema::new();
597 schema.queries.push(QueryDefinition {
598 name: "users".to_string(),
599 return_type: "User".to_string(),
600 returns_list: true,
601 nullable: false,
602 arguments: Vec::new(),
603 sql_source: Some("v_user".to_string()),
604 description: None,
605 auto_params: crate::schema::AutoParams::default(),
606 deprecation: None,
607 jsonb_column: "data".to_string(),
608 relay: false,
609 relay_cursor_column: None,
610 relay_cursor_type: CursorType::default(),
611 inject_params: IndexMap::default(),
612 cache_ttl_seconds: None,
613 additional_views: vec![],
614 requires_role: None,
615 rest_path: None,
616 rest_method: None,
617 native_columns: HashMap::new(),
618 });
619 let matcher = QueryMatcher::new(schema);
620
621 let result = matcher.match_query("{ userr { id } }", None);
623 let err = result.expect_err("expected Err for typo'd query name");
624 let msg = err.to_string();
625 assert!(msg.contains("Did you mean 'users'?"), "expected suggestion in: {msg}");
626 }
627
628 #[test]
633 fn test_resolve_inline_arg_literal_integer() {
634 let arg = crate::graphql::GraphQLArgument {
635 name: "limit".to_string(),
636 value_json: "3".to_string(),
637 value_type: "int".to_string(),
638 };
639 let vars = HashMap::new();
640 let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
641 assert_eq!(result, Some(serde_json::json!(3)));
642 }
643
644 #[test]
645 fn test_resolve_inline_arg_literal_string() {
646 let arg = crate::graphql::GraphQLArgument {
647 name: "status".to_string(),
648 value_json: "\"active\"".to_string(),
649 value_type: "string".to_string(),
650 };
651 let vars = HashMap::new();
652 let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
653 assert_eq!(result, Some(serde_json::json!("active")));
654 }
655
656 #[test]
657 fn test_resolve_inline_arg_literal_boolean() {
658 let arg = crate::graphql::GraphQLArgument {
659 name: "active".to_string(),
660 value_json: "true".to_string(),
661 value_type: "boolean".to_string(),
662 };
663 let vars = HashMap::new();
664 let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
665 assert_eq!(result, Some(serde_json::json!(true)));
666 }
667
668 #[test]
669 fn test_resolve_inline_arg_literal_null() {
670 let arg = crate::graphql::GraphQLArgument {
671 name: "limit".to_string(),
672 value_json: "null".to_string(),
673 value_type: "null".to_string(),
674 };
675 let vars = HashMap::new();
676 let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
677 assert_eq!(result, Some(serde_json::Value::Null));
678 }
679
680 #[test]
681 fn test_resolve_inline_arg_variable_reference_json_quoted() {
682 let arg = crate::graphql::GraphQLArgument {
684 name: "limit".to_string(),
685 value_json: "\"$myLimit\"".to_string(),
686 value_type: "variable".to_string(),
687 };
688 let mut vars = HashMap::new();
689 vars.insert("myLimit".to_string(), serde_json::json!(5));
690 let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
691 assert_eq!(result, Some(serde_json::json!(5)));
692 }
693
694 #[test]
695 fn test_resolve_inline_arg_variable_reference_raw() {
696 let arg = crate::graphql::GraphQLArgument {
698 name: "limit".to_string(),
699 value_json: "$limit".to_string(),
700 value_type: "variable".to_string(),
701 };
702 let mut vars = HashMap::new();
703 vars.insert("limit".to_string(), serde_json::json!(10));
704 let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
705 assert_eq!(result, Some(serde_json::json!(10)));
706 }
707
708 #[test]
709 fn test_resolve_inline_arg_variable_not_found() {
710 let arg = crate::graphql::GraphQLArgument {
711 name: "limit".to_string(),
712 value_json: "\"$missing\"".to_string(),
713 value_type: "variable".to_string(),
714 };
715 let vars = HashMap::new();
716 let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
717 assert_eq!(result, None);
718 }
719
720 #[test]
721 fn test_resolve_inline_arg_object() {
722 let arg = crate::graphql::GraphQLArgument {
723 name: "where".to_string(),
724 value_json: r#"{"status":{"eq":"active"}}"#.to_string(),
725 value_type: "object".to_string(),
726 };
727 let vars = HashMap::new();
728 let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
729 assert_eq!(result, Some(serde_json::json!({"status": {"eq": "active"}})));
730 }
731
732 #[test]
733 fn test_resolve_inline_arg_list() {
734 let arg = crate::graphql::GraphQLArgument {
735 name: "ids".to_string(),
736 value_json: "[1,2,3]".to_string(),
737 value_type: "list".to_string(),
738 };
739 let vars = HashMap::new();
740 let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
741 assert_eq!(result, Some(serde_json::json!([1, 2, 3])));
742 }
743}