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 use crate::federation::types::{
124 FederatedType, FieldFederationDirectives, KeyDirective,
125 };
126
127 // Entities carry an `@key` (and, for an extended entity, `extend type` +
128 // `@external` on the borrowed key/fields). Per-field directives are
129 // rebuilt from the entity's `external_fields` / `shareable_fields` so the
130 // SDL renderer can append `@external` / `@shareable` to each field line.
131 let mut types: Vec<FederatedType> = fed
132 .entities
133 .iter()
134 .map(|e| {
135 let mut field_directives: HashMap<String, FieldFederationDirectives> =
136 HashMap::new();
137 for f in &e.external_fields {
138 field_directives.entry(f.clone()).or_default().external = true;
139 }
140 for f in &e.shareable_fields {
141 field_directives.entry(f.clone()).or_default().shareable = true;
142 }
143 FederatedType {
144 name: e.name.clone(),
145 keys: vec![KeyDirective {
146 fields: e.key_fields.clone(),
147 resolvable: true,
148 }],
149 is_extends: e.extends,
150 external_fields: e.external_fields.clone(),
151 shareable_fields: e.shareable_fields.clone(),
152 inaccessible_fields: Vec::new(),
153 field_directives,
154 type_shareable: false,
155 }
156 })
157 .collect();
158
159 // Non-entity `@shareable` value types (e.g. a shared `MutationError`):
160 // no `@key`, never a member of the `_Entity` union — they only receive a
161 // type-level `@shareable` so both subgraphs can define the identical type
162 // without an `INVALID_FIELD_SHARING` composition error.
163 for name in &fed.shareable_types {
164 types.push(FederatedType {
165 name: name.clone(),
166 keys: Vec::new(),
167 is_extends: false,
168 external_fields: Vec::new(),
169 shareable_fields: Vec::new(),
170 inaccessible_fields: Vec::new(),
171 field_directives: HashMap::new(),
172 type_shareable: true,
173 });
174 }
175
176 crate::federation::FederationMetadata {
177 enabled: fed.enabled,
178 version: fed.version.clone().unwrap_or_else(|| "v2".to_string()),
179 types,
180 remote_subscription_fields: HashMap::new(),
181 }
182 })
183 }
184
185 /// Build the per-entity-type backing source map (`typename` →
186 /// [`EntitySource`](crate::federation::EntitySource)) the federation
187 /// `_entities` resolver reads from instead of guessing `lower(typename)`
188 /// (#504/#507).
189 ///
190 /// Two sources, query-wins:
191 ///
192 /// 1. **Query-sourced** (owned entities): the backing relation rides on the root query that
193 /// returns the type, keyed by `return_type`, first-wins — the same query→type binding the
194 /// Relay `node` path uses. The query's `jsonb_column` (which the compiler defaults to
195 /// `"data"`) drives jsonb projection.
196 /// 2. **Type-sourced fallback** (#507): an owner-split `extend type … @key` entity resolved in
197 /// a subgraph that does not own it exposes no root query, so there is nothing in (1) to
198 /// source its relation from. Its relation instead rides on the type-level `sql_source` the
199 /// compiler carries from the authoring SDK. This only fills gaps — a query-sourced entry
200 /// always wins.
201 ///
202 /// Both sources read the entity's `jsonb_column` the same way: a non-empty column selects
203 /// jsonb-projection mode (`<col>->'<field>'`), an empty one selects flat-column mode (bare
204 /// columns). The compiler defaults both a query's and an extends type's `jsonb_column` to the
205 /// standard `"data"` view shape, so a flat-column entity must be authored with an explicit
206 /// empty `jsonb_column`.
207 #[cfg(feature = "federation")]
208 #[must_use]
209 pub fn entity_sources(&self) -> HashMap<String, crate::federation::EntitySource> {
210 use crate::federation::EntitySource;
211
212 let mut sources: HashMap<String, EntitySource> = HashMap::new();
213
214 // (1) Query-sourced — owned entities. First-wins per return_type.
215 for q in &self.queries {
216 if let Some(relation) = &q.sql_source {
217 sources.entry(q.return_type.clone()).or_insert_with(|| EntitySource {
218 relation: relation.clone(),
219 jsonb_column: (!q.jsonb_column.is_empty()).then(|| q.jsonb_column.clone()),
220 });
221 }
222 }
223
224 // (2) Type-sourced fallback — owner-split `extend type` entities (#507).
225 // Skipped for owned types (their type-level sql_source is empty) and never
226 // overrides a query-sourced entry. The empty-jsonb-column → flat-mode rule
227 // mirrors the query path above, so flat-column extends entities resolve too.
228 for t in &self.types {
229 if t.sql_source.as_str().is_empty() {
230 continue;
231 }
232 sources.entry(t.name.to_string()).or_insert_with(|| EntitySource {
233 relation: t.sql_source.to_string(),
234 jsonb_column: (!t.jsonb_column.is_empty()).then(|| t.jsonb_column.clone()),
235 });
236 }
237
238 sources
239 }
240
241 /// Stub federation metadata when federation feature is disabled.
242 #[cfg(not(feature = "federation"))]
243 #[must_use]
244 pub const fn federation_metadata(&self) -> Option<()> {
245 None
246 }
247
248 /// Get security configuration from schema.
249 ///
250 /// # Returns
251 ///
252 /// Security configuration if present (includes role definitions)
253 #[must_use]
254 pub const fn security_config(&self) -> Option<&SecurityConfig> {
255 self.security.as_ref()
256 }
257
258 /// Returns `true` if this schema declares a multi-tenant deployment.
259 ///
260 /// Multi-tenant schemas require Row-Level Security (RLS) to be active whenever
261 /// query result caching is enabled. Without RLS, all tenants sharing the same
262 /// query parameters would receive the same cached response.
263 ///
264 /// Detection is based on `security.multi_tenant` in the compiled schema JSON.
265 #[must_use]
266 pub fn is_multi_tenant(&self) -> bool {
267 self.security.as_ref().is_some_and(|s| s.multi_tenant)
268 }
269
270 /// Returns the tenancy isolation mode configured for this schema.
271 ///
272 /// Defaults to `TenancyMode::None` when no security or tenancy configuration
273 /// is present, meaning single-tenant operation with no isolation machinery.
274 #[must_use]
275 pub fn tenancy_mode(&self) -> crate::schema::TenancyMode {
276 self.security
277 .as_ref()
278 .map_or(crate::schema::TenancyMode::None, |s| s.tenancy.mode)
279 }
280
281 /// Returns the tenancy configuration, if present.
282 ///
283 /// Returns `None` when no security configuration exists. Returns the
284 /// default `TenancyConfig` (mode=none) when security exists but tenancy
285 /// is not explicitly configured.
286 #[must_use]
287 pub fn tenancy_config(&self) -> Option<&crate::schema::TenancyConfig> {
288 self.security.as_ref().map(|s| &s.tenancy)
289 }
290
291 /// Find a role definition by name.
292 ///
293 /// # Arguments
294 ///
295 /// * `role_name` - Name of the role to find
296 ///
297 /// # Returns
298 ///
299 /// Role definition if found
300 #[must_use]
301 pub fn find_role(&self, role_name: &str) -> Option<RoleDefinition> {
302 self.security.as_ref().and_then(|config| config.find_role(role_name).cloned())
303 }
304
305 /// Get scopes for a role.
306 ///
307 /// # Arguments
308 ///
309 /// * `role_name` - Name of the role
310 ///
311 /// # Returns
312 ///
313 /// Vector of scopes granted to the role
314 #[must_use]
315 pub fn get_role_scopes(&self, role_name: &str) -> Vec<String> {
316 self.security
317 .as_ref()
318 .map(|config| config.get_role_scopes(role_name))
319 .unwrap_or_default()
320 }
321
322 /// Check if a role has a specific scope.
323 ///
324 /// # Arguments
325 ///
326 /// * `role_name` - Name of the role
327 /// * `scope` - Scope to check for
328 ///
329 /// # Returns
330 ///
331 /// true if role has the scope, false otherwise
332 #[must_use]
333 pub fn role_has_scope(&self, role_name: &str, scope: &str) -> bool {
334 self.security
335 .as_ref()
336 .is_some_and(|config| config.role_has_scope(role_name, scope))
337 }
338
339 /// Returns `true` if Row-Level Security policies are declared in this schema.
340 ///
341 /// Used at server startup to validate that caching is safe for multi-tenant
342 /// deployments. When caching is enabled and no RLS policies are configured,
343 /// the server emits a startup warning about potential data leakage.
344 ///
345 /// # Example
346 ///
347 /// ```
348 /// use fraiseql_core::schema::CompiledSchema;
349 ///
350 /// let schema = CompiledSchema::default();
351 /// assert!(!schema.has_rls_configured());
352 /// ```
353 #[must_use]
354 pub fn has_rls_configured(&self) -> bool {
355 self.security.as_ref().is_some_and(|s| {
356 !s.additional
357 .get("policies")
358 .and_then(|p: &serde_json::Value| p.as_array())
359 .is_none_or(|a| a.is_empty())
360 })
361 }
362
363 /// Get raw GraphQL schema SDL.
364 ///
365 /// # Returns
366 ///
367 /// Raw schema string if available, otherwise generates from type definitions,
368 /// including the root `Query`/`Mutation` types.
369 ///
370 /// Root operations are stored in [`self.queries`](Self::queries) /
371 /// [`self.mutations`](Self::mutations) rather than as `Query`/`Mutation` object
372 /// types in [`self.types`](Self::types), so they are rendered here explicitly.
373 /// Omitting them produces an SDL that advertises no root fields — which makes the
374 /// federation `_service` SDL (built from this output) fail gateway composition
375 /// with `NO_QUERIES`.
376 #[must_use]
377 pub fn raw_schema(&self) -> String {
378 self.schema_sdl.clone().unwrap_or_else(|| {
379 // Generate basic SDL from type definitions if not provided
380 let mut sdl = String::new();
381
382 // Non-built-in scalar declarations. The rendered operations and fields
383 // reference custom and standard-but-non-built-in scalars (`DateTime`,
384 // `JSON`, `Decimal`, rich scalars, …); a gateway composing the subgraph
385 // reports `Unknown type` for any it isn't declared.
386 for name in self.referenced_scalars() {
387 let _ = writeln!(sdl, "scalar {name}");
388 }
389 if !self.enums.is_empty()
390 || !self.interfaces.is_empty()
391 || !self.input_types.is_empty()
392 || !self.unions.is_empty()
393 || !self.types.is_empty()
394 {
395 sdl.push('\n');
396 }
397
398 // Enum types
399 for enum_def in &self.enums {
400 let _ = writeln!(sdl, "enum {} {{", enum_def.name);
401 for value in &enum_def.values {
402 let _ = writeln!(sdl, " {}", value.name);
403 }
404 sdl.push_str("}\n\n");
405 }
406
407 // Interface types
408 for iface in &self.interfaces {
409 let _ = writeln!(sdl, "interface {} {{", iface.name);
410 for field in &iface.fields {
411 let _ = writeln!(sdl, " {}: {}", field.name, field.field_type);
412 }
413 sdl.push_str("}\n\n");
414 }
415
416 // Input object types (`field_type` is a pre-rendered GraphQL string;
417 // normalise the trailing non-null marker against the `nullable` flag).
418 for input in &self.input_types {
419 let _ = writeln!(sdl, "input {} {{", input.name);
420 for field in &input.fields {
421 let base = field.field_type.trim_end_matches('!');
422 let non_null = if field.nullable { "" } else { "!" };
423 let _ = writeln!(sdl, " {}: {base}{non_null}", field.name);
424 }
425 sdl.push_str("}\n\n");
426 }
427
428 // Union types (covers synthesized mutation result unions)
429 for union_def in &self.unions {
430 let _ = writeln!(
431 sdl,
432 "union {} = {}",
433 union_def.name,
434 union_def.member_types.join(" | ")
435 );
436 }
437 if !self.unions.is_empty() {
438 sdl.push('\n');
439 }
440
441 // Add output/object types
442 for type_def in &self.types {
443 let _ = writeln!(sdl, "type {} {{", type_def.name);
444 for field in &type_def.fields {
445 let _ = writeln!(sdl, " {}: {}", field.name, field.field_type);
446 }
447 sdl.push_str("}\n\n");
448 }
449
450 // Root Query type (rendered from `self.queries`, never present in `types`)
451 if !self.queries.is_empty() {
452 sdl.push_str("type Query {\n");
453 for q in &self.queries {
454 let _ = writeln!(
455 sdl,
456 " {}",
457 render_operation_field(
458 &q.name,
459 &q.graphql_arguments(),
460 &q.return_type,
461 q.returns_list,
462 q.nullable,
463 )
464 );
465 }
466 sdl.push_str("}\n\n");
467 }
468
469 // Root Mutation type (rendered from `self.mutations`). Mutation payloads
470 // are single, non-null values, so they render as `Name(args): Return!`.
471 if !self.mutations.is_empty() {
472 sdl.push_str("type Mutation {\n");
473 for m in &self.mutations {
474 let _ = writeln!(
475 sdl,
476 " {}",
477 render_operation_field(&m.name, &m.arguments, &m.return_type, false, false)
478 );
479 }
480 sdl.push_str("}\n\n");
481 }
482
483 sdl
484 })
485 }
486
487 /// Collect the non-built-in scalar type names the schema references, so
488 /// [`raw_schema`](Self::raw_schema) can declare each one (`scalar Name`) and the
489 /// SDL is type-complete.
490 ///
491 /// A referenced type is treated as a scalar to declare when it is neither a
492 /// built-in GraphQL scalar nor a type the schema defines as an object, enum,
493 /// input, interface, or union. Names are collected **exactly as the fields render
494 /// them** (the verbatim leaf of each field/argument type and each operation return
495 /// type) so the declaration and the reference always agree — declaring a canonical
496 /// alias (`DateTime`) while a field renders `datetime` would leave the reference
497 /// dangling (`Unknown type datetime`). The custom-scalar registry is also included.
498 /// The federation `_Any`/`_Entity`/`_Service`/`_FieldSet` built-ins (supplied by the
499 /// federation layer) are excluded.
500 fn referenced_scalars(&self) -> Vec<String> {
501 use std::collections::{BTreeSet, HashSet};
502
503 const BUILTINS: [&str; 5] = ["String", "Int", "Float", "Boolean", "ID"];
504 const FED_BUILTINS: [&str; 4] = ["_Any", "_Entity", "_Service", "_FieldSet"];
505
506 // Names the schema defines as composite types — never re-declared as scalars.
507 let mut defined: HashSet<&str> = HashSet::new();
508 for t in &self.types {
509 defined.insert(t.name.as_str());
510 }
511 for e in &self.enums {
512 defined.insert(e.name.as_str());
513 }
514 for i in &self.input_types {
515 defined.insert(i.name.as_str());
516 }
517 for i in &self.interfaces {
518 defined.insert(i.name.as_str());
519 }
520 for u in &self.unions {
521 defined.insert(u.name.as_str());
522 }
523
524 // Every type reference, collected as the verbatim leaf name fields render.
525 let mut referenced: BTreeSet<String> = BTreeSet::new();
526 let add = |rendered: &str, set: &mut BTreeSet<String>| {
527 let leaf = leaf_type_name(rendered);
528 if !leaf.is_empty() {
529 set.insert(leaf);
530 }
531 };
532 for type_def in &self.types {
533 for field in &type_def.fields {
534 add(&field.field_type.to_string(), &mut referenced);
535 }
536 }
537 for iface in &self.interfaces {
538 for field in &iface.fields {
539 add(&field.field_type.to_string(), &mut referenced);
540 }
541 }
542 for query in &self.queries {
543 // Walk the *rendered* arguments so any scalar synthesized for an
544 // `auto_params` query (notably `JSON` for `where`/`orderBy`) is
545 // declared — `render_operation_field` renders this same list.
546 for arg in &query.graphql_arguments() {
547 add(&arg.arg_type.to_string(), &mut referenced);
548 }
549 add(&query.return_type, &mut referenced);
550 }
551 for mutation in &self.mutations {
552 for arg in &mutation.arguments {
553 add(&arg.arg_type.to_string(), &mut referenced);
554 }
555 add(&mutation.return_type, &mut referenced);
556 }
557 for input in &self.input_types {
558 for field in &input.fields {
559 add(&field.field_type, &mut referenced);
560 }
561 }
562 for (name, _) in self.custom_scalars.list_all() {
563 referenced.insert(name);
564 }
565
566 referenced
567 .into_iter()
568 .filter(|name| {
569 !defined.contains(name.as_str())
570 && !BUILTINS.contains(&name.as_str())
571 && !FED_BUILTINS.contains(&name.as_str())
572 })
573 .collect()
574 }
575
576 /// Validate the schema for internal consistency.
577 ///
578 /// Checks:
579 /// - All type references resolve to defined types
580 /// - No duplicate type/operation names
581 /// - Required fields have valid types
582 ///
583 /// # Errors
584 ///
585 /// Returns list of validation errors if schema is invalid.
586 pub fn validate(&self) -> Result<(), Vec<String>> {
587 let mut errors = Vec::new();
588
589 // Check for duplicate type names
590 let mut type_names: std::collections::HashSet<&str> = std::collections::HashSet::new();
591 for type_def in &self.types {
592 if !type_names.insert(type_def.name.as_str()) {
593 errors.push(format!("Duplicate type name: {}", type_def.name));
594 }
595 }
596
597 // Check for duplicate query names
598 let mut query_names: std::collections::HashSet<&str> = std::collections::HashSet::new();
599 for query in &self.queries {
600 if !query_names.insert(&query.name) {
601 errors.push(format!("Duplicate query name: {}", query.name));
602 }
603 }
604
605 // Check for duplicate mutation names
606 let mut mutation_names: std::collections::HashSet<&str> = std::collections::HashSet::new();
607 for mutation in &self.mutations {
608 if !mutation_names.insert(&mutation.name) {
609 errors.push(format!("Duplicate mutation name: {}", mutation.name));
610 }
611 }
612
613 // Check type references in queries
614 for query in &self.queries {
615 if !type_names.contains(query.return_type.as_str())
616 && !is_builtin_type(&query.return_type)
617 {
618 errors.push(format!(
619 "Query '{}' references undefined type '{}'",
620 query.name, query.return_type
621 ));
622 }
623 }
624
625 // Check type references in mutations
626 for mutation in &self.mutations {
627 if !type_names.contains(mutation.return_type.as_str())
628 && !is_builtin_type(&mutation.return_type)
629 {
630 errors.push(format!(
631 "Mutation '{}' references undefined type '{}'",
632 mutation.name, mutation.return_type
633 ));
634 }
635 }
636
637 if errors.is_empty() {
638 Ok(())
639 } else {
640 Err(errors)
641 }
642 }
643}
644
645/// Render a root operation as a GraphQL SDL field: `name(arg: T!, …): Return`.
646///
647/// `return_type` is a bare type name; list-ness and nullability are applied here so
648/// the rendered signature matches GraphQL conventions (`[User!]!`, `User`, `User!`).
649fn render_operation_field(
650 name: &str,
651 arguments: &[crate::schema::ArgumentDefinition],
652 return_type: &str,
653 returns_list: bool,
654 nullable: bool,
655) -> String {
656 let non_null = if nullable { "" } else { "!" };
657 let ret = if returns_list {
658 format!("[{return_type}!]{non_null}")
659 } else {
660 format!("{return_type}{non_null}")
661 };
662 if arguments.is_empty() {
663 return format!("{name}: {ret}");
664 }
665 let args = arguments
666 .iter()
667 .map(|a| format!("{}: {}{}", a.name, a.arg_type, if a.nullable { "" } else { "!" }))
668 .collect::<Vec<_>>()
669 .join(", ");
670 format!("{name}({args}): {ret}")
671}
672
673/// Strip GraphQL list and non-null markers from a rendered type string, leaving the
674/// bare leaf type name: `[User!]!` → `User`, `datetime` → `datetime`.
675fn leaf_type_name(rendered: &str) -> String {
676 rendered
677 .chars()
678 .filter(|c| !matches!(c, '[' | ']' | '!'))
679 .collect::<String>()
680 .trim()
681 .to_string()
682}
683
684/// Check if a type name is a built-in scalar type.
685fn is_builtin_type(name: &str) -> bool {
686 matches!(
687 name,
688 "String"
689 | "Int"
690 | "Float"
691 | "Boolean"
692 | "ID"
693 | "DateTime"
694 | "Date"
695 | "Time"
696 | "JSON"
697 | "UUID"
698 | "Decimal"
699 )
700}