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