Skip to main content

fraiseql_core/schema/compiled/
schema.rs

1//! Compiled schema types - pure Rust, no authoring-language references.
2//!
3//! These types represent GraphQL schemas after compilation from authoring languages.
4//! All data is owned by Rust - no foreign object references.
5//!
6//! # Schema Freeze Invariant
7//!
8//! After `CompiledSchema::from_json()`, the schema is frozen:
9//! - All data is Rust-owned
10//! - No authoring-language callbacks or object references
11//! - Safe to use from any Tokio worker thread
12//!
13//! This enables the Axum server to handle requests without any
14//! interaction with the authoring-language runtime.
15
16use 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
40/// Current schema format version.
41///
42/// Increment this constant when the compiled schema JSON format changes in a
43/// backward-incompatible way so that startup rejects stale compiled schemas.
44pub const CURRENT_SCHEMA_FORMAT_VERSION: u32 = 1;
45
46/// Complete compiled schema - all type information for serving.
47///
48/// This is the central type that holds the entire GraphQL schema
49/// after compilation from any supported authoring language.
50///
51/// # Example
52///
53/// ```
54/// use fraiseql_core::schema::CompiledSchema;
55///
56/// let json = r#"{
57///     "types": [],
58///     "queries": [],
59///     "mutations": [],
60///     "subscriptions": []
61/// }"#;
62///
63/// let schema = CompiledSchema::from_json(json).unwrap();
64/// assert_eq!(schema.types.len(), 0);
65/// ```
66#[derive(Debug, Clone, Default, Serialize, Deserialize)]
67pub struct CompiledSchema {
68    /// GraphQL object type definitions.
69    #[serde(default)]
70    pub types: Vec<TypeDefinition>,
71
72    /// GraphQL enum type definitions.
73    #[serde(default)]
74    pub enums: Vec<EnumDefinition>,
75
76    /// GraphQL input object type definitions.
77    #[serde(default)]
78    pub input_types: Vec<InputObjectDefinition>,
79
80    /// GraphQL interface type definitions.
81    #[serde(default)]
82    pub interfaces: Vec<InterfaceDefinition>,
83
84    /// GraphQL union type definitions.
85    #[serde(default)]
86    pub unions: Vec<UnionDefinition>,
87
88    /// GraphQL query definitions.
89    #[serde(default)]
90    pub queries: Vec<QueryDefinition>,
91
92    /// GraphQL mutation definitions.
93    #[serde(default)]
94    pub mutations: Vec<MutationDefinition>,
95
96    /// GraphQL subscription definitions.
97    #[serde(default)]
98    pub subscriptions: Vec<SubscriptionDefinition>,
99
100    /// Custom directive definitions.
101    /// These are user-defined directives beyond the built-in @skip, @include, @deprecated.
102    #[serde(default, skip_serializing_if = "Vec::is_empty")]
103    pub directives: Vec<DirectiveDefinition>,
104
105    /// Fact table metadata (for analytics queries).
106    /// Key: table name (e.g., `tf_sales`)
107    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
108    pub fact_tables: HashMap<String, FactTableMetadata>,
109
110    /// Observer definitions (database change event listeners).
111    #[serde(default, skip_serializing_if = "Vec::is_empty")]
112    pub observers: Vec<ObserverDefinition>,
113
114    /// Federation metadata for Apollo Federation v2 support.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub federation: Option<FederationConfig>,
117
118    /// Security configuration (from fraiseql.toml).
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub security: Option<SecurityConfig>,
121
122    /// Observers/event system configuration (from fraiseql.toml).
123    ///
124    /// Contains backend connection settings (`redis_url`, `nats_url`, etc.) and
125    /// event handler definitions compiled from the `[observers]` TOML section.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub observers_config: Option<ObserversConfig>,
128
129    /// `WebSocket` subscription configuration (hooks, limits).
130    /// Compiled from the `[subscriptions]` TOML section.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub subscriptions_config: Option<SubscriptionsConfig>,
133
134    /// Query validation config (depth/complexity limits).
135    /// Compiled from the `[validation]` TOML section.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub validation_config: Option<ValidationConfig>,
138
139    /// Debug/development configuration.
140    /// Compiled from the `[debug]` TOML section.
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub debug_config: Option<DebugConfig>,
143
144    /// MCP (Model Context Protocol) server configuration.
145    /// Compiled from the `[mcp]` TOML section.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub mcp_config: Option<McpConfig>,
148
149    /// REST transport configuration.
150    /// Compiled from the `[rest]` TOML section.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub rest_config: Option<RestConfig>,
153
154    /// gRPC transport configuration.
155    /// Compiled from the `[grpc]` TOML section.
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub grpc_config: Option<GrpcConfig>,
158
159    /// Session variable injection configuration.
160    ///
161    /// When populated, the executor calls PostgreSQL `set_config()` before each
162    /// mutation, injecting per-request values (JWT claims, HTTP headers, literals)
163    /// as transaction-scoped settings.  SQL functions read these via
164    /// `current_setting('app.tenant_id', true)`.
165    ///
166    /// Compiled from the `[session_variables]` TOML section.
167    #[serde(default)]
168    pub session_variables: SessionVariablesConfig,
169
170    /// Naming convention for GraphQL operation names.
171    ///
172    /// When set to `CamelCase`, operation names are converted from `snake_case`
173    /// (e.g., `create_dns_server` → `createDnsServer`) in the introspection
174    /// schema and lookup indexes. Compiled from `[fraiseql]` in `fraiseql.toml`.
175    #[serde(default)]
176    pub naming_convention: NamingConvention,
177
178    /// Schema format version emitted by the compiler.
179    ///
180    /// Used to detect runtime/compiler skew. If present and ≠ `CURRENT_SCHEMA_FORMAT_VERSION`,
181    /// `validate_format_version()` returns an error.
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub schema_format_version: Option<u32>,
184
185    /// Raw GraphQL schema as string (for SDL generation).
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub schema_sdl: Option<String>,
188
189    /// Custom scalar type registry.
190    ///
191    /// Contains definitions for custom scalar types defined in the schema.
192    /// Built during code generation from `IRScalar` definitions.
193    /// Not serialized - populated at runtime from `ir.scalars`.
194    #[serde(skip)]
195    pub custom_scalars: CustomTypeRegistry,
196
197    /// O(1) lookup index: query name → index into `self.queries`.
198    /// Built at construction time by `build_indexes()`; not serialized.
199    /// Populated automatically by `from_json()`; call `build_indexes()` after
200    /// direct mutation of `self.queries`.
201    #[serde(skip)]
202    pub query_index: HashMap<String, usize>,
203
204    /// O(1) lookup index: mutation name → index into `self.mutations`.
205    /// Built at construction time by `build_indexes()`; not serialized.
206    /// Populated automatically by `from_json()`; call `build_indexes()` after
207    /// direct mutation of `self.mutations`.
208    #[serde(skip)]
209    pub mutation_index: HashMap<String, usize>,
210
211    /// O(1) lookup index: subscription name → index into `self.subscriptions`.
212    /// Built at construction time by `build_indexes()`; not serialized.
213    /// Populated automatically by `from_json()`; call `build_indexes()` after
214    /// direct mutation of `self.subscriptions`.
215    #[serde(skip)]
216    pub subscription_index: HashMap<String, usize>,
217}
218
219impl PartialEq for CompiledSchema {
220    fn eq(&self, other: &Self) -> bool {
221        // Compare all fields except custom_scalars (runtime state)
222        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    /// Create empty schema.
248    #[must_use]
249    pub fn new() -> Self {
250        Self::default()
251    }
252
253    /// Verify that the compiled schema was produced by a compatible compiler version.
254    ///
255    /// Schemas without a `schema_format_version` field (produced before v2.1) are
256    /// accepted with a warning. Schemas with a mismatched version are rejected to
257    /// prevent silent data corruption from structural changes.
258    ///
259    /// # Errors
260    ///
261    /// Returns an error string if the version is present and incompatible.
262    pub fn validate_format_version(&self) -> Result<(), String> {
263        match self.schema_format_version {
264            None => {
265                // Pre-versioning schema — accept but callers may want to warn.
266                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    /// Build O(1) lookup indexes for queries, mutations, and subscriptions.
278    ///
279    /// Called automatically by `from_json()`. Must be called manually after any
280    /// direct mutation of `self.queries`, `self.mutations`, or `self.subscriptions`.
281    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    /// Return the display name for an operation, applying the naming convention.
334    ///
335    /// When `naming_convention` is `CamelCase`, converts `snake_case` names to
336    /// `camelCase` (e.g., `create_dns_server` → `createDnsServer`).
337    /// When `Preserve`, returns the name unchanged.
338    #[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    /// Deserialize from JSON string.
347    ///
348    /// This is the primary way to create a schema from any authoring language.
349    /// The authoring language emits `schema.json`; `fraiseql-cli compile` produces
350    /// `schema.compiled.json`; Rust deserializes and owns the result.
351    ///
352    /// # Integrity Checking
353    ///
354    /// When `fraiseql-cli compile` embeds a `_content_hash` field in the compiled JSON,
355    /// the runtime should verify it against `content_hash()` before accepting the schema.
356    /// This guards against accidental corruption or tampering between compilation and
357    /// deployment. The check is not performed here because `_content_hash` is not yet
358    /// written by the CLI; once it is, add a post-deserialization step:
359    ///
360    /// ```rust,ignore
361    /// let schema = CompiledSchema::from_json(json)?;
362    /// if let Some(expected) = &schema._content_hash {
363    ///     let actual = schema.content_hash();
364    ///     if *expected != actual {
365    ///         return Err(IntegrityError::HashMismatch { expected, actual });
366    ///     }
367    /// }
368    /// ```
369    ///
370    /// # Errors
371    ///
372    /// Returns error if JSON is malformed or doesn't match schema structure.
373    ///
374    /// # Example
375    ///
376    /// ```
377    /// use fraiseql_core::schema::CompiledSchema;
378    ///
379    /// let json = r#"{"types": [], "queries": [], "mutations": [], "subscriptions": []}"#;
380    /// let schema = CompiledSchema::from_json(json).unwrap();
381    /// ```
382    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    /// Serialize to JSON string.
389    ///
390    /// # Errors
391    ///
392    /// Returns error if serialization fails (should not happen for valid schema).
393    pub fn to_json(&self) -> Result<String, serde_json::Error> {
394        serde_json::to_string(self)
395    }
396
397    /// Serialize to pretty JSON string (for debugging/config files).
398    ///
399    /// # Errors
400    ///
401    /// Returns error if serialization fails.
402    pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
403        serde_json::to_string_pretty(self)
404    }
405
406    /// Find a type definition by name.
407    #[must_use]
408    pub fn find_type(&self, name: &str) -> Option<&TypeDefinition> {
409        self.types.iter().find(|t| t.name == name)
410    }
411
412    /// Find an enum definition by name.
413    #[must_use]
414    pub fn find_enum(&self, name: &str) -> Option<&EnumDefinition> {
415        self.enums.iter().find(|e| e.name == name)
416    }
417
418    /// Find an input object definition by name.
419    #[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    /// Find an interface definition by name.
425    #[must_use]
426    pub fn find_interface(&self, name: &str) -> Option<&InterfaceDefinition> {
427        self.interfaces.iter().find(|i| i.name == name)
428    }
429
430    /// Find all types that implement a given interface.
431    #[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    /// Find a union definition by name.
440    #[must_use]
441    pub fn find_union(&self, name: &str) -> Option<&UnionDefinition> {
442        self.unions.iter().find(|u| u.name == name)
443    }
444
445    /// Find a query definition by name.
446    ///
447    /// Uses the O(1) pre-built index when available; falls back to O(n) linear
448    /// scan for schemas built directly in tests without calling `build_indexes()`.
449    ///
450    /// If the exact name is not found, retries with `to_snake_case(name)` to
451    /// handle camelCase → `snake_case` normalization (e.g. `dnsServers` →
452    /// `dns_servers`). This supports schemas compiled before the SDK camelCase
453    /// migration.
454    #[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    /// Find a mutation definition by name.
470    ///
471    /// Uses the O(1) pre-built index when available; falls back to O(n) linear
472    /// scan for schemas built directly in tests without calling `build_indexes()`.
473    ///
474    /// If the exact name is not found, retries with `to_snake_case(name)` to
475    /// handle camelCase → `snake_case` normalization. This supports schemas
476    /// compiled before the SDK camelCase migration.
477    #[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    /// Find a subscription definition by name.
493    ///
494    /// Uses the O(1) pre-built index when available; falls back to O(n) linear
495    /// scan for schemas built directly in tests without calling `build_indexes()`.
496    ///
497    /// If the exact name is not found, retries with `to_snake_case(name)` to
498    /// handle camelCase → `snake_case` normalization. This supports schemas
499    /// compiled before the SDK camelCase migration.
500    #[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    /// Find a custom directive definition by name.
516    #[must_use]
517    pub fn find_directive(&self, name: &str) -> Option<&DirectiveDefinition> {
518        self.directives.iter().find(|d| d.name == name)
519    }
520
521    /// Get total number of operations (queries + mutations + subscriptions).
522    #[must_use]
523    pub const fn operation_count(&self) -> usize {
524        self.queries.len() + self.mutations.len() + self.subscriptions.len()
525    }
526
527    /// Register fact table metadata.
528    ///
529    /// # Arguments
530    ///
531    /// * `table_name` - Fact table name (e.g., `tf_sales`)
532    /// * `metadata` - Typed `FactTableMetadata`
533    pub fn add_fact_table(&mut self, table_name: String, metadata: FactTableMetadata) {
534        self.fact_tables.insert(table_name, metadata);
535    }
536
537    /// Get fact table metadata by name.
538    ///
539    /// # Arguments
540    ///
541    /// * `name` - Fact table name
542    ///
543    /// # Returns
544    ///
545    /// Fact table metadata if found
546    #[must_use]
547    pub fn get_fact_table(&self, name: &str) -> Option<&FactTableMetadata> {
548        self.fact_tables.get(name)
549    }
550
551    /// List all fact table names.
552    ///
553    /// # Returns
554    ///
555    /// Vector of fact table names
556    #[must_use]
557    pub fn list_fact_tables(&self) -> Vec<&str> {
558        self.fact_tables.keys().map(String::as_str).collect()
559    }
560
561    /// Check if schema contains any fact tables.
562    #[must_use]
563    pub fn has_fact_tables(&self) -> bool {
564        !self.fact_tables.is_empty()
565    }
566
567    /// Find an observer definition by name.
568    #[must_use]
569    pub fn find_observer(&self, name: &str) -> Option<&ObserverDefinition> {
570        self.observers.iter().find(|o| o.name == name)
571    }
572
573    /// Get all observers for a specific entity type.
574    #[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    /// Get all observers for a specific event type (INSERT, UPDATE, DELETE).
580    #[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    /// Check if schema contains any observers.
586    #[must_use]
587    pub const fn has_observers(&self) -> bool {
588        !self.observers.is_empty()
589    }
590
591    /// Get total number of observers.
592    #[must_use]
593    pub const fn observer_count(&self) -> usize {
594        self.observers.len()
595    }
596
597    /// Get federation metadata from schema.
598    ///
599    /// # Returns
600    ///
601    /// Federation metadata if configured in schema
602    #[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    /// Stub federation metadata when federation feature is disabled.
631    #[cfg(not(feature = "federation"))]
632    #[must_use]
633    pub const fn federation_metadata(&self) -> Option<()> {
634        None
635    }
636
637    /// Get security configuration from schema.
638    ///
639    /// # Returns
640    ///
641    /// Security configuration if present (includes role definitions)
642    #[must_use]
643    pub const fn security_config(&self) -> Option<&SecurityConfig> {
644        self.security.as_ref()
645    }
646
647    /// Returns `true` if this schema declares a multi-tenant deployment.
648    ///
649    /// Multi-tenant schemas require Row-Level Security (RLS) to be active whenever
650    /// query result caching is enabled. Without RLS, all tenants sharing the same
651    /// query parameters would receive the same cached response.
652    ///
653    /// Detection is based on `security.multi_tenant` in the compiled schema JSON.
654    #[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    /// Find a role definition by name.
660    ///
661    /// # Arguments
662    ///
663    /// * `role_name` - Name of the role to find
664    ///
665    /// # Returns
666    ///
667    /// Role definition if found
668    #[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    /// Get scopes for a role.
674    ///
675    /// # Arguments
676    ///
677    /// * `role_name` - Name of the role
678    ///
679    /// # Returns
680    ///
681    /// Vector of scopes granted to the role
682    #[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    /// Check if a role has a specific scope.
691    ///
692    /// # Arguments
693    ///
694    /// * `role_name` - Name of the role
695    /// * `scope` - Scope to check for
696    ///
697    /// # Returns
698    ///
699    /// true if role has the scope, false otherwise
700    #[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    /// Returns a 32-character hex SHA-256 content hash of this schema's canonical JSON.
708    ///
709    /// Use as `schema_version` when constructing `CachedDatabaseAdapter` to guarantee
710    /// cache invalidation on any schema change, regardless of whether the package
711    /// version was bumped.
712    ///
713    /// Two schemas that differ by even one field will produce different hashes.
714    /// The same schema serialised twice always produces the same hash (stable).
715    ///
716    /// # Panics
717    ///
718    /// Does not panic — `CompiledSchema` always serialises to valid JSON.
719    ///
720    /// # Example
721    ///
722    /// ```
723    /// use fraiseql_core::schema::CompiledSchema;
724    ///
725    /// let schema = CompiledSchema::default();
726    /// let hash = schema.content_hash();
727    /// assert_eq!(hash.len(), 32); // 16 bytes → 32 hex chars
728    /// ```
729    #[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]) // 32 hex chars — sufficient collision resistance
735    }
736
737    /// Returns `true` if Row-Level Security policies are declared in this schema.
738    ///
739    /// Used at server startup to validate that caching is safe for multi-tenant
740    /// deployments. When caching is enabled and no RLS policies are configured,
741    /// the server emits a startup warning about potential data leakage.
742    ///
743    /// # Example
744    ///
745    /// ```
746    /// use fraiseql_core::schema::CompiledSchema;
747    ///
748    /// let schema = CompiledSchema::default();
749    /// assert!(!schema.has_rls_configured());
750    /// ```
751    #[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    /// Get raw GraphQL schema SDL.
762    ///
763    /// # Returns
764    ///
765    /// Raw schema string if available, otherwise generates from type definitions
766    #[must_use]
767    pub fn raw_schema(&self) -> String {
768        self.schema_sdl.clone().unwrap_or_else(|| {
769            // Generate basic SDL from type definitions if not provided
770            let mut sdl = String::new();
771
772            // Add types
773            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    /// Validate the schema for internal consistency.
786    ///
787    /// Checks:
788    /// - All type references resolve to defined types
789    /// - No duplicate type/operation names
790    /// - Required fields have valid types
791    ///
792    /// # Errors
793    ///
794    /// Returns list of validation errors if schema is invalid.
795    pub fn validate(&self) -> Result<(), Vec<String>> {
796        let mut errors = Vec::new();
797
798        // Check for duplicate type names
799        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        // Check for duplicate query names
807        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        // Check for duplicate mutation names
815        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        // Check type references in queries
823        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        // Check type references in mutations
835        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
854/// Check if a type name is a built-in scalar type.
855fn 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)] // Reason: test code, panics are acceptable assertions
875    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    // -------------------------------------------------------------------------
886    // Helpers
887    // -------------------------------------------------------------------------
888
889    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    // -------------------------------------------------------------------------
914    // Constructor behaviour
915    // -------------------------------------------------------------------------
916
917    #[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        // All fields have #[serde(default)] — an empty JSON object is valid
942        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    // -------------------------------------------------------------------------
979    // Serialization round-trip
980    // -------------------------------------------------------------------------
981
982    #[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        // Should re-parse without error
996        let _: serde_json::Value = serde_json::from_str(&pretty).unwrap();
997    }
998
999    // -------------------------------------------------------------------------
1000    // Format version
1001    // -------------------------------------------------------------------------
1002
1003    #[test]
1004    fn validate_format_version_none_is_ok() {
1005        let schema = CompiledSchema::new(); // schema_format_version = None
1006        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    // -------------------------------------------------------------------------
1027    // Index building
1028    // -------------------------------------------------------------------------
1029
1030    #[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    // -------------------------------------------------------------------------
1053    // Finder methods
1054    // -------------------------------------------------------------------------
1055
1056    #[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        // Build schema directly without calling build_indexes
1087        let mut schema = CompiledSchema::new();
1088        schema.queries.push(make_query("direct", "String"));
1089        // query_index is empty but queries is not — should fall back to linear scan
1090        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")); // does not implement Node
1126
1127        let implementors = schema.find_implementors("Node");
1128        assert_eq!(implementors.len(), 1);
1129        assert_eq!(implementors[0].name, "User");
1130    }
1131
1132    // -------------------------------------------------------------------------
1133    // operation_count
1134    // -------------------------------------------------------------------------
1135
1136    #[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    // -------------------------------------------------------------------------
1151    // Fact tables
1152    // -------------------------------------------------------------------------
1153
1154    #[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    // -------------------------------------------------------------------------
1204    // Observers
1205    // -------------------------------------------------------------------------
1206
1207    #[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    // -------------------------------------------------------------------------
1253    // Security helpers
1254    // -------------------------------------------------------------------------
1255
1256    #[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    // -------------------------------------------------------------------------
1306    // Federation metadata
1307    // -------------------------------------------------------------------------
1308
1309    #[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    // -------------------------------------------------------------------------
1346    // content_hash
1347    // -------------------------------------------------------------------------
1348
1349    #[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    // -------------------------------------------------------------------------
1371    // has_rls_configured
1372    // -------------------------------------------------------------------------
1373
1374    #[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    // -------------------------------------------------------------------------
1401    // validate()
1402    // -------------------------------------------------------------------------
1403
1404    #[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")); // duplicate
1414        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")); // duplicate
1425        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")); // duplicate
1436        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        // No "Widget" type defined
1444        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    // -------------------------------------------------------------------------
1468    // raw_schema
1469    // -------------------------------------------------------------------------
1470
1471    #[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    // -------------------------------------------------------------------------
1487    // is_builtin_type (private fn — tested via validate())
1488    // -------------------------------------------------------------------------
1489
1490    #[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    // ── Operation name normalization (issue #199) ────────────────────────
1511
1512    #[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        // Exact match works
1528        assert!(schema.find_query("dns_servers").is_some());
1529        // camelCase fallback also works
1530        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        // No build_indexes() — exercises the linear scan fallback path
1539        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    // ── NamingConvention (issue #216) ───────────────────────────────────
1561
1562    #[test]
1563    fn display_name_preserve_returns_unchanged() {
1564        let schema = CompiledSchema::new(); // default = Preserve
1565        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        // camelCase lookup via index
1588        assert!(schema.find_query("dnsServers").is_some());
1589        assert!(schema.find_mutation("createDnsServer").is_some());
1590
1591        // Original snake_case still works
1592        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(); // Preserve
1599        schema.types.push(make_type_def("DnsServer"));
1600        schema.queries.push(make_query("dns_servers", "DnsServer"));
1601        schema.build_indexes();
1602
1603        // Only 1 index entry (the original name); camelCase only works via fallback
1604        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}