Skip to main content

fraiseql_core/schema/compiled/
schema.rs

1//! Compiled schema types - pure Rust, no authoring-language references.
2//!
3//! These types represent GraphQL schemas after compilation from authoring languages.
4//! All data is owned by Rust - no foreign object references.
5//!
6//! # Schema Freeze Invariant
7//!
8//! After `CompiledSchema::from_json()`, the schema is frozen:
9//! - All data is Rust-owned
10//! - No authoring-language callbacks or object references
11//! - Safe to use from any Tokio worker thread
12//!
13//! This enables the Axum server to handle requests without any
14//! interaction with the authoring-language runtime.
15
16use std::collections::HashMap;
17
18use serde::{Deserialize, Serialize};
19
20use super::{directive::DirectiveDefinition, mutation::MutationDefinition, query::QueryDefinition};
21use crate::{
22    compiler::fact_table::FactTableMetadata,
23    schema::{
24        config_types::{
25            ChangelogConfig, DebugConfig, FederationConfig, GrpcConfig, McpConfig,
26            NamingConvention, ObserversConfig, RestConfig, SessionVariablesConfig,
27            SubscriptionsConfig, ValidationConfig,
28        },
29        graphql_type_defs::{
30            EnumDefinition, InputObjectDefinition, InterfaceDefinition, TypeDefinition,
31            UnionDefinition,
32        },
33        hierarchy::HierarchiesConfig,
34        observer_types::ObserverDefinition,
35        security_config::SecurityConfig,
36        subscription_types::SubscriptionDefinition,
37    },
38    validation::CustomTypeRegistry,
39};
40
41/// Current schema format version.
42///
43/// Increment this constant when the compiled schema JSON format changes in a
44/// backward-incompatible way so that startup rejects stale compiled schemas.
45pub const CURRENT_SCHEMA_FORMAT_VERSION: u32 = 1;
46
47/// A `@subscribable` declaration in the compiled schema (#366).
48///
49/// Maps a GraphQL type to the physical base table(s) whose **external** writes
50/// (a raw `INSERT`/`UPDATE`/`DELETE` from psql / a migration / a third-party
51/// tool) should be captured onto the Change Spine by the shipped fallback trigger
52/// `core.fn_entity_change_log_capture`. The compiler aggregates one of these per
53/// type carrying `@subscribable(tables=[...])`; the
54/// [`generate_capture_trigger_ddl`](crate::schema::generate_capture_trigger_ddl)
55/// generator turns them into per-table statement-level triggers that stamp
56/// `object_type = entity_type` — the GraphQL type name the reader and the
57/// subscription matcher key on, never the table name — so a captured external
58/// write fans out through the existing poller with no table→type lookup.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct SubscribableEntity {
61    /// The GraphQL type name (e.g. `"Post"`) stamped as `object_type` on every
62    /// captured change-log row.
63    pub entity_type: String,
64
65    /// The physical base table(s) backing `entity_type` (e.g. `["tb_post"]`,
66    /// optionally schema-qualified `["public.tb_post"]`). A capture trigger is
67    /// installed on each.
68    pub tables: Vec<String>,
69}
70
71/// Complete compiled schema - all type information for serving.
72///
73/// This is the central type that holds the entire GraphQL schema
74/// after compilation from any supported authoring language.
75///
76/// # Example
77///
78/// ```
79/// use fraiseql_core::schema::CompiledSchema;
80///
81/// let json = r#"{
82///     "types": [],
83///     "queries": [],
84///     "mutations": [],
85///     "subscriptions": []
86/// }"#;
87///
88/// let schema = CompiledSchema::from_json(json, false).unwrap();
89/// assert_eq!(schema.types.len(), 0);
90/// ```
91#[derive(Debug, Clone, Default, Serialize, Deserialize)]
92pub struct CompiledSchema {
93    /// GraphQL object type definitions.
94    #[serde(default)]
95    pub types: Vec<TypeDefinition>,
96
97    /// GraphQL enum type definitions.
98    #[serde(default)]
99    pub enums: Vec<EnumDefinition>,
100
101    /// GraphQL input object type definitions.
102    #[serde(default)]
103    pub input_types: Vec<InputObjectDefinition>,
104
105    /// GraphQL interface type definitions.
106    #[serde(default)]
107    pub interfaces: Vec<InterfaceDefinition>,
108
109    /// GraphQL union type definitions.
110    #[serde(default)]
111    pub unions: Vec<UnionDefinition>,
112
113    /// GraphQL query definitions.
114    #[serde(default)]
115    pub queries: Vec<QueryDefinition>,
116
117    /// GraphQL mutation definitions.
118    #[serde(default)]
119    pub mutations: Vec<MutationDefinition>,
120
121    /// GraphQL subscription definitions.
122    #[serde(default)]
123    pub subscriptions: Vec<SubscriptionDefinition>,
124
125    /// Custom directive definitions.
126    /// These are user-defined directives beyond the built-in @skip, @include, @deprecated.
127    #[serde(default, skip_serializing_if = "Vec::is_empty")]
128    pub directives: Vec<DirectiveDefinition>,
129
130    /// Fact table metadata (for analytics queries).
131    /// Key: table name (e.g., `tf_sales`)
132    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
133    pub fact_tables: HashMap<String, FactTableMetadata>,
134
135    /// Observer definitions (database change event listeners).
136    #[serde(default, skip_serializing_if = "Vec::is_empty")]
137    pub observers: Vec<ObserverDefinition>,
138
139    /// `@subscribable` declarations (#366): GraphQL types whose underlying
140    /// table(s) get the shipped external-write capture trigger.
141    ///
142    /// Aggregated by the compiler from each type's `@subscribable(tables=[...])`
143    /// annotation; consumed by
144    /// [`generate_capture_trigger_ddl`](crate::schema::generate_capture_trigger_ddl)
145    /// to emit per-table capture triggers. Empty (and omitted from the compiled
146    /// JSON) when no type is subscribable — so a schema that predates this field
147    /// deserializes and re-serializes byte-for-byte unchanged.
148    #[serde(default, skip_serializing_if = "Vec::is_empty")]
149    pub subscribable: Vec<SubscribableEntity>,
150
151    /// Federation metadata for Apollo Federation v2 support.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub federation: Option<FederationConfig>,
154
155    /// Security configuration (from fraiseql.toml).
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub security: Option<SecurityConfig>,
158
159    /// Observers/event system configuration (from fraiseql.toml).
160    ///
161    /// Contains backend connection settings (`redis_url`, `nats_url`, etc.) and
162    /// event handler definitions compiled from the `[observers]` TOML section.
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub observers_config: Option<ObserversConfig>,
165
166    /// `WebSocket` subscription configuration (hooks, limits).
167    /// Compiled from the `[subscriptions]` TOML section.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub subscriptions_config: Option<SubscriptionsConfig>,
170
171    /// Query validation config (depth/complexity limits).
172    /// Compiled from the `[validation]` TOML section.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub validation_config: Option<ValidationConfig>,
175
176    /// Debug/development configuration.
177    /// Compiled from the `[debug]` TOML section.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub debug_config: Option<DebugConfig>,
180
181    /// MCP (Model Context Protocol) server configuration.
182    /// Compiled from the `[mcp]` TOML section.
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub mcp_config: Option<McpConfig>,
185
186    /// REST transport configuration.
187    /// Compiled from the `[rest]` TOML section.
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub rest_config: Option<RestConfig>,
190
191    /// gRPC transport configuration.
192    /// Compiled from the `[grpc]` TOML section.
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub grpc_config: Option<GrpcConfig>,
195
196    /// Changelog GraphQL-exposure configuration.
197    ///
198    /// Compiled from the `[changelog]` TOML section. When present with
199    /// `expose = true`, the compiler injects the `EntityChangeLog` /
200    /// `TransportCheckpoint` types plus their cursor query, point-lookup query, and
201    /// checkpoint upsert mutation. `None` when the block is absent (the default).
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub changelog: Option<ChangelogConfig>,
204
205    /// Session variable injection configuration.
206    ///
207    /// When populated, the executor calls PostgreSQL `set_config()` before each
208    /// mutation, injecting per-request values (JWT claims, HTTP headers, literals)
209    /// as transaction-scoped settings.  SQL functions read these via
210    /// `current_setting('app.tenant_id', true)`.
211    ///
212    /// Compiled from the `[session_variables]` TOML section.
213    #[serde(default)]
214    pub session_variables: SessionVariablesConfig,
215
216    /// Hierarchy definitions for ID-based ltree operators.
217    ///
218    /// Maps hierarchy names to `table`/`path_column` pairs. Compiled from the
219    /// `[hierarchies]` TOML section. Used at runtime to resolve `HierarchyContext`
220    /// for `descendantOfId` / `ancestorOfId` WHERE clause generation.
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub hierarchies_config: Option<HierarchiesConfig>,
223
224    /// Naming convention for GraphQL operation names.
225    ///
226    /// When set to `CamelCase`, operation names are converted from `snake_case`
227    /// (e.g., `create_dns_server` → `createDnsServer`) in the introspection
228    /// schema and lookup indexes. Compiled from `[fraiseql]` in `fraiseql.toml`.
229    #[serde(default)]
230    pub naming_convention: NamingConvention,
231
232    /// Schema format version emitted by the compiler.
233    ///
234    /// Used to detect runtime/compiler skew. If present and ≠ `CURRENT_SCHEMA_FORMAT_VERSION`,
235    /// `validate_format_version()` returns an error.
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub schema_format_version: Option<u32>,
238
239    /// Raw GraphQL schema as string (for SDL generation).
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub schema_sdl: Option<String>,
242
243    /// Custom scalar type registry.
244    ///
245    /// Contains definitions for custom scalar types defined in the schema.
246    /// Built during code generation from `IRScalar` definitions.
247    /// Not serialized - populated at runtime from `ir.scalars`.
248    #[serde(skip)]
249    pub custom_scalars: CustomTypeRegistry,
250
251    /// O(1) lookup index: query name → index into `self.queries`.
252    /// Built at construction time by `build_indexes()`; not serialized.
253    /// Populated automatically by `from_json()`; call `build_indexes()` after
254    /// direct mutation of `self.queries`.
255    #[serde(skip)]
256    pub query_index: HashMap<String, usize>,
257
258    /// O(1) lookup index: mutation name → index into `self.mutations`.
259    /// Built at construction time by `build_indexes()`; not serialized.
260    /// Populated automatically by `from_json()`; call `build_indexes()` after
261    /// direct mutation of `self.mutations`.
262    #[serde(skip)]
263    pub mutation_index: HashMap<String, usize>,
264
265    /// O(1) lookup index: subscription name → index into `self.subscriptions`.
266    /// Built at construction time by `build_indexes()`; not serialized.
267    /// Populated automatically by `from_json()`; call `build_indexes()` after
268    /// direct mutation of `self.subscriptions`.
269    #[serde(skip)]
270    pub subscription_index: HashMap<String, usize>,
271}
272
273impl PartialEq for CompiledSchema {
274    fn eq(&self, other: &Self) -> bool {
275        // Compare all fields except custom_scalars (runtime state)
276        self.schema_format_version == other.schema_format_version
277            && self.types == other.types
278            && self.enums == other.enums
279            && self.input_types == other.input_types
280            && self.interfaces == other.interfaces
281            && self.unions == other.unions
282            && self.queries == other.queries
283            && self.mutations == other.mutations
284            && self.subscriptions == other.subscriptions
285            && self.directives == other.directives
286            && self.fact_tables == other.fact_tables
287            && self.observers == other.observers
288            && self.subscribable == other.subscribable
289            && self.federation == other.federation
290            && self.security == other.security
291            && self.observers_config == other.observers_config
292            && self.subscriptions_config == other.subscriptions_config
293            && self.validation_config == other.validation_config
294            && self.debug_config == other.debug_config
295            && self.mcp_config == other.mcp_config
296            && self.changelog == other.changelog
297            && self.naming_convention == other.naming_convention
298            && self.schema_sdl == other.schema_sdl
299    }
300}
301
302impl CompiledSchema {
303    /// Create empty schema.
304    #[must_use]
305    pub fn new() -> Self {
306        Self::default()
307    }
308}