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 });
381 schema
382 }
383
384 #[test]
385 fn test_matcher_new() {
386 let schema = test_schema();
387 let matcher = QueryMatcher::new(schema);
388 assert_eq!(matcher.schema().queries.len(), 1);
389 }
390
391 #[test]
392 fn test_match_simple_query() {
393 let schema = test_schema();
394 let matcher = QueryMatcher::new(schema);
395
396 let query = "{ users { id name } }";
397 let result = matcher.match_query(query, None).unwrap();
398
399 assert_eq!(result.query_def.name, "users");
400 assert_eq!(result.fields.len(), 1); assert!(result.selections[0].nested_fields.len() >= 2); }
403
404 #[test]
405 fn test_match_query_with_operation_name() {
406 let schema = test_schema();
407 let matcher = QueryMatcher::new(schema);
408
409 let query = "query GetUsers { users { id name } }";
410 let result = matcher.match_query(query, None).unwrap();
411
412 assert_eq!(result.query_def.name, "users");
413 assert_eq!(result.operation_name, Some("GetUsers".to_string()));
414 }
415
416 #[test]
417 fn test_match_query_with_fragment() {
418 let schema = test_schema();
419 let matcher = QueryMatcher::new(schema);
420
421 let query = r"
422 fragment UserFields on User {
423 id
424 name
425 }
426 query { users { ...UserFields } }
427 ";
428 let result = matcher.match_query(query, None).unwrap();
429
430 assert_eq!(result.query_def.name, "users");
431 let root_selection = &result.selections[0];
433 assert!(root_selection.nested_fields.iter().any(|f| f.name == "id"));
434 assert!(root_selection.nested_fields.iter().any(|f| f.name == "name"));
435 }
436
437 #[test]
438 fn test_match_query_with_skip_directive() {
439 let schema = test_schema();
440 let matcher = QueryMatcher::new(schema);
441
442 let query = r"{ users { id name @skip(if: true) } }";
443 let result = matcher.match_query(query, None).unwrap();
444
445 assert_eq!(result.query_def.name, "users");
446 let root_selection = &result.selections[0];
448 assert!(root_selection.nested_fields.iter().any(|f| f.name == "id"));
449 assert!(!root_selection.nested_fields.iter().any(|f| f.name == "name"));
450 }
451
452 #[test]
453 fn test_match_query_with_include_directive_variable() {
454 let schema = test_schema();
455 let matcher = QueryMatcher::new(schema);
456
457 let query =
458 r"query($includeEmail: Boolean!) { users { id email @include(if: $includeEmail) } }";
459 let variables = serde_json::json!({ "includeEmail": false });
460 let result = matcher.match_query(query, Some(&variables)).unwrap();
461
462 assert_eq!(result.query_def.name, "users");
463 let root_selection = &result.selections[0];
465 assert!(root_selection.nested_fields.iter().any(|f| f.name == "id"));
466 assert!(!root_selection.nested_fields.iter().any(|f| f.name == "email"));
467 }
468
469 #[test]
470 fn test_match_query_unknown_query() {
471 let schema = test_schema();
472 let matcher = QueryMatcher::new(schema);
473
474 let query = "{ unknown { id } }";
475 let result = matcher.match_query(query, None);
476
477 assert!(
478 matches!(result, Err(FraiseQLError::Validation { .. })),
479 "expected Validation error for unknown query, got: {result:?}"
480 );
481 }
482
483 #[test]
484 fn test_extract_arguments_none() {
485 let schema = test_schema();
486 let matcher = QueryMatcher::new(schema);
487
488 let args = matcher.extract_arguments(None);
489 assert!(args.is_empty());
490 }
491
492 #[test]
493 fn test_extract_arguments_some() {
494 let schema = test_schema();
495 let matcher = QueryMatcher::new(schema);
496
497 let variables = serde_json::json!({
498 "id": "123",
499 "limit": 10
500 });
501
502 let args = matcher.extract_arguments(Some(&variables));
503 assert_eq!(args.len(), 2);
504 assert_eq!(args.get("id"), Some(&serde_json::json!("123")));
505 assert_eq!(args.get("limit"), Some(&serde_json::json!(10)));
506 }
507
508 #[test]
513 fn test_suggest_similar_exact_typo() {
514 let suggestions = suggest_similar("userr", &["users", "posts", "comments"]);
515 assert_eq!(suggestions, vec!["users"]);
516 }
517
518 #[test]
519 fn test_suggest_similar_transposition() {
520 let suggestions = suggest_similar("suers", &["users", "posts"]);
521 assert_eq!(suggestions, vec!["users"]);
522 }
523
524 #[test]
525 fn test_suggest_similar_no_match() {
526 let suggestions = suggest_similar("zzz", &["users", "posts", "comments"]);
528 assert!(suggestions.is_empty());
529 }
530
531 #[test]
532 fn test_suggest_similar_capped_at_three() {
533 let suggestions =
535 suggest_similar("us", &["users", "user", "uses", "usher", "something_far"]);
536 assert!(suggestions.len() <= 3);
537 }
538
539 #[test]
540 fn test_levenshtein_identical() {
541 assert_eq!(levenshtein("foo", "foo"), 0);
542 }
543
544 #[test]
545 fn test_levenshtein_insertion() {
546 assert_eq!(levenshtein("foo", "fooo"), 1);
547 }
548
549 #[test]
550 fn test_levenshtein_deletion() {
551 assert_eq!(levenshtein("fooo", "foo"), 1);
552 }
553
554 #[test]
555 fn test_levenshtein_substitution() {
556 assert_eq!(levenshtein("foo", "bar"), 3);
557 }
558
559 #[test]
560 fn test_uzer_typo_suggests_user() {
561 let mut schema = CompiledSchema::new();
562 schema.queries.push(QueryDefinition {
563 name: "user".to_string(),
564 return_type: "User".to_string(),
565 returns_list: false,
566 nullable: true,
567 arguments: Vec::new(),
568 sql_source: Some("v_user".to_string()),
569 description: None,
570 auto_params: crate::schema::AutoParams::default(),
571 deprecation: None,
572 jsonb_column: "data".to_string(),
573 relay: false,
574 relay_cursor_column: None,
575 relay_cursor_type: CursorType::default(),
576 inject_params: IndexMap::default(),
577 cache_ttl_seconds: None,
578 additional_views: vec![],
579 requires_role: None,
580 rest_path: None,
581 rest_method: None,
582 });
583 let matcher = QueryMatcher::new(schema);
584
585 let result = matcher.match_query("{ uzer { id } }", None);
587 let err = result.expect_err("expected Err for typo'd query name");
588 let msg = err.to_string();
589 assert!(msg.contains("Did you mean"), "expected 'Did you mean' suggestion in: {msg}");
590 }
591
592 #[test]
593 fn test_unknown_query_error_includes_suggestion() {
594 let mut schema = CompiledSchema::new();
595 schema.queries.push(QueryDefinition {
596 name: "users".to_string(),
597 return_type: "User".to_string(),
598 returns_list: true,
599 nullable: false,
600 arguments: Vec::new(),
601 sql_source: Some("v_user".to_string()),
602 description: None,
603 auto_params: crate::schema::AutoParams::default(),
604 deprecation: None,
605 jsonb_column: "data".to_string(),
606 relay: false,
607 relay_cursor_column: None,
608 relay_cursor_type: CursorType::default(),
609 inject_params: IndexMap::default(),
610 cache_ttl_seconds: None,
611 additional_views: vec![],
612 requires_role: None,
613 rest_path: None,
614 rest_method: None,
615 });
616 let matcher = QueryMatcher::new(schema);
617
618 let result = matcher.match_query("{ userr { id } }", None);
620 let err = result.expect_err("expected Err for typo'd query name");
621 let msg = err.to_string();
622 assert!(msg.contains("Did you mean 'users'?"), "expected suggestion in: {msg}");
623 }
624
625 #[test]
630 fn test_resolve_inline_arg_literal_integer() {
631 let arg = crate::graphql::GraphQLArgument {
632 name: "limit".to_string(),
633 value_json: "3".to_string(),
634 value_type: "int".to_string(),
635 };
636 let vars = HashMap::new();
637 let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
638 assert_eq!(result, Some(serde_json::json!(3)));
639 }
640
641 #[test]
642 fn test_resolve_inline_arg_literal_string() {
643 let arg = crate::graphql::GraphQLArgument {
644 name: "status".to_string(),
645 value_json: "\"active\"".to_string(),
646 value_type: "string".to_string(),
647 };
648 let vars = HashMap::new();
649 let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
650 assert_eq!(result, Some(serde_json::json!("active")));
651 }
652
653 #[test]
654 fn test_resolve_inline_arg_literal_boolean() {
655 let arg = crate::graphql::GraphQLArgument {
656 name: "active".to_string(),
657 value_json: "true".to_string(),
658 value_type: "boolean".to_string(),
659 };
660 let vars = HashMap::new();
661 let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
662 assert_eq!(result, Some(serde_json::json!(true)));
663 }
664
665 #[test]
666 fn test_resolve_inline_arg_literal_null() {
667 let arg = crate::graphql::GraphQLArgument {
668 name: "limit".to_string(),
669 value_json: "null".to_string(),
670 value_type: "null".to_string(),
671 };
672 let vars = HashMap::new();
673 let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
674 assert_eq!(result, Some(serde_json::Value::Null));
675 }
676
677 #[test]
678 fn test_resolve_inline_arg_variable_reference_json_quoted() {
679 let arg = crate::graphql::GraphQLArgument {
681 name: "limit".to_string(),
682 value_json: "\"$myLimit\"".to_string(),
683 value_type: "variable".to_string(),
684 };
685 let mut vars = HashMap::new();
686 vars.insert("myLimit".to_string(), serde_json::json!(5));
687 let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
688 assert_eq!(result, Some(serde_json::json!(5)));
689 }
690
691 #[test]
692 fn test_resolve_inline_arg_variable_reference_raw() {
693 let arg = crate::graphql::GraphQLArgument {
695 name: "limit".to_string(),
696 value_json: "$limit".to_string(),
697 value_type: "variable".to_string(),
698 };
699 let mut vars = HashMap::new();
700 vars.insert("limit".to_string(), serde_json::json!(10));
701 let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
702 assert_eq!(result, Some(serde_json::json!(10)));
703 }
704
705 #[test]
706 fn test_resolve_inline_arg_variable_not_found() {
707 let arg = crate::graphql::GraphQLArgument {
708 name: "limit".to_string(),
709 value_json: "\"$missing\"".to_string(),
710 value_type: "variable".to_string(),
711 };
712 let vars = HashMap::new();
713 let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
714 assert_eq!(result, None);
715 }
716
717 #[test]
718 fn test_resolve_inline_arg_object() {
719 let arg = crate::graphql::GraphQLArgument {
720 name: "where".to_string(),
721 value_json: r#"{"status":{"eq":"active"}}"#.to_string(),
722 value_type: "object".to_string(),
723 };
724 let vars = HashMap::new();
725 let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
726 assert_eq!(result, Some(serde_json::json!({"status": {"eq": "active"}})));
727 }
728
729 #[test]
730 fn test_resolve_inline_arg_list() {
731 let arg = crate::graphql::GraphQLArgument {
732 name: "ids".to_string(),
733 value_json: "[1,2,3]".to_string(),
734 value_type: "list".to_string(),
735 };
736 let vars = HashMap::new();
737 let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
738 assert_eq!(result, Some(serde_json::json!([1, 2, 3])));
739 }
740}