1use std::{collections::HashMap, fmt::Write as _};
17
18use serde::{Deserialize, Serialize};
19
20use super::{directive::DirectiveDefinition, mutation::MutationDefinition, query::QueryDefinition};
21use crate::{
22 compiler::fact_table::FactTableMetadata,
23 schema::{
24 config_types::{
25 DebugConfig, FederationConfig, GrpcConfig, McpConfig, NamingConvention,
26 ObserversConfig, RestConfig, SessionVariablesConfig, SubscriptionsConfig,
27 ValidationConfig,
28 },
29 graphql_type_defs::{
30 EnumDefinition, InputObjectDefinition, InterfaceDefinition, TypeDefinition,
31 UnionDefinition,
32 },
33 observer_types::ObserverDefinition,
34 security_config::{RoleDefinition, SecurityConfig},
35 subscription_types::SubscriptionDefinition,
36 },
37 validation::CustomTypeRegistry,
38};
39
40pub const CURRENT_SCHEMA_FORMAT_VERSION: u32 = 1;
45
46#[derive(Debug, Clone, Default, Serialize, Deserialize)]
67pub struct CompiledSchema {
68 #[serde(default)]
70 pub types: Vec<TypeDefinition>,
71
72 #[serde(default)]
74 pub enums: Vec<EnumDefinition>,
75
76 #[serde(default)]
78 pub input_types: Vec<InputObjectDefinition>,
79
80 #[serde(default)]
82 pub interfaces: Vec<InterfaceDefinition>,
83
84 #[serde(default)]
86 pub unions: Vec<UnionDefinition>,
87
88 #[serde(default)]
90 pub queries: Vec<QueryDefinition>,
91
92 #[serde(default)]
94 pub mutations: Vec<MutationDefinition>,
95
96 #[serde(default)]
98 pub subscriptions: Vec<SubscriptionDefinition>,
99
100 #[serde(default, skip_serializing_if = "Vec::is_empty")]
103 pub directives: Vec<DirectiveDefinition>,
104
105 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
108 pub fact_tables: HashMap<String, FactTableMetadata>,
109
110 #[serde(default, skip_serializing_if = "Vec::is_empty")]
112 pub observers: Vec<ObserverDefinition>,
113
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub federation: Option<FederationConfig>,
117
118 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub security: Option<SecurityConfig>,
121
122 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub observers_config: Option<ObserversConfig>,
128
129 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub subscriptions_config: Option<SubscriptionsConfig>,
133
134 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub validation_config: Option<ValidationConfig>,
138
139 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub debug_config: Option<DebugConfig>,
143
144 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub mcp_config: Option<McpConfig>,
148
149 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub rest_config: Option<RestConfig>,
153
154 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub grpc_config: Option<GrpcConfig>,
158
159 #[serde(default)]
168 pub session_variables: SessionVariablesConfig,
169
170 #[serde(default)]
176 pub naming_convention: NamingConvention,
177
178 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub schema_format_version: Option<u32>,
184
185 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub schema_sdl: Option<String>,
188
189 #[serde(skip)]
195 pub custom_scalars: CustomTypeRegistry,
196
197 #[serde(skip)]
202 pub query_index: HashMap<String, usize>,
203
204 #[serde(skip)]
209 pub mutation_index: HashMap<String, usize>,
210
211 #[serde(skip)]
216 pub subscription_index: HashMap<String, usize>,
217}
218
219impl PartialEq for CompiledSchema {
220 fn eq(&self, other: &Self) -> bool {
221 self.schema_format_version == other.schema_format_version
223 && self.types == other.types
224 && self.enums == other.enums
225 && self.input_types == other.input_types
226 && self.interfaces == other.interfaces
227 && self.unions == other.unions
228 && self.queries == other.queries
229 && self.mutations == other.mutations
230 && self.subscriptions == other.subscriptions
231 && self.directives == other.directives
232 && self.fact_tables == other.fact_tables
233 && self.observers == other.observers
234 && self.federation == other.federation
235 && self.security == other.security
236 && self.observers_config == other.observers_config
237 && self.subscriptions_config == other.subscriptions_config
238 && self.validation_config == other.validation_config
239 && self.debug_config == other.debug_config
240 && self.mcp_config == other.mcp_config
241 && self.naming_convention == other.naming_convention
242 && self.schema_sdl == other.schema_sdl
243 }
244}
245
246impl CompiledSchema {
247 #[must_use]
249 pub fn new() -> Self {
250 Self::default()
251 }
252
253 pub fn validate_format_version(&self) -> Result<(), String> {
263 match self.schema_format_version {
264 None => {
265 Ok(())
267 },
268 Some(v) if v == CURRENT_SCHEMA_FORMAT_VERSION => Ok(()),
269 Some(v) => Err(format!(
270 "Schema format version mismatch: compiled schema has version {v}, \
271 but this runtime expects version {CURRENT_SCHEMA_FORMAT_VERSION}. \
272 Please recompile your schema with the matching fraiseql-cli version."
273 )),
274 }
275 }
276
277 pub fn build_indexes(&mut self) {
282 let camel = matches!(self.naming_convention, NamingConvention::CamelCase);
283
284 self.query_index = self
285 .queries
286 .iter()
287 .enumerate()
288 .flat_map(|(i, q)| {
289 let mut entries = vec![(q.name.clone(), i)];
290 if camel {
291 let converted = crate::utils::casing::to_camel_case(&q.name);
292 if converted != q.name {
293 entries.push((converted, i));
294 }
295 }
296 entries
297 })
298 .collect();
299
300 self.mutation_index = self
301 .mutations
302 .iter()
303 .enumerate()
304 .flat_map(|(i, m)| {
305 let mut entries = vec![(m.name.clone(), i)];
306 if camel {
307 let converted = crate::utils::casing::to_camel_case(&m.name);
308 if converted != m.name {
309 entries.push((converted, i));
310 }
311 }
312 entries
313 })
314 .collect();
315
316 self.subscription_index = self
317 .subscriptions
318 .iter()
319 .enumerate()
320 .flat_map(|(i, s)| {
321 let mut entries = vec![(s.name.clone(), i)];
322 if camel {
323 let converted = crate::utils::casing::to_camel_case(&s.name);
324 if converted != s.name {
325 entries.push((converted, i));
326 }
327 }
328 entries
329 })
330 .collect();
331 }
332
333 #[must_use]
339 pub fn display_name(&self, name: &str) -> String {
340 match self.naming_convention {
341 NamingConvention::CamelCase => crate::utils::casing::to_camel_case(name),
342 NamingConvention::Preserve => name.to_string(),
343 }
344 }
345
346 pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
383 let mut schema: Self = serde_json::from_str(json)?;
384 schema.build_indexes();
385 Ok(schema)
386 }
387
388 pub fn to_json(&self) -> Result<String, serde_json::Error> {
394 serde_json::to_string(self)
395 }
396
397 pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
403 serde_json::to_string_pretty(self)
404 }
405
406 #[must_use]
408 pub fn find_type(&self, name: &str) -> Option<&TypeDefinition> {
409 self.types.iter().find(|t| t.name == name)
410 }
411
412 #[must_use]
414 pub fn find_enum(&self, name: &str) -> Option<&EnumDefinition> {
415 self.enums.iter().find(|e| e.name == name)
416 }
417
418 #[must_use]
420 pub fn find_input_type(&self, name: &str) -> Option<&InputObjectDefinition> {
421 self.input_types.iter().find(|i| i.name == name)
422 }
423
424 #[must_use]
426 pub fn find_interface(&self, name: &str) -> Option<&InterfaceDefinition> {
427 self.interfaces.iter().find(|i| i.name == name)
428 }
429
430 #[must_use]
432 pub fn find_implementors(&self, interface_name: &str) -> Vec<&TypeDefinition> {
433 self.types
434 .iter()
435 .filter(|t| t.implements.contains(&interface_name.to_string()))
436 .collect()
437 }
438
439 #[must_use]
441 pub fn find_union(&self, name: &str) -> Option<&UnionDefinition> {
442 self.unions.iter().find(|u| u.name == name)
443 }
444
445 #[must_use]
455 pub fn find_query(&self, name: &str) -> Option<&QueryDefinition> {
456 if self.query_index.is_empty() && !self.queries.is_empty() {
457 self.queries.iter().find(|q| q.name == name).or_else(|| {
458 let snake = crate::utils::casing::to_snake_case(name);
459 self.queries.iter().find(|q| q.name == snake)
460 })
461 } else {
462 self.query_index
463 .get(name)
464 .or_else(|| self.query_index.get(&crate::utils::casing::to_snake_case(name)))
465 .map(|&i| &self.queries[i])
466 }
467 }
468
469 #[must_use]
478 pub fn find_mutation(&self, name: &str) -> Option<&MutationDefinition> {
479 if self.mutation_index.is_empty() && !self.mutations.is_empty() {
480 self.mutations.iter().find(|m| m.name == name).or_else(|| {
481 let snake = crate::utils::casing::to_snake_case(name);
482 self.mutations.iter().find(|m| m.name == snake)
483 })
484 } else {
485 self.mutation_index
486 .get(name)
487 .or_else(|| self.mutation_index.get(&crate::utils::casing::to_snake_case(name)))
488 .map(|&i| &self.mutations[i])
489 }
490 }
491
492 #[must_use]
501 pub fn find_subscription(&self, name: &str) -> Option<&SubscriptionDefinition> {
502 if self.subscription_index.is_empty() && !self.subscriptions.is_empty() {
503 self.subscriptions.iter().find(|s| s.name == name).or_else(|| {
504 let snake = crate::utils::casing::to_snake_case(name);
505 self.subscriptions.iter().find(|s| s.name == snake)
506 })
507 } else {
508 self.subscription_index
509 .get(name)
510 .or_else(|| self.subscription_index.get(&crate::utils::casing::to_snake_case(name)))
511 .map(|&i| &self.subscriptions[i])
512 }
513 }
514
515 #[must_use]
517 pub fn find_directive(&self, name: &str) -> Option<&DirectiveDefinition> {
518 self.directives.iter().find(|d| d.name == name)
519 }
520
521 #[must_use]
523 pub const fn operation_count(&self) -> usize {
524 self.queries.len() + self.mutations.len() + self.subscriptions.len()
525 }
526
527 pub fn add_fact_table(&mut self, table_name: String, metadata: FactTableMetadata) {
534 self.fact_tables.insert(table_name, metadata);
535 }
536
537 #[must_use]
547 pub fn get_fact_table(&self, name: &str) -> Option<&FactTableMetadata> {
548 self.fact_tables.get(name)
549 }
550
551 #[must_use]
557 pub fn list_fact_tables(&self) -> Vec<&str> {
558 self.fact_tables.keys().map(String::as_str).collect()
559 }
560
561 #[must_use]
563 pub fn has_fact_tables(&self) -> bool {
564 !self.fact_tables.is_empty()
565 }
566
567 #[must_use]
569 pub fn find_observer(&self, name: &str) -> Option<&ObserverDefinition> {
570 self.observers.iter().find(|o| o.name == name)
571 }
572
573 #[must_use]
575 pub fn find_observers_for_entity(&self, entity: &str) -> Vec<&ObserverDefinition> {
576 self.observers.iter().filter(|o| o.entity == entity).collect()
577 }
578
579 #[must_use]
581 pub fn find_observers_for_event(&self, event: &str) -> Vec<&ObserverDefinition> {
582 self.observers.iter().filter(|o| o.event == event).collect()
583 }
584
585 #[must_use]
587 pub const fn has_observers(&self) -> bool {
588 !self.observers.is_empty()
589 }
590
591 #[must_use]
593 pub const fn observer_count(&self) -> usize {
594 self.observers.len()
595 }
596
597 #[cfg(feature = "federation")]
603 #[must_use]
604 pub fn federation_metadata(&self) -> Option<crate::federation::FederationMetadata> {
605 self.federation.as_ref().filter(|fed| fed.enabled).map(|fed| {
606 let types = fed
607 .entities
608 .iter()
609 .map(|e| crate::federation::types::FederatedType {
610 name: e.name.clone(),
611 keys: vec![crate::federation::types::KeyDirective {
612 fields: e.key_fields.clone(),
613 resolvable: true,
614 }],
615 is_extends: false,
616 external_fields: Vec::new(),
617 shareable_fields: Vec::new(),
618 field_directives: std::collections::HashMap::new(),
619 })
620 .collect();
621
622 crate::federation::FederationMetadata {
623 enabled: fed.enabled,
624 version: fed.version.clone().unwrap_or_else(|| "v2".to_string()),
625 types,
626 }
627 })
628 }
629
630 #[cfg(not(feature = "federation"))]
632 #[must_use]
633 pub const fn federation_metadata(&self) -> Option<()> {
634 None
635 }
636
637 #[must_use]
643 pub const fn security_config(&self) -> Option<&SecurityConfig> {
644 self.security.as_ref()
645 }
646
647 #[must_use]
655 pub fn is_multi_tenant(&self) -> bool {
656 self.security.as_ref().is_some_and(|s| s.multi_tenant)
657 }
658
659 #[must_use]
669 pub fn find_role(&self, role_name: &str) -> Option<RoleDefinition> {
670 self.security.as_ref().and_then(|config| config.find_role(role_name).cloned())
671 }
672
673 #[must_use]
683 pub fn get_role_scopes(&self, role_name: &str) -> Vec<String> {
684 self.security
685 .as_ref()
686 .map(|config| config.get_role_scopes(role_name))
687 .unwrap_or_default()
688 }
689
690 #[must_use]
701 pub fn role_has_scope(&self, role_name: &str, scope: &str) -> bool {
702 self.security
703 .as_ref()
704 .is_some_and(|config| config.role_has_scope(role_name, scope))
705 }
706
707 #[must_use]
730 pub fn content_hash(&self) -> String {
731 use sha2::{Digest, Sha256};
732 let json = self.to_json().expect("CompiledSchema always serialises — BUG if this fails");
733 let digest = Sha256::digest(json.as_bytes());
734 hex::encode(&digest[..16]) }
736
737 #[must_use]
752 pub fn has_rls_configured(&self) -> bool {
753 self.security.as_ref().is_some_and(|s| {
754 !s.additional
755 .get("policies")
756 .and_then(|p: &serde_json::Value| p.as_array())
757 .is_none_or(|a| a.is_empty())
758 })
759 }
760
761 #[must_use]
767 pub fn raw_schema(&self) -> String {
768 self.schema_sdl.clone().unwrap_or_else(|| {
769 let mut sdl = String::new();
771
772 for type_def in &self.types {
774 let _ = writeln!(sdl, "type {} {{", type_def.name);
775 for field in &type_def.fields {
776 let _ = writeln!(sdl, " {}: {}", field.name, field.field_type);
777 }
778 sdl.push_str("}\n\n");
779 }
780
781 sdl
782 })
783 }
784
785 pub fn validate(&self) -> Result<(), Vec<String>> {
796 let mut errors = Vec::new();
797
798 let mut type_names: std::collections::HashSet<&str> = std::collections::HashSet::new();
800 for type_def in &self.types {
801 if !type_names.insert(type_def.name.as_str()) {
802 errors.push(format!("Duplicate type name: {}", type_def.name));
803 }
804 }
805
806 let mut query_names: std::collections::HashSet<&str> = std::collections::HashSet::new();
808 for query in &self.queries {
809 if !query_names.insert(&query.name) {
810 errors.push(format!("Duplicate query name: {}", query.name));
811 }
812 }
813
814 let mut mutation_names: std::collections::HashSet<&str> = std::collections::HashSet::new();
816 for mutation in &self.mutations {
817 if !mutation_names.insert(&mutation.name) {
818 errors.push(format!("Duplicate mutation name: {}", mutation.name));
819 }
820 }
821
822 for query in &self.queries {
824 if !type_names.contains(query.return_type.as_str())
825 && !is_builtin_type(&query.return_type)
826 {
827 errors.push(format!(
828 "Query '{}' references undefined type '{}'",
829 query.name, query.return_type
830 ));
831 }
832 }
833
834 for mutation in &self.mutations {
836 if !type_names.contains(mutation.return_type.as_str())
837 && !is_builtin_type(&mutation.return_type)
838 {
839 errors.push(format!(
840 "Mutation '{}' references undefined type '{}'",
841 mutation.name, mutation.return_type
842 ));
843 }
844 }
845
846 if errors.is_empty() {
847 Ok(())
848 } else {
849 Err(errors)
850 }
851 }
852}
853
854fn is_builtin_type(name: &str) -> bool {
856 matches!(
857 name,
858 "String"
859 | "Int"
860 | "Float"
861 | "Boolean"
862 | "ID"
863 | "DateTime"
864 | "Date"
865 | "Time"
866 | "JSON"
867 | "UUID"
868 | "Decimal"
869 )
870}
871
872#[cfg(test)]
873mod tests {
874 #![allow(clippy::unwrap_used)] use super::*;
876 #[cfg(feature = "federation")]
877 use crate::schema::config_types::FederationEntity;
878 use crate::schema::{
879 config_types::FederationConfig,
880 graphql_type_defs::TypeDefinition,
881 observer_types::ObserverDefinition,
882 security_config::{RoleDefinition, SecurityConfig},
883 };
884
885 fn make_type_def(name: &str) -> TypeDefinition {
890 TypeDefinition {
891 name: name.into(),
892 sql_source: format!("v_{}", name.to_lowercase()).as_str().into(),
893 jsonb_column: "data".to_string(),
894 fields: vec![],
895 description: None,
896 sql_projection_hint: None,
897 implements: vec![],
898 requires_role: None,
899 is_error: false,
900 relay: false,
901 relationships: vec![],
902 }
903 }
904
905 fn make_query(name: &str, return_type: &str) -> QueryDefinition {
906 QueryDefinition::new(name, return_type)
907 }
908
909 fn make_mutation(name: &str, return_type: &str) -> MutationDefinition {
910 MutationDefinition::new(name, return_type)
911 }
912
913 #[test]
918 fn new_returns_empty_schema() {
919 let schema = CompiledSchema::new();
920 assert!(schema.types.is_empty());
921 assert!(schema.queries.is_empty());
922 assert!(schema.mutations.is_empty());
923 assert!(schema.subscriptions.is_empty());
924 assert!(schema.enums.is_empty());
925 assert!(schema.interfaces.is_empty());
926 assert!(schema.unions.is_empty());
927 }
928
929 #[test]
930 fn from_json_empty_array_fields() {
931 let json = r#"{"types":[],"queries":[],"mutations":[],"subscriptions":[]}"#;
932 let schema = CompiledSchema::from_json(json).unwrap();
933 assert_eq!(schema.types.len(), 0);
934 assert_eq!(schema.queries.len(), 0);
935 assert_eq!(schema.mutations.len(), 0);
936 assert_eq!(schema.subscriptions.len(), 0);
937 }
938
939 #[test]
940 fn from_json_minimal_empty_object() {
941 let schema = CompiledSchema::from_json("{}").unwrap();
943 assert!(schema.types.is_empty());
944 assert!(schema.queries.is_empty());
945 }
946
947 #[test]
948 fn from_json_invalid_returns_error() {
949 let result = CompiledSchema::from_json("not json at all");
950 assert!(result.is_err());
951 }
952
953 #[test]
954 fn from_json_builds_query_index() {
955 let json = r#"{
956 "types": [{"name":"User","sql_source":"v_user","fields":[]}],
957 "queries": [{"name":"users","return_type":"User"}],
958 "mutations": [],
959 "subscriptions": []
960 }"#;
961 let schema = CompiledSchema::from_json(json).unwrap();
962 assert!(schema.query_index.contains_key("users"));
963 assert_eq!(schema.query_index["users"], 0);
964 }
965
966 #[test]
967 fn from_json_builds_mutation_index() {
968 let json = r#"{
969 "types": [{"name":"User","sql_source":"v_user","fields":[]}],
970 "mutations": [{"name":"createUser","return_type":"User"}],
971 "queries": [],
972 "subscriptions": []
973 }"#;
974 let schema = CompiledSchema::from_json(json).unwrap();
975 assert!(schema.mutation_index.contains_key("createUser"));
976 }
977
978 #[test]
983 fn to_json_and_back_is_identity() {
984 let mut schema = CompiledSchema::new();
985 schema.schema_format_version = Some(1);
986 let json = schema.to_json().unwrap();
987 let schema2 = CompiledSchema::from_json(&json).unwrap();
988 assert_eq!(schema, schema2);
989 }
990
991 #[test]
992 fn to_json_pretty_is_valid_json() {
993 let schema = CompiledSchema::new();
994 let pretty = schema.to_json_pretty().unwrap();
995 let _: serde_json::Value = serde_json::from_str(&pretty).unwrap();
997 }
998
999 #[test]
1004 fn validate_format_version_none_is_ok() {
1005 let schema = CompiledSchema::new(); assert!(schema.validate_format_version().is_ok());
1007 }
1008
1009 #[test]
1010 fn validate_format_version_current_is_ok() {
1011 let mut schema = CompiledSchema::new();
1012 schema.schema_format_version = Some(CURRENT_SCHEMA_FORMAT_VERSION);
1013 assert!(schema.validate_format_version().is_ok());
1014 }
1015
1016 #[test]
1017 fn validate_format_version_mismatch_is_err() {
1018 let mut schema = CompiledSchema::new();
1019 schema.schema_format_version = Some(CURRENT_SCHEMA_FORMAT_VERSION + 1);
1020 let result = schema.validate_format_version();
1021 assert!(result.is_err());
1022 let msg = result.unwrap_err();
1023 assert!(msg.contains("mismatch"));
1024 }
1025
1026 #[test]
1031 fn build_indexes_populates_all_three_maps() {
1032 let mut schema = CompiledSchema::new();
1033 schema.queries.push(make_query("getUser", "User"));
1034 schema.mutations.push(make_mutation("createUser", "User"));
1035 schema.build_indexes();
1036 assert!(schema.query_index.contains_key("getUser"));
1037 assert!(schema.mutation_index.contains_key("createUser"));
1038 }
1039
1040 #[test]
1041 fn build_indexes_multiple_queries() {
1042 let mut schema = CompiledSchema::new();
1043 schema.queries.push(make_query("alpha", "A"));
1044 schema.queries.push(make_query("beta", "B"));
1045 schema.queries.push(make_query("gamma", "C"));
1046 schema.build_indexes();
1047 assert_eq!(schema.query_index["alpha"], 0);
1048 assert_eq!(schema.query_index["beta"], 1);
1049 assert_eq!(schema.query_index["gamma"], 2);
1050 }
1051
1052 #[test]
1057 fn find_type_returns_none_for_missing() {
1058 let schema = CompiledSchema::new();
1059 assert!(schema.find_type("Ghost").is_none());
1060 }
1061
1062 #[test]
1063 fn find_type_returns_existing() {
1064 let mut schema = CompiledSchema::new();
1065 schema.types.push(make_type_def("User"));
1066 assert!(schema.find_type("User").is_some());
1067 assert_eq!(schema.find_type("User").unwrap().name, "User");
1068 }
1069
1070 #[test]
1071 fn find_query_uses_index_when_populated() {
1072 let json = r#"{
1073 "types": [{"name":"User","sql_source":"v_user","fields":[]}],
1074 "queries": [{"name":"users","return_type":"User"}],
1075 "mutations": [],
1076 "subscriptions": []
1077 }"#;
1078 let schema = CompiledSchema::from_json(json).unwrap();
1079 let q = schema.find_query("users");
1080 assert!(q.is_some());
1081 assert_eq!(q.unwrap().name, "users");
1082 }
1083
1084 #[test]
1085 fn find_query_falls_back_to_linear_scan_without_index() {
1086 let mut schema = CompiledSchema::new();
1088 schema.queries.push(make_query("direct", "String"));
1089 let q = schema.find_query("direct");
1091 assert!(q.is_some());
1092 }
1093
1094 #[test]
1095 fn find_query_returns_none_for_missing() {
1096 let schema = CompiledSchema::from_json("{}").unwrap();
1097 assert!(schema.find_query("nope").is_none());
1098 }
1099
1100 #[test]
1101 fn find_mutation_returns_correct_entry() {
1102 let json = r#"{
1103 "types": [{"name":"User","sql_source":"v_user","fields":[]}],
1104 "mutations": [{"name":"createUser","return_type":"User"}],
1105 "queries": [],
1106 "subscriptions": []
1107 }"#;
1108 let schema = CompiledSchema::from_json(json).unwrap();
1109 assert!(schema.find_mutation("createUser").is_some());
1110 assert!(schema.find_mutation("nope").is_none());
1111 }
1112
1113 #[test]
1114 fn find_interface_returns_none_when_absent() {
1115 let schema = CompiledSchema::new();
1116 assert!(schema.find_interface("Node").is_none());
1117 }
1118
1119 #[test]
1120 fn find_implementors_filters_by_interface() {
1121 let mut schema = CompiledSchema::new();
1122 let mut user = make_type_def("User");
1123 user.implements = vec!["Node".to_string()];
1124 schema.types.push(user);
1125 schema.types.push(make_type_def("Product")); let implementors = schema.find_implementors("Node");
1128 assert_eq!(implementors.len(), 1);
1129 assert_eq!(implementors[0].name, "User");
1130 }
1131
1132 #[test]
1137 fn operation_count_sums_all_three() {
1138 let mut schema = CompiledSchema::new();
1139 schema.queries.push(make_query("q1", "String"));
1140 schema.queries.push(make_query("q2", "String"));
1141 schema.mutations.push(make_mutation("m1", "String"));
1142 assert_eq!(schema.operation_count(), 3);
1143 }
1144
1145 #[test]
1146 fn operation_count_zero_for_empty_schema() {
1147 assert_eq!(CompiledSchema::new().operation_count(), 0);
1148 }
1149
1150 #[test]
1155 fn fact_table_add_and_get() {
1156 use crate::compiler::fact_table::{DimensionColumn, FactTableMetadata};
1157
1158 let mut schema = CompiledSchema::new();
1159 assert!(!schema.has_fact_tables());
1160
1161 let meta = FactTableMetadata {
1162 table_name: "tf_sales".to_string(),
1163 measures: vec![],
1164 dimensions: DimensionColumn {
1165 name: "data".to_string(),
1166 paths: vec![],
1167 },
1168 denormalized_filters: vec![],
1169 calendar_dimensions: vec![],
1170 };
1171 schema.add_fact_table("tf_sales".to_string(), meta);
1172
1173 assert!(schema.has_fact_tables());
1174 assert!(schema.get_fact_table("tf_sales").is_some());
1175 assert!(schema.get_fact_table("tf_missing").is_none());
1176 }
1177
1178 #[test]
1179 fn list_fact_tables_returns_all_names() {
1180 use crate::compiler::fact_table::{DimensionColumn, FactTableMetadata};
1181
1182 let make_meta = |name: &str| FactTableMetadata {
1183 table_name: name.to_string(),
1184 measures: vec![],
1185 dimensions: DimensionColumn {
1186 name: "data".to_string(),
1187 paths: vec![],
1188 },
1189 denormalized_filters: vec![],
1190 calendar_dimensions: vec![],
1191 };
1192
1193 let mut schema = CompiledSchema::new();
1194 schema.add_fact_table("tf_a".to_string(), make_meta("tf_a"));
1195 schema.add_fact_table("tf_b".to_string(), make_meta("tf_b"));
1196
1197 let names = schema.list_fact_tables();
1198 assert_eq!(names.len(), 2);
1199 assert!(names.contains(&"tf_a"));
1200 assert!(names.contains(&"tf_b"));
1201 }
1202
1203 #[test]
1208 fn has_observers_false_for_empty_schema() {
1209 assert!(!CompiledSchema::new().has_observers());
1210 }
1211
1212 #[test]
1213 fn find_observer_returns_by_name() {
1214 let mut schema = CompiledSchema::new();
1215 schema.observers.push(ObserverDefinition::new("onInsert", "Order", "INSERT"));
1216 assert!(schema.find_observer("onInsert").is_some());
1217 assert!(schema.find_observer("missing").is_none());
1218 }
1219
1220 #[test]
1221 fn find_observers_for_entity_filters_correctly() {
1222 let mut schema = CompiledSchema::new();
1223 schema.observers.push(ObserverDefinition::new("obs1", "Order", "INSERT"));
1224 schema.observers.push(ObserverDefinition::new("obs2", "Order", "UPDATE"));
1225 schema.observers.push(ObserverDefinition::new("obs3", "User", "INSERT"));
1226
1227 let order_obs = schema.find_observers_for_entity("Order");
1228 assert_eq!(order_obs.len(), 2);
1229 let user_obs = schema.find_observers_for_entity("User");
1230 assert_eq!(user_obs.len(), 1);
1231 }
1232
1233 #[test]
1234 fn find_observers_for_event_filters_correctly() {
1235 let mut schema = CompiledSchema::new();
1236 schema.observers.push(ObserverDefinition::new("obs1", "Order", "INSERT"));
1237 schema.observers.push(ObserverDefinition::new("obs2", "User", "INSERT"));
1238 schema.observers.push(ObserverDefinition::new("obs3", "Order", "DELETE"));
1239
1240 let inserts = schema.find_observers_for_event("INSERT");
1241 assert_eq!(inserts.len(), 2);
1242 }
1243
1244 #[test]
1245 fn observer_count_matches_vec_length() {
1246 let mut schema = CompiledSchema::new();
1247 assert_eq!(schema.observer_count(), 0);
1248 schema.observers.push(ObserverDefinition::new("o1", "A", "INSERT"));
1249 assert_eq!(schema.observer_count(), 1);
1250 }
1251
1252 #[test]
1257 fn is_multi_tenant_false_by_default() {
1258 assert!(!CompiledSchema::new().is_multi_tenant());
1259 }
1260
1261 #[test]
1262 fn is_multi_tenant_true_when_configured() {
1263 let mut schema = CompiledSchema::new();
1264 let mut sec = SecurityConfig::new();
1265 sec.multi_tenant = true;
1266 schema.security = Some(sec);
1267 assert!(schema.is_multi_tenant());
1268 }
1269
1270 #[test]
1271 fn find_role_returns_none_without_security_config() {
1272 assert!(CompiledSchema::new().find_role("admin").is_none());
1273 }
1274
1275 #[test]
1276 fn find_role_returns_defined_role() {
1277 let mut schema = CompiledSchema::new();
1278 let mut sec = SecurityConfig::new();
1279 sec.add_role(RoleDefinition::new("editor", vec!["read:*".to_string()]));
1280 schema.security = Some(sec);
1281 assert!(schema.find_role("editor").is_some());
1282 }
1283
1284 #[test]
1285 fn role_has_scope_false_without_security() {
1286 assert!(!CompiledSchema::new().role_has_scope("admin", "read:*"));
1287 }
1288
1289 #[test]
1290 fn role_has_scope_true_when_granted() {
1291 let mut schema = CompiledSchema::new();
1292 let mut sec = SecurityConfig::new();
1293 sec.add_role(RoleDefinition::new("admin", vec!["read:*".to_string()]));
1294 schema.security = Some(sec);
1295 assert!(schema.role_has_scope("admin", "read:anything"));
1296 assert!(!schema.role_has_scope("admin", "write:anything"));
1297 }
1298
1299 #[test]
1300 fn get_role_scopes_empty_for_missing_role() {
1301 let schema = CompiledSchema::new();
1302 assert!(schema.get_role_scopes("ghost").is_empty());
1303 }
1304
1305 #[test]
1310 fn federation_metadata_none_when_no_federation() {
1311 assert!(CompiledSchema::new().federation_metadata().is_none());
1312 }
1313
1314 #[test]
1315 fn federation_metadata_none_when_disabled() {
1316 let mut schema = CompiledSchema::new();
1317 schema.federation = Some(FederationConfig {
1318 enabled: false,
1319 ..Default::default()
1320 });
1321 assert!(schema.federation_metadata().is_none());
1322 }
1323
1324 #[test]
1325 #[cfg(feature = "federation")]
1326 fn federation_metadata_some_when_enabled() {
1327 let mut schema = CompiledSchema::new();
1328 schema.federation = Some(FederationConfig {
1329 enabled: true,
1330 version: Some("v2".to_string()),
1331 entities: vec![FederationEntity {
1332 name: "User".to_string(),
1333 key_fields: vec!["id".to_string()],
1334 }],
1335 ..Default::default()
1336 });
1337 let meta = schema.federation_metadata();
1338 assert!(meta.is_some());
1339 let meta = meta.unwrap();
1340 assert!(meta.enabled);
1341 assert_eq!(meta.types.len(), 1);
1342 assert_eq!(meta.types[0].name, "User");
1343 }
1344
1345 #[test]
1350 fn content_hash_is_32_hex_chars() {
1351 let hash = CompiledSchema::new().content_hash();
1352 assert_eq!(hash.len(), 32);
1353 assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
1354 }
1355
1356 #[test]
1357 fn content_hash_is_stable() {
1358 let schema = CompiledSchema::new();
1359 assert_eq!(schema.content_hash(), schema.content_hash());
1360 }
1361
1362 #[test]
1363 fn content_hash_differs_for_different_schemas() {
1364 let s1 = CompiledSchema::new();
1365 let mut s2 = CompiledSchema::new();
1366 s2.schema_format_version = Some(1);
1367 assert_ne!(s1.content_hash(), s2.content_hash());
1368 }
1369
1370 #[test]
1375 fn has_rls_configured_false_without_security() {
1376 assert!(!CompiledSchema::new().has_rls_configured());
1377 }
1378
1379 #[test]
1380 fn has_rls_configured_false_when_policies_empty() {
1381 let mut schema = CompiledSchema::new();
1382 let mut sec = SecurityConfig::new();
1383 sec.additional.insert("policies".to_string(), serde_json::json!([]));
1384 schema.security = Some(sec);
1385 assert!(!schema.has_rls_configured());
1386 }
1387
1388 #[test]
1389 fn has_rls_configured_true_when_policies_present() {
1390 let mut schema = CompiledSchema::new();
1391 let mut sec = SecurityConfig::new();
1392 sec.additional.insert(
1393 "policies".to_string(),
1394 serde_json::json!([{"table": "orders", "using": "tenant_id = current_setting('app.tenant_id')"}]),
1395 );
1396 schema.security = Some(sec);
1397 assert!(schema.has_rls_configured());
1398 }
1399
1400 #[test]
1405 fn validate_empty_schema_is_ok() {
1406 assert!(CompiledSchema::new().validate().is_ok());
1407 }
1408
1409 #[test]
1410 fn validate_detects_duplicate_type_names() {
1411 let mut schema = CompiledSchema::new();
1412 schema.types.push(make_type_def("User"));
1413 schema.types.push(make_type_def("User")); let result = schema.validate();
1415 assert!(result.is_err());
1416 let errors = result.unwrap_err();
1417 assert!(errors.iter().any(|e| e.contains("Duplicate type name")));
1418 }
1419
1420 #[test]
1421 fn validate_detects_duplicate_query_names() {
1422 let mut schema = CompiledSchema::new();
1423 schema.queries.push(make_query("getUser", "String"));
1424 schema.queries.push(make_query("getUser", "String")); let result = schema.validate();
1426 assert!(result.is_err());
1427 let errors = result.unwrap_err();
1428 assert!(errors.iter().any(|e| e.contains("Duplicate query name")));
1429 }
1430
1431 #[test]
1432 fn validate_detects_duplicate_mutation_names() {
1433 let mut schema = CompiledSchema::new();
1434 schema.mutations.push(make_mutation("createUser", "String"));
1435 schema.mutations.push(make_mutation("createUser", "String")); let result = schema.validate();
1437 assert!(result.is_err());
1438 }
1439
1440 #[test]
1441 fn validate_undefined_return_type_in_query_is_error() {
1442 let mut schema = CompiledSchema::new();
1443 schema.queries.push(make_query("getWidget", "Widget"));
1445 let result = schema.validate();
1446 assert!(result.is_err());
1447 let errors = result.unwrap_err();
1448 assert!(errors.iter().any(|e| e.contains("Widget")));
1449 }
1450
1451 #[test]
1452 fn validate_builtin_scalar_return_type_is_ok() {
1453 let mut schema = CompiledSchema::new();
1454 schema.queries.push(make_query("ping", "String"));
1455 schema.queries.push(make_query("count", "Int"));
1456 assert!(schema.validate().is_ok());
1457 }
1458
1459 #[test]
1460 fn validate_defined_type_as_return_type_is_ok() {
1461 let mut schema = CompiledSchema::new();
1462 schema.types.push(make_type_def("User"));
1463 schema.queries.push(make_query("getUser", "User"));
1464 assert!(schema.validate().is_ok());
1465 }
1466
1467 #[test]
1472 fn raw_schema_returns_sdl_when_set() {
1473 let mut schema = CompiledSchema::new();
1474 schema.schema_sdl = Some("type Query { ping: String }".to_string());
1475 assert_eq!(schema.raw_schema(), "type Query { ping: String }");
1476 }
1477
1478 #[test]
1479 fn raw_schema_generates_from_types_when_sdl_absent() {
1480 let mut schema = CompiledSchema::new();
1481 schema.types.push(make_type_def("User"));
1482 let sdl = schema.raw_schema();
1483 assert!(sdl.contains("User"));
1484 }
1485
1486 #[test]
1491 fn builtin_scalar_types_pass_validation() {
1492 let scalars = [
1493 "String", "Int", "Float", "Boolean", "ID", "DateTime", "Date", "Time", "JSON", "UUID",
1494 "Decimal",
1495 ];
1496 for scalar in scalars {
1497 let mut schema = CompiledSchema::new();
1498 schema.queries.push(make_query("q", scalar));
1499 assert!(schema.validate().is_ok(), "{scalar} should be a recognised built-in");
1500 }
1501 }
1502
1503 #[test]
1504 fn unknown_scalar_fails_validation() {
1505 let mut schema = CompiledSchema::new();
1506 schema.queries.push(make_query("q", "Blob"));
1507 assert!(schema.validate().is_err());
1508 }
1509
1510 #[test]
1513 fn find_query_exact_match() {
1514 let mut schema = CompiledSchema::new();
1515 schema.types.push(make_type_def("User"));
1516 schema.queries.push(make_query("users", "User"));
1517 schema.build_indexes();
1518 assert!(schema.find_query("users").is_some());
1519 }
1520
1521 #[test]
1522 fn find_query_camel_to_snake_fallback() {
1523 let mut schema = CompiledSchema::new();
1524 schema.types.push(make_type_def("DnsServer"));
1525 schema.queries.push(make_query("dns_servers", "DnsServer"));
1526 schema.build_indexes();
1527 assert!(schema.find_query("dns_servers").is_some());
1529 assert!(schema.find_query("dnsServers").is_some());
1531 }
1532
1533 #[test]
1534 fn find_query_camel_to_snake_fallback_without_index() {
1535 let mut schema = CompiledSchema::new();
1536 schema.types.push(make_type_def("DnsServer"));
1537 schema.queries.push(make_query("dns_servers", "DnsServer"));
1538 assert!(schema.find_query("dnsServers").is_some());
1540 }
1541
1542 #[test]
1543 fn find_mutation_camel_to_snake_fallback() {
1544 let mut schema = CompiledSchema::new();
1545 schema.types.push(make_type_def("Location"));
1546 schema.mutations.push(make_mutation("create_location", "Location"));
1547 schema.build_indexes();
1548 assert!(schema.find_mutation("createLocation").is_some());
1549 }
1550
1551 #[test]
1552 fn find_query_returns_none_for_unknown() {
1553 let mut schema = CompiledSchema::new();
1554 schema.types.push(make_type_def("User"));
1555 schema.queries.push(make_query("users", "User"));
1556 schema.build_indexes();
1557 assert!(schema.find_query("nonexistent").is_none());
1558 }
1559
1560 #[test]
1563 fn display_name_preserve_returns_unchanged() {
1564 let schema = CompiledSchema::new(); assert_eq!(schema.display_name("create_dns_server"), "create_dns_server");
1566 assert_eq!(schema.display_name("dns_servers"), "dns_servers");
1567 }
1568
1569 #[test]
1570 fn display_name_camel_case_converts() {
1571 let mut schema = CompiledSchema::new();
1572 schema.naming_convention = NamingConvention::CamelCase;
1573 assert_eq!(schema.display_name("create_dns_server"), "createDnsServer");
1574 assert_eq!(schema.display_name("dns_servers"), "dnsServers");
1575 assert_eq!(schema.display_name("delete_outreach_sequence"), "deleteOutreachSequence");
1576 }
1577
1578 #[test]
1579 fn camel_case_index_lookup() {
1580 let mut schema = CompiledSchema::new();
1581 schema.naming_convention = NamingConvention::CamelCase;
1582 schema.types.push(make_type_def("DnsServer"));
1583 schema.queries.push(make_query("dns_servers", "DnsServer"));
1584 schema.mutations.push(make_mutation("create_dns_server", "DnsServer"));
1585 schema.build_indexes();
1586
1587 assert!(schema.find_query("dnsServers").is_some());
1589 assert!(schema.find_mutation("createDnsServer").is_some());
1590
1591 assert!(schema.find_query("dns_servers").is_some());
1593 assert!(schema.find_mutation("create_dns_server").is_some());
1594 }
1595
1596 #[test]
1597 fn preserve_convention_no_camel_index_entry() {
1598 let mut schema = CompiledSchema::new(); schema.types.push(make_type_def("DnsServer"));
1600 schema.queries.push(make_query("dns_servers", "DnsServer"));
1601 schema.build_indexes();
1602
1603 assert_eq!(schema.query_index.len(), 1);
1605 assert!(schema.query_index.contains_key("dns_servers"));
1606 }
1607
1608 #[test]
1609 fn naming_convention_serde_in_compiled_schema() {
1610 let mut schema = CompiledSchema::new();
1611 schema.naming_convention = NamingConvention::CamelCase;
1612 let json = schema.to_json().unwrap();
1613 let restored = CompiledSchema::from_json(&json).unwrap();
1614 assert_eq!(restored.naming_convention, NamingConvention::CamelCase);
1615 }
1616}