Skip to main content

fraiseql_cli/schema/intermediate/
mod.rs

1//! Intermediate Schema Format
2//!
3//! Language-agnostic schema representation that all language libraries output.
4//! See `docs/architecture/intermediate-schema.md` for full specification.
5
6pub mod advanced_types;
7pub mod analytics;
8pub mod fragments;
9pub mod operations;
10pub mod subscriptions;
11pub mod types;
12
13pub use advanced_types::{
14    IntermediateInputField, IntermediateInputObject, IntermediateInterface, IntermediateUnion,
15};
16pub use analytics::{
17    IntermediateAggregateQuery, IntermediateDimensionPath, IntermediateDimensions,
18    IntermediateFactTable, IntermediateFilter, IntermediateMeasure,
19};
20pub use fragments::{
21    IntermediateAppliedDirective, IntermediateDirective, IntermediateFragment,
22    IntermediateFragmentField, IntermediateFragmentFieldDef,
23};
24use fraiseql_core::schema::{DebugConfig, McpConfig, SubscriptionsConfig, ValidationConfig};
25pub use operations::{
26    IntermediateArgument, IntermediateAutoParams, IntermediateMutation, IntermediateQuery,
27    IntermediateQueryDefaults,
28};
29use serde::{Deserialize, Serialize};
30pub use subscriptions::{
31    IntermediateFilterCondition, IntermediateObserver, IntermediateObserverAction,
32    IntermediateRetryConfig, IntermediateSubscription, IntermediateSubscriptionFilter,
33};
34pub use types::{
35    IntermediateDeprecation, IntermediateEnum, IntermediateEnumValue, IntermediateField,
36    IntermediateScalar, IntermediateType,
37};
38
39/// Intermediate schema - universal format from all language libraries
40#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
41pub struct IntermediateSchema {
42    /// Schema format version
43    #[serde(default = "default_version")]
44    pub version: String,
45
46    /// GraphQL object types
47    #[serde(default)]
48    pub types: Vec<IntermediateType>,
49
50    /// GraphQL enum types
51    #[serde(default)]
52    pub enums: Vec<IntermediateEnum>,
53
54    /// GraphQL input object types
55    #[serde(default)]
56    pub input_types: Vec<IntermediateInputObject>,
57
58    /// GraphQL interface types (per GraphQL spec §3.7)
59    #[serde(default)]
60    pub interfaces: Vec<IntermediateInterface>,
61
62    /// GraphQL union types (per GraphQL spec §3.10)
63    #[serde(default)]
64    pub unions: Vec<IntermediateUnion>,
65
66    /// GraphQL queries
67    #[serde(default)]
68    pub queries: Vec<IntermediateQuery>,
69
70    /// GraphQL mutations
71    #[serde(default)]
72    pub mutations: Vec<IntermediateMutation>,
73
74    /// GraphQL subscriptions
75    #[serde(default)]
76    pub subscriptions: Vec<IntermediateSubscription>,
77
78    /// GraphQL fragments (reusable field selections)
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub fragments: Option<Vec<IntermediateFragment>>,
81
82    /// GraphQL directive definitions (custom directives)
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub directives: Option<Vec<IntermediateDirective>>,
85
86    /// Analytics fact tables (optional)
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub fact_tables: Option<Vec<IntermediateFactTable>>,
89
90    /// Analytics aggregate queries (optional)
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub aggregate_queries: Option<Vec<IntermediateAggregateQuery>>,
93
94    /// Observer definitions (database change event listeners)
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub observers: Option<Vec<IntermediateObserver>>,
97
98    /// Custom scalar type definitions
99    ///
100    /// Defines custom GraphQL scalar types with validation rules.
101    /// Custom scalars can be defined in Python, TypeScript, Java, Go, and Rust SDKs,
102    /// and are compiled into the CompiledSchema's CustomTypeRegistry.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub custom_scalars: Option<Vec<IntermediateScalar>>,
105
106    /// Security configuration (from fraiseql.toml)
107    /// Compiled from the security section of fraiseql.toml at compile time.
108    /// Optional - if not provided, defaults are used.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub security: Option<serde_json::Value>,
111
112    /// Observers/event system configuration (from fraiseql.toml).
113    ///
114    /// Contains backend connection settings (redis_url, nats_url, etc.) compiled
115    /// from the `[observers]` TOML section. Embedded verbatim into the compiled schema.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub observers_config: Option<serde_json::Value>,
118
119    /// Federation configuration (from fraiseql.toml).
120    ///
121    /// Contains Apollo Federation settings and circuit breaker configuration compiled
122    /// from the `[federation]` TOML section. Embedded verbatim into the compiled schema.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub federation_config: Option<serde_json::Value>,
125
126    /// WebSocket subscription configuration (hooks, limits).
127    ///
128    /// Compiled from the `[subscriptions]` TOML section. Embedded verbatim into
129    /// the compiled schema for server-side consumption.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub subscriptions_config: Option<SubscriptionsConfig>,
132
133    /// Query validation config (depth/complexity limits).
134    ///
135    /// Compiled from `[validation]` in `fraiseql.toml`. Embedded into the compiled
136    /// schema for server-side consumption.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub validation_config: Option<ValidationConfig>,
139
140    /// Debug/development configuration.
141    ///
142    /// Compiled from `[debug]` in `fraiseql.toml`. Embedded into the compiled
143    /// schema for server-side consumption.
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub debug_config: Option<DebugConfig>,
146
147    /// MCP (Model Context Protocol) server configuration.
148    ///
149    /// Compiled from `[mcp]` in `fraiseql.toml`. Embedded into the compiled
150    /// schema for server-side consumption.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub mcp_config: Option<McpConfig>,
153
154    /// Global auto-param defaults for list queries (injected from TOML by the merger).
155    ///
156    /// Never present in `schema.json` — populated at compile time from `[query_defaults]`
157    /// in `fraiseql.toml`. Used by the converter to resolve per-query `auto_params`.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub query_defaults: Option<IntermediateQueryDefaults>,
160}
161
162fn default_version() -> String {
163    "2.0.0".to_string()
164}
165
166#[cfg(test)]
167#[allow(clippy::unwrap_used)] // Reason: test code
168mod tests {
169    use super::*;
170
171    #[test]
172    fn test_parse_minimal_schema() {
173        let json = r#"{
174            "types": [],
175            "queries": [],
176            "mutations": []
177        }"#;
178
179        let schema: IntermediateSchema = serde_json::from_str(json).unwrap();
180        assert_eq!(schema.version, "2.0.0");
181        assert_eq!(schema.types.len(), 0);
182        assert_eq!(schema.queries.len(), 0);
183        assert_eq!(schema.mutations.len(), 0);
184    }
185
186    #[test]
187    fn test_parse_type_with_type_field() {
188        let json = r#"{
189            "types": [{
190                "name": "User",
191                "fields": [
192                    {
193                        "name": "id",
194                        "type": "Int",
195                        "nullable": false
196                    },
197                    {
198                        "name": "name",
199                        "type": "String",
200                        "nullable": false
201                    }
202                ]
203            }],
204            "queries": [],
205            "mutations": []
206        }"#;
207
208        let schema: IntermediateSchema = serde_json::from_str(json).unwrap();
209        assert_eq!(schema.types.len(), 1);
210        assert_eq!(schema.types[0].name, "User");
211        assert_eq!(schema.types[0].fields.len(), 2);
212        assert_eq!(schema.types[0].fields[0].name, "id");
213        assert_eq!(schema.types[0].fields[0].field_type, "Int");
214        assert!(!schema.types[0].fields[0].nullable);
215    }
216
217    #[test]
218    fn test_parse_query_with_arguments() {
219        let json = r#"{
220            "types": [],
221            "queries": [{
222                "name": "users",
223                "return_type": "User",
224                "returns_list": true,
225                "nullable": false,
226                "arguments": [
227                    {
228                        "name": "limit",
229                        "type": "Int",
230                        "nullable": false,
231                        "default": 10
232                    }
233                ],
234                "sql_source": "v_user"
235            }],
236            "mutations": []
237        }"#;
238
239        let schema: IntermediateSchema = serde_json::from_str(json).unwrap();
240        assert_eq!(schema.queries.len(), 1);
241        assert_eq!(schema.queries[0].arguments.len(), 1);
242        assert_eq!(schema.queries[0].arguments[0].arg_type, "Int");
243        assert_eq!(schema.queries[0].arguments[0].default, Some(serde_json::json!(10)));
244    }
245
246    #[test]
247    fn test_parse_fragment_simple() {
248        let json = r#"{
249            "types": [],
250            "queries": [],
251            "mutations": [],
252            "fragments": [{
253                "name": "UserFields",
254                "on": "User",
255                "fields": ["id", "name", "email"]
256            }]
257        }"#;
258
259        let schema: IntermediateSchema = serde_json::from_str(json).unwrap();
260        assert!(schema.fragments.is_some());
261        let fragments = schema.fragments.unwrap();
262        assert_eq!(fragments.len(), 1);
263        assert_eq!(fragments[0].name, "UserFields");
264        assert_eq!(fragments[0].type_condition, "User");
265        assert_eq!(fragments[0].fields.len(), 3);
266
267        // Check simple fields
268        match &fragments[0].fields[0] {
269            IntermediateFragmentField::Simple(name) => assert_eq!(name, "id"),
270            IntermediateFragmentField::Complex(_) => panic!("Expected simple field"),
271        }
272    }
273
274    #[test]
275    fn test_parse_fragment_with_nested_fields() {
276        let json = r#"{
277            "types": [],
278            "queries": [],
279            "mutations": [],
280            "fragments": [{
281                "name": "PostFields",
282                "on": "Post",
283                "fields": [
284                    "id",
285                    "title",
286                    {
287                        "name": "author",
288                        "alias": "writer",
289                        "fields": ["id", "name"]
290                    }
291                ]
292            }]
293        }"#;
294
295        let schema: IntermediateSchema = serde_json::from_str(json).unwrap();
296        let fragments = schema.fragments.unwrap();
297        assert_eq!(fragments[0].fields.len(), 3);
298
299        // Check nested field
300        match &fragments[0].fields[2] {
301            IntermediateFragmentField::Complex(def) => {
302                assert_eq!(def.name, "author");
303                assert_eq!(def.alias, Some("writer".to_string()));
304                assert!(def.fields.is_some());
305                assert_eq!(def.fields.as_ref().unwrap().len(), 2);
306            },
307            IntermediateFragmentField::Simple(_) => panic!("Expected complex field"),
308        }
309    }
310
311    #[test]
312    fn test_parse_directive_definition() {
313        let json = r#"{
314            "types": [],
315            "queries": [],
316            "mutations": [],
317            "directives": [{
318                "name": "auth",
319                "locations": ["FIELD_DEFINITION", "OBJECT"],
320                "arguments": [
321                    {"name": "role", "type": "String", "nullable": false}
322                ],
323                "description": "Requires authentication"
324            }]
325        }"#;
326
327        let schema: IntermediateSchema = serde_json::from_str(json).unwrap();
328        assert!(schema.directives.is_some());
329        let directives = schema.directives.unwrap();
330        assert_eq!(directives.len(), 1);
331        assert_eq!(directives[0].name, "auth");
332        assert_eq!(directives[0].locations, vec!["FIELD_DEFINITION", "OBJECT"]);
333        assert_eq!(directives[0].arguments.len(), 1);
334        assert_eq!(directives[0].description, Some("Requires authentication".to_string()));
335    }
336
337    #[test]
338    fn test_parse_field_with_directive() {
339        let json = r#"{
340            "types": [{
341                "name": "User",
342                "fields": [
343                    {
344                        "name": "oldId",
345                        "type": "Int",
346                        "nullable": false,
347                        "directives": [
348                            {"name": "deprecated", "arguments": {"reason": "Use 'id' instead"}}
349                        ]
350                    }
351                ]
352            }],
353            "queries": [],
354            "mutations": []
355        }"#;
356
357        let schema: IntermediateSchema = serde_json::from_str(json).unwrap();
358        let field = &schema.types[0].fields[0];
359        assert_eq!(field.name, "oldId");
360        assert!(field.directives.is_some());
361        let directives = field.directives.as_ref().unwrap();
362        assert_eq!(directives.len(), 1);
363        assert_eq!(directives[0].name, "deprecated");
364        assert_eq!(
365            directives[0].arguments,
366            Some(serde_json::json!({"reason": "Use 'id' instead"}))
367        );
368    }
369
370    #[test]
371    fn test_parse_fragment_with_spread() {
372        let json = r#"{
373            "types": [],
374            "queries": [],
375            "mutations": [],
376            "fragments": [
377                {
378                    "name": "UserFields",
379                    "on": "User",
380                    "fields": ["id", "name"]
381                },
382                {
383                    "name": "PostWithAuthor",
384                    "on": "Post",
385                    "fields": [
386                        "id",
387                        "title",
388                        {
389                            "name": "author",
390                            "spread": "UserFields"
391                        }
392                    ]
393                }
394            ]
395        }"#;
396
397        let schema: IntermediateSchema = serde_json::from_str(json).unwrap();
398        let fragments = schema.fragments.unwrap();
399        assert_eq!(fragments.len(), 2);
400
401        // Check the spread reference
402        match &fragments[1].fields[2] {
403            IntermediateFragmentField::Complex(def) => {
404                assert_eq!(def.name, "author");
405                assert_eq!(def.spread, Some("UserFields".to_string()));
406            },
407            IntermediateFragmentField::Simple(_) => panic!("Expected complex field"),
408        }
409    }
410
411    #[test]
412    fn test_parse_enum() {
413        let json = r#"{
414            "types": [],
415            "queries": [],
416            "mutations": [],
417            "enums": [{
418                "name": "OrderStatus",
419                "values": [
420                    {"name": "PENDING"},
421                    {"name": "PROCESSING", "description": "Currently being processed"},
422                    {"name": "SHIPPED"},
423                    {"name": "DELIVERED"}
424                ],
425                "description": "Possible states of an order"
426            }]
427        }"#;
428
429        let schema: IntermediateSchema = serde_json::from_str(json).unwrap();
430        assert_eq!(schema.enums.len(), 1);
431        let enum_def = &schema.enums[0];
432        assert_eq!(enum_def.name, "OrderStatus");
433        assert_eq!(enum_def.description, Some("Possible states of an order".to_string()));
434        assert_eq!(enum_def.values.len(), 4);
435        assert_eq!(enum_def.values[0].name, "PENDING");
436        assert_eq!(enum_def.values[1].description, Some("Currently being processed".to_string()));
437    }
438
439    #[test]
440    fn test_parse_enum_with_deprecated_value() {
441        let json = r#"{
442            "types": [],
443            "queries": [],
444            "mutations": [],
445            "enums": [{
446                "name": "UserRole",
447                "values": [
448                    {"name": "ADMIN"},
449                    {"name": "USER"},
450                    {"name": "GUEST", "deprecated": {"reason": "Use USER with limited permissions instead"}}
451                ]
452            }]
453        }"#;
454
455        let schema: IntermediateSchema = serde_json::from_str(json).unwrap();
456        let enum_def = &schema.enums[0];
457        assert_eq!(enum_def.values.len(), 3);
458
459        // Check deprecated value
460        let guest = &enum_def.values[2];
461        assert_eq!(guest.name, "GUEST");
462        assert!(guest.deprecated.is_some());
463        assert_eq!(
464            guest.deprecated.as_ref().unwrap().reason,
465            Some("Use USER with limited permissions instead".to_string())
466        );
467    }
468
469    #[test]
470    fn test_parse_input_object() {
471        let json = r#"{
472            "types": [],
473            "queries": [],
474            "mutations": [],
475            "input_types": [{
476                "name": "UserFilter",
477                "fields": [
478                    {"name": "name", "type": "String", "nullable": true},
479                    {"name": "email", "type": "String", "nullable": true},
480                    {"name": "active", "type": "Boolean", "nullable": true, "default": true}
481                ],
482                "description": "Filter criteria for users"
483            }]
484        }"#;
485
486        let schema: IntermediateSchema = serde_json::from_str(json).unwrap();
487        assert_eq!(schema.input_types.len(), 1);
488        let input = &schema.input_types[0];
489        assert_eq!(input.name, "UserFilter");
490        assert_eq!(input.description, Some("Filter criteria for users".to_string()));
491        assert_eq!(input.fields.len(), 3);
492
493        // Check fields
494        assert_eq!(input.fields[0].name, "name");
495        assert_eq!(input.fields[0].field_type, "String");
496        assert!(input.fields[0].nullable);
497
498        // Check default value
499        assert_eq!(input.fields[2].name, "active");
500        assert_eq!(input.fields[2].default, Some(serde_json::json!(true)));
501    }
502
503    #[test]
504    fn test_parse_interface() {
505        let json = r#"{
506            "types": [],
507            "queries": [],
508            "mutations": [],
509            "interfaces": [{
510                "name": "Node",
511                "fields": [
512                    {"name": "id", "type": "ID", "nullable": false}
513                ],
514                "description": "An object with a globally unique ID"
515            }]
516        }"#;
517
518        let schema: IntermediateSchema = serde_json::from_str(json).unwrap();
519        assert_eq!(schema.interfaces.len(), 1);
520        let interface = &schema.interfaces[0];
521        assert_eq!(interface.name, "Node");
522        assert_eq!(interface.description, Some("An object with a globally unique ID".to_string()));
523        assert_eq!(interface.fields.len(), 1);
524        assert_eq!(interface.fields[0].name, "id");
525        assert_eq!(interface.fields[0].field_type, "ID");
526        assert!(!interface.fields[0].nullable);
527    }
528
529    #[test]
530    fn test_parse_type_implements_interface() {
531        let json = r#"{
532            "types": [{
533                "name": "User",
534                "fields": [
535                    {"name": "id", "type": "ID", "nullable": false},
536                    {"name": "name", "type": "String", "nullable": false}
537                ],
538                "implements": ["Node"]
539            }],
540            "queries": [],
541            "mutations": [],
542            "interfaces": [{
543                "name": "Node",
544                "fields": [
545                    {"name": "id", "type": "ID", "nullable": false}
546                ]
547            }]
548        }"#;
549
550        let schema: IntermediateSchema = serde_json::from_str(json).unwrap();
551        assert_eq!(schema.types.len(), 1);
552        assert_eq!(schema.types[0].name, "User");
553        assert_eq!(schema.types[0].implements, vec!["Node"]);
554
555        assert_eq!(schema.interfaces.len(), 1);
556        assert_eq!(schema.interfaces[0].name, "Node");
557    }
558
559    #[test]
560    fn test_parse_input_object_with_deprecated_field() {
561        let json = r#"{
562            "types": [],
563            "queries": [],
564            "mutations": [],
565            "input_types": [{
566                "name": "CreateUserInput",
567                "fields": [
568                    {"name": "email", "type": "String!", "nullable": false},
569                    {"name": "name", "type": "String!", "nullable": false},
570                    {
571                        "name": "username",
572                        "type": "String",
573                        "nullable": true,
574                        "deprecated": {"reason": "Use email as unique identifier instead"}
575                    }
576                ]
577            }]
578        }"#;
579
580        let schema: IntermediateSchema = serde_json::from_str(json).unwrap();
581        let input = &schema.input_types[0];
582
583        // Check deprecated field
584        let username_field = &input.fields[2];
585        assert_eq!(username_field.name, "username");
586        assert!(username_field.deprecated.is_some());
587        assert_eq!(
588            username_field.deprecated.as_ref().unwrap().reason,
589            Some("Use email as unique identifier instead".to_string())
590        );
591    }
592
593    #[test]
594    fn test_parse_union() {
595        let json = r#"{
596            "types": [
597                {"name": "User", "fields": [{"name": "id", "type": "ID", "nullable": false}]},
598                {"name": "Post", "fields": [{"name": "id", "type": "ID", "nullable": false}]}
599            ],
600            "queries": [],
601            "mutations": [],
602            "unions": [{
603                "name": "SearchResult",
604                "member_types": ["User", "Post"],
605                "description": "Result from a search query"
606            }]
607        }"#;
608
609        let schema: IntermediateSchema = serde_json::from_str(json).unwrap();
610        assert_eq!(schema.unions.len(), 1);
611        let union_def = &schema.unions[0];
612        assert_eq!(union_def.name, "SearchResult");
613        assert_eq!(union_def.member_types, vec!["User", "Post"]);
614        assert_eq!(union_def.description, Some("Result from a search query".to_string()));
615    }
616
617    #[test]
618    fn test_parse_field_with_requires_scope() {
619        let json = r#"{
620            "types": [{
621                "name": "Employee",
622                "fields": [
623                    {
624                        "name": "id",
625                        "type": "ID",
626                        "nullable": false
627                    },
628                    {
629                        "name": "name",
630                        "type": "String",
631                        "nullable": false
632                    },
633                    {
634                        "name": "salary",
635                        "type": "Float",
636                        "nullable": false,
637                        "description": "Employee salary - protected field",
638                        "requires_scope": "read:Employee.salary"
639                    },
640                    {
641                        "name": "ssn",
642                        "type": "String",
643                        "nullable": true,
644                        "description": "Social Security Number",
645                        "requires_scope": "admin"
646                    }
647                ]
648            }],
649            "queries": [],
650            "mutations": []
651        }"#;
652
653        let schema: IntermediateSchema = serde_json::from_str(json).unwrap();
654        assert_eq!(schema.types.len(), 1);
655
656        let employee = &schema.types[0];
657        assert_eq!(employee.name, "Employee");
658        assert_eq!(employee.fields.len(), 4);
659
660        // id - no scope required
661        assert_eq!(employee.fields[0].name, "id");
662        assert!(employee.fields[0].requires_scope.is_none());
663
664        // name - no scope required
665        assert_eq!(employee.fields[1].name, "name");
666        assert!(employee.fields[1].requires_scope.is_none());
667
668        // salary - requires specific scope
669        assert_eq!(employee.fields[2].name, "salary");
670        assert_eq!(employee.fields[2].requires_scope, Some("read:Employee.salary".to_string()));
671
672        // ssn - requires admin scope
673        assert_eq!(employee.fields[3].name, "ssn");
674        assert_eq!(employee.fields[3].requires_scope, Some("admin".to_string()));
675    }
676}