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    is_shape_iri(q.predicate.as_str())
50}
51
52fn is_shape_iri(p: &str) -> bool {
53    p.strip_prefix(SH)
54        .is_some_and(|l| SHAPE_LOCALS.contains(&l))
55}
56
57/// Can this update invalidate the shape index? (Conservative: variables count as shapes.)
58pub fn update_touches_shapes(update: &Update) -> bool {
59    let pattern = |p: &NamedNodePattern| match p {
60        NamedNodePattern::Variable(_) => true,
61        NamedNodePattern::NamedNode(n) => is_shape_iri(n.as_str()),
62    };
63    update.operations.iter().any(|op| match op {
64        GraphUpdateOperation::InsertData { data } => {
65            data.iter().any(|q| is_shape_iri(q.predicate.as_str()))
66        }
67        GraphUpdateOperation::DeleteData { data } => {
68            data.iter().any(|q| is_shape_iri(q.predicate.as_str()))
69        }
70        GraphUpdateOperation::DeleteInsert { delete, insert, .. } => {
71            delete.iter().any(|q| pattern(&q.predicate))
72                || insert.iter().any(|q| pattern(&q.predicate))
73        }
74        GraphUpdateOperation::Create { .. } => false,
75        GraphUpdateOperation::Load { .. }
76        | GraphUpdateOperation::Clear { .. }
77        | GraphUpdateOperation::Drop { .. } => true,
78    })
79}
80
81/// SQL: the integer value of a term id that should be an `xsd:integer` literal. Canonical
82/// integers are inline (`Tag::Integer`); anything else falls back to `terms.num`, and a
83/// non-numeric term decodes to NULL, i.e. "not declared".
84fn int_value(x: &str) -> String {
85    let int_base = Tag::Integer.base() + INT_OFFSET;
86    let k_int = Tag::Integer as i64;
87    format!(
88        "CASE WHEN (({x}) >> {PAYLOAD_BITS}) = {k_int} THEN ({x}) - {int_base} \
89         ELSE (SELECT CAST(n.num AS INTEGER) FROM terms n WHERE n.id = ({x}) AND n.nt IS NOT NULL) END"
90    )
91}
92
93/// Statements rebuilding `shapes_index` and `shapes_in` from the asserted quads.
94pub fn refresh_statements() -> Vec<Statement> {
95    let sq = scoped_quads(SchemaRole::Shacl);
96    // Every (shape, target class, property shape, path) the shapes graphs declare.
97    let ps = format!(
98        "SELECT t.o AS target, pr.o AS pshape, pa.o AS path \
99         FROM {sq} t JOIN {sq} pr ON pr.s = t.s AND pr.p = {property} \
100         JOIN {sq} pa ON pa.s = pr.o AND pa.p = {path} WHERE t.p = {target_class}",
101        property = sh("property"),
102        path = sh("path"),
103        target_class = sh("targetClass"),
104    );
105    // A property shape's single-valued constraint, as the lexical form of its object.
106    let lex_of = |local: &str| {
107        format!(
108            "(SELECT v.lex FROM {sq} x JOIN terms v ON v.id = x.o WHERE x.s = ps.pshape AND x.p = {})",
109            sh(local)
110        )
111    };
112    let int_of = |local: &str| {
113        format!(
114            "(SELECT {} FROM {sq} x WHERE x.s = ps.pshape AND x.p = {})",
115            int_value("x.o"),
116            sh(local)
117        )
118    };
119    // `sh:class` / `sh:node` make a property relationship-valued.
120    let rel = format!(
121        "(SELECT EXISTS (SELECT 1 FROM {sq} x WHERE x.s = ps.pshape AND x.p IN ({}, {})))",
122        sh("class"),
123        sh("node")
124    );
125    // Several shapes may target the same (target, path). MAX ignores NULLs, so grouping
126    // reproduces a field-by-field merge and is deterministic.
127    let index = format!(
128        "INSERT OR REPLACE INTO shapes_index(target, path, datatype, min_count, max_count, pattern, relationship) \
129         SELECT tt.lex, pt.lex, MAX({datatype}), MAX({min}), MAX({max}), MAX({pattern}), MAX({rel}) \
130         FROM ({ps}) ps JOIN terms tt ON tt.id = ps.target JOIN terms pt ON pt.id = ps.path \
131         GROUP BY tt.lex, pt.lex",
132        datatype = lex_of("datatype"),
133        min = int_of("minCount"),
134        max = int_of("maxCount"),
135        pattern = lex_of("pattern"),
136    );
137    // `sh:in` is an RDF list: walk rdf:rest* from the list head, then take each rdf:first.
138    let values = format!(
139        "WITH RECURSIVE cells(pshape, node) AS (\
140            SELECT x.s, x.o FROM {sq} x WHERE x.p = {sh_in} \
141            UNION SELECT c.pshape, r.o FROM cells c JOIN {sq} r ON r.s = c.node AND r.p = {rest}) \
142         INSERT OR REPLACE INTO shapes_in(target, path, id, lex, dt, lang, dir) \
143         SELECT tt.lex, pt.lex, f.o, v.lex, v.dt, v.lang, v.dir \
144         FROM ({ps}) ps JOIN cells c ON c.pshape = ps.pshape \
145         JOIN {sq} f ON f.s = c.node AND f.p = {first} \
146         JOIN terms tt ON tt.id = ps.target JOIN terms pt ON pt.id = ps.path \
147         LEFT JOIN terms v ON v.id = f.o",
148        sh_in = sh("in"),
149        rest = named_node_id(rdf::REST.as_str()),
150        first = named_node_id(rdf::FIRST.as_str()),
151    );
152    vec![
153        Statement::new("DELETE FROM shapes_index"),
154        Statement::new("DELETE FROM shapes_in"),
155        Statement::new(index),
156        Statement::new(values),
157    ]
158}
159
160/// One property shape, merged across every shape declaring it.
161#[derive(Debug, Clone, Default, PartialEq)]
162pub struct PropertyShape {
163    pub datatype: Option<NamedNode>,
164    pub min: Option<i64>,
165    pub max: Option<i64>,
166    pub values_in: Vec<Term>,
167    pub pattern: Option<String>,
168    /// Relationship-valued (`sh:class` / `sh:node`).
169    pub relationship: bool,
170}
171
172/// The compiled property shapes, by target class and path.
173#[derive(Debug, Clone, Default, PartialEq)]
174pub struct ShapeIndex {
175    pub by_class: BTreeMap<NamedNode, BTreeMap<NamedNode, PropertyShape>>,
176}
177
178impl ShapeIndex {
179    pub fn is_empty(&self) -> bool {
180        self.by_class.is_empty()
181    }
182
183    /// The shapes of a path for a node with these classes.
184    pub fn get(&self, class: &NamedNode, path: &NamedNode) -> Option<&PropertyShape> {
185        self.by_class.get(class).and_then(|m| m.get(path))
186    }
187
188    /// Statements loading both tables.
189    pub fn load_request(caps: &Capabilities) -> Request {
190        let id = if caps.int64_as_text {
191            "CAST(id AS TEXT)"
192        } else {
193            "id"
194        };
195        Request::read(vec![
196            Statement::new(
197                "SELECT target, path, datatype, min_count, max_count, pattern, relationship FROM shapes_index",
198            ),
199            Statement::new(format!(
200                "SELECT target, path, {id}, lex, dt, lang, dir FROM shapes_in"
201            )),
202        ])
203    }
204
205    /// Decodes the response of [`Self::load_request`].
206    pub fn from_response(response: &Response) -> Result<Self> {
207        let mut me = Self::default();
208        let Some(index) = response.first() else {
209            return Ok(me);
210        };
211        for row in &index.rows {
212            let (Some(target), Some(path)) = (named(col(row, 0)?), named(col(row, 1)?)) else {
213                continue;
214            };
215            let shape = me.by_class.entry(target).or_default().entry(path).or_default();
216            shape.datatype = named(col(row, 2)?);
217            shape.min = col(row, 3)?.as_i64();
218            shape.max = col(row, 4)?.as_i64();
219            shape.pattern = col(row, 5)?.clone().into_string();
220            shape.relationship = col(row, 6)?.as_i64().unwrap_or(0) != 0;
221        }
222        let Some(values) = response.get(1) else {
223            return Ok(me);
224        };
225        for row in &values.rows {
226            let (Some(target), Some(path), Some(id)) =
227                (named(col(row, 0)?), named(col(row, 1)?), col(row, 2)?.as_i64())
228            else {
229                continue;
230            };
231            let term = match col(row, 3)?.clone().into_string() {
232                // A hashed term: rebuild it from its `terms` row.
233                Some(lex) => decode_row(
234                    id,
235                    lex,
236                    col(row, 4)?.clone().into_string(),
237                    col(row, 5)?.clone().into_string(),
238                    col(row, 6)?.as_i64(),
239                )?,
240                // An inline value (integer or boolean) has no `terms` row.
241                None => match decode_inline(id) {
242                    Some(t) => t,
243                    None => continue,
244                },
245            };
246            let shape = me.by_class.entry(target).or_default().entry(path).or_default();
247            if !shape.values_in.contains(&term) {
248                shape.values_in.push(term);
249            }
250        }
251        Ok(me)
252    }
253}
254
255fn named(v: &crate::sql::SqlValue) -> Option<NamedNode> {
256    NamedNode::new(v.as_str()?).ok()
257}