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 pub fn merge_explicit_files(
97 toml_path: &str,
98 type_files: &[String],
99 query_files: &[String],
100 mutation_files: &[String],
101 ) -> Result<IntermediateSchema> {
102 let toml_schema = TomlSchema::from_file(toml_path)
103 .context(format!("Failed to load TOML from {toml_path}"))?;
104
105 toml_schema.validate()?;
106
107 let mut types_value = serde_json::json!({
109 "types": [],
110 "queries": [],
111 "mutations": []
112 });
113
114 if !type_files.is_empty() {
116 let type_paths: Vec<std::path::PathBuf> =
117 type_files.iter().map(std::path::PathBuf::from).collect();
118 let loaded = crate::schema::MultiFileLoader::load_from_paths(&type_paths)
119 .context("Failed to load type files")?;
120 if let Some(types_array) = loaded.get("types") {
121 types_value["types"] = types_array.clone();
122 }
123 }
124
125 if !query_files.is_empty() {
127 let query_paths: Vec<std::path::PathBuf> =
128 query_files.iter().map(std::path::PathBuf::from).collect();
129 let loaded = crate::schema::MultiFileLoader::load_from_paths(&query_paths)
130 .context("Failed to load query files")?;
131 if let Some(queries_array) = loaded.get("queries") {
132 types_value["queries"] = queries_array.clone();
133 }
134 }
135
136 if !mutation_files.is_empty() {
138 let mutation_paths: Vec<std::path::PathBuf> =
139 mutation_files.iter().map(std::path::PathBuf::from).collect();
140 let loaded = crate::schema::MultiFileLoader::load_from_paths(&mutation_paths)
141 .context("Failed to load mutation files")?;
142 if let Some(mutations_array) = loaded.get("mutations") {
143 types_value["mutations"] = mutations_array.clone();
144 }
145 }
146
147 Self::merge_values(&types_value, &toml_schema)
149 }
150
151 pub fn merge_from_domains(toml_path: &str) -> Result<IntermediateSchema> {
159 let toml_schema = TomlSchema::from_file(toml_path)
160 .context(format!("Failed to load TOML from {toml_path}"))?;
161
162 toml_schema.validate()?;
163
164 let domains = toml_schema
166 .domain_discovery
167 .resolve_domains()
168 .context("Failed to discover domains")?;
169
170 if domains.is_empty() {
171 let empty_value = serde_json::json!({
173 "types": [],
174 "queries": [],
175 "mutations": []
176 });
177 return Self::merge_values(&empty_value, &toml_schema);
178 }
179
180 let mut all_types = Vec::new();
182 let mut all_queries = Vec::new();
183 let mut all_mutations = Vec::new();
184
185 for domain in domains {
186 let types_path = domain.path.join("types.json");
188 if types_path.exists() {
189 let content = fs::read_to_string(&types_path)
190 .context(format!("Failed to read {}", types_path.display()))?;
191 let value: Value = serde_json::from_str(&content)
192 .context(format!("Failed to parse {}", types_path.display()))?;
193
194 if let Some(Value::Array(type_items)) = value.get("types") {
195 all_types.extend(type_items.clone());
196 }
197 if let Some(Value::Array(query_items)) = value.get("queries") {
198 all_queries.extend(query_items.clone());
199 }
200 if let Some(Value::Array(mutation_items)) = value.get("mutations") {
201 all_mutations.extend(mutation_items.clone());
202 }
203 }
204
205 let queries_path = domain.path.join("queries.json");
207 if queries_path.exists() {
208 let content = fs::read_to_string(&queries_path)
209 .context(format!("Failed to read {}", queries_path.display()))?;
210 let value: Value = serde_json::from_str(&content)
211 .context(format!("Failed to parse {}", queries_path.display()))?;
212
213 if let Some(Value::Array(query_items)) = value.get("queries") {
214 all_queries.extend(query_items.clone());
215 }
216 }
217
218 let mutations_path = domain.path.join("mutations.json");
220 if mutations_path.exists() {
221 let content = fs::read_to_string(&mutations_path)
222 .context(format!("Failed to read {}", mutations_path.display()))?;
223 let value: Value = serde_json::from_str(&content)
224 .context(format!("Failed to parse {}", mutations_path.display()))?;
225
226 if let Some(Value::Array(mutation_items)) = value.get("mutations") {
227 all_mutations.extend(mutation_items.clone());
228 }
229 }
230 }
231
232 let types_value = serde_json::json!({
233 "types": all_types,
234 "queries": all_queries,
235 "mutations": all_mutations,
236 });
237
238 Self::merge_values(&types_value, &toml_schema)
240 }
241
242 pub fn merge_with_includes(toml_path: &str) -> Result<IntermediateSchema> {
250 let toml_schema = TomlSchema::from_file(toml_path)
251 .context(format!("Failed to load TOML from {toml_path}"))?;
252
253 toml_schema.validate()?;
254
255 let types_value = if toml_schema.includes.is_empty() {
257 serde_json::json!({
259 "types": [],
260 "queries": [],
261 "mutations": []
262 })
263 } else {
264 let resolved = toml_schema
265 .includes
266 .resolve_globs()
267 .context("Failed to resolve glob patterns in schema.includes")?;
268
269 let type_files: Vec<std::path::PathBuf> = resolved.types;
271 let mut merged_types = if type_files.is_empty() {
272 serde_json::json!({
273 "types": [],
274 "queries": [],
275 "mutations": []
276 })
277 } else {
278 crate::schema::MultiFileLoader::load_from_paths(&type_files)
279 .context("Failed to load type files")?
280 };
281
282 if !resolved.queries.is_empty() {
284 let query_value =
285 crate::schema::MultiFileLoader::load_from_paths(&resolved.queries)
286 .context("Failed to load query files")?;
287 if let Some(Value::Array(queries)) = query_value.get("queries") {
288 if let Some(Value::Array(existing_queries)) = merged_types.get_mut("queries") {
289 existing_queries.extend(queries.clone());
290 }
291 }
292 }
293
294 if !resolved.mutations.is_empty() {
296 let mutation_value =
297 crate::schema::MultiFileLoader::load_from_paths(&resolved.mutations)
298 .context("Failed to load mutation files")?;
299 if let Some(Value::Array(mutations)) = mutation_value.get("mutations") {
300 if let Some(Value::Array(existing_mutations)) =
301 merged_types.get_mut("mutations")
302 {
303 existing_mutations.extend(mutations.clone());
304 }
305 }
306 }
307
308 merged_types
309 };
310
311 Self::merge_values(&types_value, &toml_schema)
313 }
314
315 fn merge_values(types_value: &Value, toml_schema: &TomlSchema) -> Result<IntermediateSchema> {
317 let mut types_array: Vec<Value> = Vec::new();
320 let mut queries_array: Vec<Value> = Vec::new();
321 let mut mutations_array: Vec<Value> = Vec::new();
322
323 if let Some(types_obj) = types_value.get("types") {
325 match types_obj {
326 Value::Array(types_list) => {
328 for type_item in types_list {
329 if let Some(type_name) = type_item.get("name").and_then(|v| v.as_str()) {
330 let mut enriched_type = type_item.clone();
331
332 if let Some(toml_type) = toml_schema.types.get(type_name) {
334 enriched_type["sql_source"] = json!(toml_type.sql_source);
335 if let Some(desc) = &toml_type.description {
336 enriched_type["description"] = json!(desc);
337 }
338 }
339
340 types_array.push(enriched_type);
341 }
342 }
343 },
344 Value::Object(types_map) => {
346 for (type_name, type_value) in types_map {
347 let mut enriched_type = type_value.clone();
348 enriched_type["name"] = json!(type_name);
349
350 if let Some(Value::Object(fields_map)) = enriched_type.get("fields") {
352 let fields_array: Vec<Value> = fields_map
353 .iter()
354 .map(|(field_name, field_value)| {
355 let mut field = field_value.clone();
356 field["name"] = json!(field_name);
357 field
358 })
359 .collect();
360 enriched_type["fields"] = json!(fields_array);
361 }
362
363 if let Some(toml_type) = toml_schema.types.get(type_name) {
364 enriched_type["sql_source"] = json!(toml_type.sql_source);
365 if let Some(desc) = &toml_type.description {
366 enriched_type["description"] = json!(desc);
367 }
368 }
369
370 types_array.push(enriched_type);
371 }
372 },
373 _ => {},
374 }
375 }
376
377 let existing_type_names: std::collections::HashSet<_> = types_array
379 .iter()
380 .filter_map(|t| {
381 t.get("name").and_then(|v| v.as_str()).map(std::string::ToString::to_string)
382 })
383 .collect();
384
385 for (type_name, toml_type) in &toml_schema.types {
386 if !existing_type_names.contains(type_name) {
387 types_array.push(json!({
388 "name": type_name,
389 "sql_source": toml_type.sql_source,
390 "description": toml_type.description,
391 "fields": toml_type.fields.iter().map(|(fname, fdef)| json!({
392 "name": fname,
393 "type": fdef.field_type,
394 "nullable": fdef.nullable,
395 "description": fdef.description,
396 })).collect::<Vec<_>>(),
397 }));
398 }
399 }
400
401 if let Some(Value::Array(queries_list)) = types_value.get("queries") {
403 queries_array.clone_from(queries_list);
404 }
405
406 for (query_name, toml_query) in &toml_schema.queries {
408 queries_array.push(json!({
409 "name": query_name,
410 "return_type": toml_query.return_type,
411 "return_array": toml_query.return_array,
412 "sql_source": toml_query.sql_source,
413 "description": toml_query.description,
414 "args": toml_query.args.iter().map(|arg| json!({
415 "name": arg.name,
416 "type": arg.arg_type,
417 "required": arg.required,
418 "default": arg.default,
419 "description": arg.description,
420 })).collect::<Vec<_>>(),
421 }));
422 }
423
424 if let Some(Value::Array(mutations_list)) = types_value.get("mutations") {
426 mutations_array.clone_from(mutations_list);
427 }
428
429 for (mutation_name, toml_mutation) in &toml_schema.mutations {
431 mutations_array.push(json!({
432 "name": mutation_name,
433 "return_type": toml_mutation.return_type,
434 "sql_source": toml_mutation.sql_source,
435 "operation": toml_mutation.operation,
436 "description": toml_mutation.description,
437 "args": toml_mutation.args.iter().map(|arg| json!({
438 "name": arg.name,
439 "type": arg.arg_type,
440 "required": arg.required,
441 "default": arg.default,
442 "description": arg.description,
443 })).collect::<Vec<_>>(),
444 }));
445 }
446
447 let mut merged = serde_json::json!({
449 "version": "2.0.0",
450 "types": types_array,
451 "queries": queries_array,
452 "mutations": mutations_array,
453 });
454
455 merged["security"] = json!({
457 "default_policy": toml_schema.security.default_policy,
458 "rules": toml_schema.security.rules.iter().map(|r| json!({
459 "name": r.name,
460 "rule": r.rule,
461 "description": r.description,
462 "cacheable": r.cacheable,
463 "cache_ttl_seconds": r.cache_ttl_seconds,
464 })).collect::<Vec<_>>(),
465 "policies": toml_schema.security.policies.iter().map(|p| json!({
466 "name": p.name,
467 "type": p.policy_type,
468 "rule": p.rule,
469 "roles": p.roles,
470 "strategy": p.strategy,
471 "attributes": p.attributes,
472 "description": p.description,
473 "cache_ttl_seconds": p.cache_ttl_seconds,
474 })).collect::<Vec<_>>(),
475 "field_auth": toml_schema.security.field_auth.iter().map(|fa| json!({
476 "type_name": fa.type_name,
477 "field_name": fa.field_name,
478 "policy": fa.policy,
479 })).collect::<Vec<_>>(),
480 "enterprise": json!({
481 "rate_limiting_enabled": toml_schema.security.enterprise.rate_limiting_enabled,
482 "auth_endpoint_max_requests": toml_schema.security.enterprise.auth_endpoint_max_requests,
483 "auth_endpoint_window_seconds": toml_schema.security.enterprise.auth_endpoint_window_seconds,
484 "audit_logging_enabled": toml_schema.security.enterprise.audit_logging_enabled,
485 "audit_log_backend": toml_schema.security.enterprise.audit_log_backend,
486 "audit_retention_days": toml_schema.security.enterprise.audit_retention_days,
487 "error_sanitization": toml_schema.security.enterprise.error_sanitization,
488 "hide_implementation_details": toml_schema.security.enterprise.hide_implementation_details,
489 "constant_time_comparison": toml_schema.security.enterprise.constant_time_comparison,
490 "pkce_enabled": toml_schema.security.enterprise.pkce_enabled,
491 }),
492 });
493
494 serde_json::from_value::<IntermediateSchema>(merged)
500 .context("Failed to convert merged schema to IntermediateSchema")
501 }
502}
503
504#[cfg(test)]
505mod tests {
506 use std::fs;
507
508 use tempfile::TempDir;
509
510 use super::*;
511
512 #[test]
513 fn test_merge_toml_only() {
514 let toml_content = r#"
515[schema]
516name = "test"
517version = "1.0.0"
518database_target = "postgresql"
519
520[database]
521url = "postgresql://localhost/test"
522
523[types.User]
524sql_source = "v_user"
525
526[types.User.fields.id]
527type = "ID"
528
529[types.User.fields.name]
530type = "String"
531
532[queries.users]
533return_type = "User"
534return_array = true
535sql_source = "v_user"
536"#;
537
538 let temp_path = "/tmp/test_fraiseql.toml";
540 std::fs::write(temp_path, toml_content).unwrap();
541
542 let result = SchemaMerger::merge_toml_only(temp_path);
544 assert!(result.is_ok());
545
546 let _ = std::fs::remove_file(temp_path);
548 }
549
550 #[test]
551 fn test_merge_with_includes() -> Result<()> {
552 let temp_dir = TempDir::new()?;
553
554 let user_types = serde_json::json!({
556 "types": [{"name": "User", "fields": []}],
557 "queries": [],
558 "mutations": []
559 });
560 fs::write(temp_dir.path().join("user.json"), user_types.to_string())?;
561
562 let post_types = serde_json::json!({
563 "types": [{"name": "Post", "fields": []}],
564 "queries": [],
565 "mutations": []
566 });
567 fs::write(temp_dir.path().join("post.json"), post_types.to_string())?;
568
569 let toml_content = format!(
571 r#"
572[schema]
573name = "test"
574version = "1.0.0"
575database_target = "postgresql"
576
577[database]
578url = "postgresql://localhost/test"
579
580[includes]
581types = ["{}/*.json"]
582queries = []
583mutations = []
584"#,
585 temp_dir.path().to_string_lossy()
586 );
587
588 let toml_path = temp_dir.path().join("fraiseql.toml");
589 fs::write(&toml_path, toml_content)?;
590
591 let result = SchemaMerger::merge_with_includes(toml_path.to_str().unwrap());
593 assert!(result.is_ok());
594
595 let schema = result?;
596 assert_eq!(schema.types.len(), 2);
597
598 Ok(())
599 }
600
601 #[test]
602 fn test_merge_with_includes_missing_files() -> Result<()> {
603 let temp_dir = TempDir::new()?;
604
605 let toml_content = r#"
606[schema]
607name = "test"
608version = "1.0.0"
609database_target = "postgresql"
610
611[database]
612url = "postgresql://localhost/test"
613
614[includes]
615types = ["/nonexistent/path/*.json"]
616queries = []
617mutations = []
618"#;
619
620 let toml_path = temp_dir.path().join("fraiseql.toml");
621 fs::write(&toml_path, toml_content)?;
622
623 let result = SchemaMerger::merge_with_includes(toml_path.to_str().unwrap());
625 assert!(result.is_ok());
626
627 let schema = result?;
628 assert_eq!(schema.types.len(), 0);
629
630 Ok(())
631 }
632
633 #[test]
634 fn test_merge_from_domains() -> Result<()> {
635 let temp_dir = TempDir::new()?;
636 let schema_dir = temp_dir.path().join("schema");
637 fs::create_dir(&schema_dir)?;
638
639 fs::create_dir(schema_dir.join("auth"))?;
641 fs::create_dir(schema_dir.join("products"))?;
642
643 let auth_types = serde_json::json!({
644 "types": [{"name": "User", "fields": []}],
645 "queries": [{"name": "getUser", "return_type": "User"}],
646 "mutations": []
647 });
648 fs::write(schema_dir.join("auth/types.json"), auth_types.to_string())?;
649
650 let product_types = serde_json::json!({
651 "types": [{"name": "Product", "fields": []}],
652 "queries": [{"name": "getProduct", "return_type": "Product"}],
653 "mutations": []
654 });
655 fs::write(schema_dir.join("products/types.json"), product_types.to_string())?;
656
657 let schema_dir_str = schema_dir.to_string_lossy().to_string();
659 let toml_content = format!(
660 r#"
661[schema]
662name = "test"
663version = "1.0.0"
664database_target = "postgresql"
665
666[database]
667url = "postgresql://localhost/test"
668
669[domain_discovery]
670enabled = true
671root_dir = "{schema_dir_str}"
672"#
673 );
674
675 let toml_path = temp_dir.path().join("fraiseql.toml");
676 fs::write(&toml_path, toml_content)?;
677
678 let result = SchemaMerger::merge_from_domains(toml_path.to_str().unwrap());
680
681 assert!(result.is_ok());
682 let schema = result?;
683
684 assert_eq!(schema.types.len(), 2);
686 assert_eq!(schema.queries.len(), 2);
688
689 Ok(())
690 }
691
692 #[test]
693 fn test_merge_from_domains_alphabetical_order() -> Result<()> {
694 let temp_dir = TempDir::new()?;
695 let schema_dir = temp_dir.path().join("schema");
696 fs::create_dir(&schema_dir)?;
697
698 fs::create_dir(schema_dir.join("zebra"))?;
700 fs::create_dir(schema_dir.join("alpha"))?;
701 fs::create_dir(schema_dir.join("middle"))?;
702
703 for domain in &["zebra", "alpha", "middle"] {
704 let types = serde_json::json!({
705 "types": [{"name": domain.to_uppercase(), "fields": []}],
706 "queries": [],
707 "mutations": []
708 });
709 fs::write(schema_dir.join(format!("{domain}/types.json")), types.to_string())?;
710 }
711
712 let schema_dir_str = schema_dir.to_string_lossy().to_string();
713 let toml_content = format!(
714 r#"
715[schema]
716name = "test"
717version = "1.0.0"
718database_target = "postgresql"
719
720[database]
721url = "postgresql://localhost/test"
722
723[domain_discovery]
724enabled = true
725root_dir = "{schema_dir_str}"
726"#
727 );
728
729 let toml_path = temp_dir.path().join("fraiseql.toml");
730 fs::write(&toml_path, toml_content)?;
731
732 let result = SchemaMerger::merge_from_domains(toml_path.to_str().unwrap());
733
734 assert!(result.is_ok());
735 let schema = result?;
736
737 let type_names: Vec<String> = schema.types.iter().map(|t| t.name.clone()).collect();
739
740 assert_eq!(type_names[0], "ALPHA");
741 assert_eq!(type_names[1], "MIDDLE");
742 assert_eq!(type_names[2], "ZEBRA");
743
744 Ok(())
745 }
746}