use crate::graph::schema::nested::{NestedCtx, validate_nested};
use crate::graph::schema::{JsonType, PropertySchema};
use crate::value::uuid::parse_canonical;
use crate::value::{ElementProperty, Number};
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct CoerceFailure {
pub(crate) reason: &'static str,
pub(crate) value: ElementProperty,
}
pub(crate) const NOT_A_CANONICAL_UUID: &str = "not a canonical UUID (8-4-4-4-12 lowercase hex)";
pub(crate) const INEXACT_FLOAT64: &str = "integer cannot be represented exactly as float64";
const MAX_EXACT_F64_INTEGER: u64 = 1 << 53;
pub(crate) fn coerce_to_declared(
value: ElementProperty,
node: &PropertySchema,
) -> Result<ElementProperty, CoerceFailure> {
if matches!(value, ElementProperty::Null) {
return Ok(value);
}
if node.types.is_none()
&& let Some(members) = &node.any_of
{
return Ok(coerce_to_union(value, members));
}
let Some(types) = &node.types else {
return Ok(value);
};
match value {
ElementProperty::String(text) if node.is_uuid_string() => match parse_canonical(&text) {
Some(uuid) => Ok(ElementProperty::Uuid(uuid)),
None => Err(CoerceFailure {
reason: NOT_A_CANONICAL_UUID,
value: ElementProperty::String(text),
}),
},
ElementProperty::Number(number)
if types.contains(&JsonType::Number) && !types.contains(&JsonType::Integer) =>
{
match number.as_i64() {
Some(integer) => int_to_float64(integer),
None => Ok(ElementProperty::Number(number)),
}
}
ElementProperty::Array(items) => match &node.items {
Some(item_node) => items
.into_iter()
.map(|item| coerce_to_declared(item, item_node))
.collect::<Result<Vec<_>, _>>()
.map(ElementProperty::Array),
None => Ok(ElementProperty::Array(items)),
},
ElementProperty::Object(mut map) => {
for (key, slot) in map.iter_mut() {
if let Some(field) = node.properties.get(key.as_str()) {
let incoming = std::mem::replace(slot, ElementProperty::Null);
*slot = coerce_to_declared(incoming, field)?;
}
}
Ok(ElementProperty::Object(map))
}
other => Ok(other),
}
}
fn int_to_float64(integer: i64) -> Result<ElementProperty, CoerceFailure> {
let float = integer as f64;
if integer.unsigned_abs() <= MAX_EXACT_F64_INTEGER && float as i64 == integer {
Ok(ElementProperty::Number(Number::from_f64(float)))
} else {
Err(CoerceFailure {
reason: INEXACT_FLOAT64,
value: ElementProperty::Number(Number::from_i64(integer)),
})
}
}
pub(crate) fn conforms_to(node: &PropertySchema, value: &ElementProperty) -> bool {
let ctx = NestedCtx {
element_type: "vertex",
label: "",
additional_default: true,
full: false,
};
validate_nested(&ctx, &mut Vec::new(), node, value).is_ok()
}
fn coerce_to_union(value: ElementProperty, members: &[PropertySchema]) -> ElementProperty {
if members.iter().any(|member| conforms_to(member, &value)) {
return value;
}
for member in members {
if let Ok(coerced) = coerce_to_declared(value.clone(), member)
&& conforms_to(member, &coerced)
{
return coerced;
}
}
value
}
#[cfg(test)]
mod tests {
use super::*;
const CANONICAL: &str = "550e8400-e29b-41d4-a716-446655440000";
const CANONICAL_BITS: u128 = 0x550e8400_e29b_41d4_a716_446655440000;
fn int(value: i64) -> ElementProperty {
ElementProperty::Number(Number::from_i64(value))
}
fn float(value: f64) -> ElementProperty {
ElementProperty::Number(Number::from_f64(value))
}
fn text(value: &str) -> ElementProperty {
ElementProperty::from(value)
}
fn is_float(value: &ElementProperty) -> bool {
matches!(value, ElementProperty::Number(n) if n.is_f64())
}
#[test]
fn null_passes_unchanged_whatever_the_declaration() {
for declared in [
PropertySchema::uuid(),
PropertySchema::number(),
PropertySchema::string(),
PropertySchema::integer(),
] {
assert_eq!(
coerce_to_declared(ElementProperty::Null, &declared),
Ok(ElementProperty::Null)
);
}
}
#[test]
fn canonical_string_becomes_uuid() {
assert_eq!(
coerce_to_declared(text(CANONICAL), &PropertySchema::uuid()),
Ok(ElementProperty::Uuid(CANONICAL_BITS))
);
}
#[test]
fn non_canonical_string_fails_for_uuid() {
for bad in [
"not-a-uuid",
"550E8400-E29B-41D4-A716-446655440000",
"{550e8400-e29b-41d4-a716-446655440000}",
"",
] {
assert_eq!(
coerce_to_declared(text(bad), &PropertySchema::uuid()),
Err(CoerceFailure {
reason: NOT_A_CANONICAL_UUID,
value: text(bad),
}),
"{bad:?} must not coerce"
);
}
}
#[test]
fn exact_integer_becomes_float64() {
let coerced = coerce_to_declared(int(3), &PropertySchema::number()).unwrap();
assert!(is_float(&coerced));
assert_eq!(coerced, float(3.0));
let limit = 1_i64 << 53;
assert_eq!(
coerce_to_declared(int(-limit), &PropertySchema::number()),
Ok(float(-limit as f64))
);
}
#[test]
fn integer_stays_where_integer_is_admitted() {
let both = PropertySchema {
types: Some(vec![JsonType::Integer, JsonType::Number]),
..PropertySchema::default()
};
assert_eq!(coerce_to_declared(int(3), &both), Ok(int(3)));
}
#[test]
fn inexact_integer_fails_for_float64() {
for too_big in [(1_i64 << 53) + 1, i64::MAX, i64::MIN] {
assert_eq!(
coerce_to_declared(int(too_big), &PropertySchema::number()),
Err(CoerceFailure {
reason: INEXACT_FLOAT64,
value: int(too_big),
}),
"{too_big} must not coerce"
);
}
}
#[test]
fn arrays_coerce_item_by_item() {
let declared = PropertySchema::array(PropertySchema::uuid());
assert_eq!(
coerce_to_declared(ElementProperty::Array(vec![text(CANONICAL)]), &declared),
Ok(ElementProperty::Array(vec![ElementProperty::Uuid(
CANONICAL_BITS
)]))
);
assert_eq!(
coerce_to_declared(
ElementProperty::Array(vec![text(CANONICAL), text("nope")]),
&declared
),
Err(CoerceFailure {
reason: NOT_A_CANONICAL_UUID,
value: text("nope"),
})
);
}
#[test]
fn objects_coerce_declared_keys_only() {
let declared = PropertySchema::object().with_property("w", PropertySchema::number());
let mut map = crate::value::PropertyMap::default();
map.insert("w", int(2));
map.insert("other", int(5));
let coerced =
coerce_to_declared(ElementProperty::Object(Box::new(map)), &declared).unwrap();
let coerced = coerced.as_object().unwrap();
assert!(is_float(&coerced["w"]));
assert!(matches!(&coerced["other"], ElementProperty::Number(n) if n.is_i64()));
}
#[test]
fn any_of_keeps_a_conforming_value() {
let declared =
PropertySchema::any_of(vec![PropertySchema::uuid(), PropertySchema::string()]);
assert_eq!(
coerce_to_declared(text(CANONICAL), &declared),
Ok(text(CANONICAL))
);
}
#[test]
fn any_of_takes_the_first_member_that_coerces() {
let declared =
PropertySchema::any_of(vec![PropertySchema::integer(), PropertySchema::uuid()]);
assert_eq!(
coerce_to_declared(text(CANONICAL), &declared),
Ok(ElementProperty::Uuid(CANONICAL_BITS))
);
let declared =
PropertySchema::any_of(vec![PropertySchema::boolean(), PropertySchema::number()]);
assert_eq!(coerce_to_declared(int(4), &declared), Ok(float(4.0)));
}
#[test]
fn any_of_leaves_an_unconvertible_value_unchanged() {
let declared =
PropertySchema::any_of(vec![PropertySchema::uuid(), PropertySchema::integer()]);
assert_eq!(
coerce_to_declared(text("nope"), &declared),
Ok(text("nope"))
);
}
#[test]
fn nothing_else_converts() {
assert_eq!(
coerce_to_declared(
ElementProperty::Uuid(CANONICAL_BITS),
&PropertySchema::string()
),
Ok(ElementProperty::Uuid(CANONICAL_BITS))
);
assert_eq!(
coerce_to_declared(float(3.0), &PropertySchema::integer()),
Ok(float(3.0))
);
assert_eq!(
coerce_to_declared(text("42"), &PropertySchema::integer()),
Ok(text("42"))
);
assert_eq!(
coerce_to_declared(int(1), &PropertySchema::boolean()),
Ok(int(1))
);
}
}