Skip to main content

pyshacl_turbo_engine/
graph.rs

1use std::collections::{HashMap, HashSet, VecDeque};
2
3use oxigraph::io::{RdfFormat, RdfParser};
4use oxigraph::model::*;
5
6use crate::{Error, GraphRead, Result};
7
8#[cfg(test)]
9#[path = "graph_tests.rs"]
10mod tests;
11
12pub const SH: &str = "http://www.w3.org/ns/shacl#";
13pub const RDF: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
14pub const RDFS: &str = "http://www.w3.org/2000/01/rdf-schema#";
15pub const XSD: &str = "http://www.w3.org/2001/XMLSchema#";
16
17pub fn iri(namespace: &str, name: &str) -> NamedNode {
18    NamedNode::new_unchecked(format!("{namespace}{name}"))
19}
20
21pub fn subject(term: &Term) -> Result<NamedOrBlankNode> {
22    match term {
23        Term::NamedNode(n) => Ok(n.clone().into()),
24        Term::BlankNode(n) => Ok(n.clone().into()),
25        _ => Err(Error::Invalid(format!(
26            "Expected a shape IRI or blank node, got {term}"
27        ))),
28    }
29}
30
31/// Preserve blank-node identities and literal spellings/types while parsing.
32/// Oxigraph Store would canonicalize terms; oxrdf Dataset retains exact terms.
33pub fn parse(input: &str, format: RdfFormat, base_iri: Option<&str>) -> Result<Dataset> {
34    parse_with_graphs(input, format, base_iri, false, false)
35}
36
37pub fn parse_dataset(input: &str, format: RdfFormat, base_iri: Option<&str>) -> Result<Dataset> {
38    parse_with_graphs(input, format, base_iri, true, false)
39}
40
41/// Store APIs expose RDF 1.1 strings as explicit xsd:string to RDFLib callers.
42/// Their N-Quads writer abbreviates the datatype, so restore that adapter
43/// identity while parsing, without altering ordinary document/Graph input.
44pub fn parse_store_snapshot(input: &str) -> Result<Dataset> {
45    parse_with_graphs(input, RdfFormat::NQuads, None, true, true)
46}
47
48pub fn store_quad(mut quad: Quad) -> Quad {
49    if let Term::Literal(literal) = &quad.object
50        && literal.datatype() == iri(XSD, "string").as_ref()
51        && !literal.has_explicit_datatype()
52    {
53        quad.object = Literal::new_typed_literal(literal.value(), iri(XSD, "string")).into();
54    }
55    quad
56}
57
58fn parse_with_graphs(
59    input: &str,
60    format: RdfFormat,
61    base_iri: Option<&str>,
62    named: bool,
63    store_strings: bool,
64) -> Result<Dataset> {
65    let mut store = Dataset::new();
66    let mut parser = RdfParser::from_format(format);
67    if !named {
68        parser = parser.without_named_graphs();
69    }
70    if let Some(base) = base_iri {
71        parser = parser
72            .with_base_iri(base)
73            .map_err(|e| Error::Invalid(format!("Invalid base IRI: {e}")))?;
74    }
75    let quads = parser
76        .for_slice(input.as_bytes())
77        .map(|q| q.map(|q| if store_strings { store_quad(q) } else { q }))
78        .collect::<std::result::Result<Vec<_>, _>>()
79        .map_err(|e| Error::Invalid(format!("RDF parse error: {e}")))?;
80    store.extend(quads);
81    Ok(store)
82}
83
84pub fn serialize(store: &Dataset) -> Result<String> {
85    let mut writer =
86        oxigraph::io::RdfSerializer::from_format(RdfFormat::NQuads).for_writer(Vec::new());
87    for quad in store.iter() {
88        writer
89            .serialize_quad(quad)
90            .map_err(|e| Error::Runtime(e.to_string()))?;
91    }
92    String::from_utf8(writer.finish().map_err(|e| Error::Runtime(e.to_string()))?)
93        .map_err(|e| Error::Runtime(e.to_string()))
94}
95
96#[cfg(test)]
97thread_local! {
98    pub(crate) static UNION_BUILDS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
99}
100
101/// Default-union view retaining the original named graphs for GRAPH clauses.
102pub fn with_union(data: &Dataset) -> Dataset {
103    #[cfg(test)]
104    UNION_BUILDS.with(|count| count.set(count.get() + 1));
105    let mut union = data.clone();
106    union.extend(
107        data.iter()
108            .map(|q| Quad::new(q.subject, q.predicate, q.object, GraphName::DefaultGraph)),
109    );
110    union
111}
112
113pub fn commit_additions(data: &mut Dataset, before: &Dataset, after: &Dataset) {
114    data.extend(
115        after
116            .iter()
117            .filter(|q| !before.contains(*q))
118            .map(|q| q.into_owned()),
119    );
120}
121
122pub fn objects(
123    store: &(impl GraphRead + ?Sized),
124    node: &Term,
125    predicate: &NamedNode,
126) -> Result<Vec<Term>> {
127    let Ok(node) = subject(node) else {
128        return Ok(Vec::new());
129    };
130    store
131        .matching_quads(
132            Some(node.as_ref()),
133            Some(predicate.as_ref()),
134            None,
135            Some(GraphNameRef::DefaultGraph),
136        )
137        .map(|q| q.map(|q| q.object))
138        .collect()
139}
140
141pub fn values(store: &(impl GraphRead + ?Sized), node: &Term, name: &str) -> Result<Vec<Term>> {
142    objects(store, node, &iri(SH, name))
143}
144
145pub fn one(store: &(impl GraphRead + ?Sized), node: &Term, name: &str) -> Result<Option<Term>> {
146    let mut terms = values(store, node, name)?;
147    if terms.len() > 1 {
148        return Err(Error::Invalid(format!(
149            "{node} has multiple sh:{name} values"
150        )));
151    }
152    Ok(terms.pop())
153}
154
155pub fn boolean(store: &(impl GraphRead + ?Sized), node: &Term, name: &str) -> Result<bool> {
156    match one(store, node, name)? {
157        None => Ok(false),
158        Some(Term::Literal(v)) if v.datatype() == iri(XSD, "boolean").as_ref() => match v.value() {
159            "true" | "1" => Ok(true),
160            "false" | "0" => Ok(false),
161            _ => Err(Error::Invalid(format!(
162                "Invalid sh:{name} boolean on {node}"
163            ))),
164        },
165        _ => Err(Error::Invalid(format!(
166            "sh:{name} must be an xsd:boolean on {node}"
167        ))),
168    }
169}
170
171pub fn subjects(
172    store: &(impl GraphRead + ?Sized),
173    predicate: &NamedNode,
174    object: Option<&Term>,
175) -> Result<Vec<Term>> {
176    store
177        .matching_quads(
178            None,
179            Some(predicate.as_ref()),
180            object.map(Term::as_ref),
181            Some(GraphNameRef::DefaultGraph),
182        )
183        .map(|q| q.map(|q| q.subject.into()))
184        .collect()
185}
186
187pub fn add(store: &mut Dataset, s: &Term, p: NamedNode, o: Term) -> Result<()> {
188    store.insert(&Quad::new(subject(s)?, p, o, GraphName::DefaultGraph));
189    Ok(())
190}
191
192/// Preserve each directly referenced blank node, and clone its descendants into
193/// a separate report description. Cache roots, matching pySHACL report assembly;
194/// unlike its depth cutoff, use a visited map to handle cycles without truncation.
195#[derive(Default)]
196pub(crate) struct ReportClones(HashSet<(usize, Term)>);
197
198impl ReportClones {
199    pub fn copy(&mut self, source: &Dataset, target: &mut Dataset, node: &Term) -> Result<()> {
200        if !matches!(node, Term::BlankNode(_))
201            || !self
202                .0
203                .insert((std::ptr::from_ref(source) as usize, node.clone()))
204        {
205            return Ok(());
206        }
207        let mut mapping = HashMap::from([(node.clone(), node.clone())]);
208        let mut queue = VecDeque::from([node.clone()]);
209        let mut seen = HashSet::new();
210        while let Some(node) = queue.pop_front() {
211            if !seen.insert(node.clone()) {
212                continue;
213            }
214            let cloned = mapping[&node].clone();
215            for quad in source.quads_for_subject(subject(&node)?.as_ref()) {
216                let object = quad.object.into_owned();
217                let copied = if matches!(object, Term::BlankNode(_)) {
218                    queue.push_back(object.clone());
219                    mapping
220                        .entry(object)
221                        .or_insert_with(|| BlankNode::default().into())
222                        .clone()
223                } else {
224                    object
225                };
226                add(target, &cloned, quad.predicate.into_owned(), copied)?;
227            }
228        }
229        Ok(())
230    }
231}
232
233pub(crate) fn instances(graph: &(impl GraphRead + ?Sized), class: &Term) -> Result<HashSet<Term>> {
234    let mut result = HashSet::new();
235    let mut seen = HashSet::new();
236    let mut pending = vec![class.clone()];
237    while let Some(class) = pending.pop() {
238        if !seen.insert(class.clone()) {
239            continue;
240        }
241        result.extend(subjects(graph, &iri(RDF, "type"), Some(&class))?);
242        pending.extend(subjects(graph, &iri(RDFS, "subClassOf"), Some(&class))?);
243    }
244    Ok(result)
245}