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