Skip to main content

oxilite_core/
registry.rs

1//! The schema registry: which named graphs hold schema rather than data, and which graphs each
2//! one applies to — kept as RDF in the system graph `<oxilite:schema>`.
3//!
4//! Each registered graph is a resource of `<oxilite:schema>` with the graph's IRI as subject
5//! (`oxl:DefaultGraph` for the default graph), typed with its role and described with the
6//! `oxl:` vocabulary ([`VOCABULARY`]). Registry operations are plain SPARQL updates built here,
7//! so they run unchanged on Oxigraph or any SPARQL store; the stores run them through their
8//! update path. The SQL fragments below read the same triples to scope the derived caches
9//! (`tbox_closure`, the shape index). While nothing is registered for a role, every graph but
10//! the system graphs may contribute to it, so a store that uses no registry behaves as it
11//! always did.
12//!
13//! The Rust reader ([`entries_from_rows`]), the SQL fragments and the SPARQL recipes of
14//! `docs/schema-registry.md` agree on every edge case: a graph typed with several roles counts
15//! for each, and only an `xsd:boolean` false (`false` or `0`) deactivates. [`problems`] checks
16//! what the registry's own SHACL shapes (in [`VOCABULARY`]) check.
17//!
18// @lat: [[architecture#Schema registry]]
19
20use crate::encoding::{named_node_id, term_id, DEFAULT_GRAPH_ID};
21use crate::error::{Error, Result};
22use crate::sql::Statement;
23use oxrdf::vocab::{rdf, xsd};
24use oxrdf::{GraphName, GraphNameRef, Literal, NamedNode, NamedNodeRef, QuadRef, Term, TermRef};
25use spargebra::term::{GraphNamePattern, NamedNodePattern, TermPattern};
26use spargebra::{GraphUpdateOperation, Update};
27use std::collections::BTreeMap;
28
29/// The system graph holding the registry.
30pub const SCHEMA_GRAPH: &str = "oxilite:schema";
31
32/// The system graph holding the oxilite vocabulary (installed by [`system_quads`]).
33pub const VOCABULARY_GRAPH: &str = "oxilite:vocabulary";
34
35/// The version of the vocabulary that [`system_quads`] installs (`oxl:version` of
36/// `<oxilite:vocabulary>` in the registry).
37pub const VOCABULARY_VERSION: &str = "2.1";
38
39/// The oxilite namespace (`oxl:`).
40pub const NS: &str = "https://oxilite.dev/ns#";
41
42/// The oxilite vocabulary as Turtle: the registry's classes and properties, the SHACL shapes
43/// the registry must conform to, and the terms of the history graph.
44pub const VOCABULARY: &str = include_str!("../vocab/oxl.ttl");
45
46/// IRIs of the registry vocabulary.
47pub mod vocab {
48    pub const SCHEMA_GRAPH: &str = "https://oxilite.dev/ns#SchemaGraph";
49    pub const SYSTEM_GRAPH: &str = "https://oxilite.dev/ns#SystemGraph";
50    pub const ONTOLOGY_GRAPH: &str = "https://oxilite.dev/ns#OntologyGraph";
51    pub const SHAPES_GRAPH: &str = "https://oxilite.dev/ns#ShapesGraph";
52    pub const SHEX_GRAPH: &str = "https://oxilite.dev/ns#ShExGraph";
53    pub const APPLIES_TO: &str = "https://oxilite.dev/ns#appliesTo";
54    pub const ACTIVE: &str = "https://oxilite.dev/ns#active";
55    pub const ONTOLOGY_IRI: &str = "https://oxilite.dev/ns#ontologyIri";
56    pub const VERSION: &str = "https://oxilite.dev/ns#version";
57    pub const SHA256: &str = "https://oxilite.dev/ns#sha256";
58    pub const LOADED_AT: &str = "https://oxilite.dev/ns#loadedAt";
59    pub const DEFAULT_GRAPH: &str = "https://oxilite.dev/ns#DefaultGraph";
60    pub const ALL_GRAPHS: &str = "https://oxilite.dev/ns#AllGraphs";
61    /// Imports recorded in the registry (vocabulary 2.1; `owl:imports` there is still read).
62    pub const IMPORTS: &str = "https://oxilite.dev/ns#imports";
63    /// `owl:imports`: asserted in an ontology's own graph, and in registries of vocabulary 2.
64    pub const OWL_IMPORTS: &str = "http://www.w3.org/2002/07/owl#imports";
65}
66
67/// The graphs oxilite maintains itself. They never contribute axioms or shapes, whatever the
68/// registry says about them.
69pub const SYSTEM_GRAPHS: [&str; 2] = [SCHEMA_GRAPH, VOCABULARY_GRAPH];
70
71/// What a registered graph holds.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
73#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
74#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
75pub enum SchemaRole {
76    /// An OWL / RDFS ontology: it feeds `tbox_closure`.
77    Ontology,
78    /// A SHACL shapes graph: it feeds the shape index.
79    Shacl,
80    /// A ShEx schema. Recorded and hidden like the others; nothing compiles it yet.
81    Shex,
82}
83
84impl SchemaRole {
85    pub const ALL: [Self; 3] = [Self::Ontology, Self::Shacl, Self::Shex];
86
87    /// The role's name: `ontology`, `shacl` or `shex`.
88    pub const fn name(self) -> &'static str {
89        match self {
90            Self::Ontology => "ontology",
91            Self::Shacl => "shacl",
92            Self::Shex => "shex",
93        }
94    }
95
96    /// The class typing a registered graph of this role.
97    pub const fn class(self) -> &'static str {
98        match self {
99            Self::Ontology => vocab::ONTOLOGY_GRAPH,
100            Self::Shacl => vocab::SHAPES_GRAPH,
101            Self::Shex => vocab::SHEX_GRAPH,
102        }
103    }
104
105    /// The role a class names.
106    pub fn from_class(iri: &str) -> Option<Self> {
107        Self::ALL.into_iter().find(|r| r.class() == iri)
108    }
109}
110
111impl std::str::FromStr for SchemaRole {
112    type Err = Error;
113
114    /// Parses a role name (`shapes` is accepted for `shacl`), ignoring case.
115    fn from_str(s: &str) -> Result<Self> {
116        Ok(match s.to_ascii_lowercase().as_str() {
117            "ontology" => Self::Ontology,
118            "shacl" | "shapes" => Self::Shacl,
119            "shex" => Self::Shex,
120            _ => {
121                return Err(Error::Other(format!(
122                    "unknown schema role {s}: one of ontology, shacl, shex"
123                )))
124            }
125        })
126    }
127}
128
129/// One registered graph, as `<oxilite:schema>` describes it.
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct SchemaGraph {
132    pub graph: GraphName,
133    pub role: SchemaRole,
134    /// The `owl:Ontology` IRI, when it differs from the graph name.
135    pub iri: Option<NamedNode>,
136    /// A version to pin (`owl:versionIRI`, a tag…).
137    pub version: Option<String>,
138    /// Digest of the document the graph was loaded from, for drift detection.
139    pub sha256: Option<String>,
140    /// Import targets (`oxl:imports`). An import naming another active registered ontology (by graph
141    /// name or `oxl:ontologyIri`) brings its axioms into this ontology's scopes.
142    pub imports: Vec<NamedNode>,
143    /// The graphs the schema applies to; empty means every graph (written as
144    /// `oxl:appliesTo oxl:AllGraphs`).
145    pub applies_to: Vec<GraphName>,
146    /// Inactive graphs stay registered (and hidden) but stop contributing.
147    pub active: bool,
148    /// When the graph was registered (`xsd:dateTime` lexical form).
149    pub loaded_at: Option<String>,
150}
151
152impl SchemaGraph {
153    /// An active registration applying to every graph, with nothing else recorded.
154    pub fn new(graph: GraphName, role: SchemaRole) -> Self {
155        Self {
156            graph,
157            role,
158            iri: None,
159            version: None,
160            sha256: None,
161            imports: Vec::new(),
162            applies_to: Vec::new(),
163            active: true,
164            loaded_at: None,
165        }
166    }
167
168    /// Does this schema apply to `target`?
169    pub fn applies(&self, target: GraphNameRef<'_>) -> bool {
170        self.applies_to.is_empty() || self.applies_to.iter().any(|g| g.as_ref() == target)
171    }
172}
173
174// ---------------------------------------------------------------------------------- SPARQL
175
176/// The IRI naming a graph in the registry (`oxl:DefaultGraph` for the default graph).
177pub fn graph_node(g: GraphNameRef<'_>) -> Result<NamedNode> {
178    match g {
179        GraphNameRef::NamedNode(n) => Ok(n.into_owned()),
180        GraphNameRef::DefaultGraph => Ok(NamedNode::new_unchecked(vocab::DEFAULT_GRAPH)),
181        GraphNameRef::BlankNode(_) => Err(Error::Other(
182            "a graph named by a blank node cannot be registered as schema".into(),
183        )),
184    }
185}
186
187/// The graph a registry IRI names (`oxl:DefaultGraph` is the default graph).
188pub fn node_graph(n: &NamedNode) -> GraphName {
189    if n.as_str() == vocab::DEFAULT_GRAPH {
190        GraphName::DefaultGraph
191    } else {
192        n.clone().into()
193    }
194}
195
196fn iri(s: &str) -> String {
197    format!("<{s}>")
198}
199
200fn describe_delete(node: &NamedNode) -> String {
201    format!(
202        "DELETE WHERE {{ GRAPH {} {{ {node} ?p ?o }} }}",
203        iri(SCHEMA_GRAPH)
204    )
205}
206
207/// Is this literal an `xsd:boolean` false? (`"0"^^xsd:boolean` too; a plain `"false"` is not.)
208fn is_false(l: &Literal) -> bool {
209    l.datatype() == xsd::BOOLEAN && matches!(l.value(), "false" | "0")
210}
211
212/// The `oxl:appliesTo` objects of a registration: its targets, or `oxl:AllGraphs`.
213fn targets_of(applies_to: &[GraphName]) -> Result<Vec<NamedNode>> {
214    if applies_to.is_empty() {
215        return Ok(vec![NamedNode::new_unchecked(vocab::ALL_GRAPHS)]);
216    }
217    applies_to.iter().map(|g| graph_node(g.as_ref())).collect()
218}
219
220/// The `xsd:dateTime` of now, where a clock is available.
221fn now() -> Option<String> {
222    Some(oxsdatatypes::DateTime::now().to_string())
223}
224
225/// SPARQL registering a graph: its old description, if any, is replaced. A named graph is
226/// created (`CREATE SILENT GRAPH`) so the registry never names a graph the store lacks.
227/// `loaded_at` is set to now when absent.
228pub fn register_update(entry: &SchemaGraph) -> Result<String> {
229    let node = graph_node(entry.graph.as_ref())?;
230    let mut out = String::new();
231    if let GraphName::NamedNode(n) = &entry.graph {
232        out.push_str(&format!("CREATE SILENT GRAPH {n} ;\n"));
233    }
234    out.push_str(&describe_delete(&node));
235    out.push_str(" ;\nINSERT DATA { GRAPH ");
236    out.push_str(&iri(SCHEMA_GRAPH));
237    out.push_str(" {\n");
238    let mut triple = |p: &str, o: String| out.push_str(&format!("  {node} {} {o} .\n", iri(p)));
239    triple(rdf::TYPE.as_str(), iri(entry.role.class()));
240    triple(vocab::ACTIVE, Literal::from(entry.active).to_string());
241    for t in targets_of(&entry.applies_to)? {
242        triple(vocab::APPLIES_TO, t.to_string());
243    }
244    if let Some(i) = &entry.iri {
245        triple(vocab::ONTOLOGY_IRI, i.to_string());
246    }
247    if let Some(v) = &entry.version {
248        triple(vocab::VERSION, Literal::new_simple_literal(v).to_string());
249    }
250    if let Some(h) = &entry.sha256 {
251        triple(vocab::SHA256, Literal::new_simple_literal(h).to_string());
252    }
253    for i in &entry.imports {
254        triple(vocab::IMPORTS, i.to_string());
255    }
256    if let Some(t) = entry.loaded_at.clone().or_else(now) {
257        triple(
258            vocab::LOADED_AT,
259            Literal::new_typed_literal(t, xsd::DATE_TIME).to_string(),
260        );
261    }
262    out.push_str("} }");
263    Ok(out)
264}
265
266/// SPARQL setting the graphs a registration applies to (empty: every graph), keeping the rest
267/// of its description. Nothing happens to an unregistered graph.
268pub fn remap_update(graph: GraphNameRef<'_>, applies_to: &[GraphName]) -> Result<String> {
269    let node = graph_node(graph)?;
270    let (g, a) = (iri(SCHEMA_GRAPH), iri(vocab::APPLIES_TO));
271    let insert = targets_of(applies_to)?
272        .iter()
273        .map(|t| format!("{node} {a} {t} ."))
274        .collect::<Vec<_>>()
275        .join(" ");
276    Ok(format!(
277        "DELETE {{ GRAPH {g} {{ {node} {a} ?t }} }} INSERT {{ GRAPH {g} {{ {insert} }} }} \
278         WHERE {{ GRAPH {g} {{ {node} a ?role OPTIONAL {{ {node} {a} ?t }} }} }}"
279    ))
280}
281
282/// SPARQL removing a registration (the graph's triples stay).
283pub fn unregister_update(graph: GraphNameRef<'_>) -> Result<String> {
284    Ok(describe_delete(&graph_node(graph)?))
285}
286
287/// SPARQL activating or deactivating a registration (nothing happens to an unregistered graph).
288pub fn set_active_update(graph: GraphNameRef<'_>, active: bool) -> Result<String> {
289    let node = graph_node(graph)?;
290    let (g, a) = (iri(SCHEMA_GRAPH), iri(vocab::ACTIVE));
291    Ok(format!(
292        "DELETE {{ GRAPH {g} {{ {node} {a} ?a }} }} INSERT {{ GRAPH {g} {{ {node} {a} {} }} }} \
293         WHERE {{ GRAPH {g} {{ {node} a ?role OPTIONAL {{ {node} {a} ?a }} }} }}",
294        Literal::from(active)
295    ))
296}
297
298/// SPARQL removing a registration and every triple of its graph.
299pub fn drop_update(graph: GraphNameRef<'_>) -> Result<String> {
300    let node = graph_node(graph)?;
301    let drop = match graph {
302        GraphNameRef::NamedNode(n) => format!("DROP SILENT GRAPH {n}"),
303        _ => "CLEAR SILENT DEFAULT".to_owned(),
304    };
305    Ok(format!("{} ;\n{drop}", describe_delete(&node)))
306}
307
308/// SPARQL asking whether a graph is registered.
309pub fn registered_query(graph: GraphNameRef<'_>) -> Result<String> {
310    Ok(format!(
311        "ASK {{ GRAPH {} {{ {} a ?role }} }}",
312        iri(SCHEMA_GRAPH),
313        graph_node(graph)?
314    ))
315}
316
317/// SPARQL counting the triples of a graph (`?n`).
318pub fn size_query(graph: GraphNameRef<'_>) -> String {
319    match graph {
320        GraphNameRef::NamedNode(n) => {
321            format!("SELECT (COUNT(*) AS ?n) WHERE {{ GRAPH {n} {{ ?s ?p ?o }} }}")
322        }
323        _ => "SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o }".to_owned(),
324    }
325}
326
327/// SPARQL reading the registry: `?g ?p ?o` rows, parsed by [`entries_from_rows`].
328pub fn entries_query() -> String {
329    format!(
330        "SELECT ?g ?p ?o WHERE {{ GRAPH {} {{ ?g ?p ?o }} }}",
331        iri(SCHEMA_GRAPH)
332    )
333}
334
335/// The registrations described by `?g ?p ?o` rows, ordered by role and graph. Subjects
336/// without a role class are ignored.
337pub fn entries_from_rows(rows: &[Vec<Option<Term>>]) -> Vec<SchemaGraph> {
338    let mut by: BTreeMap<String, (NamedNode, Vec<(String, Term)>)> = BTreeMap::new();
339    for row in rows {
340        let (Some(Term::NamedNode(s)), Some(Term::NamedNode(p)), Some(o)) = (
341            row.first().cloned().flatten(),
342            row.get(1).cloned().flatten(),
343            row.get(2).cloned().flatten(),
344        ) else {
345            continue;
346        };
347        by.entry(s.as_str().to_owned())
348            .or_insert_with(|| (s.clone(), Vec::new()))
349            .1
350            .push((p.into_string(), o));
351    }
352    let mut out = Vec::new();
353    for (_, (node, props)) in by {
354        // A graph may hold several roles (an ontology with its SHACL shapes): one entry each,
355        // as the SQL scopes count it once per role.
356        let mut roles: Vec<SchemaRole> = props
357            .iter()
358            .filter_map(|(p, o)| match o {
359                Term::NamedNode(c) if p == rdf::TYPE.as_str() => SchemaRole::from_class(c.as_str()),
360                _ => None,
361            })
362            .collect();
363        roles.sort();
364        roles.dedup();
365        let Some(&first) = roles.first() else {
366            continue;
367        };
368        let mut e = SchemaGraph::new(node_graph(&node), first);
369        let mut all = false;
370        for (p, o) in props {
371            match (p.as_str(), o) {
372                (vocab::ACTIVE, Term::Literal(l)) if is_false(&l) => {
373                    e.active = false;
374                }
375                (vocab::APPLIES_TO, Term::NamedNode(n)) if n.as_str() == vocab::ALL_GRAPHS => {
376                    all = true;
377                }
378                (vocab::APPLIES_TO, Term::NamedNode(n)) => e.applies_to.push(node_graph(&n)),
379                (vocab::ONTOLOGY_IRI, Term::NamedNode(n)) => e.iri = Some(n),
380                (vocab::VERSION, Term::Literal(l)) => e.version = Some(l.value().to_owned()),
381                (vocab::SHA256, Term::Literal(l)) => e.sha256 = Some(l.value().to_owned()),
382                (vocab::LOADED_AT, Term::Literal(l)) => e.loaded_at = Some(l.value().to_owned()),
383                (vocab::IMPORTS | vocab::OWL_IMPORTS, Term::NamedNode(n)) => e.imports.push(n),
384                _ => {}
385            }
386        }
387        if all {
388            e.applies_to.clear();
389        }
390        e.applies_to.sort_by_key(ToString::to_string);
391        e.imports.sort();
392        e.imports.dedup();
393        for role in roles {
394            out.push(SchemaGraph { role, ..e.clone() });
395        }
396    }
397    out.sort_by_key(|e| (e.role, e.graph.to_string()));
398    out
399}
400
401// ------------------------------------------------------------------------ change detection
402
403fn registry_graph(g: GraphNameRef<'_>) -> bool {
404    matches!(g, GraphNameRef::NamedNode(n) if n.as_str() == SCHEMA_GRAPH)
405}
406
407/// Does writing this quad change what the registry says? (Any quad of `<oxilite:schema>`.)
408pub fn is_registry_quad(q: QuadRef<'_>) -> bool {
409    registry_graph(q.graph_name)
410}
411
412/// Can this update change the registry? Any triple written to `<oxilite:schema>` counts. With
413/// a variable graph (conservative), a triple counts when it could be a registry triple: a
414/// variable predicate, an `oxl:` predicate, `owl:imports`, or `rdf:type` with a variable or
415/// `oxl:` class — so a bulk `INSERT { GRAPH ?g { ?s a ex:Person } }` rebuilds nothing.
416pub fn update_touches_registry(update: &Update) -> bool {
417    let registry_triple = |p: &NamedNodePattern, o: &TermPattern| match p {
418        NamedNodePattern::Variable(_) => true,
419        NamedNodePattern::NamedNode(n) if n.as_ref() == rdf::TYPE => match o {
420            TermPattern::NamedNode(c) => c.as_str().starts_with(NS),
421            TermPattern::Variable(_) => true,
422            _ => false,
423        },
424        NamedNodePattern::NamedNode(n) => {
425            n.as_str().starts_with(NS) || n.as_str() == vocab::OWL_IMPORTS
426        }
427    };
428    let counts = |g: &GraphNamePattern, p: &NamedNodePattern, o: &TermPattern| match g {
429        GraphNamePattern::NamedNode(n) => n.as_str() == SCHEMA_GRAPH,
430        GraphNamePattern::DefaultGraph => false,
431        GraphNamePattern::Variable(_) => registry_triple(p, o),
432    };
433    update.operations.iter().any(|op| match op {
434        GraphUpdateOperation::InsertData { data } => data.iter().any(|q| {
435            matches!(&q.graph_name, spargebra::term::GraphName::NamedNode(n) if n.as_str() == SCHEMA_GRAPH)
436        }),
437        GraphUpdateOperation::DeleteData { data } => data.iter().any(|q| {
438            matches!(&q.graph_name, spargebra::term::GraphName::NamedNode(n) if n.as_str() == SCHEMA_GRAPH)
439        }),
440        GraphUpdateOperation::DeleteInsert { delete, insert, .. } => {
441            delete.iter().any(|q| {
442                let o: TermPattern = q.object.clone().into();
443                counts(&q.graph_name, &q.predicate, &o)
444            }) || insert
445                .iter()
446                .any(|q| counts(&q.graph_name, &q.predicate, &q.object))
447        }
448        GraphUpdateOperation::Create { .. } => false,
449        GraphUpdateOperation::Load { .. }
450        | GraphUpdateOperation::Clear { .. }
451        | GraphUpdateOperation::Drop { .. } => true,
452    })
453}
454
455// -------------------------------------------------------------------------------------- SQL
456
457/// Term ids the SQL fragments need.
458struct Ids {
459    reg: i64,
460    ty: i64,
461    applies: i64,
462    active: i64,
463    /// `false` and `"0"^^xsd:boolean`, the two lexical forms of an `xsd:boolean` false.
464    falses: [i64; 2],
465    dflt: i64,
466    all: i64,
467    system: i64,
468    /// `oxl:imports`, and `owl:imports` (registries of vocabulary 2, and ontology graphs).
469    imports: i64,
470    owl_imports: i64,
471    ontology_iri: i64,
472}
473
474fn ids() -> Ids {
475    Ids {
476        reg: named_node_id(SCHEMA_GRAPH),
477        ty: named_node_id(rdf::TYPE.as_str()),
478        applies: named_node_id(vocab::APPLIES_TO),
479        active: named_node_id(vocab::ACTIVE),
480        falses: [
481            term_id(TermRef::Literal(Literal::from(false).as_ref())),
482            term_id(TermRef::Literal(
483                Literal::new_typed_literal("0", xsd::BOOLEAN).as_ref(),
484            )),
485        ],
486        dflt: named_node_id(vocab::DEFAULT_GRAPH),
487        all: named_node_id(vocab::ALL_GRAPHS),
488        system: named_node_id(vocab::SYSTEM_GRAPH),
489        imports: named_node_id(vocab::IMPORTS),
490        owl_imports: named_node_id(vocab::OWL_IMPORTS),
491        ontology_iri: named_node_id(vocab::ONTOLOGY_IRI),
492    }
493}
494
495/// The scope of the closure that applies to every graph (the id of `oxl:AllGraphs`).
496pub fn all_scope() -> i64 {
497    named_node_id(vocab::ALL_GRAPHS)
498}
499
500/// The graph id of the registry graph.
501pub fn registry_graph_id() -> i64 {
502    named_node_id(SCHEMA_GRAPH)
503}
504
505/// SQL: the graph id a registry node names (`oxl:DefaultGraph` is 0).
506fn graph_of(i: &Ids, col: &str) -> String {
507    format!(
508        "(CASE {col} WHEN {} THEN {DEFAULT_GRAPH_ID} ELSE {col} END)",
509        i.dflt
510    )
511}
512
513/// SQL: the registry nodes (column `s`) of the active graphs of these roles.
514/// SQL: the registry nodes (column `s`) of the graphs registered with this role, active or
515/// not: the "every graph" fallback holds only while there are none, so deactivating the last
516/// ontology silences it instead of letting every graph back in.
517fn role_nodes(i: &Ids, role: SchemaRole) -> String {
518    format!(
519        "SELECT r.s AS s FROM quads r WHERE r.g = {} AND r.p = {} AND r.o = {}",
520        i.reg,
521        i.ty,
522        named_node_id(role.class())
523    )
524}
525
526fn active_nodes(i: &Ids, roles: &[SchemaRole]) -> String {
527    let classes = roles
528        .iter()
529        .map(|r| named_node_id(r.class()).to_string())
530        .collect::<Vec<_>>()
531        .join(", ");
532    format!(
533        "SELECT r.s AS s FROM quads r WHERE r.g = {reg} AND r.p = {ty} AND r.o IN ({classes}) \
534         AND NOT EXISTS (SELECT 1 FROM quads z WHERE z.g = {reg} AND z.s = r.s AND z.p = {active} AND z.o IN ({f0}, {f1}))",
535        reg = i.reg,
536        ty = i.ty,
537        active = i.active,
538        f0 = i.falses[0],
539        f1 = i.falses[1]
540    )
541}
542
543/// SQL: the ids of the system graphs, a fixed list. Typing another graph `oxl:SystemGraph`
544/// hides it, but never takes it out of the "every graph" fallback.
545fn system_graphs() -> String {
546    SYSTEM_GRAPHS
547        .iter()
548        .map(|g| named_node_id(g).to_string())
549        .collect::<Vec<_>>()
550        .join(", ")
551}
552
553/// SQL: the graphs that may contribute to `role` — the active registered ones, or every graph
554/// but the system graphs while none is registered for it (active or not).
555///
556/// `column` is the graph column of the quad source being filtered (e.g. `"g"`, `"x.g"`).
557pub fn scope(role: SchemaRole, column: &str) -> String {
558    let i = ids();
559    let nodes = active_nodes(&i, &[role]);
560    format!(
561        "((NOT EXISTS ({}) AND {column} NOT IN ({})) OR {column} IN (SELECT {} FROM ({nodes}) an))",
562        role_nodes(&i, role),
563        system_graphs(),
564        graph_of(&i, "an.s")
565    )
566}
567
568/// SQL: a quad source restricted to the graphs that may contribute to `role`.
569pub fn scoped_quads(role: SchemaRole) -> String {
570    format!("(SELECT s, p, o, g FROM quads WHERE {})", scope(role, "g"))
571}
572
573/// SQL: the ids of every registered schema graph, whatever its role and whether or not it is
574/// active.
575fn registered_graphs(i: &Ids) -> String {
576    let classes = SchemaRole::ALL
577        .iter()
578        .map(|r| named_node_id(r.class()))
579        .chain([i.system])
580        .map(|id| id.to_string())
581        .collect::<Vec<_>>()
582        .join(", ");
583    format!(
584        "SELECT {} FROM quads r WHERE r.g = {} AND r.p = {} AND r.o IN ({classes})",
585        graph_of(i, "r.s"),
586        i.reg,
587        i.ty
588    )
589}
590
591/// SQL: a quad source without the registry graph and the graphs it registers (see
592/// `QueryOptions::include_schema_graphs`).
593pub fn quads_without_schema_graphs() -> String {
594    without_schema_graphs("quads")
595}
596
597/// SQL: `source` (a quad table) without the registry graph and the graphs it registers.
598pub fn without_schema_graphs(source: &str) -> String {
599    let i = ids();
600    format!(
601        "(SELECT s, p, o, g FROM {source} WHERE g NOT IN ({}) AND g NOT IN ({}))",
602        system_graphs(),
603        registered_graphs(&i)
604    )
605}
606
607/// SQL: the ontology axioms as `(s, p, o, scope)`, restricted by `cond` (on alias `q`).
608///
609/// The axioms of the active ontology graphs that apply to every graph come with the scope
610/// [`all_scope`] and again with every specific scope; those of an ontology mapped to graph G
611/// with scope G. An ontology's imports (`oxl:imports` or `owl:imports` recorded in the registry,
612/// or `owl:imports` asserted in its own graph) that name another active ontology — by graph name or `oxl:ontologyIri` — bring that
613/// ontology's axioms into the importer's scopes, transitively. While no ontology is
614/// registered, every graph's triples but the system graphs' count, with scope [`all_scope`].
615pub fn ontology_axioms(cond: &str) -> String {
616    let i = ids();
617    let act = active_nodes(&i, &[SchemaRole::Ontology]);
618    // Specific targets of registry nodes (not oxl:AllGraphs).
619    let targets = format!(
620        "SELECT a.s AS n, {} AS t FROM quads a WHERE a.g = {} AND a.p = {} AND a.o <> {}",
621        graph_of(&i, "a.o"),
622        i.reg,
623        i.applies,
624        i.all
625    );
626    let to_all = format!(
627        "SELECT a.s FROM quads a WHERE a.g = {} AND a.p = {} AND a.o = {}",
628        i.reg, i.applies, i.all
629    );
630    // Active ontologies applying to every graph: no specific target, or oxl:AllGraphs.
631    let global = format!(
632        "SELECT ac.s AS s FROM ({act}) ac WHERE ac.s NOT IN (SELECT n FROM ({targets})) OR ac.s IN ({to_all})"
633    );
634    let specific = format!("SELECT st.n AS n, st.t AS t FROM ({targets}) st WHERE st.n IN ({act})");
635    let direct = format!(
636        "SELECT {g} AS g, {all} AS scope FROM ({global}) gl \
637         UNION SELECT {g}, sp.t FROM ({global}) gl JOIN ({specific}) sp \
638         UNION SELECT {n}, sp.t FROM ({specific}) sp",
639        g = graph_of(&i, "gl.s"),
640        n = graph_of(&i, "sp.n"),
641        all = i.all
642    );
643    // (importer graph, imported IRI): recorded in the registry, or asserted in the ontology.
644    let imports = format!(
645        "SELECT {gx} AS f, x.o AS iri FROM quads x WHERE x.g = {reg} AND x.p IN ({imp}, {owl}) \
646         UNION SELECT {gi}, y.o FROM ({act}) ia JOIN quads y ON y.p = {owl} AND y.g = {gi}",
647        gx = graph_of(&i, "x.s"),
648        gi = graph_of(&i, "ia.s"),
649        reg = i.reg,
650        imp = i.imports,
651        owl = i.owl_imports
652    );
653    // (IRI, graph) naming each active ontology: its graph name and its oxl:ontologyIri.
654    let named = format!(
655        "SELECT nb.s AS iri, {gn} AS t FROM ({act}) nb \
656         UNION SELECT oi.o, {go} FROM quads oi WHERE oi.g = {reg} AND oi.p = {oiri} AND oi.s IN ({act})",
657        gn = graph_of(&i, "nb.s"),
658        go = graph_of(&i, "oi.s"),
659        reg = i.reg,
660        oiri = i.ontology_iri
661    );
662    let edges = format!(
663        "SELECT io.f AS f, nm.t AS t FROM ({imports}) io JOIN ({named}) nm ON nm.iri = io.iri"
664    );
665    // UNION (not UNION ALL) ends the recursion on import cycles.
666    let map = format!(
667        "WITH RECURSIVE reg_direct(g, scope) AS ({direct}), \
668         reg_map(g, scope) AS (SELECT g, scope FROM reg_direct \
669           UNION SELECT ed.t, reg_map.scope FROM reg_map JOIN ({edges}) ed ON ed.f = reg_map.g) \
670         SELECT g, scope FROM reg_map"
671    );
672    format!(
673        "(SELECT q.s AS s, q.p AS p, q.o AS o, m.scope AS scope FROM quads q JOIN ({map}) m ON m.g = q.g WHERE {cond} \
674         UNION ALL SELECT q.s, q.p, q.o, {all} FROM quads q WHERE {cond} AND NOT EXISTS ({registered}) AND q.g NOT IN ({sys}))",
675        registered = role_nodes(&i, SchemaRole::Ontology),
676        all = i.all,
677        sys = system_graphs()
678    )
679}
680
681/// Loads the specific closure scopes (graphs with ontologies of their own), kept in memory with
682/// the planner statistics.
683pub fn scopes_statement(id_col: impl Fn(&str) -> String) -> Statement {
684    Statement::new(format!(
685        "SELECT DISTINCT {} FROM tbox_closure WHERE scope <> {}",
686        id_col("scope"),
687        all_scope()
688    ))
689}
690
691// ---------------------------------------------------------------------------- system graphs
692
693/// The system graphs a new store starts with: the vocabulary in `<oxilite:vocabulary>`, and in
694/// `<oxilite:schema>` a description of both system graphs (`oxl:SystemGraph`, with the
695/// vocabulary's version). System graphs never narrow reasoning or the shape index.
696pub fn system_quads() -> Vec<oxrdf::Quad> {
697    let vocabulary = NamedNode::new_unchecked(VOCABULARY_GRAPH);
698    let registry = NamedNode::new_unchecked(SCHEMA_GRAPH);
699    let mut out: Vec<oxrdf::Quad> = oxrdfio::RdfParser::from_format(oxrdfio::RdfFormat::Turtle)
700        .for_slice(VOCABULARY.as_bytes())
701        .map(|q| {
702            let q = q.expect("the bundled vocabulary is valid Turtle");
703            oxrdf::Quad::new(q.subject, q.predicate, q.object, vocabulary.clone())
704        })
705        .collect();
706    let t = |s: &NamedNode, p: &str, o: Term| {
707        oxrdf::Quad::new(s.clone(), NamedNode::new_unchecked(p), o, registry.clone())
708    };
709    let system: Term = NamedNode::new_unchecked(vocab::SYSTEM_GRAPH).into();
710    let label = "http://www.w3.org/2000/01/rdf-schema#label";
711    out.extend([
712        t(&registry, rdf::TYPE.as_str(), system.clone()),
713        t(
714            &registry,
715            label,
716            Literal::new_simple_literal("schema registry").into(),
717        ),
718        t(&vocabulary, rdf::TYPE.as_str(), system),
719        t(
720            &vocabulary,
721            label,
722            Literal::new_simple_literal("oxilite vocabulary").into(),
723        ),
724        t(&vocabulary, vocab::APPLIES_TO, registry.clone().into()),
725        t(
726            &vocabulary,
727            vocab::ONTOLOGY_IRI,
728            NamedNode::new_unchecked(NS).into(),
729        ),
730        t(
731            &vocabulary,
732            vocab::VERSION,
733            Literal::new_simple_literal(VOCABULARY_VERSION).into(),
734        ),
735    ]);
736    out
737}
738
739/// SPARQL installing (or refreshing) the system graphs on any store: the vocabulary graph is
740/// replaced and the system graphs' descriptions rewritten; registrations are untouched.
741pub fn system_graphs_update() -> String {
742    let (reg, voc) = (iri(SCHEMA_GRAPH), iri(VOCABULARY_GRAPH));
743    let mut out = format!(
744        "CREATE SILENT GRAPH {reg} ;\nCREATE SILENT GRAPH {voc} ;\nCLEAR SILENT GRAPH {voc} ;\n\
745         DELETE WHERE {{ GRAPH {reg} {{ {reg} ?p ?o }} }} ;\n\
746         DELETE WHERE {{ GRAPH {reg} {{ {voc} ?p ?o }} }} ;\nINSERT DATA {{\n"
747    );
748    for q in system_quads() {
749        out.push_str(&format!(
750            "  GRAPH {} {{ {} {} {} . }}\n",
751            q.graph_name, q.subject, q.predicate, q.object
752        ));
753    }
754    out.push('}');
755    out
756}
757
758/// SPARQL `ASK`: are the system graphs installed at the current vocabulary version?
759pub fn system_graphs_ready_query() -> String {
760    format!(
761        "ASK {{ GRAPH {} {{ {} {} {} }} }}",
762        iri(SCHEMA_GRAPH),
763        iri(VOCABULARY_GRAPH),
764        iri(vocab::VERSION),
765        Literal::new_simple_literal(VOCABULARY_VERSION)
766    )
767}
768
769// ------------------------------------------------------------------------------ validation
770
771/// What is wrong with the registry described by `?g ?p ?o` rows (see [`entries_query`]): the
772/// violations of the registry's SHACL shapes (`oxl:RegistrationShape` in [`VOCABULARY`]),
773/// found without a SHACL engine, one message per violation. Empty means valid.
774///
775/// A node is checked when it has a role class, `oxl:SystemGraph`, or a registration property.
776pub fn problems(rows: &[Vec<Option<Term>>]) -> Vec<String> {
777    const PROPS: [&str; 8] = [
778        vocab::APPLIES_TO,
779        vocab::ACTIVE,
780        vocab::ONTOLOGY_IRI,
781        vocab::VERSION,
782        vocab::SHA256,
783        vocab::LOADED_AT,
784        vocab::IMPORTS,
785        vocab::OWL_IMPORTS,
786    ];
787    let mut by: BTreeMap<String, Vec<(String, Term)>> = BTreeMap::new();
788    for row in rows {
789        let (Some(s), Some(Term::NamedNode(p)), Some(o)) = (
790            row.first().cloned().flatten(),
791            row.get(1).cloned().flatten(),
792            row.get(2).cloned().flatten(),
793        ) else {
794            continue;
795        };
796        by.entry(s.to_string())
797            .or_default()
798            .push((p.into_string(), o));
799    }
800    let mut out = Vec::new();
801    for (node, props) in by {
802        let values = |p: &'static str| props.iter().filter(move |(q, _)| q == p).map(|(_, o)| o);
803        let typed = values(rdf::TYPE.as_str()).any(|o| {
804            matches!(o, Term::NamedNode(c)
805                if SchemaRole::from_class(c.as_str()).is_some() || c.as_str() == vocab::SYSTEM_GRAPH)
806        });
807        let described = props.iter().any(|(p, _)| PROPS.contains(&p.as_str()));
808        if !typed && !described {
809            continue;
810        }
811        let mut bad = |m: String| out.push(format!("{node}: {m}"));
812        if node.starts_with("_:") {
813            bad("a blank node cannot name a graph".into());
814        }
815        if !typed {
816            bad("has registration properties but no role class (oxl:OntologyGraph, oxl:ShapesGraph, oxl:ShExGraph)".into());
817        }
818        for p in [
819            vocab::ACTIVE,
820            vocab::ONTOLOGY_IRI,
821            vocab::VERSION,
822            vocab::SHA256,
823            vocab::LOADED_AT,
824        ] {
825            let n = values(p).count();
826            if n > 1 {
827                bad(format!("<{p}> has {n} values, at most one is allowed"));
828            }
829        }
830        for p in [
831            vocab::APPLIES_TO,
832            vocab::ONTOLOGY_IRI,
833            vocab::IMPORTS,
834            vocab::OWL_IMPORTS,
835        ] {
836            for o in values(p).filter(|o| !matches!(o, Term::NamedNode(_))) {
837                bad(format!("<{p}> {o} is not an IRI"));
838            }
839        }
840        let literal = |p: &'static str, dt: NamedNodeRef<'static>| {
841            values(p)
842                .filter(move |o| !matches!(o, Term::Literal(l) if l.datatype() == dt))
843                .map(move |o| format!("<{p}> {o} is not an <{}>", dt.as_str()))
844                .collect::<Vec<_>>()
845        };
846        for m in literal(vocab::ACTIVE, xsd::BOOLEAN)
847            .into_iter()
848            .chain(literal(vocab::VERSION, xsd::STRING))
849            .chain(literal(vocab::SHA256, xsd::STRING))
850            .chain(literal(vocab::LOADED_AT, xsd::DATE_TIME))
851        {
852            bad(m);
853        }
854        for o in values(vocab::ACTIVE) {
855            if let Term::Literal(l) = o {
856                if l.datatype() == xsd::BOOLEAN
857                    && !matches!(l.value(), "true" | "false" | "1" | "0")
858                {
859                    bad(format!(
860                        "<{}> {o} is not a valid xsd:boolean",
861                        vocab::ACTIVE
862                    ));
863                }
864            }
865        }
866        for o in values(vocab::SHA256) {
867            if let Term::Literal(l) = o {
868                let v = l.value();
869                if v.len() != 64 || !v.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) {
870                    bad(format!(
871                        "<{}> {o} is not 64 lowercase hex digits",
872                        vocab::SHA256
873                    ));
874                }
875            }
876        }
877    }
878    out
879}
880
881// -------------------------------------------------------------------------------- migration
882
883/// Schema version 1 kept registrations in a `schema_graphs` table: reads its rows with the
884/// graph IRIs (`g`, `lex`, `role`, `iri`, `version`, `sha256`, `imports`, `active`).
885pub fn legacy_rows_statement() -> Statement {
886    Statement::new(
887        "SELECT sg.g, t.lex, sg.role, sg.iri, sg.version, sg.sha256, sg.imports, sg.active \
888         FROM schema_graphs sg LEFT JOIN terms t ON t.id = sg.g",
889    )
890}
891
892/// The registrations of legacy rows (see [`legacy_rows_statement`]). Graphs named by blank
893/// nodes, which the registry graph cannot name, are skipped.
894pub fn legacy_entries(response: &crate::sql::Response) -> Result<Vec<SchemaGraph>> {
895    use crate::sql::col;
896    let Some(rs) = response.first() else {
897        return Ok(Vec::new());
898    };
899    let mut out = Vec::new();
900    for row in &rs.rows {
901        let g = col(row, 0)?.as_i64();
902        let text = |i: usize| -> Result<Option<String>> {
903            Ok(col(row, i)?.clone().into_string().filter(|s| !s.is_empty()))
904        };
905        let graph = if g == Some(DEFAULT_GRAPH_ID) {
906            GraphName::DefaultGraph
907        } else {
908            match text(1)?.and_then(|l| NamedNode::new(l).ok()) {
909                Some(n) if g == Some(named_node_id(n.as_str())) => n.into(),
910                _ => continue,
911            }
912        };
913        let role = match col(row, 2)?.as_i64() {
914            Some(1) => SchemaRole::Ontology,
915            Some(2) => SchemaRole::Shacl,
916            Some(3) => SchemaRole::Shex,
917            _ => continue,
918        };
919        let mut e = SchemaGraph::new(graph, role);
920        e.iri = text(3)?.and_then(|s| NamedNode::new(s).ok());
921        e.version = text(4)?;
922        e.sha256 = text(5)?;
923        e.imports = text(6)?
924            .map(|s| {
925                s.split('\n')
926                    .filter_map(|i| NamedNode::new(i).ok())
927                    .collect()
928            })
929            .unwrap_or_default();
930        e.active = col(row, 7)?.as_i64().unwrap_or(1) != 0;
931        out.push(e);
932    }
933    Ok(out)
934}
935
936/// The registry triples of a registration, as quads of `<oxilite:schema>` (for writers that
937/// cannot run SPARQL, like the migration).
938pub fn entry_quads(entry: &SchemaGraph) -> Result<Vec<oxrdf::Quad>> {
939    let node = graph_node(entry.graph.as_ref())?;
940    let g = NamedNode::new_unchecked(SCHEMA_GRAPH);
941    let q = |p: &str, o: Term| {
942        oxrdf::Quad::new(node.clone(), NamedNode::new_unchecked(p), o, g.clone())
943    };
944    let mut out = vec![
945        q(
946            rdf::TYPE.as_str(),
947            NamedNode::new_unchecked(entry.role.class()).into(),
948        ),
949        q(vocab::ACTIVE, Literal::from(entry.active).into()),
950    ];
951    for t in targets_of(&entry.applies_to)? {
952        out.push(q(vocab::APPLIES_TO, t.into()));
953    }
954    if let Some(i) = &entry.iri {
955        out.push(q(vocab::ONTOLOGY_IRI, i.clone().into()));
956    }
957    if let Some(v) = &entry.version {
958        out.push(q(vocab::VERSION, Literal::new_simple_literal(v).into()));
959    }
960    if let Some(h) = &entry.sha256 {
961        out.push(q(vocab::SHA256, Literal::new_simple_literal(h).into()));
962    }
963    for i in &entry.imports {
964        out.push(q(vocab::IMPORTS, i.clone().into()));
965    }
966    if let Some(t) = &entry.loaded_at {
967        out.push(q(
968            vocab::LOADED_AT,
969            Literal::new_typed_literal(t, xsd::DATE_TIME).into(),
970        ));
971    }
972    Ok(out)
973}
974
975#[cfg(test)]
976mod tests {
977    use super::*;
978
979    #[test]
980    fn register_update_parses_and_reads_back() {
981        let mut e = SchemaGraph::new(
982            NamedNode::new_unchecked("http://ex.org/onto").into(),
983            SchemaRole::Ontology,
984        );
985        e.version = Some("v \"1\"".into());
986        e.applies_to = vec![
987            NamedNode::new_unchecked("http://ex.org/data").into(),
988            GraphName::DefaultGraph,
989        ];
990        e.imports = vec![NamedNode::new_unchecked("http://ex.org/base")];
991        e.loaded_at = Some("2026-09-26T10:00:00Z".into());
992        let u = register_update(&e).unwrap();
993        spargebra::SparqlParser::new().parse_update(&u).unwrap();
994        for s in [
995            unregister_update(e.graph.as_ref()).unwrap(),
996            set_active_update(e.graph.as_ref(), false).unwrap(),
997            drop_update(e.graph.as_ref()).unwrap(),
998            drop_update(GraphNameRef::DefaultGraph).unwrap(),
999        ] {
1000            spargebra::SparqlParser::new().parse_update(&s).unwrap();
1001        }
1002        let rows: Vec<Vec<Option<Term>>> = entry_quads(&e)
1003            .unwrap()
1004            .into_iter()
1005            .map(|q| {
1006                vec![
1007                    Some(q.subject.into()),
1008                    Some(q.predicate.into()),
1009                    Some(q.object),
1010                ]
1011            })
1012            .collect();
1013        let back = entries_from_rows(&rows);
1014        let mut want = e.clone();
1015        want.applies_to.sort_by_key(ToString::to_string);
1016        assert_eq!(back, vec![want]);
1017        assert_eq!(problems(&rows), Vec::<String>::new());
1018        spargebra::SparqlParser::new()
1019            .parse_update(&remap_update(e.graph.as_ref(), &[]).unwrap())
1020            .unwrap();
1021    }
1022
1023    fn row(s: &str, p: &str, o: Term) -> Vec<Option<Term>> {
1024        vec![
1025            Some(NamedNode::new_unchecked(s).into()),
1026            Some(NamedNode::new_unchecked(p).into()),
1027            Some(o),
1028        ]
1029    }
1030
1031    fn class(c: &str) -> Term {
1032        NamedNode::new_unchecked(c).into()
1033    }
1034
1035    // @lat: [[tests#Schema registry#Readers agree on edge cases]]
1036    #[test]
1037    fn readers_agree_on_booleans_and_roles() {
1038        let g = "http://ex.org/g";
1039        let ty = rdf::TYPE.as_str();
1040        // A graph with two roles is listed once per role.
1041        let rows = vec![
1042            row(g, ty, class(vocab::ONTOLOGY_GRAPH)),
1043            row(g, ty, class(vocab::SHAPES_GRAPH)),
1044        ];
1045        let roles: Vec<_> = entries_from_rows(&rows).iter().map(|e| e.role).collect();
1046        assert_eq!(roles, vec![SchemaRole::Ontology, SchemaRole::Shacl]);
1047        // Only an xsd:boolean false deactivates, in either lexical form.
1048        for (o, active) in [
1049            (Literal::from(false), false),
1050            (Literal::new_typed_literal("0", xsd::BOOLEAN), false),
1051            (Literal::new_simple_literal("false"), true),
1052            (Literal::from(true), true),
1053        ] {
1054            let rows = vec![
1055                row(g, ty, class(vocab::ONTOLOGY_GRAPH)),
1056                row(g, vocab::ACTIVE, o.clone().into()),
1057            ];
1058            assert_eq!(entries_from_rows(&rows)[0].active, active, "{o}");
1059        }
1060        // The SQL reads both lexical forms of false.
1061        let sql = active_nodes(&ids(), &[SchemaRole::Ontology]);
1062        assert!(sql.contains(&ids().falses[1].to_string()));
1063    }
1064
1065    // @lat: [[tests#Schema registry#Registry writes are detected narrowly]]
1066    #[test]
1067    fn registry_writes_are_detected_narrowly() {
1068        let touches = |u: &str| {
1069            let u =
1070                format!("PREFIX oxl: <https://oxilite.dev/ns#> PREFIX ex: <http://ex.org/> {u}");
1071            update_touches_registry(&spargebra::SparqlParser::new().parse_update(&u).unwrap())
1072        };
1073        // Bulk typing through a variable graph is not a registry write.
1074        assert!(!touches(
1075            "INSERT { GRAPH ?g { ?s a ex:Person } } WHERE { GRAPH ?g { ?s ex:p ?o } }"
1076        ));
1077        assert!(!touches(
1078            "DELETE { GRAPH ?g { ?s ex:p ?o } } WHERE { GRAPH ?g { ?s ex:p ?o } }"
1079        ));
1080        // Anything that could be one is.
1081        assert!(touches(
1082            "INSERT { GRAPH ?g { ?s a oxl:OntologyGraph } } WHERE { GRAPH ?g { ?s ex:p ?o } }"
1083        ));
1084        assert!(touches(
1085            "INSERT { GRAPH ?g { ?s a ?c } } WHERE { GRAPH ?g { ?s ex:p ?c } }"
1086        ));
1087        assert!(touches("DELETE { GRAPH ?g { ?s <http://www.w3.org/2002/07/owl#imports> ?o } } WHERE { GRAPH ?g { ?s ?p ?o } }"));
1088        assert!(touches("DELETE { GRAPH <oxilite:schema> { ?s ex:p ?o } } WHERE { GRAPH <oxilite:schema> { ?s ex:p ?o } }"));
1089        assert!(touches(
1090            "INSERT DATA { GRAPH <oxilite:schema> { ex:a ex:b ex:c } }"
1091        ));
1092    }
1093
1094    // @lat: [[tests#Schema registry#The vocabulary is consistent with the system graphs]]
1095    #[test]
1096    fn vocabulary_is_consistent_with_the_system_graphs() {
1097        use std::collections::BTreeSet;
1098        let rdfs = |l: &str| format!("http://www.w3.org/2000/01/rdf-schema#{l}");
1099        let disjoint = "http://www.w3.org/2002/07/owl#disjointWith";
1100        let vocabulary: Vec<oxrdf::Triple> =
1101            oxrdfio::RdfParser::from_format(oxrdfio::RdfFormat::Turtle)
1102                .for_slice(VOCABULARY.as_bytes())
1103                .map(|q| oxrdf::Triple::from(q.unwrap()))
1104                .collect();
1105        let objects = |s: &str, p: &str| -> Vec<String> {
1106            vocabulary
1107                .iter()
1108                .filter(|t| t.subject.to_string() == format!("<{s}>") && t.predicate.as_str() == p)
1109                .filter_map(|t| match &t.object {
1110                    Term::NamedNode(n) => Some(n.as_str().to_owned()),
1111                    _ => None,
1112                })
1113                .collect()
1114        };
1115        // RDFS entailment of the types of each subject of <oxilite:schema>: its rdf:type and
1116        // the domains of its properties, closed over rdfs:subClassOf.
1117        let mut types: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
1118        for q in system_quads() {
1119            if !matches!(&q.graph_name, GraphName::NamedNode(g) if g.as_str() == SCHEMA_GRAPH) {
1120                continue;
1121            }
1122            let entry = types.entry(q.subject.to_string()).or_default();
1123            match (&q.predicate, &q.object) {
1124                (p, Term::NamedNode(c)) if *p == rdf::TYPE => {
1125                    entry.insert(c.as_str().to_owned());
1126                }
1127                (p, _) => entry.extend(objects(p.as_str(), &rdfs("domain"))),
1128            }
1129        }
1130        for (node, classes) in &mut types {
1131            let mut todo: Vec<String> = classes.iter().cloned().collect();
1132            while let Some(c) = todo.pop() {
1133                for sup in objects(&c, &rdfs("subClassOf")) {
1134                    if classes.insert(sup.clone()) {
1135                        todo.push(sup);
1136                    }
1137                }
1138            }
1139            for c in classes.iter() {
1140                for d in objects(c, disjoint) {
1141                    assert!(
1142                        !classes.contains(&d),
1143                        "{node} is both <{c}> and the disjoint <{d}>"
1144                    );
1145                }
1146            }
1147            assert!(classes.contains(vocab::SYSTEM_GRAPH), "{node}: {classes:?}");
1148        }
1149        assert_eq!(types.len(), SYSTEM_GRAPHS.len());
1150        // Every registration property has a declared domain.
1151        for p in [
1152            vocab::APPLIES_TO,
1153            vocab::ACTIVE,
1154            vocab::ONTOLOGY_IRI,
1155            vocab::VERSION,
1156            vocab::SHA256,
1157            vocab::LOADED_AT,
1158            vocab::IMPORTS,
1159        ] {
1160            assert_eq!(
1161                objects(p, &rdfs("domain")),
1162                [format!("{NS}RegisteredGraph")],
1163                "{p}"
1164            );
1165        }
1166    }
1167
1168    // @lat: [[tests#Schema registry#Registry problems]]
1169    #[test]
1170    fn problems_mirror_the_registry_shapes() {
1171        let g = "http://ex.org/g";
1172        let ty = rdf::TYPE.as_str();
1173        let rows = vec![
1174            row(g, ty, class(vocab::ONTOLOGY_GRAPH)),
1175            row(
1176                g,
1177                vocab::ACTIVE,
1178                Literal::new_simple_literal("false").into(),
1179            ),
1180            row(g, vocab::ACTIVE, Literal::from(true).into()),
1181            row(
1182                g,
1183                vocab::APPLIES_TO,
1184                Literal::new_simple_literal("x").into(),
1185            ),
1186            row(g, vocab::SHA256, Literal::new_simple_literal("ABC").into()),
1187            row(
1188                "http://ex.org/h",
1189                vocab::APPLIES_TO,
1190                class(vocab::ALL_GRAPHS),
1191            ),
1192        ];
1193        let p = problems(&rows);
1194        assert_eq!(p.len(), 5, "{p:#?}");
1195        assert!(p
1196            .iter()
1197            .any(|m| m.starts_with("<http://ex.org/h>") && m.contains("no role class")));
1198    }
1199}