Skip to main content

oxilite_core/
registry.rs

1//! The schema registry: which named graphs hold schema rather than data.
2//!
3//! A schema graph is an ordinary named graph whose triples stay in `quads`; `schema_graphs`
4//! only labels it with a [`SchemaRole`]. Registering an ontology narrows the TBox closure to
5//! the active ontology graphs ([`crate::reason`]); registering a shapes graph narrows the
6//! compiled shape index ([`crate::shapes`]). While nothing is registered for a role, every
7//! graph may contribute to it, so a store that uses no registry behaves as it always did.
8//!
9// @lat: [[architecture#Schema registry]]
10
11use crate::encoding::DEFAULT_GRAPH_ID;
12use crate::error::Result;
13use crate::sql::{col, sql_str, Capabilities, Request, Response, Statement};
14
15/// What a registered graph holds.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
17#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
18#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
19#[repr(i64)]
20pub enum SchemaRole {
21    /// An OWL / RDFS ontology: it feeds `tbox_closure`.
22    Ontology = 1,
23    /// A SHACL shapes graph: it feeds `shapes_index`.
24    Shacl = 2,
25    /// A ShEx schema. Recorded and hidden like the others; nothing compiles it yet.
26    Shex = 3,
27}
28
29impl SchemaRole {
30    pub fn from_i64(v: i64) -> Option<Self> {
31        Some(match v {
32            1 => Self::Ontology,
33            2 => Self::Shacl,
34            3 => Self::Shex,
35            _ => return None,
36        })
37    }
38
39    pub const fn as_i64(self) -> i64 {
40        self as i64
41    }
42}
43
44/// One row of `schema_graphs`.
45#[derive(Debug, Clone, PartialEq)]
46#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
47#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
48pub struct SchemaGraph {
49    /// Term id of the graph name (0 is the default graph).
50    pub graph: i64,
51    pub role: SchemaRole,
52    /// The `owl:Ontology` IRI, when it differs from the graph name.
53    pub iri: Option<String>,
54    /// `owl:versionIRI`, a version string, or anything the caller wants to pin.
55    pub version: Option<String>,
56    /// Digest of the loaded document, for drift detection by the caller.
57    pub sha256: Option<String>,
58    /// `owl:imports` targets, as the caller recorded them.
59    pub imports: Vec<String>,
60    /// Inactive graphs stay registered (and hidden) but stop contributing.
61    pub active: bool,
62    /// Seconds since the Unix epoch, as the caller recorded them.
63    pub loaded_at: f64,
64}
65
66impl SchemaGraph {
67    /// A registration with only the required fields filled in.
68    pub fn new(graph: i64, role: SchemaRole) -> Self {
69        Self {
70            graph,
71            role,
72            iri: None,
73            version: None,
74            sha256: None,
75            imports: Vec::new(),
76            active: true,
77            loaded_at: 0.0,
78        }
79    }
80}
81
82fn opt_str(v: Option<&String>) -> String {
83    v.map_or_else(|| "NULL".into(), |s| sql_str(s))
84}
85
86/// Registers (or replaces the registration of) one graph.
87pub fn register_statements(entry: &SchemaGraph) -> Vec<Statement> {
88    // `imports` is stored as newline-separated IRIs: an IRI cannot contain a newline, so no
89    // escaping is needed and no JSON parser is pulled into the core.
90    let imports = if entry.imports.is_empty() {
91        "NULL".to_string()
92    } else {
93        sql_str(&entry.imports.join("\n"))
94    };
95    vec![Statement::new(format!(
96        "INSERT OR REPLACE INTO schema_graphs(g, role, iri, version, sha256, imports, active, loaded_at) \
97         VALUES ({g}, {role}, {iri}, {version}, {sha}, {imports}, {active}, {loaded_at})",
98        g = entry.graph,
99        role = entry.role.as_i64(),
100        iri = opt_str(entry.iri.as_ref()),
101        version = opt_str(entry.version.as_ref()),
102        sha = opt_str(entry.sha256.as_ref()),
103        active = i64::from(entry.active),
104        loaded_at = entry.loaded_at,
105    ))]
106}
107
108/// Removes a registration, leaving the graph's triples alone.
109pub fn unregister_statements(graph: i64) -> Vec<Statement> {
110    vec![Statement::new(format!(
111        "DELETE FROM schema_graphs WHERE g = {graph}"
112    ))]
113}
114
115/// Activates or deactivates a registration.
116pub fn set_active_statements(graph: i64, active: bool) -> Vec<Statement> {
117    vec![Statement::new(format!(
118        "UPDATE schema_graphs SET active = {} WHERE g = {graph}",
119        i64::from(active)
120    ))]
121}
122
123/// Removes a registration together with every quad of its graph.
124pub fn drop_statements(graph: i64) -> Vec<Statement> {
125    let mut s = vec![Statement::new(format!(
126        "DELETE FROM quads WHERE g = {graph}"
127    ))];
128    if graph != DEFAULT_GRAPH_ID {
129        s.push(Statement::new(format!(
130            "DELETE FROM graphs WHERE id = {graph}"
131        )));
132    }
133    s.extend(unregister_statements(graph));
134    s
135}
136
137/// Reads the whole registry.
138pub fn load_request(caps: &Capabilities) -> Request {
139    let g = if caps.int64_as_text {
140        "CAST(g AS TEXT)"
141    } else {
142        "g"
143    };
144    Request::read(vec![Statement::new(format!(
145        "SELECT {g}, role, iri, version, sha256, imports, active, loaded_at FROM schema_graphs ORDER BY role, g"
146    ))])
147}
148
149/// Decodes the response of [`load_request`].
150pub fn from_response(response: &Response) -> Result<Vec<SchemaGraph>> {
151    let Some(rs) = response.first() else {
152        return Ok(Vec::new());
153    };
154    let mut out = Vec::with_capacity(rs.rows.len());
155    for row in &rs.rows {
156        let (Some(graph), Some(role)) = (col(row, 0)?.as_i64(), col(row, 1)?.as_i64()) else {
157            continue;
158        };
159        let Some(role) = SchemaRole::from_i64(role) else {
160            continue;
161        };
162        let text = |i: usize| -> Result<Option<String>> {
163            Ok(col(row, i)?.clone().into_string().filter(|s| !s.is_empty()))
164        };
165        out.push(SchemaGraph {
166            graph,
167            role,
168            iri: text(2)?,
169            version: text(3)?,
170            sha256: text(4)?,
171            imports: text(5)?
172                .map(|s| s.split('\n').map(str::to_owned).collect())
173                .unwrap_or_default(),
174            active: col(row, 6)?.as_i64().unwrap_or(1) != 0,
175            loaded_at: col(row, 7)?.as_f64().unwrap_or(0.0),
176        });
177    }
178    Ok(out)
179}
180
181/// SQL: the graphs that may contribute to `role` — the active registered ones, or every graph
182/// while none is registered for it.
183///
184/// `column` is the graph column of the quad source being filtered (e.g. `"g"`, `"x.g"`).
185pub fn scope(role: SchemaRole, column: &str) -> String {
186    let r = role.as_i64();
187    format!(
188        "(NOT EXISTS (SELECT 1 FROM schema_graphs WHERE role = {r} AND active = 1) \
189         OR {column} IN (SELECT g FROM schema_graphs WHERE role = {r} AND active = 1))"
190    )
191}
192
193/// SQL: a quad source restricted to the graphs that may contribute to `role`.
194pub fn scoped_quads(role: SchemaRole) -> String {
195    format!("(SELECT s, p, o, g FROM quads WHERE {})", scope(role, "g"))
196}
197
198/// SQL: a quad source with every registered schema graph removed, whatever its role and
199/// whether or not it is active (see `QueryOptions::include_schema_graphs`).
200pub fn quads_without_schema_graphs() -> &'static str {
201    "(SELECT s, p, o, g FROM quads WHERE g NOT IN (SELECT g FROM schema_graphs))"
202}
203
204/// SQL: `source` (a quad table) with every registered schema graph removed.
205pub fn without_schema_graphs(source: &str) -> String {
206    format!("(SELECT s, p, o, g FROM {source} WHERE g NOT IN (SELECT g FROM schema_graphs))")
207}