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";
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 = "http://www.w3.org/2002/07/owl#imports";
62}
63
64pub const SYSTEM_GRAPHS: [&str; 2] = [SCHEMA_GRAPH, VOCABULARY_GRAPH];
67
68#[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 Ontology,
75 Shacl,
77 Shex,
79}
80
81impl SchemaRole {
82 pub const ALL: [Self; 3] = [Self::Ontology, Self::Shacl, Self::Shex];
83
84 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct SchemaGraph {
129 pub graph: GraphName,
130 pub role: SchemaRole,
131 pub iri: Option<NamedNode>,
133 pub version: Option<String>,
135 pub sha256: Option<String>,
137 pub imports: Vec<NamedNode>,
140 pub applies_to: Vec<GraphName>,
143 pub active: bool,
145 pub loaded_at: Option<String>,
147}
148
149impl SchemaGraph {
150 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 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
171pub 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
184pub 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
204fn is_false(l: &Literal) -> bool {
206 l.datatype() == xsd::BOOLEAN && matches!(l.value(), "false" | "0")
207}
208
209fn 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
217fn now() -> Option<String> {
219 Some(oxsdatatypes::DateTime::now().to_string())
220}
221
222pub 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
263pub 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
279pub fn unregister_update(graph: GraphNameRef<'_>) -> Result<String> {
281 Ok(describe_delete(&graph_node(graph)?))
282}
283
284pub 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
295pub 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
305pub 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
314pub 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
324pub fn entries_query() -> String {
326 format!(
327 "SELECT ?g ?p ?o WHERE {{ GRAPH {} {{ ?g ?p ?o }} }}",
328 iri(SCHEMA_GRAPH)
329 )
330}
331
332pub 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 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
397fn registry_graph(g: GraphNameRef<'_>) -> bool {
400 matches!(g, GraphNameRef::NamedNode(n) if n.as_str() == SCHEMA_GRAPH)
401}
402
403pub fn is_registry_quad(q: QuadRef<'_>) -> bool {
405 registry_graph(q.graph_name)
406}
407
408pub 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
451struct Ids {
455 reg: i64,
456 ty: i64,
457 applies: i64,
458 active: i64,
459 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
488pub fn all_scope() -> i64 {
490 named_node_id(vocab::ALL_GRAPHS)
491}
492
493pub fn registry_graph_id() -> i64 {
495 named_node_id(SCHEMA_GRAPH)
496}
497
498fn 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
506fn 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
536fn system_graphs() -> String {
539 SYSTEM_GRAPHS
540 .iter()
541 .map(|g| named_node_id(g).to_string())
542 .collect::<Vec<_>>()
543 .join(", ")
544}
545
546pub 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
561pub fn scoped_quads(role: SchemaRole) -> String {
563 format!("(SELECT s, p, o, g FROM quads WHERE {})", scope(role, "g"))
564}
565
566fn 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
584pub fn quads_without_schema_graphs() -> String {
587 without_schema_graphs("quads")
588}
589
590pub 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
600pub fn ontology_axioms(cond: &str) -> String {
609 let i = ids();
610 let act = active_nodes(&i, &[SchemaRole::Ontology]);
611 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 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 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 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 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
673pub 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
683pub 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(®istry, rdf::TYPE.as_str(), system.clone()),
705 t(
706 ®istry,
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
731pub 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
750pub 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
761pub 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
867pub 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
878pub 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
922pub 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 #[test]
1023 fn readers_agree_on_booleans_and_roles() {
1024 let g = "http://ex.org/g";
1025 let ty = rdf::TYPE.as_str();
1026 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 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 let sql = active_nodes(&ids(), &[SchemaRole::Ontology]);
1048 assert!(sql.contains(&ids().falses[1].to_string()));
1049 }
1050
1051 #[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 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 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 #[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}