fraiseql_cli/schema/intermediate/types.rs
1//! Core type structs: `IntermediateType`, `IntermediateField`, `IntermediateEnum`,
2//! `IntermediateEnumValue`, `IntermediateScalar`, `IntermediateDeprecation`.
3
4use fraiseql_core::validation::ValidationRule;
5use indexmap::IndexMap;
6use serde::{Deserialize, Serialize};
7
8use super::fragments::IntermediateAppliedDirective;
9
10/// Type definition in intermediate format
11#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
12#[serde(deny_unknown_fields)]
13pub struct IntermediateType {
14 /// Type name (e.g., "User")
15 pub name: String,
16
17 /// Type fields
18 pub fields: Vec<IntermediateField>,
19
20 /// Type description (from docstring)
21 #[serde(skip_serializing_if = "Option::is_none")]
22 pub description: Option<String>,
23
24 /// Type-level SQL source (backing table/view), e.g. `v_organization`.
25 ///
26 /// Owned types bind their relation on the *query* that returns them, so this
27 /// is normally absent. It is emitted by the authoring SDK only for an
28 /// owner-split `extend type … @key` federation entity: a subgraph that does
29 /// not own the entity exposes no root query returning it, so the federation
30 /// `_entities` resolver has no query to source the backing relation from and
31 /// would otherwise guess `lower(typename)` and resolve to null (#507). When
32 /// present it flows through to `TypeDefinition.sql_source` and is used as the
33 /// `_entities` fallback relation.
34 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub sql_source: Option<String>,
36
37 /// Interfaces this type implements (GraphQL spec §3.6)
38 #[serde(default, skip_serializing_if = "Vec::is_empty")]
39 pub implements: Vec<String>,
40
41 /// Role required to execute any operation returning this type.
42 ///
43 /// Lowered onto every query and mutation whose `return_type` is this type when the
44 /// compiled schema loads, so the runtime's operation-level role gate enforces it —
45 /// there is no separate type-level check to keep in step (#677). A gated type
46 /// reachable as a *field* of a type that is not gated the same way is refused at
47 /// load: operations returning the container carry no role, so the gated type would
48 /// travel out ungated.
49 ///
50 /// It also filters the REST `/introspection` route. It does **not** filter GraphQL
51 /// `__schema` introspection.
52 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub requires_role: Option<String>,
54
55 /// Tenant/owner scoping declared on the type itself (#1142).
56 ///
57 /// The counterpart to [`sql_source`](Self::sql_source) above: a type carrying one is
58 /// an entity the `_entities` resolver reaches with no query behind it, so there is no
59 /// operation to carry `inject_params`. Before this key, such a type could declare
60 /// `requires_role` — honoured from the type since #1030 — but had nowhere to declare
61 /// tenant scoping, and the compile succeeded with the annotation covering nothing.
62 ///
63 /// Accepts the same two value shapes as the operation-level key (#806) and threads
64 /// through to `TypeDefinition.inject_params`, where both `_entities` consumers read it
65 /// behind the backing query. A type and its query declaring the same column from
66 /// different sources is refused when the compiled schema loads.
67 #[serde(
68 default,
69 with = "super::operations::inject_params_serde",
70 skip_serializing_if = "IndexMap::is_empty"
71 )]
72 pub inject_params: IndexMap<String, String>,
73
74 /// Whether this type is a mutation error type (tagged with `@fraiseql.error`).
75 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
76 pub is_error: bool,
77
78 /// Whether the author declared this a GraphQL **input** object rather than an output
79 /// type (`is_input: true`).
80 ///
81 /// Four SDKs advertise this in their READMEs and emit it — Elixir
82 /// (`fraiseql_type "X", is_input: true`), F# (`[<GraphQLType(IsInput=…)>]`), PHP
83 /// (`#[GraphQLType(isInput: true)]`) and C# (`GraphQLTypeAttribute.IsInput`). There was
84 /// no field here to receive it, so such a type compiled as an *object* type: a mutation
85 /// argument referencing it produced a schema violating GraphQL §3.10 ("arguments must
86 /// be input types"), which introspection-driven clients reject and federation
87 /// composition fails on. `fraiseql compile` and `fraiseql lint` both exited 0 (#848).
88 ///
89 /// The converter routes a type carrying this into `input_types` and omits it from
90 /// `types`. That is what makes Elixir's surface work at all: its exporter emits no
91 /// `input_types` key whatsoever, so this flag is its *only* route to an input object.
92 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
93 pub is_input: bool,
94
95 /// Whether this type implements the Relay Node interface.
96 /// When true, the compiler generates global node IDs (`base64("TypeName:uuid")`)
97 /// and validates that `pk_{entity}` (BIGINT) is present in the view's data JSONB.
98 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
99 pub relay: bool,
100
101 /// Whether the author declared this an embedded value object
102 /// (`@fraiseql.type(embedded=True)`, #687).
103 ///
104 /// A value object has no independent identity and is always nested under a parent
105 /// entity, so the compiler exempts it from cascade entity classification: no
106 /// `id: ID!` enforcement, no auto-`implements CascadeNode`. Threaded verbatim to
107 /// `TypeDefinition.embedded` — it is the author's declaration, not something the
108 /// compiler infers.
109 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
110 pub embedded: bool,
111
112 /// `@subscribable(tables=[...])` — the underlying base table(s) whose external
113 /// writes should be captured onto the Change Spine (#366).
114 ///
115 /// The compiler aggregates the set of `(name, tables)` for every type carrying
116 /// this into `CompiledSchema.subscribable`, which the
117 /// `generate_capture_trigger_ddl` generator turns into per-table capture
118 /// triggers. `None`/absent (the default) when the type is not subscribable.
119 ///
120 /// # Example
121 ///
122 /// ```json
123 /// {
124 /// "name": "Post",
125 /// "fields": [],
126 /// "subscribable_tables": ["tb_post"]
127 /// }
128 /// ```
129 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub subscribable_tables: Option<Vec<String>>,
131
132 /// `@subscribable(..., pre_image=True)` — whether the capture triggers on this
133 /// type's tables also record the changed entity's pre-image (OLD) into
134 /// `object_data_before`, the out-of-band parity for a mutation's
135 /// `changelog_pre_image`. Only meaningful alongside `subscribable_tables`;
136 /// `false`/absent (the default) captures the after-image only.
137 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
138 pub subscribable_pre_image: bool,
139
140 /// Relationships to other types, the declarations REST resource embedding follows
141 /// (#1266).
142 ///
143 /// Threaded verbatim to `TypeDefinition.relationships`. Until this key existed the
144 /// compiled field had no producer at all: every converter wrote the empty vector and
145 /// `deny_unknown_fields` above turned an SDK that emitted the key into a refusal of
146 /// the whole document — so `?select=posts(id,title)`, `?select=posts.count`,
147 /// `?posts.status=`, the `OpenAPI` relationship properties and the generated
148 /// `relationships.*` client modules were reachable only from a hand-written
149 /// `schema.compiled.json`.
150 ///
151 /// A relationship the embed executor could not follow — an undeclared target, a join
152 /// column no field publishes, a target no list query returns — is refused at compile
153 /// time and again when the compiled schema loads. That check is the durable half:
154 /// #1230 was a join key the projection omitted, served as `null` under a 200.
155 #[serde(default, skip_serializing_if = "Vec::is_empty")]
156 pub relationships: Vec<IntermediateRelationship>,
157
158 /// Per-row visibility for this type's subscription deliveries: a subscriber receives a
159 /// row only when the row's `owner_path` field equals its enriched `identity_field`,
160 /// unless it holds one of `bypass_roles`. Threaded verbatim to
161 /// `TypeDefinition.subscription_policy`; the owner field must be a single-level field of
162 /// this type, checked at compile.
163 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub subscription_policy: Option<fraiseql_core::schema::SubscriptionPolicy>,
165}
166
167/// A relationship in intermediate format (#1266).
168///
169/// Reuses the compiled-schema [`Cardinality`](fraiseql_core::schema::Cardinality)
170/// directly, for the reason `FieldDefinition::vector` reuses `VectorConfig`: the authored
171/// and compiled shapes cannot then drift.
172///
173/// `foreign_key` and `referenced_key` are SQL **column** names — the foreign key lives on
174/// the child table and the referenced key on the parent, whichever side of the
175/// relationship each happens to be:
176///
177/// | `cardinality` | key read off the **declaring** type | key filtered on the **target** |
178/// |---|---|---|
179/// | `OneToMany` | `referenced_key` | `foreign_key` |
180/// | `ManyToOne` | `foreign_key` | `referenced_key` |
181/// | `OneToOne` | `foreign_key` | `referenced_key` |
182///
183/// Both are required here, unlike the compiled struct where they carry `#[serde(default)]`
184/// — an empty join column is a relationship no embed can compose a predicate for, and an
185/// author who omits one should learn that from the parse error rather than from an empty
186/// array on a 200.
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(deny_unknown_fields)]
189pub struct IntermediateRelationship {
190 /// Relationship name — the key in `?select=<name>(...)` and in the response.
191 pub name: String,
192
193 /// Target GraphQL type name, e.g. `"Post"`. Must be a declared type that some list
194 /// query returns; the embed sources its rows from that query.
195 pub target_type: String,
196
197 /// `OneToMany`, `ManyToOne` or `OneToOne`.
198 pub cardinality: fraiseql_core::schema::Cardinality,
199
200 /// Foreign key column on the child table, e.g. `fk_author`.
201 pub foreign_key: String,
202
203 /// Referenced key column on the parent table, e.g. `id`.
204 pub referenced_key: String,
205}
206
207/// Field definition in intermediate format
208///
209/// **NOTE**: Uses `type` field (not `field_type`)
210/// This is the language-agnostic format. Rust conversion happens in converter.
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212#[serde(deny_unknown_fields)]
213pub struct IntermediateField {
214 /// Field name (e.g., "id")
215 pub name: String,
216
217 /// Field type name (e.g., "Int", "String", "User")
218 ///
219 /// **Language-agnostic**: All languages use "type", not "`field_type`"
220 #[serde(rename = "type")]
221 pub field_type: String,
222
223 /// Is field nullable?
224 pub nullable: bool,
225
226 /// Field description (from docstring)
227 #[serde(skip_serializing_if = "Option::is_none")]
228 pub description: Option<String>,
229
230 /// Applied directives (e.g., @deprecated)
231 #[serde(skip_serializing_if = "Option::is_none")]
232 pub directives: Option<Vec<IntermediateAppliedDirective>>,
233
234 /// Declarable **only to be refused**, with a reason (#1329).
235 ///
236 /// A root query field can be backed by a function; a nested field cannot, and
237 /// this key exists so that saying so is a sentence rather than
238 /// `unknown field \`function\``. It is the natural next thing to try after
239 /// declaring one on a query, and the difference between the two messages is the
240 /// difference between an author who learns the rule and one who concludes the
241 /// feature is broken.
242 ///
243 /// The refusal is not a limitation to be lifted later. A nested resolver runs
244 /// once per row, so a function there is an N+1 measured in V8 isolates — at
245 /// ~5–8 ms each, a hundred-row page costs most of a second before the function
246 /// does any work. Root-only makes "one invocation per query" a property of the
247 /// shape; see [`QueryDefinition::function`] for the rest of the reasoning.
248 ///
249 /// [`QueryDefinition::function`]: fraiseql_core::schema::QueryDefinition::function
250 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub function: Option<String>,
252
253 /// Deprecation information for this field.
254 ///
255 /// The first-class spelling, matching `IntermediateQuery`,
256 /// `IntermediateInputField`, `IntermediateSubscription` and enum values.
257 /// Field deprecation used to be reachable only through a `@deprecated`
258 /// entry in [`directives`](Self::directives), so a field was the one
259 /// construct that could not say it plainly — and both the Python and
260 /// `TypeScript` SDKs emitted this key, which `deny_unknown_fields` turned
261 /// into a refusal of the whole document (#1025).
262 ///
263 /// The directive spelling still works; this takes precedence when both are
264 /// present.
265 #[serde(default, skip_serializing_if = "Option::is_none")]
266 pub deprecated: Option<IntermediateDeprecation>,
267
268 /// Scope required to access this field (field-level access control)
269 ///
270 /// When set, users must have this scope in their JWT to query this field.
271 /// Supports patterns like "read:Type.field" or custom scopes like "hr:view_pii".
272 ///
273 /// # Example
274 ///
275 /// ```json
276 /// {
277 /// "name": "salary",
278 /// "type": "Int",
279 /// "nullable": false,
280 /// "requires_scope": "read:Employee.salary"
281 /// }
282 /// ```
283 #[serde(skip_serializing_if = "Option::is_none")]
284 pub requires_scope: Option<String>,
285
286 /// Policy when the user lacks `requires_scope`: `"reject"` (default) or `"mask"`.
287 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub on_deny: Option<String>,
289
290 /// pgvector configuration for `Vector` fields (#386).
291 ///
292 /// Required when `type` is `"Vector"` (the compiler needs the dimension for
293 /// DDL and request-time validation); an error on any other type. Reuses the
294 /// compiled-schema type so the authored and compiled shapes cannot drift.
295 ///
296 /// # Example
297 ///
298 /// ```json
299 /// {
300 /// "name": "embedding",
301 /// "type": "Vector",
302 /// "nullable": false,
303 /// "vector_config": { "dimensions": 1536, "index_type": "hnsw", "distance_metric": "cosine" }
304 /// }
305 /// ```
306 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub vector_config: Option<fraiseql_core::schema::VectorConfig>,
308
309 /// Names the vector field whose search distance this field carries (#959).
310 ///
311 /// # Example
312 ///
313 /// ```json
314 /// {
315 /// "name": "similarity",
316 /// "type": "Float",
317 /// "nullable": false,
318 /// "vector_distance": "embedding"
319 /// }
320 /// ```
321 #[serde(default, skip_serializing_if = "Option::is_none")]
322 pub vector_distance: Option<String>,
323
324 /// Whether this field is gated by the dynamic field authorizer at runtime.
325 ///
326 /// When `true`, the compiled field is marked policy-gated
327 /// (`FieldDefinition.authorize`) and a configured `FieldAuthorizer` is consulted
328 /// per row. Defaults to `false` when absent.
329 ///
330 /// # Example
331 ///
332 /// ```json
333 /// {
334 /// "name": "email",
335 /// "type": "String",
336 /// "nullable": true,
337 /// "authorize": true
338 /// }
339 /// ```
340 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub authorize: Option<bool>,
342
343 /// Named hierarchy reference for ID-based ltree operators.
344 /// References a key in the `hierarchies` config map.
345 #[serde(default, skip_serializing_if = "Option::is_none")]
346 pub hierarchy: Option<String>,
347
348 /// Whether this field's column is unique over the type's relation.
349 ///
350 /// Declares the fact a **to-one** relationship joining on this field needs, and
351 /// without which the schema is refused at load; see
352 /// [`FieldDefinition::is_unique_key`](fraiseql_core::schema::FieldDefinition::is_unique_key)
353 /// for why the entity identity is exempt and `pk_<type>` is not.
354 ///
355 /// `Option<bool>` rather than `bool` for the reason [`authorize`](Self::authorize) is:
356 /// absent and `false` reach the converter as the same compiled value, but the key is
357 /// then omitted from a re-emitted document rather than written back as `false` on
358 /// every field in the schema.
359 ///
360 /// # Example
361 ///
362 /// ```json
363 /// {
364 /// "name": "identifier",
365 /// "type": "String",
366 /// "nullable": false,
367 /// "unique": true
368 /// }
369 /// ```
370 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub unique: Option<bool>,
372
373 /// A localized `String` (#1513): stored as a locale map (`{"fr-FR": "Pomme", ...}`) and
374 /// returned as the request locale's label. Emitted by the SDKs' `Localized` type.
375 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
376 pub localized: bool,
377}
378
379// =============================================================================
380// Enum Definitions
381// =============================================================================
382
383/// GraphQL enum type definition in intermediate format.
384///
385/// Enums represent a finite set of possible values.
386///
387/// # Example JSON
388///
389/// ```json
390/// {
391/// "name": "OrderStatus",
392/// "values": [
393/// {"name": "PENDING"},
394/// {"name": "PROCESSING"},
395/// {"name": "SHIPPED", "description": "Package has been shipped"},
396/// {"name": "DELIVERED"}
397/// ],
398/// "description": "Possible states of an order"
399/// }
400/// ```
401#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
402#[serde(deny_unknown_fields)]
403pub struct IntermediateEnum {
404 /// Enum type name (e.g., "OrderStatus")
405 pub name: String,
406
407 /// Possible values for this enum
408 pub values: Vec<IntermediateEnumValue>,
409
410 /// Enum description (from docstring)
411 #[serde(skip_serializing_if = "Option::is_none")]
412 pub description: Option<String>,
413}
414
415/// A single value within an enum type.
416///
417/// # Example JSON
418///
419/// ```json
420/// {
421/// "name": "ACTIVE",
422/// "description": "The item is currently active",
423/// "deprecated": {"reason": "Use ENABLED instead"}
424/// }
425/// ```
426#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
427#[serde(deny_unknown_fields)]
428pub struct IntermediateEnumValue {
429 /// Value name (e.g., "PENDING")
430 pub name: String,
431
432 /// Value description (from docstring)
433 #[serde(skip_serializing_if = "Option::is_none")]
434 pub description: Option<String>,
435
436 /// Deprecation info (if value is deprecated)
437 #[serde(skip_serializing_if = "Option::is_none")]
438 pub deprecated: Option<IntermediateDeprecation>,
439
440 /// The value the member is stored as, when it is not its name (#1580): the value the
441 /// authoring language gives the member, a string (`SHIPPED = "shipped"`) or an integer
442 /// (`LOW = 1`). Any other JSON value is refused when the document is read.
443 #[serde(default, skip_serializing_if = "Option::is_none")]
444 pub value: Option<fraiseql_core::schema::StoredEnumValue>,
445}
446
447/// Deprecation information for enum values or input fields.
448#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
449#[serde(deny_unknown_fields)]
450pub struct IntermediateDeprecation {
451 /// Deprecation reason (what to use instead)
452 #[serde(skip_serializing_if = "Option::is_none")]
453 pub reason: Option<String>,
454}
455
456// =============================================================================
457// Custom Scalar Definitions
458// =============================================================================
459
460/// Custom scalar type definition in intermediate format.
461///
462/// Custom scalars allow applications to define domain-specific types with validation.
463/// Scalars are defined in language SDKs (Python, `TypeScript`, Java, Go, Rust)
464/// and compiled into the schema.
465///
466/// # Example JSON
467///
468/// ```json
469/// {
470/// "name": "Email",
471/// "description": "Valid email address",
472/// "specified_by_url": "https://tools.ietf.org/html/rfc5322",
473/// "base_type": "String",
474/// "validation_rules": [
475/// {
476/// "type": "pattern",
477/// "value": {
478/// "pattern": "^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}$"
479/// }
480/// }
481/// ]
482/// }
483/// ```
484#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
485#[serde(deny_unknown_fields)]
486pub struct IntermediateScalar {
487 /// Scalar name (e.g., "Email", "Phone", "ISBN")
488 pub name: String,
489
490 /// Scalar description
491 #[serde(skip_serializing_if = "Option::is_none")]
492 pub description: Option<String>,
493
494 /// URL to specification/RFC (GraphQL spec §3.5.1)
495 #[serde(skip_serializing_if = "Option::is_none")]
496 pub specified_by_url: Option<String>,
497
498 /// Built-in validation rules
499 #[serde(default)]
500 pub validation_rules: Vec<ValidationRule>,
501
502 /// Base type for type aliases (e.g., "String" for Email scalar)
503 #[serde(skip_serializing_if = "Option::is_none")]
504 pub base_type: Option<String>,
505}