1use 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
29pub const SCHEMA_GRAPH: &str = "oxilite:schema";
31
32pub const VOCABULARY_GRAPH: &str = "oxilite:vocabulary";
34
35pub const VOCABULARY_VERSION: &str = "2.1";
38
39pub const NS: &str = "https://oxilite.dev/ns#";
41
42pub const VOCABULARY: &str = include_str!("../vocab/oxl.ttl");
45
46pub 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 = "https://oxilite.dev/ns#imports";
63 pub const OWL_IMPORTS: &str = "http://www.w3.org/2002/07/owl#imports";
65}
66
67pub const SYSTEM_GRAPHS: [&str; 2] = [SCHEMA_GRAPH, VOCABULARY_GRAPH];
70
71#[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 Ontology,
78 Shacl,
80 Shex,
82}
83
84impl SchemaRole {
85 pub const ALL: [Self; 3] = [Self::Ontology, Self::Shacl, Self::Shex];
86
87 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct SchemaGraph {
132 pub graph: GraphName,
133 pub role: SchemaRole,
134 pub iri: Option<NamedNode>,
136 pub version: Option<String>,
138 pub sha256: Option<String>,
140 pub imports: Vec<NamedNode>,
143 pub applies_to: Vec<GraphName>,
146 pub active: bool,
148 pub loaded_at: Option<String>,
150}
151
152impl SchemaGraph {
153 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 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
174pub 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
187pub 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
207fn is_false(l: &Literal) -> bool {
209 l.datatype() == xsd::BOOLEAN && matches!(l.value(), "false" | "0")
210}
211
212fn 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
220fn now() -> Option<String> {
222 Some(oxsdatatypes::DateTime::now().to_string())
223}
224
225pub 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
266pub 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
282pub fn unregister_update(graph: GraphNameRef<'_>) -> Result<String> {
284 Ok(describe_delete(&graph_node(graph)?))
285}
286
287pub 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
298pub 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
308pub 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
317pub 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
327pub fn entries_query() -> String {
329 format!(
330 "SELECT ?g ?p ?o WHERE {{ GRAPH {} {{ ?g ?p ?o }} }}",
331 iri(SCHEMA_GRAPH)
332 )
333}
334
335pub 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 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
401fn registry_graph(g: GraphNameRef<'_>) -> bool {
404 matches!(g, GraphNameRef::NamedNode(n) if n.as_str() == SCHEMA_GRAPH)
405}
406
407pub fn is_registry_quad(q: QuadRef<'_>) -> bool {
409 registry_graph(q.graph_name)
410}
411
412pub 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
455struct Ids {
459 reg: i64,
460 ty: i64,
461 applies: i64,
462 active: i64,
463 falses: [i64; 2],
465 dflt: i64,
466 all: i64,
467 system: i64,
468 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
495pub fn all_scope() -> i64 {
497 named_node_id(vocab::ALL_GRAPHS)
498}
499
500pub fn registry_graph_id() -> i64 {
502 named_node_id(SCHEMA_GRAPH)
503}
504
505fn 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
513fn 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
543fn system_graphs() -> String {
546 SYSTEM_GRAPHS
547 .iter()
548 .map(|g| named_node_id(g).to_string())
549 .collect::<Vec<_>>()
550 .join(", ")
551}
552
553pub 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
568pub fn scoped_quads(role: SchemaRole) -> String {
570 format!("(SELECT s, p, o, g FROM quads WHERE {})", scope(role, "g"))
571}
572
573fn 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
591pub fn quads_without_schema_graphs() -> String {
594 without_schema_graphs("quads")
595}
596
597pub 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
607pub fn ontology_axioms(cond: &str) -> String {
616 let i = ids();
617 let act = active_nodes(&i, &[SchemaRole::Ontology]);
618 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 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 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 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 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
681pub 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
691pub 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(®istry, rdf::TYPE.as_str(), system.clone()),
713 t(
714 ®istry,
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
739pub 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
758pub 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
769pub 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
881pub 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
892pub 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
936pub 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 #[test]
1037 fn readers_agree_on_booleans_and_roles() {
1038 let g = "http://ex.org/g";
1039 let ty = rdf::TYPE.as_str();
1040 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 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 let sql = active_nodes(&ids(), &[SchemaRole::Ontology]);
1062 assert!(sql.contains(&ids().falses[1].to_string()));
1063 }
1064
1065 #[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 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 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 #[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 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 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 #[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}