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 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 let fields = self.extract_field_names(&final_selections);
203
204 let mut arguments = self.extract_arguments(variables);
206
207 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 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 fn extract_field_names(&self, selections: &[FieldSelection]) -> Vec<String> {
243 selections.iter().map(|s| s.name.clone()).collect()
244 }
245
246 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 fn resolve_inline_arg(
268 arg: &crate::graphql::GraphQLArgument,
269 variables: &HashMap<String, serde_json::Value>,
270 ) -> Option<serde_json::Value> {
271 if let Some(var_name) = arg.value_json.strip_prefix('$') {
273 return variables.get(var_name).cloned();
274 }
275 let parsed: serde_json::Value = serde_json::from_str(&arg.value_json).ok()?;
277 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 Some(parsed)
285 }
286
287 #[must_use]
289 pub const fn schema(&self) -> &CompiledSchema {
290 &self.schema
291 }
292}
293
294pub 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
320fn 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 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)] 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); assert!(result.selections[0].nested_fields.len() >= 2); }
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 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 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 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 #[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 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 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 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 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 #[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 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 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}