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_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}