Skip to main content

fraiseql_cli/schema/
merger.rs

1//! Schema merger - combines language-generated types.json with TOML configuration
2//!
3//! This module merges:
4//! - types.json: Generated by language implementations (Python, Go, etc.)
5//! - fraiseql.toml: Configuration (security, federation, observers, caching, etc.)
6//!
7//! Result: Complete IntermediateSchema ready for compilation
8
9use std::fs;
10
11use anyhow::{Context, Result};
12use serde_json::{Value, json};
13
14use crate::{config::TomlSchema, schema::IntermediateSchema};
15
16/// Schema merger combining language types and TOML config
17pub struct SchemaMerger;
18
19impl SchemaMerger {
20    /// Merge types.json file with TOML configuration
21    ///
22    /// # Arguments
23    /// * `types_path` - Path to types.json (from language implementation)
24    /// * `toml_path` - Path to fraiseql.toml (configuration)
25    ///
26    /// # Returns
27    /// Combined IntermediateSchema
28    pub fn merge_files(types_path: &str, toml_path: &str) -> Result<IntermediateSchema> {
29        // Load types.json
30        let types_json = fs::read_to_string(types_path)
31            .context(format!("Failed to read types.json from {types_path}"))?;
32        let types_value: Value =
33            serde_json::from_str(&types_json).context("Failed to parse types.json")?;
34
35        // Load TOML
36        let toml_schema = TomlSchema::from_file(toml_path)
37            .context(format!("Failed to load TOML from {toml_path}"))?;
38
39        // Note: TOML validation is skipped here because queries may reference types
40        // from types.json (not yet loaded). Validation happens in the compiler after merge.
41
42        // Merge
43        Self::merge_values(&types_value, &toml_schema)
44    }
45
46    /// Merge TOML-only (no types.json)
47    ///
48    /// # Arguments
49    /// * `toml_path` - Path to fraiseql.toml with inline type definitions
50    ///
51    /// # Returns
52    /// IntermediateSchema from TOML definitions
53    pub fn merge_toml_only(toml_path: &str) -> Result<IntermediateSchema> {
54        let toml_schema = TomlSchema::from_file(toml_path)
55            .context(format!("Failed to load TOML from {toml_path}"))?;
56
57        toml_schema.validate()?;
58
59        // Convert TOML to intermediate schema
60        let types_value = toml_schema.to_intermediate_schema();
61        Self::merge_values(&types_value, &toml_schema)
62    }
63
64    /// Merge from directory with auto-discovery
65    ///
66    /// # Arguments
67    /// * `toml_path` - Path to fraiseql.toml (configuration)
68    /// * `schema_dir` - Path to directory containing schema files
69    ///
70    /// # Returns
71    /// IntermediateSchema from loaded files + TOML definitions
72    pub fn merge_from_directory(toml_path: &str, schema_dir: &str) -> Result<IntermediateSchema> {
73        let toml_schema = TomlSchema::from_file(toml_path)
74            .context(format!("Failed to load TOML from {toml_path}"))?;
75
76        toml_schema.validate()?;
77
78        // Load all files from directory
79        let types_value = crate::schema::MultiFileLoader::load_from_directory(schema_dir)
80            .context(format!("Failed to load schema from directory {schema_dir}"))?;
81
82        // Merge with TOML definitions
83        Self::merge_values(&types_value, &toml_schema)
84    }
85
86    /// Load a named section from a set of files, returning `None` when the list is empty.
87    fn load_section(files: &[String], key: &str) -> Result<Option<serde_json::Value>> {
88        if files.is_empty() {
89            return Ok(None);
90        }
91        let paths: Vec<std::path::PathBuf> = files.iter().map(std::path::PathBuf::from).collect();
92        let loaded = crate::schema::MultiFileLoader::load_from_paths(&paths)
93            .with_context(|| format!("Failed to load {key} files"))?;
94        Ok(loaded.get(key).cloned())
95    }
96
97    /// Parse a JSON file and extend the target vectors with its `types`, `queries`, and
98    /// `mutations` arrays. Missing keys are silently skipped.
99    fn extend_from_json_file(
100        path: &std::path::Path,
101        all_types: &mut Vec<Value>,
102        all_queries: &mut Vec<Value>,
103        all_mutations: &mut Vec<Value>,
104    ) -> Result<()> {
105        let content = fs::read_to_string(path)
106            .with_context(|| format!("Failed to read {}", path.display()))?;
107        let value: Value = serde_json::from_str(&content)
108            .with_context(|| format!("Failed to parse {}", path.display()))?;
109        for (vec, key) in [
110            (all_types as &mut Vec<Value>, "types"),
111            (all_queries, "queries"),
112            (all_mutations, "mutations"),
113        ] {
114            if let Some(Value::Array(items)) = value.get(key) {
115                vec.extend(items.iter().cloned());
116            }
117        }
118        Ok(())
119    }
120
121    /// Apply TOML metadata (`sql_source`, `description`) to a type JSON object in place.
122    fn enrich_type_from_toml(enriched_type: &mut Value, toml_type: &crate::config::toml_schema::TypeDefinition) {
123        enriched_type["sql_source"] = json!(toml_type.sql_source);
124        if let Some(desc) = &toml_type.description {
125            enriched_type["description"] = json!(desc);
126        }
127    }
128
129    /// Merge explicit file lists
130    ///
131    /// # Arguments
132    /// * `toml_path` - Path to fraiseql.toml (configuration)
133    /// * `type_files` - Vector of type file paths
134    /// * `query_files` - Vector of query file paths
135    /// * `mutation_files` - Vector of mutation file paths
136    ///
137    /// # Returns
138    /// IntermediateSchema from loaded files + TOML definitions
139    pub fn merge_explicit_files(
140        toml_path: &str,
141        type_files: &[String],
142        query_files: &[String],
143        mutation_files: &[String],
144    ) -> Result<IntermediateSchema> {
145        let toml_schema = TomlSchema::from_file(toml_path)
146            .context(format!("Failed to load TOML from {toml_path}"))?;
147
148        toml_schema.validate()?;
149
150        let mut types_value = serde_json::json!({
151            "types": [],
152            "queries": [],
153            "mutations": []
154        });
155
156        if let Some(v) = Self::load_section(type_files, "types")? {
157            types_value["types"] = v;
158        }
159        if let Some(v) = Self::load_section(query_files, "queries")? {
160            types_value["queries"] = v;
161        }
162        if let Some(v) = Self::load_section(mutation_files, "mutations")? {
163            types_value["mutations"] = v;
164        }
165
166        Self::merge_values(&types_value, &toml_schema)
167    }
168
169    /// Merge from domains (domain-based organization)
170    ///
171    /// # Arguments
172    /// * `toml_path` - Path to fraiseql.toml with domain_discovery enabled
173    ///
174    /// # Returns
175    /// IntermediateSchema from all domains (types.json, queries.json, mutations.json)
176    pub fn merge_from_domains(toml_path: &str) -> Result<IntermediateSchema> {
177        let toml_schema = TomlSchema::from_file(toml_path)
178            .context(format!("Failed to load TOML from {toml_path}"))?;
179
180        toml_schema.validate()?;
181
182        // Resolve domains from configuration
183        let domains = toml_schema
184            .domain_discovery
185            .resolve_domains()
186            .context("Failed to discover domains")?;
187
188        if domains.is_empty() {
189            // No domains found, return empty schema merged with TOML definitions
190            let empty_value = serde_json::json!({
191                "types": [],
192                "queries": [],
193                "mutations": []
194            });
195            return Self::merge_values(&empty_value, &toml_schema);
196        }
197
198        let mut all_types = Vec::new();
199        let mut all_queries = Vec::new();
200        let mut all_mutations = Vec::new();
201
202        for domain in domains {
203            for filename in ["types.json", "queries.json", "mutations.json"] {
204                let path = domain.path.join(filename);
205                if path.exists() {
206                    Self::extend_from_json_file(
207                        &path,
208                        &mut all_types,
209                        &mut all_queries,
210                        &mut all_mutations,
211                    )?;
212                }
213            }
214        }
215
216        let types_value = serde_json::json!({
217            "types": all_types,
218            "queries": all_queries,
219            "mutations": all_mutations,
220        });
221
222        // Merge with TOML definitions
223        Self::merge_values(&types_value, &toml_schema)
224    }
225
226    /// Merge with TOML includes (glob patterns for schema files)
227    ///
228    /// # Arguments
229    /// * `toml_path` - Path to fraiseql.toml with schema.includes section
230    ///
231    /// # Returns
232    /// IntermediateSchema from loaded files + TOML definitions
233    pub fn merge_with_includes(toml_path: &str) -> Result<IntermediateSchema> {
234        let toml_schema = TomlSchema::from_file(toml_path)
235            .context(format!("Failed to load TOML from {toml_path}"))?;
236
237        toml_schema.validate()?;
238
239        // If includes are specified, load and merge files
240        let types_value = if toml_schema.includes.is_empty() {
241            // No includes specified, use empty schema
242            serde_json::json!({
243                "types": [],
244                "queries": [],
245                "mutations": []
246            })
247        } else {
248            let resolved = toml_schema
249                .includes
250                .resolve_globs()
251                .context("Failed to resolve glob patterns in schema.includes")?;
252
253            // Load all type files
254            let type_files: Vec<std::path::PathBuf> = resolved.types;
255            let mut merged_types = if type_files.is_empty() {
256                serde_json::json!({
257                    "types": [],
258                    "queries": [],
259                    "mutations": []
260                })
261            } else {
262                crate::schema::MultiFileLoader::load_from_paths(&type_files)
263                    .context("Failed to load type files")?
264            };
265
266            // Load and merge query files
267            if !resolved.queries.is_empty() {
268                let loaded = crate::schema::MultiFileLoader::load_from_paths(&resolved.queries)
269                    .context("Failed to load query files")?;
270                let new_items =
271                    loaded.get("queries").and_then(Value::as_array).cloned().unwrap_or_default();
272                if let Some(Value::Array(existing)) = merged_types.get_mut("queries") {
273                    existing.extend(new_items);
274                }
275            }
276
277            // Load and merge mutation files
278            if !resolved.mutations.is_empty() {
279                let loaded = crate::schema::MultiFileLoader::load_from_paths(&resolved.mutations)
280                    .context("Failed to load mutation files")?;
281                let new_items = loaded
282                    .get("mutations")
283                    .and_then(Value::as_array)
284                    .cloned()
285                    .unwrap_or_default();
286                if let Some(Value::Array(existing)) = merged_types.get_mut("mutations") {
287                    existing.extend(new_items);
288                }
289            }
290
291            merged_types
292        };
293
294        // Merge with TOML definitions
295        Self::merge_values(&types_value, &toml_schema)
296    }
297
298    /// Merge JSON types with TOML schema
299    fn merge_values(types_value: &Value, toml_schema: &TomlSchema) -> Result<IntermediateSchema> {
300        // Start with arrays for types, queries, mutations (not objects!)
301        // This matches IntermediateSchema structure which uses Vec<T>
302        let mut types_array: Vec<Value> = Vec::new();
303        let mut queries_array: Vec<Value> = Vec::new();
304        let mut mutations_array: Vec<Value> = Vec::new();
305
306        // Process types from types.json (comes as array from language SDKs)
307        if let Some(types_obj) = types_value.get("types") {
308            match types_obj {
309                // Handle array format (from language SDKs)
310                Value::Array(types_list) => {
311                    for type_item in types_list {
312                        if let Some(type_name) = type_item.get("name").and_then(|v| v.as_str()) {
313                            let mut enriched_type = type_item.clone();
314                            if let Some(toml_type) = toml_schema.types.get(type_name) {
315                                Self::enrich_type_from_toml(&mut enriched_type, toml_type);
316                            }
317                            types_array.push(enriched_type);
318                        }
319                    }
320                },
321                // Handle object format (from TOML-only, for backward compatibility)
322                Value::Object(types_map) => {
323                    for (type_name, type_value) in types_map {
324                        let mut enriched_type = type_value.clone();
325                        enriched_type["name"] = json!(type_name);
326
327                        // Convert fields from object to array format if needed
328                        if let Some(Value::Object(fields_map)) = enriched_type.get("fields") {
329                            let fields_array: Vec<Value> = fields_map
330                                .iter()
331                                .map(|(field_name, field_value)| {
332                                    let mut field = field_value.clone();
333                                    field["name"] = json!(field_name);
334                                    field
335                                })
336                                .collect();
337                            enriched_type["fields"] = json!(fields_array);
338                        }
339
340                        if let Some(toml_type) = toml_schema.types.get(type_name) {
341                            Self::enrich_type_from_toml(&mut enriched_type, toml_type);
342                        }
343
344                        types_array.push(enriched_type);
345                    }
346                },
347                _ => {},
348            }
349        }
350
351        // Add types from TOML that aren't already in types_array
352        let existing_type_names: std::collections::HashSet<_> = types_array
353            .iter()
354            .filter_map(|t| {
355                t.get("name").and_then(|v| v.as_str()).map(str::to_string)
356            })
357            .collect();
358
359        for (type_name, toml_type) in &toml_schema.types {
360            if !existing_type_names.contains(type_name) {
361                types_array.push(json!({
362                    "name": type_name,
363                    "sql_source": toml_type.sql_source,
364                    "description": toml_type.description,
365                    "fields": toml_type.fields.iter().map(|(fname, fdef)| json!({
366                        "name": fname,
367                        "type": fdef.field_type,
368                        "nullable": fdef.nullable,
369                        "description": fdef.description,
370                    })).collect::<Vec<_>>(),
371                }));
372            }
373        }
374
375        if let Some(Value::Array(queries_list)) = types_value.get("queries") {
376            queries_array.clone_from(queries_list);
377        }
378
379        // Add queries from TOML
380        for (query_name, toml_query) in &toml_schema.queries {
381            queries_array.push(json!({
382                "name": query_name,
383                "return_type": toml_query.return_type,
384                "return_array": toml_query.return_array,
385                "sql_source": toml_query.sql_source,
386                "description": toml_query.description,
387                "args": toml_query.args.iter().map(|arg| json!({
388                    "name": arg.name,
389                    "type": arg.arg_type,
390                    "required": arg.required,
391                    "default": arg.default,
392                    "description": arg.description,
393                })).collect::<Vec<_>>(),
394            }));
395        }
396
397        if let Some(Value::Array(mutations_list)) = types_value.get("mutations") {
398            mutations_array.clone_from(mutations_list);
399        }
400
401        // Add mutations from TOML
402        for (mutation_name, toml_mutation) in &toml_schema.mutations {
403            mutations_array.push(json!({
404                "name": mutation_name,
405                "return_type": toml_mutation.return_type,
406                "sql_source": toml_mutation.sql_source,
407                "operation": toml_mutation.operation,
408                "description": toml_mutation.description,
409                "args": toml_mutation.args.iter().map(|arg| json!({
410                    "name": arg.name,
411                    "type": arg.arg_type,
412                    "required": arg.required,
413                    "default": arg.default,
414                    "description": arg.description,
415                })).collect::<Vec<_>>(),
416            }));
417        }
418
419        // Build merged schema with arrays
420        let mut merged = serde_json::json!({
421            "version": "2.0.0",
422            "types": types_array,
423            "queries": queries_array,
424            "mutations": mutations_array,
425        });
426
427        // Add security configuration if available in TOML
428        merged["security"] = json!({
429            "default_policy": toml_schema.security.default_policy,
430            "rules": toml_schema.security.rules.iter().map(|r| json!({
431                "name": r.name,
432                "rule": r.rule,
433                "description": r.description,
434                "cacheable": r.cacheable,
435                "cache_ttl_seconds": r.cache_ttl_seconds,
436            })).collect::<Vec<_>>(),
437            "policies": toml_schema.security.policies.iter().map(|p| json!({
438                "name": p.name,
439                "type": p.policy_type,
440                "rule": p.rule,
441                "roles": p.roles,
442                "strategy": p.strategy,
443                "attributes": p.attributes,
444                "description": p.description,
445                "cache_ttl_seconds": p.cache_ttl_seconds,
446            })).collect::<Vec<_>>(),
447            "field_auth": toml_schema.security.field_auth.iter().map(|fa| json!({
448                "type_name": fa.type_name,
449                "field_name": fa.field_name,
450                "policy": fa.policy,
451            })).collect::<Vec<_>>(),
452            "enterprise": json!({
453                "rate_limiting_enabled": toml_schema.security.enterprise.rate_limiting_enabled,
454                "auth_endpoint_max_requests": toml_schema.security.enterprise.auth_endpoint_max_requests,
455                "auth_endpoint_window_seconds": toml_schema.security.enterprise.auth_endpoint_window_seconds,
456                "audit_logging_enabled": toml_schema.security.enterprise.audit_logging_enabled,
457                "audit_log_backend": toml_schema.security.enterprise.audit_log_backend,
458                "audit_retention_days": toml_schema.security.enterprise.audit_retention_days,
459                "error_sanitization": toml_schema.security.enterprise.error_sanitization,
460                "hide_implementation_details": toml_schema.security.enterprise.hide_implementation_details,
461                "constant_time_comparison": toml_schema.security.enterprise.constant_time_comparison,
462                "pkce_enabled": toml_schema.security.enterprise.pkce_enabled,
463            }),
464        });
465
466        // Embed observers configuration if enabled or if any backend URL is set
467        if toml_schema.observers.enabled
468            || toml_schema.observers.redis_url.is_some()
469            || toml_schema.observers.nats_url.is_some()
470        {
471            if toml_schema.observers.backend == "nats" && toml_schema.observers.nats_url.is_none() {
472                tracing::warn!(
473                    "observers.backend is \"nats\" but observers.nats_url is not set; \
474                     the runtime will require FRAISEQL_NATS_URL to be configured"
475                );
476            }
477            merged["observers_config"] = json!({
478                "enabled": toml_schema.observers.enabled,
479                "backend": toml_schema.observers.backend,
480                "redis_url": toml_schema.observers.redis_url,
481                "nats_url": toml_schema.observers.nats_url,
482                "handlers": toml_schema.observers.handlers.iter().map(|h| json!({
483                    "name": h.name,
484                    "event": h.event,
485                    "action": h.action,
486                    "webhook_url": h.webhook_url,
487                    "retry_strategy": h.retry_strategy,
488                    "max_retries": h.max_retries,
489                    "description": h.description,
490                })).collect::<Vec<_>>(),
491            });
492        }
493
494        // Embed federation configuration if enabled
495        if toml_schema.federation.enabled {
496            merged["federation_config"] = serde_json::to_value(&toml_schema.federation)
497                .unwrap_or_default();
498        }
499
500        // Convert to IntermediateSchema
501        serde_json::from_value::<IntermediateSchema>(merged)
502            .context("Failed to convert merged schema to IntermediateSchema")
503    }
504}
505
506#[cfg(test)]
507mod tests {
508    use std::fs;
509
510    use tempfile::TempDir;
511
512    use super::*;
513
514    #[test]
515    fn test_merge_toml_only() {
516        let toml_content = r#"
517[schema]
518name = "test"
519version = "1.0.0"
520database_target = "postgresql"
521
522[database]
523url = "postgresql://localhost/test"
524
525[types.User]
526sql_source = "v_user"
527
528[types.User.fields.id]
529type = "ID"
530
531[types.User.fields.name]
532type = "String"
533
534[queries.users]
535return_type = "User"
536return_array = true
537sql_source = "v_user"
538"#;
539
540        // Write temp file
541        let temp_path = "/tmp/test_fraiseql.toml";
542        std::fs::write(temp_path, toml_content).unwrap();
543
544        // Merge
545        let result = SchemaMerger::merge_toml_only(temp_path);
546        assert!(result.is_ok());
547
548        // Clean up
549        let _ = std::fs::remove_file(temp_path);
550    }
551
552    #[test]
553    fn test_merge_with_includes() -> Result<()> {
554        let temp_dir = TempDir::new()?;
555
556        // Create schema files
557        let user_types = serde_json::json!({
558            "types": [{"name": "User", "fields": []}],
559            "queries": [],
560            "mutations": []
561        });
562        fs::write(temp_dir.path().join("user.json"), user_types.to_string())?;
563
564        let post_types = serde_json::json!({
565            "types": [{"name": "Post", "fields": []}],
566            "queries": [],
567            "mutations": []
568        });
569        fs::write(temp_dir.path().join("post.json"), post_types.to_string())?;
570
571        // Create TOML with includes
572        let toml_content = format!(
573            r#"
574[schema]
575name = "test"
576version = "1.0.0"
577database_target = "postgresql"
578
579[database]
580url = "postgresql://localhost/test"
581
582[includes]
583types = ["{}/*.json"]
584queries = []
585mutations = []
586"#,
587            temp_dir.path().to_string_lossy()
588        );
589
590        let toml_path = temp_dir.path().join("fraiseql.toml");
591        fs::write(&toml_path, toml_content)?;
592
593        // Merge
594        let result = SchemaMerger::merge_with_includes(toml_path.to_str().unwrap());
595        assert!(result.is_ok());
596
597        let schema = result?;
598        assert_eq!(schema.types.len(), 2);
599
600        Ok(())
601    }
602
603    #[test]
604    fn test_merge_with_includes_missing_files() -> Result<()> {
605        let temp_dir = TempDir::new()?;
606
607        let toml_content = r#"
608[schema]
609name = "test"
610version = "1.0.0"
611database_target = "postgresql"
612
613[database]
614url = "postgresql://localhost/test"
615
616[includes]
617types = ["/nonexistent/path/*.json"]
618queries = []
619mutations = []
620"#;
621
622        let toml_path = temp_dir.path().join("fraiseql.toml");
623        fs::write(&toml_path, toml_content)?;
624
625        // Should succeed but with no files loaded (glob matches nothing)
626        let result = SchemaMerger::merge_with_includes(toml_path.to_str().unwrap());
627        assert!(result.is_ok());
628
629        let schema = result?;
630        assert_eq!(schema.types.len(), 0);
631
632        Ok(())
633    }
634
635    #[test]
636    fn test_merge_from_domains() -> Result<()> {
637        let temp_dir = TempDir::new()?;
638        let schema_dir = temp_dir.path().join("schema");
639        fs::create_dir(&schema_dir)?;
640
641        // Create domain structure
642        fs::create_dir(schema_dir.join("auth"))?;
643        fs::create_dir(schema_dir.join("products"))?;
644
645        let auth_types = serde_json::json!({
646            "types": [{"name": "User", "fields": []}],
647            "queries": [{"name": "getUser", "return_type": "User"}],
648            "mutations": []
649        });
650        fs::write(schema_dir.join("auth/types.json"), auth_types.to_string())?;
651
652        let product_types = serde_json::json!({
653            "types": [{"name": "Product", "fields": []}],
654            "queries": [{"name": "getProduct", "return_type": "Product"}],
655            "mutations": []
656        });
657        fs::write(schema_dir.join("products/types.json"), product_types.to_string())?;
658
659        // Create TOML with domain discovery (use absolute path)
660        let schema_dir_str = schema_dir.to_string_lossy().to_string();
661        let toml_content = format!(
662            r#"
663[schema]
664name = "test"
665version = "1.0.0"
666database_target = "postgresql"
667
668[database]
669url = "postgresql://localhost/test"
670
671[domain_discovery]
672enabled = true
673root_dir = "{schema_dir_str}"
674"#
675        );
676
677        let toml_path = temp_dir.path().join("fraiseql.toml");
678        fs::write(&toml_path, toml_content)?;
679
680        // Merge
681        let result = SchemaMerger::merge_from_domains(toml_path.to_str().unwrap());
682
683        assert!(result.is_ok());
684        let schema = result?;
685
686        // Should have 2 types (from both domains)
687        assert_eq!(schema.types.len(), 2);
688        // Should have 2 queries (from both domains)
689        assert_eq!(schema.queries.len(), 2);
690
691        Ok(())
692    }
693
694    #[test]
695    fn test_merge_from_domains_alphabetical_order() -> Result<()> {
696        let temp_dir = TempDir::new()?;
697        let schema_dir = temp_dir.path().join("schema");
698        fs::create_dir(&schema_dir)?;
699
700        // Create domains in non-alphabetical order
701        fs::create_dir(schema_dir.join("zebra"))?;
702        fs::create_dir(schema_dir.join("alpha"))?;
703        fs::create_dir(schema_dir.join("middle"))?;
704
705        for domain in &["zebra", "alpha", "middle"] {
706            let types = serde_json::json!({
707                "types": [{"name": domain.to_uppercase(), "fields": []}],
708                "queries": [],
709                "mutations": []
710            });
711            fs::write(schema_dir.join(format!("{domain}/types.json")), types.to_string())?;
712        }
713
714        let schema_dir_str = schema_dir.to_string_lossy().to_string();
715        let toml_content = format!(
716            r#"
717[schema]
718name = "test"
719version = "1.0.0"
720database_target = "postgresql"
721
722[database]
723url = "postgresql://localhost/test"
724
725[domain_discovery]
726enabled = true
727root_dir = "{schema_dir_str}"
728"#
729        );
730
731        let toml_path = temp_dir.path().join("fraiseql.toml");
732        fs::write(&toml_path, toml_content)?;
733
734        let result = SchemaMerger::merge_from_domains(toml_path.to_str().unwrap());
735
736        assert!(result.is_ok());
737        let schema = result?;
738
739        // Types should be loaded in alphabetical order: ALPHA, MIDDLE, ZEBRA
740        let type_names: Vec<String> = schema.types.iter().map(|t| t.name.clone()).collect();
741
742        assert_eq!(type_names[0], "ALPHA");
743        assert_eq!(type_names[1], "MIDDLE");
744        assert_eq!(type_names[2], "ZEBRA");
745
746        Ok(())
747    }
748}