1use 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
20fn 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
35fn 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
65pub struct SchemaMerger;
67
68impl SchemaMerger {
69 pub fn merge_files(types_path: &str, toml_path: &str) -> Result<IntermediateSchema> {
83 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 let toml_schema = TomlSchema::from_file(toml_path)
91 .context(format!("Failed to load TOML from {toml_path}"))?;
92
93 Self::merge_values(&types_value, &toml_schema)
98 }
99
100 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 let types_value = toml_schema.to_intermediate_schema();
120 Self::merge_values(&types_value, &toml_schema)
121 }
122
123 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 let types_value = crate::schema::MultiFileLoader::load_from_directory(schema_dir)
144 .context(format!("Failed to load schema from directory {schema_dir}"))?;
145
146 Self::merge_values(&types_value, &toml_schema)
148 }
149
150 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 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 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 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 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 let domains = toml_schema
262 .domain_discovery
263 .resolve_domains()
264 .context("Failed to discover domains")?;
265
266 if domains.is_empty() {
267 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 Self::merge_values(&types_value, &toml_schema)
302 }
303
304 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 let types_value = if toml_schema.includes.is_empty() {
325 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 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 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 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 Self::merge_values(&types_value, &toml_schema)
377 }
378
379 #[allow(clippy::cognitive_complexity)] fn merge_values(types_value: &Value, toml_schema: &TomlSchema) -> Result<IntermediateSchema> {
382 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 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 if let Some(types_obj) = types_value.get("types") {
399 match types_obj {
400 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 merged["naming_convention"] = serde_json::to_value(toml_schema.naming_convention)
647 .context("Failed to serialize naming_convention")?;
648
649 let mut schema = serde_json::from_value::<IntermediateSchema>(merged)
651 .context("Failed to convert merged schema to IntermediateSchema")?;
652
653 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)] #[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 let temp_path = "/tmp/test_fraiseql.toml";
703 std::fs::write(temp_path, toml_content).unwrap();
704
705 let result = SchemaMerger::merge_toml_only(temp_path);
707 result.unwrap_or_else(|e| panic!("expected Ok from merge_toml_only: {e}"));
708
709 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 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 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 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 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 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 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 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 assert_eq!(schema.types.len(), 2);
845 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 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 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 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 assert!(schema.validation_config.is_none());
966
967 let _ = std::fs::remove_file(temp_path);
968 }
969
970 #[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}