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