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
159/// A relationship in intermediate format (#1266).
160///
161/// Reuses the compiled-schema [`Cardinality`](fraiseql_core::schema::Cardinality)
162/// directly, for the reason `FieldDefinition::vector` reuses `VectorConfig`: the authored
163/// and compiled shapes cannot then drift.
164///
165/// `foreign_key` and `referenced_key` are SQL **column** names — the foreign key lives on
166/// the child table and the referenced key on the parent, whichever side of the
167/// relationship each happens to be:
168///
169/// | `cardinality` | key read off the **declaring** type | key filtered on the **target** |
170/// |---|---|---|
171/// | `OneToMany` | `referenced_key` | `foreign_key` |
172/// | `ManyToOne` | `foreign_key` | `referenced_key` |
173/// | `OneToOne` | `foreign_key` | `referenced_key` |
174///
175/// Both are required here, unlike the compiled struct where they carry `#[serde(default)]`
176/// — an empty join column is a relationship no embed can compose a predicate for, and an
177/// author who omits one should learn that from the parse error rather than from an empty
178/// array on a 200.
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(deny_unknown_fields)]
181pub struct IntermediateRelationship {
182 /// Relationship name — the key in `?select=<name>(...)` and in the response.
183 pub name: String,
184
185 /// Target GraphQL type name, e.g. `"Post"`. Must be a declared type that some list
186 /// query returns; the embed sources its rows from that query.
187 pub target_type: String,
188
189 /// `OneToMany`, `ManyToOne` or `OneToOne`.
190 pub cardinality: fraiseql_core::schema::Cardinality,
191
192 /// Foreign key column on the child table, e.g. `fk_author`.
193 pub foreign_key: String,
194
195 /// Referenced key column on the parent table, e.g. `id`.
196 pub referenced_key: String,
197}
198
199/// Field definition in intermediate format
200///
201/// **NOTE**: Uses `type` field (not `field_type`)
202/// This is the language-agnostic format. Rust conversion happens in converter.
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204#[serde(deny_unknown_fields)]
205pub struct IntermediateField {
206 /// Field name (e.g., "id")
207 pub name: String,
208
209 /// Field type name (e.g., "Int", "String", "User")
210 ///
211 /// **Language-agnostic**: All languages use "type", not "`field_type`"
212 #[serde(rename = "type")]
213 pub field_type: String,
214
215 /// Is field nullable?
216 pub nullable: bool,
217
218 /// Field description (from docstring)
219 #[serde(skip_serializing_if = "Option::is_none")]
220 pub description: Option<String>,
221
222 /// Applied directives (e.g., @deprecated)
223 #[serde(skip_serializing_if = "Option::is_none")]
224 pub directives: Option<Vec<IntermediateAppliedDirective>>,
225
226 /// Declarable **only to be refused**, with a reason (#1329).
227 ///
228 /// A root query field can be backed by a function; a nested field cannot, and
229 /// this key exists so that saying so is a sentence rather than
230 /// `unknown field \`function\``. It is the natural next thing to try after
231 /// declaring one on a query, and the difference between the two messages is the
232 /// difference between an author who learns the rule and one who concludes the
233 /// feature is broken.
234 ///
235 /// The refusal is not a limitation to be lifted later. A nested resolver runs
236 /// once per row, so a function there is an N+1 measured in V8 isolates — at
237 /// ~5–8 ms each, a hundred-row page costs most of a second before the function
238 /// does any work. Root-only makes "one invocation per query" a property of the
239 /// shape; see [`QueryDefinition::function`] for the rest of the reasoning.
240 ///
241 /// [`QueryDefinition::function`]: fraiseql_core::schema::QueryDefinition::function
242 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub function: Option<String>,
244
245 /// Deprecation information for this field.
246 ///
247 /// The first-class spelling, matching `IntermediateQuery`,
248 /// `IntermediateInputField`, `IntermediateSubscription` and enum values.
249 /// Field deprecation used to be reachable only through a `@deprecated`
250 /// entry in [`directives`](Self::directives), so a field was the one
251 /// construct that could not say it plainly — and both the Python and
252 /// `TypeScript` SDKs emitted this key, which `deny_unknown_fields` turned
253 /// into a refusal of the whole document (#1025).
254 ///
255 /// The directive spelling still works; this takes precedence when both are
256 /// present.
257 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub deprecated: Option<IntermediateDeprecation>,
259
260 /// Scope required to access this field (field-level access control)
261 ///
262 /// When set, users must have this scope in their JWT to query this field.
263 /// Supports patterns like "read:Type.field" or custom scopes like "hr:view_pii".
264 ///
265 /// # Example
266 ///
267 /// ```json
268 /// {
269 /// "name": "salary",
270 /// "type": "Int",
271 /// "nullable": false,
272 /// "requires_scope": "read:Employee.salary"
273 /// }
274 /// ```
275 #[serde(skip_serializing_if = "Option::is_none")]
276 pub requires_scope: Option<String>,
277
278 /// Policy when the user lacks `requires_scope`: `"reject"` (default) or `"mask"`.
279 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub on_deny: Option<String>,
281
282 /// pgvector configuration for `Vector` fields (#386).
283 ///
284 /// Required when `type` is `"Vector"` (the compiler needs the dimension for
285 /// DDL and request-time validation); an error on any other type. Reuses the
286 /// compiled-schema type so the authored and compiled shapes cannot drift.
287 ///
288 /// # Example
289 ///
290 /// ```json
291 /// {
292 /// "name": "embedding",
293 /// "type": "Vector",
294 /// "nullable": false,
295 /// "vector_config": { "dimensions": 1536, "index_type": "hnsw", "distance_metric": "cosine" }
296 /// }
297 /// ```
298 #[serde(default, skip_serializing_if = "Option::is_none")]
299 pub vector_config: Option<fraiseql_core::schema::VectorConfig>,
300
301 /// Names the vector field whose search distance this field carries (#959).
302 ///
303 /// # Example
304 ///
305 /// ```json
306 /// {
307 /// "name": "similarity",
308 /// "type": "Float",
309 /// "nullable": false,
310 /// "vector_distance": "embedding"
311 /// }
312 /// ```
313 #[serde(default, skip_serializing_if = "Option::is_none")]
314 pub vector_distance: Option<String>,
315
316 /// Whether this field is gated by the dynamic field authorizer at runtime.
317 ///
318 /// When `true`, the compiled field is marked policy-gated
319 /// (`FieldDefinition.authorize`) and a configured `FieldAuthorizer` is consulted
320 /// per row. Defaults to `false` when absent.
321 ///
322 /// # Example
323 ///
324 /// ```json
325 /// {
326 /// "name": "email",
327 /// "type": "String",
328 /// "nullable": true,
329 /// "authorize": true
330 /// }
331 /// ```
332 #[serde(default, skip_serializing_if = "Option::is_none")]
333 pub authorize: Option<bool>,
334
335 /// Named hierarchy reference for ID-based ltree operators.
336 /// References a key in the `hierarchies` config map.
337 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub hierarchy: Option<String>,
339
340 /// Whether this field's column is unique over the type's relation.
341 ///
342 /// Declares the fact a **to-one** relationship joining on this field needs, and
343 /// without which the schema is refused at load; see
344 /// [`FieldDefinition::is_unique_key`](fraiseql_core::schema::FieldDefinition::is_unique_key)
345 /// for why the entity identity is exempt and `pk_<type>` is not.
346 ///
347 /// `Option<bool>` rather than `bool` for the reason [`authorize`](Self::authorize) is:
348 /// absent and `false` reach the converter as the same compiled value, but the key is
349 /// then omitted from a re-emitted document rather than written back as `false` on
350 /// every field in the schema.
351 ///
352 /// # Example
353 ///
354 /// ```json
355 /// {
356 /// "name": "identifier",
357 /// "type": "String",
358 /// "nullable": false,
359 /// "unique": true
360 /// }
361 /// ```
362 #[serde(default, skip_serializing_if = "Option::is_none")]
363 pub unique: Option<bool>,
364
365 /// A localized `String` (#1513): stored as a locale map (`{"fr-FR": "Pomme", ...}`) and
366 /// returned as the request locale's label. Emitted by the SDKs' `Localized` type.
367 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
368 pub localized: bool,
369}
370
371// =============================================================================
372// Enum Definitions
373// =============================================================================
374
375/// GraphQL enum type definition in intermediate format.
376///
377/// Enums represent a finite set of possible values.
378///
379/// # Example JSON
380///
381/// ```json
382/// {
383/// "name": "OrderStatus",
384/// "values": [
385/// {"name": "PENDING"},
386/// {"name": "PROCESSING"},
387/// {"name": "SHIPPED", "description": "Package has been shipped"},
388/// {"name": "DELIVERED"}
389/// ],
390/// "description": "Possible states of an order"
391/// }
392/// ```
393#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
394#[serde(deny_unknown_fields)]
395pub struct IntermediateEnum {
396 /// Enum type name (e.g., "OrderStatus")
397 pub name: String,
398
399 /// Possible values for this enum
400 pub values: Vec<IntermediateEnumValue>,
401
402 /// Enum description (from docstring)
403 #[serde(skip_serializing_if = "Option::is_none")]
404 pub description: Option<String>,
405}
406
407/// A single value within an enum type.
408///
409/// # Example JSON
410///
411/// ```json
412/// {
413/// "name": "ACTIVE",
414/// "description": "The item is currently active",
415/// "deprecated": {"reason": "Use ENABLED instead"}
416/// }
417/// ```
418#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
419#[serde(deny_unknown_fields)]
420pub struct IntermediateEnumValue {
421 /// Value name (e.g., "PENDING")
422 pub name: String,
423
424 /// Value description (from docstring)
425 #[serde(skip_serializing_if = "Option::is_none")]
426 pub description: Option<String>,
427
428 /// Deprecation info (if value is deprecated)
429 #[serde(skip_serializing_if = "Option::is_none")]
430 pub deprecated: Option<IntermediateDeprecation>,
431
432 /// The value the member is stored as, when it is not its name (#1580): the value the
433 /// authoring language gives the member, a string (`SHIPPED = "shipped"`) or an integer
434 /// (`LOW = 1`). Any other JSON value is refused when the document is read.
435 #[serde(default, skip_serializing_if = "Option::is_none")]
436 pub value: Option<fraiseql_core::schema::StoredEnumValue>,
437}
438
439/// Deprecation information for enum values or input fields.
440#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
441#[serde(deny_unknown_fields)]
442pub struct IntermediateDeprecation {
443 /// Deprecation reason (what to use instead)
444 #[serde(skip_serializing_if = "Option::is_none")]
445 pub reason: Option<String>,
446}
447
448// =============================================================================
449// Custom Scalar Definitions
450// =============================================================================
451
452/// Custom scalar type definition in intermediate format.
453///
454/// Custom scalars allow applications to define domain-specific types with validation.
455/// Scalars are defined in language SDKs (Python, `TypeScript`, Java, Go, Rust)
456/// and compiled into the schema.
457///
458/// # Example JSON
459///
460/// ```json
461/// {
462/// "name": "Email",
463/// "description": "Valid email address",
464/// "specified_by_url": "https://tools.ietf.org/html/rfc5322",
465/// "base_type": "String",
466/// "validation_rules": [
467/// {
468/// "type": "pattern",
469/// "value": {
470/// "pattern": "^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}$"
471/// }
472/// }
473/// ]
474/// }
475/// ```
476#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
477#[serde(deny_unknown_fields)]
478pub struct IntermediateScalar {
479 /// Scalar name (e.g., "Email", "Phone", "ISBN")
480 pub name: String,
481
482 /// Scalar description
483 #[serde(skip_serializing_if = "Option::is_none")]
484 pub description: Option<String>,
485
486 /// URL to specification/RFC (GraphQL spec §3.5.1)
487 #[serde(skip_serializing_if = "Option::is_none")]
488 pub specified_by_url: Option<String>,
489
490 /// Built-in validation rules
491 #[serde(default)]
492 pub validation_rules: Vec<ValidationRule>,
493
494 /// Base type for type aliases (e.g., "String" for Email scalar)
495 #[serde(skip_serializing_if = "Option::is_none")]
496 pub base_type: Option<String>,
497}