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