Initial commit
This commit is contained in:
@@ -0,0 +1,411 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user