use std::fmt;
use selene_core::{
Change, DbString, EdgeId, LabelSet, NodeId, PropertyMap, PropertyValueType, Value,
byte_string_fits_type, character_string_fits_type, decimal_fits_type,
};
use crate::graph::SeleneGraph;
use crate::graph_types::{EdgeEndpointDef, GraphTypeDef, PropertyTypeDef, ValidationMode};
mod unique;
#[cfg(test)]
pub(crate) use unique::unique_property_check_required;
pub(crate) use unique::{validate_unique_property_changes, validate_unique_property_state};
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum EntityId {
Node(NodeId),
Edge(EdgeId),
}
impl fmt::Display for EntityId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Node(id) => write!(formatter, "node {id}"),
Self::Edge(id) => write!(formatter, "edge {id}"),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, miette::Diagnostic)]
#[non_exhaustive]
pub enum TypeViolation {
#[error("node {id} has labels {labels:?}, which do not match any node type")]
#[diagnostic(code(SLENE_G_030))]
UnknownNodeLabel {
id: NodeId,
labels: LabelSet,
},
#[error("edge {id} has label {label}, which does not match any edge type")]
#[diagnostic(code(SLENE_G_031))]
UnknownEdgeLabel {
id: EdgeId,
label: DbString,
},
#[error(
"edge {id} label {label} expected endpoint types ({expected_source_type}, {expected_target_type}) but observed ({observed_source_type}, {observed_target_type})"
)]
#[diagnostic(code(SLENE_G_032))]
EdgeEndpointTypeMismatch {
id: EdgeId,
label: DbString,
expected_source_type: EdgeEndpointDef,
observed_source_type: u32,
expected_target_type: EdgeEndpointDef,
observed_target_type: u32,
},
#[error("{entity_id} is missing required property {property} declared in {declared_in}")]
#[diagnostic(code(SLENE_G_033))]
MissingRequiredProperty {
entity_id: EntityId,
property: DbString,
declared_in: DbString,
},
#[error("{entity_id} property {property} expected {expected} but observed {observed}")]
#[diagnostic(code(SLENE_G_034))]
PropertyTypeMismatch {
entity_id: EntityId,
property: DbString,
expected: PropertyValueType,
observed: &'static str,
},
#[error("{entity_id} property {property} uses a Value::Extended payload")]
#[diagnostic(code(SLENE_G_035))]
ExtensionValueRejected {
entity_id: EntityId,
property: DbString,
},
#[error("{entity_id} property {property} is not declared by the matched type")]
#[diagnostic(code(SLENE_G_036))]
UndeclaredProperty {
entity_id: EntityId,
property: DbString,
},
#[error("{entity_id} property {property} declared in {declared_in} is immutable")]
#[diagnostic(code(SLENE_G_037))]
ImmutablePropertyUpdate {
entity_id: EntityId,
property: DbString,
declared_in: DbString,
},
#[error(
"{entity_id} property {property} declared in {declared_in} duplicates {conflicting_entity_id}"
)]
#[diagnostic(code(SLENE_G_038))]
UniquePropertyDuplicate {
entity_id: EntityId,
conflicting_entity_id: EntityId,
property: DbString,
declared_in: DbString,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TypeWarning {
pub violation: TypeViolation,
}
pub fn validate_change(
change: &Change,
graph: &SeleneGraph,
type_def: &GraphTypeDef,
) -> Result<Vec<TypeWarning>, TypeViolation> {
match change {
Change::NodeCreated { id, .. } => {
if !graph.is_node_alive(*id) {
return Ok(Vec::new());
}
validate_node_state(*id, graph, type_def).map(|(_, warnings)| warnings)
}
Change::NodeUpdated {
id,
labels_diff,
properties_diff,
} => {
if !graph.is_node_alive(*id) {
return Ok(Vec::new());
}
let (node_type_index, mut warnings) = validate_node_state(*id, graph, type_def)?;
let node_type = &type_def.node_types[node_type_index as usize];
reject_immutable_property_update(
EntityId::Node(*id),
node_type.name.clone(),
&node_type.properties,
properties_diff,
)?;
if !labels_diff.is_empty() {
warnings.extend(revalidate_incident_edges(*id, graph, type_def)?);
}
Ok(warnings)
}
Change::EdgeCreated { id, .. } => {
if !graph.is_edge_alive(*id) {
return Ok(Vec::new());
}
validate_edge_state(*id, graph, type_def).map(|(_, warnings)| warnings)
}
Change::EdgeUpdated {
id,
properties_diff,
} => {
if !graph.is_edge_alive(*id) {
return Ok(Vec::new());
}
let (edge_type, warnings) = validate_edge_state(*id, graph, type_def)?;
reject_immutable_property_update(
EntityId::Edge(*id),
edge_type.name.clone(),
&edge_type.properties,
properties_diff,
)?;
Ok(warnings)
}
Change::NodePropertyRemoved { id, property } => {
if !graph.is_node_alive(*id) {
return Ok(Vec::new());
}
let (node_type_index, warnings) = validate_node_state(*id, graph, type_def)?;
let node_type = &type_def.node_types[node_type_index as usize];
reject_if_immutable(
EntityId::Node(*id),
node_type.name.clone(),
&node_type.properties,
property.clone(),
)?;
Ok(warnings)
}
Change::EdgePropertyRemoved { id, property } => {
if !graph.is_edge_alive(*id) {
return Ok(Vec::new());
}
let (edge_type, warnings) = validate_edge_state(*id, graph, type_def)?;
reject_if_immutable(
EntityId::Edge(*id),
edge_type.name.clone(),
&edge_type.properties,
property.clone(),
)?;
Ok(warnings)
}
Change::NodeLabelRemoved { id, .. } => {
if !graph.is_node_alive(*id) {
return Ok(Vec::new());
}
let (_, mut warnings) = validate_node_state(*id, graph, type_def)?;
warnings.extend(revalidate_incident_edges(*id, graph, type_def)?);
Ok(warnings)
}
Change::NodeDeleted { .. }
| Change::EdgeDeleted { .. }
| Change::NodesOfTypeTruncated { .. }
| Change::EdgesOfTypeTruncated { .. }
| Change::GraphReset { .. }
| Change::SchemaChanged { .. } => Ok(Vec::new()),
}
}
fn revalidate_incident_edges(
node: NodeId,
graph: &SeleneGraph,
type_def: &GraphTypeDef,
) -> Result<Vec<TypeWarning>, TypeViolation> {
let mut warnings = Vec::new();
if let Some(entry) = graph.outgoing_edges(node) {
for edge in entry.iter() {
if graph.is_edge_alive(edge.edge_id) {
warnings.extend(validate_edge_state(edge.edge_id, graph, type_def)?.1);
}
}
}
if let Some(entry) = graph.incoming_edges(node) {
for edge in entry.iter() {
if graph.is_edge_alive(edge.edge_id) {
warnings.extend(validate_edge_state(edge.edge_id, graph, type_def)?.1);
}
}
}
Ok(warnings)
}
pub fn validate_entity_state(
graph: &SeleneGraph,
type_def: &GraphTypeDef,
) -> Result<Vec<TypeWarning>, TypeViolation> {
let mut warnings = Vec::new();
for row in graph.node_store.alive.iter() {
let id = graph
.node_id_for_row(crate::store::RowIndex::new(row))
.expect("alive node row has a mapped external id (BRIEF-Item-4a)");
warnings.extend(validate_node_state(id, graph, type_def)?.1);
}
for row in graph.edge_store.alive.iter() {
let id = graph
.edge_id_for_row(crate::store::RowIndex::new(row))
.expect("alive edge row has a mapped external id (BRIEF-Item-4a)");
warnings.extend(validate_edge_state(id, graph, type_def)?.1);
}
validate_unique_property_state(graph, type_def)?;
Ok(warnings)
}
fn validate_node_state(
id: NodeId,
graph: &SeleneGraph,
type_def: &GraphTypeDef,
) -> Result<(u32, Vec<TypeWarning>), TypeViolation> {
let empty_labels = LabelSet::new();
let labels = graph.node_labels(id).unwrap_or(&empty_labels);
let node_type_index =
type_def
.find_node_type_index(labels)
.ok_or_else(|| TypeViolation::UnknownNodeLabel {
id,
labels: labels.clone(),
})?;
let node_type = &type_def.node_types[node_type_index as usize];
let empty_props = PropertyMap::new();
let properties = graph.node_properties(id).unwrap_or(&empty_props);
let warnings = validate_properties(
EntityId::Node(id),
node_type.name.clone(),
node_type.validation_mode,
&node_type.properties,
properties,
)?;
Ok((node_type_index, warnings))
}
fn validate_edge_state<'a>(
id: EdgeId,
graph: &SeleneGraph,
type_def: &'a GraphTypeDef,
) -> Result<(&'a crate::graph_types::EdgeTypeDef, Vec<TypeWarning>), TypeViolation> {
let label = graph
.edge_label(id)
.cloned()
.ok_or(TypeViolation::UnknownEdgeLabel {
id,
label: selene_core::db_string("__selene_missing_edge_label")
.expect("static label admits"),
})?;
let (source, target) =
graph
.edge_endpoints(id)
.ok_or_else(|| TypeViolation::UnknownEdgeLabel {
id,
label: label.clone(),
})?;
let (source_type, mut warnings) = validate_node_state(source, graph, type_def)?;
let (target_type, target_warnings) = validate_node_state(target, graph, type_def)?;
warnings.extend(target_warnings);
let Some(edge_type) = type_def.find_edge_type(label.clone(), source_type, target_type) else {
let Some(expected) = type_def.first_edge_type_with_label(label.clone()) else {
return Err(TypeViolation::UnknownEdgeLabel { id, label });
};
return Err(TypeViolation::EdgeEndpointTypeMismatch {
id,
label,
expected_source_type: expected.source_node_type.clone(),
observed_source_type: source_type,
expected_target_type: expected.target_node_type.clone(),
observed_target_type: target_type,
});
};
let empty_props = PropertyMap::new();
let properties = graph.edge_properties(id).unwrap_or(&empty_props);
warnings.extend(validate_properties(
EntityId::Edge(id),
edge_type.name.clone(),
edge_type.validation_mode,
&edge_type.properties,
properties,
)?);
Ok((edge_type, warnings))
}
fn reject_immutable_property_update(
entity_id: EntityId,
declared_in: DbString,
declarations: &[PropertyTypeDef],
diff: &selene_core::PropertyDiff,
) -> Result<(), TypeViolation> {
for (key, _) in &diff.set {
reject_if_immutable(entity_id, declared_in.clone(), declarations, key.clone())?;
}
for key in &diff.removed {
reject_if_immutable(entity_id, declared_in.clone(), declarations, key.clone())?;
}
Ok(())
}
fn reject_if_immutable(
entity_id: EntityId,
declared_in: DbString,
declarations: &[PropertyTypeDef],
property: DbString,
) -> Result<(), TypeViolation> {
if declarations
.iter()
.any(|declaration| declaration.name == property && declaration.immutable)
{
return Err(TypeViolation::ImmutablePropertyUpdate {
entity_id,
property,
declared_in,
});
}
Ok(())
}
fn validate_properties(
entity_id: EntityId,
declared_in: DbString,
validation_mode: ValidationMode,
declarations: &[PropertyTypeDef],
properties: &PropertyMap,
) -> Result<Vec<TypeWarning>, TypeViolation> {
let mut warnings = Vec::new();
for (key, value) in properties.iter() {
let Some(declaration) = declarations.iter().find(|decl| decl.name == *key) else {
let violation = TypeViolation::UndeclaredProperty {
entity_id,
property: key.clone(),
};
if validation_mode == ValidationMode::Warn {
warnings.push(TypeWarning { violation });
continue;
}
return Err(violation);
};
if matches!(value, Value::Extended { .. }) {
return Err(TypeViolation::ExtensionValueRejected {
entity_id,
property: key.clone(),
});
}
if matches!(value, Value::Null) {
if declaration.required {
return Err(TypeViolation::MissingRequiredProperty {
entity_id,
property: key.clone(),
declared_in: declared_in.clone(),
});
}
continue;
}
if !property_value_matches(declaration, value) {
return Err(TypeViolation::PropertyTypeMismatch {
entity_id,
property: key.clone(),
expected: declaration.value_type,
observed: PropertyValueType::observed_name(value),
});
}
}
for declaration in declarations.iter().filter(|decl| decl.required) {
if properties
.get(&declaration.name)
.is_none_or(|value| matches!(value, Value::Null))
{
return Err(TypeViolation::MissingRequiredProperty {
entity_id,
property: declaration.name.clone(),
declared_in: declared_in.clone(),
});
}
}
Ok(warnings)
}
fn property_value_matches(declaration: &PropertyTypeDef, value: &Value) -> bool {
match declaration.value_type {
PropertyValueType::List => {
let Some(element_type) = declaration.list_element_type.as_ref() else {
return matches!(value, Value::List(_));
};
match value {
Value::List(values) => values.iter().all(|value| element_type.matches(value)),
_ => false,
}
}
PropertyValueType::Record | PropertyValueType::RecordTyped => {
if !matches!(value, Value::Record(_) | Value::RecordTyped(_)) {
return false;
}
match declaration.record_field_types.as_ref() {
Some(fields) => fields.matches(value),
None => true,
}
}
PropertyValueType::Decimal => match declaration.decimal_type {
Some(decimal_type) => {
matches!(value, Value::Decimal(value) if decimal_fits_type(*value, decimal_type))
}
None => declaration.value_type.matches(value),
},
PropertyValueType::String => match declaration.character_string_type {
Some(character_string_type) => {
matches!(value, Value::String(value) if character_string_fits_type(value, character_string_type))
}
None => declaration.value_type.matches(value),
},
PropertyValueType::Bytes => match declaration.byte_string_type {
Some(byte_string_type) => {
matches!(value, Value::Bytes(value) if byte_string_fits_type(value, byte_string_type))
}
None => declaration.value_type.matches(value),
},
_ => declaration.value_type.matches(value),
}
}
#[cfg(test)]
#[path = "type_validator_tests.rs"]
mod tests;