use crate::{debug, schema::core::SchemaInferenceConfig};
use serde_json::Value;
fn normalise_nullable(v: &Value) -> &Value {
let mut current = v;
loop {
if let Some(arr) = current.as_array() {
if arr.len() == 2 && arr.contains(&Value::String("null".to_string())) {
current = arr
.iter()
.find(|x| *x != &Value::String("null".to_string()))
.unwrap();
continue;
}
}
return current;
}
}
fn schema_type_str(schema: &Value) -> String {
if let Some(t) = schema.get("type").and_then(|v| v.as_str()) {
return t.to_string();
}
if let Some(arr) = schema.as_array() {
for v in arr {
if v != "null" {
if let Some(t) = v.get("type").and_then(|x| x.as_str()) {
return t.to_string();
}
}
}
}
"unknown".to_string()
}
pub(crate) fn check_unifiable_schemas(
schemas: &[Value],
path: &str,
config: &SchemaInferenceConfig,
) -> Option<Value> {
if schemas.is_empty() {
debug!(config, "{path}: failed (empty schema list)");
return None;
}
if !schemas
.iter()
.all(|s| s.get("type") == Some(&Value::String("object".into())))
{
return None;
}
let mut all_fields = ordermap::OrderMap::new();
let mut field_counts = std::collections::HashMap::new();
let schemas_compatible = |existing: &Value, new: &Value| -> Option<Value> {
if existing == new {
return Some(existing.clone());
}
let extract_nullable_info = |schema: &Value| -> (bool, Value) {
if let Some(Value::Array(type_arr)) = schema.get("type") {
if type_arr.len() == 2 && type_arr.contains(&Value::String("null".into())) {
let non_null_type = type_arr
.iter()
.find(|t| *t != &Value::String("null".into()))
.unwrap();
(true, serde_json::json!({"type": non_null_type}))
} else {
(false, schema.clone())
}
} else {
(false, schema.clone())
}
};
let (existing_nullable, existing_inner) = extract_nullable_info(existing);
let (new_nullable, new_inner) = extract_nullable_info(new);
if existing_inner == new_inner {
if existing_nullable || new_nullable {
let inner_type = existing_inner.get("type").unwrap();
return Some(serde_json::json!({
"type": ["null", inner_type]
}));
} else {
return Some(existing_inner);
}
}
None
};
for (i, schema) in schemas.iter().enumerate() {
if let Some(Value::Object(props)) = schema.get("properties") {
for (field_name, field_schema) in props {
*field_counts.entry(field_name.clone()).or_insert(0) += 1;
match all_fields.entry(field_name.clone()) {
ordermap::map::Entry::Vacant(e) => {
debug!(config, "Schema[{i}] introduces new field `{field_name}`");
e.insert(normalise_nullable(field_schema).clone());
}
ordermap::map::Entry::Occupied(mut e) => {
let existing = normalise_nullable(e.get()).clone();
let new = normalise_nullable(field_schema).clone();
if let Some(compatible_schema) = schemas_compatible(&existing, &new) {
debug!(config, "Field `{field_name}` compatible (nullable/non-nullable unification)");
e.insert(compatible_schema);
} else if existing.get("type") == Some(&Value::String("object".into()))
&& new.get("type") == Some(&Value::String("object".into()))
{
debug!(config,
"Field `{field_name}` has conflicting object schemas, attempting recursive unify"
);
if let Some(unified) = check_unifiable_schemas(
&[existing.clone(), new.clone()],
&format!("{path}.{}", field_name),
config,
) {
debug!(
config,
"Field `{field_name}` unified successfully after recursion"
);
e.insert(unified);
} else {
debug!(config, "{path}.{}: failed to unify", field_name);
return None;
}
} else {
if config.wrap_scalars {
let existing_is_obj =
existing.get("type") == Some(&Value::String("object".into()));
let new_is_obj = field_schema.get("type")
== Some(&Value::String("object".into()));
if existing_is_obj ^ new_is_obj {
let (obj_schema, scalar_schema, scalar_side) =
if existing_is_obj {
(existing.clone(), field_schema.clone(), "new")
} else {
(field_schema.clone(), existing.clone(), "existing")
};
let type_suffix = schema_type_str(&scalar_schema);
let wrapped_key = format!("{}__{}", field_name, type_suffix);
debug!(config,
"Promoting scalar on {} side: wrapping into object under key `{}`",
scalar_side, wrapped_key
);
let mut wrapped_props = serde_json::Map::new();
wrapped_props.insert(wrapped_key, scalar_schema.clone());
let promoted = serde_json::json!({
"type": "object",
"properties": wrapped_props
});
if let Some(unified) = check_unifiable_schemas(
&[obj_schema.clone(), promoted.clone()],
&format!("{path}.{}", field_name),
config,
) {
debug!(config,
"Field `{field_name}` unified successfully after scalar promotion"
);
e.insert(unified);
continue;
}
}
}
debug!(config,
"{path}.{field_name}: incompatible types:\n existing={:#?}\n new={:#?}",
existing, field_schema
);
return None; }
}
}
}
} else {
debug!(config, "Schema[{i}] has no properties object");
return None;
}
}
let total_schemas = schemas.len();
let mut unified_properties = serde_json::Map::new();
for (field_name, field_type) in &all_fields {
let count = field_counts.get(field_name).unwrap_or(&0);
if *count == total_schemas {
debug!(
config,
"Field `{field_name}` present in all schemas → keeping non-nullable"
);
unified_properties.insert(field_name.clone(), field_type.clone());
}
}
for (field_name, field_type) in &all_fields {
let count = field_counts.get(field_name).unwrap_or(&0);
if *count < total_schemas {
debug!(
config,
"Field `{field_name}` missing in {}/{} schemas → making nullable",
total_schemas - count,
total_schemas
);
if let Some(type_str) = field_type.get("type").and_then(|t| t.as_str()) {
let mut nullable_field = field_type.clone();
nullable_field["type"] = serde_json::json!(["null", type_str]);
unified_properties.insert(field_name.clone(), nullable_field);
} else {
unified_properties
.insert(field_name.clone(), serde_json::json!(["null", field_type]));
}
}
}
debug!(config, "Schemas unified successfully");
Some(serde_json::json!({
"type": "object",
"properties": unified_properties
}))
}