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    /// 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}