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 may
10//! contribute to it, so a store that uses no registry behaves as it always did.
11//!
12// @lat: [[architecture#Schema registry]]
13
14use crate::encoding::{named_node_id, term_id, DEFAULT_GRAPH_ID};
15use crate::error::{Error, Result};
16use crate::sql::Statement;
17use oxrdf::vocab::{rdf, xsd};
18use oxrdf::{GraphName, GraphNameRef, Literal, NamedNode, QuadRef, Term, TermRef};
19use spargebra::term::{GraphNamePattern, NamedNodePattern};
20use spargebra::{GraphUpdateOperation, Update};
21use std::collections::BTreeMap;
22
23/// The system graph holding the registry.
24pub const SCHEMA_GRAPH: &str = "oxilite:schema";
25
26/// The system graph holding the oxilite vocabulary (installed by [`system_quads`]).
27pub const VOCABULARY_GRAPH: &str = "oxilite:vocabulary";
28
29/// The version of the vocabulary that [`system_quads`] installs (`oxl:version` of
30/// `<oxilite:vocabulary>` in the registry).
31pub const VOCABULARY_VERSION: &str = "1";
32
33/// The oxilite namespace (`oxl:`).
34pub const NS: &str = "https://oxilite.dev/ns#";
35
36/// The oxilite vocabulary as Turtle: the registry's classes and properties, and the terms of
37/// the history graph.
38pub const VOCABULARY: &str = include_str!("../vocab/oxl.ttl");
39
40/// IRIs of the registry vocabulary.
41pub mod vocab {
42    pub const SCHEMA_GRAPH: &str = "https://oxilite.dev/ns#SchemaGraph";
43    pub const SYSTEM_GRAPH: &str = "https://oxilite.dev/ns#SystemGraph";
44    pub const ONTOLOGY_GRAPH: &str = "https://oxilite.dev/ns#OntologyGraph";
45    pub const SHAPES_GRAPH: &str = "https://oxilite.dev/ns#ShapesGraph";
46    pub const SHEX_GRAPH: &str = "https://oxilite.dev/ns#ShExGraph";
47    pub const APPLIES_TO: &str = "https://oxilite.dev/ns#appliesTo";
48    pub const ACTIVE: &str = "https://oxilite.dev/ns#active";
49    pub const ONTOLOGY_IRI: &str = "https://oxilite.dev/ns#ontologyIri";
50    pub const VERSION: &str = "https://oxilite.dev/ns#version";
51    pub const SHA256: &str = "https://oxilite.dev/ns#sha256";
52    pub const LOADED_AT: &str = "https://oxilite.dev/ns#loadedAt";
53    pub const DEFAULT_GRAPH: &str = "https://oxilite.dev/ns#DefaultGraph";
54    pub const ALL_GRAPHS: &str = "https://oxilite.dev/ns#AllGraphs";
55    pub const IMPORTS: &str = "http://www.w3.org/2002/07/owl#imports";
56}
57
58/// What a registered graph holds.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
60#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
61#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
62pub enum SchemaRole {
63    /// An OWL / RDFS ontology: it feeds `tbox_closure`.
64    Ontology,
65    /// A SHACL shapes graph: it feeds the shape index.
66    Shacl,
67    /// A ShEx schema. Recorded and hidden like the others; nothing compiles it yet.
68    Shex,
69}
70
71impl SchemaRole {
72    pub const ALL: [Self; 3] = [Self::Ontology, Self::Shacl, Self::Shex];
73
74    /// The role's name: `ontology`, `shacl` or `shex`.
75    pub const fn name(self) -> &'static str {
76        match self {
77            Self::Ontology => "ontology",
78            Self::Shacl => "shacl",
79            Self::Shex => "shex",
80        }
81    }
82
83    /// The class typing a registered graph of this role.
84    pub const fn class(self) -> &'static str {
85        match self {
86            Self::Ontology => vocab::ONTOLOGY_GRAPH,
87            Self::Shacl => vocab::SHAPES_GRAPH,
88            Self::Shex => vocab::SHEX_GRAPH,
89        }
90    }
91
92    /// The role a class names.
93    pub fn from_class(iri: &str) -> Option<Self> {
94        Self::ALL.into_iter().find(|r| r.class() == iri)
95    }
96}
97
98impl std::str::FromStr for SchemaRole {
99    type Err = Error;
100
101    /// Parses a role name (`shapes` is accepted for `shacl`), ignoring case.
102    fn from_str(s: &str) -> Result<Self> {
103        Ok(match s.to_ascii_lowercase().as_str() {
104            "ontology" => Self::Ontology,
105            "shacl" | "shapes" => Self::Shacl,
106            "shex" => Self::Shex,
107            _ => {
108                return Err(Error::Other(format!(
109                    "unknown schema role {s}: one of ontology, shacl, shex"
110                )))
111            }
112        })
113    }
114}
115
116/// One registered graph, as `<oxilite:schema>` describes it.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct SchemaGraph {
119    pub graph: GraphName,
120    pub role: SchemaRole,
121    /// The `owl:Ontology` IRI, when it differs from the graph name.
122    pub iri: Option<NamedNode>,
123    /// A version to pin (`owl:versionIRI`, a tag…).
124    pub version: Option<String>,
125    /// Digest of the document the graph was loaded from, for drift detection.
126    pub sha256: Option<String>,
127    /// `owl:imports` targets, recorded but not resolved.
128    pub imports: Vec<NamedNode>,
129    /// The graphs the schema applies to; empty means every graph.
130    pub applies_to: Vec<GraphName>,
131    /// Inactive graphs stay registered (and hidden) but stop contributing.
132    pub active: bool,
133    /// When the graph was registered (`xsd:dateTime` lexical form).
134    pub loaded_at: Option<String>,
135}
136
137impl SchemaGraph {
138    /// An active registration applying to every graph, with nothing else recorded.
139    pub fn new(graph: GraphName, role: SchemaRole) -> Self {
140        Self {
141            graph,
142            role,
143            iri: None,
144            version: None,
145            sha256: None,
146            imports: Vec::new(),
147            applies_to: Vec::new(),
148            active: true,
149            loaded_at: None,
150        }
151    }
152
153    /// Does this schema apply to `target`?
154    pub fn applies(&self, target: GraphNameRef<'_>) -> bool {
155        self.applies_to.is_empty() || self.applies_to.iter().any(|g| g.as_ref() == target)
156    }
157}
158
159// ---------------------------------------------------------------------------------- SPARQL
160
161/// The IRI naming a graph in the registry (`oxl:DefaultGraph` for the default graph).
162pub fn graph_node(g: GraphNameRef<'_>) -> Result<NamedNode> {
163    match g {
164        GraphNameRef::NamedNode(n) => Ok(n.into_owned()),
165        GraphNameRef::DefaultGraph => Ok(NamedNode::new_unchecked(vocab::DEFAULT_GRAPH)),
166        GraphNameRef::BlankNode(_) => Err(Error::Other(
167            "a graph named by a blank node cannot be registered as schema".into(),
168        )),
169    }
170}
171
172/// The graph a registry IRI names (`oxl:DefaultGraph` is the default graph).
173pub fn node_graph(n: &NamedNode) -> GraphName {
174    if n.as_str() == vocab::DEFAULT_GRAPH {
175        GraphName::DefaultGraph
176    } else {
177        n.clone().into()
178    }
179}
180
181fn iri(s: &str) -> String {
182    format!("<{s}>")
183}
184
185fn describe_delete(node: &NamedNode) -> String {
186    format!(
187        "DELETE WHERE {{ GRAPH {} {{ {node} ?p ?o }} }}",
188        iri(SCHEMA_GRAPH)
189    )
190}
191
192/// The `xsd:dateTime` of now, where a clock is available.
193fn now() -> Option<String> {
194    Some(oxsdatatypes::DateTime::now().to_string())
195}
196
197/// SPARQL registering a graph: its old description, if any, is replaced. A named graph is
198/// created (`CREATE SILENT GRAPH`) so the registry never names a graph the store lacks.
199/// `loaded_at` is set to now when absent.
200pub fn register_update(entry: &SchemaGraph) -> Result<String> {
201    let node = graph_node(entry.graph.as_ref())?;
202    let mut out = String::new();
203    if let GraphName::NamedNode(n) = &entry.graph {
204        out.push_str(&format!("CREATE SILENT GRAPH {n} ;\n"));
205    }
206    out.push_str(&describe_delete(&node));
207    out.push_str(" ;\nINSERT DATA { GRAPH ");
208    out.push_str(&iri(SCHEMA_GRAPH));
209    out.push_str(" {\n");
210    let mut triple = |p: &str, o: String| out.push_str(&format!("  {node} {} {o} .\n", iri(p)));
211    triple(rdf::TYPE.as_str(), iri(entry.role.class()));
212    triple(vocab::ACTIVE, Literal::from(entry.active).to_string());
213    for g in &entry.applies_to {
214        triple(vocab::APPLIES_TO, graph_node(g.as_ref())?.to_string());
215    }
216    if let Some(i) = &entry.iri {
217        triple(vocab::ONTOLOGY_IRI, i.to_string());
218    }
219    if let Some(v) = &entry.version {
220        triple(vocab::VERSION, Literal::new_simple_literal(v).to_string());
221    }
222    if let Some(h) = &entry.sha256 {
223        triple(vocab::SHA256, Literal::new_simple_literal(h).to_string());
224    }
225    for i in &entry.imports {
226        triple(vocab::IMPORTS, i.to_string());
227    }
228    if let Some(t) = entry.loaded_at.clone().or_else(now) {
229        triple(
230            vocab::LOADED_AT,
231            Literal::new_typed_literal(t, xsd::DATE_TIME).to_string(),
232        );
233    }
234    out.push_str("} }");
235    Ok(out)
236}
237
238/// SPARQL removing a registration (the graph's triples stay).
239pub fn unregister_update(graph: GraphNameRef<'_>) -> Result<String> {
240    Ok(describe_delete(&graph_node(graph)?))
241}
242
243/// SPARQL activating or deactivating a registration (nothing happens to an unregistered graph).
244pub fn set_active_update(graph: GraphNameRef<'_>, active: bool) -> Result<String> {
245    let node = graph_node(graph)?;
246    let (g, a) = (iri(SCHEMA_GRAPH), iri(vocab::ACTIVE));
247    Ok(format!(
248        "DELETE {{ GRAPH {g} {{ {node} {a} ?a }} }} INSERT {{ GRAPH {g} {{ {node} {a} {} }} }} \
249         WHERE {{ GRAPH {g} {{ {node} a ?role OPTIONAL {{ {node} {a} ?a }} }} }}",
250        Literal::from(active)
251    ))
252}
253
254/// SPARQL removing a registration and every triple of its graph.
255pub fn drop_update(graph: GraphNameRef<'_>) -> Result<String> {
256    let node = graph_node(graph)?;
257    let drop = match graph {
258        GraphNameRef::NamedNode(n) => format!("DROP SILENT GRAPH {n}"),
259        _ => "CLEAR SILENT DEFAULT".to_owned(),
260    };
261    Ok(format!("{} ;\n{drop}", describe_delete(&node)))
262}
263
264/// SPARQL asking whether a graph is registered.
265pub fn registered_query(graph: GraphNameRef<'_>) -> Result<String> {
266    Ok(format!(
267        "ASK {{ GRAPH {} {{ {} a ?role }} }}",
268        iri(SCHEMA_GRAPH),
269        graph_node(graph)?
270    ))
271}
272
273/// SPARQL counting the triples of a graph (`?n`).
274pub fn size_query(graph: GraphNameRef<'_>) -> String {
275    match graph {
276        GraphNameRef::NamedNode(n) => {
277            format!("SELECT (COUNT(*) AS ?n) WHERE {{ GRAPH {n} {{ ?s ?p ?o }} }}")
278        }
279        _ => "SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o }".to_owned(),
280    }
281}
282
283/// SPARQL reading the registry: `?g ?p ?o` rows, parsed by [`entries_from_rows`].
284pub fn entries_query() -> String {
285    format!(
286        "SELECT ?g ?p ?o WHERE {{ GRAPH {} {{ ?g ?p ?o }} }}",
287        iri(SCHEMA_GRAPH)
288    )
289}
290
291/// The registrations described by `?g ?p ?o` rows, ordered by role and graph. Subjects
292/// without a role class are ignored.
293pub fn entries_from_rows(rows: &[Vec<Option<Term>>]) -> Vec<SchemaGraph> {
294    let mut by: BTreeMap<String, (NamedNode, Vec<(String, Term)>)> = BTreeMap::new();
295    for row in rows {
296        let (Some(Term::NamedNode(s)), Some(Term::NamedNode(p)), Some(o)) = (
297            row.first().cloned().flatten(),
298            row.get(1).cloned().flatten(),
299            row.get(2).cloned().flatten(),
300        ) else {
301            continue;
302        };
303        by.entry(s.as_str().to_owned())
304            .or_insert_with(|| (s.clone(), Vec::new()))
305            .1
306            .push((p.into_string(), o));
307    }
308    let mut out = Vec::new();
309    for (_, (node, props)) in by {
310        let role = props.iter().find_map(|(p, o)| match o {
311            Term::NamedNode(c) if p == rdf::TYPE.as_str() => SchemaRole::from_class(c.as_str()),
312            _ => None,
313        });
314        let Some(role) = role else { continue };
315        let mut e = SchemaGraph::new(node_graph(&node), role);
316        let mut all = false;
317        for (p, o) in props {
318            match (p.as_str(), o) {
319                (vocab::ACTIVE, Term::Literal(l)) if matches!(l.value(), "false" | "0") => {
320                    e.active = false;
321                }
322                (vocab::APPLIES_TO, Term::NamedNode(n)) if n.as_str() == vocab::ALL_GRAPHS => {
323                    all = true;
324                }
325                (vocab::APPLIES_TO, Term::NamedNode(n)) => e.applies_to.push(node_graph(&n)),
326                (vocab::ONTOLOGY_IRI, Term::NamedNode(n)) => e.iri = Some(n),
327                (vocab::VERSION, Term::Literal(l)) => e.version = Some(l.value().to_owned()),
328                (vocab::SHA256, Term::Literal(l)) => e.sha256 = Some(l.value().to_owned()),
329                (vocab::LOADED_AT, Term::Literal(l)) => e.loaded_at = Some(l.value().to_owned()),
330                (vocab::IMPORTS, Term::NamedNode(n)) => e.imports.push(n),
331                _ => {}
332            }
333        }
334        if all {
335            e.applies_to.clear();
336        }
337        e.applies_to.sort_by_key(ToString::to_string);
338        e.imports.sort();
339        out.push(e);
340    }
341    out.sort_by_key(|e| (e.role, e.graph.to_string()));
342    out
343}
344
345// ------------------------------------------------------------------------ change detection
346
347fn registry_graph(g: GraphNameRef<'_>) -> bool {
348    matches!(g, GraphNameRef::NamedNode(n) if n.as_str() == SCHEMA_GRAPH)
349}
350
351/// Does writing this quad change what the registry says? (Any quad of `<oxilite:schema>`.)
352pub fn is_registry_quad(q: QuadRef<'_>) -> bool {
353    registry_graph(q.graph_name)
354}
355
356/// Can this update change the registry? (Conservative: a variable graph counts when the
357/// predicate is a variable or a registry predicate.)
358pub fn update_touches_registry(update: &Update) -> bool {
359    let named = |g: &GraphNamePattern| match g {
360        GraphNamePattern::NamedNode(n) => n.as_str() == SCHEMA_GRAPH,
361        GraphNamePattern::DefaultGraph => false,
362        GraphNamePattern::Variable(_) => true,
363    };
364    let registry_predicate = |p: &NamedNodePattern| match p {
365        NamedNodePattern::Variable(_) => true,
366        NamedNodePattern::NamedNode(n) => n.as_str().starts_with(NS) || n.as_ref() == rdf::TYPE,
367    };
368    update.operations.iter().any(|op| match op {
369        GraphUpdateOperation::InsertData { data } => data.iter().any(|q| {
370            matches!(&q.graph_name, spargebra::term::GraphName::NamedNode(n) if n.as_str() == SCHEMA_GRAPH)
371        }),
372        GraphUpdateOperation::DeleteData { data } => data.iter().any(|q| {
373            matches!(&q.graph_name, spargebra::term::GraphName::NamedNode(n) if n.as_str() == SCHEMA_GRAPH)
374        }),
375        GraphUpdateOperation::DeleteInsert { delete, insert, .. } => {
376            delete
377                .iter()
378                .any(|q| named(&q.graph_name) && registry_predicate(&q.predicate))
379                || insert
380                    .iter()
381                    .any(|q| named(&q.graph_name) && registry_predicate(&q.predicate))
382        }
383        GraphUpdateOperation::Create { .. } => false,
384        GraphUpdateOperation::Load { .. }
385        | GraphUpdateOperation::Clear { .. }
386        | GraphUpdateOperation::Drop { .. } => true,
387    })
388}
389
390// -------------------------------------------------------------------------------------- SQL
391
392/// Term ids the SQL fragments need.
393struct Ids {
394    reg: i64,
395    ty: i64,
396    applies: i64,
397    active: i64,
398    false_: i64,
399    dflt: i64,
400    all: i64,
401    system: i64,
402}
403
404fn ids() -> Ids {
405    Ids {
406        reg: named_node_id(SCHEMA_GRAPH),
407        ty: named_node_id(rdf::TYPE.as_str()),
408        applies: named_node_id(vocab::APPLIES_TO),
409        active: named_node_id(vocab::ACTIVE),
410        false_: term_id(TermRef::Literal(Literal::from(false).as_ref())),
411        dflt: named_node_id(vocab::DEFAULT_GRAPH),
412        all: named_node_id(vocab::ALL_GRAPHS),
413        system: named_node_id(vocab::SYSTEM_GRAPH),
414    }
415}
416
417/// The scope of the closure that applies to every graph (the id of `oxl:AllGraphs`).
418pub fn all_scope() -> i64 {
419    named_node_id(vocab::ALL_GRAPHS)
420}
421
422/// The graph id of the registry graph.
423pub fn registry_graph_id() -> i64 {
424    named_node_id(SCHEMA_GRAPH)
425}
426
427/// SQL: the graph id a registry node names (`oxl:DefaultGraph` is 0).
428fn graph_of(i: &Ids, col: &str) -> String {
429    format!(
430        "(CASE {col} WHEN {} THEN {DEFAULT_GRAPH_ID} ELSE {col} END)",
431        i.dflt
432    )
433}
434
435/// SQL: the registry nodes (column `s`) of the active graphs of these roles.
436fn active_nodes(i: &Ids, roles: &[SchemaRole]) -> String {
437    let classes = roles
438        .iter()
439        .map(|r| named_node_id(r.class()).to_string())
440        .collect::<Vec<_>>()
441        .join(", ");
442    format!(
443        "SELECT r.s AS s FROM quads r WHERE r.g = {reg} AND r.p = {ty} AND r.o IN ({classes}) \
444         AND NOT EXISTS (SELECT 1 FROM quads z WHERE z.g = {reg} AND z.s = r.s AND z.p = {active} AND z.o = {f})",
445        reg = i.reg,
446        ty = i.ty,
447        active = i.active,
448        f = i.false_
449    )
450}
451
452/// SQL: the ids of the system graphs (`oxl:SystemGraph` in the registry).
453fn system_graphs(i: &Ids) -> String {
454    format!(
455        "SELECT {} FROM quads y WHERE y.g = {} AND y.p = {} AND y.o = {}",
456        graph_of(i, "y.s"),
457        i.reg,
458        i.ty,
459        i.system
460    )
461}
462
463/// SQL: the graphs that may contribute to `role` — the active registered ones, or every graph
464/// but the system graphs while none is registered for it.
465///
466/// `column` is the graph column of the quad source being filtered (e.g. `"g"`, `"x.g"`).
467pub fn scope(role: SchemaRole, column: &str) -> String {
468    let i = ids();
469    let nodes = active_nodes(&i, &[role]);
470    format!(
471        "((NOT EXISTS ({nodes}) AND {column} NOT IN ({})) OR {column} IN (SELECT {} FROM ({nodes}) an))",
472        system_graphs(&i),
473        graph_of(&i, "an.s")
474    )
475}
476
477/// SQL: a quad source restricted to the graphs that may contribute to `role`.
478pub fn scoped_quads(role: SchemaRole) -> String {
479    format!("(SELECT s, p, o, g FROM quads WHERE {})", scope(role, "g"))
480}
481
482/// SQL: the ids of every registered schema graph, whatever its role and whether or not it is
483/// active.
484fn registered_graphs(i: &Ids) -> String {
485    let classes = SchemaRole::ALL
486        .iter()
487        .map(|r| named_node_id(r.class()))
488        .chain([i.system])
489        .map(|id| id.to_string())
490        .collect::<Vec<_>>()
491        .join(", ");
492    format!(
493        "SELECT {} FROM quads r WHERE r.g = {} AND r.p = {} AND r.o IN ({classes})",
494        graph_of(i, "r.s"),
495        i.reg,
496        i.ty
497    )
498}
499
500/// SQL: a quad source without the registry graph and the graphs it registers (see
501/// `QueryOptions::include_schema_graphs`).
502pub fn quads_without_schema_graphs() -> String {
503    without_schema_graphs("quads")
504}
505
506/// SQL: `source` (a quad table) without the registry graph and the graphs it registers.
507pub fn without_schema_graphs(source: &str) -> String {
508    let i = ids();
509    format!(
510        "(SELECT s, p, o, g FROM {source} WHERE g <> {} AND g NOT IN ({}))",
511        i.reg,
512        registered_graphs(&i)
513    )
514}
515
516/// SQL: the ontology axioms as `(s, p, o, scope)`, restricted by `cond` (on alias `q`).
517///
518/// The axioms of the active ontology graphs that apply to every graph come with the scope
519/// [`all_scope`] and again with every specific scope; those of an ontology mapped to graph G
520/// with scope G. While no ontology is registered, every graph's triples count, with scope
521/// [`all_scope`].
522pub fn ontology_axioms(cond: &str) -> String {
523    let i = ids();
524    let act = active_nodes(&i, &[SchemaRole::Ontology]);
525    // Specific targets of registry nodes (not oxl:AllGraphs).
526    let targets = format!(
527        "SELECT a.s AS n, {} AS t FROM quads a WHERE a.g = {} AND a.p = {} AND a.o <> {}",
528        graph_of(&i, "a.o"),
529        i.reg,
530        i.applies,
531        i.all
532    );
533    let to_all = format!(
534        "SELECT a.s FROM quads a WHERE a.g = {} AND a.p = {} AND a.o = {}",
535        i.reg, i.applies, i.all
536    );
537    // Active ontologies applying to every graph: no specific target, or oxl:AllGraphs.
538    let global = format!(
539        "SELECT ac.s AS s FROM ({act}) ac WHERE ac.s NOT IN (SELECT n FROM ({targets})) OR ac.s IN ({to_all})"
540    );
541    let specific = format!("SELECT st.n AS n, st.t AS t FROM ({targets}) st WHERE st.n IN ({act})");
542    let map = format!(
543        "SELECT {g} AS g, {all} AS scope FROM ({global}) gl \
544         UNION SELECT {g}, sp.t FROM ({global}) gl JOIN ({specific}) sp \
545         UNION SELECT {n}, sp.t FROM ({specific}) sp",
546        g = graph_of(&i, "gl.s"),
547        n = graph_of(&i, "sp.n"),
548        all = i.all
549    );
550    format!(
551        "(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} \
552         UNION ALL SELECT q.s, q.p, q.o, {all} FROM quads q WHERE {cond} AND NOT EXISTS ({act}) AND q.g NOT IN ({sys}))",
553        all = i.all,
554        sys = system_graphs(&i)
555    )
556}
557
558/// Loads the specific closure scopes (graphs with ontologies of their own), kept in memory with
559/// the planner statistics.
560pub fn scopes_statement(id_col: impl Fn(&str) -> String) -> Statement {
561    Statement::new(format!(
562        "SELECT DISTINCT {} FROM tbox_closure WHERE scope <> {}",
563        id_col("scope"),
564        all_scope()
565    ))
566}
567
568// ---------------------------------------------------------------------------- system graphs
569
570/// The system graphs a new store starts with: the vocabulary in `<oxilite:vocabulary>`, and in
571/// `<oxilite:schema>` a description of both system graphs (`oxl:SystemGraph`, with the
572/// vocabulary's version). System graphs never narrow reasoning or the shape index.
573pub fn system_quads() -> Vec<oxrdf::Quad> {
574    let vocabulary = NamedNode::new_unchecked(VOCABULARY_GRAPH);
575    let registry = NamedNode::new_unchecked(SCHEMA_GRAPH);
576    let mut out: Vec<oxrdf::Quad> = oxrdfio::RdfParser::from_format(oxrdfio::RdfFormat::Turtle)
577        .for_slice(VOCABULARY.as_bytes())
578        .map(|q| {
579            let q = q.expect("the bundled vocabulary is valid Turtle");
580            oxrdf::Quad::new(q.subject, q.predicate, q.object, vocabulary.clone())
581        })
582        .collect();
583    let t = |s: &NamedNode, p: &str, o: Term| {
584        oxrdf::Quad::new(s.clone(), NamedNode::new_unchecked(p), o, registry.clone())
585    };
586    let system: Term = NamedNode::new_unchecked(vocab::SYSTEM_GRAPH).into();
587    let label = "http://www.w3.org/2000/01/rdf-schema#label";
588    out.extend([
589        t(&registry, rdf::TYPE.as_str(), system.clone()),
590        t(
591            &registry,
592            label,
593            Literal::new_simple_literal("schema registry").into(),
594        ),
595        t(&vocabulary, rdf::TYPE.as_str(), system),
596        t(
597            &vocabulary,
598            label,
599            Literal::new_simple_literal("oxilite vocabulary").into(),
600        ),
601        t(&vocabulary, vocab::APPLIES_TO, registry.clone().into()),
602        t(
603            &vocabulary,
604            vocab::ONTOLOGY_IRI,
605            NamedNode::new_unchecked(NS).into(),
606        ),
607        t(
608            &vocabulary,
609            vocab::VERSION,
610            Literal::new_simple_literal(VOCABULARY_VERSION).into(),
611        ),
612    ]);
613    out
614}
615
616/// SPARQL installing (or refreshing) the system graphs on any store: the vocabulary graph is
617/// replaced and the system graphs' descriptions rewritten; registrations are untouched.
618pub fn system_graphs_update() -> String {
619    let (reg, voc) = (iri(SCHEMA_GRAPH), iri(VOCABULARY_GRAPH));
620    let mut out = format!(
621        "CREATE SILENT GRAPH {reg} ;\nCREATE SILENT GRAPH {voc} ;\nCLEAR SILENT GRAPH {voc} ;\n\
622         DELETE WHERE {{ GRAPH {reg} {{ {reg} ?p ?o }} }} ;\n\
623         DELETE WHERE {{ GRAPH {reg} {{ {voc} ?p ?o }} }} ;\nINSERT DATA {{\n"
624    );
625    for q in system_quads() {
626        out.push_str(&format!(
627            "  GRAPH {} {{ {} {} {} . }}\n",
628            q.graph_name, q.subject, q.predicate, q.object
629        ));
630    }
631    out.push('}');
632    out
633}
634
635/// SPARQL `ASK`: are the system graphs installed at the current vocabulary version?
636pub fn system_graphs_ready_query() -> String {
637    format!(
638        "ASK {{ GRAPH {} {{ {} {} {} }} }}",
639        iri(SCHEMA_GRAPH),
640        iri(VOCABULARY_GRAPH),
641        iri(vocab::VERSION),
642        Literal::new_simple_literal(VOCABULARY_VERSION)
643    )
644}
645
646// -------------------------------------------------------------------------------- migration
647
648/// Schema version 1 kept registrations in a `schema_graphs` table: reads its rows with the
649/// graph IRIs (`g`, `lex`, `role`, `iri`, `version`, `sha256`, `imports`, `active`).
650pub fn legacy_rows_statement() -> Statement {
651    Statement::new(
652        "SELECT sg.g, t.lex, sg.role, sg.iri, sg.version, sg.sha256, sg.imports, sg.active \
653         FROM schema_graphs sg LEFT JOIN terms t ON t.id = sg.g",
654    )
655}
656
657/// The registrations of legacy rows (see [`legacy_rows_statement`]). Graphs named by blank
658/// nodes, which the registry graph cannot name, are skipped.
659pub fn legacy_entries(response: &crate::sql::Response) -> Result<Vec<SchemaGraph>> {
660    use crate::sql::col;
661    let Some(rs) = response.first() else {
662        return Ok(Vec::new());
663    };
664    let mut out = Vec::new();
665    for row in &rs.rows {
666        let g = col(row, 0)?.as_i64();
667        let text = |i: usize| -> Result<Option<String>> {
668            Ok(col(row, i)?.clone().into_string().filter(|s| !s.is_empty()))
669        };
670        let graph = if g == Some(DEFAULT_GRAPH_ID) {
671            GraphName::DefaultGraph
672        } else {
673            match text(1)?.and_then(|l| NamedNode::new(l).ok()) {
674                Some(n) if g == Some(named_node_id(n.as_str())) => n.into(),
675                _ => continue,
676            }
677        };
678        let role = match col(row, 2)?.as_i64() {
679            Some(1) => SchemaRole::Ontology,
680            Some(2) => SchemaRole::Shacl,
681            Some(3) => SchemaRole::Shex,
682            _ => continue,
683        };
684        let mut e = SchemaGraph::new(graph, role);
685        e.iri = text(3)?.and_then(|s| NamedNode::new(s).ok());
686        e.version = text(4)?;
687        e.sha256 = text(5)?;
688        e.imports = text(6)?
689            .map(|s| {
690                s.split('\n')
691                    .filter_map(|i| NamedNode::new(i).ok())
692                    .collect()
693            })
694            .unwrap_or_default();
695        e.active = col(row, 7)?.as_i64().unwrap_or(1) != 0;
696        out.push(e);
697    }
698    Ok(out)
699}
700
701/// The registry triples of a registration, as quads of `<oxilite:schema>` (for writers that
702/// cannot run SPARQL, like the migration).
703pub fn entry_quads(entry: &SchemaGraph) -> Result<Vec<oxrdf::Quad>> {
704    let node = graph_node(entry.graph.as_ref())?;
705    let g = NamedNode::new_unchecked(SCHEMA_GRAPH);
706    let q = |p: &str, o: Term| {
707        oxrdf::Quad::new(node.clone(), NamedNode::new_unchecked(p), o, g.clone())
708    };
709    let mut out = vec![
710        q(
711            rdf::TYPE.as_str(),
712            NamedNode::new_unchecked(entry.role.class()).into(),
713        ),
714        q(vocab::ACTIVE, Literal::from(entry.active).into()),
715    ];
716    for t in &entry.applies_to {
717        out.push(q(vocab::APPLIES_TO, graph_node(t.as_ref())?.into()));
718    }
719    if let Some(i) = &entry.iri {
720        out.push(q(vocab::ONTOLOGY_IRI, i.clone().into()));
721    }
722    if let Some(v) = &entry.version {
723        out.push(q(vocab::VERSION, Literal::new_simple_literal(v).into()));
724    }
725    if let Some(h) = &entry.sha256 {
726        out.push(q(vocab::SHA256, Literal::new_simple_literal(h).into()));
727    }
728    for i in &entry.imports {
729        out.push(q(vocab::IMPORTS, i.clone().into()));
730    }
731    if let Some(t) = &entry.loaded_at {
732        out.push(q(
733            vocab::LOADED_AT,
734            Literal::new_typed_literal(t, xsd::DATE_TIME).into(),
735        ));
736    }
737    Ok(out)
738}
739
740#[cfg(test)]
741mod tests {
742    use super::*;
743
744    #[test]
745    fn register_update_parses_and_reads_back() {
746        let mut e = SchemaGraph::new(
747            NamedNode::new_unchecked("http://ex.org/onto").into(),
748            SchemaRole::Ontology,
749        );
750        e.version = Some("v \"1\"".into());
751        e.applies_to = vec![
752            NamedNode::new_unchecked("http://ex.org/data").into(),
753            GraphName::DefaultGraph,
754        ];
755        e.imports = vec![NamedNode::new_unchecked("http://ex.org/base")];
756        e.loaded_at = Some("2026-09-26T10:00:00Z".into());
757        let u = register_update(&e).unwrap();
758        spargebra::SparqlParser::new().parse_update(&u).unwrap();
759        for s in [
760            unregister_update(e.graph.as_ref()).unwrap(),
761            set_active_update(e.graph.as_ref(), false).unwrap(),
762            drop_update(e.graph.as_ref()).unwrap(),
763            drop_update(GraphNameRef::DefaultGraph).unwrap(),
764        ] {
765            spargebra::SparqlParser::new().parse_update(&s).unwrap();
766        }
767        let rows: Vec<Vec<Option<Term>>> = entry_quads(&e)
768            .unwrap()
769            .into_iter()
770            .map(|q| {
771                vec![
772                    Some(q.subject.into()),
773                    Some(q.predicate.into()),
774                    Some(q.object),
775                ]
776            })
777            .collect();
778        let back = entries_from_rows(&rows);
779        let mut want = e.clone();
780        want.applies_to.sort_by_key(ToString::to_string);
781        assert_eq!(back, vec![want]);
782    }
783}