use std::collections::HashSet;
use fraiseql_core::schema::{
CompiledSchema, FieldDefinition, FieldDenyPolicy, FieldType, TypeDefinition, UnionDefinition,
};
use tracing::warn;
const ERROR_TYPE: &str = "MutationError";
pub(super) fn synthesize_mutation_error_unions(schema: &mut CompiledSchema) {
let existing_union_names: HashSet<String> =
schema.unions.iter().map(|u| u.name.clone()).collect();
let existing_type_names: HashSet<String> =
schema.types.iter().map(|t| t.name.to_string()).collect();
let object_type_names: HashSet<String> = schema
.types
.iter()
.filter(|t| !t.is_error)
.map(|t| t.name.to_string())
.collect();
let mut plans: Vec<(usize, String, String, String)> = Vec::new();
for (idx, mutation) in schema.mutations.iter().enumerate() {
let return_type = mutation.return_type.clone();
if existing_union_names.contains(&return_type) {
continue;
}
if !object_type_names.contains(&return_type) {
continue;
}
let union_name = result_union_name(&mutation.name);
plans.push((idx, return_type, union_name, mutation.name.clone()));
}
if plans.is_empty() {
return;
}
if !existing_type_names.contains(ERROR_TYPE) {
schema.types.push(mutation_error_type());
}
let mut created_unions: HashSet<String> = HashSet::new();
for (idx, success_type, union_name, mutation_name) in plans {
if existing_type_names.contains(&union_name) {
warn!(
mutation = %mutation_name,
union = %union_name,
"auto_error_union: a type already uses the result-union name; leaving this \
mutation's return type unchanged",
);
continue;
}
if !existing_union_names.contains(&union_name) && !created_unions.contains(&union_name) {
schema.unions.push(
UnionDefinition::new(union_name.clone())
.with_members(vec![success_type, ERROR_TYPE.to_string()])
.with_description(format!(
"Result of the {mutation_name} mutation: the success entity or a \
MutationError."
)),
);
created_unions.insert(union_name.clone());
}
schema.mutations[idx].return_type = union_name;
}
}
fn result_union_name(mutation_name: &str) -> String {
let mut chars = mutation_name.chars();
let pascal = match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
};
format!("{pascal}Result")
}
fn mutation_error_type() -> TypeDefinition {
let field = |name: &str, field_type: FieldType, nullable: bool, desc: &str| FieldDefinition {
name: name.into(),
field_type,
nullable,
description: Some(desc.to_string()),
default_value: None,
vector_config: None,
vector_distance: None,
alias: None,
deprecation: None,
requires_scope: None,
on_deny: FieldDenyPolicy::default(),
authorize: false,
encryption: None,
hierarchy: None,
unique: false,
};
TypeDefinition {
name: ERROR_TYPE.into(),
sql_source: String::new().into(), jsonb_column: String::new(),
fields: vec![
field(
"status",
FieldType::String,
false,
"Error class discriminator (e.g. \"not_found\", \"validation\").",
),
field("message", FieldType::String, true, "Human-readable error message."),
field("httpStatus", FieldType::Int, true, "Suggested HTTP status code."),
field(
"errorClass",
FieldType::String,
true,
"Error classification (the same value surfaced as `status`).",
),
],
description: Some(
"Shared mutation error type (auto-synthesized by auto_error_union). Populated from \
the mutation_response composite."
.to_string(),
),
sql_projection_hint: None,
implements: Vec::new(),
requires_role: None,
is_error: true,
relay: false,
internal: false,
embedded: false,
relationships: Vec::new(),
inject_params: indexmap::IndexMap::new(),
subscription_policy: None,
}
}