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_FRAISEQL_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 /// Refuse a compiled schema this build did not produce (#1304).
21 ///
22 /// The compiled schema is a build artifact of the fraiseql release that
23 /// wrote it, so the only artifact whose meaning this runtime can vouch for
24 /// is its own build's. The check is an equality, deliberately: a runtime
25 /// that decided whether another build's artifact were close enough would be
26 /// making the judgement #1303 got wrong, silently, on the operator's behalf.
27 ///
28 /// An artifact carrying no stamp is refused too. It is the *older* case —
29 /// nothing this project has shipped since v2.1 omits the field — and
30 /// accepting it, as the format-version check it replaces did, left the
31 /// guard admitting the stalest artifacts while refusing merely-stale ones.
32 ///
33 /// # Errors
34 ///
35 /// Returns an operator-facing message naming both builds and the recompile,
36 /// unless the artifact was produced by this one.
37 pub fn validate_producer_version(&self) -> Result<(), String> {
38 match self.fraiseql_version.get() {
39 Some(v) if v == CURRENT_FRAISEQL_VERSION => Ok(()),
40 Some(v) => Err(format!(
41 "Compiled schema was produced by fraiseql {v}, but this runtime is fraiseql \
42 {CURRENT_FRAISEQL_VERSION}. A compiled schema is a build artifact of the \
43 release that produced it: this runtime reads fields {v} never wrote, and \
44 reads their absence as a setting rather than as a gap. Recompile the schema \
45 with fraiseql-cli {CURRENT_FRAISEQL_VERSION}."
46 )),
47 None => Err(format!(
48 "Compiled schema carries no `fraiseql_version` stamp, so the build that \
49 produced it cannot be established; this runtime is fraiseql \
50 {CURRENT_FRAISEQL_VERSION}. The stamp was introduced in fraiseql 2.15.0, so \
51 an unstamped artifact was written by an earlier release or by hand — either \
52 way this runtime cannot vouch for what its fields, and its missing fields, \
53 mean here. Recompile it with fraiseql-cli {CURRENT_FRAISEQL_VERSION}."
54 )),
55 }
56 }
57
58 /// Register fact table metadata.
59 ///
60 /// # Arguments
61 ///
62 /// * `table_name` - Fact table name (e.g., `tf_sales`)
63 /// * `metadata` - Typed `FactTableMetadata`
64 pub fn add_fact_table(&mut self, table_name: String, metadata: FactTableMetadata) {
65 self.fact_tables.insert(table_name, metadata);
66 }
67
68 /// Get fact table metadata by name.
69 ///
70 /// # Arguments
71 ///
72 /// * `name` - Fact table name
73 ///
74 /// # Returns
75 ///
76 /// Fact table metadata if found
77 #[must_use]
78 pub fn get_fact_table(&self, name: &str) -> Option<&FactTableMetadata> {
79 self.fact_tables.get(name)
80 }
81
82 /// List all fact table names.
83 ///
84 /// # Returns
85 ///
86 /// Vector of fact table names
87 #[must_use]
88 pub fn list_fact_tables(&self) -> Vec<&str> {
89 self.fact_tables.keys().map(String::as_str).collect()
90 }
91
92 /// Check if schema contains any fact tables.
93 #[must_use]
94 pub fn has_fact_tables(&self) -> bool {
95 !self.fact_tables.is_empty()
96 }
97
98 /// Find an observer definition by name.
99 #[must_use]
100 pub fn find_observer(&self, name: &str) -> Option<&ObserverDefinition> {
101 self.observers.iter().find(|o| o.name == name)
102 }
103
104 /// Get all observers for a specific entity type.
105 #[must_use]
106 pub fn find_observers_for_entity(&self, entity: &str) -> Vec<&ObserverDefinition> {
107 self.observers.iter().filter(|o| o.entity == entity).collect()
108 }
109
110 /// Get all observers for a specific event type (INSERT, UPDATE, DELETE).
111 #[must_use]
112 pub fn find_observers_for_event(&self, event: &str) -> Vec<&ObserverDefinition> {
113 self.observers.iter().filter(|o| o.event == event).collect()
114 }
115
116 /// Check if schema contains any observers.
117 #[must_use]
118 pub const fn has_observers(&self) -> bool {
119 !self.observers.is_empty()
120 }
121
122 /// Get total number of observers.
123 #[must_use]
124 pub const fn observer_count(&self) -> usize {
125 self.observers.len()
126 }
127
128 /// Get federation metadata from schema.
129 ///
130 /// # Returns
131 ///
132 /// Federation metadata if configured in schema
133 #[cfg(feature = "federation")]
134 #[must_use]
135 pub fn federation_metadata(&self) -> Option<crate::federation::FederationMetadata> {
136 self.federation.as_ref().filter(|fed| fed.enabled).map(|fed| {
137 use crate::federation::types::{
138 FederatedType, FieldFederationDirectives, KeyDirective,
139 };
140
141 // Entities carry an `@key` (and, for an extended entity, `extend type` +
142 // `@external` on the borrowed key/fields). Per-field directives are
143 // rebuilt from the entity's `external_fields` / `shareable_fields` so the
144 // SDL renderer can append `@external` / `@shareable` to each field line.
145 let mut types: Vec<FederatedType> = fed
146 .entities
147 .iter()
148 .map(|e| {
149 let mut field_directives: HashMap<String, FieldFederationDirectives> =
150 HashMap::new();
151 for f in &e.external_fields {
152 field_directives.entry(f.clone()).or_default().external = true;
153 }
154 for f in &e.shareable_fields {
155 field_directives.entry(f.clone()).or_default().shareable = true;
156 }
157 FederatedType {
158 name: e.name.clone(),
159 keys: vec![KeyDirective {
160 fields: e.key_fields.clone(),
161 resolvable: true,
162 }],
163 is_extends: e.extends,
164 external_fields: e.external_fields.clone(),
165 shareable_fields: e.shareable_fields.clone(),
166 inaccessible_fields: Vec::new(),
167 field_directives,
168 type_shareable: false,
169 }
170 })
171 .collect();
172
173 // Non-entity `@shareable` value types (e.g. a shared `MutationError`):
174 // no `@key`, never a member of the `_Entity` union — they only receive a
175 // type-level `@shareable` so both subgraphs can define the identical type
176 // without an `INVALID_FIELD_SHARING` composition error.
177 for name in &fed.shareable_types {
178 types.push(FederatedType {
179 name: name.clone(),
180 keys: Vec::new(),
181 is_extends: false,
182 external_fields: Vec::new(),
183 shareable_fields: Vec::new(),
184 inaccessible_fields: Vec::new(),
185 field_directives: HashMap::new(),
186 type_shareable: true,
187 });
188 }
189
190 crate::federation::FederationMetadata {
191 enabled: fed.enabled,
192 version: fed.version.clone().unwrap_or_else(|| "v2".to_string()),
193 types,
194 }
195 })
196 }
197
198 /// Build the per-entity-type backing source map (`typename` →
199 /// [`EntitySource`](crate::federation::EntitySource)) the federation
200 /// `_entities` resolver reads from instead of guessing `lower(typename)`
201 /// (#504/#507).
202 ///
203 /// Two sources, query-wins:
204 ///
205 /// 1. **Query-sourced** (owned entities): the backing relation rides on the root query that
206 /// returns the type, keyed by `return_type`, first-wins — the same query→type binding the
207 /// Relay `node` path uses. The query's `jsonb_column` (which the compiler defaults to
208 /// `"data"`) drives jsonb projection.
209 /// 2. **Type-sourced fallback** (#507): an owner-split `extend type … @key` entity resolved in
210 /// a subgraph that does not own it exposes no root query, so there is nothing in (1) to
211 /// source its relation from. Its relation instead rides on the type-level `sql_source` the
212 /// compiler carries from the authoring SDK. This only fills gaps — a query-sourced entry
213 /// always wins.
214 ///
215 /// Both sources read the entity's `jsonb_column` the same way: a non-empty column selects
216 /// jsonb-projection mode (`<col>->'<field>'`), an empty one selects flat-column mode (bare
217 /// columns). The compiler defaults both a query's and an extends type's `jsonb_column` to the
218 /// standard `"data"` view shape, so a flat-column entity must be authored with an explicit
219 /// empty `jsonb_column`.
220 #[cfg(feature = "federation")]
221 #[must_use]
222 pub fn entity_sources(&self) -> HashMap<String, crate::federation::EntitySource> {
223 use crate::federation::EntitySource;
224
225 let mut sources: HashMap<String, EntitySource> = HashMap::new();
226
227 // (1) Query-sourced — owned entities. First-wins per return_type.
228 for q in &self.queries {
229 if let Some(relation) = &q.sql_source {
230 sources.entry(q.return_type.clone()).or_insert_with(|| EntitySource {
231 relation: relation.clone(),
232 jsonb_column: (!q.jsonb_column.is_empty()).then(|| q.jsonb_column.clone()),
233 });
234 }
235 }
236
237 // (2) Type-sourced fallback — owner-split `extend type` entities (#507).
238 // Skipped for owned types (their type-level sql_source is empty) and never
239 // overrides a query-sourced entry. The empty-jsonb-column → flat-mode rule
240 // mirrors the query path above, so flat-column extends entities resolve too.
241 for t in &self.types {
242 if t.sql_source.as_str().is_empty() {
243 continue;
244 }
245 sources.entry(t.name.to_string()).or_insert_with(|| EntitySource {
246 relation: t.sql_source.to_string(),
247 jsonb_column: (!t.jsonb_column.is_empty()).then(|| t.jsonb_column.clone()),
248 });
249 }
250
251 sources
252 }
253
254 /// Stub federation metadata when federation feature is disabled.
255 #[cfg(not(feature = "federation"))]
256 #[must_use]
257 pub const fn federation_metadata(&self) -> Option<()> {
258 None
259 }
260
261 /// Get security configuration from schema.
262 ///
263 /// # Returns
264 ///
265 /// Security configuration if present (includes role definitions)
266 #[must_use]
267 pub const fn security_config(&self) -> Option<&SecurityConfig> {
268 self.security.as_ref()
269 }
270
271 /// Returns `true` if this schema declares a multi-tenant deployment.
272 ///
273 /// Multi-tenant schemas require Row-Level Security (RLS) to be active whenever
274 /// query result caching is enabled. Without RLS, all tenants sharing the same
275 /// query parameters would receive the same cached response.
276 ///
277 /// True when `[security] multi_tenant = true` **or** a non-`none`
278 /// `[tenancy] mode`. The second clause is load-bearing: `tenancy.mode` is the
279 /// knob operators actually set, and reading only `security.multi_tenant` — which
280 /// no compile path could produce — left both dependent gates permanently off
281 /// (#758).
282 #[must_use]
283 pub fn is_multi_tenant(&self) -> bool {
284 self.security
285 .as_ref()
286 .is_some_and(|s| s.multi_tenant || s.tenancy.mode != crate::schema::TenancyMode::None)
287 }
288
289 /// Returns the tenancy isolation mode configured for this schema.
290 ///
291 /// Defaults to `TenancyMode::None` when no security or tenancy configuration
292 /// is present, meaning single-tenant operation with no isolation machinery.
293 #[must_use]
294 pub fn tenancy_mode(&self) -> crate::schema::TenancyMode {
295 self.security
296 .as_ref()
297 .map_or(crate::schema::TenancyMode::None, |s| s.tenancy.mode)
298 }
299
300 /// Returns the tenancy configuration, if present.
301 ///
302 /// Returns `None` when no security configuration exists. Returns the
303 /// default `TenancyConfig` (mode=none) when security exists but tenancy
304 /// is not explicitly configured.
305 #[must_use]
306 pub fn tenancy_config(&self) -> Option<&crate::schema::TenancyConfig> {
307 self.security.as_ref().map(|s| &s.tenancy)
308 }
309
310 /// Find a role definition by name.
311 ///
312 /// # Arguments
313 ///
314 /// * `role_name` - Name of the role to find
315 ///
316 /// # Returns
317 ///
318 /// Role definition if found
319 #[must_use]
320 pub fn find_role(&self, role_name: &str) -> Option<RoleDefinition> {
321 self.security.as_ref().and_then(|config| config.find_role(role_name).cloned())
322 }
323
324 /// Get scopes for a role.
325 ///
326 /// # Arguments
327 ///
328 /// * `role_name` - Name of the role
329 ///
330 /// # Returns
331 ///
332 /// Vector of scopes granted to the role
333 #[must_use]
334 pub fn get_role_scopes(&self, role_name: &str) -> Vec<String> {
335 self.security
336 .as_ref()
337 .map(|config| config.get_role_scopes(role_name))
338 .unwrap_or_default()
339 }
340
341 /// Check if a role has a specific scope.
342 ///
343 /// # Arguments
344 ///
345 /// * `role_name` - Name of the role
346 /// * `scope` - Scope to check for
347 ///
348 /// # Returns
349 ///
350 /// true if role has the scope, false otherwise
351 #[must_use]
352 pub fn role_has_scope(&self, role_name: &str, scope: &str) -> bool {
353 self.security
354 .as_ref()
355 .is_some_and(|config| config.role_has_scope(role_name, scope))
356 }
357
358 /// Returns `true` if this schema declares that database Row-Level Security
359 /// isolates its data (`[security.rls] enabled = true`).
360 ///
361 /// Used at server startup to validate that caching is safe for multi-tenant
362 /// deployments, and to decide whether to verify the declaration against the
363 /// live database catalog.
364 ///
365 /// This is a *declaration*, not a proof: FraiseQL does not author RLS policies,
366 /// so nothing in the compiled schema can establish that they exist. That is what
367 /// [`CachedDatabaseAdapter::validate_rls_active`] checks against the real
368 /// database (#762).
369 ///
370 /// It previously counted `security.additional["policies"]` — authorization
371 /// policies, not RLS, and a section #612 made a hard compile error — so it
372 /// answered `false` for every schema any supported workflow could produce
373 /// (#758).
374 ///
375 /// [`CachedDatabaseAdapter::validate_rls_active`]: crate::cache::CachedDatabaseAdapter::validate_rls_active
376 ///
377 /// # Example
378 ///
379 /// ```
380 /// use fraiseql_core::schema::CompiledSchema;
381 ///
382 /// let schema = CompiledSchema::default();
383 /// assert!(!schema.has_rls_configured());
384 /// ```
385 #[must_use]
386 pub fn has_rls_configured(&self) -> bool {
387 self.security.as_ref().is_some_and(|s| s.rls.enabled)
388 }
389
390 /// Get raw GraphQL schema SDL.
391 ///
392 /// # Returns
393 ///
394 /// Raw schema string if available, otherwise generates from type definitions,
395 /// including the root `Query`/`Mutation` types.
396 ///
397 /// Root operations are stored in [`self.queries`](Self::queries) /
398 /// [`self.mutations`](Self::mutations) rather than as `Query`/`Mutation` object
399 /// types in [`self.types`](Self::types), so they are rendered here explicitly.
400 /// Omitting them produces an SDL that advertises no root fields — which makes the
401 /// federation `_service` SDL (built from this output) fail gateway composition
402 /// with `NO_QUERIES`.
403 #[must_use]
404 pub fn raw_schema(&self) -> String {
405 self.schema_sdl.clone().unwrap_or_else(|| {
406 // Generate basic SDL from type definitions if not provided
407 let mut sdl = String::new();
408
409 // Non-built-in scalar declarations. The rendered operations and fields
410 // reference custom and standard-but-non-built-in scalars (`DateTime`,
411 // `JSON`, `Decimal`, rich scalars, …); a gateway composing the subgraph
412 // reports `Unknown type` for any it isn't declared.
413 for name in self.referenced_scalars() {
414 let _ = writeln!(sdl, "scalar {name}");
415 }
416 if !self.enums.is_empty()
417 || !self.interfaces.is_empty()
418 || !self.input_types.is_empty()
419 || !self.unions.is_empty()
420 || !self.types.is_empty()
421 {
422 sdl.push('\n');
423 }
424
425 // Enum types
426 for enum_def in &self.enums {
427 let _ = writeln!(sdl, "enum {} {{", enum_def.name);
428 for value in &enum_def.values {
429 let _ = writeln!(sdl, " {}", value.name);
430 }
431 sdl.push_str("}\n\n");
432 }
433
434 // Interface types
435 for iface in &self.interfaces {
436 let _ = writeln!(sdl, "interface {} {{", iface.name);
437 for field in &iface.fields {
438 let _ = writeln!(sdl, " {}: {}", field.name, field.field_type);
439 }
440 sdl.push_str("}\n\n");
441 }
442
443 // Input object types (`field_type` is a pre-rendered GraphQL string;
444 // normalise the trailing non-null marker against the `nullable` flag).
445 for input in &self.input_types {
446 let _ = writeln!(sdl, "input {} {{", input.name);
447 for field in &input.fields {
448 let base = field.field_type.trim_end_matches('!');
449 let non_null = if field.nullable { "" } else { "!" };
450 let _ = writeln!(sdl, " {}: {base}{non_null}", field.name);
451 }
452 sdl.push_str("}\n\n");
453 }
454
455 // Union types (covers synthesized mutation result unions)
456 for union_def in &self.unions {
457 let _ = writeln!(
458 sdl,
459 "union {} = {}",
460 union_def.name,
461 union_def.member_types.join(" | ")
462 );
463 }
464 if !self.unions.is_empty() {
465 sdl.push('\n');
466 }
467
468 // Add output/object types
469 for type_def in &self.types {
470 let _ = writeln!(sdl, "type {} {{", type_def.name);
471 for field in &type_def.fields {
472 let _ = writeln!(sdl, " {}: {}", field.name, field.field_type);
473 }
474 sdl.push_str("}\n\n");
475 }
476
477 // Root Query type (rendered from `self.queries`, never present in `types`)
478 if !self.queries.is_empty() {
479 sdl.push_str("type Query {\n");
480 for q in &self.queries {
481 let _ = writeln!(
482 sdl,
483 " {}",
484 render_operation_field(
485 &q.name,
486 &q.graphql_arguments(self),
487 &q.return_type,
488 q.returns_list,
489 q.nullable,
490 )
491 );
492 }
493 sdl.push_str("}\n\n");
494 }
495
496 // Root Mutation type (rendered from `self.mutations`). Mutation payloads
497 // are single, non-null values, so they render as `Name(args): Return!`.
498 if !self.mutations.is_empty() {
499 sdl.push_str("type Mutation {\n");
500 for m in &self.mutations {
501 let _ = writeln!(
502 sdl,
503 " {}",
504 render_operation_field(&m.name, &m.arguments, &m.return_type, false, false)
505 );
506 }
507 sdl.push_str("}\n\n");
508 }
509
510 sdl
511 })
512 }
513
514 /// Collect the non-built-in scalar type names the schema references, so
515 /// [`raw_schema`](Self::raw_schema) can declare each one (`scalar Name`) and the
516 /// SDL is type-complete.
517 ///
518 /// A referenced type is treated as a scalar to declare when it is neither a
519 /// built-in GraphQL scalar nor a type the schema defines as an object, enum,
520 /// input, interface, or union. Names are collected **exactly as the fields render
521 /// them** (the verbatim leaf of each field/argument type and each operation return
522 /// type) so the declaration and the reference always agree — declaring a canonical
523 /// alias (`DateTime`) while a field renders `datetime` would leave the reference
524 /// dangling (`Unknown type datetime`). The custom-scalar registry is also included.
525 /// The federation `_Any`/`_Entity`/`_Service`/`_FieldSet` built-ins (supplied by the
526 /// federation layer) are excluded.
527 pub(crate) fn referenced_scalars(&self) -> Vec<String> {
528 use std::collections::{BTreeSet, HashSet};
529
530 const BUILTINS: [&str; 5] = ["String", "Int", "Float", "Boolean", "ID"];
531 const FED_BUILTINS: [&str; 4] = ["_Any", "_Entity", "_Service", "_FieldSet"];
532
533 // Names the schema defines as composite types — never re-declared as scalars.
534 let mut defined: HashSet<&str> = HashSet::new();
535 for t in &self.types {
536 defined.insert(t.name.as_str());
537 }
538 for e in &self.enums {
539 defined.insert(e.name.as_str());
540 }
541 for i in &self.input_types {
542 defined.insert(i.name.as_str());
543 }
544 for i in &self.interfaces {
545 defined.insert(i.name.as_str());
546 }
547 for u in &self.unions {
548 defined.insert(u.name.as_str());
549 }
550
551 // Every type reference, collected as the verbatim leaf name fields render.
552 let mut referenced: BTreeSet<String> = BTreeSet::new();
553 let add = |rendered: &str, set: &mut BTreeSet<String>| {
554 let leaf = leaf_type_name(rendered);
555 if !leaf.is_empty() {
556 set.insert(leaf);
557 }
558 };
559 for type_def in &self.types {
560 for field in &type_def.fields {
561 add(&field.field_type.to_string(), &mut referenced);
562 }
563 }
564 for iface in &self.interfaces {
565 for field in &iface.fields {
566 add(&field.field_type.to_string(), &mut referenced);
567 }
568 }
569 for query in &self.queries {
570 // Walk the *rendered* arguments so any scalar synthesized for an
571 // `auto_params` query (notably `JSON` for `where`/`orderBy`) is
572 // declared — `render_operation_field` renders this same list.
573 for arg in &query.graphql_arguments(self) {
574 add(&arg.arg_type.to_string(), &mut referenced);
575 }
576 add(&query.return_type, &mut referenced);
577 }
578 for mutation in &self.mutations {
579 for arg in &mutation.arguments {
580 add(&arg.arg_type.to_string(), &mut referenced);
581 }
582 add(&mutation.return_type, &mut referenced);
583 }
584 for input in &self.input_types {
585 for field in &input.fields {
586 add(&field.field_type, &mut referenced);
587 }
588 }
589 for (name, _) in self.custom_scalars.list_all() {
590 referenced.insert(name);
591 }
592
593 referenced
594 .into_iter()
595 .filter(|name| {
596 !defined.contains(name.as_str())
597 && !BUILTINS.contains(&name.as_str())
598 && !FED_BUILTINS.contains(&name.as_str())
599 })
600 .collect()
601 }
602}
603
604/// Render a root operation as a GraphQL SDL field: `name(arg: T!, …): Return`.
605///
606/// `return_type` is a bare type name; list-ness and nullability are applied here so
607/// the rendered signature matches GraphQL conventions (`[User!]!`, `User`, `User!`).
608fn render_operation_field(
609 name: &str,
610 arguments: &[crate::schema::ArgumentDefinition],
611 return_type: &str,
612 returns_list: bool,
613 nullable: bool,
614) -> String {
615 let non_null = if nullable { "" } else { "!" };
616 let ret = if returns_list {
617 format!("[{return_type}!]{non_null}")
618 } else {
619 format!("{return_type}{non_null}")
620 };
621 if arguments.is_empty() {
622 return format!("{name}: {ret}");
623 }
624 let args = arguments
625 .iter()
626 .map(|a| format!("{}: {}{}", a.name, a.arg_type, if a.nullable { "" } else { "!" }))
627 .collect::<Vec<_>>()
628 .join(", ");
629 format!("{name}({args}): {ret}")
630}
631
632/// Strip GraphQL list and non-null markers from a rendered type string, leaving the
633/// bare leaf type name: `[User!]!` → `User`, `datetime` → `datetime`.
634fn leaf_type_name(rendered: &str) -> String {
635 rendered
636 .chars()
637 .filter(|c| !matches!(c, '[' | ']' | '!'))
638 .collect::<String>()
639 .trim()
640 .to_string()
641}