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 fraiseql_core::schema::CrudNamingConfig;
13use serde_json::{Value, json};
14
15use crate::{
16    config::TomlSchema,
17    schema::{IntermediateSchema, intermediate::IntermediateQueryDefaults},
18};
19
20/// Convert a PascalCase GraphQL type name to a `snake_case` entity name.
21///
22/// Used to derive the entity segment for CRUD naming templates:
23/// `"UserProfile"` → `"user_profile"`, `"User"` → `"user"`.
24fn pascal_to_snake(type_name: &str) -> String {
25    let mut out = String::with_capacity(type_name.len() + 4);
26    for (i, ch) in type_name.chars().enumerate() {
27        if ch.is_uppercase() && i > 0 {
28            out.push('_');
29        }
30        out.push(ch.to_ascii_lowercase());
31    }
32    out
33}
34
35/// Resolve the `sql_source` for a TOML-defined mutation.
36///
37/// Precedence (highest first):
38/// 1. Explicit `sql_source` on the mutation.
39/// 2. `[crud]` naming config resolved from `operation` + entity derived from `return_type`.
40///
41/// Returns an error when neither is available, naming the offending mutation.
42fn resolve_mutation_sql_source(
43    mutation_name: &str,
44    sql_source: Option<&str>,
45    operation: &str,
46    return_type: &str,
47    crud: Option<&CrudNamingConfig>,
48) -> Result<String> {
49    if let Some(src) = sql_source {
50        return Ok(src.to_string());
51    }
52    if let Some(cfg) = crud {
53        let entity = pascal_to_snake(return_type);
54        if let Some(resolved) = cfg.resolve(operation, &entity) {
55            return Ok(resolved);
56        }
57    }
58    anyhow::bail!(
59        "Mutation '{mutation_name}' has no `sql_source` and no `[crud]` naming config \
60         could resolve it (operation = {operation:?}, return_type = {return_type:?}). \
61         Either add `sql_source` to the mutation or configure `[crud]` in fraiseql.toml."
62    )
63}
64
65/// Schema merger combining language types and TOML config
66pub struct SchemaMerger;
67
68impl SchemaMerger {
69    /// Merge types.json file with TOML configuration
70    ///
71    /// # Arguments
72    /// * `types_path` - Path to types.json (from language implementation)
73    /// * `toml_path` - Path to fraiseql.toml (configuration)
74    ///
75    /// # Returns
76    /// Combined `IntermediateSchema`.
77    ///
78    /// # Errors
79    ///
80    /// Returns an error if either file cannot be read or parsed, or if the
81    /// merged result cannot be deserialized into an `IntermediateSchema`.
82    pub fn merge_files(types_path: &str, toml_path: &str) -> Result<IntermediateSchema> {
83        // Load types.json
84        let types_json = fs::read_to_string(types_path)
85            .context(format!("Failed to read types.json from {types_path}"))?;
86        let types_value: Value =
87            serde_json::from_str(&types_json).context("Failed to parse types.json")?;
88
89        // Load TOML
90        let toml_schema = TomlSchema::from_file(toml_path)
91            .context(format!("Failed to load TOML from {toml_path}"))?;
92
93        // Note: TOML validation is skipped here because queries may reference types
94        // from types.json (not yet loaded). Validation happens in the compiler after merge.
95
96        // Merge
97        Self::merge_values(&types_value, &toml_schema)
98    }
99
100    /// Merge TOML-only (no types.json)
101    ///
102    /// # Arguments
103    /// * `toml_path` - Path to fraiseql.toml with inline type definitions
104    ///
105    /// # Returns
106    /// `IntermediateSchema` from TOML definitions.
107    ///
108    /// # Errors
109    ///
110    /// Returns an error if the TOML file cannot be loaded, if validation fails,
111    /// or if the merged result cannot be deserialized into an `IntermediateSchema`.
112    pub fn merge_toml_only(toml_path: &str) -> Result<IntermediateSchema> {
113        let toml_schema = TomlSchema::from_file(toml_path)
114            .context(format!("Failed to load TOML from {toml_path}"))?;
115
116        toml_schema.validate()?;
117
118        // Convert TOML to intermediate schema
119        let types_value = toml_schema.to_intermediate_schema();
120        Self::merge_values(&types_value, &toml_schema)
121    }
122
123    /// Merge from directory with auto-discovery
124    ///
125    /// # Arguments
126    /// * `toml_path` - Path to fraiseql.toml (configuration)
127    /// * `schema_dir` - Path to directory containing schema files
128    ///
129    /// # Returns
130    /// `IntermediateSchema` from loaded files + TOML definitions.
131    ///
132    /// # Errors
133    ///
134    /// Returns an error if either file cannot be loaded or validated, if the
135    /// directory cannot be read, or if the merged result cannot be deserialized.
136    pub fn merge_from_directory(toml_path: &str, schema_dir: &str) -> Result<IntermediateSchema> {
137        let toml_schema = TomlSchema::from_file(toml_path)
138            .context(format!("Failed to load TOML from {toml_path}"))?;
139
140        toml_schema.validate()?;
141
142        // Load all files from directory
143        let types_value = crate::schema::MultiFileLoader::load_from_directory(schema_dir)
144            .context(format!("Failed to load schema from directory {schema_dir}"))?;
145
146        // Merge with TOML definitions
147        Self::merge_values(&types_value, &toml_schema)
148    }
149
150    /// Load a named section from a set of files, returning `None` when the list is empty.
151    fn load_section(files: &[String], key: &str) -> Result<Option<serde_json::Value>> {
152        if files.is_empty() {
153            return Ok(None);
154        }
155        let paths: Vec<std::path::PathBuf> = files.iter().map(std::path::PathBuf::from).collect();
156        let loaded = crate::schema::MultiFileLoader::load_from_paths(&paths)
157            .with_context(|| format!("Failed to load {key} files"))?;
158        Ok(loaded.get(key).cloned())
159    }
160
161    /// Parse a JSON file and extend the target vectors with its `types`, `queries`, and
162    /// `mutations` arrays. Missing keys are silently skipped.
163    fn extend_from_json_file(
164        path: &std::path::Path,
165        all_types: &mut Vec<Value>,
166        all_queries: &mut Vec<Value>,
167        all_mutations: &mut Vec<Value>,
168    ) -> Result<()> {
169        let content = fs::read_to_string(path)
170            .with_context(|| format!("Failed to read {}", path.display()))?;
171        let value: Value = serde_json::from_str(&content)
172            .with_context(|| format!("Failed to parse {}", path.display()))?;
173        for (vec, key) in [
174            (all_types as &mut Vec<Value>, "types"),
175            (all_queries, "queries"),
176            (all_mutations, "mutations"),
177        ] {
178            if let Some(Value::Array(items)) = value.get(key) {
179                vec.extend(items.iter().cloned());
180            }
181        }
182        Ok(())
183    }
184
185    /// Apply TOML metadata (`sql_source`, `description`) to a type JSON object in place.
186    fn enrich_type_from_toml(
187        enriched_type: &mut Value,
188        toml_type: &crate::config::toml_schema::TypeDefinition,
189    ) {
190        enriched_type["sql_source"] = json!(toml_type.sql_source);
191        if let Some(desc) = &toml_type.description {
192            enriched_type["description"] = json!(desc);
193        }
194    }
195
196    /// Merge explicit file lists
197    ///
198    /// # Arguments
199    /// * `toml_path` - Path to fraiseql.toml (configuration)
200    /// * `type_files` - Vector of type file paths
201    /// * `query_files` - Vector of query file paths
202    /// * `mutation_files` - Vector of mutation file paths
203    ///
204    /// # Returns
205    /// IntermediateSchema from loaded files + TOML definitions
206    ///
207    /// # Errors
208    ///
209    /// Returns an error if the TOML file cannot be loaded or validated, or if any
210    /// of the type/query/mutation files fail to load or parse.
211    pub fn merge_explicit_files(
212        toml_path: &str,
213        type_files: &[String],
214        query_files: &[String],
215        mutation_files: &[String],
216    ) -> Result<IntermediateSchema> {
217        let toml_schema = TomlSchema::from_file(toml_path)
218            .context(format!("Failed to load TOML from {toml_path}"))?;
219
220        toml_schema.validate()?;
221
222        let mut types_value = serde_json::json!({
223            "types": [],
224            "queries": [],
225            "mutations": []
226        });
227
228        if let Some(v) = Self::load_section(type_files, "types")? {
229            types_value["types"] = v;
230        }
231        if let Some(v) = Self::load_section(query_files, "queries")? {
232            types_value["queries"] = v;
233        }
234        if let Some(v) = Self::load_section(mutation_files, "mutations")? {
235            types_value["mutations"] = v;
236        }
237
238        Self::merge_values(&types_value, &toml_schema)
239    }
240
241    /// Merge from domains (domain-based organization)
242    ///
243    /// # Arguments
244    /// * `toml_path` - Path to fraiseql.toml with domain_discovery enabled
245    ///
246    /// # Returns
247    /// `IntermediateSchema` from all domains (types.json, queries.json, mutations.json).
248    ///
249    /// # Errors
250    ///
251    /// Returns an error if the TOML cannot be loaded or validated, if domain
252    /// discovery fails, if any domain file cannot be parsed, or if the merged
253    /// result cannot be deserialized.
254    pub fn merge_from_domains(toml_path: &str) -> Result<IntermediateSchema> {
255        let toml_schema = TomlSchema::from_file(toml_path)
256            .context(format!("Failed to load TOML from {toml_path}"))?;
257
258        toml_schema.validate()?;
259
260        // Resolve domains from configuration
261        let domains = toml_schema
262            .domain_discovery
263            .resolve_domains()
264            .context("Failed to discover domains")?;
265
266        if domains.is_empty() {
267            // No domains found, return empty schema merged with TOML definitions
268            let empty_value = serde_json::json!({
269                "types": [],
270                "queries": [],
271                "mutations": []
272            });
273            return Self::merge_values(&empty_value, &toml_schema);
274        }
275
276        let mut all_types = Vec::new();
277        let mut all_queries = Vec::new();
278        let mut all_mutations = Vec::new();
279
280        for domain in domains {
281            for filename in ["types.json", "queries.json", "mutations.json"] {
282                let path = domain.path.join(filename);
283                if path.exists() {
284                    Self::extend_from_json_file(
285                        &path,
286                        &mut all_types,
287                        &mut all_queries,
288                        &mut all_mutations,
289                    )?;
290                }
291            }
292        }
293
294        let types_value = serde_json::json!({
295            "types": all_types,
296            "queries": all_queries,
297            "mutations": all_mutations,
298        });
299
300        // Merge with TOML definitions
301        Self::merge_values(&types_value, &toml_schema)
302    }
303
304    /// Merge with TOML includes (glob patterns for schema files)
305    ///
306    /// # Arguments
307    /// * `toml_path` - Path to fraiseql.toml with schema.includes section
308    ///
309    /// # Returns
310    /// `IntermediateSchema` from loaded files + TOML definitions.
311    ///
312    /// # Errors
313    ///
314    /// Returns an error if the TOML cannot be loaded or validated, if any glob
315    /// pattern is invalid, if a matched file cannot be parsed, or if the merged
316    /// result cannot be deserialized.
317    pub fn merge_with_includes(toml_path: &str) -> Result<IntermediateSchema> {
318        let toml_schema = TomlSchema::from_file(toml_path)
319            .context(format!("Failed to load TOML from {toml_path}"))?;
320
321        toml_schema.validate()?;
322
323        // If includes are specified, load and merge files
324        let types_value = if toml_schema.includes.is_empty() {
325            // No includes specified, use empty schema
326            serde_json::json!({
327                "types": [],
328                "queries": [],
329                "mutations": []
330            })
331        } else {
332            let resolved = toml_schema
333                .includes
334                .resolve_globs()
335                .context("Failed to resolve glob patterns in schema.includes")?;
336
337            // Load all type files
338            let type_files: Vec<std::path::PathBuf> = resolved.types;
339            let mut merged_types = if type_files.is_empty() {
340                serde_json::json!({
341                    "types": [],
342                    "queries": [],
343                    "mutations": []
344                })
345            } else {
346                crate::schema::MultiFileLoader::load_from_paths(&type_files)
347                    .context("Failed to load type files")?
348            };
349
350            // Load and merge query files
351            if !resolved.queries.is_empty() {
352                let loaded = crate::schema::MultiFileLoader::load_from_paths(&resolved.queries)
353                    .context("Failed to load query files")?;
354                let new_items =
355                    loaded.get("queries").and_then(Value::as_array).cloned().unwrap_or_default();
356                if let Some(Value::Array(existing)) = merged_types.get_mut("queries") {
357                    existing.extend(new_items);
358                }
359            }
360
361            // Load and merge mutation files
362            if !resolved.mutations.is_empty() {
363                let loaded = crate::schema::MultiFileLoader::load_from_paths(&resolved.mutations)
364                    .context("Failed to load mutation files")?;
365                let new_items =
366                    loaded.get("mutations").and_then(Value::as_array).cloned().unwrap_or_default();
367                if let Some(Value::Array(existing)) = merged_types.get_mut("mutations") {
368                    existing.extend(new_items);
369                }
370            }
371
372            merged_types
373        };
374
375        // Merge with TOML definitions
376        Self::merge_values(&types_value, &toml_schema)
377    }
378
379    /// Merge JSON types with TOML schema
380    #[allow(clippy::cognitive_complexity)] // Reason: deep merge of two schema formats with many field-level transformations
381    fn merge_values(types_value: &Value, toml_schema: &TomlSchema) -> Result<IntermediateSchema> {
382        // Typo guard: [queries.defaults] is a common mistake for [query_defaults].
383        if toml_schema.queries.contains_key("defaults") {
384            anyhow::bail!(
385                "Found a query definition named 'defaults' under [queries.defaults]. \
386                 Did you mean [query_defaults] to set global auto-param defaults?\n\
387                 If you intended a query called 'defaults', rename it to avoid confusion."
388            );
389        }
390
391        // Start with arrays for types, queries, mutations (not objects!)
392        // This matches IntermediateSchema structure which uses Vec<T>
393        let mut types_array: Vec<Value> = Vec::new();
394        let mut queries_array: Vec<Value> = Vec::new();
395        let mut mutations_array: Vec<Value> = Vec::new();
396
397        // Process types from types.json (comes as array from language SDKs)
398        if let Some(types_obj) = types_value.get("types") {
399            match types_obj {
400                // Handle array format (from language SDKs)
401                Value::Array(types_list) => {
402                    for type_item in types_list {
403                        if let Some(type_name) = type_item.get("name").and_then(|v| v.as_str()) {
404                            let mut enriched_type = type_item.clone();
405                            if let Some(toml_type) = toml_schema.types.get(type_name) {
406                                Self::enrich_type_from_toml(&mut enriched_type, toml_type);
407                            }
408                            types_array.push(enriched_type);
409                        }
410                    }
411                },
412                // Handle object format (from TOML-only, for backward compatibility)
413                Value::Object(types_map) => {
414                    for (type_name, type_value) in types_map {
415                        let mut enriched_type = type_value.clone();
416                        enriched_type["name"] = json!(type_name);
417
418                        // Convert fields from object to array format if needed
419                        if let Some(Value::Object(fields_map)) = enriched_type.get("fields") {
420                            let fields_array: Vec<Value> = fields_map
421                                .iter()
422                                .map(|(field_name, field_value)| {
423                                    let mut field = field_value.clone();
424                                    field["name"] = json!(field_name);
425                                    field
426                                })
427                                .collect();
428                            enriched_type["fields"] = json!(fields_array);
429                        }
430
431                        if let Some(toml_type) = toml_schema.types.get(type_name) {
432                            Self::enrich_type_from_toml(&mut enriched_type, toml_type);
433                        }
434
435                        types_array.push(enriched_type);
436                    }
437                },
438                _ => {},
439            }
440        }
441
442        // Add types from TOML that aren't already in types_array
443        let existing_type_names: std::collections::HashSet<_> = types_array
444            .iter()
445            .filter_map(|t| t.get("name").and_then(|v| v.as_str()).map(str::to_string))
446            .collect();
447
448        for (type_name, toml_type) in &toml_schema.types {
449            if !existing_type_names.contains(type_name) {
450                types_array.push(json!({
451                    "name": type_name,
452                    "sql_source": toml_type.sql_source,
453                    "description": toml_type.description,
454                    "fields": toml_type.fields.iter().map(|(fname, fdef)| json!({
455                        "name": fname,
456                        "type": fdef.field_type,
457                        "nullable": fdef.nullable,
458                        "description": fdef.description,
459                    })).collect::<Vec<_>>(),
460                }));
461            }
462        }
463
464        if let Some(Value::Array(queries_list)) = types_value.get("queries") {
465            queries_array.clone_from(queries_list);
466        }
467
468        // Add queries from TOML
469        for (query_name, toml_query) in &toml_schema.queries {
470            queries_array.push(json!({
471                "name": query_name,
472                "return_type": toml_query.return_type,
473                "return_array": toml_query.return_array,
474                "sql_source": toml_query.sql_source,
475                "description": toml_query.description,
476                "args": toml_query.args.iter().map(|arg| json!({
477                    "name": arg.name,
478                    "type": arg.arg_type,
479                    "required": arg.required,
480                    "default": arg.default,
481                    "description": arg.description,
482                })).collect::<Vec<_>>(),
483            }));
484        }
485
486        if let Some(Value::Array(mutations_list)) = types_value.get("mutations") {
487            mutations_array.clone_from(mutations_list);
488        }
489
490        // Add mutations from TOML
491        for (mutation_name, toml_mutation) in &toml_schema.mutations {
492            let sql_source = resolve_mutation_sql_source(
493                mutation_name,
494                toml_mutation.sql_source.as_deref(),
495                &toml_mutation.operation,
496                &toml_mutation.return_type,
497                toml_schema.crud.as_ref(),
498            )?;
499            mutations_array.push(json!({
500                "name": mutation_name,
501                "return_type": toml_mutation.return_type,
502                "sql_source": sql_source,
503                "operation": toml_mutation.operation,
504                "description": toml_mutation.description,
505                "args": toml_mutation.args.iter().map(|arg| json!({
506                    "name": arg.name,
507                    "type": arg.arg_type,
508                    "required": arg.required,
509                    "default": arg.default,
510                    "description": arg.description,
511                })).collect::<Vec<_>>(),
512            }));
513        }
514
515        // Build merged schema with arrays
516        let mut merged = serde_json::json!({
517            "version": "2.0.0",
518            "types": types_array,
519            "queries": queries_array,
520            "mutations": mutations_array,
521        });
522
523        // Warn when PKCE is enabled without state encryption (insecure configuration).
524        if let Some(pkce) = &toml_schema.security.pkce {
525            if pkce.enabled {
526                let enc_enabled =
527                    toml_schema.security.state_encryption.as_ref().is_some_and(|e| e.enabled);
528                if !enc_enabled {
529                    tracing::warn!(
530                        "pkce.enabled = true but state_encryption.enabled = false. \
531                         PKCE state will be stored unencrypted. \
532                         Set [security.state_encryption] enabled = true for production."
533                    );
534                }
535            }
536        }
537
538        // Add security configuration if available in TOML
539        merged["security"] = json!({
540            "default_policy": toml_schema.security.default_policy,
541            "rules": toml_schema.security.rules.iter().map(|r| json!({
542                "name": r.name,
543                "rule": r.rule,
544                "description": r.description,
545                "cacheable": r.cacheable,
546                "cache_ttl_seconds": r.cache_ttl_seconds,
547            })).collect::<Vec<_>>(),
548            "policies": toml_schema.security.policies.iter().map(|p| json!({
549                "name": p.name,
550                "type": p.policy_type,
551                "rule": p.rule,
552                "roles": p.roles,
553                "strategy": p.strategy,
554                "attributes": p.attributes,
555                "description": p.description,
556                "cache_ttl_seconds": p.cache_ttl_seconds,
557            })).collect::<Vec<_>>(),
558            "field_auth": toml_schema.security.field_auth.iter().map(|fa| json!({
559                "type_name": fa.type_name,
560                "field_name": fa.field_name,
561                "policy": fa.policy,
562            })).collect::<Vec<_>>(),
563            "enterprise": json!({
564                "rate_limiting_enabled": toml_schema.security.enterprise.rate_limiting_enabled,
565                "auth_endpoint_max_requests": toml_schema.security.enterprise.auth_endpoint_max_requests,
566                "auth_endpoint_window_seconds": toml_schema.security.enterprise.auth_endpoint_window_seconds,
567                "audit_logging_enabled": toml_schema.security.enterprise.audit_logging_enabled,
568                "audit_log_backend": toml_schema.security.enterprise.audit_log_backend,
569                "audit_retention_days": toml_schema.security.enterprise.audit_retention_days,
570                "error_sanitization": toml_schema.security.enterprise.error_sanitization,
571                "hide_implementation_details": toml_schema.security.enterprise.hide_implementation_details,
572                "constant_time_comparison": toml_schema.security.enterprise.constant_time_comparison,
573                "pkce_enabled": toml_schema.security.enterprise.pkce_enabled,
574            }),
575            "error_sanitization": toml_schema.security.error_sanitization,
576            "rate_limiting": toml_schema.security.rate_limiting,
577            "state_encryption": toml_schema.security.state_encryption,
578            "pkce": toml_schema.security.pkce,
579            "api_keys": toml_schema.security.api_keys,
580            "token_revocation": toml_schema.security.token_revocation,
581            "trusted_documents": toml_schema.security.trusted_documents,
582        });
583
584        // Embed observers configuration if enabled or if any backend URL is set
585        if toml_schema.observers.enabled
586            || toml_schema.observers.redis_url.is_some()
587            || toml_schema.observers.nats_url.is_some()
588        {
589            if toml_schema.observers.backend == "nats" && toml_schema.observers.nats_url.is_none() {
590                tracing::warn!(
591                    "observers.backend is \"nats\" but observers.nats_url is not set; \
592                     the runtime will require FRAISEQL_NATS_URL to be configured"
593                );
594            }
595            merged["observers_config"] = json!({
596                "enabled": toml_schema.observers.enabled,
597                "backend": toml_schema.observers.backend,
598                "redis_url": toml_schema.observers.redis_url,
599                "nats_url": toml_schema.observers.nats_url,
600                "handlers": toml_schema.observers.handlers.iter().map(|h| json!({
601                    "name": h.name,
602                    "event": h.event,
603                    "action": h.action,
604                    "webhook_url": h.webhook_url,
605                    "retry_strategy": h.retry_strategy,
606                    "max_retries": h.max_retries,
607                    "description": h.description,
608                })).collect::<Vec<_>>(),
609            });
610        }
611
612        // Embed federation configuration if enabled
613        if toml_schema.federation.enabled {
614            merged["federation_config"] = serde_json::to_value(&toml_schema.federation)
615                .context("Failed to serialize federation config")?;
616        }
617
618        // Embed subscriptions configuration (hooks, limits)
619        let subs_json = serde_json::to_value(&toml_schema.subscriptions)
620            .context("Failed to serialize subscriptions config")?;
621        if subs_json != serde_json::json!({}) {
622            merged["subscriptions_config"] = subs_json;
623        }
624
625        // Embed validation config (depth/complexity limits)
626        let val_json = serde_json::to_value(&toml_schema.validation)
627            .context("Failed to serialize validation config")?;
628        if val_json != serde_json::json!({}) {
629            merged["validation_config"] = val_json;
630        }
631
632        // Embed debug config when enabled
633        if toml_schema.debug.enabled {
634            let debug_json = serde_json::to_value(&toml_schema.debug)
635                .context("Failed to serialize debug config")?;
636            merged["debug_config"] = debug_json;
637        }
638
639        // Embed MCP config when enabled
640        if toml_schema.mcp.enabled {
641            merged["mcp_config"] =
642                serde_json::to_value(&toml_schema.mcp).context("Failed to serialize MCP config")?;
643        }
644
645        // Embed naming convention
646        merged["naming_convention"] = serde_json::to_value(toml_schema.naming_convention)
647            .context("Failed to serialize naming_convention")?;
648
649        // Convert to IntermediateSchema
650        let mut schema = serde_json::from_value::<IntermediateSchema>(merged)
651            .context("Failed to convert merged schema to IntermediateSchema")?;
652
653        // Inject TOML [query_defaults] into the schema so the converter can apply
654        // them as project-wide fallbacks for list-query auto-params.
655        schema.query_defaults = Some(IntermediateQueryDefaults {
656            where_clause: toml_schema.query_defaults.where_clause,
657            order_by:     toml_schema.query_defaults.order_by,
658            limit:        toml_schema.query_defaults.limit,
659            offset:       toml_schema.query_defaults.offset,
660        });
661
662        Ok(schema)
663    }
664}
665
666#[allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
667#[cfg(test)]
668mod tests {
669    use std::fs;
670
671    use tempfile::TempDir;
672
673    use super::*;
674
675    #[test]
676    fn test_merge_toml_only() {
677        let toml_content = r#"
678[schema]
679name = "test"
680version = "1.0.0"
681database_target = "postgresql"
682
683[database]
684url = "postgresql://localhost/test"
685
686[types.User]
687sql_source = "v_user"
688
689[types.User.fields.id]
690type = "ID"
691
692[types.User.fields.name]
693type = "String"
694
695[queries.users]
696return_type = "User"
697return_array = true
698sql_source = "v_user"
699"#;
700
701        // Write temp file
702        let temp_path = "/tmp/test_fraiseql.toml";
703        std::fs::write(temp_path, toml_content).unwrap();
704
705        // Merge
706        let result = SchemaMerger::merge_toml_only(temp_path);
707        result.unwrap_or_else(|e| panic!("expected Ok from merge_toml_only: {e}"));
708
709        // Clean up
710        let _ = std::fs::remove_file(temp_path);
711    }
712
713    #[test]
714    fn test_merge_with_includes() -> Result<()> {
715        let temp_dir = TempDir::new()?;
716
717        // Create schema files
718        let user_types = serde_json::json!({
719            "types": [{"name": "User", "fields": []}],
720            "queries": [],
721            "mutations": []
722        });
723        fs::write(temp_dir.path().join("user.json"), user_types.to_string())?;
724
725        let post_types = serde_json::json!({
726            "types": [{"name": "Post", "fields": []}],
727            "queries": [],
728            "mutations": []
729        });
730        fs::write(temp_dir.path().join("post.json"), post_types.to_string())?;
731
732        // Create TOML with includes
733        let toml_content = format!(
734            r#"
735[schema]
736name = "test"
737version = "1.0.0"
738database_target = "postgresql"
739
740[database]
741url = "postgresql://localhost/test"
742
743[includes]
744types = ["{}/*.json"]
745queries = []
746mutations = []
747"#,
748            temp_dir.path().to_string_lossy()
749        );
750
751        let toml_path = temp_dir.path().join("fraiseql.toml");
752        fs::write(&toml_path, toml_content)?;
753
754        // Merge
755        let result = SchemaMerger::merge_with_includes(toml_path.to_str().unwrap());
756        let schema = result.unwrap_or_else(|e| panic!("expected Ok from merge_with_includes: {e}"));
757        assert_eq!(schema.types.len(), 2);
758
759        Ok(())
760    }
761
762    #[test]
763    fn test_merge_with_includes_missing_files() -> Result<()> {
764        let temp_dir = TempDir::new()?;
765
766        let toml_content = r#"
767[schema]
768name = "test"
769version = "1.0.0"
770database_target = "postgresql"
771
772[database]
773url = "postgresql://localhost/test"
774
775[includes]
776types = ["/nonexistent/path/*.json"]
777queries = []
778mutations = []
779"#;
780
781        let toml_path = temp_dir.path().join("fraiseql.toml");
782        fs::write(&toml_path, toml_content)?;
783
784        // Should succeed but with no files loaded (glob matches nothing)
785        let result = SchemaMerger::merge_with_includes(toml_path.to_str().unwrap());
786        let schema = result.unwrap_or_else(|e| {
787            panic!("expected Ok from merge_with_includes (missing files): {e}")
788        });
789        assert_eq!(schema.types.len(), 0);
790
791        Ok(())
792    }
793
794    #[test]
795    fn test_merge_from_domains() -> 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 domain structure
801        fs::create_dir(schema_dir.join("auth"))?;
802        fs::create_dir(schema_dir.join("products"))?;
803
804        let auth_types = serde_json::json!({
805            "types": [{"name": "User", "fields": []}],
806            "queries": [{"name": "getUser", "return_type": "User"}],
807            "mutations": []
808        });
809        fs::write(schema_dir.join("auth/types.json"), auth_types.to_string())?;
810
811        let product_types = serde_json::json!({
812            "types": [{"name": "Product", "fields": []}],
813            "queries": [{"name": "getProduct", "return_type": "Product"}],
814            "mutations": []
815        });
816        fs::write(schema_dir.join("products/types.json"), product_types.to_string())?;
817
818        // Create TOML with domain discovery (use absolute path)
819        let schema_dir_str = schema_dir.to_string_lossy().to_string();
820        let toml_content = format!(
821            r#"
822[schema]
823name = "test"
824version = "1.0.0"
825database_target = "postgresql"
826
827[database]
828url = "postgresql://localhost/test"
829
830[domain_discovery]
831enabled = true
832root_dir = "{schema_dir_str}"
833"#
834        );
835
836        let toml_path = temp_dir.path().join("fraiseql.toml");
837        fs::write(&toml_path, toml_content)?;
838
839        // Merge
840        let schema = SchemaMerger::merge_from_domains(toml_path.to_str().unwrap())
841            .unwrap_or_else(|e| panic!("expected Ok from merge_from_domains: {e}"));
842
843        // Should have 2 types (from both domains)
844        assert_eq!(schema.types.len(), 2);
845        // Should have 2 queries (from both domains)
846        assert_eq!(schema.queries.len(), 2);
847
848        Ok(())
849    }
850
851    #[test]
852    fn test_merge_from_domains_alphabetical_order() -> Result<()> {
853        let temp_dir = TempDir::new()?;
854        let schema_dir = temp_dir.path().join("schema");
855        fs::create_dir(&schema_dir)?;
856
857        // Create domains in non-alphabetical order
858        fs::create_dir(schema_dir.join("zebra"))?;
859        fs::create_dir(schema_dir.join("alpha"))?;
860        fs::create_dir(schema_dir.join("middle"))?;
861
862        for domain in &["zebra", "alpha", "middle"] {
863            let types = serde_json::json!({
864                "types": [{"name": domain.to_uppercase(), "fields": []}],
865                "queries": [],
866                "mutations": []
867            });
868            fs::write(schema_dir.join(format!("{domain}/types.json")), types.to_string())?;
869        }
870
871        let schema_dir_str = schema_dir.to_string_lossy().to_string();
872        let toml_content = format!(
873            r#"
874[schema]
875name = "test"
876version = "1.0.0"
877database_target = "postgresql"
878
879[database]
880url = "postgresql://localhost/test"
881
882[domain_discovery]
883enabled = true
884root_dir = "{schema_dir_str}"
885"#
886        );
887
888        let toml_path = temp_dir.path().join("fraiseql.toml");
889        fs::write(&toml_path, toml_content)?;
890
891        let schema = SchemaMerger::merge_from_domains(toml_path.to_str().unwrap())
892            .unwrap_or_else(|e| panic!("expected Ok from merge_from_domains (alphabetical): {e}"));
893
894        // Types should be loaded in alphabetical order: ALPHA, MIDDLE, ZEBRA
895        let type_names: Vec<String> = schema.types.iter().map(|t| t.name.clone()).collect();
896
897        assert_eq!(type_names[0], "ALPHA");
898        assert_eq!(type_names[1], "MIDDLE");
899        assert_eq!(type_names[2], "ZEBRA");
900
901        Ok(())
902    }
903
904    #[test]
905    fn test_merge_toml_only_with_validation_config() {
906        let toml_content = r#"
907[schema]
908name = "test"
909version = "1.0.0"
910database_target = "postgresql"
911
912[database]
913url = "postgresql://localhost/test"
914
915[types.User]
916sql_source = "v_user"
917
918[types.User.fields.id]
919type = "ID"
920
921[validation]
922max_query_depth = 3
923max_query_complexity = 25
924"#;
925
926        let temp_path = "/tmp/test_fraiseql_validation.toml";
927        std::fs::write(temp_path, toml_content).unwrap();
928
929        let schema = SchemaMerger::merge_toml_only(temp_path)
930            .unwrap_or_else(|e| panic!("expected Ok from merge_toml_only (with validation): {e}"));
931
932        // validation_config should be populated
933        let vc = schema.validation_config.as_ref().expect("validation_config should be set");
934        assert_eq!(vc.max_query_depth, Some(3));
935        assert_eq!(vc.max_query_complexity, Some(25));
936
937        let _ = std::fs::remove_file(temp_path);
938    }
939
940    #[test]
941    fn test_merge_toml_only_without_validation_config() {
942        let toml_content = r#"
943[schema]
944name = "test"
945version = "1.0.0"
946database_target = "postgresql"
947
948[database]
949url = "postgresql://localhost/test"
950
951[types.User]
952sql_source = "v_user"
953
954[types.User.fields.id]
955type = "ID"
956"#;
957
958        let temp_path = "/tmp/test_fraiseql_no_validation.toml";
959        std::fs::write(temp_path, toml_content).unwrap();
960
961        let schema = SchemaMerger::merge_toml_only(temp_path)
962            .unwrap_or_else(|e| panic!("expected Ok from merge_toml_only (no validation): {e}"));
963
964        // validation_config should be None when no [validation] section
965        assert!(schema.validation_config.is_none());
966
967        let _ = std::fs::remove_file(temp_path);
968    }
969
970    // ── CRUD naming config (P2) ───────────────────────────────────────────────
971
972    #[test]
973    fn pascal_to_snake_single_word() {
974        assert_eq!(pascal_to_snake("User"), "user");
975    }
976
977    #[test]
978    fn pascal_to_snake_compound_type() {
979        assert_eq!(pascal_to_snake("UserProfile"), "user_profile");
980    }
981
982    #[test]
983    fn pascal_to_snake_already_lower() {
984        assert_eq!(pascal_to_snake("user"), "user");
985    }
986
987    #[test]
988    fn pascal_to_snake_three_words() {
989        assert_eq!(pascal_to_snake("DnsServerConfig"), "dns_server_config");
990    }
991
992    fn write_temp_toml(content: &str) -> String {
993        let path = format!(
994            "/tmp/test_crud_merger_{}.toml",
995            std::time::SystemTime::now()
996                .duration_since(std::time::UNIX_EPOCH)
997                .unwrap_or_default()
998                .subsec_nanos()
999        );
1000        std::fs::write(&path, content).unwrap();
1001        path
1002    }
1003
1004    #[test]
1005    fn crud_trinity_resolves_create_mutation() {
1006        let toml = r#"
1007[schema]
1008name = "test"
1009version = "1.0.0"
1010
1011[crud]
1012function_schema = "app"
1013function_naming = "trinity"
1014
1015[types.User]
1016sql_source = "v_user"
1017
1018[types.User.fields.id]
1019type = "ID"
1020
1021[mutations.create_user]
1022return_type = "User"
1023operation = "CREATE"
1024"#;
1025        let temp_path = write_temp_toml(toml);
1026        let schema =
1027            SchemaMerger::merge_toml_only(&temp_path).expect("should merge with crud naming");
1028        let mutation = schema.mutations.iter().find(|m| m.name == "create_user").unwrap();
1029        assert_eq!(mutation.sql_source.as_deref(), Some("app.create_user"));
1030        let _ = std::fs::remove_file(&temp_path);
1031    }
1032
1033    #[test]
1034    fn crud_trinity_resolves_pascal_return_type() {
1035        let toml = r#"
1036[schema]
1037name = "test"
1038version = "1.0.0"
1039
1040[crud]
1041function_naming = "trinity"
1042
1043[types.UserProfile]
1044sql_source = "v_user_profile"
1045
1046[types.UserProfile.fields.id]
1047type = "ID"
1048
1049[mutations.create_user_profile]
1050return_type = "UserProfile"
1051operation = "CREATE"
1052"#;
1053        let temp_path = write_temp_toml(toml);
1054        let schema = SchemaMerger::merge_toml_only(&temp_path).expect("should merge");
1055        let mutation = schema.mutations.iter().find(|m| m.name == "create_user_profile").unwrap();
1056        assert_eq!(mutation.sql_source.as_deref(), Some("create_user_profile"));
1057        let _ = std::fs::remove_file(&temp_path);
1058    }
1059
1060    #[test]
1061    fn explicit_sql_source_wins_over_crud() {
1062        let toml = r#"
1063[schema]
1064name = "test"
1065version = "1.0.0"
1066
1067[crud]
1068function_schema = "app"
1069function_naming = "trinity"
1070
1071[types.User]
1072sql_source = "v_user"
1073
1074[types.User.fields.id]
1075type = "ID"
1076
1077[mutations.create_user]
1078return_type = "User"
1079operation = "CREATE"
1080sql_source = "custom_create_user_fn"
1081"#;
1082        let temp_path = write_temp_toml(toml);
1083        let schema = SchemaMerger::merge_toml_only(&temp_path).expect("should merge");
1084        let mutation = schema.mutations.iter().find(|m| m.name == "create_user").unwrap();
1085        assert_eq!(mutation.sql_source.as_deref(), Some("custom_create_user_fn"));
1086        let _ = std::fs::remove_file(&temp_path);
1087    }
1088
1089    #[test]
1090    fn no_sql_source_no_crud_errors_with_mutation_name() {
1091        let toml = r#"
1092[schema]
1093name = "test"
1094version = "1.0.0"
1095
1096[types.User]
1097sql_source = "v_user"
1098
1099[types.User.fields.id]
1100type = "ID"
1101
1102[mutations.create_user]
1103return_type = "User"
1104operation = "CREATE"
1105"#;
1106        let temp_path = write_temp_toml(toml);
1107        let err = SchemaMerger::merge_toml_only(&temp_path)
1108            .expect_err("should fail without sql_source and no crud config");
1109        let msg = format!("{err}");
1110        assert!(msg.contains("create_user"), "error should name the mutation, got: {msg}");
1111        assert!(msg.contains("sql_source") || msg.contains("crud"), "got: {msg}");
1112        let _ = std::fs::remove_file(&temp_path);
1113    }
1114
1115    #[test]
1116    fn crud_custom_template_resolved_in_merger() {
1117        let toml = r#"
1118[schema]
1119name = "test"
1120version = "1.0.0"
1121
1122[crud]
1123function_schema = "app"
1124create_template = "insert_{entity}"
1125
1126[types.Order]
1127sql_source = "v_order"
1128
1129[types.Order.fields.id]
1130type = "ID"
1131
1132[mutations.create_order]
1133return_type = "Order"
1134operation = "CREATE"
1135"#;
1136        let temp_path = write_temp_toml(toml);
1137        let schema = SchemaMerger::merge_toml_only(&temp_path).expect("should merge");
1138        let mutation = schema.mutations.iter().find(|m| m.name == "create_order").unwrap();
1139        assert_eq!(mutation.sql_source.as_deref(), Some("app.insert_order"));
1140        let _ = std::fs::remove_file(&temp_path);
1141    }
1142
1143    #[test]
1144    fn crud_update_and_delete_resolved() {
1145        let toml = r#"
1146[schema]
1147name = "test"
1148version = "1.0.0"
1149
1150[crud]
1151function_schema = "app"
1152function_naming = "trinity"
1153
1154[types.User]
1155sql_source = "v_user"
1156
1157[types.User.fields.id]
1158type = "ID"
1159
1160[mutations.update_user]
1161return_type = "User"
1162operation = "UPDATE"
1163
1164[mutations.delete_user]
1165return_type = "User"
1166operation = "DELETE"
1167"#;
1168        let temp_path = write_temp_toml(toml);
1169        let schema = SchemaMerger::merge_toml_only(&temp_path).expect("should merge");
1170        let update = schema.mutations.iter().find(|m| m.name == "update_user").unwrap();
1171        let delete = schema.mutations.iter().find(|m| m.name == "delete_user").unwrap();
1172        assert_eq!(update.sql_source.as_deref(), Some("app.update_user"));
1173        assert_eq!(delete.sql_source.as_deref(), Some("app.delete_user"));
1174        let _ = std::fs::remove_file(&temp_path);
1175    }
1176}