#![allow(clippy::unwrap_used, clippy::panic)]
use std::collections::HashMap;
use fraiseql_core::schema::FieldType;
use super::{arg_type_convertible, detect_query_native_columns};
fn cols(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect()
}
#[test]
fn inject_param_matching_a_column_is_native() {
let column_map = cols(&[("id", "uuid"), ("tenant_id", "uuid"), ("data", "jsonb")]);
let (native, fallbacks) = detect_query_native_columns(
&[],
["tenant_id"].into_iter(),
&column_map,
&std::collections::HashSet::new(),
);
assert_eq!(native.get("tenant_id").map(|c| c.pg_type.as_str()), Some("uuid"));
assert!(fallbacks.is_empty(), "inject-param matches must not emit a fallback warning");
}
#[test]
fn inject_param_without_a_column_is_silent_jsonb_fallback() {
let column_map = cols(&[("id", "uuid"), ("data", "jsonb")]);
let (native, fallbacks) = detect_query_native_columns(
&[],
["tenant_id"].into_iter(),
&column_map,
&std::collections::HashSet::new(),
);
assert!(native.is_empty());
assert!(fallbacks.is_empty());
}
#[test]
fn explicit_arg_matching_a_column_is_native() {
let column_map = cols(&[("id", "uuid"), ("data", "jsonb")]);
let (native, fallbacks) = detect_query_native_columns(
&["id"],
std::iter::empty::<&str>(),
&column_map,
&std::collections::HashSet::new(),
);
assert_eq!(native.get("id").map(|c| c.pg_type.as_str()), Some("uuid"));
assert!(fallbacks.is_empty());
}
#[test]
fn explicit_arg_without_a_column_warns_fallback() {
let column_map = cols(&[("data", "jsonb")]);
let (native, fallbacks) = detect_query_native_columns(
&["slug"],
std::iter::empty::<&str>(),
&column_map,
&std::collections::HashSet::new(),
);
assert!(native.is_empty());
assert_eq!(fallbacks, vec!["slug".to_string()]);
}
#[test]
fn args_and_inject_params_combine() {
let column_map = cols(&[("id", "uuid"), ("tenant_id", "uuid")]);
let (native, fallbacks) = detect_query_native_columns(
&["id"],
["tenant_id"].into_iter(),
&column_map,
&std::collections::HashSet::new(),
);
assert_eq!(native.len(), 2);
assert_eq!(native.get("id").map(|c| c.pg_type.as_str()), Some("uuid"));
assert_eq!(native.get("tenant_id").map(|c| c.pg_type.as_str()), Some("uuid"));
assert!(fallbacks.is_empty());
}
#[test]
fn int_arg_against_numeric_column_is_convertible() {
assert!(arg_type_convertible(&FieldType::Int, "integer"));
assert!(arg_type_convertible(&FieldType::Int, "bigint"));
assert!(arg_type_convertible(&FieldType::Float, "numeric"));
assert!(arg_type_convertible(&FieldType::Decimal, "double precision"));
}
#[test]
fn int_arg_against_non_numeric_column_is_not_convertible() {
assert!(!arg_type_convertible(&FieldType::Int, "uuid"));
assert!(!arg_type_convertible(&FieldType::Int, "text"));
assert!(!arg_type_convertible(&FieldType::Int, "boolean"));
}
#[test]
fn boolean_arg_only_matches_boolean_column() {
assert!(arg_type_convertible(&FieldType::Boolean, "boolean"));
assert!(!arg_type_convertible(&FieldType::Boolean, "text"));
assert!(!arg_type_convertible(&FieldType::Boolean, "integer"));
}
#[test]
fn uuid_arg_matches_uuid_or_text_column() {
assert!(arg_type_convertible(&FieldType::Uuid, "uuid"));
assert!(arg_type_convertible(&FieldType::Uuid, "character varying"));
assert!(!arg_type_convertible(&FieldType::Uuid, "bigint"));
}
#[test]
fn id_and_string_args_are_permissive() {
assert!(arg_type_convertible(&FieldType::Id, "uuid"));
assert!(arg_type_convertible(&FieldType::Id, "bigint"));
assert!(arg_type_convertible(&FieldType::Id, "text"));
assert!(arg_type_convertible(&FieldType::String, "integer"));
}
#[test]
fn unknown_column_family_is_never_flagged() {
assert!(arg_type_convertible(&FieldType::Int, "mood_enum"));
assert!(arg_type_convertible(&FieldType::Boolean, "ltree"));
}
#[test]
fn list_arg_is_checked_by_its_element_type() {
let int_list = FieldType::list(FieldType::Int);
assert!(!arg_type_convertible(&int_list, "uuid"));
let id_list = FieldType::list(FieldType::Id);
assert!(arg_type_convertible(&id_list, "uuid"));
}
mod tview_index_advice_tests {
use fraiseql_core::schema::{ArgumentDefinition, FieldType, QueryDefinition};
use serde_json::{Value, json};
use super::super::{
DatabaseWarning, QueryFilter, tview_index_advice, unify_tview_index_advice,
};
fn query() -> QueryDefinition {
let mut query = QueryDefinition::new("members", "Member");
for (name, ty) in [
("email", FieldType::String),
("age", FieldType::Int),
("score", FieldType::Float),
("active", FieldType::Boolean),
] {
query.arguments.push(ArgumentDefinition::new(name, ty));
}
query
}
fn declared(options: Value) -> fraiseql_db::TviewDeclaration {
fraiseql_db::TviewDeclaration {
tview: "app.tv_member".to_string(),
options,
}
}
fn arg(name: &str) -> (QueryFilter, String) {
(QueryFilter::Argument(name.to_string()), name.to_string())
}
fn advice(options: Value, filters: &[(QueryFilter, String)]) -> Option<(Vec<String>, Value)> {
match tview_index_advice(&query(), &declared(options), filters)? {
DatabaseWarning::TviewIndexMissing { keys, options, .. } => Some((keys, options)),
other => panic!("{other:?}"),
}
}
#[test]
fn the_entry_types_each_key_as_fraiseql_compares_it() {
let filters = [
(QueryFilter::Inject("org".to_string()), "org".to_string()),
arg("email"),
arg("age"),
arg("score"),
arg("active"),
];
let (keys, options) = advice(json!({}), &filters).unwrap();
assert_eq!(keys, ["org", "email", "age", "score", "active"]);
assert_eq!(
options["indexes"],
json!([[
"org",
"email",
{ "path": "age", "type": "numeric" },
{ "path": "score", "type": "numeric" },
{ "path": "active", "type": "boolean" }
]])
);
}
#[test]
fn the_options_keep_every_other_declaration() {
let (_, options) = advice(
json!({ "fillfactor": 70, "typename": "Member", "indexes": [["region"]] }),
&[arg("email")],
)
.unwrap();
assert_eq!(
options,
json!({ "fillfactor": 70, "typename": "Member", "indexes": [["region"], ["email"]] })
);
}
#[test]
fn a_declared_index_leading_with_the_keys_serves_them() {
for indexes in [
json!([["email"]]),
json!([[{ "path": "email" }]]),
json!([[{ "path": "email", "type": "text", "desc": true }, "region"]]),
] {
assert_eq!(advice(json!({ "indexes": indexes }), &[arg("email")]), None, "{indexes}");
}
assert_eq!(
advice(json!({ "indexes": [[{ "path": "age", "type": "numeric" }]] }), &[arg("age")]),
None
);
}
#[test]
fn an_index_that_does_not_lead_with_the_keys_serves_nothing() {
for indexes in [
json!([["region", "email"]]),
json!([["age"]]),
json!([[{ "path": "age", "type": "bigint" }]]),
] {
let filters = [arg("email"), arg("age")];
let filters = if indexes.to_string().contains("region") {
&filters[..1]
} else {
&filters[1..]
};
assert!(advice(json!({ "indexes": indexes }), filters).is_some(), "{indexes}");
}
}
#[test]
fn an_index_on_fewer_keys_serves_nothing() {
assert!(advice(json!({ "indexes": [["email"]] }), &[arg("email"), arg("age")]).is_some());
}
#[test]
fn a_query_with_no_document_filter_needs_no_index() {
assert_eq!(advice(json!({}), &[]), None);
}
#[test]
fn the_statement_quotes_what_it_embeds() {
let warning = DatabaseWarning::TviewIndexMissing {
query_name: "q".to_string(),
tview: "app.\"tv_o'brien\"".to_string(),
keys: vec!["email".to_string()],
options: json!({ "typename": "O'Brien", "indexes": [["email"]] }),
};
let statement = warning.statement().unwrap();
let literal = statement.split_once(", '").unwrap().1.split_once("'::jsonb").unwrap().0;
let options: Value = serde_json::from_str(&literal.replace("''", "'")).unwrap();
assert_eq!(options, json!({ "typename": "O'Brien", "indexes": [["email"]] }));
assert!(statement.ends_with(r#"= 'app."tv_o''brien"';"#), "{statement}");
}
#[test]
fn every_statement_for_one_tview_declares_every_index_advised_for_it() {
let warning = |query: &str, tview: &str, key: &str| DatabaseWarning::TviewIndexMissing {
query_name: query.to_string(),
tview: tview.to_string(),
keys: vec![key.to_string()],
options: json!({ "fillfactor": 70, "indexes": [["region"], [key]] }),
};
let mut warnings = vec![
warning("byEmail", "app.tv_member", "email"),
DatabaseWarning::MissingRelation {
query_name: "other".to_string(),
sql_source: "v_other".to_string(),
},
warning("byAge", "app.tv_member", "age"),
warning("byName", "app.tv_team", "name"),
];
unify_tview_index_advice(&mut warnings);
let options = |i: usize| match &warnings[i] {
DatabaseWarning::TviewIndexMissing { options, .. } => options.clone(),
other => panic!("{other:?}"),
};
let member = json!({ "fillfactor": 70, "indexes": [["region"], ["email"], ["age"]] });
assert_eq!(options(0), member);
assert_eq!(options(2), member);
assert_eq!(options(3), json!({ "fillfactor": 70, "indexes": [["region"], ["name"]] }));
}
}