#![allow(clippy::unwrap_used)]
use super::{
query::QueryDefinition,
schema::{CompiledSchema, SubscribableEntity},
};
#[cfg(feature = "federation")]
use crate::schema::config_types::FederationEntity;
use crate::schema::{
CURRENT_FRAISEQL_VERSION, MutationDefinition, ProducerVersion,
config_types::{FederationConfig, NamingConvention},
graphql_type_defs::TypeDefinition,
observer_types::{ObserverDefinition, RetryConfig},
security_config::{RoleDefinition, SecurityConfig},
};
#[test]
fn test_compiled_schema_with_observers() {
let json = r#"{
"types": [],
"enums": [],
"input_types": [],
"interfaces": [],
"unions": [],
"queries": [],
"mutations": [],
"subscriptions": [],
"observers": [
{
"name": "onHighValueOrder",
"entity": "Order",
"event": "INSERT",
"condition": "total > 1000",
"actions": [
{
"type": "webhook",
"url": "https://api.example.com/webhook"
}
],
"retry": {
"max_attempts": 3,
"backoff_strategy": "exponential",
"initial_delay_ms": 1000,
"max_delay_ms": 60000
}
}
]
}"#;
let schema = CompiledSchema::from_json(json, false).unwrap();
assert!(schema.has_observers());
assert_eq!(schema.observer_count(), 1);
let observer = schema.find_observer("onHighValueOrder").unwrap();
assert_eq!(observer.entity, "Order");
assert_eq!(observer.event, "INSERT");
assert_eq!(observer.condition, Some("total > 1000".to_string()));
assert_eq!(observer.actions.len(), 1);
assert_eq!(observer.retry.max_attempts, 3);
assert!(observer.retry.is_exponential());
}
#[test]
fn test_compiled_schema_backward_compatible() {
let json = r#"{
"types": [],
"enums": [],
"input_types": [],
"interfaces": [],
"unions": [],
"queries": [],
"mutations": [],
"subscriptions": []
}"#;
let schema = CompiledSchema::from_json(json, false).unwrap();
assert!(!schema.has_observers());
assert_eq!(schema.observer_count(), 0);
}
#[test]
fn test_find_observers_for_entity() {
let schema = CompiledSchema {
observers: vec![
ObserverDefinition::new("onOrderInsert", "Order", "INSERT"),
ObserverDefinition::new("onOrderUpdate", "Order", "UPDATE"),
ObserverDefinition::new("onUserInsert", "User", "INSERT"),
],
..Default::default()
};
let order_observers = schema.find_observers_for_entity("Order");
assert_eq!(order_observers.len(), 2);
let user_observers = schema.find_observers_for_entity("User");
assert_eq!(user_observers.len(), 1);
}
#[test]
fn test_find_observers_for_event() {
let schema = CompiledSchema {
observers: vec![
ObserverDefinition::new("onOrderInsert", "Order", "INSERT"),
ObserverDefinition::new("onOrderUpdate", "Order", "UPDATE"),
ObserverDefinition::new("onUserInsert", "User", "INSERT"),
],
..Default::default()
};
let insert_observers = schema.find_observers_for_event("INSERT");
assert_eq!(insert_observers.len(), 2);
let update_observers = schema.find_observers_for_event("UPDATE");
assert_eq!(update_observers.len(), 1);
}
#[test]
fn test_observer_definition_builder() {
let observer = ObserverDefinition::new("test", "Order", "INSERT")
.with_condition("total > 1000")
.with_action(serde_json::json!({"type": "webhook", "url": "https://example.com"}))
.with_retry(RetryConfig::exponential(5, 1000, 60000));
assert_eq!(observer.name, "test");
assert_eq!(observer.entity, "Order");
assert_eq!(observer.event, "INSERT");
assert!(observer.has_condition());
assert_eq!(observer.action_count(), 1);
assert_eq!(observer.retry.max_attempts, 5);
}
#[test]
fn test_retry_config_types() {
let exponential = RetryConfig::exponential(3, 1000, 60000);
assert!(exponential.is_exponential());
assert!(!exponential.is_linear());
assert!(!exponential.is_fixed());
let linear = RetryConfig::linear(3, 1000, 60000);
assert!(!linear.is_exponential());
assert!(linear.is_linear());
assert!(!linear.is_fixed());
let fixed = RetryConfig::fixed(3, 5000);
assert!(!fixed.is_exponential());
assert!(!fixed.is_linear());
assert!(fixed.is_fixed());
assert_eq!(fixed.initial_delay_ms, 5000);
assert_eq!(fixed.max_delay_ms, 5000);
}
#[test]
fn test_content_hash_stable() {
let schema = CompiledSchema::default();
assert_eq!(
schema.content_hash(),
schema.content_hash(),
"Same schema must produce same hash"
);
}
#[test]
fn test_content_hash_length() {
let hash = CompiledSchema::default().content_hash();
assert_eq!(hash.len(), 32, "Hash must be 32 hex chars (16 bytes)");
assert!(hash.chars().all(|c| c.is_ascii_hexdigit()), "Hash must be valid hex");
}
#[test]
fn test_content_hash_changes_on_field_rename() {
let mut schema_a = CompiledSchema::default();
schema_a
.queries
.push(QueryDefinition::new("users", "User").with_sql_source("v_user"));
let mut schema_b = CompiledSchema::default();
schema_b
.queries
.push(QueryDefinition::new("users", "User").with_sql_source("v_account"));
assert_ne!(
schema_a.content_hash(),
schema_b.content_hash(),
"Schemas with different view names must produce different hashes"
);
}
#[test]
fn subscribable_defaults_empty_when_absent() {
let json = r#"{ "types": [], "queries": [], "mutations": [], "subscriptions": [] }"#;
let schema: CompiledSchema = serde_json::from_str(json).unwrap();
assert!(schema.subscribable.is_empty(), "absent subscribable defaults to empty");
}
#[test]
fn subscribable_empty_is_not_serialized_so_hash_is_unchanged() {
let schema = CompiledSchema::default();
let json = serde_json::to_string(&schema).unwrap();
assert!(!json.contains("subscribable"), "empty subscribable is omitted: {json}");
}
#[test]
fn subscribable_round_trips_when_present() {
let schema = CompiledSchema {
subscribable: vec![SubscribableEntity {
entity_type: "Post".to_string(),
tables: vec!["tb_post".to_string(), "public.tb_post_archive".to_string()],
pre_image: false,
}],
..CompiledSchema::default()
};
let json = serde_json::to_string(&schema).unwrap();
assert!(json.contains("subscribable"), "present subscribable is serialized");
let back: CompiledSchema = serde_json::from_str(&json).unwrap();
assert_eq!(back.subscribable, schema.subscribable, "subscribable round-trips");
}
#[test]
fn mutation_cascade_defaults_off() {
let m = MutationDefinition::new("createPost", "Post");
assert!(!m.cascade, "a new mutation has the cascade surface off by default");
}
#[test]
fn mutation_cascade_absent_is_not_serialized() {
let m = MutationDefinition::new("createPost", "Post");
let json = serde_json::to_string(&m).unwrap();
assert!(!json.contains("cascade"), "off cascade is omitted: {json}");
}
#[test]
fn mutation_cascade_round_trips_when_on() {
let json = r#"{ "name": "createPost", "return_type": "Post", "cascade": true }"#;
let m: MutationDefinition = serde_json::from_str(json).unwrap();
assert!(m.cascade, "cascade=true deserializes");
let back = serde_json::to_string(&m).unwrap();
assert!(back.contains("\"cascade\":true"), "on cascade serializes: {back}");
}
#[test]
fn subscribable_participates_in_content_hash() {
let plain = CompiledSchema::default();
let with_sub = CompiledSchema {
subscribable: vec![SubscribableEntity {
entity_type: "Post".to_string(),
tables: vec!["tb_post".to_string()],
pre_image: false,
}],
..CompiledSchema::default()
};
assert_ne!(
plain.content_hash(),
with_sub.content_hash(),
"adding a subscribable entity changes the content hash"
);
}
#[test]
fn test_has_rls_configured_no_security() {
let schema = CompiledSchema::default();
assert!(
!schema.has_rls_configured(),
"Schema with no security section must return false"
);
}
#[test]
fn test_has_rls_configured_with_empty_policies() {
let sec = SecurityConfig {
policies: vec![],
..SecurityConfig::default()
};
let schema = CompiledSchema {
security: Some(sec),
..CompiledSchema::default()
};
assert!(!schema.has_rls_configured(), "Empty policies array must return false");
}
#[test]
fn test_has_rls_configured_with_rls_declaration() {
let sec = SecurityConfig {
rls: crate::schema::RlsConfig { enabled: true },
..SecurityConfig::default()
};
let schema = CompiledSchema {
security: Some(sec),
..CompiledSchema::default()
};
assert!(schema.has_rls_configured(), "`[security.rls] enabled = true` must return true");
}
#[test]
fn test_has_rls_configured_ignores_authorization_policies() {
let sec = SecurityConfig {
policies: vec![crate::schema::AuthorizationPolicy {
name: "tenant_isolation".to_string(),
policy_type: "RBAC".to_string(),
rule: Some("tenant_id = $1".to_string()),
roles: vec!["tenant".to_string()],
strategy: None,
attributes: vec![],
description: None,
cache_ttl_seconds: None,
}],
..SecurityConfig::default()
};
let schema = CompiledSchema {
security: Some(sec),
..CompiledSchema::default()
};
assert!(!schema.has_rls_configured());
}
#[test]
fn test_has_rls_configured_no_policies_key() {
let sec = SecurityConfig {
rate_limiting: Some(crate::schema::RateLimitingSecurityConfig {
enabled: true,
..crate::schema::RateLimitingSecurityConfig::default()
}),
..SecurityConfig::default()
};
let schema = CompiledSchema {
security: Some(sec),
..CompiledSchema::default()
};
assert!(!schema.has_rls_configured(), "Security without policies key must return false");
}
fn make_type_def(name: &str) -> TypeDefinition {
TypeDefinition {
name: name.into(),
sql_source: format!("v_{}", name.to_lowercase()).as_str().into(),
jsonb_column: "data".to_string(),
fields: vec![],
description: None,
sql_projection_hint: None,
implements: vec![],
requires_role: None,
is_error: false,
relay: false,
internal: false,
embedded: false,
relationships: vec![],
inject_params: indexmap::IndexMap::new(),
subscription_policy: None,
}
}
fn make_query(name: &str, return_type: &str) -> QueryDefinition {
QueryDefinition::new(name, return_type)
}
fn make_mutation(name: &str, return_type: &str) -> MutationDefinition {
MutationDefinition::new(name, return_type)
}
#[test]
fn new_returns_empty_schema() {
let schema = CompiledSchema::new();
assert!(schema.types.is_empty());
assert!(schema.queries.is_empty());
assert!(schema.mutations.is_empty());
assert!(schema.subscriptions.is_empty());
assert!(schema.enums.is_empty());
assert!(schema.interfaces.is_empty());
assert!(schema.unions.is_empty());
}
#[test]
fn from_json_empty_array_fields() {
let json = r#"{"types":[],"queries":[],"mutations":[],"subscriptions":[]}"#;
let schema = CompiledSchema::from_json(json, false).unwrap();
assert_eq!(schema.types.len(), 0);
assert_eq!(schema.queries.len(), 0);
assert_eq!(schema.mutations.len(), 0);
assert_eq!(schema.subscriptions.len(), 0);
}
#[test]
fn from_json_minimal_empty_object() {
let schema = CompiledSchema::from_json("{}", false).unwrap();
assert!(schema.types.is_empty());
assert!(schema.queries.is_empty());
}
#[test]
fn from_json_invalid_returns_error() {
let result = CompiledSchema::from_json("not json at all", false);
assert!(result.is_err());
}
#[test]
fn from_json_builds_query_index() {
let json = r#"{
"types": [{"name":"User","sql_source":"v_user","fields":[]}],
"queries": [{"name":"users","return_type":"User"}],
"mutations": [],
"subscriptions": []
}"#;
let schema = CompiledSchema::from_json(json, false).unwrap();
assert!(schema.query_index.contains_key("users"));
assert_eq!(schema.query_index["users"], 0);
}
#[test]
fn a_query_and_its_type_disagreeing_on_an_inject_column_is_refused_at_load() {
let json = r#"{
"types": [{
"name":"User","sql_source":"v_user","fields":[],
"inject_params": {"tenant_id": {"source":"jwt","claim":"org_id"}}
}],
"queries": [{
"name":"users","return_type":"User","sql_source":"v_user",
"inject_params": {"tenant_id": {"source":"jwt","claim":"tenant_id"}}
}],
"mutations": [],
"subscriptions": []
}"#;
let err = CompiledSchema::from_json(json, false).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("tenant_id"), "the message must name the column, got: {msg}");
assert!(msg.contains("User"), "the message must name the type, got: {msg}");
assert!(msg.contains("users"), "the message must name the query, got: {msg}");
}
#[test]
fn a_scoped_field_without_a_security_section_is_refused_at_load() {
let json = r#"{
"types": [{
"name":"User","sql_source":"v_user",
"fields":[
{"name":"id","field_type":"ID"},
{"name":"salary","field_type":"Int","requires_scope":"read:salary"}
]
}],
"queries": [], "mutations": [], "subscriptions": []
}"#;
let err = CompiledSchema::from_json(json, false).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("User.salary"), "the message must name the field, got: {msg}");
assert!(msg.contains("read:salary"), "the message must name the scope, got: {msg}");
}
#[test]
fn a_scoped_field_with_a_security_section_loads() {
let json = r#"{
"types": [{
"name":"User","sql_source":"v_user",
"fields":[
{"name":"id","field_type":"ID"},
{"name":"salary","field_type":"Int","requires_scope":"read:salary"}
]
}],
"queries": [], "mutations": [], "subscriptions": [],
"security": {}
}"#;
let schema = CompiledSchema::from_json(json, false).unwrap();
assert!(schema.security.is_some());
}
#[test]
fn a_query_and_its_type_agreeing_on_an_inject_column_loads() {
let json = r#"{
"types": [{
"name":"User","sql_source":"v_user","fields":[],
"inject_params": {"tenant_id": {"source":"jwt","claim":"tenant_id"}}
}],
"queries": [{
"name":"users","return_type":"User","sql_source":"v_user",
"inject_params": {"tenant_id": {"source":"jwt","claim":"tenant_id"}}
}],
"mutations": [],
"subscriptions": []
}"#;
let schema = CompiledSchema::from_json(json, false).unwrap();
assert_eq!(schema.types[0].inject_params.len(), 1);
}
#[test]
fn a_type_scoping_a_column_its_query_does_not_loads() {
let json = r#"{
"types": [{
"name":"User","sql_source":"v_user","fields":[],
"inject_params": {"owner_id": {"source":"jwt","claim":"sub"}}
}],
"queries": [{
"name":"users","return_type":"User","sql_source":"v_user",
"inject_params": {"tenant_id": {"source":"jwt","claim":"tenant_id"}}
}],
"mutations": [],
"subscriptions": []
}"#;
let schema = CompiledSchema::from_json(json, false).unwrap();
assert_eq!(schema.types[0].inject_params.len(), 1);
assert_eq!(schema.queries[0].inject_params.len(), 1);
}
#[test]
fn a_queryless_type_carries_its_inject_params_through_a_round_trip() {
let json = r#"{
"types": [{
"name":"User","sql_source":"v_user","fields":[],
"inject_params": {"tenant_id": {"source":"jwt","claim":"tenant_id"}}
}],
"queries": [],
"mutations": [],
"subscriptions": []
}"#;
let schema = CompiledSchema::from_json(json, false).unwrap();
let reloaded = CompiledSchema::from_json(&schema.to_json().unwrap(), false).unwrap();
assert_eq!(
reloaded.types[0].inject_params.get("tenant_id"),
Some(&crate::schema::InjectedParamSource::Jwt("tenant_id".to_string())),
"the type's scoping must survive the compiled artefact it is written to"
);
}
#[test]
fn from_json_builds_mutation_index() {
let json = r#"{
"types": [{"name":"User","sql_source":"v_user","fields":[]}],
"mutations": [{"name":"createUser","return_type":"User"}],
"queries": [],
"subscriptions": []
}"#;
let schema = CompiledSchema::from_json(json, false).unwrap();
assert!(schema.mutation_index.contains_key("createUser"));
}
#[test]
fn to_json_and_back_is_identity() {
let schema = CompiledSchema::new();
let json = schema.to_json().unwrap();
let schema2 = CompiledSchema::from_json(&json, false).unwrap();
assert_eq!(schema, schema2);
}
#[test]
fn to_json_pretty_is_valid_json() {
let schema = CompiledSchema::new();
let pretty = schema.to_json_pretty().unwrap();
let _: serde_json::Value = serde_json::from_str(&pretty).unwrap();
}
#[test]
fn a_schema_built_in_process_is_this_build() {
let schema = CompiledSchema::new();
assert_eq!(schema.fraiseql_version.get(), Some(CURRENT_FRAISEQL_VERSION));
assert!(schema.validate_producer_version().is_ok());
}
#[test]
fn an_artifact_from_another_build_is_refused() {
let mut schema = CompiledSchema::new();
schema.fraiseql_version = serde_json::from_value(serde_json::json!("2.14.0")).unwrap();
let msg = schema.validate_producer_version().expect_err("another build must be refused");
assert!(msg.contains("2.14.0"), "the message must name the producing build: {msg}");
assert!(
msg.contains(CURRENT_FRAISEQL_VERSION),
"the message must name this runtime's build: {msg}"
);
assert!(msg.contains("Recompile"), "the message must tell an operator what to do: {msg}");
}
#[test]
fn an_unstamped_artifact_is_refused() {
let mut schema = CompiledSchema::new();
schema.fraiseql_version = ProducerVersion::unstamped();
let msg = schema
.validate_producer_version()
.expect_err("an unstamped artifact is refused");
assert!(
msg.contains("fraiseql_version"),
"the message must name the missing stamp: {msg}"
);
assert!(msg.contains("Recompile"), "and the remedy: {msg}");
}
#[test]
fn a_missing_key_does_not_deserialize_to_this_build() {
let deserialized: CompiledSchema = serde_json::from_str(
r#"{"types": [], "queries": [], "mutations": [], "subscriptions": []}"#,
)
.unwrap();
assert!(deserialized.fraiseql_version.is_unstamped());
assert_ne!(
deserialized.fraiseql_version,
ProducerVersion::default(),
"an artifact that names no build must not equal one this build constructed"
);
assert!(deserialized.validate_producer_version().is_err());
}
#[test]
fn a_stamped_schema_round_trips_through_json() {
let json = serde_json::to_string(&CompiledSchema::new()).unwrap();
assert!(json.contains(CURRENT_FRAISEQL_VERSION), "the stamp must reach the artifact");
let reloaded = CompiledSchema::from_json(&json, false).unwrap();
assert!(reloaded.validate_producer_version().is_ok());
}
#[test]
fn an_unstamped_schema_serializes_no_key() {
let mut schema = CompiledSchema::new();
schema.fraiseql_version = ProducerVersion::unstamped();
let value = serde_json::to_value(&schema).unwrap();
assert!(
value.get("fraiseql_version").is_none(),
"an absent stamp is an absent key: {value:?}"
);
}
#[test]
fn build_indexes_populates_all_three_maps() {
let mut schema = CompiledSchema::new();
schema.queries.push(make_query("getUser", "User"));
schema.mutations.push(make_mutation("createUser", "User"));
schema.build_indexes();
assert!(schema.query_index.contains_key("getUser"));
assert!(schema.mutation_index.contains_key("createUser"));
}
#[test]
fn build_indexes_multiple_queries() {
let mut schema = CompiledSchema::new();
schema.queries.push(make_query("alpha", "A"));
schema.queries.push(make_query("beta", "B"));
schema.queries.push(make_query("gamma", "C"));
schema.build_indexes();
assert_eq!(schema.query_index["alpha"], 0);
assert_eq!(schema.query_index["beta"], 1);
assert_eq!(schema.query_index["gamma"], 2);
}
#[test]
fn find_type_returns_none_for_missing() {
let schema = CompiledSchema::new();
assert!(schema.find_type("Ghost").is_none());
}
#[test]
fn find_type_returns_existing() {
let mut schema = CompiledSchema::new();
schema.types.push(make_type_def("User"));
assert!(schema.find_type("User").is_some());
assert_eq!(schema.find_type("User").unwrap().name, "User");
}
#[test]
fn find_query_uses_index_when_populated() {
let json = r#"{
"types": [{"name":"User","sql_source":"v_user","fields":[]}],
"queries": [{"name":"users","return_type":"User"}],
"mutations": [],
"subscriptions": []
}"#;
let schema = CompiledSchema::from_json(json, false).unwrap();
let q = schema.find_query("users");
assert!(q.is_some());
assert_eq!(q.unwrap().name, "users");
}
#[test]
fn find_query_falls_back_to_linear_scan_without_index() {
let mut schema = CompiledSchema::new();
schema.queries.push(make_query("direct", "String"));
let q = schema.find_query("direct");
assert!(q.is_some());
}
#[test]
fn find_query_returns_none_for_missing() {
let schema = CompiledSchema::from_json("{}", false).unwrap();
assert!(schema.find_query("nope").is_none());
}
#[test]
fn find_mutation_returns_correct_entry() {
let json = r#"{
"types": [{"name":"User","sql_source":"v_user","fields":[]}],
"mutations": [{"name":"createUser","return_type":"User"}],
"queries": [],
"subscriptions": []
}"#;
let schema = CompiledSchema::from_json(json, false).unwrap();
assert!(schema.find_mutation("createUser").is_some());
assert!(schema.find_mutation("nope").is_none());
}
#[test]
fn find_interface_returns_none_when_absent() {
let schema = CompiledSchema::new();
assert!(schema.find_interface("Node").is_none());
}
#[test]
fn find_implementors_filters_by_interface() {
let mut schema = CompiledSchema::new();
let mut user = make_type_def("User");
user.implements = vec!["Node".to_string()];
schema.types.push(user);
schema.types.push(make_type_def("Product"));
let implementors = schema.find_implementors("Node");
assert_eq!(implementors.len(), 1);
assert_eq!(implementors[0].name, "User");
}
#[test]
fn operation_count_sums_all_three() {
let mut schema = CompiledSchema::new();
schema.queries.push(make_query("q1", "String"));
schema.queries.push(make_query("q2", "String"));
schema.mutations.push(make_mutation("m1", "String"));
assert_eq!(schema.operation_count(), 3);
}
#[test]
fn operation_count_zero_for_empty_schema() {
assert_eq!(CompiledSchema::new().operation_count(), 0);
}
#[test]
fn fact_table_add_and_get() {
use crate::compiler::fact_table::{DimensionColumn, FactTableMetadata};
let mut schema = CompiledSchema::new();
assert!(!schema.has_fact_tables());
let meta = FactTableMetadata {
table_name: "tf_sales".to_string(),
type_name: None,
measures: vec![],
dimensions: DimensionColumn {
name: "data".to_string(),
paths: vec![],
},
denormalized_filters: vec![],
calendar_dimensions: vec![],
partial_period: None,
native_measures: std::collections::HashMap::new(),
native_dimension_mapping: std::collections::HashMap::new(),
};
schema.add_fact_table("tf_sales".to_string(), meta);
assert!(schema.has_fact_tables());
assert!(schema.get_fact_table("tf_sales").is_some());
assert!(schema.get_fact_table("tf_missing").is_none());
}
#[test]
fn list_fact_tables_returns_all_names() {
use crate::compiler::fact_table::{DimensionColumn, FactTableMetadata};
let make_meta = |name: &str| FactTableMetadata {
table_name: name.to_string(),
type_name: None,
measures: vec![],
dimensions: DimensionColumn {
name: "data".to_string(),
paths: vec![],
},
denormalized_filters: vec![],
calendar_dimensions: vec![],
partial_period: None,
native_measures: std::collections::HashMap::new(),
native_dimension_mapping: std::collections::HashMap::new(),
};
let mut schema = CompiledSchema::new();
schema.add_fact_table("tf_a".to_string(), make_meta("tf_a"));
schema.add_fact_table("tf_b".to_string(), make_meta("tf_b"));
let names = schema.list_fact_tables();
assert_eq!(names.len(), 2);
assert!(names.contains(&"tf_a"));
assert!(names.contains(&"tf_b"));
}
#[test]
fn has_observers_false_for_empty_schema() {
assert!(!CompiledSchema::new().has_observers());
}
#[test]
fn find_observer_returns_by_name() {
let mut schema = CompiledSchema::new();
schema.observers.push(ObserverDefinition::new("onInsert", "Order", "INSERT"));
assert!(schema.find_observer("onInsert").is_some());
assert!(schema.find_observer("missing").is_none());
}
#[test]
fn find_observers_for_entity_filters_correctly() {
let mut schema = CompiledSchema::new();
schema.observers.push(ObserverDefinition::new("obs1", "Order", "INSERT"));
schema.observers.push(ObserverDefinition::new("obs2", "Order", "UPDATE"));
schema.observers.push(ObserverDefinition::new("obs3", "User", "INSERT"));
let order_obs = schema.find_observers_for_entity("Order");
assert_eq!(order_obs.len(), 2);
let user_obs = schema.find_observers_for_entity("User");
assert_eq!(user_obs.len(), 1);
}
#[test]
fn find_observers_for_event_filters_correctly() {
let mut schema = CompiledSchema::new();
schema.observers.push(ObserverDefinition::new("obs1", "Order", "INSERT"));
schema.observers.push(ObserverDefinition::new("obs2", "User", "INSERT"));
schema.observers.push(ObserverDefinition::new("obs3", "Order", "DELETE"));
let inserts = schema.find_observers_for_event("INSERT");
assert_eq!(inserts.len(), 2);
}
#[test]
fn observer_count_matches_vec_length() {
let mut schema = CompiledSchema::new();
assert_eq!(schema.observer_count(), 0);
schema.observers.push(ObserverDefinition::new("o1", "A", "INSERT"));
assert_eq!(schema.observer_count(), 1);
}
#[test]
fn is_multi_tenant_false_by_default() {
assert!(!CompiledSchema::new().is_multi_tenant());
}
#[test]
fn is_multi_tenant_true_when_configured() {
let mut schema = CompiledSchema::new();
let mut sec = SecurityConfig::new();
sec.multi_tenant = true;
schema.security = Some(sec);
assert!(schema.is_multi_tenant());
}
#[test]
fn tenancy_mode_none_by_default() {
use crate::schema::TenancyMode;
assert_eq!(CompiledSchema::new().tenancy_mode(), TenancyMode::None);
}
#[test]
fn tenancy_mode_row_when_configured() {
use crate::schema::{TenancyConfig, TenancyMode};
let mut schema = CompiledSchema::new();
let mut sec = SecurityConfig::new();
sec.tenancy = TenancyConfig {
mode: TenancyMode::Row,
tenant_claim: "tenant_id".to_string(),
};
schema.security = Some(sec);
assert_eq!(schema.tenancy_mode(), TenancyMode::Row);
}
#[test]
fn tenancy_mode_schema_when_configured() {
use crate::schema::{TenancyConfig, TenancyMode};
let mut schema = CompiledSchema::new();
let mut sec = SecurityConfig::new();
sec.tenancy = TenancyConfig {
mode: TenancyMode::Schema,
tenant_claim: "org_id".to_string(),
};
schema.security = Some(sec);
assert_eq!(schema.tenancy_mode(), TenancyMode::Schema);
}
#[test]
fn tenancy_config_none_without_security() {
assert!(CompiledSchema::new().tenancy_config().is_none());
}
#[test]
fn tenancy_config_returns_default_when_security_present() {
use crate::schema::TenancyMode;
let mut schema = CompiledSchema::new();
schema.security = Some(SecurityConfig::new());
let tc = schema.tenancy_config().unwrap();
assert_eq!(tc.mode, TenancyMode::None);
assert_eq!(tc.tenant_claim, "tenant_id");
}
#[test]
fn tenancy_round_trip_through_json() {
use crate::schema::{TenancyConfig, TenancyMode};
let mut schema = CompiledSchema::new();
let mut sec = SecurityConfig::new();
sec.tenancy = TenancyConfig {
mode: TenancyMode::Row,
tenant_claim: "org_id".to_string(),
};
schema.security = Some(sec);
let json = schema.to_json().unwrap();
let restored = CompiledSchema::from_json(&json, false).unwrap();
assert_eq!(restored.tenancy_mode(), TenancyMode::Row);
assert_eq!(restored.tenancy_config().unwrap().tenant_claim, "org_id");
}
#[test]
fn find_role_returns_none_without_security_config() {
assert!(CompiledSchema::new().find_role("admin").is_none());
}
#[test]
fn find_role_returns_defined_role() {
let mut schema = CompiledSchema::new();
let mut sec = SecurityConfig::new();
sec.add_role(RoleDefinition::new("editor", vec!["read:*".to_string()]));
schema.security = Some(sec);
assert!(schema.find_role("editor").is_some());
}
#[test]
fn role_has_scope_false_without_security() {
assert!(!CompiledSchema::new().role_has_scope("admin", "read:*"));
}
#[test]
fn role_has_scope_true_when_granted() {
let mut schema = CompiledSchema::new();
let mut sec = SecurityConfig::new();
sec.add_role(RoleDefinition::new("admin", vec!["read:*".to_string()]));
schema.security = Some(sec);
assert!(schema.role_has_scope("admin", "read:anything"));
assert!(!schema.role_has_scope("admin", "write:anything"));
}
#[test]
fn role_scope_verb_wildcard_stops_at_the_delimiter() {
let mut schema = CompiledSchema::new();
let mut sec = SecurityConfig::new();
sec.add_role(RoleDefinition::new("reader", vec!["read:*".to_string()]));
schema.security = Some(sec);
assert!(schema.role_has_scope("reader", "read:User.email"));
assert!(!schema.role_has_scope("reader", "readwrite:User.email"));
assert!(!schema.role_has_scope("reader", "read"));
}
#[test]
fn role_scope_type_wildcard_requires_boundary() {
let mut schema = CompiledSchema::new();
let mut sec = SecurityConfig::new();
sec.add_role(RoleDefinition::new("user_reader", vec!["read:User.*".to_string()]));
sec.add_role(RoleDefinition::new("sloppy", vec!["read*".to_string()]));
schema.security = Some(sec);
assert!(schema.role_has_scope("user_reader", "read:User.email"));
assert!(!schema.role_has_scope("user_reader", "read:UserAudit.email"));
assert!(!schema.role_has_scope("sloppy", "readwrite:User.email"));
}
#[test]
fn get_role_scopes_empty_for_missing_role() {
let schema = CompiledSchema::new();
assert!(schema.get_role_scopes("ghost").is_empty());
}
#[test]
fn federation_metadata_none_when_no_federation() {
assert!(CompiledSchema::new().federation_metadata().is_none());
}
#[test]
fn federation_metadata_none_when_disabled() {
let mut schema = CompiledSchema::new();
schema.federation = Some(FederationConfig {
enabled: false,
..Default::default()
});
assert!(schema.federation_metadata().is_none());
}
#[test]
#[cfg(feature = "federation")]
fn federation_metadata_some_when_enabled() {
let mut schema = CompiledSchema::new();
schema.federation = Some(FederationConfig {
enabled: true,
version: Some("v2".to_string()),
entities: vec![FederationEntity {
name: "User".to_string(),
key_fields: vec!["id".to_string()],
..Default::default()
}],
..Default::default()
});
let meta = schema.federation_metadata();
assert!(meta.is_some());
let meta = meta.unwrap();
assert!(meta.enabled);
assert_eq!(meta.types.len(), 1);
assert_eq!(meta.types[0].name, "User");
}
#[test]
#[cfg(feature = "federation")]
fn entity_sources_query_wins_then_type_level_fallback() {
let mut schema = CompiledSchema::new();
schema
.queries
.push(QueryDefinition::new("user", "User").with_sql_source("v_user"));
schema.types.push(TypeDefinition::new("User", ""));
schema.types.push(TypeDefinition::new("Organization", "v_organization"));
schema
.queries
.push(QueryDefinition::new("account", "Account").with_sql_source("v_account_query"));
schema.types.push(TypeDefinition::new("Account", "v_account_type"));
let sources = schema.entity_sources();
let user = sources.get("User").expect("owned entity sourced from its query");
assert_eq!(user.relation, "v_user");
assert_eq!(
user.jsonb_column.as_deref(),
Some("data"),
"the query's jsonb_column defaults to data"
);
let org = sources
.get("Organization")
.expect("extends entity sourced from type-level sql_source");
assert_eq!(org.relation, "v_organization");
assert_eq!(
org.jsonb_column.as_deref(),
Some("data"),
"an extends entity defaults to the standard jsonb `data` column"
);
let account = sources.get("Account").expect("Account is present");
assert_eq!(
account.relation, "v_account_query",
"a query-sourced relation wins over the type-level fallback"
);
}
#[test]
fn content_hash_is_32_hex_chars() {
let hash = CompiledSchema::new().content_hash();
assert_eq!(hash.len(), 32);
assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn content_hash_is_stable() {
let schema = CompiledSchema::new();
assert_eq!(schema.content_hash(), schema.content_hash());
}
#[test]
fn content_hash_differs_for_different_schemas() {
let s1 = CompiledSchema::new();
let mut s2 = CompiledSchema::new();
s2.queries.push(QueryDefinition::new("users", "User"));
assert_ne!(s1.content_hash(), s2.content_hash());
}
#[test]
fn content_hash_covers_the_producing_build() {
let stamped = CompiledSchema::new();
let mut unstamped = CompiledSchema::new();
unstamped.fraiseql_version = ProducerVersion::unstamped();
assert_ne!(stamped.content_hash(), unstamped.content_hash());
}
#[test]
fn has_rls_configured_false_without_security() {
assert!(!CompiledSchema::new().has_rls_configured());
}
#[test]
fn has_rls_configured_false_when_policies_empty() {
let mut schema = CompiledSchema::new();
let sec = SecurityConfig {
policies: vec![],
..SecurityConfig::default()
};
schema.security = Some(sec);
assert!(!schema.has_rls_configured());
}
#[test]
fn has_rls_configured_true_when_declared() {
let mut schema = CompiledSchema::new();
let mut sec = SecurityConfig::new();
sec.rls.enabled = true;
schema.security = Some(sec);
assert!(schema.has_rls_configured());
}
#[test]
fn tenancy_mode_implies_multi_tenant() {
for mode in [
crate::schema::TenancyMode::Row,
crate::schema::TenancyMode::Schema,
] {
let mut schema = CompiledSchema::new();
let mut sec = SecurityConfig::new();
sec.tenancy.mode = mode;
schema.security = Some(sec);
assert!(schema.is_multi_tenant(), "{mode} must imply multi-tenant");
}
let mut single = CompiledSchema::new();
single.security = Some(SecurityConfig::new());
assert!(!single.is_multi_tenant(), "mode = none without the flag is single-tenant");
}
#[test]
fn raw_schema_returns_sdl_when_set() {
let mut schema = CompiledSchema::new();
schema.schema_sdl = Some("type Query { ping: String }".to_string());
assert_eq!(schema.raw_schema(), "type Query { ping: String }");
}
#[test]
fn raw_schema_generates_from_types_when_sdl_absent() {
let mut schema = CompiledSchema::new();
schema.types.push(make_type_def("User"));
let sdl = schema.raw_schema();
assert!(sdl.contains("User"));
}
#[test]
fn raw_schema_includes_root_query_and_mutation() {
use crate::schema::{ArgumentDefinition, FieldType};
let mut schema = CompiledSchema::new();
schema.types.push(make_type_def("User"));
let mut users = QueryDefinition::new("users", "User");
users.returns_list = true;
let mut user = QueryDefinition::new("user", "User");
user.nullable = true;
user.arguments = vec![ArgumentDefinition::new("id", FieldType::Id)];
schema.queries.push(users);
schema.queries.push(user);
let mut create = MutationDefinition::new("createUser", "User");
create.arguments = vec![ArgumentDefinition::new("name", FieldType::String)];
schema.mutations.push(create);
let sdl = schema.raw_schema();
assert!(sdl.contains("type Query {"), "SDL must declare a root Query type:\n{sdl}");
assert!(sdl.contains("users: [User!]!"), "list query rendered as a list type:\n{sdl}");
assert!(sdl.contains("user(id: ID!): User"), "nullable single query with arg:\n{sdl}");
assert!(sdl.contains("type Mutation {"), "SDL must declare a root Mutation type:\n{sdl}");
assert!(sdl.contains("createUser(name: String!): User"), "mutation field:\n{sdl}");
}
#[test]
fn raw_schema_renders_auto_params_as_arguments() {
use crate::schema::AutoParams;
let mut schema = CompiledSchema::new();
schema.types.push(make_type_def("Event"));
let mut events = QueryDefinition::new("events", "Event").returning_list();
events.auto_params = AutoParams {
has_where: true,
has_order_by: true,
has_limit: true,
has_offset: false,
};
schema.queries.push(events);
let sdl = schema.raw_schema();
assert!(
sdl.contains("events(where: JSON, orderBy: JSON, limit: Int): [Event!]!"),
"auto_params must render as arguments:\n{sdl}"
);
assert!(sdl.contains("scalar JSON"), "JSON scalar must be declared:\n{sdl}");
assert!(!sdl.contains("offset"), "disabled auto_param must not appear:\n{sdl}");
assert!(!sdl.contains("scalar Int"), "Int is a builtin, never declared:\n{sdl}");
}
#[test]
fn graphql_arguments_synthesizes_and_dedups_auto_params() {
use crate::schema::{ArgumentDefinition, AutoParams, FieldType};
let schema = CompiledSchema::default();
let plain = QueryDefinition::new("user", "User");
assert!(plain.graphql_arguments(&schema).is_empty());
let mut q = QueryDefinition::new("users", "User").returning_list();
q.arguments = vec![ArgumentDefinition::optional("limit", FieldType::Int)];
q.auto_params = AutoParams::all();
let args = q.graphql_arguments(&schema);
let names: Vec<&str> = args.iter().map(|a| a.name.as_str()).collect();
assert_eq!(names, vec!["limit", "where", "orderBy", "offset"], "no duplicate `limit`");
let mut relay = QueryDefinition::new("posts", "Post").returning_list();
relay.relay = true;
relay.auto_params = AutoParams::all();
assert!(relay.graphql_arguments(&schema).is_empty(), "relay queries are untouched");
}
#[cfg(feature = "federation")]
#[test]
fn service_sdl_advertises_root_query_fields() {
let mut schema = CompiledSchema::new();
schema.types.push(make_type_def("User"));
let mut users = QueryDefinition::new("users", "User");
users.returns_list = true;
schema.queries.push(users);
schema.federation = Some(FederationConfig {
enabled: true,
version: Some("v2".to_string()),
entities: vec![FederationEntity {
name: "User".to_string(),
key_fields: vec!["id".to_string()],
..Default::default()
}],
..Default::default()
});
let meta = schema.federation_metadata().expect("federation enabled");
let sdl = crate::federation::generate_service_sdl(&schema.raw_schema(), &meta);
assert!(
sdl.contains("users: [User!]!"),
"root query must be in the _service SDL:\n{sdl}"
);
assert!(sdl.contains("@key(fields: \"id\")"), "entity key must be present:\n{sdl}");
assert!(sdl.contains("_service"), "federation plumbing must be present:\n{sdl}");
}
#[test]
fn raw_schema_renders_full_type_closure() {
use crate::schema::{
ArgumentDefinition, EnumDefinition, EnumValueDefinition, FieldType, InputFieldDefinition,
InputObjectDefinition, UnionDefinition,
};
let mut schema = CompiledSchema::new();
schema.types.push(make_type_def("QuoteSuccess"));
schema.types.push(make_type_def("MutationError"));
let mut status = EnumDefinition::new("QuoteStatus");
status.values = vec![
EnumValueDefinition::new("DRAFT"),
EnumValueDefinition::new("SENT"),
];
schema.enums.push(status);
let mut input = InputObjectDefinition::new("CreateQuoteInput");
input.fields = vec![
InputFieldDefinition::new("amount", "Decimal"), InputFieldDefinition::new("status", "QuoteStatus"), ];
schema.input_types.push(input);
schema.unions.push(UnionDefinition {
name: "CreateQuoteResult".to_string(),
member_types: vec!["QuoteSuccess".to_string(), "MutationError".to_string()],
description: None,
});
let mut create = MutationDefinition::new("createQuote", "CreateQuoteResult");
create.arguments = vec![ArgumentDefinition::new(
"input",
FieldType::Input("CreateQuoteInput".to_string()),
)];
schema.mutations.push(create);
let sdl = schema.raw_schema();
assert!(sdl.contains("enum QuoteStatus {"), "enum must be declared:\n{sdl}");
assert!(sdl.contains("DRAFT"), "enum values must be rendered:\n{sdl}");
assert!(
sdl.contains("input CreateQuoteInput {"),
"input object must be declared:\n{sdl}"
);
assert!(
sdl.contains("union CreateQuoteResult = QuoteSuccess | MutationError"),
"result union must be declared:\n{sdl}"
);
assert!(sdl.contains("scalar Decimal"), "non-built-in scalar must be declared:\n{sdl}");
assert!(
sdl.contains("createQuote(input: CreateQuoteInput!): CreateQuoteResult"),
"mutation signature references the now-declared input + result:\n{sdl}"
);
}
#[test]
fn raw_schema_declares_scalars_under_the_name_fields_reference() {
use crate::schema::{InputFieldDefinition, InputObjectDefinition};
let mut schema = CompiledSchema::new();
let mut input = InputObjectDefinition::new("EventFilter");
input.fields = vec![
InputFieldDefinition::new("at", "datetime"), InputFieldDefinition::new("span", "FloatRange"), ];
schema.input_types.push(input);
let sdl = schema.raw_schema();
assert!(sdl.contains("at: datetime"), "field renders the verbatim scalar name:\n{sdl}");
assert!(
sdl.contains("scalar datetime"),
"the referenced scalar must be declared under the SAME name:\n{sdl}"
);
assert!(sdl.contains("span: FloatRange"), "field renders FloatRange:\n{sdl}");
assert!(sdl.contains("scalar FloatRange"), "FloatRange must be declared:\n{sdl}");
}
#[test]
fn find_query_exact_match() {
let mut schema = CompiledSchema::new();
schema.types.push(make_type_def("User"));
schema.queries.push(make_query("users", "User"));
schema.build_indexes();
assert!(schema.find_query("users").is_some());
}
#[test]
fn find_query_camel_to_snake_fallback() {
let mut schema = CompiledSchema::new();
schema.types.push(make_type_def("DnsServer"));
schema.queries.push(make_query("dns_servers", "DnsServer"));
schema.build_indexes();
assert!(schema.find_query("dns_servers").is_some());
assert!(schema.find_query("dnsServers").is_some());
}
#[test]
fn find_query_camel_to_snake_fallback_without_index() {
let mut schema = CompiledSchema::new();
schema.types.push(make_type_def("DnsServer"));
schema.queries.push(make_query("dns_servers", "DnsServer"));
assert!(schema.find_query("dnsServers").is_some());
}
#[test]
fn find_mutation_camel_to_snake_fallback() {
let mut schema = CompiledSchema::new();
schema.types.push(make_type_def("Location"));
schema.mutations.push(make_mutation("create_location", "Location"));
schema.build_indexes();
assert!(schema.find_mutation("createLocation").is_some());
}
#[test]
fn find_query_returns_none_for_unknown() {
let mut schema = CompiledSchema::new();
schema.types.push(make_type_def("User"));
schema.queries.push(make_query("users", "User"));
schema.build_indexes();
assert!(schema.find_query("nonexistent").is_none());
}
#[test]
fn display_name_preserve_returns_unchanged() {
let schema = CompiledSchema::new(); assert_eq!(schema.display_name("create_dns_server"), "create_dns_server");
assert_eq!(schema.display_name("dns_servers"), "dns_servers");
}
#[test]
fn display_name_camel_case_converts() {
let mut schema = CompiledSchema::new();
schema.naming_convention = NamingConvention::CamelCase;
assert_eq!(schema.display_name("create_dns_server"), "createDnsServer");
assert_eq!(schema.display_name("dns_servers"), "dnsServers");
assert_eq!(schema.display_name("delete_outreach_sequence"), "deleteOutreachSequence");
}
#[test]
fn camel_case_index_lookup() {
let mut schema = CompiledSchema::new();
schema.naming_convention = NamingConvention::CamelCase;
schema.types.push(make_type_def("DnsServer"));
schema.queries.push(make_query("dns_servers", "DnsServer"));
schema.mutations.push(make_mutation("create_dns_server", "DnsServer"));
schema.build_indexes();
assert!(schema.find_query("dnsServers").is_some());
assert!(schema.find_mutation("createDnsServer").is_some());
assert!(schema.find_query("dns_servers").is_some());
assert!(schema.find_mutation("create_dns_server").is_some());
}
#[test]
fn preserve_convention_no_camel_index_entry() {
let mut schema = CompiledSchema::new(); schema.types.push(make_type_def("DnsServer"));
schema.queries.push(make_query("dns_servers", "DnsServer"));
schema.build_indexes();
assert_eq!(schema.query_index.len(), 1);
assert!(schema.query_index.contains_key("dns_servers"));
}
#[test]
fn naming_convention_serde_in_compiled_schema() {
let mut schema = CompiledSchema::new();
schema.naming_convention = NamingConvention::CamelCase;
let json = schema.to_json().unwrap();
let restored = CompiledSchema::from_json(&json, false).unwrap();
assert_eq!(restored.naming_convention, NamingConvention::CamelCase);
}
#[test]
fn schema_integrity_verification() {
let schema = CompiledSchema::new();
let body = schema.to_json().unwrap();
let value: serde_json::Value = serde_json::from_str(&body).unwrap();
let hash_hex = super::content_hash_of(&value);
let obj = value.as_object().unwrap();
let mut new_obj = serde_json::Map::new();
new_obj.insert("_content_hash".to_string(), serde_json::Value::String(hash_hex));
for (k, v) in obj {
new_obj.insert(k.clone(), v.clone());
}
let wrapped_json = serde_json::to_string_pretty(&serde_json::Value::Object(new_obj)).unwrap();
let restored = CompiledSchema::from_json(&wrapped_json, true).unwrap();
assert_eq!(restored.types.len(), schema.types.len());
let mut mismatch_value: serde_json::Value = serde_json::from_str(&wrapped_json).unwrap();
let mismatch_obj = mismatch_value.as_object_mut().unwrap();
mismatch_obj.insert(
"_content_hash".to_string(),
serde_json::Value::String("0000000000000000".to_string()),
);
let mismatch_json = serde_json::to_string_pretty(&mismatch_value).unwrap();
let result = CompiledSchema::from_json(&mismatch_json, true);
assert!(result.is_err(), "Expected validation error for hash mismatch");
let result = CompiledSchema::from_json(&body, true);
assert!(result.is_err(), "Expected error for missing hash in strict mode");
let restored = CompiledSchema::from_json(&body, false).unwrap();
assert_eq!(restored.types.len(), schema.types.len());
}
#[test]
fn content_hash_is_independent_of_key_order() {
let a: serde_json::Value =
serde_json::from_str(r#"{"b": 1, "a": {"y": [2, {"q": 3, "p": 4}], "x": 5}}"#).unwrap();
let b: serde_json::Value =
serde_json::from_str(r#"{"a": {"x": 5, "y": [2, {"p": 4, "q": 3}]}, "b": 1}"#).unwrap();
assert_eq!(super::content_hash_of(&a), super::content_hash_of(&b));
let c: serde_json::Value = serde_json::from_str(r#"{"a": 1, "b": 2}"#).unwrap();
assert_ne!(super::content_hash_of(&a), super::content_hash_of(&c));
}
#[test]
fn content_hash_is_sensitive_to_array_order() {
let a: serde_json::Value = serde_json::from_str(r#"{"types": [{"n": 1}, {"n": 2}]}"#).unwrap();
let b: serde_json::Value = serde_json::from_str(r#"{"types": [{"n": 2}, {"n": 1}]}"#).unwrap();
assert_ne!(super::content_hash_of(&a), super::content_hash_of(&b));
}
#[test]
fn internal_flag_round_trips_when_true() {
let mut ty = make_type_def("EntityChangeLog");
ty.internal = true;
let json = serde_json::to_string(&ty).unwrap();
assert!(json.contains("\"internal\":true"), "internal=true must serialize: {json}");
let back: TypeDefinition = serde_json::from_str(&json).unwrap();
assert!(back.internal, "internal must survive round-trip");
assert_eq!(ty, back);
}
#[test]
fn internal_flag_omitted_when_false() {
let ty = make_type_def("User");
assert!(!ty.internal, "authored types default to internal = false");
let json = serde_json::to_string(&ty).unwrap();
assert!(!json.contains("internal"), "internal=false must be omitted from JSON: {json}");
let back: TypeDefinition = serde_json::from_str(&json).unwrap();
assert!(!back.internal, "omitted key deserializes back to false");
assert_eq!(ty, back);
}
fn schema_with_a_filterable_query() -> CompiledSchema {
use crate::schema::{AutoParams, FieldDefinition, FieldType};
let mut schema = CompiledSchema::default();
let mut order = TypeDefinition::new("Order", "v_order");
order.fields.push(FieldDefinition::new("reference", FieldType::String));
order.fields.push(FieldDefinition::new("total", FieldType::Int));
schema.types.push(order);
let mut orders = QueryDefinition::new("orders", "Order");
orders.returns_list = true;
orders.auto_params = AutoParams::all();
schema.queries.push(orders);
schema
}
#[test]
fn build_indexes_materialises_the_derived_filter_surface() {
let mut schema = schema_with_a_filterable_query();
assert!(schema.find_input_type("OrderWhereInput").is_none(), "not yet derived");
schema.build_indexes();
assert!(
schema.find_input_type("OrderWhereInput").is_some(),
"`where` needs a type to name"
);
assert!(schema.find_input_type("OrderOrderByInput").is_some(), "`orderBy` needs one too");
assert!(schema.find_input_type("StringFilter").is_some(), "and so does each leaf");
assert!(schema.find_enum("SortDirection").is_some(), "the sort surface names an enum");
}
#[test]
fn build_indexes_is_idempotent() {
let mut schema = schema_with_a_filterable_query();
schema.build_indexes();
let after_first = schema.input_types.len();
let enums_after_first = schema.enums.len();
schema.build_indexes();
assert_eq!(schema.input_types.len(), after_first, "a second pass duplicated input types");
assert_eq!(schema.enums.len(), enums_after_first, "a second pass duplicated enums");
}
#[test]
fn graphql_arguments_types_where_and_order_by_against_the_derived_surface() {
use crate::schema::FieldType;
let mut schema = schema_with_a_filterable_query();
schema.build_indexes();
let args = schema.queries[0].graphql_arguments(&schema);
let arg = |name: &str| args.iter().find(|a| a.name == name).expect("argument present");
assert_eq!(arg("where").arg_type, FieldType::Input("OrderWhereInput".to_string()));
assert_eq!(
arg("orderBy").arg_type,
FieldType::List(Box::new(FieldType::Input("OrderOrderByInput".to_string()))),
"the engine parses `[{{field, direction}}]`, so the published type is a list"
);
assert_eq!(arg("limit").arg_type, FieldType::Int);
assert_eq!(arg("offset").arg_type, FieldType::Int);
}
#[test]
fn an_auto_wired_argument_stays_json_when_no_type_backs_the_name() {
use crate::schema::{AutoParams, FieldType};
let mut schema = CompiledSchema::default();
let mut ghosts = QueryDefinition::new("ghosts", "Ghost");
ghosts.returns_list = true;
ghosts.auto_params = AutoParams::all();
schema.queries.push(ghosts);
schema.build_indexes();
let args = schema.queries[0].graphql_arguments(&schema);
let arg = |name: &str| args.iter().find(|a| a.name == name).expect("argument present");
assert_eq!(arg("where").arg_type, FieldType::Json, "no `GhostWhereInput` exists to name");
assert_eq!(arg("orderBy").arg_type, FieldType::Json);
}
#[test]
fn every_published_argument_type_resolves_against_the_schema() {
let mut schema = schema_with_a_filterable_query();
schema.build_indexes();
let mut checked = 0_usize;
for query in &schema.queries {
for arg in query.graphql_arguments(&schema) {
let mut ty = &arg.arg_type;
while let crate::schema::FieldType::List(inner) = ty {
ty = inner;
}
if let crate::schema::FieldType::Input(name) = ty {
assert!(
schema.find_input_type(name).is_some(),
"query `{}` publishes `{name}`, which the schema does not define",
query.name
);
checked += 1;
}
}
}
assert!(checked > 0, "the walk found no input-typed arguments, so it proved nothing");
}
#[test]
fn every_derived_input_field_names_a_type_the_schema_carries() {
let mut schema = schema_with_a_filterable_query();
schema.build_indexes();
let published = crate::schema::published_scalar_names();
for input_type in &schema.input_types {
for field in &input_type.fields {
let leaf = field.field_type.trim_start_matches('[').trim_end_matches(['!', ']']);
assert!(
published.iter().any(|s| s == leaf)
|| schema.find_enum(leaf).is_some()
|| schema.find_input_type(leaf).is_some(),
"`{}.{}` names `{leaf}`, which the schema does not define",
input_type.name,
field.name
);
}
}
}
#[test]
fn raw_schema_renders_the_derived_filter_types_as_input_blocks() {
let mut schema = schema_with_a_filterable_query();
schema.build_indexes();
let sdl = schema.raw_schema();
assert!(
sdl.contains("orders(where: OrderWhereInput, orderBy: [OrderOrderByInput], "),
"the arguments must reference the derived types:\n{sdl}"
);
assert!(
sdl.contains("input OrderWhereInput {"),
"the filter type must be declared:\n{sdl}"
);
assert!(sdl.contains("input OrderOrderByInput {"), "the sort item too:\n{sdl}");
assert!(sdl.contains("input StringFilter {"), "and each leaf filter:\n{sdl}");
assert!(sdl.contains("enum SortDirection {"), "and the direction enum:\n{sdl}");
assert!(
!sdl.contains("scalar OrderWhereInput"),
"a derived input type must never be declared as a bare scalar:\n{sdl}"
);
}
#[test]
fn the_derived_filter_types_are_acceptable_variable_types() {
use crate::{graphql::parse_query, runtime::validate_variable_types};
let mut schema = schema_with_a_filterable_query();
schema.build_indexes();
let var_defs = |source: &str| parse_query(source).expect("query parses").variables;
validate_variable_types(
&schema,
Some("Q"),
&var_defs("query Q($w: OrderWhereInput) { orders(where: $w) { reference } }"),
)
.expect("the schema publishes `OrderWhereInput`, so a client may write it");
validate_variable_types(
&schema,
Some("Q"),
&var_defs("query Q($o: [OrderOrderByInput!]) { orders(orderBy: $o) { reference } }"),
)
.expect("and `OrderOrderByInput`, list-wrapped");
validate_variable_types(
&schema,
Some("Q"),
&var_defs("query Q($d: SortDirection) { orders(orderBy: [{field: \"x\", direction: $d}]) { reference } }"),
)
.expect("and the direction enum");
let err = validate_variable_types(
&schema,
Some("Q"),
&var_defs("query Q($w: OrdrWhereInput) { orders(where: $w) { reference } }"),
)
.expect_err("a name the schema does not carry is still refused");
let message = err.to_string();
assert!(message.contains("OrdrWhereInput"), "message was: {message}");
assert!(message.contains("Did you mean 'OrderWhereInput'?"), "message was: {message}");
}
fn golden_fixtures_dir() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.join("tests/fixtures/golden")
}
#[test]
fn no_golden_fixture_filters_on_an_argument_it_does_not_declare() {
let mut checked = 0_usize;
for entry in std::fs::read_dir(golden_fixtures_dir()).expect("fixtures dir readable") {
let path = entry.expect("readable dir entry").path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let name = path.file_name().unwrap().to_str().unwrap().to_string();
let json = std::fs::read_to_string(&path).unwrap();
let Ok(schema) = serde_json::from_str::<CompiledSchema>(&json) else {
continue;
};
checked += 1;
for sub in &schema.subscriptions {
assert!(
sub.filter_violation().is_none(),
"{name}: {}",
sub.filter_violation().unwrap_or_default()
);
}
}
assert!(
checked >= 9,
"expected the golden fixtures to be found and parsed, got {checked}"
);
}
#[test]
fn a_compiled_schema_with_a_dangling_subscription_filter_is_refused_at_load() {
let json = r#"{
"types": [{"name": "Order", "sql_source": "v_order",
"fields": [{"name": "id", "field_type": "ID", "nullable": false}]}],
"queries": [], "mutations": [],
"subscriptions": [{
"name": "orderUpdated", "return_type": "Order", "topic": "order_events",
"arguments": [{"name": "orderId", "arg_type": "ID", "nullable": true}],
"filter": {"argument_paths": {"no_such_argument": "/id"}}
}]
}"#;
let err = CompiledSchema::from_json(json, false)
.expect_err("a filter that cannot be applied must not load");
let message = err.to_string();
assert!(
message.contains("no_such_argument"),
"the refusal names the reference: {message}"
);
assert!(message.contains("orderId"), "the refusal names what is declared: {message}");
}
#[test]
fn a_compiled_schema_with_a_resolvable_subscription_filter_loads() {
let json = r#"{
"types": [{"name": "Order", "sql_source": "v_order",
"fields": [{"name": "id", "field_type": "ID", "nullable": false}]}],
"queries": [], "mutations": [],
"subscriptions": [{
"name": "orderUpdated", "return_type": "Order", "topic": "order_events",
"arguments": [{"name": "orderId", "arg_type": "ID", "nullable": true}],
"filter": {"argument_paths": {"orderId": "/id"}}
}]
}"#;
let schema = CompiledSchema::from_json(json, false).expect("a resolvable filter must load");
let filter = schema.subscriptions[0].filter.as_ref().expect("filter survives the load");
assert_eq!(filter.argument_paths.get("orderId"), Some(&"/id".to_string()));
}
fn relationship_fixture() -> serde_json::Value {
serde_json::json!({
"types": [
{"name": "Author", "sql_source": "v_author",
"fields": [{"name": "id", "field_type": "ID", "nullable": false},
{"name": "name", "field_type": "String", "nullable": false}],
"relationships": [{"name": "posts", "target_type": "Post",
"cardinality": "OneToMany",
"foreign_key": "fk_author", "referenced_key": "id"}]},
{"name": "Post", "sql_source": "v_post",
"fields": [{"name": "id", "field_type": "ID", "nullable": false},
{"name": "fk_author", "field_type": "Int", "nullable": false}]}
],
"queries": [
{"name": "authors", "return_type": "Author", "returns_list": true, "sql_source": "v_author"},
{"name": "posts", "return_type": "Post", "returns_list": true, "sql_source": "v_post"}
],
"mutations": []
})
}
fn refusal_for(mutate: impl FnOnce(&mut serde_json::Value)) -> String {
let mut doc = relationship_fixture();
mutate(&mut doc);
let err = CompiledSchema::from_json(&doc.to_string(), false)
.expect_err("an unfollowable relationship must not load");
err.to_string()
}
#[test]
fn a_followable_relationship_loads_and_survives() {
let schema = CompiledSchema::from_json(&relationship_fixture().to_string(), false)
.expect("a followable relationship must load");
let rels = &schema.find_type("Author").expect("Author loads").relationships;
assert_eq!(rels.len(), 1, "the relationship survives the load");
assert_eq!(rels[0].target_type, "Post");
}
#[test]
fn a_relationship_targeting_an_undeclared_type_is_refused_at_load() {
let message = refusal_for(|d| d["types"][0]["relationships"][0]["target_type"] = "Nope".into());
assert!(
message.contains("targets type 'Nope', which the schema does not declare"),
"the refusal diagnoses the missing type rather than a downstream symptom: {message}"
);
assert!(message.contains("Author.posts"), "and names the relationship: {message}");
}
#[test]
fn a_relationship_whose_parent_join_column_names_no_field_is_refused_at_load() {
let message =
refusal_for(|d| d["types"][0]["relationships"][0]["referenced_key"] = "no_such".into());
assert!(message.contains("'no_such'"), "the refusal names the column: {message}");
assert!(message.contains("of 'Author'"), "and the side it is missing from: {message}");
}
#[test]
fn a_relationship_whose_target_join_column_names_no_field_is_refused_at_load() {
let message =
refusal_for(|d| d["types"][0]["relationships"][0]["foreign_key"] = "no_such".into());
assert!(message.contains("'no_such'"), "the refusal names the column: {message}");
assert!(message.contains("'Post'"), "and the side it is missing from: {message}");
}
#[test]
fn flipping_the_cardinality_alone_makes_the_same_columns_unfollowable() {
let message =
refusal_for(|d| d["types"][0]["relationships"][0]["cardinality"] = "ManyToOne".into());
assert!(
message.contains("joins on 'fk_author' of 'Author'"),
"ManyToOne reads the foreign key off the declaring type: {message}"
);
}
#[test]
fn a_relationship_with_an_empty_join_column_is_refused_at_load() {
let message = refusal_for(|d| {
d["types"][0]["relationships"][0]
.as_object_mut()
.expect("relationship is an object")
.remove("foreign_key");
});
assert!(message.contains("`foreign_key`"), "the refusal names the key: {message}");
}
#[test]
fn a_type_declaring_one_relationship_name_twice_is_refused_at_load() {
let message = refusal_for(|d| {
let dup = d["types"][0]["relationships"][0].clone();
d["types"][0]["relationships"].as_array_mut().expect("array").push(dup);
});
assert!(message.contains("more than once"), "the refusal names the shape: {message}");
}
#[test]
fn a_relationship_whose_target_has_no_list_query_is_refused_at_load() {
let message = refusal_for(|d| d["queries"][1]["returns_list"] = false.into());
assert!(message.contains("no list query"), "the refusal names the cause: {message}");
}
#[test]
fn a_join_column_resolves_against_a_camel_case_field_name() {
let mut doc = relationship_fixture();
doc["types"][0]["relationships"][0]["cardinality"] = "ManyToOne".into();
doc["types"][0]["relationships"][0]["target_type"] = "Post".into();
doc["types"][0]["fields"].as_array_mut().expect("array").push(serde_json::json!(
{"name": "fkAuthor", "field_type": "Int", "nullable": false}));
CompiledSchema::from_json(&doc.to_string(), false)
.expect("`fk_author` is published as `fkAuthor`, which is the spelling the executor reads");
}
fn to_one_fixture(key: &str) -> serde_json::Value {
let mut doc = relationship_fixture();
doc["types"][1]["relationships"] = serde_json::json!([{
"name": "author", "target_type": "Author", "cardinality": "ManyToOne",
"foreign_key": "fk_author", "referenced_key": key
}]);
doc
}
#[test]
fn a_to_one_joining_on_a_non_unique_key_is_refused_at_load() {
let err = CompiledSchema::from_json(&to_one_fixture("name").to_string(), false)
.expect_err("a to-one on a non-unique key must not load");
let message = err.to_string();
assert!(
message.contains("not declared unique"),
"the refusal names the missing fact rather than a downstream symptom: {message}"
);
assert!(message.contains("Post.author"), "and names the relationship: {message}");
assert!(
message.contains("unique = true"),
"and names the remedy, which is the whole reason this is a refusal and not a \
silent first-row: {message}"
);
}
#[test]
fn declaring_the_target_key_unique_makes_the_same_to_one_followable() {
let mut doc = to_one_fixture("name");
doc["types"][0]["fields"][1]["unique"] = true.into();
CompiledSchema::from_json(&doc.to_string(), false)
.expect("the author's `unique` declaration is the fact the check was missing");
}
#[test]
fn the_entity_identity_needs_no_uniqueness_declaration() {
CompiledSchema::from_json(&to_one_fixture("id").to_string(), false)
.expect("`id: ID` is unique under ADR-0017 without being declared so");
}
#[test]
fn a_field_merely_named_id_is_not_taken_for_the_entity_identity() {
let mut doc = to_one_fixture("id");
doc["types"][0]["fields"][0]["field_type"] = "Int".into();
let err = CompiledSchema::from_json(&doc.to_string(), false)
.expect_err("an Int `id` is not the ADR-0017 identity");
assert!(
err.to_string().contains("not declared unique"),
"the name alone buys no exemption: {err}"
);
}
#[test]
fn a_to_many_on_the_same_non_unique_key_still_loads() {
let mut doc = to_one_fixture("name");
doc["types"][1]["relationships"][0]["cardinality"] = "OneToMany".into();
doc["types"][1]["relationships"][0]["referenced_key"] = "fk_author".into();
doc["types"][1]["relationships"][0]["foreign_key"] = "name".into();
CompiledSchema::from_json(&doc.to_string(), false)
.expect("a to-many on a non-unique key is a collection, which is the point of one");
}
#[test]
fn pagination_order_round_trips_with_a_legible_spelling() {
use super::query::PaginationOrder;
let json_identity = serde_json::to_value(PaginationOrder::JsonIdentity).unwrap();
assert_eq!(json_identity, serde_json::json!("json_identity"));
let column = serde_json::to_value(PaginationOrder::Column("pk_user".into())).unwrap();
assert_eq!(column, serde_json::json!({ "column": "pk_user" }));
for order in [
PaginationOrder::JsonIdentity,
PaginationOrder::Column("pk_user".into()),
] {
let mut q = QueryDefinition::new("users", "User").returning_list();
q.pagination_order = Some(order.clone());
let back: QueryDefinition =
serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
assert_eq!(back.pagination_order, Some(order));
}
}
#[test]
fn a_query_with_no_pagination_order_emits_no_key() {
let q = QueryDefinition::new("user", "User");
let value = serde_json::to_value(&q).unwrap();
assert!(
value.get("pagination_order").is_none(),
"an absent order must not be spelled out: {value}"
);
let back: QueryDefinition = serde_json::from_value(value).unwrap();
assert_eq!(back.pagination_order, None);
}
fn policy_fixture() -> serde_json::Value {
serde_json::json!({
"types": [
{"name": "Order", "sql_source": "v_order",
"fields": [{"name": "id", "field_type": "Int", "nullable": false},
{"name": "owner_id", "field_type": "String", "nullable": false}],
"subscription_policy": {"owner_path": "$.owner_id",
"identity_field": "user_id",
"bypass_roles": ["admin"]}}
],
"queries": [
{"name": "orders", "return_type": "Order", "returns_list": true, "sql_source": "v_order"}
],
"mutations": []
})
}
fn policy_refusal_for(mutate: impl FnOnce(&mut serde_json::Value)) -> String {
let mut doc = policy_fixture();
mutate(&mut doc);
CompiledSchema::from_json(&doc.to_string(), false)
.expect_err("a malformed subscription policy must not load")
.to_string()
}
#[test]
fn a_well_formed_subscription_policy_loads_and_survives() {
let schema = CompiledSchema::from_json(&policy_fixture().to_string(), false)
.expect("a well-formed policy must load");
let policy = schema
.find_type("Order")
.expect("Order loads")
.subscription_policy
.as_ref()
.expect("the policy survives the load");
assert_eq!(policy.owner_field(), "owner_id");
assert_eq!(policy.identity_field, "user_id");
}
#[test]
fn a_nested_subscription_policy_owner_path_is_refused_at_load() {
let message =
policy_refusal_for(|d| d["types"][0]["subscription_policy"]["owner_path"] = "$.a.b".into());
assert!(
message.contains("must be a single-level"),
"the refusal diagnoses the nested path: {message}"
);
assert!(message.contains("Order"), "and names the declaring type: {message}");
}
#[test]
fn an_empty_subscription_policy_identity_field_is_refused_at_load() {
let message = policy_refusal_for(|d| {
d["types"][0]["subscription_policy"]["identity_field"] = "".into();
});
assert!(
message.contains("identity_field must not be empty"),
"the refusal diagnoses the empty identity field: {message}"
);
}
#[test]
fn a_subscription_policy_owner_path_naming_no_field_is_refused_at_load() {
let message =
policy_refusal_for(|d| d["types"][0]["subscription_policy"]["owner_path"] = "$.".into());
assert!(
message.contains("must name a field"),
"the refusal diagnoses the empty owner field: {message}"
);
}
#[test]
fn a_duplicate_type_name_is_refused_at_load() {
let message = policy_refusal_for(|d| {
let dup = d["types"][0].clone();
d["types"].as_array_mut().expect("types is an array").push(dup);
});
assert!(message.contains("Duplicate type name: Order"), "{message}");
}
#[test]
fn a_duplicate_query_name_is_refused_at_load() {
let message = policy_refusal_for(|d| {
let dup = d["queries"][0].clone();
d["queries"].as_array_mut().expect("queries is an array").push(dup);
});
assert!(message.contains("Duplicate query name: orders"), "{message}");
}
#[test]
fn a_duplicate_mutation_name_is_refused_at_load() {
let message = policy_refusal_for(|d| {
let m = serde_json::json!({"name": "createOrder", "return_type": "Order",
"operation": {"Insert": {"table": "tb_order"}}});
let arr = d["mutations"].as_array_mut().expect("mutations is an array");
arr.push(m.clone());
arr.push(m);
});
assert!(message.contains("Duplicate mutation name: createOrder"), "{message}");
}
#[test]
fn a_query_returning_an_enum_or_union_still_loads() {
let doc = serde_json::json!({
"types": [
{"name": "Order", "sql_source": "v_order",
"fields": [{"name": "id", "field_type": "Int", "nullable": false}]}
],
"enums": [{"name": "OrderStatus", "values": [{"name": "OPEN"}, {"name": "CLOSED"}]}],
"queries": [
{"name": "orderStatus", "return_type": "OrderStatus", "sql_source": "v_order"}
],
"mutations": []
});
CompiledSchema::from_json(&doc.to_string(), false).expect(
"a query returning an enum must load: the load path resolves return types \
against types/enums/interfaces/unions or not at all, never against `types` alone",
);
}
#[test]
fn a_fact_table_linked_to_a_type_lacking_one_of_its_columns_is_refused_at_load() {
let json = r#"{
"types": [{
"name":"Sale","sql_source":"v_sale",
"fields":[{"name":"revenue","field_type":"Float"}]
}],
"queries": [], "mutations": [], "subscriptions": [],
"fact_tables": {"tf_sales": {
"table_name":"tf_sales","type_name":"Sale",
"measures":[
{"name":"revenue","sql_type":"Decimal","nullable":false},
{"name":"margin","sql_type":"Decimal","nullable":true}
],
"dimensions":{"name":"data","paths":[]},
"denormalized_filters":[]
}}
}"#;
let err = CompiledSchema::from_json(json, false).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("tf_sales"), "the message must name the fact table, got: {msg}");
assert!(msg.contains("Sale"), "the message must name the type, got: {msg}");
assert!(msg.contains("margin"), "the message must name the missing field, got: {msg}");
}
#[test]
fn a_fact_table_linked_to_an_unknown_type_is_refused_at_load() {
let json = r#"{
"types": [], "queries": [], "mutations": [], "subscriptions": [],
"fact_tables": {"tf_sales": {
"table_name":"tf_sales","type_name":"Sael",
"measures":[{"name":"revenue","sql_type":"Decimal","nullable":false}],
"dimensions":{"name":"data","paths":[]},
"denormalized_filters":[]
}}
}"#;
let msg = CompiledSchema::from_json(json, false).unwrap_err().to_string();
assert!(msg.contains("Sael"), "the message must name the type, got: {msg}");
}
#[test]
fn an_unlinked_or_completely_linked_fact_table_loads() {
let json = r#"{
"types": [{
"name":"Sale","sql_source":"v_sale",
"fields":[{"name":"revenue","field_type":"Float"},{"name":"category","field_type":"String"}]
}],
"queries": [], "mutations": [], "subscriptions": [],
"fact_tables": {
"tf_sales": {
"table_name":"tf_sales","type_name":"Sale",
"measures":[{"name":"revenue","sql_type":"Decimal","nullable":false}],
"dimensions":{"name":"data","paths":[
{"name":"category","json_path":"data->>'category'","data_type":"text"}
]},
"denormalized_filters":[]
},
"tf_events": {
"table_name":"tf_events",
"measures":[{"name":"n","sql_type":"Int","nullable":false}],
"dimensions":{"name":"data","paths":[]},
"denormalized_filters":[]
}
}
}"#;
CompiledSchema::from_json(json, false).unwrap();
}
mod fact_table_hierarchy {
use std::collections::HashMap;
use crate::{
compiler::fact_table::{DimensionColumn, FactTableMetadata, FilterColumn, SqlType},
schema::{CompiledSchema, HierarchyDefinition},
};
fn schema_with(sql_type: SqlType, hierarchy: &str, declared: bool) -> String {
let mut schema = CompiledSchema::new();
schema.add_fact_table(
"tf_org".to_string(),
FactTableMetadata {
table_name: "tf_org".to_string(),
type_name: None,
measures: vec![],
dimensions: DimensionColumn {
name: "data".to_string(),
paths: vec![],
},
denormalized_filters: vec![FilterColumn {
name: "org_path".to_string(),
sql_type,
indexed: true,
hierarchy: Some(hierarchy.to_string()),
}],
calendar_dimensions: vec![],
partial_period: None,
native_measures: HashMap::new(),
native_dimension_mapping: HashMap::new(),
},
);
if declared {
schema.hierarchies_config = Some(HashMap::from([(
"org".to_string(),
HierarchyDefinition {
table: "tb_org_unit".to_string(),
path_column: "path".to_string(),
},
)]));
}
serde_json::to_string(&schema).unwrap()
}
#[test]
fn a_declared_hierarchy_on_an_ltree_column_loads() {
CompiledSchema::from_json(&schema_with(SqlType::Ltree, "org", true), false).unwrap();
}
#[test]
fn an_undeclared_hierarchy_is_refused() {
let err = CompiledSchema::from_json(&schema_with(SqlType::Ltree, "org", false), false)
.unwrap_err()
.to_string();
assert!(err.contains("tf_org.org_path") && err.contains("[hierarchies.org]"), "{err}");
}
#[test]
fn a_hierarchy_on_a_column_that_is_not_a_path_is_refused() {
let err = CompiledSchema::from_json(&schema_with(SqlType::Text, "org", true), false)
.unwrap_err()
.to_string();
assert!(err.contains("tf_org.org_path") && err.contains("ltree"), "{err}");
}
}