use crate::error::SchemaError;
use crate::graph::schema::coerce::coerce_to_declared;
use crate::graph::schema::nested::{self, NestedCtx};
use crate::graph::schema::{GraphSchema, PropertySchema, SchemaMode};
use crate::value::json_path::JsonPathSegment;
use crate::value::{ElementProperty, PropertyMap};
use compact_str::CompactString;
use std::ops::ControlFlow;
pub(super) fn join_labels(labels: &[CompactString]) -> String {
labels
.iter()
.map(|l| l.as_str())
.collect::<Vec<_>>()
.join(":")
}
pub(crate) type LabelRoot<'a> = (&'a str, &'a PropertySchema);
pub(crate) struct SchemaValidator;
impl SchemaValidator {
pub fn validate_vertex(
schema: &GraphSchema,
labels: &[CompactString],
properties: &mut PropertyMap,
) -> Result<(), SchemaError> {
let Some(declarations) = Self::vertex_declarations(schema, labels)? else {
return Ok(());
};
Self::coerce_properties(&declarations, "vertex", properties)?;
Self::check_properties(&declarations, "vertex", properties, schema.mode)
}
pub(crate) fn check_vertex(
schema: &GraphSchema,
labels: &[CompactString],
properties: &PropertyMap,
) -> Result<(), SchemaError> {
let Some(declarations) = Self::vertex_declarations(schema, labels)? else {
return Ok(());
};
Self::check_properties(&declarations, "vertex", properties, schema.mode)
}
pub fn validate_edge(
schema: &GraphSchema,
label: Option<&str>,
source_labels: &[CompactString],
target_labels: &[CompactString],
properties: &mut PropertyMap,
) -> Result<(), SchemaError> {
let Some(declarations) = Self::edge_declarations(schema, label)? else {
return Ok(());
};
Self::check_topology(schema, label, source_labels, target_labels)?;
Self::coerce_properties(&declarations, "edge", properties)?;
Self::check_properties(&declarations, "edge", properties, schema.mode)
}
pub(crate) fn check_edge(
schema: &GraphSchema,
label: Option<&str>,
source_labels: &[CompactString],
target_labels: &[CompactString],
properties: &PropertyMap,
) -> Result<(), SchemaError> {
let Some(declarations) = Self::edge_declarations(schema, label)? else {
return Ok(());
};
Self::check_topology(schema, label, source_labels, target_labels)?;
Self::check_properties(&declarations, "edge", properties, schema.mode)
}
pub fn validate_element_property(
schema: &GraphSchema,
element_type: &'static str,
labels: &[CompactString],
prop_name: &str,
value: ElementProperty,
) -> Result<ElementProperty, SchemaError> {
let Some(declarations) = Self::declarations(schema, element_type, labels)? else {
return Ok(value);
};
let value = Self::coerce_property(&declarations, element_type, prop_name, value)?;
Self::check_property(
&declarations,
element_type,
prop_name,
&value,
schema.mode.default_additional_properties(),
)?;
Ok(value)
}
pub(crate) fn coerce_path_leaf(
schema: &GraphSchema,
element_type: &'static str,
labels: &[CompactString],
path: &crate::value::JsonPath,
value: ElementProperty,
) -> Result<ElementProperty, SchemaError> {
let Some(declarations) = Self::declarations(schema, element_type, labels)? else {
return Ok(value);
};
let segments = path.segments();
let Some(JsonPathSegment::Key(name)) = segments.first() else {
return Ok(value);
};
let Some((label, node)) = declarations
.iter()
.find_map(|(lbl, root)| root.properties.get(name.as_str()).map(|node| (*lbl, node)))
else {
return Ok(value);
};
let ctx = NestedCtx {
element_type,
label,
additional_default: schema.mode.default_additional_properties(),
full: true,
};
let Some(declared) = nested::declared_node_at(&ctx, segments, node, 1, &value)? else {
return Ok(value);
};
coerce_to_declared(value, declared).map_err(|failure| SchemaError::CoercionFailed {
element_type,
label: label.to_string(),
property_name: path.to_string(),
declared_type: declared.non_null_type_name(),
actual_value: crate::traversal::step::barrier_steps::numeric::preview(&failure.value)
.into_boxed_str(),
reason: failure.reason,
})
}
pub(crate) fn check_element_property(
schema: &GraphSchema,
element_type: &'static str,
labels: &[CompactString],
prop_name: &str,
value: &ElementProperty,
) -> Result<(), SchemaError> {
let Some(declarations) = Self::declarations(schema, element_type, labels)? else {
return Ok(());
};
Self::check_property(
&declarations,
element_type,
prop_name,
value,
schema.mode.default_additional_properties(),
)
}
fn declarations<'a>(
schema: &'a GraphSchema,
element_type: &'static str,
labels: &'a [CompactString],
) -> Result<Option<Vec<LabelRoot<'a>>>, SchemaError> {
if element_type == "vertex" {
Self::vertex_declarations(schema, labels)
} else {
Self::edge_declarations(schema, labels.first().map(|l| l.as_str()))
}
}
pub(crate) fn vertex_declarations<'a>(
schema: &'a GraphSchema,
labels: &'a [CompactString],
) -> Result<Option<Vec<LabelRoot<'a>>>, SchemaError> {
match schema.mode {
SchemaMode::None => Ok(None),
SchemaMode::Open => {
if labels.is_empty() {
return Ok(None);
}
Ok(Some(
labels
.iter()
.filter_map(|lbl| {
schema
.vertices
.get(lbl.as_str())
.map(|v| (lbl.as_str(), &v.schema))
})
.collect(),
))
}
SchemaMode::Closed => {
if labels.is_empty() {
return Err(SchemaError::UnknownVertexLabel {
label: "<unlabeled>".to_string(),
allowed: schema.vertices.keys().cloned().collect(),
});
}
let mut declarations = Vec::with_capacity(labels.len());
for lbl in labels {
let vertex = schema.vertices.get(lbl.as_str()).ok_or_else(|| {
SchemaError::UnknownVertexLabel {
label: lbl.to_string(),
allowed: schema.vertices.keys().cloned().collect(),
}
})?;
declarations.push((lbl.as_str(), &vertex.schema));
}
Ok(Some(declarations))
}
}
}
pub(crate) fn edge_declarations<'a>(
schema: &'a GraphSchema,
label: Option<&'a str>,
) -> Result<Option<Vec<LabelRoot<'a>>>, SchemaError> {
match schema.mode {
SchemaMode::None => Ok(None),
SchemaMode::Open => {
let Some(lbl) = label else {
return Ok(None);
};
Ok(Some(
schema
.edges
.get(lbl)
.map(|e| (lbl, &e.schema))
.into_iter()
.collect(),
))
}
SchemaMode::Closed => {
let lbl = match label {
Some(l) if !l.is_empty() => l,
_ => {
return Err(SchemaError::UnknownEdgeLabel {
label: "<unlabeled>".to_string(),
allowed: schema.edges.keys().cloned().collect(),
});
}
};
let edge = schema
.edges
.get(lbl)
.ok_or_else(|| SchemaError::UnknownEdgeLabel {
label: lbl.to_string(),
allowed: schema.edges.keys().cloned().collect(),
})?;
Ok(Some(vec![(lbl, &edge.schema)]))
}
}
}
pub(crate) fn check_topology(
schema: &GraphSchema,
label: Option<&str>,
source_labels: &[CompactString],
target_labels: &[CompactString],
) -> Result<(), SchemaError> {
if schema.mode != SchemaMode::Closed {
return Ok(());
}
let Some(lbl) = label else {
return Ok(());
};
let Some(edge) = schema.edges.get(lbl) else {
return Ok(());
};
if edge.connections.is_empty() {
return Ok(());
}
let matches_end = |declared: &str, labels: &[CompactString]| {
declared == "*" || labels.iter().any(|l| l.as_str() == declared)
};
let matching = edge
.connections
.iter()
.any(|c| matches_end(&c.from, source_labels) && matches_end(&c.to, target_labels));
if matching {
return Ok(());
}
Err(SchemaError::InvalidEdgeTopology {
source_label: join_labels(source_labels),
edge_label: lbl.to_string(),
target_label: join_labels(target_labels),
allowed: edge
.connections
.iter()
.map(|c| format!("(:{})-[:{lbl}]->(:{})", c.from, c.to))
.collect(),
})
}
fn coerce_properties(
declarations: &[LabelRoot<'_>],
element_type: &'static str,
properties: &mut PropertyMap,
) -> Result<(), SchemaError> {
for (prop_name, slot) in properties.iter_mut() {
let incoming = std::mem::replace(slot, ElementProperty::Null);
*slot = Self::coerce_property(declarations, element_type, prop_name, incoming)?;
}
Ok(())
}
fn coerce_property(
declarations: &[LabelRoot<'_>],
element_type: &'static str,
prop_name: &str,
value: ElementProperty,
) -> Result<ElementProperty, SchemaError> {
let declaring = declarations
.iter()
.find_map(|(lbl, root)| root.properties.get(prop_name).map(|node| (*lbl, node)));
let Some((lbl, node)) = declaring else {
return Ok(value);
};
coerce_to_declared(value, node).map_err(|failure| SchemaError::CoercionFailed {
element_type,
label: lbl.to_string(),
property_name: prop_name.to_string(),
declared_type: node.non_null_type_name(),
actual_value: crate::traversal::step::barrier_steps::numeric::preview(&failure.value)
.into_boxed_str(),
reason: failure.reason,
})
}
fn check_properties(
declarations: &[LabelRoot<'_>],
element_type: &'static str,
properties: &PropertyMap,
mode: SchemaMode,
) -> Result<(), SchemaError> {
let additional_default = mode.default_additional_properties();
for (prop_name, prop_value) in properties {
Self::check_property(
declarations,
element_type,
prop_name,
prop_value,
additional_default,
)?;
}
if let Some((label, name)) = Self::missing_required(declarations, properties).next() {
return Err(SchemaError::MissingRequiredProperty {
element_type,
label: label.to_string(),
property_name: name.to_string(),
});
}
Ok(())
}
pub(crate) fn missing_required<'a>(
declarations: &'a [LabelRoot<'a>],
properties: &'a PropertyMap,
) -> impl Iterator<Item = (&'a str, &'a str)> + 'a {
declarations.iter().flat_map(move |(label, root)| {
root.required
.iter()
.filter(move |name| !properties.contains_key(name.as_str()))
.map(move |name| (*label, name.as_str()))
})
}
pub(crate) fn check_removal(
schema: &GraphSchema,
element_type: &'static str,
labels: &[CompactString],
prop_name: &str,
) -> Result<(), SchemaError> {
let declarations = Self::declarations(schema, element_type, labels).unwrap_or(None);
let Some(declarations) = declarations else {
return Ok(());
};
for (lbl, root) in &declarations {
if root.is_required(prop_name) {
return Err(SchemaError::RequiredPropertyRemoval {
element_type,
label: lbl.to_string(),
property_name: prop_name.to_string(),
});
}
}
Ok(())
}
pub(crate) fn check_property(
declarations: &[LabelRoot<'_>],
element_type: &'static str,
prop_name: &str,
value: &ElementProperty,
additional_default: bool,
) -> Result<(), SchemaError> {
let mut sink = nested::FailFast::default();
let _ = Self::walk_property(
declarations,
element_type,
prop_name,
value,
additional_default,
&mut sink,
);
sink.into_result()
}
pub(crate) fn walk_property<S: nested::Sink>(
declarations: &[LabelRoot<'_>],
element_type: &'static str,
prop_name: &str,
value: &ElementProperty,
additional_default: bool,
sink: &mut S,
) -> ControlFlow<()> {
let mut declared = false;
for (lbl, root) in declarations {
if let Some(node) = root.properties.get(prop_name) {
declared = true;
let ctx = NestedCtx {
element_type,
label: lbl,
additional_default,
full: true,
};
let mut path = vec![JsonPathSegment::Key(prop_name.into())];
nested::walk(&ctx, &mut path, node, value, sink)?;
}
}
if !declared
&& !declarations.is_empty()
&& !declarations
.iter()
.any(|(_, root)| root.admits_additional_properties(additional_default))
{
return sink.report(SchemaError::UndeclaredProperty {
element_type,
label: declarations
.first()
.map(|(lbl, _)| lbl.to_string())
.unwrap_or_default(),
property_name: prop_name.to_string(),
});
}
ControlFlow::Continue(())
}
#[cfg(test)]
pub(crate) fn validate_value(
node: &PropertySchema,
mode: SchemaMode,
label: &str,
prop_name: &str,
value: &ElementProperty,
) -> Result<(), SchemaError> {
let ctx = NestedCtx {
element_type: "vertex",
label,
additional_default: mode.default_additional_properties(),
full: true,
};
let mut path = vec![JsonPathSegment::Key(prop_name.into())];
nested::validate_nested(&ctx, &mut path, node, value)
}
}
#[cfg(test)]
mod value_check_tests {
use super::SchemaValidator;
use crate::error::{DiagnosticError, SchemaError};
use crate::graph::schema::{JsonType, PropertySchema, SchemaMode};
use crate::value::{ElementProperty, Number};
fn check(node: &PropertySchema, value: ElementProperty) -> Result<(), SchemaError> {
SchemaValidator::validate_value(node, SchemaMode::Closed, "person", "p", &value)
}
#[test]
fn test_type_truth_table() {
let int = ElementProperty::from(1);
let float = ElementProperty::Number(Number::from_f64(1.0));
let text = ElementProperty::from("x");
let uuid = ElementProperty::Uuid(0x550e8400_e29b_41d4_a716_446655440000);
assert!(check(&PropertySchema::integer(), int.clone()).is_ok());
assert!(check(&PropertySchema::integer(), float.clone()).is_err());
assert!(check(&PropertySchema::number(), float.clone()).is_ok());
assert!(check(&PropertySchema::number(), int.clone()).is_err());
assert!(check(&PropertySchema::string(), text.clone()).is_ok());
assert!(check(&PropertySchema::uuid(), text.clone()).is_err());
assert!(check(&PropertySchema::string(), uuid.clone()).is_err());
assert!(check(&PropertySchema::uuid(), uuid.clone()).is_ok());
let either = PropertySchema::of_types(&[JsonType::Integer, JsonType::String]);
assert!(check(&either, int.clone()).is_ok());
assert!(check(&either, text.clone()).is_ok());
assert!(check(&either, ElementProperty::Boolean(true)).is_err());
let any_of =
PropertySchema::any_of(vec![PropertySchema::integer(), PropertySchema::uuid()]);
assert!(check(&any_of, uuid.clone()).is_ok());
assert!(check(&any_of, text.clone()).is_err());
for value in [
int.clone(),
text.clone(),
uuid.clone(),
ElementProperty::Null,
] {
assert!(check(&PropertySchema::any(), value).is_ok());
}
let ids = PropertySchema::array(PropertySchema::uuid());
assert!(check(&ids, ElementProperty::Array(vec![uuid.clone()])).is_ok());
assert!(check(&ids, ElementProperty::Array(vec![text.clone()])).is_err());
assert!(check(&ids, ElementProperty::Array(Vec::new())).is_ok());
assert!(check(&ids, uuid).is_err());
}
#[test]
fn test_null_is_governed_by_the_type() {
let required_text = PropertySchema::string();
let nullable_text = PropertySchema::string().nullable();
assert!(check(&nullable_text, ElementProperty::Null).is_ok());
let err = check(&required_text, ElementProperty::Null).expect_err("null is not a string");
match &err {
SchemaError::TypeMismatch { actual_type, .. } => assert_eq!(actual_type, "null"),
other => panic!("Expected TypeMismatch, got {other:?}"),
}
assert!(err.help().unwrap().contains("does not admit null"));
}
#[test]
fn test_type_mismatch_renders_canonical_type_names() {
let err = check(&PropertySchema::integer(), ElementProperty::from("29"))
.expect_err("a string must not satisfy a declared int64");
match err {
SchemaError::TypeMismatch {
ref expected_type,
ref actual_type,
..
} => {
assert_eq!(expected_type, "int64");
assert_eq!(actual_type, "string");
}
ref other => panic!("Expected SchemaError::TypeMismatch, got {other:?}"),
}
assert_eq!(
err.to_string(),
"Type mismatch on vertex 'person.p': expected int64, got string"
);
let union = PropertySchema::integer().nullable();
assert_eq!(union.type_name(), "union<int64|null>");
}
}