Skip to main content

oxilite_core/
shapes.rs

1//! The compiled SHACL shape index.
2//!
3//! `shapes_index` and `shapes_in` hold the property shapes of the registered shapes graphs
4//! (every graph while none is registered), pre-resolved to target class, path, `sh:datatype`,
5//! `sh:minCount`, `sh:maxCount`, `sh:pattern`, `sh:in` values and whether the shape is
6//! relationship-valued. They are caches: rebuilt from `quads` by `optimize()` and, inside the
7//! same atomic request, by any write that touches a SHACL predicate — the discipline
8//! `tbox_closure` already follows. Reading them costs one request and no SPARQL evaluation.
9//!
10// @lat: [[architecture#Schema registry#Compiled shape index]]
11
12use crate::encoding::{decode_inline, decode_row, named_node_id, Tag, INT_OFFSET, PAYLOAD_BITS};
13use crate::error::Result;
14use crate::registry::{scoped_quads, SchemaRole};
15use crate::sql::{col, Capabilities, Request, Response, Statement};
16use oxrdf::vocab::rdf;
17use oxrdf::{NamedNode, QuadRef, Term};
18use spargebra::term::NamedNodePattern;
19use spargebra::{GraphUpdateOperation, Update};
20use std::collections::BTreeMap;
21
22const SH: &str = "http://www.w3.org/ns/shacl#";
23
24/// SHACL predicates whose triples change the compiled index.
25///
26/// `rdf:first` / `rdf:rest` are deliberately absent: they would make every write touching any
27/// RDF list refresh the index. A shape and its `sh:in` list are written together in practice,
28/// and the `sh:in` triple itself is a trigger; appending to an existing list without touching a
29/// `sh:` predicate leaves the index stale until the next `optimize()`.
30const SHAPE_LOCALS: [&str; 10] = [
31    "targetClass",
32    "property",
33    "path",
34    "datatype",
35    "minCount",
36    "maxCount",
37    "pattern",
38    "class",
39    "node",
40    "in",
41];
42
43fn sh(local: &str) -> i64 {
44    named_node_id(&format!("{SH}{local}"))
45}
46
47/// Does writing this quad invalidate the shape index?
48pub fn is_shape_quad(q: QuadRef<'_>) -> bool {
49    // The registry graph decides which graphs are shapes graphs.
50    crate::registry::is_registry_quad(q) || is_shape_iri(q.predicate.as_str())
51}
52
53fn is_shape_iri(p: &str) -> bool {
54    p.strip_prefix(SH)
55        .is_some_and(|l| SHAPE_LOCALS.contains(&l))
56}
57
58/// Can this update invalidate the shape index? (Conservative: variables count as shapes.)
59pub fn update_touches_shapes(update: &Update) -> bool {
60    crate::registry::update_touches_registry(update) || touches_shape_triples(update)
61}
62
63fn touches_shape_triples(update: &Update) -> bool {
64    let pattern = |p: &NamedNodePattern| match p {
65        NamedNodePattern::Variable(_) => true,
66        NamedNodePattern::NamedNode(n) => is_shape_iri(n.as_str()),
67    };
68    update.operations.iter().any(|op| match op {
69        GraphUpdateOperation::InsertData { data } => {
70            data.iter().any(|q| is_shape_iri(q.predicate.as_str()))
71        }
72        GraphUpdateOperation::DeleteData { data } => {
73            data.iter().any(|q| is_shape_iri(q.predicate.as_str()))
74        }
75        GraphUpdateOperation::DeleteInsert { delete, insert, .. } => {
76            delete.iter().any(|q| pattern(&q.predicate))
77                || insert.iter().any(|q| pattern(&q.predicate))
78        }
79        GraphUpdateOperation::Create { .. } => false,
80        GraphUpdateOperation::Load { .. }
81        | GraphUpdateOperation::Clear { .. }
82        | GraphUpdateOperation::Drop { .. } => true,
83    })
84}
85
86/// SQL: the integer value of a term id that should be an `xsd:integer` literal. Canonical
87/// integers are inline (`Tag::Integer`); anything else falls back to `terms.num`, and a
88/// non-numeric term decodes to NULL, i.e. "not declared".
89fn int_value(x: &str) -> String {
90    let int_base = Tag::Integer.base() + INT_OFFSET;
91    let k_int = Tag::Integer as i64;
92    format!(
93        "CASE WHEN (({x}) >> {PAYLOAD_BITS}) = {k_int} THEN ({x}) - {int_base} \
94         ELSE (SELECT CAST(n.num AS INTEGER) FROM terms n WHERE n.id = ({x}) AND n.nt IS NOT NULL) END"
95    )
96}
97
98/// Statements rebuilding `shapes_index` and `shapes_in` from the asserted quads.
99pub fn refresh_statements() -> Vec<Statement> {
100    let sq = scoped_quads(SchemaRole::Shacl);
101    // Every (shape, target class, property shape, path) the shapes graphs declare.
102    let ps = format!(
103        "SELECT t.o AS target, pr.o AS pshape, pa.o AS path \
104         FROM {sq} t JOIN {sq} pr ON pr.s = t.s AND pr.p = {property} \
105         JOIN {sq} pa ON pa.s = pr.o AND pa.p = {path} WHERE t.p = {target_class}",
106        property = sh("property"),
107        path = sh("path"),
108        target_class = sh("targetClass"),
109    );
110    // A property shape's single-valued constraint, as the lexical form of its object.
111    let lex_of = |local: &str| {
112        format!(
113            "(SELECT v.lex FROM {sq} x JOIN terms v ON v.id = x.o WHERE x.s = ps.pshape AND x.p = {})",
114            sh(local)
115        )
116    };
117    let int_of = |local: &str| {
118        format!(
119            "(SELECT {} FROM {sq} x WHERE x.s = ps.pshape AND x.p = {})",
120            int_value("x.o"),
121            sh(local)
122        )
123    };
124    // `sh:class` / `sh:node` make a property relationship-valued.
125    let rel = format!(
126        "(SELECT EXISTS (SELECT 1 FROM {sq} x WHERE x.s = ps.pshape AND x.p IN ({}, {})))",
127        sh("class"),
128        sh("node")
129    );
130    // Several shapes may target the same (target, path). MAX ignores NULLs, so grouping
131    // reproduces a field-by-field merge and is deterministic.
132    let index = format!(
133        "INSERT OR REPLACE INTO shapes_index(target, path, datatype, min_count, max_count, pattern, relationship) \
134         SELECT tt.lex, pt.lex, MAX({datatype}), MAX({min}), MAX({max}), MAX({pattern}), MAX({rel}) \
135         FROM ({ps}) ps JOIN terms tt ON tt.id = ps.target JOIN terms pt ON pt.id = ps.path \
136         GROUP BY tt.lex, pt.lex",
137        datatype = lex_of("datatype"),
138        min = int_of("minCount"),
139        max = int_of("maxCount"),
140        pattern = lex_of("pattern"),
141    );
142    // `sh:in` is an RDF list: walk rdf:rest* from the list head, then take each rdf:first.
143    let values = format!(
144        "WITH RECURSIVE cells(pshape, node) AS (\
145            SELECT x.s, x.o FROM {sq} x WHERE x.p = {sh_in} \
146            UNION SELECT c.pshape, r.o FROM cells c JOIN {sq} r ON r.s = c.node AND r.p = {rest}) \
147         INSERT OR REPLACE INTO shapes_in(target, path, id, lex, dt, lang, dir) \
148         SELECT tt.lex, pt.lex, f.o, v.lex, v.dt, v.lang, v.dir \
149         FROM ({ps}) ps JOIN cells c ON c.pshape = ps.pshape \
150         JOIN {sq} f ON f.s = c.node AND f.p = {first} \
151         JOIN terms tt ON tt.id = ps.target JOIN terms pt ON pt.id = ps.path \
152         LEFT JOIN terms v ON v.id = f.o",
153        sh_in = sh("in"),
154        rest = named_node_id(rdf::REST.as_str()),
155        first = named_node_id(rdf::FIRST.as_str()),
156    );
157    vec![
158        Statement::new("DELETE FROM shapes_index"),
159        Statement::new("DELETE FROM shapes_in"),
160        Statement::new(index),
161        Statement::new(values),
162    ]
163}
164
165/// One property shape, merged across every shape declaring it.
166#[derive(Debug, Clone, Default, PartialEq)]
167pub struct PropertyShape {
168    pub datatype: Option<NamedNode>,
169    pub min: Option<i64>,
170    pub max: Option<i64>,
171    pub values_in: Vec<Term>,
172    pub pattern: Option<String>,
173    /// Relationship-valued (`sh:class` / `sh:node`).
174    pub relationship: bool,
175}
176
177/// The compiled property shapes, by target class and path.
178#[derive(Debug, Clone, Default, PartialEq)]
179pub struct ShapeIndex {
180    pub by_class: BTreeMap<NamedNode, BTreeMap<NamedNode, PropertyShape>>,
181}
182
183impl ShapeIndex {
184    pub fn is_empty(&self) -> bool {
185        self.by_class.is_empty()
186    }
187
188    /// The shapes of a path for a node with these classes.
189    pub fn get(&self, class: &NamedNode, path: &NamedNode) -> Option<&PropertyShape> {
190        self.by_class.get(class).and_then(|m| m.get(path))
191    }
192
193    /// Statements loading both tables.
194    pub fn load_request(caps: &Capabilities) -> Request {
195        let id = if caps.int64_as_text {
196            "CAST(id AS TEXT)"
197        } else {
198            "id"
199        };
200        Request::read(vec![
201            Statement::new(
202                "SELECT target, path, datatype, min_count, max_count, pattern, relationship FROM shapes_index",
203            ),
204            Statement::new(format!(
205                "SELECT target, path, {id}, lex, dt, lang, dir FROM shapes_in"
206            )),
207        ])
208    }
209
210    /// Decodes the response of [`Self::load_request`].
211    pub fn from_response(response: &Response) -> Result<Self> {
212        let mut me = Self::default();
213        let Some(index) = response.first() else {
214            return Ok(me);
215        };
216        for row in &index.rows {
217            let (Some(target), Some(path)) = (named(col(row, 0)?), named(col(row, 1)?)) else {
218                continue;
219            };
220            let shape = me
221                .by_class
222                .entry(target)
223                .or_default()
224                .entry(path)
225                .or_default();
226            shape.datatype = named(col(row, 2)?);
227            shape.min = col(row, 3)?.as_i64();
228            shape.max = col(row, 4)?.as_i64();
229            shape.pattern = col(row, 5)?.clone().into_string();
230            shape.relationship = col(row, 6)?.as_i64().unwrap_or(0) != 0;
231        }
232        let Some(values) = response.get(1) else {
233            return Ok(me);
234        };
235        for row in &values.rows {
236            let (Some(target), Some(path), Some(id)) = (
237                named(col(row, 0)?),
238                named(col(row, 1)?),
239                col(row, 2)?.as_i64(),
240            ) else {
241                continue;
242            };
243            let term = match col(row, 3)?.clone().into_string() {
244                // A hashed term: rebuild it from its `terms` row.
245                Some(lex) => decode_row(
246                    id,
247                    lex,
248                    col(row, 4)?.clone().into_string(),
249                    col(row, 5)?.clone().into_string(),
250                    col(row, 6)?.as_i64(),
251                )?,
252                // An inline value (integer or boolean) has no `terms` row.
253                None => match decode_inline(id) {
254                    Some(t) => t,
255                    None => continue,
256                },
257            };
258            let shape = me
259                .by_class
260                .entry(target)
261                .or_default()
262                .entry(path)
263                .or_default();
264            if !shape.values_in.contains(&term) {
265                shape.values_in.push(term);
266            }
267        }
268        Ok(me)
269    }
270}
271
272fn named(v: &crate::sql::SqlValue) -> Option<NamedNode> {
273    NamedNode::new(v.as_str()?).ok()
274}