#![allow(clippy::unwrap_used, clippy::panic)] #![allow(clippy::wildcard_imports)]
mod database_validator_tests {
use std::collections::HashMap;
use fraiseql_core::{
db::{
DatabaseType,
introspector::{DatabaseIntrospector, RelationInfo},
},
schema::{
ArgumentDefinition, AutoParams, CompiledSchema, CursorType, FieldDefinition, FieldType,
MutationDefinition, PaginationOrder, QueryDefinition, TypeDefinition,
},
};
use indexmap::IndexMap;
use super::super::{database_validator::*, mutation_contract::Severity};
struct MockIntrospector {
relations: Vec<RelationInfo>,
columns: HashMap<String, Vec<(String, String, bool)>>,
json_samples: HashMap<(String, String), Vec<serde_json::Value>>,
functions: std::collections::HashSet<String>,
view_defs: HashMap<String, String>,
tviews: std::collections::HashSet<String>,
db_type: DatabaseType,
}
impl MockIntrospector {
fn new(db_type: DatabaseType) -> Self {
Self {
relations: Vec::new(),
columns: HashMap::new(),
json_samples: HashMap::new(),
functions: std::collections::HashSet::new(),
view_defs: HashMap::new(),
tviews: std::collections::HashSet::new(),
db_type,
}
}
fn with_function(mut self, name: &str) -> Self {
self.functions.insert(name.to_string());
self
}
fn with_relation(
mut self,
schema: &str,
name: &str,
kind: fraiseql_core::db::RelationKind,
) -> Self {
self.relations.push(RelationInfo {
schema: schema.to_string(),
name: name.to_string(),
kind,
});
self
}
fn with_columns(mut self, table: &str, cols: Vec<(&str, &str, bool)>) -> Self {
self.columns.insert(
table.to_string(),
cols.into_iter()
.map(|(n, t, nullable)| (n.to_string(), t.to_string(), nullable))
.collect(),
);
self
}
fn with_view_definition(mut self, view: &str, body: &str) -> Self {
self.view_defs.insert(view.to_string(), body.to_string());
self
}
fn with_tview(mut self, relation: &str) -> Self {
self.tviews.insert(relation.to_string());
self
}
fn with_json_samples(
mut self,
table: &str,
column: &str,
samples: Vec<serde_json::Value>,
) -> Self {
self.json_samples.insert((table.to_string(), column.to_string()), samples);
self
}
}
#[allow(unknown_lints, clippy::unused_async_trait_impl)]
impl DatabaseIntrospector for MockIntrospector {
async fn list_fact_tables(&self) -> fraiseql_core::Result<Vec<String>> {
Ok(Vec::new())
}
async fn get_columns(
&self,
table_name: &str,
) -> fraiseql_core::Result<Vec<(String, String, bool)>> {
Ok(self.columns.get(table_name).cloned().unwrap_or_default())
}
async fn get_indexed_columns(
&self,
_table_name: &str,
) -> fraiseql_core::Result<Vec<String>> {
Ok(Vec::new())
}
fn database_type(&self) -> DatabaseType {
self.db_type
}
async fn list_relations(&self) -> fraiseql_core::Result<Vec<RelationInfo>> {
Ok(self.relations.clone())
}
async fn get_sample_json_rows(
&self,
table_name: &str,
column_name: &str,
_limit: usize,
) -> fraiseql_core::Result<Vec<serde_json::Value>> {
Ok(self
.json_samples
.get(&(table_name.to_string(), column_name.to_string()))
.cloned()
.unwrap_or_default())
}
async fn function_exists(
&self,
schema: Option<&str>,
name: &str,
) -> fraiseql_core::Result<Option<bool>> {
let key = match schema {
Some(s) => format!("{s}.{name}"),
None => name.to_string(),
};
Ok(Some(self.functions.contains(&key)))
}
async fn get_view_definition(
&self,
relation: &str,
) -> fraiseql_core::Result<Option<String>> {
Ok(self.view_defs.get(relation).cloned())
}
async fn is_tview(&self, relation: &str) -> fraiseql_core::Result<bool> {
Ok(self.tviews.contains(relation))
}
}
fn make_query(name: &str, return_type: &str, sql_source: &str) -> QueryDefinition {
QueryDefinition {
function: None,
requires_actor: Vec::new(),
returns_count: false,
name: name.to_string(),
return_type: return_type.to_string(),
returns_list: true,
nullable: false,
arguments: vec![],
sql_source: Some(sql_source.to_string()),
sql_source_kind: fraiseql_core::schema::SourceKind::Relation,
description: None,
auto_params: AutoParams::default(),
deprecation: None,
jsonb_column: "data".to_string(),
relay: false,
relay_cursor_column: None,
relay_cursor_type: CursorType::default(),
inject_params: IndexMap::default(),
read_routing: fraiseql_core::db::types::ReadRouting::default(),
cache_ttl_seconds: None,
additional_views: vec![],
requires_role: None,
rest_path: None,
rest_method: None,
rest_stream: false,
native_columns: HashMap::new(),
pagination_order: None,
}
}
fn make_type(name: &str, fields: Vec<(&str, FieldType)>) -> TypeDefinition {
TypeDefinition {
name: name.into(),
fields: fields
.into_iter()
.map(|(n, ft)| FieldDefinition::new(n, ft))
.collect(),
description: None,
sql_source: "".into(),
jsonb_column: "data".to_string(),
sql_projection_hint: None,
implements: vec![],
requires_role: None,
is_error: false,
relay: false,
internal: false,
embedded: false,
relationships: Vec::new(),
inject_params: indexmap::IndexMap::new(),
subscription_policy: None,
}
}
fn make_schema(types: Vec<TypeDefinition>, queries: Vec<QueryDefinition>) -> CompiledSchema {
CompiledSchema {
types,
queries,
enums: vec![],
input_types: vec![],
interfaces: vec![],
unions: vec![],
mutations: vec![],
subscriptions: vec![],
directives: vec![],
observers: Vec::new(),
fact_tables: HashMap::default(),
federation: None,
security: None,
observers_config: None,
subscriptions_config: None,
validation_config: None,
debug_config: None,
mcp_config: None,
schema_sdl: None,
..Default::default()
}
}
#[tokio::test]
async fn test_valid_schema_no_warnings() {
let introspector = MockIntrospector::new(DatabaseType::PostgreSQL)
.with_relation("public", "v_user", fraiseql_core::db::RelationKind::View)
.with_columns("v_user", vec![("data", "jsonb", false), ("pk_user", "bigint", false)])
.with_json_samples(
"v_user",
"data",
vec![serde_json::json!({"name": "Alice", "email": "alice@example.com"})],
);
let schema = make_schema(
vec![make_type(
"User",
vec![("name", FieldType::String), ("email", FieldType::String)],
)],
vec![make_query("users", "User", "v_user")],
);
let report = validate_schema_against_database(&schema, &introspector).await.unwrap();
assert!(
report.warnings.is_empty(),
"Expected no warnings, got: {:?}",
report.warnings.len()
);
}
#[tokio::test]
async fn a_function_root_is_not_looked_up_as_a_relation() {
let introspector = MockIntrospector::new(DatabaseType::PostgreSQL);
let mut root = make_query("lineItems", "LineItem", "fn_line_items");
root.sql_source_kind = fraiseql_core::schema::SourceKind::Function;
let schema = make_schema(vec![], vec![root]);
let report = validate_schema_against_database(&schema, &introspector).await.unwrap();
assert!(report.warnings.is_empty(), "{:?}", report.warnings);
}
#[tokio::test]
async fn test_missing_relation() {
let introspector = MockIntrospector::new(DatabaseType::PostgreSQL);
let schema = make_schema(vec![], vec![make_query("users", "User", "v_user")]);
let report = validate_schema_against_database(&schema, &introspector).await.unwrap();
assert_eq!(report.warnings.len(), 1);
assert!(
matches!(&report.warnings[0], DatabaseWarning::MissingRelation { sql_source, .. } if sql_source == "v_user")
);
}
#[tokio::test]
async fn missing_type_source_warns_and_skips_synthetic_types() {
let introspector = MockIntrospector::new(DatabaseType::PostgreSQL).with_relation(
"public",
"v_order",
fraiseql_core::db::RelationKind::View,
);
let order = TypeDefinition {
sql_source: "v_order".into(),
..make_type("Order", vec![("id", FieldType::Id)])
};
let money = TypeDefinition {
sql_source: "v_money".into(),
..make_type("Money", vec![("amount", FieldType::Int)])
};
let synthetic =
make_type("CreateOrderPayload", vec![("entity", FieldType::Object("Order".into()))]);
let schema = make_schema(vec![order, money, synthetic], vec![]);
let report = validate_schema_against_database(&schema, &introspector).await.unwrap();
let type_warnings: Vec<_> = report
.warnings
.iter()
.filter(|w| matches!(w, DatabaseWarning::MissingTypeSource { .. }))
.collect();
assert_eq!(
type_warnings.len(),
1,
"only the phantom type source warns: {:?}",
report.warnings
);
assert!(
matches!(&type_warnings[0], DatabaseWarning::MissingTypeSource { type_name, sql_source }
if type_name == "Money" && sql_source == "v_money")
);
assert!(
type_warnings[0].to_string().contains("embedded value object"),
"message points at the real fix: {}",
type_warnings[0]
);
}
#[tokio::test]
async fn validator_checks_internal_projection_view_existence() {
let introspector = MockIntrospector::new(DatabaseType::PostgreSQL).with_relation(
"public",
"v_user",
fraiseql_core::db::RelationKind::View,
);
let user = TypeDefinition {
sql_source: "v_user".into(),
..make_type("User", vec![("id", FieldType::Id)])
};
let change_log = TypeDefinition {
sql_source: "v_entity_change_log".into(),
internal: true,
..make_type("EntityChangeLog", vec![("id", FieldType::Id)])
};
let schema = make_schema(vec![user, change_log], vec![]);
let report = validate_schema_against_database(&schema, &introspector).await.unwrap();
let flagged = report
.warnings
.iter()
.filter(|w| matches!(w, DatabaseWarning::MissingTypeSource { type_name, .. } if type_name == "EntityChangeLog"))
.count();
assert_eq!(
flagged, 1,
"the internal projection's missing view MUST still be flagged (#569): {:?}",
report.warnings
);
}
#[tokio::test]
async fn test_missing_additional_view() {
let introspector = MockIntrospector::new(DatabaseType::PostgreSQL)
.with_relation("public", "v_user", fraiseql_core::db::RelationKind::View)
.with_columns("v_user", vec![("data", "jsonb", false)]);
let mut query = make_query("users", "User", "v_user");
query.additional_views = vec!["v_missing".to_string()];
let schema = make_schema(vec![], vec![query]);
let report = validate_schema_against_database(&schema, &introspector).await.unwrap();
assert_eq!(report.warnings.len(), 1);
assert!(
matches!(&report.warnings[0], DatabaseWarning::MissingAdditionalView { view_name, .. } if view_name == "v_missing")
);
}
#[tokio::test]
async fn test_missing_jsonb_column() {
let introspector = MockIntrospector::new(DatabaseType::PostgreSQL)
.with_relation("public", "v_user", fraiseql_core::db::RelationKind::View)
.with_columns("v_user", vec![("pk_user", "bigint", false)]);
let schema = make_schema(vec![], vec![make_query("users", "User", "v_user")]);
let report = validate_schema_against_database(&schema, &introspector).await.unwrap();
assert_eq!(report.warnings.len(), 1);
assert!(
matches!(&report.warnings[0], DatabaseWarning::MissingJsonColumn { column_name, .. } if column_name == "data")
);
}
#[tokio::test]
async fn test_wrong_json_column_type() {
let introspector = MockIntrospector::new(DatabaseType::PostgreSQL)
.with_relation("public", "v_user", fraiseql_core::db::RelationKind::View)
.with_columns("v_user", vec![("data", "text", false)]);
let schema = make_schema(vec![], vec![make_query("users", "User", "v_user")]);
let report = validate_schema_against_database(&schema, &introspector).await.unwrap();
assert_eq!(report.warnings.len(), 1);
assert!(
matches!(&report.warnings[0], DatabaseWarning::WrongJsonColumnType { actual_type, .. } if actual_type == "text")
);
}
#[tokio::test]
async fn test_missing_cursor_column() {
let introspector = MockIntrospector::new(DatabaseType::PostgreSQL)
.with_relation("public", "v_user", fraiseql_core::db::RelationKind::View)
.with_columns("v_user", vec![("data", "jsonb", false)]);
let mut query = make_query("users", "User", "v_user");
query.relay = true;
query.relay_cursor_column = Some("pk_user".to_string());
let schema = make_schema(vec![], vec![query]);
let report = validate_schema_against_database(&schema, &introspector).await.unwrap();
assert!(report.warnings.iter().any(|w| matches!(w, DatabaseWarning::MissingCursorColumn { column_name, .. } if column_name == "pk_user")));
}
#[tokio::test]
async fn test_missing_json_key() {
let introspector = MockIntrospector::new(DatabaseType::PostgreSQL)
.with_relation("public", "v_user", fraiseql_core::db::RelationKind::View)
.with_columns("v_user", vec![("data", "jsonb", false)])
.with_json_samples("v_user", "data", vec![serde_json::json!({"name": "Alice"})]);
let schema = make_schema(
vec![make_type(
"User",
vec![("name", FieldType::String), ("email", FieldType::String)],
)],
vec![make_query("users", "User", "v_user")],
);
let report = validate_schema_against_database(&schema, &introspector).await.unwrap();
assert!(report.warnings.iter().any(|w| matches!(
w,
DatabaseWarning::MissingJsonKey { field_name, type_name, .. }
if field_name == "email" && type_name == "User"
)));
}
#[tokio::test]
async fn test_arg_type_convertibility_warning() {
let introspector = MockIntrospector::new(DatabaseType::PostgreSQL)
.with_relation("public", "v_order", fraiseql_core::db::RelationKind::View)
.with_columns("v_order", vec![("data", "jsonb", false), ("id", "uuid", false)])
.with_json_samples("v_order", "data", vec![serde_json::json!({"total": 10})]);
let mut query = make_query("orders", "Order", "v_order");
query.arguments = vec![ArgumentDefinition::new("id", FieldType::Int)];
let schema =
make_schema(vec![make_type("Order", vec![("total", FieldType::Int)])], vec![query]);
let report = validate_schema_against_database(&schema, &introspector).await.unwrap();
assert!(report.warnings.iter().any(|w| matches!(
w,
DatabaseWarning::TypeConvertibility { arg_name, graphql_type, column_type, .. }
if arg_name == "id" && graphql_type == "Int" && column_type == "uuid"
)));
}
#[tokio::test]
async fn test_id_arg_against_uuid_column_no_convertibility_warning() {
let introspector = MockIntrospector::new(DatabaseType::PostgreSQL)
.with_relation("public", "v_order", fraiseql_core::db::RelationKind::View)
.with_columns("v_order", vec![("data", "jsonb", false), ("id", "uuid", false)])
.with_json_samples("v_order", "data", vec![serde_json::json!({"total": 10})]);
let mut query = make_query("orders", "Order", "v_order");
query.arguments = vec![ArgumentDefinition::new("id", FieldType::Id)];
let schema =
make_schema(vec![make_type("Order", vec![("total", FieldType::Int)])], vec![query]);
let report = validate_schema_against_database(&schema, &introspector).await.unwrap();
assert!(
!report
.warnings
.iter()
.any(|w| matches!(w, DatabaseWarning::TypeConvertibility { .. }))
);
}
#[tokio::test]
async fn test_empty_json_sample_no_l3_warnings() {
let introspector = MockIntrospector::new(DatabaseType::PostgreSQL)
.with_relation("public", "v_user", fraiseql_core::db::RelationKind::View)
.with_columns("v_user", vec![("data", "jsonb", false)]);
let schema = make_schema(
vec![make_type("User", vec![("name", FieldType::String)])],
vec![make_query("users", "User", "v_user")],
);
let report = validate_schema_against_database(&schema, &introspector).await.unwrap();
assert!(
!report
.warnings
.iter()
.any(|w| matches!(w, DatabaseWarning::MissingJsonKey { .. }))
);
}
#[tokio::test]
async fn test_schema_qualified_match() {
let introspector = MockIntrospector::new(DatabaseType::PostgreSQL)
.with_relation("etl_log", "v_foo", fraiseql_core::db::RelationKind::View)
.with_columns("v_foo", vec![("data", "jsonb", false)]);
let schema = make_schema(vec![], vec![make_query("foos", "Foo", "etl_log.v_foo")]);
let report = validate_schema_against_database(&schema, &introspector).await.unwrap();
assert!(
!report
.warnings
.iter()
.any(|w| matches!(w, DatabaseWarning::MissingRelation { .. }))
);
}
#[tokio::test]
async fn test_schema_qualified_wrong_schema() {
let introspector = MockIntrospector::new(DatabaseType::PostgreSQL).with_relation(
"public",
"v_foo",
fraiseql_core::db::RelationKind::View,
);
let schema = make_schema(vec![], vec![make_query("foos", "Foo", "etl_log.v_foo")]);
let report = validate_schema_against_database(&schema, &introspector).await.unwrap();
assert_eq!(report.warnings.len(), 1);
assert!(
matches!(&report.warnings[0], DatabaseWarning::MissingRelation { sql_source, .. } if sql_source == "etl_log.v_foo")
);
}
#[tokio::test]
async fn test_mutation_missing_function_reported_as_missing_function() {
let introspector = MockIntrospector::new(DatabaseType::PostgreSQL);
let mut schema = make_schema(vec![], vec![]);
schema.mutations.push(MutationDefinition {
name: "createUser".to_string(),
sql_source: Some("fn_create_user".to_string()),
..MutationDefinition::new("createUser", "User")
});
let report = validate_schema_against_database(&schema, &introspector).await.unwrap();
assert_eq!(report.warnings.len(), 1);
assert!(
matches!(&report.warnings[0], DatabaseWarning::MissingFunction { sql_source, .. } if sql_source == "fn_create_user"),
"got {:?}",
report.warnings
);
}
#[tokio::test]
async fn test_mutation_function_not_probed_as_relation() {
let introspector =
MockIntrospector::new(DatabaseType::PostgreSQL).with_function("fn_create_user");
let mut schema = make_schema(vec![], vec![]);
schema.mutations.push(MutationDefinition {
name: "createUser".to_string(),
sql_source: Some("fn_create_user".to_string()),
..MutationDefinition::new("createUser", "User")
});
let report = validate_schema_against_database(&schema, &introspector).await.unwrap();
assert!(
report.warnings.is_empty(),
"backed function must be clean, got {:?}",
report.warnings
);
}
#[tokio::test]
async fn test_query_no_sql_source_skipped() {
let introspector = MockIntrospector::new(DatabaseType::PostgreSQL);
let mut query = make_query("users", "User", "v_user");
query.sql_source = None;
let schema = make_schema(vec![], vec![query]);
let report = validate_schema_against_database(&schema, &introspector).await.unwrap();
assert!(report.warnings.is_empty());
}
#[tokio::test]
async fn test_multiple_samples_merge_keys() {
let introspector = MockIntrospector::new(DatabaseType::PostgreSQL)
.with_relation("public", "v_user", fraiseql_core::db::RelationKind::View)
.with_columns("v_user", vec![("data", "jsonb", false)])
.with_json_samples(
"v_user",
"data",
vec![
serde_json::json!({"name": "Alice", "email": "alice@example.com"}),
serde_json::json!({"email": "bob@example.com", "age": 30}),
],
);
let schema = make_schema(
vec![make_type(
"User",
vec![
("name", FieldType::String),
("email", FieldType::String),
("age", FieldType::Int),
],
)],
vec![make_query("users", "User", "v_user")],
);
let report = validate_schema_against_database(&schema, &introspector).await.unwrap();
assert!(
!report
.warnings
.iter()
.any(|w| matches!(w, DatabaseWarning::MissingJsonKey { .. }))
);
}
#[tokio::test]
async fn test_acronym_field_no_false_missing_json_key() {
let introspector = MockIntrospector::new(DatabaseType::PostgreSQL)
.with_relation("public", "v_log", fraiseql_core::db::RelationKind::View)
.with_columns("v_log", vec![("data", "jsonb", false)])
.with_json_samples(
"v_log",
"data",
vec![serde_json::json!({"http_response": 200, "dns_1_id": "x"})],
);
let schema = make_schema(
vec![make_type(
"Log",
vec![
("httpResponse", FieldType::Int),
("dns1Id", FieldType::String),
],
)],
vec![make_query("logs", "Log", "v_log")],
);
let report = validate_schema_against_database(&schema, &introspector).await.unwrap();
assert!(
!report
.warnings
.iter()
.any(|w| matches!(w, DatabaseWarning::MissingJsonKey { .. })),
"acronym/digit fields must not false-flag MissingJsonKey, got {:?}",
report.warnings
);
}
#[test]
fn test_is_json_type_postgres() {
assert!(is_json_type("jsonb", DatabaseType::PostgreSQL));
assert!(is_json_type("json", DatabaseType::PostgreSQL));
assert!(!is_json_type("text", DatabaseType::PostgreSQL));
}
#[test]
fn test_display_warnings() {
let warning = DatabaseWarning::MissingRelation {
query_name: "users".to_string(),
sql_source: "v_user".to_string(),
};
assert_eq!(
warning.to_string(),
"query `users`: sql_source `v_user` does not exist in database"
);
}
fn paginating_query(order: Option<PaginationOrder>) -> QueryDefinition {
let mut q = make_query("users", "User", "v_user");
q.pagination_order = order;
q
}
fn paginating_schema(order: Option<PaginationOrder>) -> CompiledSchema {
make_schema(
vec![make_type("User", vec![("name", FieldType::String)])],
vec![paginating_query(order)],
)
}
#[tokio::test]
async fn a_derived_identity_is_sharpened_to_the_pk_column() {
let introspector = MockIntrospector::new(DatabaseType::PostgreSQL)
.with_relation("public", "v_user", fraiseql_core::db::RelationKind::View)
.with_columns(
"v_user",
vec![
("data", "jsonb", false),
("id", "uuid", false),
("pk_user", "bigint", false),
],
)
.with_json_samples("v_user", "data", vec![serde_json::json!({"name": "Alice"})]);
let report = validate_schema_against_database(
&paginating_schema(Some(PaginationOrder::Identity)),
&introspector,
)
.await
.unwrap();
assert_eq!(
report.pagination_orders.get("users"),
Some(&PaginationOrder::Column("pk_user".into()))
);
}
#[tokio::test]
async fn a_relation_with_only_an_id_column_keeps_the_identity() {
let introspector = MockIntrospector::new(DatabaseType::PostgreSQL)
.with_relation("public", "v_user", fraiseql_core::db::RelationKind::View)
.with_columns("v_user", vec![("data", "jsonb", false), ("id", "uuid", false)])
.with_json_samples("v_user", "data", vec![serde_json::json!({"name": "Alice"})]);
let report = validate_schema_against_database(
&paginating_schema(Some(PaginationOrder::Identity)),
&introspector,
)
.await
.unwrap();
assert!(report.pagination_orders.is_empty(), "{:?}", report.pagination_orders);
assert!(
!report
.warnings
.iter()
.any(|w| matches!(w, DatabaseWarning::MissingIdentityColumn { .. })),
"{:?}",
report.warnings
);
}
#[tokio::test]
async fn a_relation_with_neither_identity_column_is_refused() {
let introspector = MockIntrospector::new(DatabaseType::PostgreSQL)
.with_relation("public", "v_user", fraiseql_core::db::RelationKind::View)
.with_columns("v_user", vec![("data", "jsonb", false)])
.with_json_samples("v_user", "data", vec![serde_json::json!({"name": "Alice"})]);
let report = validate_schema_against_database(
&paginating_schema(Some(PaginationOrder::Identity)),
&introspector,
)
.await
.unwrap();
assert!(report.pagination_orders.is_empty(), "{:?}", report.pagination_orders);
let finding = report
.warnings
.iter()
.find(|w| matches!(w, DatabaseWarning::MissingIdentityColumn { .. }))
.unwrap_or_else(|| {
panic!("expected a missing-identity finding, got {:?}", report.warnings)
});
assert_eq!(finding.severity(), Severity::Error, "an ORDER BY that cannot run is drift");
assert!(finding.to_string().contains("pk_user"), "{finding}");
}
#[tokio::test]
async fn an_authored_column_is_checked_but_never_overridden() {
let introspector = MockIntrospector::new(DatabaseType::PostgreSQL)
.with_relation("public", "v_user", fraiseql_core::db::RelationKind::View)
.with_columns(
"v_user",
vec![
("data", "jsonb", false),
("pk_user", "bigint", false),
("email", "text", false),
],
)
.with_json_samples("v_user", "data", vec![serde_json::json!({"name": "Alice"})]);
let report = validate_schema_against_database(
&paginating_schema(Some(PaginationOrder::Column("email".into()))),
&introspector,
)
.await
.unwrap();
assert!(
report.pagination_orders.is_empty(),
"a cheaper column exists on the relation, and the author's choice still stands: {:?}",
report.pagination_orders
);
assert!(report.warnings.is_empty(), "{:?}", report.warnings);
}
#[tokio::test]
async fn a_declared_column_that_does_not_exist_is_a_drift_error() {
let introspector = MockIntrospector::new(DatabaseType::PostgreSQL)
.with_relation("public", "v_user", fraiseql_core::db::RelationKind::View)
.with_columns("v_user", vec![("data", "jsonb", false)])
.with_json_samples("v_user", "data", vec![serde_json::json!({"name": "Alice"})]);
let report = validate_schema_against_database(
&paginating_schema(Some(PaginationOrder::Column("pk_user".into()))),
&introspector,
)
.await
.unwrap();
let finding = report
.warnings
.iter()
.find(|w| matches!(w, DatabaseWarning::MissingPaginationColumn { .. }))
.unwrap_or_else(|| {
panic!("expected a missing-column finding, got {:?}", report.warnings)
});
assert_eq!(finding.severity(), Severity::Error);
assert!(finding.to_string().contains("pk_user"), "{finding}");
}
#[tokio::test]
async fn a_query_that_opted_out_is_left_alone() {
let introspector = MockIntrospector::new(DatabaseType::PostgreSQL)
.with_relation("public", "v_user", fraiseql_core::db::RelationKind::View)
.with_columns("v_user", vec![("data", "jsonb", false), ("pk_user", "bigint", false)])
.with_view_definition("v_user", " SELECT data FROM tb_user ORDER BY data ->> 'name'")
.with_json_samples("v_user", "data", vec![serde_json::json!({"name": "Alice"})]);
let report = validate_schema_against_database(&paginating_schema(None), &introspector)
.await
.unwrap();
assert!(report.pagination_orders.is_empty(), "{:?}", report.pagination_orders);
assert!(
report.warnings.is_empty(),
"the author already answered the question this view raises: {:?}",
report.warnings
);
}
#[tokio::test]
async fn a_self_ordering_view_asks_the_author_to_declare() {
let introspector = MockIntrospector::new(DatabaseType::PostgreSQL)
.with_relation("public", "v_user", fraiseql_core::db::RelationKind::View)
.with_columns("v_user", vec![("data", "jsonb", false), ("pk_user", "bigint", false)])
.with_view_definition(
"v_user",
" SELECT data FROM tb_user ORDER BY (data ->> 'created_at'::text)",
)
.with_json_samples("v_user", "data", vec![serde_json::json!({"name": "Alice"})]);
let report = validate_schema_against_database(
&paginating_schema(Some(PaginationOrder::Identity)),
&introspector,
)
.await
.unwrap();
let finding = report
.warnings
.iter()
.find(|w| matches!(w, DatabaseWarning::SelfOrderingView { .. }))
.unwrap_or_else(|| {
panic!("expected a self-ordering finding, got {:?}", report.warnings)
});
assert_eq!(finding.severity(), Severity::Warn);
assert!(finding.to_string().contains("none"), "the remedy must be named: {finding}");
}
#[test]
fn any_introspector_forwards_every_trait_method() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("crates/")
.join("fraiseql-db/src/introspector.rs");
let trait_src = std::fs::read_to_string(&root)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", root.display()));
let trait_body = trait_src
.split_once("pub trait DatabaseIntrospector")
.expect("the trait moved; this gate reads it by name")
.1;
let methods: Vec<&str> = trait_body
.lines()
.filter_map(|l| {
let l = l.trim_start();
let rest = l.strip_prefix("async fn ").or_else(|| l.strip_prefix("fn "))?;
rest.split(['(', '<']).next()
})
.collect();
assert!(methods.len() >= 8, "only found {methods:?} — the parse broke, not the impl");
let validator = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("src/schema/database_validator.rs");
let impl_src = std::fs::read_to_string(&validator).expect("read database_validator.rs");
let impl_body = impl_src
.split_once("impl DatabaseIntrospector for AnyIntrospector")
.expect("the impl moved; this gate reads it by name")
.1;
let missing: Vec<&&str> =
methods.iter().filter(|m| !impl_body.contains(&format!("fn {m}("))).collect();
assert!(
missing.is_empty(),
"AnyIntrospector does not forward {missing:?}. Each one silently inherits the \
trait default for every `compile --database` run, which reads as a database \
with nothing to report."
);
}
#[tokio::test]
async fn a_plain_view_raises_no_self_ordering_finding() {
let introspector = MockIntrospector::new(DatabaseType::PostgreSQL)
.with_relation("public", "v_user", fraiseql_core::db::RelationKind::View)
.with_columns("v_user", vec![("data", "jsonb", false), ("pk_user", "bigint", false)])
.with_view_definition("v_user", " SELECT data FROM tb_user")
.with_json_samples("v_user", "data", vec![serde_json::json!({"name": "Alice"})]);
let report = validate_schema_against_database(
&paginating_schema(Some(PaginationOrder::Identity)),
&introspector,
)
.await
.unwrap();
assert!(
!report
.warnings
.iter()
.any(|w| matches!(w, DatabaseWarning::SelfOrderingView { .. })),
"{:?}",
report.warnings
);
}
#[derive(Clone, Copy)]
enum Backing {
Tview,
Table,
View,
}
async fn temporal_report_for(
source: &str,
backing: Backing,
field: &str,
ty: FieldType,
auto_params: AutoParams,
extra_columns: Vec<(&str, &str, bool)>,
mapped_by_argument: bool,
) -> Vec<DatabaseWarning> {
let mut columns = vec![("data", "jsonb", false), ("id", "uuid", false)];
columns.extend(extra_columns);
let kind = match backing {
Backing::View => fraiseql_core::db::RelationKind::View,
Backing::Tview | Backing::Table => fraiseql_core::db::RelationKind::Table,
};
let mut introspector = MockIntrospector::new(DatabaseType::PostgreSQL)
.with_relation("public", source, kind)
.with_columns(source, columns)
.with_json_samples(source, "data", vec![serde_json::json!({ field: "x" })]);
if matches!(backing, Backing::Tview) {
introspector = introspector.with_tview(source);
}
let mut query = make_query("events", "Event", source);
query.auto_params = auto_params;
if mapped_by_argument {
query.arguments.push(ArgumentDefinition::new(field, FieldType::DateTime));
}
let schema = make_schema(vec![make_type("Event", vec![(field, ty)])], vec![query]);
validate_schema_against_database(&schema, &introspector).await.unwrap().warnings
}
async fn temporal_report(
source: &str,
ty: FieldType,
auto_params: AutoParams,
extra_columns: Vec<(&str, &str, bool)>,
mapped_by_argument: bool,
) -> Vec<DatabaseWarning> {
temporal_report_for(
source,
Backing::Tview,
"occurredAt",
ty,
auto_params,
extra_columns,
mapped_by_argument,
)
.await
}
fn sorting() -> AutoParams {
AutoParams {
has_order_by: true,
..AutoParams::default()
}
}
fn temporal_advice(warnings: &[DatabaseWarning]) -> Vec<(&str, &str, &str, TemporalKeyRemedy)> {
warnings
.iter()
.filter_map(|w| match w {
DatabaseWarning::TemporalKeyInDocument {
query_name,
field,
column,
remedy,
..
} => Some((query_name.as_str(), field.as_str(), column.as_str(), *remedy)),
_ => None,
})
.collect()
}
fn temporal_message(warnings: &[DatabaseWarning]) -> String {
warnings
.iter()
.find(|w| matches!(w, DatabaseWarning::TemporalKeyInDocument { .. }))
.unwrap_or_else(|| panic!("no temporal advice: {warnings:?}"))
.to_string()
}
const TIMESTAMPTZ: &str = "timestamp with time zone";
#[tokio::test]
async fn a_sorted_datetime_read_from_a_tview_document_is_advised() {
let warnings =
temporal_report("tv_event", FieldType::DateTime, sorting(), vec![], false).await;
assert_eq!(
temporal_advice(&warnings),
vec![("events", "occurredAt", "occurred_at", TemporalKeyRemedy::AddToTview)]
);
let advice = warnings
.iter()
.find(|w| matches!(w, DatabaseWarning::TemporalKeyInDocument { .. }))
.unwrap();
assert_eq!(advice.severity(), Severity::Warn, "advice, never a failed compile");
}
#[tokio::test]
async fn a_filtered_date_is_advised_too() {
let filtering = AutoParams {
has_where: true,
..AutoParams::default()
};
let warnings = temporal_report("tv_event", FieldType::Date, filtering, vec![], false).await;
assert_eq!(
temporal_advice(&warnings),
vec![("events", "occurredAt", "occurred_at", TemporalKeyRemedy::AddToTview)]
);
}
#[tokio::test]
async fn a_column_the_query_does_not_map_yet_is_named_as_existing() {
let column = vec![("occurred_at", TIMESTAMPTZ, false)];
let warnings =
temporal_report("tv_event", FieldType::DateTime, sorting(), column, false).await;
assert_eq!(
temporal_advice(&warnings),
vec![("events", "occurredAt", "occurred_at", TemporalKeyRemedy::MapColumn)]
);
}
#[tokio::test]
async fn a_temporal_key_mapped_to_its_native_column_is_not_advised() {
let column = vec![("occurred_at", TIMESTAMPTZ, false)];
let warnings =
temporal_report("tv_event", FieldType::DateTime, sorting(), column, true).await;
assert!(temporal_advice(&warnings).is_empty(), "{warnings:?}");
}
#[tokio::test]
async fn a_query_that_neither_sorts_nor_filters_is_not_advised() {
let warnings =
temporal_report("tv_event", FieldType::DateTime, AutoParams::default(), vec![], false)
.await;
assert!(temporal_advice(&warnings).is_empty(), "{warnings:?}");
}
#[tokio::test]
async fn a_non_temporal_key_is_not_advised() {
let warnings =
temporal_report("tv_event", FieldType::String, sorting(), vec![], false).await;
assert!(temporal_advice(&warnings).is_empty(), "{warnings:?}");
}
#[tokio::test]
async fn the_advice_follows_what_the_relation_is() {
use TemporalKeyRemedy::*;
let dated = |c: &'static str| vec![(c, TIMESTAMPTZ, false)];
let cases = [
("v_event", Backing::View, "occurredAt", vec![], ExposeFromView),
("v_event", Backing::View, "occurredAt", dated("occurred_at"), MapColumn),
("tv_event", Backing::Table, "occurredAt", vec![], AddToTable),
("event_doc", Backing::Tview, "occurredAt", vec![], AddToTview),
("tv_event", Backing::Tview, "createdAt", dated("created_at"), MapColumn),
("tb_event", Backing::Table, "createdAt", dated("created_at"), MapColumn),
];
for (source, backing, field, columns, remedy) in cases {
let warnings = temporal_report_for(
source,
backing,
field,
FieldType::DateTime,
sorting(),
columns,
false,
)
.await;
let column = fraiseql_core::utils::to_snake_case(field);
assert_eq!(
temporal_advice(&warnings),
vec![("events", field, column.as_str(), remedy)],
"{source}"
);
let message = temporal_message(&warnings);
assert_eq!(message.contains("TVIEW"), remedy == AddToTview, "{message}");
}
}
}
mod merger_tests {
use std::fs;
use tempfile::TempDir;
use super::super::merger::*;
#[test]
fn test_merge_toml_only() {
let toml_content = r#"
[schema]
name = "test"
version = "1.0.0"
database_target = "postgresql"
[database]
url = "postgresql://localhost/test"
[types.User]
sql_source = "v_user"
[types.User.fields.id]
type = "ID"
[types.User.fields.name]
type = "String"
[queries.users]
return_type = "User"
return_array = true
sql_source = "v_user"
"#;
let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
std::fs::write(tmp.path(), toml_content).unwrap();
let result = SchemaMerger::merge_toml_only(tmp.path().to_str().unwrap());
result.unwrap_or_else(|e| panic!("expected Ok from merge_toml_only: {e}"));
}
#[test]
fn test_merge_with_includes() -> anyhow::Result<()> {
let temp_dir = TempDir::new()?;
let user_types = serde_json::json!({
"types": [{"name": "User", "fields": []}],
"queries": [],
"mutations": []
});
fs::write(temp_dir.path().join("user.json"), user_types.to_string())?;
let post_types = serde_json::json!({
"types": [{"name": "Post", "fields": []}],
"queries": [],
"mutations": []
});
fs::write(temp_dir.path().join("post.json"), post_types.to_string())?;
let toml_content = format!(
r#"
[schema]
name = "test"
version = "1.0.0"
database_target = "postgresql"
[database]
url = "postgresql://localhost/test"
[includes]
types = ["{}/*.json"]
queries = []
mutations = []
"#,
temp_dir.path().to_string_lossy()
);
let toml_path = temp_dir.path().join("fraiseql.toml");
fs::write(&toml_path, toml_content)?;
let result = SchemaMerger::merge_with_includes(toml_path.to_str().unwrap());
let schema = result.unwrap_or_else(|e| panic!("expected Ok from merge_with_includes: {e}"));
assert_eq!(schema.types.len(), 2);
Ok(())
}
#[test]
fn test_merge_with_includes_missing_files() -> anyhow::Result<()> {
let temp_dir = TempDir::new()?;
let toml_content = r#"
[schema]
name = "test"
version = "1.0.0"
database_target = "postgresql"
[database]
url = "postgresql://localhost/test"
[includes]
types = ["/nonexistent/path/*.json"]
queries = []
mutations = []
"#;
let toml_path = temp_dir.path().join("fraiseql.toml");
fs::write(&toml_path, toml_content)?;
let err = SchemaMerger::merge_with_includes(toml_path.to_str().unwrap())
.expect_err("a configured include pattern matching no files must fail the compile");
let msg = format!("{err:#}");
assert!(
msg.contains("/nonexistent/path/*.json"),
"the error must name the pattern that matched nothing; got: {msg}"
);
Ok(())
}
#[test]
fn test_merge_from_domains() -> anyhow::Result<()> {
let temp_dir = TempDir::new()?;
let schema_dir = temp_dir.path().join("schema");
fs::create_dir(&schema_dir)?;
fs::create_dir(schema_dir.join("auth"))?;
fs::create_dir(schema_dir.join("products"))?;
let auth_types = serde_json::json!({
"types": [{"name": "User", "fields": []}],
"queries": [{"name": "getUser", "return_type": "User"}],
"mutations": []
});
fs::write(schema_dir.join("auth/types.json"), auth_types.to_string())?;
let product_types = serde_json::json!({
"types": [{"name": "Product", "fields": []}],
"queries": [{"name": "getProduct", "return_type": "Product"}],
"mutations": []
});
fs::write(schema_dir.join("products/types.json"), product_types.to_string())?;
let schema_dir_str = schema_dir.to_string_lossy().to_string();
let toml_content = format!(
r#"
[schema]
name = "test"
version = "1.0.0"
database_target = "postgresql"
[database]
url = "postgresql://localhost/test"
[domain_discovery]
enabled = true
root_dir = "{schema_dir_str}"
"#
);
let toml_path = temp_dir.path().join("fraiseql.toml");
fs::write(&toml_path, toml_content)?;
let schema = SchemaMerger::merge_from_domains(toml_path.to_str().unwrap())
.unwrap_or_else(|e| panic!("expected Ok from merge_from_domains: {e}"));
assert_eq!(schema.types.len(), 2);
assert_eq!(schema.queries.len(), 2);
Ok(())
}
#[test]
fn test_merge_from_domains_alphabetical_order() -> anyhow::Result<()> {
let temp_dir = TempDir::new()?;
let schema_dir = temp_dir.path().join("schema");
fs::create_dir(&schema_dir)?;
fs::create_dir(schema_dir.join("zebra"))?;
fs::create_dir(schema_dir.join("alpha"))?;
fs::create_dir(schema_dir.join("middle"))?;
for domain in &["zebra", "alpha", "middle"] {
let types = serde_json::json!({
"types": [{"name": domain.to_uppercase(), "fields": []}],
"queries": [],
"mutations": []
});
fs::write(schema_dir.join(format!("{domain}/types.json")), types.to_string())?;
}
let schema_dir_str = schema_dir.to_string_lossy().to_string();
let toml_content = format!(
r#"
[schema]
name = "test"
version = "1.0.0"
database_target = "postgresql"
[database]
url = "postgresql://localhost/test"
[domain_discovery]
enabled = true
root_dir = "{schema_dir_str}"
"#
);
let toml_path = temp_dir.path().join("fraiseql.toml");
fs::write(&toml_path, toml_content)?;
let schema = SchemaMerger::merge_from_domains(toml_path.to_str().unwrap())
.unwrap_or_else(|e| panic!("expected Ok from merge_from_domains (alphabetical): {e}"));
let type_names: Vec<String> = schema.types.iter().map(|t| t.name.clone()).collect();
assert_eq!(type_names[0], "ALPHA");
assert_eq!(type_names[1], "MIDDLE");
assert_eq!(type_names[2], "ZEBRA");
Ok(())
}
#[test]
fn test_merge_toml_only_with_validation_config() {
let toml_content = r#"
[schema]
name = "test"
version = "1.0.0"
database_target = "postgresql"
[database]
url = "postgresql://localhost/test"
[types.User]
sql_source = "v_user"
[types.User.fields.id]
type = "ID"
[validation]
max_query_depth = 3
max_query_complexity = 25
max_page_size = 750
"#;
let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
std::fs::write(tmp.path(), toml_content).unwrap();
let schema = SchemaMerger::merge_toml_only(tmp.path().to_str().unwrap())
.unwrap_or_else(|e| panic!("expected Ok from merge_toml_only (with validation): {e}"));
let vc = schema.validation_config.as_ref().expect("validation_config should be set");
assert_eq!(vc.max_query_depth, Some(3));
assert_eq!(vc.max_query_complexity, Some(25));
assert_eq!(vc.max_page_size, Some(750));
}
#[test]
fn test_merge_toml_only_without_validation_config() {
let toml_content = r#"
[schema]
name = "test"
version = "1.0.0"
database_target = "postgresql"
[database]
url = "postgresql://localhost/test"
[types.User]
sql_source = "v_user"
[types.User.fields.id]
type = "ID"
"#;
let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
std::fs::write(tmp.path(), toml_content).unwrap();
let schema = SchemaMerger::merge_toml_only(tmp.path().to_str().unwrap())
.unwrap_or_else(|e| panic!("expected Ok from merge_toml_only (no validation): {e}"));
assert!(schema.validation_config.is_none());
}
#[test]
fn pascal_to_snake_single_word() {
assert_eq!(pascal_to_snake("User"), "user");
}
#[test]
fn pascal_to_snake_compound_type() {
assert_eq!(pascal_to_snake("UserProfile"), "user_profile");
}
#[test]
fn pascal_to_snake_already_lower() {
assert_eq!(pascal_to_snake("user"), "user");
}
#[test]
fn pascal_to_snake_three_words() {
assert_eq!(pascal_to_snake("DnsServerConfig"), "dns_server_config");
}
fn write_temp_toml(content: &str) -> tempfile::NamedTempFile {
let tmp = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
std::fs::write(tmp.path(), content).unwrap();
tmp
}
#[test]
fn crud_trinity_resolves_create_mutation() {
let toml = r#"
[schema]
name = "test"
version = "1.0.0"
[crud]
function_schema = "app"
function_naming = "trinity"
[types.User]
sql_source = "v_user"
[types.User.fields.id]
type = "ID"
[mutations.create_user]
return_type = "User"
operation = "CREATE"
"#;
let tmp = write_temp_toml(toml);
let schema = SchemaMerger::merge_toml_only(tmp.path().to_str().unwrap())
.expect("should merge with crud naming");
let mutation = schema.mutations.iter().find(|m| m.name == "create_user").unwrap();
assert_eq!(mutation.sql_source.as_deref(), Some("app.create_user"));
}
#[test]
fn crud_trinity_resolves_pascal_return_type() {
let toml = r#"
[schema]
name = "test"
version = "1.0.0"
[crud]
function_naming = "trinity"
[types.UserProfile]
sql_source = "v_user_profile"
[types.UserProfile.fields.id]
type = "ID"
[mutations.create_user_profile]
return_type = "UserProfile"
operation = "CREATE"
"#;
let tmp = write_temp_toml(toml);
let schema =
SchemaMerger::merge_toml_only(tmp.path().to_str().unwrap()).expect("should merge");
let mutation = schema.mutations.iter().find(|m| m.name == "create_user_profile").unwrap();
assert_eq!(mutation.sql_source.as_deref(), Some("create_user_profile"));
}
#[test]
fn explicit_sql_source_wins_over_crud() {
let toml = r#"
[schema]
name = "test"
version = "1.0.0"
[crud]
function_schema = "app"
function_naming = "trinity"
[types.User]
sql_source = "v_user"
[types.User.fields.id]
type = "ID"
[mutations.create_user]
return_type = "User"
operation = "CREATE"
sql_source = "custom_create_user_fn"
"#;
let tmp = write_temp_toml(toml);
let schema =
SchemaMerger::merge_toml_only(tmp.path().to_str().unwrap()).expect("should merge");
let mutation = schema.mutations.iter().find(|m| m.name == "create_user").unwrap();
assert_eq!(mutation.sql_source.as_deref(), Some("custom_create_user_fn"));
}
#[test]
fn no_sql_source_no_crud_errors_with_mutation_name() {
let toml = r#"
[schema]
name = "test"
version = "1.0.0"
[types.User]
sql_source = "v_user"
[types.User.fields.id]
type = "ID"
[mutations.create_user]
return_type = "User"
operation = "CREATE"
"#;
let tmp = write_temp_toml(toml);
let err = SchemaMerger::merge_toml_only(tmp.path().to_str().unwrap())
.expect_err("should fail without sql_source and no crud config");
let msg = format!("{err}");
assert!(msg.contains("create_user"), "error should name the mutation, got: {msg}");
assert!(msg.contains("sql_source") || msg.contains("crud"), "got: {msg}");
}
#[test]
fn crud_custom_template_resolved_in_merger() {
let toml = r#"
[schema]
name = "test"
version = "1.0.0"
[crud]
function_schema = "app"
create_template = "insert_{entity}"
[types.Order]
sql_source = "v_order"
[types.Order.fields.id]
type = "ID"
[mutations.create_order]
return_type = "Order"
operation = "CREATE"
"#;
let tmp = write_temp_toml(toml);
let schema =
SchemaMerger::merge_toml_only(tmp.path().to_str().unwrap()).expect("should merge");
let mutation = schema.mutations.iter().find(|m| m.name == "create_order").unwrap();
assert_eq!(mutation.sql_source.as_deref(), Some("app.insert_order"));
}
#[test]
fn crud_update_and_delete_resolved() {
let toml = r#"
[schema]
name = "test"
version = "1.0.0"
[crud]
function_schema = "app"
function_naming = "trinity"
[types.User]
sql_source = "v_user"
[types.User.fields.id]
type = "ID"
[mutations.update_user]
return_type = "User"
operation = "UPDATE"
[mutations.delete_user]
return_type = "User"
operation = "DELETE"
"#;
let tmp = write_temp_toml(toml);
let schema =
SchemaMerger::merge_toml_only(tmp.path().to_str().unwrap()).expect("should merge");
let update = schema.mutations.iter().find(|m| m.name == "update_user").unwrap();
let delete = schema.mutations.iter().find(|m| m.name == "delete_user").unwrap();
assert_eq!(update.sql_source.as_deref(), Some("app.update_user"));
assert_eq!(delete.sql_source.as_deref(), Some("app.delete_user"));
}
#[test]
fn toml_mutation_input_style_threads_to_intermediate() {
use fraiseql_core::schema::InputStyle;
let toml = r#"
[schema]
name = "test"
version = "1.0.0"
[types.Order]
sql_source = "v_order"
[types.Order.fields.id]
type = "ID"
[mutations.create_order]
return_type = "Order"
operation = "CREATE"
sql_source = "create_order"
input_style = "jsonb"
[mutations.touch_order]
return_type = "Order"
operation = "CREATE"
sql_source = "touch_order"
"#;
let tmp = write_temp_toml(toml);
let schema =
SchemaMerger::merge_toml_only(tmp.path().to_str().unwrap()).expect("should merge");
let create = schema.mutations.iter().find(|m| m.name == "create_order").unwrap();
assert_eq!(create.input_style, InputStyle::Jsonb);
let touch = schema.mutations.iter().find(|m| m.name == "touch_order").unwrap();
assert_eq!(touch.input_style, InputStyle::Flatten);
}
#[test]
fn toml_mutation_changelog_pre_image_threads_to_intermediate() {
let toml = r#"
[schema]
name = "test"
version = "1.0.0"
[types.Price]
sql_source = "v_price"
[types.Price.fields.id]
type = "ID"
[mutations.update_price]
return_type = "Price"
operation = "UPDATE"
sql_source = "update_price"
changelog_pre_image = true
[mutations.touch_price]
return_type = "Price"
operation = "UPDATE"
sql_source = "touch_price"
"#;
let tmp = write_temp_toml(toml);
let schema =
SchemaMerger::merge_toml_only(tmp.path().to_str().unwrap()).expect("should merge");
let update = schema.mutations.iter().find(|m| m.name == "update_price").unwrap();
assert!(update.changelog_pre_image);
let touch = schema.mutations.iter().find(|m| m.name == "touch_price").unwrap();
assert!(!touch.changelog_pre_image);
}
#[test]
fn toml_mutation_cascade_threads_to_intermediate() {
let toml = r#"
[schema]
name = "test"
version = "1.0.0"
[types.Post]
sql_source = "v_post"
[types.Post.fields.id]
type = "ID"
[mutations.create_post]
return_type = "Post"
operation = "CREATE"
sql_source = "create_post"
cascade = true
[mutations.touch_post]
return_type = "Post"
operation = "UPDATE"
sql_source = "touch_post"
"#;
let tmp = write_temp_toml(toml);
let schema =
SchemaMerger::merge_toml_only(tmp.path().to_str().unwrap()).expect("should merge");
let create = schema.mutations.iter().find(|m| m.name == "create_post").unwrap();
assert!(create.cascade);
let touch = schema.mutations.iter().find(|m| m.name == "touch_post").unwrap();
assert!(!touch.cascade);
}
}
mod multi_file_loader_tests {
use std::fs;
use serde_json::json;
use tempfile::TempDir;
use super::super::multi_file_loader::*;
fn create_test_file(dir: &std::path::Path, name: &str, content: &str) -> anyhow::Result<()> {
let path = dir.join(name);
fs::write(path, content)?;
Ok(())
}
#[test]
fn test_load_single_type_file() -> anyhow::Result<()> {
let temp_dir = TempDir::new()?;
let schema = json!({
"types": [
{"name": "User", "fields": []}
],
"queries": [],
"mutations": []
});
create_test_file(temp_dir.path(), "types.json", &schema.to_string())?;
let result = MultiFileLoader::load_from_directory(temp_dir.path().to_str().unwrap())?;
assert_eq!(result["types"].as_array().unwrap().len(), 1);
assert_eq!(result["types"][0]["name"], "User");
assert_eq!(result["queries"].as_array().unwrap().len(), 0);
assert_eq!(result["mutations"].as_array().unwrap().len(), 0);
Ok(())
}
#[test]
fn test_merge_multiple_type_files() -> anyhow::Result<()> {
let temp_dir = TempDir::new()?;
let user_schema = json!({
"types": [
{"name": "User", "fields": []}
],
"queries": [],
"mutations": []
});
create_test_file(temp_dir.path(), "user.json", &user_schema.to_string())?;
let post_schema = json!({
"types": [
{"name": "Post", "fields": []}
],
"queries": [],
"mutations": []
});
create_test_file(temp_dir.path(), "post.json", &post_schema.to_string())?;
let result = MultiFileLoader::load_from_directory(temp_dir.path().to_str().unwrap())?;
assert_eq!(result["types"].as_array().unwrap().len(), 2);
let type_names: Vec<&str> = result["types"]
.as_array()
.unwrap()
.iter()
.filter_map(|t| t["name"].as_str())
.collect();
assert!(type_names.contains(&"User"));
assert!(type_names.contains(&"Post"));
Ok(())
}
#[test]
fn test_merge_respects_alphabetical_order() -> anyhow::Result<()> {
let temp_dir = TempDir::new()?;
let c_schema = json!({
"types": [{"name": "C", "fields": []}],
"queries": [],
"mutations": []
});
create_test_file(temp_dir.path(), "c.json", &c_schema.to_string())?;
let a_schema = json!({
"types": [{"name": "A", "fields": []}],
"queries": [],
"mutations": []
});
create_test_file(temp_dir.path(), "a.json", &a_schema.to_string())?;
let b_schema = json!({
"types": [{"name": "B", "fields": []}],
"queries": [],
"mutations": []
});
create_test_file(temp_dir.path(), "b.json", &b_schema.to_string())?;
let result = MultiFileLoader::load_from_directory(temp_dir.path().to_str().unwrap())?;
let type_names: Vec<&str> = result["types"]
.as_array()
.unwrap()
.iter()
.filter_map(|t| t["name"].as_str())
.collect();
assert_eq!(type_names[0], "A");
assert_eq!(type_names[1], "B");
assert_eq!(type_names[2], "C");
Ok(())
}
#[test]
fn test_merge_queries_and_mutations() -> anyhow::Result<()> {
let temp_dir = TempDir::new()?;
let schema = json!({
"types": [
{"name": "User", "fields": []}
],
"queries": [
{"name": "getUser", "return_type": "User"}
],
"mutations": [
{"name": "createUser", "return_type": "User"}
]
});
create_test_file(temp_dir.path(), "schema.json", &schema.to_string())?;
let result = MultiFileLoader::load_from_directory(temp_dir.path().to_str().unwrap())?;
assert_eq!(result["types"].as_array().unwrap().len(), 1);
assert_eq!(result["queries"].as_array().unwrap().len(), 1);
assert_eq!(result["queries"][0]["name"], "getUser");
assert_eq!(result["mutations"].as_array().unwrap().len(), 1);
assert_eq!(result["mutations"][0]["name"], "createUser");
Ok(())
}
#[test]
fn test_nested_directory_structure() -> anyhow::Result<()> {
let temp_dir = TempDir::new()?;
fs::create_dir_all(temp_dir.path().join("types"))?;
fs::create_dir_all(temp_dir.path().join("queries"))?;
let user_type = json!({
"types": [{"name": "User", "fields": []}],
"queries": [],
"mutations": []
});
create_test_file(
temp_dir.path().join("types").as_path(),
"user.json",
&user_type.to_string(),
)?;
let post_type = json!({
"types": [{"name": "Post", "fields": []}],
"queries": [],
"mutations": []
});
create_test_file(
temp_dir.path().join("types").as_path(),
"post.json",
&post_type.to_string(),
)?;
let user_queries = json!({
"types": [],
"queries": [{"name": "getUser", "return_type": "User"}],
"mutations": []
});
create_test_file(
temp_dir.path().join("queries").as_path(),
"user_queries.json",
&user_queries.to_string(),
)?;
let result = MultiFileLoader::load_from_directory(temp_dir.path().to_str().unwrap())?;
assert_eq!(result["types"].as_array().unwrap().len(), 2);
assert_eq!(result["queries"].as_array().unwrap().len(), 1);
Ok(())
}
#[test]
fn test_duplicate_type_names_error() -> anyhow::Result<()> {
let temp_dir = TempDir::new()?;
let file1 = json!({
"types": [{"name": "User", "fields": []}],
"queries": [],
"mutations": []
});
create_test_file(temp_dir.path(), "file1.json", &file1.to_string())?;
let file2 = json!({
"types": [{"name": "User", "fields": []}],
"queries": [],
"mutations": []
});
create_test_file(temp_dir.path(), "file2.json", &file2.to_string())?;
let result = MultiFileLoader::load_from_directory(temp_dir.path().to_str().unwrap());
assert!(result.is_err(), "expected Err, got: {result:?}");
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("Duplicate type 'User'"));
assert!(err_msg.contains("file1.json"));
assert!(err_msg.contains("file2.json"));
Ok(())
}
#[test]
fn test_duplicate_query_names_error() -> anyhow::Result<()> {
let temp_dir = TempDir::new()?;
let file1 = json!({
"types": [],
"queries": [{"name": "getUser", "return_type": "User"}],
"mutations": []
});
create_test_file(temp_dir.path(), "file1.json", &file1.to_string())?;
let file2 = json!({
"types": [],
"queries": [{"name": "getUser", "return_type": "User"}],
"mutations": []
});
create_test_file(temp_dir.path(), "file2.json", &file2.to_string())?;
let result = MultiFileLoader::load_from_directory(temp_dir.path().to_str().unwrap());
assert!(result.is_err(), "expected Err, got: {result:?}");
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("Duplicate query 'getUser'"));
Ok(())
}
#[test]
fn test_empty_directory() -> anyhow::Result<()> {
let temp_dir = TempDir::new()?;
let result = MultiFileLoader::load_from_directory(temp_dir.path().to_str().unwrap())?;
assert_eq!(result["types"].as_array().unwrap().len(), 0);
assert_eq!(result["queries"].as_array().unwrap().len(), 0);
assert_eq!(result["mutations"].as_array().unwrap().len(), 0);
Ok(())
}
#[test]
fn test_nonexistent_directory() {
let result = MultiFileLoader::load_from_directory("/nonexistent/path/to/schema");
assert!(result.is_err(), "expected Err for nonexistent directory, got: {result:?}");
}
#[test]
fn test_load_from_paths() -> anyhow::Result<()> {
let temp_dir = TempDir::new()?;
let schema1 = json!({
"types": [{"name": "User", "fields": []}],
"queries": [],
"mutations": []
});
create_test_file(temp_dir.path(), "schema1.json", &schema1.to_string())?;
let schema2 = json!({
"types": [{"name": "Post", "fields": []}],
"queries": [],
"mutations": []
});
create_test_file(temp_dir.path(), "schema2.json", &schema2.to_string())?;
let paths = vec![
temp_dir.path().join("schema1.json"),
temp_dir.path().join("schema2.json"),
];
let result = MultiFileLoader::load_from_paths(&paths)?;
assert_eq!(result["types"].as_array().unwrap().len(), 2);
Ok(())
}
#[test]
fn load_from_paths_rejects_a_duplicate_name_across_files() -> anyhow::Result<()> {
let temp_dir = TempDir::new()?;
let schema1 = json!({"types": [{"name": "User", "fields": []}]});
create_test_file(temp_dir.path(), "a.json", &schema1.to_string())?;
let schema2 = json!({"types": [{"name": "User", "fields": []}]});
create_test_file(temp_dir.path(), "b.json", &schema2.to_string())?;
let paths = vec![
temp_dir.path().join("a.json"),
temp_dir.path().join("b.json"),
];
let error = MultiFileLoader::load_from_paths(&paths)
.expect_err("a duplicate type across two listed files must be refused");
let message = error.to_string();
assert!(message.contains("User"), "the diagnostic must name the duplicate: {message}");
assert!(message.contains("a.json"), "the diagnostic must name both files: {message}");
assert!(message.contains("b.json"), "the diagnostic must name both files: {message}");
Ok(())
}
#[test]
fn load_from_paths_rejects_a_duplicate_in_any_section() -> anyhow::Result<()> {
let temp_dir = TempDir::new()?;
let schema1 = json!({"enums": [{"name": "Status", "values": [{"name": "A"}]}]});
create_test_file(temp_dir.path(), "a.json", &schema1.to_string())?;
let schema2 = json!({"enums": [{"name": "Status", "values": [{"name": "B"}]}]});
create_test_file(temp_dir.path(), "b.json", &schema2.to_string())?;
let paths = vec![
temp_dir.path().join("a.json"),
temp_dir.path().join("b.json"),
];
let error = MultiFileLoader::load_from_paths(&paths)
.expect_err("a duplicate enum across two listed files must be refused");
assert!(
error.to_string().contains("enum 'Status'"),
"the diagnostic must name the section and the duplicate: {error}"
);
Ok(())
}
#[test]
fn load_from_paths_still_merges_distinct_names() -> anyhow::Result<()> {
let temp_dir = TempDir::new()?;
create_test_file(
temp_dir.path(),
"a.json",
&json!({"types": [{"name": "User", "fields": []}]}).to_string(),
)?;
create_test_file(
temp_dir.path(),
"b.json",
&json!({"types": [{"name": "Post", "fields": []}]}).to_string(),
)?;
let paths = vec![
temp_dir.path().join("a.json"),
temp_dir.path().join("b.json"),
];
let merged = MultiFileLoader::load_from_paths(&paths)?;
assert_eq!(merged["types"].as_array().expect("types is an array").len(), 2);
Ok(())
}
#[test]
fn test_directory_file_count_limit_exceeded() -> anyhow::Result<()> {
let temp_dir = tempfile::tempdir()?;
let schema = json!({"types": [], "queries": [], "mutations": []});
let content = schema.to_string();
for i in 0..=MAX_SCHEMA_FILES {
create_test_file(temp_dir.path(), &format!("schema_{i:04}.json"), &content)?;
}
let result =
MultiFileLoader::load_from_directory_with_tracking(temp_dir.path().to_str().unwrap());
assert!(result.is_err(), "expected error when file count exceeds limit");
let msg = result.err().unwrap().to_string();
assert!(msg.contains("more than"), "error should mention the limit: {msg}");
Ok(())
}
}
mod optimizer_tests {
use std::collections::HashMap;
use fraiseql_core::{
db::types::DatabaseType,
schema::{
ArgumentDefinition, AutoParams, CompiledSchema, CursorType, FieldDefinition,
FieldDenyPolicy, FieldType, QueryDefinition, TypeDefinition,
},
};
use indexmap::IndexMap;
use super::super::optimizer::*;
#[test]
fn test_optimize_empty_schema() {
let mut schema = CompiledSchema {
types: vec![],
enums: vec![],
input_types: vec![],
interfaces: vec![],
unions: vec![],
queries: vec![],
mutations: vec![],
subscriptions: vec![],
directives: vec![],
observers: Vec::new(),
fact_tables: HashMap::default(),
federation: None,
security: None,
observers_config: None,
subscriptions_config: None,
validation_config: None,
debug_config: None,
mcp_config: None,
schema_sdl: None,
..Default::default()
};
let report = SchemaOptimizer::optimize(&mut schema).unwrap();
assert_eq!(report.total_hints(), 0);
}
#[test]
fn test_index_hint_for_list_query() {
let mut schema = CompiledSchema {
types: vec![],
enums: vec![],
input_types: vec![],
interfaces: vec![],
unions: vec![],
queries: vec![QueryDefinition {
function: None,
requires_actor: Vec::new(),
returns_count: false,
name: "users".to_string(),
return_type: "User".to_string(),
returns_list: true,
nullable: false,
arguments: vec![ArgumentDefinition {
name: "status".to_string(),
arg_type: FieldType::String,
nullable: false,
default_value: None,
description: None,
deprecation: None,
localized: false,
}],
sql_source: Some("users".to_string()),
sql_source_kind: fraiseql_core::schema::SourceKind::Relation,
description: None,
auto_params: AutoParams::default(),
deprecation: None,
jsonb_column: "data".to_string(),
relay: false,
relay_cursor_column: None,
relay_cursor_type: CursorType::default(),
inject_params: IndexMap::default(),
read_routing: fraiseql_core::db::types::ReadRouting::default(),
cache_ttl_seconds: None,
additional_views: vec![],
requires_role: None,
rest_path: None,
rest_method: None,
rest_stream: false,
native_columns: HashMap::new(),
pagination_order: None,
}],
mutations: vec![],
subscriptions: vec![],
directives: vec![],
observers: Vec::new(),
fact_tables: HashMap::default(),
federation: None,
security: None,
observers_config: None,
subscriptions_config: None,
validation_config: None,
debug_config: None,
mcp_config: None,
schema_sdl: None,
..Default::default()
};
let report = SchemaOptimizer::optimize(&mut schema).unwrap();
assert!(report.total_hints() > 0);
assert!(!report.index_hints.is_empty());
assert_eq!(report.index_hints[0].query_name, "users");
}
#[test]
fn test_pagination_note() {
let mut schema = CompiledSchema {
types: vec![],
enums: vec![],
input_types: vec![],
interfaces: vec![],
unions: vec![],
queries: vec![QueryDefinition {
function: None,
requires_actor: Vec::new(),
returns_count: false,
name: "products".to_string(),
return_type: "Product".to_string(),
returns_list: true,
nullable: false,
arguments: vec![],
sql_source: Some("products".to_string()),
sql_source_kind: fraiseql_core::schema::SourceKind::Relation,
description: None,
auto_params: AutoParams {
has_where: false,
has_order_by: false,
has_limit: true,
has_offset: true,
},
deprecation: None,
jsonb_column: "data".to_string(),
relay: false,
relay_cursor_column: None,
relay_cursor_type: CursorType::default(),
inject_params: IndexMap::default(),
read_routing: fraiseql_core::db::types::ReadRouting::default(),
cache_ttl_seconds: None,
additional_views: vec![],
requires_role: None,
rest_path: None,
rest_method: None,
rest_stream: false,
native_columns: HashMap::new(),
pagination_order: None,
}],
mutations: vec![],
subscriptions: vec![],
directives: vec![],
observers: Vec::new(),
fact_tables: HashMap::default(),
federation: None,
security: None,
observers_config: None,
subscriptions_config: None,
validation_config: None,
debug_config: None,
mcp_config: None,
schema_sdl: None,
..Default::default()
};
let report = SchemaOptimizer::optimize(&mut schema).unwrap();
assert!(report.optimization_notes.iter().any(|note| note.contains("pagination")));
}
#[test]
fn test_large_type_warning() {
let mut schema = CompiledSchema {
types: vec![TypeDefinition {
name: "BigType".into(),
sql_source: String::new().into(),
jsonb_column: String::new(),
fields: (0..25)
.map(|i| FieldDefinition {
name: format!("field{i}").into(),
field_type: FieldType::String,
nullable: false,
default_value: None,
description: None,
vector_config: None,
vector_distance: None,
alias: None,
deprecation: None,
requires_scope: None,
on_deny: FieldDenyPolicy::default(),
authorize: false,
encryption: None,
hierarchy: None,
unique: false,
localized: false,
})
.collect(),
description: None,
sql_projection_hint: None,
implements: vec![],
requires_role: None,
is_error: false,
relay: false,
internal: false,
embedded: false,
relationships: Vec::new(),
inject_params: indexmap::IndexMap::new(),
subscription_policy: None,
}],
enums: vec![],
input_types: vec![],
interfaces: vec![],
unions: vec![],
queries: vec![],
mutations: vec![],
subscriptions: vec![],
directives: vec![],
observers: Vec::new(),
fact_tables: HashMap::default(),
federation: None,
security: None,
observers_config: None,
subscriptions_config: None,
validation_config: None,
debug_config: None,
mcp_config: None,
schema_sdl: None,
..Default::default()
};
let report = SchemaOptimizer::optimize(&mut schema).unwrap();
assert!(report.optimization_notes.iter().any(|note| note.contains("25 fields")));
}
#[test]
fn test_projection_hint_for_large_type() {
let mut schema = CompiledSchema {
types: vec![TypeDefinition {
name: "User".into(),
sql_source: "users".into(),
jsonb_column: "data".to_string(),
fields: (0..15)
.map(|i| FieldDefinition {
name: format!("field{i}").into(),
field_type: FieldType::String,
nullable: false,
default_value: None,
description: None,
vector_config: None,
vector_distance: None,
alias: None,
deprecation: None,
requires_scope: None,
on_deny: FieldDenyPolicy::default(),
authorize: false,
encryption: None,
hierarchy: None,
unique: false,
localized: false,
})
.collect(),
description: None,
sql_projection_hint: None,
implements: vec![],
requires_role: None,
is_error: false,
relay: false,
internal: false,
embedded: false,
relationships: Vec::new(),
inject_params: indexmap::IndexMap::new(),
subscription_policy: None,
}],
enums: vec![],
input_types: vec![],
interfaces: vec![],
unions: vec![],
queries: vec![],
mutations: vec![],
subscriptions: vec![],
directives: vec![],
observers: Vec::new(),
fact_tables: HashMap::default(),
federation: None,
security: None,
observers_config: None,
subscriptions_config: None,
validation_config: None,
debug_config: None,
mcp_config: None,
schema_sdl: None,
..Default::default()
};
let report = SchemaOptimizer::optimize(&mut schema).unwrap();
assert!(!report.projection_hints.is_empty());
assert_eq!(report.projection_hints[0].type_name, "User");
assert_eq!(report.projection_hints[0].field_count, 15);
assert!(schema.types[0].has_sql_projection());
let hint = schema.types[0].sql_projection_hint.as_ref().unwrap();
assert_eq!(hint.database, DatabaseType::PostgreSQL);
assert!(hint.estimated_reduction_percent > 0);
}
#[test]
fn test_projection_not_applied_without_jsonb() {
let mut schema = CompiledSchema {
types: vec![TypeDefinition {
name: "SmallType".into(),
sql_source: "small_table".into(),
jsonb_column: String::new(), fields: (0..15)
.map(|i| FieldDefinition {
name: format!("field{i}").into(),
field_type: FieldType::String,
nullable: false,
default_value: None,
description: None,
vector_config: None,
vector_distance: None,
alias: None,
deprecation: None,
requires_scope: None,
on_deny: FieldDenyPolicy::default(),
authorize: false,
encryption: None,
hierarchy: None,
unique: false,
localized: false,
})
.collect(),
description: None,
sql_projection_hint: None,
implements: vec![],
requires_role: None,
is_error: false,
relay: false,
internal: false,
embedded: false,
relationships: Vec::new(),
inject_params: indexmap::IndexMap::new(),
subscription_policy: None,
}],
enums: vec![],
input_types: vec![],
interfaces: vec![],
unions: vec![],
queries: vec![],
mutations: vec![],
subscriptions: vec![],
directives: vec![],
observers: Vec::new(),
fact_tables: HashMap::default(),
federation: None,
security: None,
observers_config: None,
subscriptions_config: None,
validation_config: None,
debug_config: None,
mcp_config: None,
schema_sdl: None,
..Default::default()
};
let report = SchemaOptimizer::optimize(&mut schema).unwrap();
assert!(report.projection_hints.is_empty());
assert!(!schema.types[0].has_sql_projection());
}
}