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