fraiseql_cli/schema/converter/mod.rs
1//! Schema Converter
2//!
3//! Converts `IntermediateSchema` (language-agnostic) to `CompiledSchema` (Rust-specific)
4
5mod cascade_types;
6mod directives;
7pub mod functions;
8mod identity;
9mod interface_conformance;
10mod mutation_error_union;
11mod mutations;
12mod queries;
13mod relay;
14mod subscriptions;
15pub(crate) mod tenancy;
16mod types;
17
18#[cfg(test)]
19mod tests;
20
21use std::collections::HashSet;
22
23use anyhow::{Context, Result};
24use fraiseql_core::{
25 compiler::fact_table::{
26 DimensionColumn, DimensionPath, FactTableMetadata, FilterColumn, MeasureColumn, SqlType,
27 },
28 schema::{CompiledSchema, FieldType},
29};
30use fraiseql_functions::FunctionsConfig;
31use tracing::{info, warn};
32
33use super::{
34 artifact::CompiledArtifact,
35 intermediate::{IntermediateFactTable, IntermediateInjectDefaults, IntermediateSchema},
36};
37
38/// The directory a project's function modules live in when nothing says otherwise.
39///
40/// The default lives here, in the compiler, rather than on the wire type: every
41/// compiled artifact carries an explicit `module_dir`, so a *hand-written*
42/// `schema.compiled.json` that omits it still fails loudly instead of quietly
43/// resolving somewhere its author did not choose.
44const DEFAULT_FUNCTION_MODULE_DIR: &str = "functions";
45
46/// Converts intermediate format to compiled format
47pub struct SchemaConverter;
48
49/// The author-declared enum, interface, union and custom-scalar names of one schema
50/// (`#923`, extended by `#1018`).
51///
52/// `parse_field_type` matched the twelve builtin scalar names and routed everything else
53/// to `FieldType::Object`, because it is a free function with no view of the document it
54/// is parsing a name from. So `status: OrderStatus`, where the same file declares
55/// `enum OrderStatus`, compiled to `{"Object": "OrderStatus"}` — and
56/// `FieldType::Enum`/`Interface`/`Union` had **no producer at all** for authored schemas;
57/// their only writers were the compiler's own synthesized cascade types.
58///
59/// Everything downstream branches on the variant:
60///
61/// * introspection (`field_resolver`) reports `TypeKind::Object` for an enum-typed field, so an
62/// introspection-driven client (Apollo, Relay, graphql-codegen) is told a scalar enum is an
63/// object with no fields;
64/// * the TypeScript client generator treats `Object` as requiring a nested selection and emits
65/// `status { … }` for a scalar value;
66/// * `--emit-ddl` maps `Object` to `JSONB` rather than to the enum's own Postgres type.
67///
68/// `#1018` is the same defect one variant over: a name declared in `custom_scalars` also
69/// fell through to `Object`, so `FieldType::Scalar` had no producer either.
70///
71/// Held by name rather than by definition because that is all resolution needs, and
72/// because it must be built *before* the type arrays are consumed by conversion.
73#[derive(Debug, Default)]
74pub(crate) struct DeclaredTypeNames {
75 objects: HashSet<String>,
76 enums: HashSet<String>,
77 interfaces: HashSet<String>,
78 unions: HashSet<String>,
79 scalars: HashSet<String>,
80}
81
82impl DeclaredTypeNames {
83 /// Collect the names an intermediate schema declares.
84 fn collect(schema: &IntermediateSchema) -> Self {
85 Self {
86 objects: schema.types.iter().map(|t| t.name.clone()).collect(),
87 enums: schema.enums.iter().map(|e| e.name.clone()).collect(),
88 interfaces: schema.interfaces.iter().map(|i| i.name.clone()).collect(),
89 unions: schema.unions.iter().map(|u| u.name.clone()).collect(),
90 scalars: schema.custom_scalars.iter().flatten().map(|s| s.name.clone()).collect(),
91 }
92 }
93
94 /// Resolve a non-builtin type name to the variant the rest of the codebase branches on.
95 ///
96 /// Falls back to `FieldType::Object` for a name declared nowhere. That never reaches an
97 /// artifact: `SchemaValidator` refuses an undeclared field type by name (#1530), and the
98 /// server refuses an artifact whose field names an undeclared object at load.
99 ///
100 /// A name declared in `custom_scalars` resolves to `FieldType::Scalar` (#1018). Without
101 /// it that variant had no producer for an authored schema — the #923 defect, one variant
102 /// over — and three consumers branched on the wrong answer: `--emit-ddl` emitted `JSONB`
103 /// where `TEXT` is correct, introspection reported `OBJECT` for a leaf, and the
104 /// TypeScript generator emitted a sub-selection for a scalar.
105 fn resolve(&self, name: &str) -> FieldType {
106 let owned = || name.to_string();
107 if self.objects.contains(name) {
108 // A declared type keeps its name, even one the engine also knows as a rich
109 // scalar (`Money`, `Image`, `Color`, …): the author declared it.
110 FieldType::Object(owned())
111 } else if self.enums.contains(name) {
112 FieldType::Enum(owned())
113 } else if self.interfaces.contains(name) {
114 FieldType::Interface(owned())
115 } else if self.unions.contains(name) {
116 FieldType::Union(owned())
117 } else if self.scalars.contains(name) || fraiseql_core::schema::is_known_scalar(name) {
118 // A declared custom scalar (#1018), or one of the engine's own rich scalars
119 // (#1530): `Email`, `Hostname`, … are scalars without anyone declaring them, and
120 // fell through to an object reference that was served without its value.
121 FieldType::Scalar(owned())
122 } else {
123 FieldType::Object(owned())
124 }
125 }
126}
127
128/// Optional, default-off behaviours for [`SchemaConverter::convert_with_options`].
129///
130/// Threaded here rather than on [`IntermediateSchema`] so adding an option never
131/// breaks the many full-literal `IntermediateSchema { .. }` constructions, and
132/// the plain [`SchemaConverter::convert`] keeps its historical signature.
133#[derive(Debug, Clone, Default)]
134pub struct ConvertOptions {
135 /// Auto-synthesize a shared `MutationError` type + per-mutation result unions
136 /// and rewrite object-returning mutations to those unions (`[fraiseql.mutations]
137 /// auto_error_union`).
138 pub auto_error_union: bool,
139}
140
141impl SchemaConverter {
142 /// Convert `IntermediateSchema` to `CompiledSchema` with default options.
143 ///
144 /// # Errors
145 ///
146 /// See [`SchemaConverter::convert_with_options`].
147 pub fn convert(intermediate: IntermediateSchema) -> Result<CompiledSchema> {
148 Self::convert_with_options(intermediate, &ConvertOptions::default())
149 }
150
151 /// Convert `IntermediateSchema` into the whole compiled **artifact** — the
152 /// schema plus the platform sections written beside it (#1325).
153 ///
154 /// [`convert_with_options`](Self::convert_with_options) produces only the
155 /// `CompiledSchema`, which is what almost every caller wants. The sections that
156 /// are *siblings* of it in `schema.compiled.json` — currently `functions` — are
157 /// assembled here, so there is exactly one place where an authored section
158 /// becomes a compiled one and exactly one place a probe has to look.
159 ///
160 /// The `[functions]` TOML settings (`module_dir`, `dlq_store`) are paired with
161 /// the authored definitions here; the two halves have one owner each.
162 ///
163 /// # Errors
164 ///
165 /// See [`convert_with_options`](Self::convert_with_options).
166 pub fn convert_artifact(
167 mut intermediate: IntermediateSchema,
168 options: &ConvertOptions,
169 ) -> Result<CompiledArtifact> {
170 let functions = Self::take_functions_section(&mut intermediate)?;
171 let schema = Self::convert_with_options(intermediate, options)?;
172 // Unconditional, including when the project declares no function at all:
173 // a query whose `function = "<name>"` names nothing is exactly the case an
174 // `if let Some(functions)` here would wave through, and it is the one an
175 // author hits by deleting a function and forgetting the query (#1329).
176 functions::validate_against_schema(functions.as_ref(), &schema)?;
177 Ok(CompiledArtifact { schema, functions })
178 }
179
180 /// Pair the authored function declarations with their `[functions]` settings.
181 ///
182 /// Returns `None` when the project declares no functions and no enabled source, so
183 /// a schema that has never heard of them serialises exactly as it did before.
184 ///
185 /// # Errors
186 ///
187 /// Refuses a `[functions]` table when neither a function nor an enabled source would
188 /// use it.
189 /// There is nothing to apply the settings to, so carrying them would write a
190 /// section the server reads and cannot act on, and dropping them would be the
191 /// silent-default failure this whole seam exists to remove (#1008 refuses a
192 /// `storage` section on the same grounds).
193 fn take_functions_section(
194 intermediate: &mut IntermediateSchema,
195 ) -> Result<Option<FunctionsConfig>> {
196 let settings = intermediate.functions_config.take().unwrap_or_default();
197 // An empty list is absence, not an empty section, and two producers reach this.
198 // Some SDKs always emit the key and some omit it, so without this rule the same
199 // project compiled through two of them yields different artifacts. More
200 // importantly `seam::empty_accumulator` seeds EVERY array section with `[]`, so
201 // every TOML-workflow compile arrives here with an empty list whether or not the
202 // project has ever heard of functions — and an emitted section is one #1326
203 // refuses to boot on a build that cannot serve it.
204 let definitions = intermediate.functions.take().unwrap_or_default();
205 // An enabled source's connector is loaded from `module_dir` too (#1399), so a
206 // project whose only code is a connector still needs the section — with no
207 // definitions in it. Refusing the table here made the documented connector
208 // layout impossible to compile.
209 let runs_a_connector = intermediate.sources.iter().flatten().any(|source| source.enabled);
210 if definitions.is_empty() && !runs_a_connector {
211 anyhow::ensure!(
212 settings.module_dir.is_none() && settings.dlq_store.is_none(),
213 "the `[functions]` table configures the functions subsystem but the schema \
214 declares no function and no enabled source, so nothing would use it. Declare \
215 a function or a source, or remove the table."
216 );
217 return Ok(None);
218 }
219
220 Ok(Some(FunctionsConfig {
221 module_dir: settings
222 .module_dir
223 .unwrap_or_else(|| std::path::PathBuf::from(DEFAULT_FUNCTION_MODULE_DIR)),
224 definitions,
225 dlq_store: settings.dlq_store,
226 }))
227 }
228
229 /// Convert `IntermediateSchema` to `CompiledSchema`.
230 ///
231 /// This performs:
232 /// 1. Type conversion (intermediate types → compiled types)
233 /// 2. Field name normalization (type → `field_type`)
234 /// 3. Optional mutation-error-union synthesis (`options.auto_error_union`)
235 /// 4. Validation (type references, circular refs, etc.)
236 ///
237 /// # Errors
238 ///
239 /// Returns an error if any type, query, mutation, interface, subscription,
240 /// or directive conversion fails, if federation/security/observer config JSON
241 /// cannot be deserialized, or if compiled schema validation detects unknown
242 /// type references.
243 pub fn convert_with_options(
244 intermediate: IntermediateSchema,
245 options: &ConvertOptions,
246 ) -> Result<CompiledSchema> {
247 info!("Converting intermediate schema to compiled format");
248
249 // Aggregate the per-type `@subscribable(tables=[...])` annotations (#366)
250 // into the top-level `CompiledSchema.subscribable` list BEFORE the types
251 // are consumed below. Empty `tables` is treated as "not subscribable".
252 let subscribable: Vec<fraiseql_core::schema::SubscribableEntity> = intermediate
253 .types
254 .iter()
255 .filter_map(|t| {
256 t.subscribable_tables
257 .as_ref()
258 .filter(|tables| !tables.is_empty())
259 .map(|tables| fraiseql_core::schema::SubscribableEntity {
260 entity_type: t.name.clone(),
261 tables: tables.clone(),
262 pre_image: t.subscribable_pre_image,
263 })
264 })
265 .collect();
266
267 // Refuse a schema that declares observers, rather than validating them and throwing
268 // them away (#779).
269 //
270 // The `observers` block is emitted by the Python SDK's `@fraiseql.observer` (its
271 // shipped example declares five), it binds to `IntermediateSchema.observers`, and
272 // `SchemaValidator` spends ~220 lines checking names, entities, events, actions and
273 // retry policy — a typo in any of them fails the compile, which tells the author
274 // emphatically that the block is honoured. It was not: this function set
275 // `observers: Vec::new()` under a comment claiming they were "populated from
276 // IntermediateSchema", nothing else ever wrote the field, and no webhook, Slack
277 // message or email ever fired for any declared event.
278 //
279 // The runtime loads observers exclusively from `tb_observer` / the admin API and
280 // reads nothing from the compiled schema. So *carrying* them here would only move
281 // the silent drop one layer down and produce a compiled artifact whose `observers`
282 // array is decoration. Until a runtime consumer exists, the honest outcome is to
283 // fail and name the mechanism that does work.
284 if let Some(observers) = intermediate.observers.as_ref().filter(|o| !o.is_empty()) {
285 let names: Vec<&str> = observers.iter().map(|o| o.name.as_str()).collect();
286 anyhow::bail!(
287 "This schema declares {} observer(s) ({}), but declared observers are not \
288 loaded by the runtime — it reads them from the `tb_observer` table and the \
289 admin API only, so compiling them would produce a schema whose observers \
290 never fire.\n\nDefine them in `tb_observer`, or register them at runtime \
291 with `POST /api/observers`, and remove the `observers` block from the \
292 authored schema.",
293 observers.len(),
294 names.join(", ")
295 );
296 }
297
298 // Refuse a schema that declares `aggregate_queries`, rather than carrying it through
299 // the seam and dropping it (#956).
300 //
301 // `aggregate_queries` is listed in `AUTHORABLE_ARRAY_SECTIONS`, so it is valid input
302 // on every compile path and every loader and merger carries it faithfully — and then
303 // this converter maps `fact_tables` and never reads it. `CompiledSchema` has no
304 // corresponding field, so the definitions reach the end of the seam and evaporate
305 // under a `✓ Schema compiled successfully`. That is the #755 shape surviving inside
306 // the very seam built to kill it.
307 //
308 // #624 gave `[[analytics.queries]]` real semantics by lowering each entry onto an
309 // ordinary `QueryDefinition`, so the capability exists and has a supported spelling.
310 // Refusing here — the `#779` disposition — names it, instead of accepting a second
311 // spelling that does nothing.
312 if let Some(aggregates) = intermediate.aggregate_queries.as_ref().filter(|a| !a.is_empty())
313 {
314 let names: Vec<&str> = aggregates.iter().map(|a| a.name.as_str()).collect();
315 anyhow::bail!(
316 "This schema declares {} aggregate_queries entry/entries ({}), which no \
317 compile path lowers into the compiled schema — `CompiledSchema` has no \
318 field for them, so they would vanish silently.\n\nDeclare them as \
319 `[[analytics.queries]]` in fraiseql.toml instead (#624): each entry \
320 becomes a real list-returning, view-backed query. Then remove the \
321 `aggregate_queries` block from the authored schema.",
322 aggregates.len(),
323 names.join(", ")
324 );
325 }
326
327 // #923: collect the declared enum / interface / union names BEFORE the arrays are
328 // consumed below, so a field or argument naming one resolves to that kind instead of
329 // to a phantom `FieldType::Object`.
330 let declared = DeclaredTypeNames::collect(&intermediate);
331
332 // Split the authored `types` array on `is_input` before converting anything (#848).
333 //
334 // A type marked `is_input` is an input object that four SDKs happen to declare in
335 // the `types` array — Elixir's exporter emits no `input_types` key at all, so this
336 // is its only route. Routing it here rather than converting it as an object type is
337 // what keeps the compiled schema GraphQL-legal: an argument may only be typed with
338 // an input type (§3.10).
339 let (input_marked_types, mut object_types): (Vec<_>, Vec<_>) =
340 intermediate.types.into_iter().partition(|t| t.is_input);
341 let inaccessible_fields = Self::take_inaccessible_fields(&mut object_types);
342
343 let types = object_types
344 .into_iter()
345 .map(|t| Self::convert_type(t, &declared))
346 .collect::<Result<Vec<_>>>()
347 .context("Failed to convert types")?;
348
349 // Extract query_defaults before consuming intermediate.queries.
350 // unwrap_or_default() → all-true, matching historical behaviour when no
351 // [query_defaults] section is present in fraiseql.toml.
352 let defaults = intermediate.query_defaults.unwrap_or_default();
353
354 // Apply `[inject_defaults]` (#847) to any operation that has not named the
355 // parameter itself.
356 //
357 // This runs here, in the converter, rather than in `commands::compile` — the
358 // converter is the single path every caller of the public API goes through, so a
359 // default cannot be lost by reaching the compiled schema some other way.
360 //
361 // It is also deliberately *after* `[fraiseql.tenancy]` validation (which
362 // `commands::compile` performs on the intermediate schema before calling this).
363 // Tenancy auto-injects when `inject_params` is empty and errors when it is
364 // non-empty but lacks the annotated field, so applying defaults first would make a
365 // single unrelated default break every tenancy-annotated operation.
366 let inject_defaults = intermediate.inject_defaults.unwrap_or_default();
367 let query_defaults_inject = inject_defaults.for_queries();
368 let mutation_defaults_inject = inject_defaults.for_mutations();
369
370 // Convert queries. A query that opted into `count` emits a second,
371 // derived `<name>Count` definition (#938) — built by `count_sibling` so
372 // its tenancy, role and filter inheritance has exactly one definition.
373 let mut queries: Vec<fraiseql_core::schema::QueryDefinition> =
374 Vec::with_capacity(intermediate.queries.len());
375 for mut q in intermediate.queries {
376 IntermediateInjectDefaults::apply_to(
377 &query_defaults_inject,
378 &mut q.inject,
379 &q.exclude_inject_defaults,
380 &q.name,
381 )?;
382 let wants_count = q.count;
383 let converted = Self::convert_query(q, &defaults, &declared)
384 .context("Failed to convert queries")?;
385 let sibling = wants_count.then(|| converted.count_sibling());
386 queries.push(converted);
387 queries.extend(sibling);
388 }
389
390 // A generated sibling must not silently displace an author's own query of
391 // the same name — whichever won, the other would vanish from the schema
392 // with no diagnostic.
393 let mut seen: HashSet<&str> = HashSet::new();
394 for q in &queries {
395 if !seen.insert(q.name.as_str()) {
396 anyhow::bail!(
397 "Query name '{}' is declared twice. A `count = true` query generates \
398 '<name>Count'; rename the conflicting query or drop count=true.",
399 q.name
400 );
401 }
402 }
403
404 // Convert mutations
405 let mutations = intermediate
406 .mutations
407 .into_iter()
408 .map(|mut m| {
409 IntermediateInjectDefaults::apply_to(
410 &mutation_defaults_inject,
411 &mut m.inject,
412 &m.exclude_inject_defaults,
413 &m.name,
414 )?;
415 Self::convert_mutation(m, &declared)
416 })
417 .collect::<Result<Vec<_>>>()
418 .context("Failed to convert mutations")?;
419
420 // Convert enums
421 let enums = intermediate.enums.into_iter().map(Self::convert_enum).collect::<Vec<_>>();
422
423 // Convert input types — the `input_types` array plus the `is_input`-marked entries
424 // lifted out of `types` above. Both routes land in one registry, so a duplicate
425 // between them is an authoring conflict and is refused rather than shadowed.
426 let mut input_objects = intermediate.input_types;
427 for marked in input_marked_types {
428 let lifted = Self::input_object_from_marked_type(marked)?;
429 if input_objects.iter().any(|existing| existing.name == lifted.name) {
430 anyhow::bail!(
431 "'{}' is declared both in `input_types` and as a type marked \
432 `is_input: true`. Keep exactly one declaration — two definitions of one \
433 input object cannot be reconciled, and whichever won would depend on \
434 compile order.",
435 lifted.name
436 );
437 }
438 input_objects.push(lifted);
439 }
440 let input_types =
441 input_objects.into_iter().map(Self::convert_input_object).collect::<Vec<_>>();
442
443 // Convert interfaces
444 let interfaces = intermediate
445 .interfaces
446 .into_iter()
447 .map(|i| Self::convert_interface(i, &declared))
448 .collect::<Result<Vec<_>>>()
449 .context("Failed to convert interfaces")?;
450
451 // Convert unions
452 let unions = intermediate.unions.into_iter().map(Self::convert_union).collect::<Vec<_>>();
453
454 // Convert subscriptions
455 let subscriptions = intermediate
456 .subscriptions
457 .into_iter()
458 .map(|s| Self::convert_subscription(s, &declared))
459 .collect::<Result<Vec<_>>>()
460 .context("Failed to convert subscriptions")?;
461
462 // Convert custom directives
463 let directives = intermediate
464 .directives
465 .unwrap_or_default()
466 .into_iter()
467 .map(|d| Self::convert_directive(d, &declared))
468 .collect::<Result<Vec<_>>>()
469 .context("Failed to convert directives")?;
470
471 // Convert fact tables from Vec<IntermediateFactTable> to HashMap<String, FactTableMetadata>
472 let fact_tables = intermediate
473 .fact_tables
474 .unwrap_or_default()
475 .into_iter()
476 .map(|ft| {
477 let name = ft.table_name.clone();
478 let metadata = Self::convert_fact_table(ft);
479 (name, metadata)
480 })
481 .collect();
482
483 let mut compiled = CompiledSchema {
484 types,
485 enums,
486 input_types,
487 interfaces,
488 unions,
489 queries,
490 mutations,
491 subscriptions,
492 directives,
493 fact_tables, // Analytics metadata
494 // Refused above when non-empty (#779); an empty vector is the only reachable
495 // value, so this is not a drop.
496 observers: Vec::new(),
497 sources: intermediate.sources.clone().unwrap_or_default(), /* #573 scheduled ingress
498 * sources */
499 subscribable, // @subscribable capture-trigger declarations (#366)
500 federation: Self::with_inaccessible_fields(
501 intermediate
502 .federation_config
503 .map(serde_json::from_value)
504 .transpose()
505 .context("federation_config: invalid JSON structure")?,
506 inaccessible_fields,
507 )?,
508 security: intermediate
509 .security
510 .map(serde_json::from_value)
511 .transpose()
512 .context("security: invalid JSON structure")?,
513 auth: intermediate
514 .auth
515 .map(serde_json::from_value)
516 .transpose()
517 .context("auth: invalid PKCE OAuth-client structure (#621)")?,
518 observers_config: intermediate
519 .observers_config
520 .map(|mut oc| {
521 // #631: `handlers` reaching this seam (SDK-authored
522 // `observers_config.handlers`, bypassing the TOML
523 // validator's #612 bail) must fail the compile, not land in
524 // the compiled schema as decoration the runtime never
525 // reads. tb_observer / the admin API is the single observer
526 // concept. An EMPTY array is inert boilerplate some
527 // exporters emit — strip it so `deny_unknown_fields` on the
528 // compiled type does not reject it.
529 match oc.get("handlers").and_then(serde_json::Value::as_array) {
530 Some(handlers) if !handlers.is_empty() => {
531 anyhow::bail!(
532 "observers_config declares {} handler(s), but compiled \
533 handlers are not a runtime concept (#631): the runtime \
534 loads observers only from the `tb_observer` table and the \
535 admin API. Define them in `tb_observer` or via \
536 `POST /api/observers`, and remove `handlers` from the \
537 authored schema.",
538 handlers.len()
539 );
540 },
541 Some(_) => {
542 if let Some(map) = oc.as_object_mut() {
543 map.remove("handlers");
544 }
545 },
546 None => {},
547 }
548 if oc.get("redis_url").is_some() {
549 anyhow::bail!(
550 "{}",
551 crate::config::toml_schema::observers::REDIS_URL_REFUSED
552 );
553 }
554 serde_json::from_value(oc).context("observers_config: invalid JSON structure")
555 })
556 .transpose()?,
557 subscriptions_config: intermediate.subscriptions_config, /* Subscriptions config from
558 * TOML */
559 validation_config: intermediate.validation_config, // Validation limits from TOML
560 debug_config: intermediate.debug_config, // Debug config from TOML
561 mcp_config: intermediate.mcp_config, // MCP config from TOML
562 rest_config: intermediate.rest_config, // REST config from TOML
563 grpc_config: intermediate.grpc_config, // gRPC config from TOML (#780)
564 naming_convention: intermediate.naming_convention, // Naming convention from TOML
565 session_variables: intermediate.session_variables.unwrap_or_default(),
566 locale: intermediate.locale,
567 hierarchies_config: intermediate.hierarchies_config,
568 changelog: intermediate.changelog_config, // Changelog exposure config from TOML
569 schema_sdl: None, // Raw GraphQL SDL
570 ..Default::default()
571 };
572
573 // Populate custom scalars from intermediate schema
574 if let Some(custom_scalars_vec) = intermediate.custom_scalars {
575 for scalar_def in custom_scalars_vec {
576 let custom_type = Self::convert_custom_scalar(scalar_def)?;
577 compiled
578 .derived
579 .custom_scalars
580 .register(custom_type.name.clone(), custom_type)
581 .context("Failed to register custom scalar")?;
582 }
583 }
584
585 // Changelog exposure requires the observer system: the views it exposes read
586 // from tables (`tb_entity_change_log`, `tb_transport_checkpoint`) that the
587 // observer install convention supplies. Emitting GraphQL types over absent
588 // tables would only fail later, at runtime — reject it here instead.
589 if let Some(ref cl) = compiled.changelog {
590 if cl.expose && !compiled.observers_config.as_ref().is_some_and(|o| o.enabled) {
591 anyhow::bail!(
592 "[changelog] expose = true requires [observers] to be enabled: the tables \
593 it exposes (tb_entity_change_log, tb_transport_checkpoint) are installed by \
594 the observer system."
595 );
596 }
597 // #497: the change-log surface is a per-database stream, not a federated
598 // value type — the injected `EntityChangeLog` type and `entity_change_logs`
599 // root query are not `@shareable`. Two subgraphs in one supergraph that both
600 // `expose` it inject the identical type/root field, which `rover supergraph
601 // compose` rejects with INVALID_FIELD_SHARING. We can't detect the collision
602 // here (each subgraph compiles alone), so warn on the federated+exposed combo
603 // and point at the single-owner pattern. This is a reminder, not an error —
604 // the owning subgraph legitimately sets both.
605 if cl.expose && compiled.federation.as_ref().is_some_and(|f| f.enabled) {
606 warn!(
607 "[changelog] expose = true on a federation subgraph: EntityChangeLog / \
608 entity_change_logs are not @shareable, so expose the change-log in exactly \
609 one subgraph per supergraph (others keep capturing via [changelog] \
610 write_enabled). Two exposing subgraphs fail `rover supergraph compose` with \
611 INVALID_FIELD_SHARING. See the changelog-graphql guide for the single-owner \
612 pattern."
613 );
614 }
615 }
616
617 // Canonicalize the Trinity external identity (`id: UUID` → `id: ID`, ADR-0017)
618 // before the interface-forcing passes, so Relay `Node` / `CascadeNode` see a
619 // conformant `id: ID` on every entity. Wire-transparent (a UUID is an `ID`).
620 identity::normalize_entity_identity(&mut compiled);
621
622 // Inject synthetic Relay types (PageInfo, Node interface, XxxConnection, XxxEdge).
623 relay::inject_relay_types(&mut compiled)?;
624
625 // Inject the changelog GraphQL surface (EntityChangeLog / TransportCheckpoint
626 // types + cursor query + point lookup + upsert mutation) when opted in.
627 fraiseql_core::schema::inject_changelog(&mut compiled);
628
629 // Synthesize the typed cascade surface (CascadeNode interface + envelope
630 // types + per-mutation `<Name>Payload` wrappers) for mutations that opt in
631 // via `cascade = true`. Runs BEFORE error-union synthesis so a cascade
632 // payload becomes the success member of the result union
633 // (`<Name>Result = <Name>Payload | MutationError`). Inert with no cascade
634 // mutations.
635 cascade_types::synthesize_cascade_types(&mut compiled)?;
636
637 // Auto-synthesize a shared MutationError type + per-mutation result unions
638 // when opted in (`[fraiseql.mutations] auto_error_union`), so the runtime's
639 // success/error discrimination has a union to resolve against.
640 if options.auto_error_union {
641 mutation_error_union::synthesize_mutation_error_unions(&mut compiled);
642 }
643
644 // IR honesty (#456): `parse_field_type` has no `Input` variant, so an
645 // argument referencing a declared input type was resolved to
646 // `FieldType::Object`. Rewrite those argument types to `FieldType::Input`
647 // now that every input type is registered (after relay / rich-filter /
648 // changelog / error-union injection) so the IR honestly represents the
649 // GraphQL input position — which also makes introspection report the correct
650 // `INPUT_OBJECT` kind for those args. The runtime already accepts both
651 // shapes, so this is purely representational.
652 Self::promote_input_type_args(&mut compiled);
653
654 // Validate the compiled schema
655 Self::validate(&compiled)?;
656
657 info!("Schema conversion successful");
658 Ok(compiled)
659 }
660
661 /// Merge the `@inaccessible` fields lifted off the authored types into the
662 /// compiled `federation` block.
663 ///
664 /// # Errors
665 ///
666 /// Refuses `@inaccessible` in a schema with no enabled `federation` section: the
667 /// directive hides a field from a supergraph, and without one it would hide nothing
668 /// while reading as if it did.
669 fn with_inaccessible_fields(
670 federation: Option<fraiseql_core::schema::FederationConfig>,
671 hidden: std::collections::BTreeMap<String, Vec<String>>,
672 ) -> Result<Option<fraiseql_core::schema::FederationConfig>> {
673 if hidden.is_empty() {
674 return Ok(federation);
675 }
676 let Some(mut federation) = federation.filter(|f| f.enabled) else {
677 let named: Vec<String> = hidden
678 .iter()
679 .flat_map(|(ty, fields)| fields.iter().map(move |f| format!("{ty}.{f}")))
680 .collect();
681 anyhow::bail!(
682 "`@inaccessible` on {} needs federation: it hides a field from the \
683 supergraph, and this schema has no enabled `federation` section, so the \
684 field would stay visible. Enable federation, or remove the directive and \
685 the field.",
686 named.join(", ")
687 );
688 };
689 for (ty, fields) in hidden {
690 federation.inaccessible_fields.entry(ty).or_default().extend(fields);
691 }
692 Ok(Some(federation))
693 }
694
695 /// Rewrite mutation/query argument types of the form `Object(name)` (and lists
696 /// thereof) to `Input(name)` when `name` is a registered input type, so the IR
697 /// honestly represents input-position references (#456). Input type names are
698 /// disjoint from output type names per the GraphQL spec, so the lookup is exact
699 /// — an output-object argument is never misclassified.
700 fn promote_input_type_args(schema: &mut CompiledSchema) {
701 fn promote(ft: &mut FieldType, input_names: &HashSet<String>) {
702 match ft {
703 FieldType::Object(name) if input_names.contains(name) => {
704 *ft = FieldType::Input(name.clone());
705 },
706 FieldType::List { item: inner, .. } => promote(inner, input_names),
707 _ => {},
708 }
709 }
710
711 let input_names: HashSet<String> =
712 schema.input_types.iter().map(|i| i.name.clone()).collect();
713 if input_names.is_empty() {
714 return;
715 }
716
717 for mutation in &mut schema.mutations {
718 for arg in &mut mutation.arguments {
719 promote(&mut arg.arg_type, &input_names);
720 }
721 }
722 for query in &mut schema.queries {
723 for arg in &mut query.arguments {
724 promote(&mut arg.arg_type, &input_names);
725 }
726 }
727 }
728
729 #[allow(clippy::cognitive_complexity)] // Reason: comprehensive schema validation with many field-level checks
730 fn validate(schema: &CompiledSchema) -> Result<()> {
731 info!("Validating compiled schema");
732
733 // Build type registry
734 let mut type_names: HashSet<String> = HashSet::new();
735 for type_def in &schema.types {
736 type_names.insert(type_def.name.to_string());
737 }
738
739 // Build interface registry. Interfaces are *also* valid query/mutation
740 // return types (a field may return an interface, narrowed via inline
741 // fragments), so register them in `type_names` too — previously omitted,
742 // which made a query/mutation returning an interface silently fail the
743 // reference check below.
744 let mut interface_names = HashSet::new();
745 for interface_def in &schema.interfaces {
746 interface_names.insert(interface_def.name.clone());
747 type_names.insert(interface_def.name.clone());
748 }
749
750 // Add input types — valid as mutation argument types (fraiseql/fraiseql#190)
751 for input_type in &schema.input_types {
752 type_names.insert(input_type.name.clone());
753 }
754
755 // Add union type names — valid as mutation/query return types
756 for union_def in &schema.unions {
757 type_names.insert(union_def.name.clone());
758 }
759
760 // Add enum type names — valid as field and argument types
761 for enum_def in &schema.enums {
762 type_names.insert(enum_def.name.clone());
763 }
764
765 // Add built-in scalars
766 for scalar in crate::schema::builtin_scalar_names() {
767 type_names.insert(scalar.to_string());
768 }
769
770 // Add **declared** custom scalars, from the registry the compiler actually carries.
771 //
772 // This used to register every type name appearing in any object field, which made a
773 // field-type typo legalize itself as a return type too (#724 item 2) — the same
774 // blindness as `SchemaValidator`, duplicated here.
775 for (name, _) in schema.derived.custom_scalars.list_all() {
776 type_names.insert(name);
777 }
778
779 // Collect every problem, then report them together.
780 //
781 // This tier used to `bail!` on the first error, with no suggestion, after redundantly
782 // `warn!`ing the same text (#724 item 4). Errors that only surface after synthesis —
783 // relay, cascade, changelog-injected types — are validated *here*, so the same class
784 // of mistake got a materially worse experience depending on which tier caught it, and
785 // a user with three typos needed three compile cycles for information the compiler
786 // had in hand on the first.
787 let mut problems: Vec<String> = Vec::new();
788 let mut known: Vec<&str> = type_names.iter().map(String::as_str).collect();
789 known.sort_unstable();
790
791 let describe = |name: &str| -> String {
792 let similar = fraiseql_core::runtime::suggest_similar(name, &known);
793 if similar.is_empty() {
794 String::new()
795 } else {
796 format!(" (did you mean: {}?)", similar.join(", "))
797 }
798 };
799
800 // Validate queries
801 for query in &schema.queries {
802 if !type_names.contains(&query.return_type) {
803 problems.push(format!(
804 "Query '{}' references unknown type '{}'{}",
805 query.name,
806 query.return_type,
807 describe(&query.return_type)
808 ));
809 }
810
811 for arg in &query.arguments {
812 let type_name = Self::extract_type_name(&arg.arg_type);
813 if !type_names.contains(&type_name) {
814 problems.push(format!(
815 "Query '{}' argument '{}' references unknown type '{}'{}",
816 query.name,
817 arg.name,
818 type_name,
819 describe(&type_name)
820 ));
821 }
822 }
823 }
824
825 // Validate mutations
826 for mutation in &schema.mutations {
827 if !type_names.contains(&mutation.return_type) {
828 problems.push(format!(
829 "Mutation '{}' references unknown type '{}'{}",
830 mutation.name,
831 mutation.return_type,
832 describe(&mutation.return_type)
833 ));
834 }
835
836 for arg in &mutation.arguments {
837 let type_name = Self::extract_type_name(&arg.arg_type);
838 if !type_names.contains(&type_name) {
839 problems.push(format!(
840 "Mutation '{}' argument '{}' references unknown type '{}'{}",
841 mutation.name,
842 arg.name,
843 type_name,
844 describe(&type_name)
845 ));
846 }
847 }
848 }
849
850 // Validate interface implementations
851 for type_def in &schema.types {
852 for interface_name in &type_def.implements {
853 if !interface_names.contains(interface_name) {
854 let mut candidates: Vec<&str> =
855 interface_names.iter().map(String::as_str).collect();
856 candidates.sort_unstable();
857 let similar =
858 fraiseql_core::runtime::suggest_similar(interface_name, &candidates);
859 let hint = if similar.is_empty() {
860 String::new()
861 } else {
862 format!(" (did you mean: {}?)", similar.join(", "))
863 };
864 problems.push(format!(
865 "Type '{}' implements unknown interface '{interface_name}'{hint}",
866 type_def.name
867 ));
868 continue;
869 }
870
871 // Validate that the type has all fields required by the interface
872 if let Some(interface) = schema.find_interface(interface_name) {
873 for interface_field in &interface.fields {
874 let type_has_field = type_def.fields.iter().any(|f| {
875 f.name == interface_field.name
876 && f.field_type == interface_field.field_type
877 });
878 if !type_has_field {
879 problems.push(format!(
880 "Type '{}' implements interface '{interface_name}' but is \
881 missing field '{}'",
882 type_def.name, interface_field.name
883 ));
884 }
885 }
886 }
887 }
888 }
889
890 // Relationships the REST embed executor could not follow (#1266) — an undeclared
891 // target, a join column no field publishes, a target no list query returns.
892 //
893 // Borrowed wholesale from the load-time check rather than restated here: the two
894 // must agree, and the way they stop agreeing is one of them being taught a rule
895 // the other is not. `CompiledSchema::finish_load` runs the same function, which
896 // is what covers the hand-edited artifact this compile-time refusal cannot reach.
897 problems.extend(schema.relationship_violations());
898
899 // Federation `@key`s the router would refuse (#1395) — the same function the load
900 // path runs, for the same reason as the relationship check above.
901 problems.extend(schema.federation_key_violations());
902
903 if !problems.is_empty() {
904 let count = problems.len();
905 let noun = if count == 1 { "problem" } else { "problems" };
906 anyhow::bail!(
907 "Schema validation found {count} {noun}:\n{}",
908 problems.iter().map(|p| format!(" - {p}")).collect::<Vec<_>>().join("\n")
909 );
910 }
911
912 info!("Schema validation passed");
913 Ok(())
914 }
915
916 /// Extract type name from `FieldType` for validation
917 ///
918 /// Built-in types return their scalar name, Object types return the object name
919 fn extract_type_name(field_type: &FieldType) -> String {
920 // A built-in scalar's name is the one the author wrote, from the same
921 // table `parse_field_type` reads — this is that function's inverse.
922 if let Some(name) = field_type.authoring_name() {
923 return name.to_string();
924 }
925 match field_type {
926 FieldType::Scalar(name) => name.clone(),
927 FieldType::Object(name) => name.clone(),
928 FieldType::Enum(name) => name.clone(),
929 FieldType::Input(name) => name.clone(),
930 FieldType::Interface(name) => name.clone(),
931 FieldType::Union(name) => name.clone(),
932 FieldType::List { item: inner, .. } => Self::extract_type_name(inner),
933 // Reason: non_exhaustive requires catch-all for cross-crate matches
934 _ => "Unknown".to_string(),
935 }
936 }
937
938 /// Convert `IntermediateFactTable` to `FactTableMetadata`.
939 pub(crate) fn convert_fact_table(ft: IntermediateFactTable) -> FactTableMetadata {
940 FactTableMetadata {
941 table_name: ft.table_name,
942 type_name: ft.type_name,
943 measures: ft
944 .measures
945 .into_iter()
946 .map(|m| MeasureColumn {
947 name: m.name,
948 sql_type: Self::parse_sql_type(&m.sql_type),
949 nullable: m.nullable,
950 additivity: m.additivity,
951 })
952 .collect(),
953 dimensions: DimensionColumn {
954 name: ft.dimensions.name,
955 paths: ft
956 .dimensions
957 .paths
958 .into_iter()
959 .map(|p| DimensionPath {
960 name: p.name,
961 json_path: p.json_path,
962 data_type: p.data_type,
963 })
964 .collect(),
965 },
966 denormalized_filters: ft
967 .denormalized_filters
968 .into_iter()
969 .map(|f| FilterColumn {
970 name: f.name,
971 sql_type: Self::parse_sql_type(&f.sql_type),
972 indexed: f.indexed,
973 hierarchy: f.hierarchy,
974 })
975 .collect(),
976 calendar_dimensions: vec![],
977 native_measures: ft.native_measures,
978 native_dimension_mapping: ft.native_dimension_mapping,
979 }
980 }
981
982 /// Parse a SQL type string into a `SqlType` enum variant.
983 fn parse_sql_type(s: &str) -> SqlType {
984 match s.to_uppercase().as_str() {
985 "INT" | "INTEGER" | "SMALLINT" | "INT4" | "INT2" => SqlType::Int,
986 "BIGINT" | "INT8" => SqlType::BigInt,
987 "DECIMAL" | "NUMERIC" | "MONEY" => SqlType::Decimal,
988 "REAL" | "FLOAT" | "DOUBLE" | "FLOAT8" | "FLOAT4" | "DOUBLE PRECISION" => {
989 SqlType::Float
990 },
991 "JSONB" => SqlType::Jsonb,
992 "JSON" => SqlType::Json,
993 "TEXT" | "VARCHAR" | "STRING" | "CHAR" | "CHARACTER VARYING" => SqlType::Text,
994 "UUID" => SqlType::Uuid,
995 "TIMESTAMP" | "TIMESTAMPTZ" | "TIMESTAMP WITH TIME ZONE" | "DATETIME" => {
996 SqlType::Timestamp
997 },
998 "DATE" => SqlType::Date,
999 "BOOLEAN" | "BOOL" => SqlType::Boolean,
1000 "LTREE" => SqlType::Ltree,
1001 _ => SqlType::Other(s.to_string()),
1002 }
1003 }
1004}
1005
1006/// Parse an authored `requires_actor` list into [`ActorType`]s (#966).
1007///
1008/// One parser for queries and mutations, so the two cannot disagree about which
1009/// tokens exist.
1010///
1011/// # Why an unknown token is an error
1012///
1013/// `requires_actor` is an allow-list, so a dropped entry does not open the
1014/// operation — it *closes* it further, and a typo'd `"human"` would refuse every
1015/// human user while the schema reads as though it admits them. And a whole list
1016/// that failed to parse would leave the field empty, which means *unrestricted*:
1017/// the failure mode of silence is a security gate that is not there. Naming the
1018/// bad token at compile time is the only reading where the author finds out.
1019///
1020/// # Errors
1021///
1022/// Returns an error naming the offending token and listing the valid ones.
1023pub(crate) fn parse_requires_actor(
1024 operation_kind: &str,
1025 operation_name: &str,
1026 tokens: &[String],
1027) -> Result<Vec<fraiseql_core::security::ActorType>> {
1028 use fraiseql_core::security::ActorType;
1029
1030 tokens
1031 .iter()
1032 .map(|token| {
1033 ActorType::from_token(token).ok_or_else(|| {
1034 anyhow::anyhow!(
1035 "{operation_kind} '{operation_name}': requires_actor entry '{token}' is not a \
1036 known actor type. Valid values: {}",
1037 ActorType::ALL.map(ActorType::as_str).join(", ")
1038 )
1039 })
1040 })
1041 .collect()
1042}