1use std::fs;
10
11use anyhow::{Context, Result};
12use serde_json::{Value, json};
13
14use crate::{config::TomlSchema, schema::IntermediateSchema};
15
16pub struct SchemaMerger;
18
19impl SchemaMerger {
20 pub fn merge_files(types_path: &str, toml_path: &str) -> Result<IntermediateSchema> {
29 let types_json = fs::read_to_string(types_path)
31 .context(format!("Failed to read types.json from {types_path}"))?;
32 let types_value: Value =
33 serde_json::from_str(&types_json).context("Failed to parse types.json")?;
34
35 let toml_schema = TomlSchema::from_file(toml_path)
37 .context(format!("Failed to load TOML from {toml_path}"))?;
38
39 Self::merge_values(&types_value, &toml_schema)
44 }
45
46 pub fn merge_toml_only(toml_path: &str) -> Result<IntermediateSchema> {
54 let toml_schema = TomlSchema::from_file(toml_path)
55 .context(format!("Failed to load TOML from {toml_path}"))?;
56
57 toml_schema.validate()?;
58
59 let types_value = toml_schema.to_intermediate_schema();
61 Self::merge_values(&types_value, &toml_schema)
62 }
63
64 pub fn merge_from_directory(toml_path: &str, schema_dir: &str) -> Result<IntermediateSchema> {
73 let toml_schema = TomlSchema::from_file(toml_path)
74 .context(format!("Failed to load TOML from {toml_path}"))?;
75
76 toml_schema.validate()?;
77
78 let types_value = crate::schema::MultiFileLoader::load_from_directory(schema_dir)
80 .context(format!("Failed to load schema from directory {schema_dir}"))?;
81
82 Self::merge_values(&types_value, &toml_schema)
84 }
85
86 fn load_section(files: &[String], key: &str) -> Result<Option<serde_json::Value>> {
88 if files.is_empty() {
89 return Ok(None);
90 }
91 let paths: Vec<std::path::PathBuf> = files.iter().map(std::path::PathBuf::from).collect();
92 let loaded = crate::schema::MultiFileLoader::load_from_paths(&paths)
93 .with_context(|| format!("Failed to load {key} files"))?;
94 Ok(loaded.get(key).cloned())
95 }
96
97 fn extend_from_json_file(
100 path: &std::path::Path,
101 all_types: &mut Vec<Value>,
102 all_queries: &mut Vec<Value>,
103 all_mutations: &mut Vec<Value>,
104 ) -> Result<()> {
105 let content = fs::read_to_string(path)
106 .with_context(|| format!("Failed to read {}", path.display()))?;
107 let value: Value = serde_json::from_str(&content)
108 .with_context(|| format!("Failed to parse {}", path.display()))?;
109 for (vec, key) in [
110 (all_types as &mut Vec<Value>, "types"),
111 (all_queries, "queries"),
112 (all_mutations, "mutations"),
113 ] {
114 if let Some(Value::Array(items)) = value.get(key) {
115 vec.extend(items.iter().cloned());
116 }
117 }
118 Ok(())
119 }
120
121 fn enrich_type_from_toml(enriched_type: &mut Value, toml_type: &crate::config::toml_schema::TypeDefinition) {
123 enriched_type["sql_source"] = json!(toml_type.sql_source);
124 if let Some(desc) = &toml_type.description {
125 enriched_type["description"] = json!(desc);
126 }
127 }
128
129 pub fn merge_explicit_files(
140 toml_path: &str,
141 type_files: &[String],
142 query_files: &[String],
143 mutation_files: &[String],
144 ) -> Result<IntermediateSchema> {
145 let toml_schema = TomlSchema::from_file(toml_path)
146 .context(format!("Failed to load TOML from {toml_path}"))?;
147
148 toml_schema.validate()?;
149
150 let mut types_value = serde_json::json!({
151 "types": [],
152 "queries": [],
153 "mutations": []
154 });
155
156 if let Some(v) = Self::load_section(type_files, "types")? {
157 types_value["types"] = v;
158 }
159 if let Some(v) = Self::load_section(query_files, "queries")? {
160 types_value["queries"] = v;
161 }
162 if let Some(v) = Self::load_section(mutation_files, "mutations")? {
163 types_value["mutations"] = v;
164 }
165
166 Self::merge_values(&types_value, &toml_schema)
167 }
168
169 pub fn merge_from_domains(toml_path: &str) -> Result<IntermediateSchema> {
177 let toml_schema = TomlSchema::from_file(toml_path)
178 .context(format!("Failed to load TOML from {toml_path}"))?;
179
180 toml_schema.validate()?;
181
182 let domains = toml_schema
184 .domain_discovery
185 .resolve_domains()
186 .context("Failed to discover domains")?;
187
188 if domains.is_empty() {
189 let empty_value = serde_json::json!({
191 "types": [],
192 "queries": [],
193 "mutations": []
194 });
195 return Self::merge_values(&empty_value, &toml_schema);
196 }
197
198 let mut all_types = Vec::new();
199 let mut all_queries = Vec::new();
200 let mut all_mutations = Vec::new();
201
202 for domain in domains {
203 for filename in ["types.json", "queries.json", "mutations.json"] {
204 let path = domain.path.join(filename);
205 if path.exists() {
206 Self::extend_from_json_file(
207 &path,
208 &mut all_types,
209 &mut all_queries,
210 &mut all_mutations,
211 )?;
212 }
213 }
214 }
215
216 let types_value = serde_json::json!({
217 "types": all_types,
218 "queries": all_queries,
219 "mutations": all_mutations,
220 });
221
222 Self::merge_values(&types_value, &toml_schema)
224 }
225
226 pub fn merge_with_includes(toml_path: &str) -> Result<IntermediateSchema> {
234 let toml_schema = TomlSchema::from_file(toml_path)
235 .context(format!("Failed to load TOML from {toml_path}"))?;
236
237 toml_schema.validate()?;
238
239 let types_value = if toml_schema.includes.is_empty() {
241 serde_json::json!({
243 "types": [],
244 "queries": [],
245 "mutations": []
246 })
247 } else {
248 let resolved = toml_schema
249 .includes
250 .resolve_globs()
251 .context("Failed to resolve glob patterns in schema.includes")?;
252
253 let type_files: Vec<std::path::PathBuf> = resolved.types;
255 let mut merged_types = if type_files.is_empty() {
256 serde_json::json!({
257 "types": [],
258 "queries": [],
259 "mutations": []
260 })
261 } else {
262 crate::schema::MultiFileLoader::load_from_paths(&type_files)
263 .context("Failed to load type files")?
264 };
265
266 if !resolved.queries.is_empty() {
268 let loaded = crate::schema::MultiFileLoader::load_from_paths(&resolved.queries)
269 .context("Failed to load query files")?;
270 let new_items =
271 loaded.get("queries").and_then(Value::as_array).cloned().unwrap_or_default();
272 if let Some(Value::Array(existing)) = merged_types.get_mut("queries") {
273 existing.extend(new_items);
274 }
275 }
276
277 if !resolved.mutations.is_empty() {
279 let loaded = crate::schema::MultiFileLoader::load_from_paths(&resolved.mutations)
280 .context("Failed to load mutation files")?;
281 let new_items = loaded
282 .get("mutations")
283 .and_then(Value::as_array)
284 .cloned()
285 .unwrap_or_default();
286 if let Some(Value::Array(existing)) = merged_types.get_mut("mutations") {
287 existing.extend(new_items);
288 }
289 }
290
291 merged_types
292 };
293
294 Self::merge_values(&types_value, &toml_schema)
296 }
297
298 fn merge_values(types_value: &Value, toml_schema: &TomlSchema) -> Result<IntermediateSchema> {
300 let mut types_array: Vec<Value> = Vec::new();
303 let mut queries_array: Vec<Value> = Vec::new();
304 let mut mutations_array: Vec<Value> = Vec::new();
305
306 if let Some(types_obj) = types_value.get("types") {
308 match types_obj {
309 Value::Array(types_list) => {
311 for type_item in types_list {
312 if let Some(type_name) = type_item.get("name").and_then(|v| v.as_str()) {
313 let mut enriched_type = type_item.clone();
314 if let Some(toml_type) = toml_schema.types.get(type_name) {
315 Self::enrich_type_from_toml(&mut enriched_type, toml_type);
316 }
317 types_array.push(enriched_type);
318 }
319 }
320 },
321 Value::Object(types_map) => {
323 for (type_name, type_value) in types_map {
324 let mut enriched_type = type_value.clone();
325 enriched_type["name"] = json!(type_name);
326
327 if let Some(Value::Object(fields_map)) = enriched_type.get("fields") {
329 let fields_array: Vec<Value> = fields_map
330 .iter()
331 .map(|(field_name, field_value)| {
332 let mut field = field_value.clone();
333 field["name"] = json!(field_name);
334 field
335 })
336 .collect();
337 enriched_type["fields"] = json!(fields_array);
338 }
339
340 if let Some(toml_type) = toml_schema.types.get(type_name) {
341 Self::enrich_type_from_toml(&mut enriched_type, toml_type);
342 }
343
344 types_array.push(enriched_type);
345 }
346 },
347 _ => {},
348 }
349 }
350
351 let existing_type_names: std::collections::HashSet<_> = types_array
353 .iter()
354 .filter_map(|t| {
355 t.get("name").and_then(|v| v.as_str()).map(str::to_string)
356 })
357 .collect();
358
359 for (type_name, toml_type) in &toml_schema.types {
360 if !existing_type_names.contains(type_name) {
361 types_array.push(json!({
362 "name": type_name,
363 "sql_source": toml_type.sql_source,
364 "description": toml_type.description,
365 "fields": toml_type.fields.iter().map(|(fname, fdef)| json!({
366 "name": fname,
367 "type": fdef.field_type,
368 "nullable": fdef.nullable,
369 "description": fdef.description,
370 })).collect::<Vec<_>>(),
371 }));
372 }
373 }
374
375 if let Some(Value::Array(queries_list)) = types_value.get("queries") {
376 queries_array.clone_from(queries_list);
377 }
378
379 for (query_name, toml_query) in &toml_schema.queries {
381 queries_array.push(json!({
382 "name": query_name,
383 "return_type": toml_query.return_type,
384 "return_array": toml_query.return_array,
385 "sql_source": toml_query.sql_source,
386 "description": toml_query.description,
387 "args": toml_query.args.iter().map(|arg| json!({
388 "name": arg.name,
389 "type": arg.arg_type,
390 "required": arg.required,
391 "default": arg.default,
392 "description": arg.description,
393 })).collect::<Vec<_>>(),
394 }));
395 }
396
397 if let Some(Value::Array(mutations_list)) = types_value.get("mutations") {
398 mutations_array.clone_from(mutations_list);
399 }
400
401 for (mutation_name, toml_mutation) in &toml_schema.mutations {
403 mutations_array.push(json!({
404 "name": mutation_name,
405 "return_type": toml_mutation.return_type,
406 "sql_source": toml_mutation.sql_source,
407 "operation": toml_mutation.operation,
408 "description": toml_mutation.description,
409 "args": toml_mutation.args.iter().map(|arg| json!({
410 "name": arg.name,
411 "type": arg.arg_type,
412 "required": arg.required,
413 "default": arg.default,
414 "description": arg.description,
415 })).collect::<Vec<_>>(),
416 }));
417 }
418
419 let mut merged = serde_json::json!({
421 "version": "2.0.0",
422 "types": types_array,
423 "queries": queries_array,
424 "mutations": mutations_array,
425 });
426
427 merged["security"] = json!({
429 "default_policy": toml_schema.security.default_policy,
430 "rules": toml_schema.security.rules.iter().map(|r| json!({
431 "name": r.name,
432 "rule": r.rule,
433 "description": r.description,
434 "cacheable": r.cacheable,
435 "cache_ttl_seconds": r.cache_ttl_seconds,
436 })).collect::<Vec<_>>(),
437 "policies": toml_schema.security.policies.iter().map(|p| json!({
438 "name": p.name,
439 "type": p.policy_type,
440 "rule": p.rule,
441 "roles": p.roles,
442 "strategy": p.strategy,
443 "attributes": p.attributes,
444 "description": p.description,
445 "cache_ttl_seconds": p.cache_ttl_seconds,
446 })).collect::<Vec<_>>(),
447 "field_auth": toml_schema.security.field_auth.iter().map(|fa| json!({
448 "type_name": fa.type_name,
449 "field_name": fa.field_name,
450 "policy": fa.policy,
451 })).collect::<Vec<_>>(),
452 "enterprise": json!({
453 "rate_limiting_enabled": toml_schema.security.enterprise.rate_limiting_enabled,
454 "auth_endpoint_max_requests": toml_schema.security.enterprise.auth_endpoint_max_requests,
455 "auth_endpoint_window_seconds": toml_schema.security.enterprise.auth_endpoint_window_seconds,
456 "audit_logging_enabled": toml_schema.security.enterprise.audit_logging_enabled,
457 "audit_log_backend": toml_schema.security.enterprise.audit_log_backend,
458 "audit_retention_days": toml_schema.security.enterprise.audit_retention_days,
459 "error_sanitization": toml_schema.security.enterprise.error_sanitization,
460 "hide_implementation_details": toml_schema.security.enterprise.hide_implementation_details,
461 "constant_time_comparison": toml_schema.security.enterprise.constant_time_comparison,
462 "pkce_enabled": toml_schema.security.enterprise.pkce_enabled,
463 }),
464 });
465
466 if toml_schema.observers.enabled
468 || toml_schema.observers.redis_url.is_some()
469 || toml_schema.observers.nats_url.is_some()
470 {
471 if toml_schema.observers.backend == "nats" && toml_schema.observers.nats_url.is_none() {
472 tracing::warn!(
473 "observers.backend is \"nats\" but observers.nats_url is not set; \
474 the runtime will require FRAISEQL_NATS_URL to be configured"
475 );
476 }
477 merged["observers_config"] = json!({
478 "enabled": toml_schema.observers.enabled,
479 "backend": toml_schema.observers.backend,
480 "redis_url": toml_schema.observers.redis_url,
481 "nats_url": toml_schema.observers.nats_url,
482 "handlers": toml_schema.observers.handlers.iter().map(|h| json!({
483 "name": h.name,
484 "event": h.event,
485 "action": h.action,
486 "webhook_url": h.webhook_url,
487 "retry_strategy": h.retry_strategy,
488 "max_retries": h.max_retries,
489 "description": h.description,
490 })).collect::<Vec<_>>(),
491 });
492 }
493
494 if toml_schema.federation.enabled {
496 merged["federation_config"] = serde_json::to_value(&toml_schema.federation)
497 .unwrap_or_default();
498 }
499
500 serde_json::from_value::<IntermediateSchema>(merged)
502 .context("Failed to convert merged schema to IntermediateSchema")
503 }
504}
505
506#[cfg(test)]
507mod tests {
508 use std::fs;
509
510 use tempfile::TempDir;
511
512 use super::*;
513
514 #[test]
515 fn test_merge_toml_only() {
516 let toml_content = r#"
517[schema]
518name = "test"
519version = "1.0.0"
520database_target = "postgresql"
521
522[database]
523url = "postgresql://localhost/test"
524
525[types.User]
526sql_source = "v_user"
527
528[types.User.fields.id]
529type = "ID"
530
531[types.User.fields.name]
532type = "String"
533
534[queries.users]
535return_type = "User"
536return_array = true
537sql_source = "v_user"
538"#;
539
540 let temp_path = "/tmp/test_fraiseql.toml";
542 std::fs::write(temp_path, toml_content).unwrap();
543
544 let result = SchemaMerger::merge_toml_only(temp_path);
546 assert!(result.is_ok());
547
548 let _ = std::fs::remove_file(temp_path);
550 }
551
552 #[test]
553 fn test_merge_with_includes() -> Result<()> {
554 let temp_dir = TempDir::new()?;
555
556 let user_types = serde_json::json!({
558 "types": [{"name": "User", "fields": []}],
559 "queries": [],
560 "mutations": []
561 });
562 fs::write(temp_dir.path().join("user.json"), user_types.to_string())?;
563
564 let post_types = serde_json::json!({
565 "types": [{"name": "Post", "fields": []}],
566 "queries": [],
567 "mutations": []
568 });
569 fs::write(temp_dir.path().join("post.json"), post_types.to_string())?;
570
571 let toml_content = format!(
573 r#"
574[schema]
575name = "test"
576version = "1.0.0"
577database_target = "postgresql"
578
579[database]
580url = "postgresql://localhost/test"
581
582[includes]
583types = ["{}/*.json"]
584queries = []
585mutations = []
586"#,
587 temp_dir.path().to_string_lossy()
588 );
589
590 let toml_path = temp_dir.path().join("fraiseql.toml");
591 fs::write(&toml_path, toml_content)?;
592
593 let result = SchemaMerger::merge_with_includes(toml_path.to_str().unwrap());
595 assert!(result.is_ok());
596
597 let schema = result?;
598 assert_eq!(schema.types.len(), 2);
599
600 Ok(())
601 }
602
603 #[test]
604 fn test_merge_with_includes_missing_files() -> Result<()> {
605 let temp_dir = TempDir::new()?;
606
607 let toml_content = r#"
608[schema]
609name = "test"
610version = "1.0.0"
611database_target = "postgresql"
612
613[database]
614url = "postgresql://localhost/test"
615
616[includes]
617types = ["/nonexistent/path/*.json"]
618queries = []
619mutations = []
620"#;
621
622 let toml_path = temp_dir.path().join("fraiseql.toml");
623 fs::write(&toml_path, toml_content)?;
624
625 let result = SchemaMerger::merge_with_includes(toml_path.to_str().unwrap());
627 assert!(result.is_ok());
628
629 let schema = result?;
630 assert_eq!(schema.types.len(), 0);
631
632 Ok(())
633 }
634
635 #[test]
636 fn test_merge_from_domains() -> Result<()> {
637 let temp_dir = TempDir::new()?;
638 let schema_dir = temp_dir.path().join("schema");
639 fs::create_dir(&schema_dir)?;
640
641 fs::create_dir(schema_dir.join("auth"))?;
643 fs::create_dir(schema_dir.join("products"))?;
644
645 let auth_types = serde_json::json!({
646 "types": [{"name": "User", "fields": []}],
647 "queries": [{"name": "getUser", "return_type": "User"}],
648 "mutations": []
649 });
650 fs::write(schema_dir.join("auth/types.json"), auth_types.to_string())?;
651
652 let product_types = serde_json::json!({
653 "types": [{"name": "Product", "fields": []}],
654 "queries": [{"name": "getProduct", "return_type": "Product"}],
655 "mutations": []
656 });
657 fs::write(schema_dir.join("products/types.json"), product_types.to_string())?;
658
659 let schema_dir_str = schema_dir.to_string_lossy().to_string();
661 let toml_content = format!(
662 r#"
663[schema]
664name = "test"
665version = "1.0.0"
666database_target = "postgresql"
667
668[database]
669url = "postgresql://localhost/test"
670
671[domain_discovery]
672enabled = true
673root_dir = "{schema_dir_str}"
674"#
675 );
676
677 let toml_path = temp_dir.path().join("fraiseql.toml");
678 fs::write(&toml_path, toml_content)?;
679
680 let result = SchemaMerger::merge_from_domains(toml_path.to_str().unwrap());
682
683 assert!(result.is_ok());
684 let schema = result?;
685
686 assert_eq!(schema.types.len(), 2);
688 assert_eq!(schema.queries.len(), 2);
690
691 Ok(())
692 }
693
694 #[test]
695 fn test_merge_from_domains_alphabetical_order() -> Result<()> {
696 let temp_dir = TempDir::new()?;
697 let schema_dir = temp_dir.path().join("schema");
698 fs::create_dir(&schema_dir)?;
699
700 fs::create_dir(schema_dir.join("zebra"))?;
702 fs::create_dir(schema_dir.join("alpha"))?;
703 fs::create_dir(schema_dir.join("middle"))?;
704
705 for domain in &["zebra", "alpha", "middle"] {
706 let types = serde_json::json!({
707 "types": [{"name": domain.to_uppercase(), "fields": []}],
708 "queries": [],
709 "mutations": []
710 });
711 fs::write(schema_dir.join(format!("{domain}/types.json")), types.to_string())?;
712 }
713
714 let schema_dir_str = schema_dir.to_string_lossy().to_string();
715 let toml_content = format!(
716 r#"
717[schema]
718name = "test"
719version = "1.0.0"
720database_target = "postgresql"
721
722[database]
723url = "postgresql://localhost/test"
724
725[domain_discovery]
726enabled = true
727root_dir = "{schema_dir_str}"
728"#
729 );
730
731 let toml_path = temp_dir.path().join("fraiseql.toml");
732 fs::write(&toml_path, toml_content)?;
733
734 let result = SchemaMerger::merge_from_domains(toml_path.to_str().unwrap());
735
736 assert!(result.is_ok());
737 let schema = result?;
738
739 let type_names: Vec<String> = schema.types.iter().map(|t| t.name.clone()).collect();
741
742 assert_eq!(type_names[0], "ALPHA");
743 assert_eq!(type_names[1], "MIDDLE");
744 assert_eq!(type_names[2], "ZEBRA");
745
746 Ok(())
747 }
748}