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        source_types::SourceDefinition,
37        subscription_types::SubscriptionDefinition,
38    },
39    validation::CustomTypeRegistry,
40};
41
42/// Current schema format version.
43///
44/// Increment this constant when the compiled schema JSON format changes in a
45/// backward-incompatible way so that startup rejects stale compiled schemas.
46pub const CURRENT_SCHEMA_FORMAT_VERSION: u32 = 1;
47
48/// A `@subscribable` declaration in the compiled schema (#366).
49///
50/// Maps a GraphQL type to the physical base table(s) whose **external** writes
51/// (a raw `INSERT`/`UPDATE`/`DELETE` from psql / a migration / a third-party
52/// tool) should be captured onto the Change Spine by the shipped fallback trigger
53/// `core.fn_entity_change_log_capture`. The compiler aggregates one of these per
54/// type carrying `@subscribable(tables=[...])`; the
55/// [`generate_capture_trigger_ddl`](crate::schema::generate_capture_trigger_ddl)
56/// generator turns them into per-table statement-level triggers that stamp
57/// `object_type = entity_type` — the GraphQL type name the reader and the
58/// subscription matcher key on, never the table name — so a captured external
59/// write fans out through the existing poller with no table→type lookup.
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61pub struct SubscribableEntity {
62    /// The GraphQL type name (e.g. `"Post"`) stamped as `object_type` on every
63    /// captured change-log row.
64    pub entity_type: String,
65
66    /// The physical base table(s) backing `entity_type` (e.g. `["tb_post"]`,
67    /// optionally schema-qualified `["public.tb_post"]`). A capture trigger is
68    /// installed on each.
69    pub tables: Vec<String>,
70
71    /// Whether the capture triggers on this entity's tables also record the
72    /// changed entity's **pre-image** (OLD) into `object_data_before` — the
73    /// out-of-band parity for the per-mutation
74    /// [`changelog_pre_image`](super::MutationDefinition::changelog_pre_image).
75    ///
76    /// The trigger always unifies `object_data` on the after-image (NEW)
77    /// regardless of this flag; `pre_image` only adds the separate before-image
78    /// column for opted-in tables, so audit-sensitive entities get an inline
79    /// Debezium `{before, after}` even for raw external writes. Default `false`
80    /// (opt in via `@subscribable(tables=[...], pre_image=True)`); an absent value
81    /// is byte-identical to before this field existed, so it does not churn the
82    /// codegen schema hash.
83    #[serde(default, skip_serializing_if = "core::ops::Not::not")]
84    pub pre_image: bool,
85}
86
87/// Complete compiled schema - all type information for serving.
88///
89/// This is the central type that holds the entire GraphQL schema
90/// after compilation from any supported authoring language.
91///
92/// # Example
93///
94/// ```
95/// use fraiseql_core::schema::CompiledSchema;
96///
97/// let json = r#"{
98///     "types": [],
99///     "queries": [],
100///     "mutations": [],
101///     "subscriptions": []
102/// }"#;
103///
104/// let schema = CompiledSchema::from_json(json, false).unwrap();
105/// assert_eq!(schema.types.len(), 0);
106/// ```
107#[derive(Debug, Clone, Default, Serialize, Deserialize)]
108pub struct CompiledSchema {
109    /// GraphQL object type definitions.
110    #[serde(default)]
111    pub types: Vec<TypeDefinition>,
112
113    /// GraphQL enum type definitions.
114    #[serde(default)]
115    pub enums: Vec<EnumDefinition>,
116
117    /// GraphQL input object type definitions.
118    #[serde(default)]
119    pub input_types: Vec<InputObjectDefinition>,
120
121    /// GraphQL interface type definitions.
122    #[serde(default)]
123    pub interfaces: Vec<InterfaceDefinition>,
124
125    /// GraphQL union type definitions.
126    #[serde(default)]
127    pub unions: Vec<UnionDefinition>,
128
129    /// GraphQL query definitions.
130    #[serde(default)]
131    pub queries: Vec<QueryDefinition>,
132
133    /// GraphQL mutation definitions.
134    #[serde(default)]
135    pub mutations: Vec<MutationDefinition>,
136
137    /// GraphQL subscription definitions.
138    #[serde(default)]
139    pub subscriptions: Vec<SubscriptionDefinition>,
140
141    /// Custom directive definitions.
142    /// These are user-defined directives beyond the built-in @skip, @include, @deprecated.
143    #[serde(default, skip_serializing_if = "Vec::is_empty")]
144    pub directives: Vec<DirectiveDefinition>,
145
146    /// Fact table metadata (for analytics queries).
147    /// Key: table name (e.g., `tf_sales`)
148    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
149    pub fact_tables: HashMap<String, FactTableMetadata>,
150
151    /// Observer definitions (database change event listeners).
152    #[serde(default, skip_serializing_if = "Vec::is_empty")]
153    pub observers: Vec<ObserverDefinition>,
154
155    /// Scheduled ingress source definitions (#573) — the dual of `observers`.
156    ///
157    /// Each runs its `function` on a cron schedule, pulling from an external system
158    /// into the database via mutations with a durable cursor. Empty (and omitted
159    /// from the compiled JSON) when no source is declared, so a schema that predates
160    /// this field deserializes and re-serializes byte-for-byte unchanged.
161    #[serde(default, skip_serializing_if = "Vec::is_empty")]
162    pub sources: Vec<SourceDefinition>,
163
164    /// `@subscribable` declarations (#366): GraphQL types whose underlying
165    /// table(s) get the shipped external-write capture trigger.
166    ///
167    /// Aggregated by the compiler from each type's `@subscribable(tables=[...])`
168    /// annotation; consumed by
169    /// [`generate_capture_trigger_ddl`](crate::schema::generate_capture_trigger_ddl)
170    /// to emit per-table capture triggers. Empty (and omitted from the compiled
171    /// JSON) when no type is subscribable — so a schema that predates this field
172    /// deserializes and re-serializes byte-for-byte unchanged.
173    #[serde(default, skip_serializing_if = "Vec::is_empty")]
174    pub subscribable: Vec<SubscribableEntity>,
175
176    /// Per-operation `@cost(weight: N)` overrides (#379): root query/mutation name
177    /// → manual cost weight, consulted by the runtime per-tenant cost-budget check
178    /// (`estimate_query_cost`) so a top-level operation counts as exactly `N`
179    /// instead of its walked subtree complexity. Aggregated by the compiler from
180    /// each operation's `@cost` annotation. Empty (and omitted from the compiled
181    /// JSON) when no operation carries `@cost` — so a schema that predates this
182    /// field deserializes and re-serializes byte-for-byte unchanged.
183    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
184    pub operation_cost_weights: HashMap<String, usize>,
185
186    /// Federation metadata for Apollo Federation v2 support.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub federation: Option<FederationConfig>,
189
190    /// Security configuration (from fraiseql.toml).
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub security: Option<SecurityConfig>,
193
194    /// Observers/event system configuration (from fraiseql.toml).
195    ///
196    /// Contains backend connection settings (`redis_url`, `nats_url`, etc.) and
197    /// event handler definitions compiled from the `[observers]` TOML section.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub observers_config: Option<ObserversConfig>,
200
201    /// `WebSocket` subscription configuration (hooks, limits).
202    /// Compiled from the `[subscriptions]` TOML section.
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub subscriptions_config: Option<SubscriptionsConfig>,
205
206    /// Query validation config (depth/complexity limits).
207    /// Compiled from the `[validation]` TOML section.
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub validation_config: Option<ValidationConfig>,
210
211    /// Debug/development configuration.
212    /// Compiled from the `[debug]` TOML section.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub debug_config: Option<DebugConfig>,
215
216    /// MCP (Model Context Protocol) server configuration.
217    /// Compiled from the `[mcp]` TOML section.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub mcp_config: Option<McpConfig>,
220
221    /// REST transport configuration.
222    /// Compiled from the `[rest]` TOML section.
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub rest_config: Option<RestConfig>,
225
226    /// gRPC transport configuration.
227    /// Compiled from the `[grpc]` TOML section.
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub grpc_config: Option<GrpcConfig>,
230
231    /// Changelog GraphQL-exposure configuration.
232    ///
233    /// Compiled from the `[changelog]` TOML section. When present with
234    /// `expose = true`, the compiler injects the `EntityChangeLog` /
235    /// `TransportCheckpoint` types plus their cursor query, point-lookup query, and
236    /// checkpoint upsert mutation. `None` when the block is absent (the default).
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub changelog: Option<ChangelogConfig>,
239
240    /// Session variable injection configuration.
241    ///
242    /// When populated, the executor calls PostgreSQL `set_config()` before each
243    /// mutation, injecting per-request values (JWT claims, HTTP headers, literals)
244    /// as transaction-scoped settings.  SQL functions read these via
245    /// `current_setting('app.tenant_id', true)`.
246    ///
247    /// Compiled from the `[session_variables]` TOML section.
248    #[serde(default)]
249    pub session_variables: SessionVariablesConfig,
250
251    /// Hierarchy definitions for ID-based ltree operators.
252    ///
253    /// Maps hierarchy names to `table`/`path_column` pairs. Compiled from the
254    /// `[hierarchies]` TOML section. Used at runtime to resolve `HierarchyContext`
255    /// for `descendantOfId` / `ancestorOfId` WHERE clause generation.
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub hierarchies_config: Option<HierarchiesConfig>,
258
259    /// Naming convention for GraphQL operation names.
260    ///
261    /// When set to `CamelCase`, operation names are converted from `snake_case`
262    /// (e.g., `create_dns_server` → `createDnsServer`) in the introspection
263    /// schema and lookup indexes. Compiled from `[fraiseql]` in `fraiseql.toml`.
264    #[serde(default)]
265    pub naming_convention: NamingConvention,
266
267    /// Acronyms whose internal digit stays attached when resolving a GraphQL field
268    /// name back to its `snake_case` JSONB key (e.g. `s3`, `ipv4`, `oauth2`). Added
269    /// to the built-in defaults at boot via `fraiseql_db::utils::set_runtime_acronyms`.
270    /// Skipped when empty so a schema with no project acronyms serializes byte-for-byte
271    /// as before this field existed (no schema-hash churn; back-compat on load).
272    #[serde(default, skip_serializing_if = "Vec::is_empty")]
273    pub naming_acronyms: Vec<String>,
274
275    /// Schema format version emitted by the compiler.
276    ///
277    /// Used to detect runtime/compiler skew. If present and ≠ `CURRENT_SCHEMA_FORMAT_VERSION`,
278    /// `validate_format_version()` returns an error.
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub schema_format_version: Option<u32>,
281
282    /// Raw GraphQL schema as string (for SDL generation).
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub schema_sdl: Option<String>,
285
286    /// Custom scalar type registry.
287    ///
288    /// Contains definitions for custom scalar types defined in the schema.
289    /// Built during code generation from `IRScalar` definitions.
290    /// Not serialized - populated at runtime from `ir.scalars`.
291    #[serde(skip)]
292    pub custom_scalars: CustomTypeRegistry,
293
294    /// O(1) lookup index: query name → index into `self.queries`.
295    /// Built at construction time by `build_indexes()`; not serialized.
296    /// Populated automatically by `from_json()`; call `build_indexes()` after
297    /// direct mutation of `self.queries`.
298    #[serde(skip)]
299    pub query_index: HashMap<String, usize>,
300
301    /// O(1) lookup index: mutation name → index into `self.mutations`.
302    /// Built at construction time by `build_indexes()`; not serialized.
303    /// Populated automatically by `from_json()`; call `build_indexes()` after
304    /// direct mutation of `self.mutations`.
305    #[serde(skip)]
306    pub mutation_index: HashMap<String, usize>,
307
308    /// O(1) lookup index: subscription name → index into `self.subscriptions`.
309    /// Built at construction time by `build_indexes()`; not serialized.
310    /// Populated automatically by `from_json()`; call `build_indexes()` after
311    /// direct mutation of `self.subscriptions`.
312    #[serde(skip)]
313    pub subscription_index: HashMap<String, usize>,
314}
315
316impl PartialEq for CompiledSchema {
317    fn eq(&self, other: &Self) -> bool {
318        // Compare all fields except custom_scalars (runtime state)
319        self.schema_format_version == other.schema_format_version
320            && self.types == other.types
321            && self.enums == other.enums
322            && self.input_types == other.input_types
323            && self.interfaces == other.interfaces
324            && self.unions == other.unions
325            && self.queries == other.queries
326            && self.mutations == other.mutations
327            && self.subscriptions == other.subscriptions
328            && self.directives == other.directives
329            && self.fact_tables == other.fact_tables
330            && self.observers == other.observers
331            && self.sources == other.sources
332            && self.subscribable == other.subscribable
333            && self.federation == other.federation
334            && self.security == other.security
335            && self.observers_config == other.observers_config
336            && self.subscriptions_config == other.subscriptions_config
337            && self.validation_config == other.validation_config
338            && self.debug_config == other.debug_config
339            && self.mcp_config == other.mcp_config
340            && self.changelog == other.changelog
341            && self.naming_convention == other.naming_convention
342            && self.naming_acronyms == other.naming_acronyms
343            && self.schema_sdl == other.schema_sdl
344    }
345}
346
347impl CompiledSchema {
348    /// Create empty schema.
349    #[must_use]
350    pub fn new() -> Self {
351        Self::default()
352    }
353}