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