knowledge-base-validation 0.3.0

Validation for the file-based knowledge base
Documentation
use super::Validator;
use super::values::{LocalizedMapRules, validate_localized_map, validate_optional_provenance, validate_provenance, validate_url, validate_value, value_type_name};
use crate::diagnostic::Diagnostics;
use crate::input::Loaded;
use knowledge_base_models::{Cardinality, Entity, EntityId, Property, PropertyId, Value};
use std::collections::{BTreeMap, BTreeSet};

pub(super) fn validate(validator: &mut Validator<'_>) {
    let entities = &validator.indexes.entities;
    let types = &validator.indexes.entity_types;
    let properties = &validator.indexes.properties;
    let references = &validator.indexes.references;
    let diagnostics = &mut validator.diagnostics;

    for item in &validator.repository.entities {
        let entity = &item.value;
        let id = entity.id.to_string();
        validate_localized_map(
            &item.path,
            &id,
            "labels",
            &entity.labels,
            LocalizedMapRules {
                required: true,
                references_required: true,
            },
            references,
            diagnostics,
        );
        validate_localized_map(
            &item.path,
            &id,
            "descriptions",
            &entity.descriptions,
            LocalizedMapRules {
                required: false,
                references_required: true,
            },
            references,
            diagnostics,
        );

        if entity.entity_types.is_empty() {
            diagnostics.schema(item, &id, "entity_types must not be empty");
        }
        for classification in &entity.entity_types {
            if !types.contains_key(&classification.value) {
                diagnostics.ontology(item, &id, format!("classified entity type {} does not exist", classification.value));
            }
            validate_provenance(&item.path, &id, "classification", &classification.references, references, diagnostics);
        }
        for image in &entity.images {
            validate_url(&item.path, &id, "image url", &image.url, diagnostics);
            validate_url(&item.path, &id, "image source_url", &image.source_url, diagnostics);
            for (field, value) in [("alt", &image.alt), ("creator", &image.creator), ("license", &image.license)] {
                if value.trim().is_empty() {
                    diagnostics.schema(item, &id, format!("image {field} must not be empty"));
                }
            }
            validate_optional_provenance(&item.path, &id, "image", &image.references, references, diagnostics);
        }

        let mut statement_ids = BTreeSet::new();
        let mut property_counts = BTreeMap::<PropertyId, usize>::new();
        for statement in &entity.statements {
            if !statement_ids.insert(statement.id.clone()) {
                diagnostics.ontology(item, &id, format!("statement identifier {} is duplicated", statement.id));
            }
            *property_counts.entry(statement.property.clone()).or_default() += 1;
            validate_provenance(&item.path, &format!("{id}/{}", statement.id), "statement", &statement.references, references, diagnostics);
            validate_value(&item.path, &format!("{id}/{}", statement.id), &statement.value, diagnostics);

            let main_property = properties.get(&statement.property).map(|item| &item.value);
            if let Some(main_property) = main_property {
                if !main_property.usage.allows_statement() {
                    diagnostics.ontology(
                        item,
                        &format!("{id}/{}", statement.id),
                        format!("property {} cannot be used as a statement", main_property.id),
                    );
                }
                validate_property_use(item, entity, &format!("{id}/{}", statement.id), main_property, &statement.value, entities, diagnostics);
            } else {
                diagnostics.ontology(item, &id, format!("statement property {} does not exist", statement.property));
            }

            for qualifier in &statement.qualifiers {
                validate_value(&item.path, &format!("{id}/{}/{}", statement.id, qualifier.property), &qualifier.value, diagnostics);
                if main_property.is_some_and(|property| !property.allowed_qualifiers.contains(&qualifier.property)) {
                    diagnostics.ontology(
                        item,
                        &id,
                        format!(
                            "qualifier {} is not allowed by property {}",
                            qualifier.property,
                            main_property.expect("checked as present").id
                        ),
                    );
                }
                let Some(qualifier_property) = properties.get(&qualifier.property).map(|item| &item.value) else {
                    diagnostics.ontology(item, &id, format!("qualifier property {} does not exist", qualifier.property));
                    continue;
                };
                if !qualifier_property.usage.allows_qualifier() {
                    diagnostics.ontology(
                        item,
                        &format!("{id}/{}", statement.id),
                        format!("property {} cannot be used as a qualifier", qualifier_property.id),
                    );
                }
                validate_property_use(
                    item,
                    entity,
                    &format!("{id}/{}/{}", statement.id, qualifier.property),
                    qualifier_property,
                    &qualifier.value,
                    entities,
                    diagnostics,
                );
            }
        }
        for (property_id, count) in property_counts {
            if count > 1 && properties.get(&property_id).is_some_and(|property| property.value.cardinality == Cardinality::One) {
                diagnostics.ontology(item, &id, format!("property {property_id} has cardinality one but occurs {count} times"));
            }
        }
    }
}

fn validate_property_use(
    item: &Loaded<Entity>,
    entity: &Entity,
    owner: &str,
    property: &Property,
    value: &Value,
    entities: &BTreeMap<EntityId, &Loaded<Entity>>,
    diagnostics: &mut Diagnostics,
) {
    let entity_types = entity.entity_types.iter().map(|classification| &classification.value).collect::<BTreeSet<_>>();
    if !property.subject_types.iter().any(|subject_type| entity_types.contains(subject_type)) {
        diagnostics.ontology(item, owner, format!("property {} is not applicable to this entity", property.id));
    }
    if value.value_type() != property.value_type {
        diagnostics.ontology(
            item,
            owner,
            format!(
                "property {} requires {} values but statement uses {}",
                property.id,
                value_type_name(property.value_type),
                value_type_name(value.value_type())
            ),
        );
        return;
    }
    if let Value::Entity { value: target_id } = value {
        let Some(target) = entities.get(target_id) else {
            diagnostics.ontology(item, owner, format!("target entity {target_id} does not exist"));
            return;
        };
        let target_types = target.value.entity_types.iter().map(|classification| &classification.value).collect::<BTreeSet<_>>();
        if !property
            .target_types
            .as_ref()
            .is_some_and(|allowed| allowed.iter().any(|type_id| target_types.contains(type_id)))
        {
            diagnostics.ontology(
                item,
                owner,
                format!("target entity {target_id} has none of property {}'s permitted target types", property.id),
            );
        }
    }
}