Skip to main content

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}