use std::collections::HashMap;
use crate::{
error::{FraiseQLError, Result},
graphql::{DirectiveEvaluator, FieldSelection, FragmentResolver, ParsedQuery, parse_query},
schema::{CompiledSchema, QueryDefinition},
};
#[derive(Debug, Clone)]
pub struct QueryMatch {
pub query_def: QueryDefinition,
pub fields: Vec<String>,
pub selections: Vec<FieldSelection>,
pub arguments: HashMap<String, serde_json::Value>,
pub operation_name: Option<String>,
pub parsed_query: ParsedQuery,
}
impl QueryMatch {
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: String::new(),
};
Ok(Self {
query_def,
fields,
selections,
arguments,
operation_name: None,
parsed_query,
})
}
}
pub struct QueryMatcher {
schema: CompiledSchema,
}
impl QueryMatcher {
#[must_use]
pub fn new(mut schema: CompiledSchema) -> Self {
schema.build_indexes();
Self { schema }
}
pub fn match_query(
&self,
query: &str,
variables: Option<&serde_json::Value>,
) -> Result<QueryMatch> {
let parsed = parse_query(query).map_err(|e| FraiseQLError::Parse {
message: e.to_string(),
location: "query".to_string(),
})?;
let variables_map = self.build_variables_map(variables);
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()),
}
})?;
let final_selections =
DirectiveEvaluator::filter_selections(&resolved_selections, &variables_map).map_err(
|e| FraiseQLError::Validation {
message: e.to_string(),
path: Some("directives".to_string()),
},
)?;
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();
let fields = self.extract_field_names(&final_selections);
let mut arguments = self.extract_arguments(variables);
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,
})
}
fn build_variables_map(
&self,
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()
}
}
fn extract_field_names(&self, selections: &[FieldSelection]) -> Vec<String> {
selections.iter().map(|s| s.name.clone()).collect()
}
fn extract_arguments(
&self,
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()
}
}
fn resolve_inline_arg(
arg: &crate::graphql::GraphQLArgument,
variables: &HashMap<String, serde_json::Value>,
) -> Option<serde_json::Value> {
if let Some(var_name) = arg.value_json.strip_prefix('$') {
return variables.get(var_name).cloned();
}
let parsed: serde_json::Value = serde_json::from_str(&arg.value_json).ok()?;
if let Some(s) = parsed.as_str() {
if let Some(var_name) = s.strip_prefix('$') {
return variables.get(var_name).cloned();
}
}
Some(parsed)
}
#[must_use]
pub const fn schema(&self) -> &CompiledSchema {
&self.schema
}
}
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()
}
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();
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]
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use indexmap::IndexMap;
use super::*;
use crate::schema::CursorType;
fn test_schema() -> CompiledSchema {
let mut schema = CompiledSchema::new();
schema.queries.push(QueryDefinition {
name: "users".to_string(),
return_type: "User".to_string(),
returns_list: true,
nullable: false,
arguments: Vec::new(),
sql_source: Some("v_user".to_string()),
description: None,
auto_params: crate::schema::AutoParams::default(),
deprecation: None,
jsonb_column: "data".to_string(),
relay: false,
relay_cursor_column: None,
relay_cursor_type: CursorType::default(),
inject_params: IndexMap::default(),
cache_ttl_seconds: None,
additional_views: vec![],
requires_role: None,
rest_path: None,
rest_method: None,
native_columns: HashMap::new(),
});
schema
}
#[test]
fn test_matcher_new() {
let schema = test_schema();
let matcher = QueryMatcher::new(schema);
assert_eq!(matcher.schema().queries.len(), 1);
}
#[test]
fn test_match_simple_query() {
let schema = test_schema();
let matcher = QueryMatcher::new(schema);
let query = "{ users { id name } }";
let result = matcher.match_query(query, None).unwrap();
assert_eq!(result.query_def.name, "users");
assert_eq!(result.fields.len(), 1); assert!(result.selections[0].nested_fields.len() >= 2); }
#[test]
fn test_match_query_with_operation_name() {
let schema = test_schema();
let matcher = QueryMatcher::new(schema);
let query = "query GetUsers { users { id name } }";
let result = matcher.match_query(query, None).unwrap();
assert_eq!(result.query_def.name, "users");
assert_eq!(result.operation_name, Some("GetUsers".to_string()));
}
#[test]
fn test_match_query_with_fragment() {
let schema = test_schema();
let matcher = QueryMatcher::new(schema);
let query = r"
fragment UserFields on User {
id
name
}
query { users { ...UserFields } }
";
let result = matcher.match_query(query, None).unwrap();
assert_eq!(result.query_def.name, "users");
let root_selection = &result.selections[0];
assert!(root_selection.nested_fields.iter().any(|f| f.name == "id"));
assert!(root_selection.nested_fields.iter().any(|f| f.name == "name"));
}
#[test]
fn test_match_query_with_skip_directive() {
let schema = test_schema();
let matcher = QueryMatcher::new(schema);
let query = r"{ users { id name @skip(if: true) } }";
let result = matcher.match_query(query, None).unwrap();
assert_eq!(result.query_def.name, "users");
let root_selection = &result.selections[0];
assert!(root_selection.nested_fields.iter().any(|f| f.name == "id"));
assert!(!root_selection.nested_fields.iter().any(|f| f.name == "name"));
}
#[test]
fn test_match_query_with_include_directive_variable() {
let schema = test_schema();
let matcher = QueryMatcher::new(schema);
let query =
r"query($includeEmail: Boolean!) { users { id email @include(if: $includeEmail) } }";
let variables = serde_json::json!({ "includeEmail": false });
let result = matcher.match_query(query, Some(&variables)).unwrap();
assert_eq!(result.query_def.name, "users");
let root_selection = &result.selections[0];
assert!(root_selection.nested_fields.iter().any(|f| f.name == "id"));
assert!(!root_selection.nested_fields.iter().any(|f| f.name == "email"));
}
#[test]
fn test_match_query_unknown_query() {
let schema = test_schema();
let matcher = QueryMatcher::new(schema);
let query = "{ unknown { id } }";
let result = matcher.match_query(query, None);
assert!(
matches!(result, Err(FraiseQLError::Validation { .. })),
"expected Validation error for unknown query, got: {result:?}"
);
}
#[test]
fn test_extract_arguments_none() {
let schema = test_schema();
let matcher = QueryMatcher::new(schema);
let args = matcher.extract_arguments(None);
assert!(args.is_empty());
}
#[test]
fn test_extract_arguments_some() {
let schema = test_schema();
let matcher = QueryMatcher::new(schema);
let variables = serde_json::json!({
"id": "123",
"limit": 10
});
let args = matcher.extract_arguments(Some(&variables));
assert_eq!(args.len(), 2);
assert_eq!(args.get("id"), Some(&serde_json::json!("123")));
assert_eq!(args.get("limit"), Some(&serde_json::json!(10)));
}
#[test]
fn test_suggest_similar_exact_typo() {
let suggestions = suggest_similar("userr", &["users", "posts", "comments"]);
assert_eq!(suggestions, vec!["users"]);
}
#[test]
fn test_suggest_similar_transposition() {
let suggestions = suggest_similar("suers", &["users", "posts"]);
assert_eq!(suggestions, vec!["users"]);
}
#[test]
fn test_suggest_similar_no_match() {
let suggestions = suggest_similar("zzz", &["users", "posts", "comments"]);
assert!(suggestions.is_empty());
}
#[test]
fn test_suggest_similar_capped_at_three() {
let suggestions =
suggest_similar("us", &["users", "user", "uses", "usher", "something_far"]);
assert!(suggestions.len() <= 3);
}
#[test]
fn test_levenshtein_identical() {
assert_eq!(levenshtein("foo", "foo"), 0);
}
#[test]
fn test_levenshtein_insertion() {
assert_eq!(levenshtein("foo", "fooo"), 1);
}
#[test]
fn test_levenshtein_deletion() {
assert_eq!(levenshtein("fooo", "foo"), 1);
}
#[test]
fn test_levenshtein_substitution() {
assert_eq!(levenshtein("foo", "bar"), 3);
}
#[test]
fn test_uzer_typo_suggests_user() {
let mut schema = CompiledSchema::new();
schema.queries.push(QueryDefinition {
name: "user".to_string(),
return_type: "User".to_string(),
returns_list: false,
nullable: true,
arguments: Vec::new(),
sql_source: Some("v_user".to_string()),
description: None,
auto_params: crate::schema::AutoParams::default(),
deprecation: None,
jsonb_column: "data".to_string(),
relay: false,
relay_cursor_column: None,
relay_cursor_type: CursorType::default(),
inject_params: IndexMap::default(),
cache_ttl_seconds: None,
additional_views: vec![],
requires_role: None,
rest_path: None,
rest_method: None,
native_columns: HashMap::new(),
});
let matcher = QueryMatcher::new(schema);
let result = matcher.match_query("{ uzer { id } }", None);
let err = result.expect_err("expected Err for typo'd query name");
let msg = err.to_string();
assert!(msg.contains("Did you mean"), "expected 'Did you mean' suggestion in: {msg}");
}
#[test]
fn test_unknown_query_error_includes_suggestion() {
let mut schema = CompiledSchema::new();
schema.queries.push(QueryDefinition {
name: "users".to_string(),
return_type: "User".to_string(),
returns_list: true,
nullable: false,
arguments: Vec::new(),
sql_source: Some("v_user".to_string()),
description: None,
auto_params: crate::schema::AutoParams::default(),
deprecation: None,
jsonb_column: "data".to_string(),
relay: false,
relay_cursor_column: None,
relay_cursor_type: CursorType::default(),
inject_params: IndexMap::default(),
cache_ttl_seconds: None,
additional_views: vec![],
requires_role: None,
rest_path: None,
rest_method: None,
native_columns: HashMap::new(),
});
let matcher = QueryMatcher::new(schema);
let result = matcher.match_query("{ userr { id } }", None);
let err = result.expect_err("expected Err for typo'd query name");
let msg = err.to_string();
assert!(msg.contains("Did you mean 'users'?"), "expected suggestion in: {msg}");
}
#[test]
fn test_resolve_inline_arg_literal_integer() {
let arg = crate::graphql::GraphQLArgument {
name: "limit".to_string(),
value_json: "3".to_string(),
value_type: "int".to_string(),
};
let vars = HashMap::new();
let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
assert_eq!(result, Some(serde_json::json!(3)));
}
#[test]
fn test_resolve_inline_arg_literal_string() {
let arg = crate::graphql::GraphQLArgument {
name: "status".to_string(),
value_json: "\"active\"".to_string(),
value_type: "string".to_string(),
};
let vars = HashMap::new();
let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
assert_eq!(result, Some(serde_json::json!("active")));
}
#[test]
fn test_resolve_inline_arg_literal_boolean() {
let arg = crate::graphql::GraphQLArgument {
name: "active".to_string(),
value_json: "true".to_string(),
value_type: "boolean".to_string(),
};
let vars = HashMap::new();
let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
assert_eq!(result, Some(serde_json::json!(true)));
}
#[test]
fn test_resolve_inline_arg_literal_null() {
let arg = crate::graphql::GraphQLArgument {
name: "limit".to_string(),
value_json: "null".to_string(),
value_type: "null".to_string(),
};
let vars = HashMap::new();
let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
assert_eq!(result, Some(serde_json::Value::Null));
}
#[test]
fn test_resolve_inline_arg_variable_reference_json_quoted() {
let arg = crate::graphql::GraphQLArgument {
name: "limit".to_string(),
value_json: "\"$myLimit\"".to_string(),
value_type: "variable".to_string(),
};
let mut vars = HashMap::new();
vars.insert("myLimit".to_string(), serde_json::json!(5));
let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
assert_eq!(result, Some(serde_json::json!(5)));
}
#[test]
fn test_resolve_inline_arg_variable_reference_raw() {
let arg = crate::graphql::GraphQLArgument {
name: "limit".to_string(),
value_json: "$limit".to_string(),
value_type: "variable".to_string(),
};
let mut vars = HashMap::new();
vars.insert("limit".to_string(), serde_json::json!(10));
let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
assert_eq!(result, Some(serde_json::json!(10)));
}
#[test]
fn test_resolve_inline_arg_variable_not_found() {
let arg = crate::graphql::GraphQLArgument {
name: "limit".to_string(),
value_json: "\"$missing\"".to_string(),
value_type: "variable".to_string(),
};
let vars = HashMap::new();
let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
assert_eq!(result, None);
}
#[test]
fn test_resolve_inline_arg_object() {
let arg = crate::graphql::GraphQLArgument {
name: "where".to_string(),
value_json: r#"{"status":{"eq":"active"}}"#.to_string(),
value_type: "object".to_string(),
};
let vars = HashMap::new();
let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
assert_eq!(result, Some(serde_json::json!({"status": {"eq": "active"}})));
}
#[test]
fn test_resolve_inline_arg_list() {
let arg = crate::graphql::GraphQLArgument {
name: "ids".to_string(),
value_json: "[1,2,3]".to_string(),
value_type: "list".to_string(),
};
let vars = HashMap::new();
let result = QueryMatcher::resolve_inline_arg(&arg, &vars);
assert_eq!(result, Some(serde_json::json!([1, 2, 3])));
}
}