Skip to main content

fraiseql_core/schema/compiled/
schema_domain.rs

1//! Domain-specific methods for [`CompiledSchema`].
2//!
3//! Fact table management, observers, federation metadata, security configuration,
4//! RLS, role scopes, tenancy, SDL generation, and schema validation.
5
6#[cfg(feature = "federation")]
7use std::collections::HashMap;
8use std::fmt::Write as _;
9
10use super::schema::{CURRENT_SCHEMA_FORMAT_VERSION, CompiledSchema};
11use crate::{
12    compiler::fact_table::FactTableMetadata,
13    schema::{
14        observer_types::ObserverDefinition,
15        security_config::{RoleDefinition, SecurityConfig},
16    },
17};
18
19impl CompiledSchema {
20    /// Verify that the compiled schema was produced by a compatible compiler version.
21    ///
22    /// Schemas without a `schema_format_version` field (produced before v2.1) are
23    /// accepted with a warning. Schemas with a mismatched version are rejected to
24    /// prevent silent data corruption from structural changes.
25    ///
26    /// # Errors
27    ///
28    /// Returns an error string if the version is present and incompatible.
29    pub fn validate_format_version(&self) -> Result<(), String> {
30        match self.schema_format_version {
31            None => {
32                // Pre-versioning schema — accept but callers may want to warn.
33                Ok(())
34            },
35            Some(v) if v == CURRENT_SCHEMA_FORMAT_VERSION => Ok(()),
36            Some(v) => Err(format!(
37                "Schema format version mismatch: compiled schema has version {v}, \
38                 but this runtime expects version {CURRENT_SCHEMA_FORMAT_VERSION}. \
39                 Please recompile your schema with the matching fraiseql-cli version."
40            )),
41        }
42    }
43
44    /// Register fact table metadata.
45    ///
46    /// # Arguments
47    ///
48    /// * `table_name` - Fact table name (e.g., `tf_sales`)
49    /// * `metadata` - Typed `FactTableMetadata`
50    pub fn add_fact_table(&mut self, table_name: String, metadata: FactTableMetadata) {
51        self.fact_tables.insert(table_name, metadata);
52    }
53
54    /// Get fact table metadata by name.
55    ///
56    /// # Arguments
57    ///
58    /// * `name` - Fact table name
59    ///
60    /// # Returns
61    ///
62    /// Fact table metadata if found
63    #[must_use]
64    pub fn get_fact_table(&self, name: &str) -> Option<&FactTableMetadata> {
65        self.fact_tables.get(name)
66    }
67
68    /// List all fact table names.
69    ///
70    /// # Returns
71    ///
72    /// Vector of fact table names
73    #[must_use]
74    pub fn list_fact_tables(&self) -> Vec<&str> {
75        self.fact_tables.keys().map(String::as_str).collect()
76    }
77
78    /// Check if schema contains any fact tables.
79    #[must_use]
80    pub fn has_fact_tables(&self) -> bool {
81        !self.fact_tables.is_empty()
82    }
83
84    /// Find an observer definition by name.
85    #[must_use]
86    pub fn find_observer(&self, name: &str) -> Option<&ObserverDefinition> {
87        self.observers.iter().find(|o| o.name == name)
88    }
89
90    /// Get all observers for a specific entity type.
91    #[must_use]
92    pub fn find_observers_for_entity(&self, entity: &str) -> Vec<&ObserverDefinition> {
93        self.observers.iter().filter(|o| o.entity == entity).collect()
94    }
95
96    /// Get all observers for a specific event type (INSERT, UPDATE, DELETE).
97    #[must_use]
98    pub fn find_observers_for_event(&self, event: &str) -> Vec<&ObserverDefinition> {
99        self.observers.iter().filter(|o| o.event == event).collect()
100    }
101
102    /// Check if schema contains any observers.
103    #[must_use]
104    pub const fn has_observers(&self) -> bool {
105        !self.observers.is_empty()
106    }
107
108    /// Get total number of observers.
109    #[must_use]
110    pub const fn observer_count(&self) -> usize {
111        self.observers.len()
112    }
113
114    /// Get federation metadata from schema.
115    ///
116    /// # Returns
117    ///
118    /// Federation metadata if configured in schema
119    #[cfg(feature = "federation")]
120    #[must_use]
121    pub fn federation_metadata(&self) -> Option<crate::federation::FederationMetadata> {
122        self.federation.as_ref().filter(|fed| fed.enabled).map(|fed| {
123            let types = fed
124                .entities
125                .iter()
126                .map(|e| crate::federation::types::FederatedType {
127                    name:                e.name.clone(),
128                    keys:                vec![crate::federation::types::KeyDirective {
129                        fields:     e.key_fields.clone(),
130                        resolvable: true,
131                    }],
132                    is_extends:          false,
133                    external_fields:     Vec::new(),
134                    shareable_fields:    Vec::new(),
135                    inaccessible_fields: Vec::new(),
136                    field_directives:    std::collections::HashMap::new(),
137                    type_shareable:      false,
138                })
139                .collect();
140
141            crate::federation::FederationMetadata {
142                enabled: fed.enabled,
143                version: fed.version.clone().unwrap_or_else(|| "v2".to_string()),
144                types,
145                remote_subscription_fields: HashMap::new(),
146            }
147        })
148    }
149
150    /// Stub federation metadata when federation feature is disabled.
151    #[cfg(not(feature = "federation"))]
152    #[must_use]
153    pub const fn federation_metadata(&self) -> Option<()> {
154        None
155    }
156
157    /// Get security configuration from schema.
158    ///
159    /// # Returns
160    ///
161    /// Security configuration if present (includes role definitions)
162    #[must_use]
163    pub const fn security_config(&self) -> Option<&SecurityConfig> {
164        self.security.as_ref()
165    }
166
167    /// Returns `true` if this schema declares a multi-tenant deployment.
168    ///
169    /// Multi-tenant schemas require Row-Level Security (RLS) to be active whenever
170    /// query result caching is enabled. Without RLS, all tenants sharing the same
171    /// query parameters would receive the same cached response.
172    ///
173    /// Detection is based on `security.multi_tenant` in the compiled schema JSON.
174    #[must_use]
175    pub fn is_multi_tenant(&self) -> bool {
176        self.security.as_ref().is_some_and(|s| s.multi_tenant)
177    }
178
179    /// Returns the tenancy isolation mode configured for this schema.
180    ///
181    /// Defaults to `TenancyMode::None` when no security or tenancy configuration
182    /// is present, meaning single-tenant operation with no isolation machinery.
183    #[must_use]
184    pub fn tenancy_mode(&self) -> crate::schema::TenancyMode {
185        self.security
186            .as_ref()
187            .map_or(crate::schema::TenancyMode::None, |s| s.tenancy.mode)
188    }
189
190    /// Returns the tenancy configuration, if present.
191    ///
192    /// Returns `None` when no security configuration exists. Returns the
193    /// default `TenancyConfig` (mode=none) when security exists but tenancy
194    /// is not explicitly configured.
195    #[must_use]
196    pub fn tenancy_config(&self) -> Option<&crate::schema::TenancyConfig> {
197        self.security.as_ref().map(|s| &s.tenancy)
198    }
199
200    /// Find a role definition by name.
201    ///
202    /// # Arguments
203    ///
204    /// * `role_name` - Name of the role to find
205    ///
206    /// # Returns
207    ///
208    /// Role definition if found
209    #[must_use]
210    pub fn find_role(&self, role_name: &str) -> Option<RoleDefinition> {
211        self.security.as_ref().and_then(|config| config.find_role(role_name).cloned())
212    }
213
214    /// Get scopes for a role.
215    ///
216    /// # Arguments
217    ///
218    /// * `role_name` - Name of the role
219    ///
220    /// # Returns
221    ///
222    /// Vector of scopes granted to the role
223    #[must_use]
224    pub fn get_role_scopes(&self, role_name: &str) -> Vec<String> {
225        self.security
226            .as_ref()
227            .map(|config| config.get_role_scopes(role_name))
228            .unwrap_or_default()
229    }
230
231    /// Check if a role has a specific scope.
232    ///
233    /// # Arguments
234    ///
235    /// * `role_name` - Name of the role
236    /// * `scope` - Scope to check for
237    ///
238    /// # Returns
239    ///
240    /// true if role has the scope, false otherwise
241    #[must_use]
242    pub fn role_has_scope(&self, role_name: &str, scope: &str) -> bool {
243        self.security
244            .as_ref()
245            .is_some_and(|config| config.role_has_scope(role_name, scope))
246    }
247
248    /// Returns `true` if Row-Level Security policies are declared in this schema.
249    ///
250    /// Used at server startup to validate that caching is safe for multi-tenant
251    /// deployments. When caching is enabled and no RLS policies are configured,
252    /// the server emits a startup warning about potential data leakage.
253    ///
254    /// # Example
255    ///
256    /// ```
257    /// use fraiseql_core::schema::CompiledSchema;
258    ///
259    /// let schema = CompiledSchema::default();
260    /// assert!(!schema.has_rls_configured());
261    /// ```
262    #[must_use]
263    pub fn has_rls_configured(&self) -> bool {
264        self.security.as_ref().is_some_and(|s| {
265            !s.additional
266                .get("policies")
267                .and_then(|p: &serde_json::Value| p.as_array())
268                .is_none_or(|a| a.is_empty())
269        })
270    }
271
272    /// Get raw GraphQL schema SDL.
273    ///
274    /// # Returns
275    ///
276    /// Raw schema string if available, otherwise generates from type definitions
277    #[must_use]
278    pub fn raw_schema(&self) -> String {
279        self.schema_sdl.clone().unwrap_or_else(|| {
280            // Generate basic SDL from type definitions if not provided
281            let mut sdl = String::new();
282
283            // Add types
284            for type_def in &self.types {
285                let _ = writeln!(sdl, "type {} {{", type_def.name);
286                for field in &type_def.fields {
287                    let _ = writeln!(sdl, "  {}: {}", field.name, field.field_type);
288                }
289                sdl.push_str("}\n\n");
290            }
291
292            sdl
293        })
294    }
295
296    /// Validate the schema for internal consistency.
297    ///
298    /// Checks:
299    /// - All type references resolve to defined types
300    /// - No duplicate type/operation names
301    /// - Required fields have valid types
302    ///
303    /// # Errors
304    ///
305    /// Returns list of validation errors if schema is invalid.
306    pub fn validate(&self) -> Result<(), Vec<String>> {
307        let mut errors = Vec::new();
308
309        // Check for duplicate type names
310        let mut type_names: std::collections::HashSet<&str> = std::collections::HashSet::new();
311        for type_def in &self.types {
312            if !type_names.insert(type_def.name.as_str()) {
313                errors.push(format!("Duplicate type name: {}", type_def.name));
314            }
315        }
316
317        // Check for duplicate query names
318        let mut query_names: std::collections::HashSet<&str> = std::collections::HashSet::new();
319        for query in &self.queries {
320            if !query_names.insert(&query.name) {
321                errors.push(format!("Duplicate query name: {}", query.name));
322            }
323        }
324
325        // Check for duplicate mutation names
326        let mut mutation_names: std::collections::HashSet<&str> = std::collections::HashSet::new();
327        for mutation in &self.mutations {
328            if !mutation_names.insert(&mutation.name) {
329                errors.push(format!("Duplicate mutation name: {}", mutation.name));
330            }
331        }
332
333        // Check type references in queries
334        for query in &self.queries {
335            if !type_names.contains(query.return_type.as_str())
336                && !is_builtin_type(&query.return_type)
337            {
338                errors.push(format!(
339                    "Query '{}' references undefined type '{}'",
340                    query.name, query.return_type
341                ));
342            }
343        }
344
345        // Check type references in mutations
346        for mutation in &self.mutations {
347            if !type_names.contains(mutation.return_type.as_str())
348                && !is_builtin_type(&mutation.return_type)
349            {
350                errors.push(format!(
351                    "Mutation '{}' references undefined type '{}'",
352                    mutation.name, mutation.return_type
353                ));
354            }
355        }
356
357        if errors.is_empty() {
358            Ok(())
359        } else {
360            Err(errors)
361        }
362    }
363}
364
365/// Check if a type name is a built-in scalar type.
366fn is_builtin_type(name: &str) -> bool {
367    matches!(
368        name,
369        "String"
370            | "Int"
371            | "Float"
372            | "Boolean"
373            | "ID"
374            | "DateTime"
375            | "Date"
376            | "Time"
377            | "JSON"
378            | "UUID"
379            | "Decimal"
380    )
381}