Files
tools/publish/src/rdf/ontology.rs
T

412 lines
14 KiB
Rust
Raw Normal View History

2026-06-08 19:33:49 -04:00
use crate::error;
use oxigraph::io::{RdfFormat, RdfParser};
use oxigraph::model::vocab::{rdf, rdfs, xsd};
use oxigraph::model::{
Dataset, NamedNode, NamedNodeRef, NamedOrBlankNode, NamedOrBlankNodeRef, Term, TermRef, Triple,
TripleRef,
};
use oxigraph::sparql::{QueryResults, SparqlEvaluator};
use std::collections::{HashMap, HashSet};
const PREFIXES: &[(&str, &str)] = &[
("rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#"),
("rdfs", "http://www.w3.org/2000/01/rdf-schema#"),
("owl", "http://www.w3.org/2002/07/owl#"),
("xsd", "http://www.w3.org/2001/XMLSchema#"),
("ldp", "http://www.w3.org/ns/ldp#"),
("dc", "http://purl.org/dc/elements/1.1/"),
("posix", "http://www.w3.org/ns/posix/stat#"),
(
"ebucore",
"http://www.ebu.ch/metadata/ontologies/ebucore/ebucore#",
),
("premis", "http://www.loc.gov/premis/rdf/v1#"),
("premis3", "http://www.loc.gov/premis/rdf/v3/"),
("dcterms", "http://purl.org/dc/terms/"),
("fedora", "http://fedora.info/definitions/v4/repository#"),
("rdaa", "http://rdaregistry.info/Elements/a/"),
("rdac", "http://rdaregistry.info/Elements/c/"),
("rdae", "http://rdaregistry.info/Elements/e/"),
("rdai", "http://rdaregistry.info/Elements/i/"),
("rdam", "http://rdaregistry.info/Elements/m/"),
("rdan", "http://rdaregistry.info/Elements/n/"),
("rdap", "http://rdaregistry.info/Elements/p/"),
("rdat", "http://rdaregistry.info/Elements/t/"),
("rdaw", "http://rdaregistry.info/Elements/w/"),
("rdax", "http://rdaregistry.info/Elements/x/"),
("schema", "https://schema.org/"),
("quill", "http://fedora.quill.lan/rest/"),
("gl", "https://graphofliberty.org/2026/04/ont/"),
];
const RDF_ONT: &[u8] = include_bytes!("ontologies/22-rdf-syntax-ns.ttl");
const RDFS_ONT: &[u8] = include_bytes!("ontologies/rdf-schema.ttl");
const LDP_ONT: &[u8] = include_bytes!("ontologies/ldp.ttl");
const FEDORA_ONT: &[u8] = include_bytes!("ontologies/fedora.xml");
const GL_ONT: &[u8] = include_bytes!("ontologies/ontology.ttl");
const GL_READ_ONLY: NamedNodeRef =
NamedNodeRef::new_unchecked("https://graphofliberty.org/2026/04/ont/ReadOnly");
const GL_TEMPLATE: NamedNodeRef =
NamedNodeRef::new_unchecked("https://graphofliberty.org/2026/04/ont/template");
const GL_INDEXED_BY_FIELD: NamedNodeRef =
NamedNodeRef::new_unchecked("https://graphofliberty.org/2026/04/ont/indexedByField");
const GL_CATALOG_ID: NamedNodeRef =
NamedNodeRef::new_unchecked("https://graphofliberty.org/2026/04/ont/catalogId");
const OWL_SAME_AS: NamedNodeRef =
NamedNodeRef::new_unchecked("http://www.w3.org/2002/07/owl#sameAs");
pub const RDA_ENTITY: NamedNodeRef =
NamedNodeRef::new_unchecked("http://rdaregistry.info/Elements/c/C10013");
pub const GL_ENTITY: NamedNodeRef =
NamedNodeRef::new_unchecked("https://graphofliberty.org/2026/04/ont/Entity");
pub struct OntologyBuilder<'a> {
ontologies: Vec<(RdfFormat, &'a [u8])>,
}
impl<'a> OntologyBuilder<'a> {
pub fn with_ontology_bytes(mut self, format: RdfFormat, bytes: &'a [u8]) -> Self {
self.ontologies.push((format, bytes));
self
}
pub fn with_default_ontologies(self) -> Self {
self.with_ontology_bytes(RdfFormat::Turtle, RDF_ONT)
.with_ontology_bytes(RdfFormat::Turtle, RDFS_ONT)
.with_ontology_bytes(RdfFormat::Turtle, LDP_ONT)
.with_ontology_bytes(RdfFormat::RdfXml, FEDORA_ONT)
.with_ontology_bytes(RdfFormat::Turtle, GL_ONT)
}
fn materialize_same_as(dataset: &mut Dataset) {
let additional_quads = dataset
.quads_for_pattern(None, Some(OWL_SAME_AS), None, None)
.fold(Dataset::new(), |mut new_dataset, alias| {
if let NamedOrBlankNodeRef::NamedNode(x) = alias.subject
&& let TermRef::NamedNode(y) = alias.object
{
for mut quad in dataset.quads_for_pattern(
Some(NamedOrBlankNodeRef::NamedNode(x)),
None,
None,
None,
) {
quad.subject = NamedOrBlankNodeRef::NamedNode(y);
new_dataset.insert(quad);
}
for mut quad in dataset.quads_for_pattern(
Some(NamedOrBlankNodeRef::NamedNode(y)),
None,
None,
None,
) {
quad.subject = NamedOrBlankNodeRef::NamedNode(x);
new_dataset.insert(quad);
}
}
new_dataset
});
let old_size = dataset.len();
dataset.extend(&additional_quads);
let new_size = dataset.len();
if new_size > old_size {
Self::materialize_same_as(dataset)
}
}
fn memoize(ontology: &mut Ontology) {
// Read-only properties
for quad in ontology.dataset.quads_for_pattern(
None,
Some(rdf::TYPE),
Some(TermRef::NamedNode(GL_READ_ONLY)),
None,
) {
if let NamedOrBlankNodeRef::NamedNode(subject) = quad.subject {
ontology.read_only_properties.insert(subject.into_owned());
}
}
// Read-only classes
for quad in ontology.dataset.quads_for_pattern(
None,
Some(rdfs::SUB_CLASS_OF),
Some(TermRef::NamedNode(GL_READ_ONLY)),
None,
) {
if let NamedOrBlankNodeRef::NamedNode(subject) = quad.subject {
ontology.read_only_classes.insert(subject.into_owned());
}
}
// Full-text search index field names
for quad in ontology
.dataset
.quads_for_pattern(None, Some(GL_INDEXED_BY_FIELD), None, None)
{
if let NamedOrBlankNodeRef::NamedNode(subject) = quad.subject
&& let TermRef::Literal(literal) = quad.object
{
ontology
.field_map
.insert(subject.into_owned(), literal.value().to_string());
}
}
// Catalog IDs (used to quickly filter full-text search results)
for quad in ontology
.dataset
.quads_for_pattern(None, Some(GL_CATALOG_ID), None, None)
{
if let NamedOrBlankNodeRef::NamedNode(subject) = quad.subject
&& let TermRef::Literal(literal) = quad.object
{
if literal.datatype() == xsd::POSITIVE_INTEGER {
let value: u64 = literal.value().parse().expect("Failed to parse catalog ID from ontology. It ought to be a positive integer.");
ontology.catalog_ids.insert(subject.into_owned(), value);
}
}
}
}
pub fn build(&mut self) -> error::Result<Ontology> {
let prefixes = PREFIXES
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect::<HashMap<String, String>>();
let mut dataset = Dataset::new();
for (format, bytes) in &self.ontologies {
let quads = RdfParser::from_format(*format)
.for_slice(bytes)
.filter_map(Result::ok);
dataset.extend(quads);
}
Self::materialize_same_as(&mut dataset);
let mut ontology = Ontology {
dataset,
prefixes,
read_only_properties: HashSet::new(),
read_only_classes: HashSet::new(),
field_map: HashMap::new(),
catalog_ids: HashMap::new(),
};
Self::memoize(&mut ontology);
Ok(ontology)
}
}
pub struct Ontology {
dataset: Dataset,
prefixes: HashMap<String, String>,
read_only_properties: HashSet<NamedNode>,
read_only_classes: HashSet<NamedNode>,
field_map: HashMap<NamedNode, String>,
catalog_ids: HashMap<NamedNode, u64>,
}
fn term_to_string(term: &Term) -> Option<String> {
match term {
Term::Literal(literal) => Some(literal.value().to_string()),
_ => None,
}
}
impl Ontology {
pub fn builder<'a>() -> OntologyBuilder<'a> {
OntologyBuilder {
ontologies: Vec::new(),
}
}
pub fn label<'a>(&'a self, node: NamedNodeRef<'a>) -> Option<&'a str> {
let quads = self.dataset.quads_for_pattern(
Some(NamedOrBlankNodeRef::NamedNode(node)),
Some(rdfs::LABEL),
None,
None,
);
for quad in quads {
if let TermRef::Literal(literal) = quad.object {
match literal.language() {
None | Some("en") => return Some(literal.value()),
_ => {}
}
}
}
None
}
pub fn abbreviate(&self, node: NamedNodeRef<'_>) -> String {
for (prefix_name, prefix_iri) in &self.prefixes {
if let Some(local_name) = node.as_str().strip_prefix(prefix_iri) {
return if local_name.is_empty() {
format!("{prefix_name}:")
} else {
format!("{prefix_name}:{local_name}")
};
}
}
node.as_str().to_string()
}
pub fn expand(&self, prefixed_iri: &str) -> Option<NamedNode> {
let (prefix, name) = prefixed_iri.split_once(':')?;
self.prefixes
.get(prefix)
.map(|base| NamedNode::new_unchecked(format!("{base}{name}")))
}
pub fn field_name(&self, property: &NamedNode) -> Option<&String> {
self.field_map.get(property)
}
pub fn catalog_id(&self, class: &NamedNode) -> Option<u64> {
self.catalog_ids.get(class).copied()
}
pub fn for_each_annotated_iri<F>(&self, f: F)
where
F: Fn(&str, Option<&str>, Option<&str>),
{
let query = SparqlEvaluator::new()
.parse_query(
r"PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT ?subject ?label ?comment WHERE {
OPTIONAL {
?subject rdfs:label ?label .
FILTER (LANG(?label) = 'en' || LANG(?label) = '')
}
OPTIONAL {
?subject rdfs:comment ?comment .
FILTER (LANG(?comment) = 'en' || LANG(?comment) = '')
}
}",
)
.expect("Unable to parse annotation query");
if let QueryResults::Solutions(solutions) =
query.on_queryable_dataset(&self.dataset).execute().unwrap()
{
for solution in solutions.filter_map(Result::ok) {
let label = solution.get("label").and_then(term_to_string);
let comment = solution.get("comment").and_then(term_to_string);
if let Some(Term::NamedNode(subject)) = solution.get("subject") {
f(subject.as_str(), label.as_deref(), comment.as_deref());
}
}
}
}
pub fn datatypes(&self) -> impl Iterator<Item = NamedNodeRef<'_>> {
self.dataset
.quads_for_pattern(
None,
Some(rdf::TYPE),
Some(TermRef::NamedNode(rdfs::DATATYPE)),
None,
)
.filter_map(|quad| match quad.subject {
NamedOrBlankNodeRef::NamedNode(subject) => Some(subject),
_ => None,
})
}
pub fn is_read_only<'a>(&self, triple: impl Into<TripleRef<'a>>) -> bool {
let triple = triple.into();
let read_only_property = self
.read_only_properties
.contains(&triple.predicate.into_owned());
let read_only_class = match (triple.predicate, triple.object) {
(rdf::TYPE, TermRef::NamedNode(node)) => {
self.read_only_classes.contains(&node.into_owned())
}
_ => false,
};
read_only_property || read_only_class
}
pub fn exclude_read_only_predicate(&self) -> impl Fn(TripleRef<'_>) -> bool + 'static {
let classes = self.read_only_classes.clone();
let properties = self.read_only_properties.clone();
move |triple| {
let read_only_property = properties.contains(&triple.predicate.into_owned());
let read_only_class = match (triple.predicate, triple.object) {
(rdf::TYPE, TermRef::NamedNode(node)) => classes.contains(&node.into_owned()),
_ => false,
};
!(read_only_property || read_only_class)
}
}
pub fn subclass_of(&self, class: NamedNodeRef<'_>) -> impl Iterator<Item = NamedNode> {
let query = SparqlEvaluator::new()
.parse_query(
format!(
"PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT ?class {{
?class (rdfs:subClassOf|^rdfs:subClassOf)* {class} .
}}"
)
.as_str(),
)
.expect("Unable to parse subclass_of query");
if let QueryResults::Solutions(solutions) =
query.on_queryable_dataset(&self.dataset).execute().unwrap()
{
solutions.filter_map(Result::ok).filter_map(|solution| {
if let Some(Term::NamedNode(class)) = solution.get("class") {
Some(class.clone())
} else {
None
}
})
} else {
unreachable!()
}
}
pub fn template_triples<'a>(
&'a self,
class: NamedNodeRef<'a>,
subject: NamedNodeRef<'_>,
) -> Option<impl Iterator<Item = Triple>> {
if let Some(quad) = self
.dataset
.quads_for_pattern(
Some(NamedOrBlankNodeRef::NamedNode(class)),
Some(GL_TEMPLATE),
None,
None,
)
.next()
{
if let TermRef::BlankNode(blank_node) = quad.object {
let iter = self
.dataset
.quads_for_subject(blank_node)
.map(|quad| quad.into_owned())
.map(Triple::from)
.map(move |mut triple| {
triple.subject = NamedOrBlankNode::NamedNode(subject.into_owned());
triple
});
Some(iter)
} else {
None
}
} else {
None
}
}
}