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}