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, ObserversConfig, RestConfig,
26            SubscriptionsConfig, ValidationConfig,
27        },
28        graphql_type_defs::{
29            EnumDefinition, InputObjectDefinition, InterfaceDefinition, TypeDefinition,
30            UnionDefinition,
31        },
32        observer_types::ObserverDefinition,
33        security_config::{RoleDefinition, SecurityConfig},
34        subscription_types::SubscriptionDefinition,
35    },
36    validation::CustomTypeRegistry,
37};
38
39/// Current schema format version.
40///
41/// Increment this constant when the compiled schema JSON format changes in a
42/// backward-incompatible way so that startup rejects stale compiled schemas.
43pub const CURRENT_SCHEMA_FORMAT_VERSION: u32 = 1;
44
45/// Complete compiled schema - all type information for serving.
46///
47/// This is the central type that holds the entire GraphQL schema
48/// after compilation from any supported authoring language.
49///
50/// # Example
51///
52/// ```
53/// use fraiseql_core::schema::CompiledSchema;
54///
55/// let json = r#"{
56///     "types": [],
57///     "queries": [],
58///     "mutations": [],
59///     "subscriptions": []
60/// }"#;
61///
62/// let schema = CompiledSchema::from_json(json).unwrap();
63/// assert_eq!(schema.types.len(), 0);
64/// ```
65#[derive(Debug, Clone, Default, Serialize, Deserialize)]
66pub struct CompiledSchema {
67    /// GraphQL object type definitions.
68    #[serde(default)]
69    pub types: Vec<TypeDefinition>,
70
71    /// GraphQL enum type definitions.
72    #[serde(default)]
73    pub enums: Vec<EnumDefinition>,
74
75    /// GraphQL input object type definitions.
76    #[serde(default)]
77    pub input_types: Vec<InputObjectDefinition>,
78
79    /// GraphQL interface type definitions.
80    #[serde(default)]
81    pub interfaces: Vec<InterfaceDefinition>,
82
83    /// GraphQL union type definitions.
84    #[serde(default)]
85    pub unions: Vec<UnionDefinition>,
86
87    /// GraphQL query definitions.
88    #[serde(default)]
89    pub queries: Vec<QueryDefinition>,
90
91    /// GraphQL mutation definitions.
92    #[serde(default)]
93    pub mutations: Vec<MutationDefinition>,
94
95    /// GraphQL subscription definitions.
96    #[serde(default)]
97    pub subscriptions: Vec<SubscriptionDefinition>,
98
99    /// Custom directive definitions.
100    /// These are user-defined directives beyond the built-in @skip, @include, @deprecated.
101    #[serde(default, skip_serializing_if = "Vec::is_empty")]
102    pub directives: Vec<DirectiveDefinition>,
103
104    /// Fact table metadata (for analytics queries).
105    /// Key: table name (e.g., `tf_sales`)
106    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
107    pub fact_tables: HashMap<String, FactTableMetadata>,
108
109    /// Observer definitions (database change event listeners).
110    #[serde(default, skip_serializing_if = "Vec::is_empty")]
111    pub observers: Vec<ObserverDefinition>,
112
113    /// Federation metadata for Apollo Federation v2 support.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub federation: Option<FederationConfig>,
116
117    /// Security configuration (from fraiseql.toml).
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub security: Option<SecurityConfig>,
120
121    /// Observers/event system configuration (from fraiseql.toml).
122    ///
123    /// Contains backend connection settings (`redis_url`, `nats_url`, etc.) and
124    /// event handler definitions compiled from the `[observers]` TOML section.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub observers_config: Option<ObserversConfig>,
127
128    /// `WebSocket` subscription configuration (hooks, limits).
129    /// Compiled from the `[subscriptions]` TOML section.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub subscriptions_config: Option<SubscriptionsConfig>,
132
133    /// Query validation config (depth/complexity limits).
134    /// Compiled from the `[validation]` TOML section.
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub validation_config: Option<ValidationConfig>,
137
138    /// Debug/development configuration.
139    /// Compiled from the `[debug]` TOML section.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub debug_config: Option<DebugConfig>,
142
143    /// MCP (Model Context Protocol) server configuration.
144    /// Compiled from the `[mcp]` TOML section.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub mcp_config: Option<McpConfig>,
147
148    /// REST transport configuration.
149    /// Compiled from the `[rest]` TOML section.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub rest_config: Option<RestConfig>,
152
153    /// gRPC transport configuration.
154    /// Compiled from the `[grpc]` TOML section.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub grpc_config: Option<GrpcConfig>,
157
158    /// Schema format version emitted by the compiler.
159    ///
160    /// Used to detect runtime/compiler skew. If present and ≠ `CURRENT_SCHEMA_FORMAT_VERSION`,
161    /// `validate_format_version()` returns an error.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub schema_format_version: Option<u32>,
164
165    /// Raw GraphQL schema as string (for SDL generation).
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub schema_sdl: Option<String>,
168
169    /// Custom scalar type registry.
170    ///
171    /// Contains definitions for custom scalar types defined in the schema.
172    /// Built during code generation from `IRScalar` definitions.
173    /// Not serialized - populated at runtime from `ir.scalars`.
174    #[serde(skip)]
175    pub custom_scalars: CustomTypeRegistry,
176
177    /// O(1) lookup index: query name → index into `self.queries`.
178    /// Built at construction time by `build_indexes()`; not serialized.
179    /// Populated automatically by `from_json()`; call `build_indexes()` after
180    /// direct mutation of `self.queries`.
181    #[serde(skip)]
182    pub query_index: HashMap<String, usize>,
183
184    /// O(1) lookup index: mutation name → index into `self.mutations`.
185    /// Built at construction time by `build_indexes()`; not serialized.
186    /// Populated automatically by `from_json()`; call `build_indexes()` after
187    /// direct mutation of `self.mutations`.
188    #[serde(skip)]
189    pub mutation_index: HashMap<String, usize>,
190
191    /// O(1) lookup index: subscription name → index into `self.subscriptions`.
192    /// Built at construction time by `build_indexes()`; not serialized.
193    /// Populated automatically by `from_json()`; call `build_indexes()` after
194    /// direct mutation of `self.subscriptions`.
195    #[serde(skip)]
196    pub subscription_index: HashMap<String, usize>,
197}
198
199impl PartialEq for CompiledSchema {
200    fn eq(&self, other: &Self) -> bool {
201        // Compare all fields except custom_scalars (runtime state)
202        self.schema_format_version == other.schema_format_version
203            && self.types == other.types
204            && self.enums == other.enums
205            && self.input_types == other.input_types
206            && self.interfaces == other.interfaces
207            && self.unions == other.unions
208            && self.queries == other.queries
209            && self.mutations == other.mutations
210            && self.subscriptions == other.subscriptions
211            && self.directives == other.directives
212            && self.fact_tables == other.fact_tables
213            && self.observers == other.observers
214            && self.federation == other.federation
215            && self.security == other.security
216            && self.observers_config == other.observers_config
217            && self.subscriptions_config == other.subscriptions_config
218            && self.validation_config == other.validation_config
219            && self.debug_config == other.debug_config
220            && self.mcp_config == other.mcp_config
221            && self.schema_sdl == other.schema_sdl
222    }
223}
224
225impl CompiledSchema {
226    /// Create empty schema.
227    #[must_use]
228    pub fn new() -> Self {
229        Self::default()
230    }
231
232    /// Verify that the compiled schema was produced by a compatible compiler version.
233    ///
234    /// Schemas without a `schema_format_version` field (produced before v2.1) are
235    /// accepted with a warning. Schemas with a mismatched version are rejected to
236    /// prevent silent data corruption from structural changes.
237    ///
238    /// # Errors
239    ///
240    /// Returns an error string if the version is present and incompatible.
241    pub fn validate_format_version(&self) -> Result<(), String> {
242        match self.schema_format_version {
243            None => {
244                // Pre-versioning schema — accept but callers may want to warn.
245                Ok(())
246            },
247            Some(v) if v == CURRENT_SCHEMA_FORMAT_VERSION => Ok(()),
248            Some(v) => Err(format!(
249                "Schema format version mismatch: compiled schema has version {v}, \
250                 but this runtime expects version {CURRENT_SCHEMA_FORMAT_VERSION}. \
251                 Please recompile your schema with the matching fraiseql-cli version."
252            )),
253        }
254    }
255
256    /// Build O(1) lookup indexes for queries, mutations, and subscriptions.
257    ///
258    /// Called automatically by `from_json()`. Must be called manually after any
259    /// direct mutation of `self.queries`, `self.mutations`, or `self.subscriptions`.
260    pub fn build_indexes(&mut self) {
261        self.query_index =
262            self.queries.iter().enumerate().map(|(i, q)| (q.name.clone(), i)).collect();
263        self.mutation_index =
264            self.mutations.iter().enumerate().map(|(i, m)| (m.name.clone(), i)).collect();
265        self.subscription_index = self
266            .subscriptions
267            .iter()
268            .enumerate()
269            .map(|(i, s)| (s.name.clone(), i))
270            .collect();
271    }
272
273    /// Deserialize from JSON string.
274    ///
275    /// This is the primary way to create a schema from any authoring language.
276    /// The authoring language emits `schema.json`; `fraiseql-cli compile` produces
277    /// `schema.compiled.json`; Rust deserializes and owns the result.
278    ///
279    /// # Integrity Checking
280    ///
281    /// When `fraiseql-cli compile` embeds a `_content_hash` field in the compiled JSON,
282    /// the runtime should verify it against `content_hash()` before accepting the schema.
283    /// This guards against accidental corruption or tampering between compilation and
284    /// deployment. The check is not performed here because `_content_hash` is not yet
285    /// written by the CLI; once it is, add a post-deserialization step:
286    ///
287    /// ```rust,ignore
288    /// let schema = CompiledSchema::from_json(json)?;
289    /// if let Some(expected) = &schema._content_hash {
290    ///     let actual = schema.content_hash();
291    ///     if *expected != actual {
292    ///         return Err(IntegrityError::HashMismatch { expected, actual });
293    ///     }
294    /// }
295    /// ```
296    ///
297    /// # Errors
298    ///
299    /// Returns error if JSON is malformed or doesn't match schema structure.
300    ///
301    /// # Example
302    ///
303    /// ```
304    /// use fraiseql_core::schema::CompiledSchema;
305    ///
306    /// let json = r#"{"types": [], "queries": [], "mutations": [], "subscriptions": []}"#;
307    /// let schema = CompiledSchema::from_json(json).unwrap();
308    /// ```
309    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
310        let mut schema: Self = serde_json::from_str(json)?;
311        schema.build_indexes();
312        Ok(schema)
313    }
314
315    /// Serialize to JSON string.
316    ///
317    /// # Errors
318    ///
319    /// Returns error if serialization fails (should not happen for valid schema).
320    pub fn to_json(&self) -> Result<String, serde_json::Error> {
321        serde_json::to_string(self)
322    }
323
324    /// Serialize to pretty JSON string (for debugging/config files).
325    ///
326    /// # Errors
327    ///
328    /// Returns error if serialization fails.
329    pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
330        serde_json::to_string_pretty(self)
331    }
332
333    /// Find a type definition by name.
334    #[must_use]
335    pub fn find_type(&self, name: &str) -> Option<&TypeDefinition> {
336        self.types.iter().find(|t| t.name == name)
337    }
338
339    /// Find an enum definition by name.
340    #[must_use]
341    pub fn find_enum(&self, name: &str) -> Option<&EnumDefinition> {
342        self.enums.iter().find(|e| e.name == name)
343    }
344
345    /// Find an input object definition by name.
346    #[must_use]
347    pub fn find_input_type(&self, name: &str) -> Option<&InputObjectDefinition> {
348        self.input_types.iter().find(|i| i.name == name)
349    }
350
351    /// Find an interface definition by name.
352    #[must_use]
353    pub fn find_interface(&self, name: &str) -> Option<&InterfaceDefinition> {
354        self.interfaces.iter().find(|i| i.name == name)
355    }
356
357    /// Find all types that implement a given interface.
358    #[must_use]
359    pub fn find_implementors(&self, interface_name: &str) -> Vec<&TypeDefinition> {
360        self.types
361            .iter()
362            .filter(|t| t.implements.contains(&interface_name.to_string()))
363            .collect()
364    }
365
366    /// Find a union definition by name.
367    #[must_use]
368    pub fn find_union(&self, name: &str) -> Option<&UnionDefinition> {
369        self.unions.iter().find(|u| u.name == name)
370    }
371
372    /// Find a query definition by name.
373    ///
374    /// Uses the O(1) pre-built index when available; falls back to O(n) linear
375    /// scan for schemas built directly in tests without calling `build_indexes()`.
376    #[must_use]
377    pub fn find_query(&self, name: &str) -> Option<&QueryDefinition> {
378        if self.query_index.is_empty() && !self.queries.is_empty() {
379            self.queries.iter().find(|q| q.name == name)
380        } else {
381            self.query_index.get(name).map(|&i| &self.queries[i])
382        }
383    }
384
385    /// Find a mutation definition by name.
386    ///
387    /// Uses the O(1) pre-built index when available; falls back to O(n) linear
388    /// scan for schemas built directly in tests without calling `build_indexes()`.
389    #[must_use]
390    pub fn find_mutation(&self, name: &str) -> Option<&MutationDefinition> {
391        if self.mutation_index.is_empty() && !self.mutations.is_empty() {
392            self.mutations.iter().find(|m| m.name == name)
393        } else {
394            self.mutation_index.get(name).map(|&i| &self.mutations[i])
395        }
396    }
397
398    /// Find a subscription definition by name.
399    ///
400    /// Uses the O(1) pre-built index when available; falls back to O(n) linear
401    /// scan for schemas built directly in tests without calling `build_indexes()`.
402    #[must_use]
403    pub fn find_subscription(&self, name: &str) -> Option<&SubscriptionDefinition> {
404        if self.subscription_index.is_empty() && !self.subscriptions.is_empty() {
405            self.subscriptions.iter().find(|s| s.name == name)
406        } else {
407            self.subscription_index.get(name).map(|&i| &self.subscriptions[i])
408        }
409    }
410
411    /// Find a custom directive definition by name.
412    #[must_use]
413    pub fn find_directive(&self, name: &str) -> Option<&DirectiveDefinition> {
414        self.directives.iter().find(|d| d.name == name)
415    }
416
417    /// Get total number of operations (queries + mutations + subscriptions).
418    #[must_use]
419    pub const fn operation_count(&self) -> usize {
420        self.queries.len() + self.mutations.len() + self.subscriptions.len()
421    }
422
423    /// Register fact table metadata.
424    ///
425    /// # Arguments
426    ///
427    /// * `table_name` - Fact table name (e.g., `tf_sales`)
428    /// * `metadata` - Typed `FactTableMetadata`
429    pub fn add_fact_table(&mut self, table_name: String, metadata: FactTableMetadata) {
430        self.fact_tables.insert(table_name, metadata);
431    }
432
433    /// Get fact table metadata by name.
434    ///
435    /// # Arguments
436    ///
437    /// * `name` - Fact table name
438    ///
439    /// # Returns
440    ///
441    /// Fact table metadata if found
442    #[must_use]
443    pub fn get_fact_table(&self, name: &str) -> Option<&FactTableMetadata> {
444        self.fact_tables.get(name)
445    }
446
447    /// List all fact table names.
448    ///
449    /// # Returns
450    ///
451    /// Vector of fact table names
452    #[must_use]
453    pub fn list_fact_tables(&self) -> Vec<&str> {
454        self.fact_tables.keys().map(String::as_str).collect()
455    }
456
457    /// Check if schema contains any fact tables.
458    #[must_use]
459    pub fn has_fact_tables(&self) -> bool {
460        !self.fact_tables.is_empty()
461    }
462
463    /// Find an observer definition by name.
464    #[must_use]
465    pub fn find_observer(&self, name: &str) -> Option<&ObserverDefinition> {
466        self.observers.iter().find(|o| o.name == name)
467    }
468
469    /// Get all observers for a specific entity type.
470    #[must_use]
471    pub fn find_observers_for_entity(&self, entity: &str) -> Vec<&ObserverDefinition> {
472        self.observers.iter().filter(|o| o.entity == entity).collect()
473    }
474
475    /// Get all observers for a specific event type (INSERT, UPDATE, DELETE).
476    #[must_use]
477    pub fn find_observers_for_event(&self, event: &str) -> Vec<&ObserverDefinition> {
478        self.observers.iter().filter(|o| o.event == event).collect()
479    }
480
481    /// Check if schema contains any observers.
482    #[must_use]
483    pub const fn has_observers(&self) -> bool {
484        !self.observers.is_empty()
485    }
486
487    /// Get total number of observers.
488    #[must_use]
489    pub const fn observer_count(&self) -> usize {
490        self.observers.len()
491    }
492
493    /// Get federation metadata from schema.
494    ///
495    /// # Returns
496    ///
497    /// Federation metadata if configured in schema
498    #[cfg(feature = "federation")]
499    #[must_use]
500    pub fn federation_metadata(&self) -> Option<crate::federation::FederationMetadata> {
501        self.federation.as_ref().filter(|fed| fed.enabled).map(|fed| {
502            let types = fed
503                .entities
504                .iter()
505                .map(|e| crate::federation::types::FederatedType {
506                    name:             e.name.clone(),
507                    keys:             vec![crate::federation::types::KeyDirective {
508                        fields:     e.key_fields.clone(),
509                        resolvable: true,
510                    }],
511                    is_extends:       false,
512                    external_fields:  Vec::new(),
513                    shareable_fields: Vec::new(),
514                    field_directives: std::collections::HashMap::new(),
515                })
516                .collect();
517
518            crate::federation::FederationMetadata {
519                enabled: fed.enabled,
520                version: fed.version.clone().unwrap_or_else(|| "v2".to_string()),
521                types,
522            }
523        })
524    }
525
526    /// Stub federation metadata when federation feature is disabled.
527    #[cfg(not(feature = "federation"))]
528    #[must_use]
529    pub const fn federation_metadata(&self) -> Option<()> {
530        None
531    }
532
533    /// Get security configuration from schema.
534    ///
535    /// # Returns
536    ///
537    /// Security configuration if present (includes role definitions)
538    #[must_use]
539    pub const fn security_config(&self) -> Option<&SecurityConfig> {
540        self.security.as_ref()
541    }
542
543    /// Returns `true` if this schema declares a multi-tenant deployment.
544    ///
545    /// Multi-tenant schemas require Row-Level Security (RLS) to be active whenever
546    /// query result caching is enabled. Without RLS, all tenants sharing the same
547    /// query parameters would receive the same cached response.
548    ///
549    /// Detection is based on `security.multi_tenant` in the compiled schema JSON.
550    #[must_use]
551    pub fn is_multi_tenant(&self) -> bool {
552        self.security.as_ref().is_some_and(|s| s.multi_tenant)
553    }
554
555    /// Find a role definition by name.
556    ///
557    /// # Arguments
558    ///
559    /// * `role_name` - Name of the role to find
560    ///
561    /// # Returns
562    ///
563    /// Role definition if found
564    #[must_use]
565    pub fn find_role(&self, role_name: &str) -> Option<RoleDefinition> {
566        self.security.as_ref().and_then(|config| config.find_role(role_name).cloned())
567    }
568
569    /// Get scopes for a role.
570    ///
571    /// # Arguments
572    ///
573    /// * `role_name` - Name of the role
574    ///
575    /// # Returns
576    ///
577    /// Vector of scopes granted to the role
578    #[must_use]
579    pub fn get_role_scopes(&self, role_name: &str) -> Vec<String> {
580        self.security
581            .as_ref()
582            .map(|config| config.get_role_scopes(role_name))
583            .unwrap_or_default()
584    }
585
586    /// Check if a role has a specific scope.
587    ///
588    /// # Arguments
589    ///
590    /// * `role_name` - Name of the role
591    /// * `scope` - Scope to check for
592    ///
593    /// # Returns
594    ///
595    /// true if role has the scope, false otherwise
596    #[must_use]
597    pub fn role_has_scope(&self, role_name: &str, scope: &str) -> bool {
598        self.security
599            .as_ref()
600            .is_some_and(|config| config.role_has_scope(role_name, scope))
601    }
602
603    /// Returns a 32-character hex SHA-256 content hash of this schema's canonical JSON.
604    ///
605    /// Use as `schema_version` when constructing `CachedDatabaseAdapter` to guarantee
606    /// cache invalidation on any schema change, regardless of whether the package
607    /// version was bumped.
608    ///
609    /// Two schemas that differ by even one field will produce different hashes.
610    /// The same schema serialised twice always produces the same hash (stable).
611    ///
612    /// # Panics
613    ///
614    /// Does not panic — `CompiledSchema` always serialises to valid JSON.
615    ///
616    /// # Example
617    ///
618    /// ```
619    /// use fraiseql_core::schema::CompiledSchema;
620    ///
621    /// let schema = CompiledSchema::default();
622    /// let hash = schema.content_hash();
623    /// assert_eq!(hash.len(), 32); // 16 bytes → 32 hex chars
624    /// ```
625    #[must_use]
626    pub fn content_hash(&self) -> String {
627        use sha2::{Digest, Sha256};
628        let json = self.to_json().expect("CompiledSchema always serialises — BUG if this fails");
629        let digest = Sha256::digest(json.as_bytes());
630        hex::encode(&digest[..16]) // 32 hex chars — sufficient collision resistance
631    }
632
633    /// Returns `true` if Row-Level Security policies are declared in this schema.
634    ///
635    /// Used at server startup to validate that caching is safe for multi-tenant
636    /// deployments. When caching is enabled and no RLS policies are configured,
637    /// the server emits a startup warning about potential data leakage.
638    ///
639    /// # Example
640    ///
641    /// ```
642    /// use fraiseql_core::schema::CompiledSchema;
643    ///
644    /// let schema = CompiledSchema::default();
645    /// assert!(!schema.has_rls_configured());
646    /// ```
647    #[must_use]
648    pub fn has_rls_configured(&self) -> bool {
649        self.security.as_ref().is_some_and(|s| {
650            !s.additional
651                .get("policies")
652                .and_then(|p: &serde_json::Value| p.as_array())
653                .is_none_or(|a| a.is_empty())
654        })
655    }
656
657    /// Get raw GraphQL schema SDL.
658    ///
659    /// # Returns
660    ///
661    /// Raw schema string if available, otherwise generates from type definitions
662    #[must_use]
663    pub fn raw_schema(&self) -> String {
664        self.schema_sdl.clone().unwrap_or_else(|| {
665            // Generate basic SDL from type definitions if not provided
666            let mut sdl = String::new();
667
668            // Add types
669            for type_def in &self.types {
670                let _ = writeln!(sdl, "type {} {{", type_def.name);
671                for field in &type_def.fields {
672                    let _ = writeln!(sdl, "  {}: {}", field.name, field.field_type);
673                }
674                sdl.push_str("}\n\n");
675            }
676
677            sdl
678        })
679    }
680
681    /// Validate the schema for internal consistency.
682    ///
683    /// Checks:
684    /// - All type references resolve to defined types
685    /// - No duplicate type/operation names
686    /// - Required fields have valid types
687    ///
688    /// # Errors
689    ///
690    /// Returns list of validation errors if schema is invalid.
691    pub fn validate(&self) -> Result<(), Vec<String>> {
692        let mut errors = Vec::new();
693
694        // Check for duplicate type names
695        let mut type_names: std::collections::HashSet<&str> = std::collections::HashSet::new();
696        for type_def in &self.types {
697            if !type_names.insert(type_def.name.as_str()) {
698                errors.push(format!("Duplicate type name: {}", type_def.name));
699            }
700        }
701
702        // Check for duplicate query names
703        let mut query_names: std::collections::HashSet<&str> = std::collections::HashSet::new();
704        for query in &self.queries {
705            if !query_names.insert(&query.name) {
706                errors.push(format!("Duplicate query name: {}", query.name));
707            }
708        }
709
710        // Check for duplicate mutation names
711        let mut mutation_names: std::collections::HashSet<&str> = std::collections::HashSet::new();
712        for mutation in &self.mutations {
713            if !mutation_names.insert(&mutation.name) {
714                errors.push(format!("Duplicate mutation name: {}", mutation.name));
715            }
716        }
717
718        // Check type references in queries
719        for query in &self.queries {
720            if !type_names.contains(query.return_type.as_str())
721                && !is_builtin_type(&query.return_type)
722            {
723                errors.push(format!(
724                    "Query '{}' references undefined type '{}'",
725                    query.name, query.return_type
726                ));
727            }
728        }
729
730        // Check type references in mutations
731        for mutation in &self.mutations {
732            if !type_names.contains(mutation.return_type.as_str())
733                && !is_builtin_type(&mutation.return_type)
734            {
735                errors.push(format!(
736                    "Mutation '{}' references undefined type '{}'",
737                    mutation.name, mutation.return_type
738                ));
739            }
740        }
741
742        if errors.is_empty() {
743            Ok(())
744        } else {
745            Err(errors)
746        }
747    }
748}
749
750/// Check if a type name is a built-in scalar type.
751fn is_builtin_type(name: &str) -> bool {
752    matches!(
753        name,
754        "String"
755            | "Int"
756            | "Float"
757            | "Boolean"
758            | "ID"
759            | "DateTime"
760            | "Date"
761            | "Time"
762            | "JSON"
763            | "UUID"
764            | "Decimal"
765    )
766}
767
768#[cfg(test)]
769mod tests {
770    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable assertions
771    use super::*;
772    #[cfg(feature = "federation")]
773    use crate::schema::config_types::FederationEntity;
774    use crate::schema::{
775        config_types::FederationConfig,
776        graphql_type_defs::TypeDefinition,
777        observer_types::ObserverDefinition,
778        security_config::{RoleDefinition, SecurityConfig},
779    };
780
781    // -------------------------------------------------------------------------
782    // Helpers
783    // -------------------------------------------------------------------------
784
785    fn make_type_def(name: &str) -> TypeDefinition {
786        TypeDefinition {
787            name:                name.into(),
788            sql_source:          format!("v_{}", name.to_lowercase()).as_str().into(),
789            jsonb_column:        "data".to_string(),
790            fields:              vec![],
791            description:         None,
792            sql_projection_hint: None,
793            implements:          vec![],
794            requires_role:       None,
795            is_error:            false,
796            relay:               false,
797            relationships:       vec![],
798        }
799    }
800
801    fn make_query(name: &str, return_type: &str) -> QueryDefinition {
802        QueryDefinition::new(name, return_type)
803    }
804
805    fn make_mutation(name: &str, return_type: &str) -> MutationDefinition {
806        MutationDefinition::new(name, return_type)
807    }
808
809    // -------------------------------------------------------------------------
810    // Constructor behaviour
811    // -------------------------------------------------------------------------
812
813    #[test]
814    fn new_returns_empty_schema() {
815        let schema = CompiledSchema::new();
816        assert!(schema.types.is_empty());
817        assert!(schema.queries.is_empty());
818        assert!(schema.mutations.is_empty());
819        assert!(schema.subscriptions.is_empty());
820        assert!(schema.enums.is_empty());
821        assert!(schema.interfaces.is_empty());
822        assert!(schema.unions.is_empty());
823    }
824
825    #[test]
826    fn from_json_empty_array_fields() {
827        let json = r#"{"types":[],"queries":[],"mutations":[],"subscriptions":[]}"#;
828        let schema = CompiledSchema::from_json(json).unwrap();
829        assert_eq!(schema.types.len(), 0);
830        assert_eq!(schema.queries.len(), 0);
831        assert_eq!(schema.mutations.len(), 0);
832        assert_eq!(schema.subscriptions.len(), 0);
833    }
834
835    #[test]
836    fn from_json_minimal_empty_object() {
837        // All fields have #[serde(default)] — an empty JSON object is valid
838        let schema = CompiledSchema::from_json("{}").unwrap();
839        assert!(schema.types.is_empty());
840        assert!(schema.queries.is_empty());
841    }
842
843    #[test]
844    fn from_json_invalid_returns_error() {
845        let result = CompiledSchema::from_json("not json at all");
846        assert!(result.is_err());
847    }
848
849    #[test]
850    fn from_json_builds_query_index() {
851        let json = r#"{
852            "types": [{"name":"User","sql_source":"v_user","fields":[]}],
853            "queries": [{"name":"users","return_type":"User"}],
854            "mutations": [],
855            "subscriptions": []
856        }"#;
857        let schema = CompiledSchema::from_json(json).unwrap();
858        assert!(schema.query_index.contains_key("users"));
859        assert_eq!(schema.query_index["users"], 0);
860    }
861
862    #[test]
863    fn from_json_builds_mutation_index() {
864        let json = r#"{
865            "types": [{"name":"User","sql_source":"v_user","fields":[]}],
866            "mutations": [{"name":"createUser","return_type":"User"}],
867            "queries": [],
868            "subscriptions": []
869        }"#;
870        let schema = CompiledSchema::from_json(json).unwrap();
871        assert!(schema.mutation_index.contains_key("createUser"));
872    }
873
874    // -------------------------------------------------------------------------
875    // Serialization round-trip
876    // -------------------------------------------------------------------------
877
878    #[test]
879    fn to_json_and_back_is_identity() {
880        let mut schema = CompiledSchema::new();
881        schema.schema_format_version = Some(1);
882        let json = schema.to_json().unwrap();
883        let schema2 = CompiledSchema::from_json(&json).unwrap();
884        assert_eq!(schema, schema2);
885    }
886
887    #[test]
888    fn to_json_pretty_is_valid_json() {
889        let schema = CompiledSchema::new();
890        let pretty = schema.to_json_pretty().unwrap();
891        // Should re-parse without error
892        let _: serde_json::Value = serde_json::from_str(&pretty).unwrap();
893    }
894
895    // -------------------------------------------------------------------------
896    // Format version
897    // -------------------------------------------------------------------------
898
899    #[test]
900    fn validate_format_version_none_is_ok() {
901        let schema = CompiledSchema::new(); // schema_format_version = None
902        assert!(schema.validate_format_version().is_ok());
903    }
904
905    #[test]
906    fn validate_format_version_current_is_ok() {
907        let mut schema = CompiledSchema::new();
908        schema.schema_format_version = Some(CURRENT_SCHEMA_FORMAT_VERSION);
909        assert!(schema.validate_format_version().is_ok());
910    }
911
912    #[test]
913    fn validate_format_version_mismatch_is_err() {
914        let mut schema = CompiledSchema::new();
915        schema.schema_format_version = Some(CURRENT_SCHEMA_FORMAT_VERSION + 1);
916        let result = schema.validate_format_version();
917        assert!(result.is_err());
918        let msg = result.unwrap_err();
919        assert!(msg.contains("mismatch"));
920    }
921
922    // -------------------------------------------------------------------------
923    // Index building
924    // -------------------------------------------------------------------------
925
926    #[test]
927    fn build_indexes_populates_all_three_maps() {
928        let mut schema = CompiledSchema::new();
929        schema.queries.push(make_query("getUser", "User"));
930        schema.mutations.push(make_mutation("createUser", "User"));
931        schema.build_indexes();
932        assert!(schema.query_index.contains_key("getUser"));
933        assert!(schema.mutation_index.contains_key("createUser"));
934    }
935
936    #[test]
937    fn build_indexes_multiple_queries() {
938        let mut schema = CompiledSchema::new();
939        schema.queries.push(make_query("alpha", "A"));
940        schema.queries.push(make_query("beta", "B"));
941        schema.queries.push(make_query("gamma", "C"));
942        schema.build_indexes();
943        assert_eq!(schema.query_index["alpha"], 0);
944        assert_eq!(schema.query_index["beta"], 1);
945        assert_eq!(schema.query_index["gamma"], 2);
946    }
947
948    // -------------------------------------------------------------------------
949    // Finder methods
950    // -------------------------------------------------------------------------
951
952    #[test]
953    fn find_type_returns_none_for_missing() {
954        let schema = CompiledSchema::new();
955        assert!(schema.find_type("Ghost").is_none());
956    }
957
958    #[test]
959    fn find_type_returns_existing() {
960        let mut schema = CompiledSchema::new();
961        schema.types.push(make_type_def("User"));
962        assert!(schema.find_type("User").is_some());
963        assert_eq!(schema.find_type("User").unwrap().name, "User");
964    }
965
966    #[test]
967    fn find_query_uses_index_when_populated() {
968        let json = r#"{
969            "types": [{"name":"User","sql_source":"v_user","fields":[]}],
970            "queries": [{"name":"users","return_type":"User"}],
971            "mutations": [],
972            "subscriptions": []
973        }"#;
974        let schema = CompiledSchema::from_json(json).unwrap();
975        let q = schema.find_query("users");
976        assert!(q.is_some());
977        assert_eq!(q.unwrap().name, "users");
978    }
979
980    #[test]
981    fn find_query_falls_back_to_linear_scan_without_index() {
982        // Build schema directly without calling build_indexes
983        let mut schema = CompiledSchema::new();
984        schema.queries.push(make_query("direct", "String"));
985        // query_index is empty but queries is not — should fall back to linear scan
986        let q = schema.find_query("direct");
987        assert!(q.is_some());
988    }
989
990    #[test]
991    fn find_query_returns_none_for_missing() {
992        let schema = CompiledSchema::from_json("{}").unwrap();
993        assert!(schema.find_query("nope").is_none());
994    }
995
996    #[test]
997    fn find_mutation_returns_correct_entry() {
998        let json = r#"{
999            "types": [{"name":"User","sql_source":"v_user","fields":[]}],
1000            "mutations": [{"name":"createUser","return_type":"User"}],
1001            "queries": [],
1002            "subscriptions": []
1003        }"#;
1004        let schema = CompiledSchema::from_json(json).unwrap();
1005        assert!(schema.find_mutation("createUser").is_some());
1006        assert!(schema.find_mutation("nope").is_none());
1007    }
1008
1009    #[test]
1010    fn find_interface_returns_none_when_absent() {
1011        let schema = CompiledSchema::new();
1012        assert!(schema.find_interface("Node").is_none());
1013    }
1014
1015    #[test]
1016    fn find_implementors_filters_by_interface() {
1017        let mut schema = CompiledSchema::new();
1018        let mut user = make_type_def("User");
1019        user.implements = vec!["Node".to_string()];
1020        schema.types.push(user);
1021        schema.types.push(make_type_def("Product")); // does not implement Node
1022
1023        let implementors = schema.find_implementors("Node");
1024        assert_eq!(implementors.len(), 1);
1025        assert_eq!(implementors[0].name, "User");
1026    }
1027
1028    // -------------------------------------------------------------------------
1029    // operation_count
1030    // -------------------------------------------------------------------------
1031
1032    #[test]
1033    fn operation_count_sums_all_three() {
1034        let mut schema = CompiledSchema::new();
1035        schema.queries.push(make_query("q1", "String"));
1036        schema.queries.push(make_query("q2", "String"));
1037        schema.mutations.push(make_mutation("m1", "String"));
1038        assert_eq!(schema.operation_count(), 3);
1039    }
1040
1041    #[test]
1042    fn operation_count_zero_for_empty_schema() {
1043        assert_eq!(CompiledSchema::new().operation_count(), 0);
1044    }
1045
1046    // -------------------------------------------------------------------------
1047    // Fact tables
1048    // -------------------------------------------------------------------------
1049
1050    #[test]
1051    fn fact_table_add_and_get() {
1052        use crate::compiler::fact_table::{DimensionColumn, FactTableMetadata};
1053
1054        let mut schema = CompiledSchema::new();
1055        assert!(!schema.has_fact_tables());
1056
1057        let meta = FactTableMetadata {
1058            table_name:           "tf_sales".to_string(),
1059            measures:             vec![],
1060            dimensions:           DimensionColumn {
1061                name:  "data".to_string(),
1062                paths: vec![],
1063            },
1064            denormalized_filters: vec![],
1065            calendar_dimensions:  vec![],
1066        };
1067        schema.add_fact_table("tf_sales".to_string(), meta);
1068
1069        assert!(schema.has_fact_tables());
1070        assert!(schema.get_fact_table("tf_sales").is_some());
1071        assert!(schema.get_fact_table("tf_missing").is_none());
1072    }
1073
1074    #[test]
1075    fn list_fact_tables_returns_all_names() {
1076        use crate::compiler::fact_table::{DimensionColumn, FactTableMetadata};
1077
1078        let make_meta = |name: &str| FactTableMetadata {
1079            table_name:           name.to_string(),
1080            measures:             vec![],
1081            dimensions:           DimensionColumn {
1082                name:  "data".to_string(),
1083                paths: vec![],
1084            },
1085            denormalized_filters: vec![],
1086            calendar_dimensions:  vec![],
1087        };
1088
1089        let mut schema = CompiledSchema::new();
1090        schema.add_fact_table("tf_a".to_string(), make_meta("tf_a"));
1091        schema.add_fact_table("tf_b".to_string(), make_meta("tf_b"));
1092
1093        let names = schema.list_fact_tables();
1094        assert_eq!(names.len(), 2);
1095        assert!(names.contains(&"tf_a"));
1096        assert!(names.contains(&"tf_b"));
1097    }
1098
1099    // -------------------------------------------------------------------------
1100    // Observers
1101    // -------------------------------------------------------------------------
1102
1103    #[test]
1104    fn has_observers_false_for_empty_schema() {
1105        assert!(!CompiledSchema::new().has_observers());
1106    }
1107
1108    #[test]
1109    fn find_observer_returns_by_name() {
1110        let mut schema = CompiledSchema::new();
1111        schema.observers.push(ObserverDefinition::new("onInsert", "Order", "INSERT"));
1112        assert!(schema.find_observer("onInsert").is_some());
1113        assert!(schema.find_observer("missing").is_none());
1114    }
1115
1116    #[test]
1117    fn find_observers_for_entity_filters_correctly() {
1118        let mut schema = CompiledSchema::new();
1119        schema.observers.push(ObserverDefinition::new("obs1", "Order", "INSERT"));
1120        schema.observers.push(ObserverDefinition::new("obs2", "Order", "UPDATE"));
1121        schema.observers.push(ObserverDefinition::new("obs3", "User", "INSERT"));
1122
1123        let order_obs = schema.find_observers_for_entity("Order");
1124        assert_eq!(order_obs.len(), 2);
1125        let user_obs = schema.find_observers_for_entity("User");
1126        assert_eq!(user_obs.len(), 1);
1127    }
1128
1129    #[test]
1130    fn find_observers_for_event_filters_correctly() {
1131        let mut schema = CompiledSchema::new();
1132        schema.observers.push(ObserverDefinition::new("obs1", "Order", "INSERT"));
1133        schema.observers.push(ObserverDefinition::new("obs2", "User", "INSERT"));
1134        schema.observers.push(ObserverDefinition::new("obs3", "Order", "DELETE"));
1135
1136        let inserts = schema.find_observers_for_event("INSERT");
1137        assert_eq!(inserts.len(), 2);
1138    }
1139
1140    #[test]
1141    fn observer_count_matches_vec_length() {
1142        let mut schema = CompiledSchema::new();
1143        assert_eq!(schema.observer_count(), 0);
1144        schema.observers.push(ObserverDefinition::new("o1", "A", "INSERT"));
1145        assert_eq!(schema.observer_count(), 1);
1146    }
1147
1148    // -------------------------------------------------------------------------
1149    // Security helpers
1150    // -------------------------------------------------------------------------
1151
1152    #[test]
1153    fn is_multi_tenant_false_by_default() {
1154        assert!(!CompiledSchema::new().is_multi_tenant());
1155    }
1156
1157    #[test]
1158    fn is_multi_tenant_true_when_configured() {
1159        let mut schema = CompiledSchema::new();
1160        let mut sec = SecurityConfig::new();
1161        sec.multi_tenant = true;
1162        schema.security = Some(sec);
1163        assert!(schema.is_multi_tenant());
1164    }
1165
1166    #[test]
1167    fn find_role_returns_none_without_security_config() {
1168        assert!(CompiledSchema::new().find_role("admin").is_none());
1169    }
1170
1171    #[test]
1172    fn find_role_returns_defined_role() {
1173        let mut schema = CompiledSchema::new();
1174        let mut sec = SecurityConfig::new();
1175        sec.add_role(RoleDefinition::new("editor", vec!["read:*".to_string()]));
1176        schema.security = Some(sec);
1177        assert!(schema.find_role("editor").is_some());
1178    }
1179
1180    #[test]
1181    fn role_has_scope_false_without_security() {
1182        assert!(!CompiledSchema::new().role_has_scope("admin", "read:*"));
1183    }
1184
1185    #[test]
1186    fn role_has_scope_true_when_granted() {
1187        let mut schema = CompiledSchema::new();
1188        let mut sec = SecurityConfig::new();
1189        sec.add_role(RoleDefinition::new("admin", vec!["read:*".to_string()]));
1190        schema.security = Some(sec);
1191        assert!(schema.role_has_scope("admin", "read:anything"));
1192        assert!(!schema.role_has_scope("admin", "write:anything"));
1193    }
1194
1195    #[test]
1196    fn get_role_scopes_empty_for_missing_role() {
1197        let schema = CompiledSchema::new();
1198        assert!(schema.get_role_scopes("ghost").is_empty());
1199    }
1200
1201    // -------------------------------------------------------------------------
1202    // Federation metadata
1203    // -------------------------------------------------------------------------
1204
1205    #[test]
1206    fn federation_metadata_none_when_no_federation() {
1207        assert!(CompiledSchema::new().federation_metadata().is_none());
1208    }
1209
1210    #[test]
1211    fn federation_metadata_none_when_disabled() {
1212        let mut schema = CompiledSchema::new();
1213        schema.federation = Some(FederationConfig {
1214            enabled: false,
1215            ..Default::default()
1216        });
1217        assert!(schema.federation_metadata().is_none());
1218    }
1219
1220    #[test]
1221    #[cfg(feature = "federation")]
1222    fn federation_metadata_some_when_enabled() {
1223        let mut schema = CompiledSchema::new();
1224        schema.federation = Some(FederationConfig {
1225            enabled: true,
1226            version: Some("v2".to_string()),
1227            entities: vec![FederationEntity {
1228                name:       "User".to_string(),
1229                key_fields: vec!["id".to_string()],
1230            }],
1231            ..Default::default()
1232        });
1233        let meta = schema.federation_metadata();
1234        assert!(meta.is_some());
1235        let meta = meta.unwrap();
1236        assert!(meta.enabled);
1237        assert_eq!(meta.types.len(), 1);
1238        assert_eq!(meta.types[0].name, "User");
1239    }
1240
1241    // -------------------------------------------------------------------------
1242    // content_hash
1243    // -------------------------------------------------------------------------
1244
1245    #[test]
1246    fn content_hash_is_32_hex_chars() {
1247        let hash = CompiledSchema::new().content_hash();
1248        assert_eq!(hash.len(), 32);
1249        assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
1250    }
1251
1252    #[test]
1253    fn content_hash_is_stable() {
1254        let schema = CompiledSchema::new();
1255        assert_eq!(schema.content_hash(), schema.content_hash());
1256    }
1257
1258    #[test]
1259    fn content_hash_differs_for_different_schemas() {
1260        let s1 = CompiledSchema::new();
1261        let mut s2 = CompiledSchema::new();
1262        s2.schema_format_version = Some(1);
1263        assert_ne!(s1.content_hash(), s2.content_hash());
1264    }
1265
1266    // -------------------------------------------------------------------------
1267    // has_rls_configured
1268    // -------------------------------------------------------------------------
1269
1270    #[test]
1271    fn has_rls_configured_false_without_security() {
1272        assert!(!CompiledSchema::new().has_rls_configured());
1273    }
1274
1275    #[test]
1276    fn has_rls_configured_false_when_policies_empty() {
1277        let mut schema = CompiledSchema::new();
1278        let mut sec = SecurityConfig::new();
1279        sec.additional.insert("policies".to_string(), serde_json::json!([]));
1280        schema.security = Some(sec);
1281        assert!(!schema.has_rls_configured());
1282    }
1283
1284    #[test]
1285    fn has_rls_configured_true_when_policies_present() {
1286        let mut schema = CompiledSchema::new();
1287        let mut sec = SecurityConfig::new();
1288        sec.additional.insert(
1289            "policies".to_string(),
1290            serde_json::json!([{"table": "orders", "using": "tenant_id = current_setting('app.tenant_id')"}]),
1291        );
1292        schema.security = Some(sec);
1293        assert!(schema.has_rls_configured());
1294    }
1295
1296    // -------------------------------------------------------------------------
1297    // validate()
1298    // -------------------------------------------------------------------------
1299
1300    #[test]
1301    fn validate_empty_schema_is_ok() {
1302        assert!(CompiledSchema::new().validate().is_ok());
1303    }
1304
1305    #[test]
1306    fn validate_detects_duplicate_type_names() {
1307        let mut schema = CompiledSchema::new();
1308        schema.types.push(make_type_def("User"));
1309        schema.types.push(make_type_def("User")); // duplicate
1310        let result = schema.validate();
1311        assert!(result.is_err());
1312        let errors = result.unwrap_err();
1313        assert!(errors.iter().any(|e| e.contains("Duplicate type name")));
1314    }
1315
1316    #[test]
1317    fn validate_detects_duplicate_query_names() {
1318        let mut schema = CompiledSchema::new();
1319        schema.queries.push(make_query("getUser", "String"));
1320        schema.queries.push(make_query("getUser", "String")); // duplicate
1321        let result = schema.validate();
1322        assert!(result.is_err());
1323        let errors = result.unwrap_err();
1324        assert!(errors.iter().any(|e| e.contains("Duplicate query name")));
1325    }
1326
1327    #[test]
1328    fn validate_detects_duplicate_mutation_names() {
1329        let mut schema = CompiledSchema::new();
1330        schema.mutations.push(make_mutation("createUser", "String"));
1331        schema.mutations.push(make_mutation("createUser", "String")); // duplicate
1332        let result = schema.validate();
1333        assert!(result.is_err());
1334    }
1335
1336    #[test]
1337    fn validate_undefined_return_type_in_query_is_error() {
1338        let mut schema = CompiledSchema::new();
1339        // No "Widget" type defined
1340        schema.queries.push(make_query("getWidget", "Widget"));
1341        let result = schema.validate();
1342        assert!(result.is_err());
1343        let errors = result.unwrap_err();
1344        assert!(errors.iter().any(|e| e.contains("Widget")));
1345    }
1346
1347    #[test]
1348    fn validate_builtin_scalar_return_type_is_ok() {
1349        let mut schema = CompiledSchema::new();
1350        schema.queries.push(make_query("ping", "String"));
1351        schema.queries.push(make_query("count", "Int"));
1352        assert!(schema.validate().is_ok());
1353    }
1354
1355    #[test]
1356    fn validate_defined_type_as_return_type_is_ok() {
1357        let mut schema = CompiledSchema::new();
1358        schema.types.push(make_type_def("User"));
1359        schema.queries.push(make_query("getUser", "User"));
1360        assert!(schema.validate().is_ok());
1361    }
1362
1363    // -------------------------------------------------------------------------
1364    // raw_schema
1365    // -------------------------------------------------------------------------
1366
1367    #[test]
1368    fn raw_schema_returns_sdl_when_set() {
1369        let mut schema = CompiledSchema::new();
1370        schema.schema_sdl = Some("type Query { ping: String }".to_string());
1371        assert_eq!(schema.raw_schema(), "type Query { ping: String }");
1372    }
1373
1374    #[test]
1375    fn raw_schema_generates_from_types_when_sdl_absent() {
1376        let mut schema = CompiledSchema::new();
1377        schema.types.push(make_type_def("User"));
1378        let sdl = schema.raw_schema();
1379        assert!(sdl.contains("User"));
1380    }
1381
1382    // -------------------------------------------------------------------------
1383    // is_builtin_type (private fn — tested via validate())
1384    // -------------------------------------------------------------------------
1385
1386    #[test]
1387    fn builtin_scalar_types_pass_validation() {
1388        let scalars = [
1389            "String", "Int", "Float", "Boolean", "ID", "DateTime", "Date", "Time", "JSON", "UUID",
1390            "Decimal",
1391        ];
1392        for scalar in scalars {
1393            let mut schema = CompiledSchema::new();
1394            schema.queries.push(make_query("q", scalar));
1395            assert!(schema.validate().is_ok(), "{scalar} should be a recognised built-in");
1396        }
1397    }
1398
1399    #[test]
1400    fn unknown_scalar_fails_validation() {
1401        let mut schema = CompiledSchema::new();
1402        schema.queries.push(make_query("q", "Blob"));
1403        assert!(schema.validate().is_err());
1404    }
1405}