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
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
use anyhow::{Context, Result};
use fraiseql_core::{
    schema::{
        EnumDefinition, EnumValueDefinition, FieldDefinition, FieldDenyPolicy, FieldType,
        InputFieldDefinition, InputObjectDefinition, InterfaceDefinition, Relationship,
        TypeDefinition, UnionDefinition,
    },
    validation::CustomTypeDef,
};

use super::{DeclaredTypeNames, SchemaConverter};
use crate::schema::intermediate::{
    IntermediateAppliedDirective, IntermediateDeprecation, IntermediateEnum, IntermediateEnumValue,
    IntermediateField, IntermediateInputField, IntermediateInputObject, IntermediateInterface,
    IntermediateRest, IntermediateScalar, IntermediateType, IntermediateUnion,
};

impl SchemaConverter {
    /// Lift every `@inaccessible` off the fields of `types`, returning the hidden
    /// fields by type name.
    ///
    /// The directive is a federation fact, carried by the compiled `federation` block
    /// rather than by the field, so it is removed here and `convert_field` refuses any
    /// that remains (on an interface or a mutation's fields, where nothing carries it).
    pub(super) fn take_inaccessible_fields(
        types: &mut [IntermediateType],
    ) -> std::collections::BTreeMap<String, Vec<String>> {
        let mut hidden: std::collections::BTreeMap<String, Vec<String>> =
            std::collections::BTreeMap::new();
        for ty in types {
            for field in &mut ty.fields {
                let Some(directives) = field.directives.as_mut() else {
                    continue;
                };
                let applied = directives.len();
                directives.retain(|d| d.name != "inaccessible");
                if directives.len() != applied {
                    hidden.entry(ty.name.clone()).or_default().push(field.name.clone());
                }
            }
        }
        hidden
    }

    /// Convert an `IntermediateType` to a compiled `TypeDefinition`.
    ///
    /// # Errors
    ///
    /// Returns an error if any field in the type cannot be converted.
    pub(super) fn convert_type(
        intermediate: IntermediateType,
        declared: &DeclaredTypeNames,
    ) -> Result<TypeDefinition> {
        // `@tenant_id` on an object type's field is read off the authored schema by the
        // tenancy validation that runs before conversion (`AnnotatedTypeIndex`), and it
        // drives the operation's tenant inject. It has done its work by the time the field
        // is converted, so it is dropped here; on any other field nothing reads it and
        // `convert_field` refuses it.
        let fields = intermediate
            .fields
            .into_iter()
            .map(|mut f| {
                if let Some(directives) = f.directives.as_mut() {
                    directives.retain(|d| d.name != "tenant_id");
                }
                Self::convert_field(f, declared)
            })
            .collect::<Result<Vec<_>>>()
            .context(format!("Failed to convert type '{}'", intermediate.name))?;

        Self::validate_vector_distance_fields(&intermediate.name, &fields)?;

        // A type-level `sql_source` becomes the `_entities` resolver's fallback relation
        // when no query supplies one (#507).
        //
        // ⚠ Do NOT read this as "only owner-split entities carry one". It is whatever the
        // authoring SDK emitted, and the Python SDK synthesizes `v_{snake_case(name)}` for
        // **every non-embedded type** (`sdks/official/fraiseql-python/.../registry.py`), so
        // a plainly-owned Python-authored type arrives here with a type-level source too.
        // The earlier "owned types are normally empty" wording described the hand-written
        // TOML path only, and reading it as a general locality guarantee is what let the
        // `_entities` path be treated as reachable by owner-split entities alone (#1068,
        // whose role gate is enforced from the type in `enforce_entities_authz`).
        //
        // Fields project from the standard `data` jsonb column — symmetric with the query
        // path, whose `jsonb_column` also defaults to `data`. A type with no source keeps
        // both empty (byte-identical compiled output, and the optimizer's projection-hint
        // heuristic stays off).
        let type_sql_source = intermediate.sql_source.unwrap_or_default();
        let type_jsonb_column = if type_sql_source.is_empty() {
            String::new()
        } else {
            "data".to_string()
        };

        // Type-level scoping for the entity no query returns (#1142). Parsed with the same
        // function as the operation path, so an unsupported source is refused here too
        // rather than compiling to a type that declares scoping and carries none.
        let type_name = intermediate.name.clone();

        // Threaded verbatim: the compiled struct is the authored one, field for field
        // (#1266). Whether the relationship can actually be followed — target declared,
        // join columns published as fields, target reachable through a list query — needs
        // the whole schema and so is checked once it is assembled, in
        // `validate_relationships`.
        let relationships = intermediate
            .relationships
            .into_iter()
            .map(|r| Relationship {
                name:           r.name,
                target_type:    r.target_type,
                cardinality:    r.cardinality,
                foreign_key:    r.foreign_key,
                referenced_key: r.referenced_key,
                native_join:    None,
            })
            .collect();

        let inject_params = intermediate
            .inject_params
            .into_iter()
            .map(|(column, source)| {
                let parsed = Self::parse_inject_source(&source)
                    .with_context(|| format!("Type '{type_name}': inject param '{column}'"))?;
                Self::refuse_optional_row_filter(&format!("Type '{type_name}'"), &column, &parsed)?;
                Ok((column, parsed))
            })
            .collect::<Result<indexmap::IndexMap<_, _>>>()?;

        Ok(TypeDefinition {
            name: intermediate.name.into(),
            fields,
            description: intermediate.description,
            sql_source: type_sql_source.into(),
            jsonb_column: type_jsonb_column,
            sql_projection_hint: None, // Will be populated by optimizer in
            implements: intermediate.implements,
            requires_role: intermediate.requires_role,
            is_error: intermediate.is_error,
            relay: intermediate.relay,
            embedded: intermediate.embedded,
            internal: false,
            relationships,
            inject_params,
            subscription_policy: intermediate.subscription_policy,
        })
    }

    /// Check every `vector_distance` declaration against its siblings (#959).
    ///
    /// The reference is only resolvable once the whole type is converted, and it
    /// has to be resolved *somewhere*: a distance field naming a vector field
    /// that does not exist compiles into a schema whose only symptom is a query
    /// refused at runtime, on a query the author cannot see from the
    /// declaration.
    ///
    /// # Errors
    ///
    /// Returns an error when the named field is absent or is not a vector field,
    /// or when the declaring field is not a `Float` — the type pgvector's
    /// distance operators return.
    fn validate_vector_distance_fields(type_name: &str, fields: &[FieldDefinition]) -> Result<()> {
        for field in fields {
            let Some(measures) = field.vector_distance.as_deref() else {
                continue;
            };
            if field.field_type != FieldType::Float {
                anyhow::bail!(
                    "Field '{type_name}.{}' declares vector_distance but its type is '{}'; \
                     a pgvector distance is a Float",
                    field.name,
                    field.field_type
                );
            }
            if field.vector_config.is_some() {
                anyhow::bail!(
                    "Field '{type_name}.{}' declares both vector_distance and vector_config; \
                     it carries the distance to '{measures}', it is not itself a vector",
                    field.name
                );
            }
            let target = fields.iter().find(|f| f.name.as_str() == measures);
            match target {
                Some(t) if t.field_type.is_searchable_vector() => {},
                Some(_) => anyhow::bail!(
                    "Field '{type_name}.{}' declares vector_distance = \"{measures}\", but \
                     '{measures}' is not a vector field",
                    field.name
                ),
                None => anyhow::bail!(
                    "Field '{type_name}.{}' declares vector_distance = \"{measures}\", and \
                     '{type_name}' has no field by that name",
                    field.name
                ),
            }
        }
        Ok(())
    }

    /// Convert `IntermediateEnum` to `EnumDefinition`
    pub(super) fn convert_enum(intermediate: IntermediateEnum) -> EnumDefinition {
        let values = intermediate.values.into_iter().map(Self::convert_enum_value).collect();

        EnumDefinition {
            name: intermediate.name,
            values,
            description: intermediate.description,
        }
    }

    /// Convert `IntermediateEnumValue` to `EnumValueDefinition`
    pub(super) fn convert_enum_value(intermediate: IntermediateEnumValue) -> EnumValueDefinition {
        let deprecation = intermediate
            .deprecated
            .map(|d| fraiseql_core::schema::DeprecationInfo { reason: d.reason });

        EnumValueDefinition {
            name: intermediate.name,
            description: intermediate.description,
            deprecation,
            value: intermediate.value,
        }
    }

    /// Convert `IntermediateScalar` to `CustomTypeDef`
    ///
    /// # Errors
    ///
    /// Currently infallible; always returns `Ok`. The `Result` return type is
    /// reserved for future validation of scalar definitions.
    /// Convert `IntermediateScalar` to `CustomTypeDef`.
    ///
    /// # `validation_rules` is refused rather than carried
    ///
    /// `CompiledSchema.custom_scalars` is `#[serde(skip)]`: this converter registers the
    /// scalar into an in-memory `CustomTypeRegistry` which is then **dropped** when the
    /// schema is written to `schema.compiled.json`. Nothing in `fraiseql-server` reads it
    /// back either — the only mention is `reload_gate.rs`, which explicitly ignores the
    /// field. So a declared `pattern`, `length` or `range` reached no runtime, from any
    /// SDK, and the author got `✓ Schema compiled successfully` and a server validating
    /// nothing.
    ///
    /// Carrying the rules through here without a runtime consumer would relocate the drop
    /// one layer later rather than fix it, which is the disposition `#779` got for
    /// observers. Making them real means serializing the registry *and* giving the
    /// executor a consumer; until then the declaration is refused by the name of the key
    /// that cannot be honoured.
    ///
    /// The scalar *declaration* itself is unaffected: the name becomes known to the
    /// compiler, so a field typed with it resolves as a scalar rather than an object
    /// reference. Only the unenforceable half is refused.
    ///
    /// # Errors
    ///
    /// Returns an error naming the scalar when it declares `validation_rules`.
    pub(super) fn convert_custom_scalar(intermediate: IntermediateScalar) -> Result<CustomTypeDef> {
        if !intermediate.validation_rules.is_empty() {
            anyhow::bail!(
                "Custom scalar '{}' declares `validation_rules`, which no compiled schema \
                 carries: `CompiledSchema.custom_scalars` is not serialized and the runtime \
                 reads no scalar rules, so the constraint would never be enforced. Remove the \
                 rules and validate in the database (a CHECK constraint or a domain type), or \
                 on the mutation's SQL function.",
                intermediate.name
            );
        }

        Ok(CustomTypeDef {
            name:             intermediate.name,
            description:      intermediate.description,
            specified_by_url: intermediate.specified_by_url,
            validation_rules: intermediate.validation_rules,
            base_type:        intermediate.base_type,
        })
    }

    /// Convert `IntermediateInputObject` to `InputObjectDefinition`
    pub(super) fn convert_input_object(
        intermediate: IntermediateInputObject,
    ) -> InputObjectDefinition {
        let fields = intermediate.fields.into_iter().map(Self::convert_input_field).collect();

        InputObjectDefinition {
            name: intermediate.name,
            fields,
            description: intermediate.description,
            metadata: None,
        }
    }

    /// Reinterpret an `is_input`-marked entry of the `types` array as an input object (#848).
    ///
    /// Output-only attributes are **refused**, not ignored: `sql_source`, `relay`,
    /// `requires_role`, `is_error`, `implements` and `subscribable_tables` have no meaning on
    /// a GraphQL input object, and an author who set one has a mistaken model of what they
    /// are declaring. Dropping them silently is the defect class this phase exists to close.
    ///
    /// # Errors
    ///
    /// Returns an error naming the type and the offending attribute.
    pub(super) fn input_object_from_marked_type(
        intermediate: IntermediateType,
    ) -> Result<IntermediateInputObject> {
        let name = intermediate.name;

        // (attribute is set, attribute name) — checked in declaration order so the message
        // is stable.
        let rejected: [(bool, &str); 6] = [
            (intermediate.sql_source.is_some(), "sql_source"),
            (!intermediate.implements.is_empty(), "implements"),
            (intermediate.requires_role.is_some(), "requires_role"),
            (intermediate.is_error, "is_error"),
            (intermediate.relay, "relay"),
            (intermediate.subscribable_tables.is_some(), "subscribable_tables"),
        ];
        if let Some((_, attribute)) = rejected.into_iter().find(|(set, _)| *set) {
            anyhow::bail!(
                "Type '{name}' is declared `is_input: true` but also sets `{attribute}`, which \
                 only applies to output types. A GraphQL input object has no backing relation, \
                 no interfaces and no role gate — it is only ever a shape for arguments. Remove \
                 `{attribute}`, or remove `is_input` if '{name}' is meant to be an output type."
            );
        }

        let fields = intermediate
            .fields
            .into_iter()
            .map(|field| Self::input_field_from_marked_field(&name, field))
            .collect::<Result<Vec<_>>>()?;

        Ok(IntermediateInputObject {
            name,
            fields,
            description: intermediate.description,
        })
    }

    /// Rebuild one field of an `is_input` type as an input field.
    ///
    /// The destructuring is exhaustive, so an attribute added to `IntermediateField` fails
    /// to compile here until it is either carried or refused. Deprecation has an input-field
    /// slot and is carried; every other output attribute is refused by name, since dropping
    /// it would compile a declaration the server never honours.
    ///
    /// # Errors
    ///
    /// Returns an error naming the input type, the field and the attribute or directive.
    fn input_field_from_marked_field(
        type_name: &str,
        field: IntermediateField,
    ) -> Result<IntermediateInputField> {
        let IntermediateField {
            name,
            field_type,
            nullable,
            description,
            directives,
            function,
            deprecated,
            requires_scope,
            on_deny,
            vector_config,
            vector_distance,
            authorize,
            hierarchy,
            unique,
            localized,
        } = field;

        if let Some(scope) = requires_scope {
            anyhow::bail!(
                "Input object '{type_name}' field '{name}' declares `requires_scope` \
                 ({scope:?}). Field-level scopes gate values the server *returns*; an input \
                 field carries a value the client *sends*, so the gate would never run. Remove \
                 it, and validate the argument in the SQL function instead."
            );
        }
        // (attribute is set, attribute name), in declaration order so the message is stable.
        let rejected: [(bool, &str); 7] = [
            (function.is_some(), "function"),
            (on_deny.is_some(), "on_deny"),
            (vector_config.is_some(), "vector_config"),
            (vector_distance.is_some(), "vector_distance"),
            (authorize == Some(true), "authorize"),
            (hierarchy.is_some(), "hierarchy"),
            (unique == Some(true), "unique"),
        ];
        if let Some((_, attribute)) = rejected.into_iter().find(|(set, _)| *set) {
            anyhow::bail!(
                "Input object '{type_name}' field '{name}' declares `{attribute}`, which only \
                 applies to a field the server returns. An input field is a value the client \
                 sends, so the compiled schema has nowhere to carry it. Remove `{attribute}`."
            );
        }
        let directives = directives.unwrap_or_default();
        if let Some(directive) = directives.iter().find(|d| d.name != "deprecated") {
            anyhow::bail!(
                "Input object '{type_name}' field '{name}' applies `@{}`, which an input field \
                 cannot carry: only `@deprecated` (or the `deprecated` key) compiles onto an \
                 input field. Remove the directive; a check on the value belongs in the \
                 mutation's SQL function.",
                directive.name
            );
        }

        Ok(IntermediateInputField {
            name,
            field_type,
            nullable,
            description,
            default: None,
            deprecated: declared_deprecation(deprecated, &directives),
            localized,
        })
    }

    /// Convert `IntermediateInputField` to `InputFieldDefinition`
    pub(super) fn convert_input_field(
        intermediate: IntermediateInputField,
    ) -> InputFieldDefinition {
        let deprecation = intermediate
            .deprecated
            .map(|d| fraiseql_core::schema::DeprecationInfo { reason: d.reason });

        // Convert default value to JSON string if present
        let default_value = intermediate.default.map(|v| v.to_string());

        // Requiredness is authorable two ways — a trailing `!` on the type string
        // and the `nullable` flag — and they were both carried into the compiled
        // schema unreconciled. The runtime reads only the flag
        // (`InputFieldDefinition::is_required`), every generated client read only
        // the `!`, so the two disagreed in both directions: a hand-written
        // `"String!"` was required to the client and optional to the runtime,
        // while the SDK spelling (`"String"` + `nullable: false`) was the
        // reverse (#1065).
        //
        // The `!` is resolved into the flag here and stripped, so a compiled
        // schema states requiredness in exactly one place. Only the **trailing**
        // marker is the field's own: `[String!]` is an optional list of non-null
        // elements, and that inner marker is part of the type.
        let (field_type, nullable) = match intermediate.field_type.strip_suffix('!') {
            Some(base) => (base.trim_end().to_string(), false),
            None => (intermediate.field_type, intermediate.nullable),
        };

        InputFieldDefinition {
            name: intermediate.name,
            field_type,
            // Carry per-field nullability into the compiled schema so the runtime
            // can enforce required input fields (#414). Output fields already do
            // this via `convert_field`.
            nullable,
            description: intermediate.description,
            default_value,
            deprecation,
            validation_rules: Vec::new(),
            localized: intermediate.localized,
        }
    }

    /// Convert `IntermediateInterface` to `InterfaceDefinition`
    pub(super) fn convert_interface(
        intermediate: IntermediateInterface,
        declared: &DeclaredTypeNames,
    ) -> Result<InterfaceDefinition> {
        let fields = intermediate
            .fields
            .into_iter()
            .map(|f| Self::convert_field(f, declared))
            .collect::<Result<Vec<_>>>()
            .context(format!("Failed to convert interface '{}'", intermediate.name))?;

        Ok(InterfaceDefinition {
            name: intermediate.name,
            fields,
            description: intermediate.description,
        })
    }

    /// Convert `IntermediateUnion` to `UnionDefinition`
    pub(super) fn convert_union(intermediate: IntermediateUnion) -> UnionDefinition {
        let mut union_def =
            UnionDefinition::new(&intermediate.name).with_members(intermediate.member_types);
        if let Some(desc) = intermediate.description {
            union_def = union_def.with_description(&desc);
        }
        union_def
    }

    /// Convert `IntermediateField` to `FieldDefinition`
    ///
    /// **Key normalization**: `type` → `field_type`
    ///
    /// # Errors
    ///
    /// Returns an error if the field's type string cannot be parsed into a
    /// `FieldType`.
    pub(super) fn convert_field(
        intermediate: IntermediateField,
        declared: &DeclaredTypeNames,
    ) -> Result<FieldDefinition> {
        // #1329: root fields only. The key exists on `IntermediateField` purely so
        // this refusal can be a sentence — `deny_unknown_fields` would already stop
        // the document, with `unknown field \`function\``, which tells an author who
        // has just declared one on a query that they misspelled something rather
        // than that they are asking for the one shape this deliberately refuses.
        if let Some(function) = intermediate.function.as_deref() {
            anyhow::bail!(
                "Field '{}': function = '{function}' is not allowed on a nested field — only a \
                 root query field can be backed by a function. A nested resolver runs once per \
                 row, so a function there costs one V8 isolate per row (~5-8 ms each) instead of \
                 one per query. Declare a root query that returns this shape and back that with \
                 the function, or compute the field inside the function that already produces \
                 the row.",
                intermediate.name
            );
        }

        // `@deprecated` compiles onto the field. On an object type's field,
        // `@inaccessible` was lifted into the federation metadata before conversion
        // (`take_inaccessible_fields`) and `@tenant_id` was consumed by tenancy
        // validation (`convert_type`). Anything left would compile as a field without
        // it, so it is refused by name.
        if let Some(directive) =
            intermediate.directives.iter().flatten().find(|d| d.name != "deprecated")
        {
            anyhow::bail!(
                "Field '{}' applies `@{}`, which the compiled schema cannot carry. An output \
                 field compiles `@deprecated` (or the `deprecated` key); a field of an object \
                 type also takes `@tenant_id`, and `@inaccessible` in a federated schema. \
                 Remove the directive.",
                intermediate.name,
                directive.name
            );
        }

        let field_type = Self::parse_field_type(&intermediate.field_type, declared)?;

        // #386: vector_config is required on Vector fields (DDL and request-time
        // dimension validation both need it) and meaningless anywhere else —
        // both inert shapes are compile errors, not silent drops. #959 adds
        // BitVector, whose config the same two consumers need, and whose metric
        // must be one pgvector defines over `bit` values.
        match (&intermediate.vector_config, field_type.is_searchable_vector()) {
            (None, true) => anyhow::bail!(
                "Field '{}' has type {} but no vector_config; declare \
                 {{\"dimensions\": N, \"index_type\": ..., \"distance_metric\": ...}} \
                 (dimensions is required)",
                intermediate.name,
                intermediate.field_type
            ),
            (Some(_), false) => anyhow::bail!(
                "Field '{}' declares vector_config but its type is '{}', not Vector or \
                 BitVector",
                intermediate.name,
                intermediate.field_type
            ),
            _ => {},
        }
        if let Some(ref config) = intermediate.vector_config {
            config
                .validate_for(&field_type)
                .map_err(|e| anyhow::anyhow!("Field '{}': {e}", intermediate.name))?;
        }

        // The first-class `deprecated` key wins, falling back to a `@deprecated`
        // directive. The key is what every sibling construct uses and what the
        // SDKs emit; the directive is the older spelling and keeps working, so a
        // schema already using it is unaffected (#1025).
        let deprecation = declared_deprecation(
            intermediate.deprecated,
            intermediate.directives.as_deref().unwrap_or_default(),
        )
        .map(|d| fraiseql_core::schema::DeprecationInfo { reason: d.reason });

        Ok(FieldDefinition {
            name: intermediate.name.into(),
            field_type,
            nullable: intermediate.nullable,
            default_value: None,
            description: intermediate.description,
            vector_config: intermediate.vector_config,
            vector_distance: intermediate.vector_distance,
            alias: None,
            deprecation,
            requires_scope: intermediate.requires_scope,
            on_deny: intermediate.on_deny.map_or(FieldDenyPolicy::default(), |v| {
                if v == "mask" {
                    FieldDenyPolicy::Mask
                } else {
                    FieldDenyPolicy::Reject
                }
            }),
            authorize: intermediate.authorize.unwrap_or(false),
            encryption: None,
            hierarchy: intermediate.hierarchy,
            unique: intermediate.unique.unwrap_or(false),
            localized: intermediate.localized,
        })
    }

    /// Parse string type name to `FieldType` enum
    ///
    /// Handles built-in scalars, custom object types, and SDL list/non-null
    /// wrappers. A list field/argument arrives as the SDL string `"[Inner!]"`,
    /// which is unwrapped (recursively) into [`FieldType::List`] so the runtime
    /// projects it as a list rather than a single object (#434). Each list level records
    /// whether its items may be `null` (`[Inner]`) or not (`[Inner!]`), at any depth
    /// (#1586). The outermost `!` is the field's or argument's own nullability, tracked
    /// separately in `nullable`.
    ///
    /// A non-builtin name is resolved against the schema's declared enum, interface and
    /// union names before falling through to [`FieldType::Object`] (`#923`) — see
    /// [`DeclaredTypeNames`].
    ///
    /// # Errors
    ///
    /// Currently infallible; a name declared nowhere is treated as `FieldType::Object`,
    /// and reported by name by `SchemaValidator`. The `Result` return type is reserved for
    /// future strict validation.
    pub(super) fn parse_field_type(
        type_name: &str,
        declared: &DeclaredTypeNames,
    ) -> Result<FieldType> {
        let type_name = type_name.trim();

        // Strip a trailing non-null marker first — it can wrap a list ("[Inner!]!")
        // or a scalar/object ("Inner!"). Doing this before the list check lets a
        // non-null list unwrap correctly; outer field nullability is tracked
        // separately in `nullable`.
        let type_name = type_name.strip_suffix('!').unwrap_or(type_name).trim();

        // SDL list wrapper: "[Inner]" / "[Inner!]" → a list of nullable / non-null items.
        // Recurse so nested lists ("[[Inner]!]") carry their own items' nullability.
        if let Some(inner) = type_name.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
            let item = Self::parse_field_type(inner, declared)?;
            return Ok(if inner.trim().ends_with('!') {
                FieldType::list(item)
            } else {
                FieldType::list_of_nullable(item)
            });
        }

        // The built-in scalar table is shared with the validator's known-type
        // registry, so a name cannot parse as a scalar here and be reported as
        // undeclared there.
        Ok(FieldType::from_authoring_name(type_name).unwrap_or_else(|| {
            // Author-declared enum / interface / union references keep their kind; anything
            // else is an object reference (#923).
            declared.resolve(type_name)
        }))
    }

    /// Check whether a string is a safe SQL identifier.
    ///
    /// Accepts up to three dot-separated segments (`name`, `schema.name`, or
    /// `catalog.schema.name`), each matching `[A-Za-z_][A-Za-z0-9_]*`.
    /// This prevents SQL injection via view names supplied in
    /// `additional_views` or `invalidates_fact_tables`.
    pub(crate) fn is_safe_sql_identifier(s: &str) -> bool {
        if s.is_empty() {
            return false;
        }
        let parts: Vec<&str> = s.split('.').collect();
        if parts.len() > 3 {
            return false;
        }
        parts.iter().all(|part| {
            if part.is_empty() {
                return false;
            }
            let mut chars = part.chars();
            let first = chars.next().expect("non-empty checked above");
            if !first.is_ascii_alphabetic() && first != '_' {
                return false;
            }
            chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
        })
    }

    /// Convert a per-operation `rest` block into the compiled `(path, method)` pair.
    ///
    /// One function for queries and mutations, because two copies of "what does a REST
    /// annotation mean" is how the pair got dropped in the first place: the block was
    /// declared by every SDK, understood by no consumer, and hardcoded to `None` at both
    /// converter sites (#846).
    ///
    /// Validation is loud, and deliberately so. The server's route derivation reads
    /// `rest_method` through `parse_http_method(..).unwrap_or(<default>)`, so an
    /// unrecognised verb silently becomes `GET` on a query or `POST` on a mutation —
    /// the author would get a route, just not the one they asked for. Compile time is
    /// where that has to fail, because it is the last point at which the authored intent
    /// is still visible.
    ///
    /// # Errors
    ///
    /// Returns an error if the path is empty, does not start with `/`, contains a query
    /// string or fragment, or if the method is not one of `GET`, `POST`, `PUT`, `PATCH`,
    /// `DELETE`.
    pub(crate) fn convert_rest_annotation(
        operation_kind: &str,
        operation_name: &str,
        rest: Option<IntermediateRest>,
    ) -> Result<(Option<String>, Option<String>)> {
        let Some(rest) = rest else {
            return Ok((None, None));
        };

        if rest.path.is_empty() {
            anyhow::bail!("{operation_kind} '{operation_name}': rest.path must not be empty.");
        }
        if !rest.path.starts_with('/') {
            anyhow::bail!(
                "{operation_kind} '{operation_name}': rest.path must start with '/', got {:?}.",
                rest.path
            );
        }
        if let Some(bad) = rest.path.chars().find(|c| matches!(c, '?' | '#')) {
            anyhow::bail!(
                "{operation_kind} '{operation_name}': rest.path must be a path only — it must \
                 not contain {bad:?}. Got {:?}.",
                rest.path
            );
        }

        let method = match rest.method {
            None => None,
            Some(m) => {
                let upper = m.to_ascii_uppercase();
                if !matches!(upper.as_str(), "GET" | "POST" | "PUT" | "PATCH" | "DELETE") {
                    anyhow::bail!(
                        "{operation_kind} '{operation_name}': rest.method {m:?} is not a \
                         supported HTTP method. Use one of GET, POST, PUT, PATCH, DELETE."
                    );
                }
                Some(upper)
            },
        };

        Ok((Some(rest.path), method))
    }
}

/// A field's deprecation: the first-class `deprecated` key wins, falling back to a
/// `@deprecated` directive. The key is what every sibling construct uses and what the SDKs
/// emit; the directive is the older spelling and keeps working.
fn declared_deprecation(
    deprecated: Option<IntermediateDeprecation>,
    directives: &[IntermediateAppliedDirective],
) -> Option<IntermediateDeprecation> {
    deprecated.or_else(|| {
        directives
            .iter()
            .find(|d| d.name == "deprecated")
            .map(|d| IntermediateDeprecation {
                reason: d
                    .arguments
                    .as_ref()
                    .and_then(|args| args.get("reason").and_then(|v| v.as_str()).map(String::from)),
            })
    })
}