fraiseql-cli 2.19.0

CLI tools for FraiseQL v2 - Schema compilation and development utilities
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
//! Core type structs: `IntermediateType`, `IntermediateField`, `IntermediateEnum`,
//! `IntermediateEnumValue`, `IntermediateScalar`, `IntermediateDeprecation`.

use fraiseql_core::validation::ValidationRule;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};

use super::fragments::IntermediateAppliedDirective;

/// Type definition in intermediate format
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IntermediateType {
    /// Type name (e.g., "User")
    pub name: String,

    /// Type fields
    pub fields: Vec<IntermediateField>,

    /// Type description (from docstring)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,

    /// Type-level SQL source (backing table/view), e.g. `v_organization`.
    ///
    /// Owned types bind their relation on the *query* that returns them, so this
    /// is normally absent. It is emitted by the authoring SDK only for an
    /// owner-split `extend type … @key` federation entity: a subgraph that does
    /// not own the entity exposes no root query returning it, so the federation
    /// `_entities` resolver has no query to source the backing relation from and
    /// would otherwise guess `lower(typename)` and resolve to null (#507). When
    /// present it flows through to `TypeDefinition.sql_source` and is used as the
    /// `_entities` fallback relation.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub sql_source: Option<String>,

    /// Interfaces this type implements (GraphQL spec §3.6)
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub implements: Vec<String>,

    /// Role required to execute any operation returning this type.
    ///
    /// Lowered onto every query and mutation whose `return_type` is this type when the
    /// compiled schema loads, so the runtime's operation-level role gate enforces it —
    /// there is no separate type-level check to keep in step (#677). A gated type
    /// reachable as a *field* of a type that is not gated the same way is refused at
    /// load: operations returning the container carry no role, so the gated type would
    /// travel out ungated.
    ///
    /// It also filters the REST `/introspection` route. It does **not** filter GraphQL
    /// `__schema` introspection.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub requires_role: Option<String>,

    /// Tenant/owner scoping declared on the type itself (#1142).
    ///
    /// The counterpart to [`sql_source`](Self::sql_source) above: a type carrying one is
    /// an entity the `_entities` resolver reaches with no query behind it, so there is no
    /// operation to carry `inject_params`. Before this key, such a type could declare
    /// `requires_role` — honoured from the type since #1030 — but had nowhere to declare
    /// tenant scoping, and the compile succeeded with the annotation covering nothing.
    ///
    /// Accepts the same two value shapes as the operation-level key (#806) and threads
    /// through to `TypeDefinition.inject_params`, where both `_entities` consumers read it
    /// behind the backing query. A type and its query declaring the same column from
    /// different sources is refused when the compiled schema loads.
    #[serde(
        default,
        with = "super::operations::inject_params_serde",
        skip_serializing_if = "IndexMap::is_empty"
    )]
    pub inject_params: IndexMap<String, String>,

    /// Whether this type is a mutation error type (tagged with `@fraiseql.error`).
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub is_error: bool,

    /// Whether the author declared this a GraphQL **input** object rather than an output
    /// type (`is_input: true`).
    ///
    /// Four SDKs advertise this in their READMEs and emit it — Elixir
    /// (`fraiseql_type "X", is_input: true`), F# (`[<GraphQLType(IsInput=…)>]`), PHP
    /// (`#[GraphQLType(isInput: true)]`) and C# (`GraphQLTypeAttribute.IsInput`). There was
    /// no field here to receive it, so such a type compiled as an *object* type: a mutation
    /// argument referencing it produced a schema violating GraphQL §3.10 ("arguments must
    /// be input types"), which introspection-driven clients reject and federation
    /// composition fails on. `fraiseql compile` and `fraiseql lint` both exited 0 (#848).
    ///
    /// The converter routes a type carrying this into `input_types` and omits it from
    /// `types`. That is what makes Elixir's surface work at all: its exporter emits no
    /// `input_types` key whatsoever, so this flag is its *only* route to an input object.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub is_input: bool,

    /// Whether this type implements the Relay Node interface.
    /// When true, the compiler generates global node IDs (`base64("TypeName:uuid")`)
    /// and validates that `pk_{entity}` (BIGINT) is present in the view's data JSONB.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub relay: bool,

    /// Whether the author declared this an embedded value object
    /// (`@fraiseql.type(embedded=True)`, #687).
    ///
    /// A value object has no independent identity and is always nested under a parent
    /// entity, so the compiler exempts it from cascade entity classification: no
    /// `id: ID!` enforcement, no auto-`implements CascadeNode`. Threaded verbatim to
    /// `TypeDefinition.embedded` — it is the author's declaration, not something the
    /// compiler infers.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub embedded: bool,

    /// `@subscribable(tables=[...])` — the underlying base table(s) whose external
    /// writes should be captured onto the Change Spine (#366).
    ///
    /// The compiler aggregates the set of `(name, tables)` for every type carrying
    /// this into `CompiledSchema.subscribable`, which the
    /// `generate_capture_trigger_ddl` generator turns into per-table capture
    /// triggers. `None`/absent (the default) when the type is not subscribable.
    ///
    /// # Example
    ///
    /// ```json
    /// {
    ///   "name": "Post",
    ///   "fields": [],
    ///   "subscribable_tables": ["tb_post"]
    /// }
    /// ```
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub subscribable_tables: Option<Vec<String>>,

    /// `@subscribable(..., pre_image=True)` — whether the capture triggers on this
    /// type's tables also record the changed entity's pre-image (OLD) into
    /// `object_data_before`, the out-of-band parity for a mutation's
    /// `changelog_pre_image`. Only meaningful alongside `subscribable_tables`;
    /// `false`/absent (the default) captures the after-image only.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub subscribable_pre_image: bool,

    /// Relationships to other types, the declarations REST resource embedding follows
    /// (#1266).
    ///
    /// Threaded verbatim to `TypeDefinition.relationships`. Until this key existed the
    /// compiled field had no producer at all: every converter wrote the empty vector and
    /// `deny_unknown_fields` above turned an SDK that emitted the key into a refusal of
    /// the whole document — so `?select=posts(id,title)`, `?select=posts.count`,
    /// `?posts.status=`, the `OpenAPI` relationship properties and the generated
    /// `relationships.*` client modules were reachable only from a hand-written
    /// `schema.compiled.json`.
    ///
    /// A relationship the embed executor could not follow — an undeclared target, a join
    /// column no field publishes, a target no list query returns — is refused at compile
    /// time and again when the compiled schema loads. That check is the durable half:
    /// #1230 was a join key the projection omitted, served as `null` under a 200.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub relationships: Vec<IntermediateRelationship>,

    /// Per-row visibility for this type's subscription deliveries: a subscriber receives a
    /// row only when the row's `owner_path` field equals its enriched `identity_field`,
    /// unless it holds one of `bypass_roles`. Threaded verbatim to
    /// `TypeDefinition.subscription_policy`; the owner field must be a single-level field of
    /// this type, checked at compile.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub subscription_policy: Option<fraiseql_core::schema::SubscriptionPolicy>,
}

/// A relationship in intermediate format (#1266).
///
/// Reuses the compiled-schema [`Cardinality`](fraiseql_core::schema::Cardinality)
/// directly, for the reason `FieldDefinition::vector` reuses `VectorConfig`: the authored
/// and compiled shapes cannot then drift.
///
/// `foreign_key` and `referenced_key` are SQL **column** names — the foreign key lives on
/// the child table and the referenced key on the parent, whichever side of the
/// relationship each happens to be:
///
/// | `cardinality` | key read off the **declaring** type | key filtered on the **target** |
/// |---|---|---|
/// | `OneToMany` | `referenced_key` | `foreign_key` |
/// | `ManyToOne` | `foreign_key` | `referenced_key` |
/// | `OneToOne` | `foreign_key` | `referenced_key` |
///
/// Both are required here, unlike the compiled struct where they carry `#[serde(default)]`
/// — an empty join column is a relationship no embed can compose a predicate for, and an
/// author who omits one should learn that from the parse error rather than from an empty
/// array on a 200.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IntermediateRelationship {
    /// Relationship name — the key in `?select=<name>(...)` and in the response.
    pub name: String,

    /// Target GraphQL type name, e.g. `"Post"`. Must be a declared type that some list
    /// query returns; the embed sources its rows from that query.
    pub target_type: String,

    /// `OneToMany`, `ManyToOne` or `OneToOne`.
    pub cardinality: fraiseql_core::schema::Cardinality,

    /// Foreign key column on the child table, e.g. `fk_author`.
    pub foreign_key: String,

    /// Referenced key column on the parent table, e.g. `id`.
    pub referenced_key: String,
}

/// Field definition in intermediate format
///
/// **NOTE**: Uses `type` field (not `field_type`)
/// This is the language-agnostic format. Rust conversion happens in converter.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IntermediateField {
    /// Field name (e.g., "id")
    pub name: String,

    /// Field type name (e.g., "Int", "String", "User")
    ///
    /// **Language-agnostic**: All languages use "type", not "`field_type`"
    #[serde(rename = "type")]
    pub field_type: String,

    /// Is field nullable?
    pub nullable: bool,

    /// Field description (from docstring)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,

    /// Applied directives (e.g., @deprecated)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub directives: Option<Vec<IntermediateAppliedDirective>>,

    /// Declarable **only to be refused**, with a reason (#1329).
    ///
    /// A root query field can be backed by a function; a nested field cannot, and
    /// this key exists so that saying so is a sentence rather than
    /// `unknown field \`function\``. It is the natural next thing to try after
    /// declaring one on a query, and the difference between the two messages is the
    /// difference between an author who learns the rule and one who concludes the
    /// feature is broken.
    ///
    /// The refusal is not a limitation to be lifted later. A nested resolver runs
    /// once per row, so a function there is an N+1 measured in V8 isolates — at
    /// ~5–8 ms each, a hundred-row page costs most of a second before the function
    /// does any work. Root-only makes "one invocation per query" a property of the
    /// shape; see [`QueryDefinition::function`] for the rest of the reasoning.
    ///
    /// [`QueryDefinition::function`]: fraiseql_core::schema::QueryDefinition::function
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub function: Option<String>,

    /// Deprecation information for this field.
    ///
    /// The first-class spelling, matching `IntermediateQuery`,
    /// `IntermediateInputField`, `IntermediateSubscription` and enum values.
    /// Field deprecation used to be reachable only through a `@deprecated`
    /// entry in [`directives`](Self::directives), so a field was the one
    /// construct that could not say it plainly — and both the Python and
    /// `TypeScript` SDKs emitted this key, which `deny_unknown_fields` turned
    /// into a refusal of the whole document (#1025).
    ///
    /// The directive spelling still works; this takes precedence when both are
    /// present.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub deprecated: Option<IntermediateDeprecation>,

    /// Scope required to access this field (field-level access control)
    ///
    /// When set, users must have this scope in their JWT to query this field.
    /// Supports patterns like "read:Type.field" or custom scopes like "hr:view_pii".
    ///
    /// # Example
    ///
    /// ```json
    /// {
    ///   "name": "salary",
    ///   "type": "Int",
    ///   "nullable": false,
    ///   "requires_scope": "read:Employee.salary"
    /// }
    /// ```
    #[serde(skip_serializing_if = "Option::is_none")]
    pub requires_scope: Option<String>,

    /// Policy when the user lacks `requires_scope`: `"reject"` (default) or `"mask"`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub on_deny: Option<String>,

    /// pgvector configuration for `Vector` fields (#386).
    ///
    /// Required when `type` is `"Vector"` (the compiler needs the dimension for
    /// DDL and request-time validation); an error on any other type. Reuses the
    /// compiled-schema type so the authored and compiled shapes cannot drift.
    ///
    /// # Example
    ///
    /// ```json
    /// {
    ///   "name": "embedding",
    ///   "type": "Vector",
    ///   "nullable": false,
    ///   "vector_config": { "dimensions": 1536, "index_type": "hnsw", "distance_metric": "cosine" }
    /// }
    /// ```
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub vector_config: Option<fraiseql_core::schema::VectorConfig>,

    /// Names the vector field whose search distance this field carries (#959).
    ///
    /// # Example
    ///
    /// ```json
    /// {
    ///   "name": "similarity",
    ///   "type": "Float",
    ///   "nullable": false,
    ///   "vector_distance": "embedding"
    /// }
    /// ```
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub vector_distance: Option<String>,

    /// Whether this field is gated by the dynamic field authorizer at runtime.
    ///
    /// When `true`, the compiled field is marked policy-gated
    /// (`FieldDefinition.authorize`) and a configured `FieldAuthorizer` is consulted
    /// per row. Defaults to `false` when absent.
    ///
    /// # Example
    ///
    /// ```json
    /// {
    ///   "name": "email",
    ///   "type": "String",
    ///   "nullable": true,
    ///   "authorize": true
    /// }
    /// ```
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub authorize: Option<bool>,

    /// Named hierarchy reference for ID-based ltree operators.
    /// References a key in the `hierarchies` config map.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub hierarchy: Option<String>,

    /// Whether this field's column is unique over the type's relation.
    ///
    /// Declares the fact a **to-one** relationship joining on this field needs, and
    /// without which the schema is refused at load; see
    /// [`FieldDefinition::is_unique_key`](fraiseql_core::schema::FieldDefinition::is_unique_key)
    /// for why the entity identity is exempt and `pk_<type>` is not.
    ///
    /// `Option<bool>` rather than `bool` for the reason [`authorize`](Self::authorize) is:
    /// absent and `false` reach the converter as the same compiled value, but the key is
    /// then omitted from a re-emitted document rather than written back as `false` on
    /// every field in the schema.
    ///
    /// # Example
    ///
    /// ```json
    /// {
    ///   "name": "identifier",
    ///   "type": "String",
    ///   "nullable": false,
    ///   "unique": true
    /// }
    /// ```
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub unique: Option<bool>,

    /// A localized `String` (#1513): stored as a locale map (`{"fr-FR": "Pomme", ...}`) and
    /// returned as the request locale's label. Emitted by the SDKs' `Localized` type.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub localized: bool,
}

// =============================================================================
// Enum Definitions
// =============================================================================

/// GraphQL enum type definition in intermediate format.
///
/// Enums represent a finite set of possible values.
///
/// # Example JSON
///
/// ```json
/// {
///   "name": "OrderStatus",
///   "values": [
///     {"name": "PENDING"},
///     {"name": "PROCESSING"},
///     {"name": "SHIPPED", "description": "Package has been shipped"},
///     {"name": "DELIVERED"}
///   ],
///   "description": "Possible states of an order"
/// }
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IntermediateEnum {
    /// Enum type name (e.g., "OrderStatus")
    pub name: String,

    /// Possible values for this enum
    pub values: Vec<IntermediateEnumValue>,

    /// Enum description (from docstring)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
}

/// A single value within an enum type.
///
/// # Example JSON
///
/// ```json
/// {
///   "name": "ACTIVE",
///   "description": "The item is currently active",
///   "deprecated": {"reason": "Use ENABLED instead"}
/// }
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IntermediateEnumValue {
    /// Value name (e.g., "PENDING")
    pub name: String,

    /// Value description (from docstring)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,

    /// Deprecation info (if value is deprecated)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub deprecated: Option<IntermediateDeprecation>,

    /// The value the member is stored as, when it is not its name (#1580): the value the
    /// authoring language gives the member, a string (`SHIPPED = "shipped"`) or an integer
    /// (`LOW = 1`). Any other JSON value is refused when the document is read.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub value: Option<fraiseql_core::schema::StoredEnumValue>,
}

/// Deprecation information for enum values or input fields.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IntermediateDeprecation {
    /// Deprecation reason (what to use instead)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reason: Option<String>,
}

// =============================================================================
// Custom Scalar Definitions
// =============================================================================

/// Custom scalar type definition in intermediate format.
///
/// Custom scalars allow applications to define domain-specific types with validation.
/// Scalars are defined in language SDKs (Python, `TypeScript`, Java, Go, Rust)
/// and compiled into the schema.
///
/// # Example JSON
///
/// ```json
/// {
///   "name": "Email",
///   "description": "Valid email address",
///   "specified_by_url": "https://tools.ietf.org/html/rfc5322",
///   "base_type": "String",
///   "validation_rules": [
///     {
///       "type": "pattern",
///       "value": {
///         "pattern": "^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}$"
///       }
///     }
///   ]
/// }
/// ```
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IntermediateScalar {
    /// Scalar name (e.g., "Email", "Phone", "ISBN")
    pub name: String,

    /// Scalar description
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,

    /// URL to specification/RFC (GraphQL spec §3.5.1)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub specified_by_url: Option<String>,

    /// Built-in validation rules
    #[serde(default)]
    pub validation_rules: Vec<ValidationRule>,

    /// Base type for type aliases (e.g., "String" for Email scalar)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub base_type: Option<String>,
}