Skip to main content

geoff_graph/
store.rs

1use camino::Utf8Path;
2use geoff_core::types::ObjectValue;
3use oxigraph::io::{RdfFormat, RdfParser};
4use oxigraph::model::{GraphNameRef, Literal, NamedNodeRef, QuadRef, Term};
5use oxigraph::sparql::{QueryResults, SparqlEvaluator};
6use oxigraph::store::Store;
7use serde_json::{Map, Value};
8
9/// Wraps Oxigraph `Store`, providing named-graph-aware RDF operations.
10/// No other crate should import oxigraph directly.
11#[derive(Clone)]
12pub struct ContentStore {
13    store: Store,
14}
15
16impl ContentStore {
17    /// Create a new in-memory content store.
18    pub fn new() -> std::result::Result<Self, Box<dyn std::error::Error>> {
19        Ok(Self {
20            store: Store::new()?,
21        })
22    }
23
24    /// Insert a triple into the specified named graph.
25    pub fn insert_triple_into(
26        &self,
27        subject: &str,
28        predicate: &str,
29        object: &ObjectValue,
30        graph: &str,
31    ) -> std::result::Result<(), Box<dyn std::error::Error>> {
32        let s_node = NamedNodeRef::new(subject)?;
33        let p_node = NamedNodeRef::new(predicate)?;
34        let g_node = NamedNodeRef::new(graph)?;
35
36        match object {
37            ObjectValue::Iri(iri) => {
38                let o_node = NamedNodeRef::new(iri)?;
39                self.store.insert(QuadRef::new(
40                    s_node,
41                    p_node,
42                    o_node,
43                    GraphNameRef::NamedNode(g_node),
44                ))?;
45            }
46            ObjectValue::Literal(value) => {
47                let lit = Literal::new_simple_literal(value);
48                self.store.insert(QuadRef::new(
49                    s_node,
50                    p_node,
51                    lit.as_ref(),
52                    GraphNameRef::NamedNode(g_node),
53                ))?;
54            }
55            ObjectValue::TypedLiteral { value, datatype } => {
56                let dt_node = NamedNodeRef::new(datatype)?;
57                let lit = Literal::new_typed_literal(value, dt_node);
58                self.store.insert(QuadRef::new(
59                    s_node,
60                    p_node,
61                    lit.as_ref(),
62                    GraphNameRef::NamedNode(g_node),
63                ))?;
64            }
65        }
66        Ok(())
67    }
68
69    /// Execute a SPARQL SELECT or ASK query and return results as JSON.
70    pub fn query_to_json(
71        &self,
72        sparql: &str,
73    ) -> std::result::Result<Value, Box<dyn std::error::Error>> {
74        let results = SparqlEvaluator::new()
75            .parse_query(sparql)?
76            .on_store(&self.store)
77            .execute()?;
78
79        match results {
80            QueryResults::Solutions(solutions) => {
81                let variables: Vec<String> = solutions
82                    .variables()
83                    .iter()
84                    .map(|v| v.as_str().to_owned())
85                    .collect();
86
87                let mut rows = Vec::new();
88                for solution in solutions {
89                    let solution = solution?;
90                    let mut row = Map::new();
91                    for var in &variables {
92                        let value =
93                            solution
94                                .get(var.as_str())
95                                .map_or(Value::Null, |term| match term {
96                                    Term::Literal(lit) => Value::String(lit.value().to_string()),
97                                    other => Value::String(other.to_string()),
98                                });
99                        row.insert(var.clone(), value);
100                    }
101                    rows.push(Value::Object(row));
102                }
103                Ok(Value::Array(rows))
104            }
105            QueryResults::Boolean(b) => Ok(Value::Bool(b)),
106            QueryResults::Graph(_) => Err("CONSTRUCT/DESCRIBE queries not supported".into()),
107        }
108    }
109
110    /// Load a Turtle (.ttl) file into the default graph.
111    pub fn load_turtle(
112        &self,
113        path: &Utf8Path,
114    ) -> std::result::Result<(), Box<dyn std::error::Error>> {
115        let file = std::fs::File::open(path.as_std_path())?;
116        let reader = std::io::BufReader::new(file);
117        self.store.load_from_reader(RdfFormat::Turtle, reader)?;
118        Ok(())
119    }
120
121    /// Load a Turtle (.ttl) file into a specific named graph.
122    pub fn load_turtle_into(
123        &self,
124        path: &Utf8Path,
125        graph: &str,
126    ) -> std::result::Result<(), Box<dyn std::error::Error>> {
127        let content = std::fs::read_to_string(path)?;
128        let g_node = NamedNodeRef::new(graph)?;
129        self.store.load_from_reader(
130            RdfParser::from_format(RdfFormat::Turtle)
131                .without_named_graphs()
132                .with_default_graph(g_node),
133            content.as_bytes(),
134        )?;
135        Ok(())
136    }
137
138    /// Clear all data from the store.
139    pub fn clear(&self) -> std::result::Result<(), Box<dyn std::error::Error>> {
140        self.store.clear()?;
141        Ok(())
142    }
143
144    /// Export all triples (flattened from all named graphs) as NTriples.
145    ///
146    /// This is useful for SHACL validation, which operates on a flat graph.
147    pub fn export_turtle(&self) -> std::result::Result<String, Box<dyn std::error::Error>> {
148        let mut out = String::new();
149        for quad in self.store.iter() {
150            let quad = quad?;
151            use std::fmt::Write;
152            writeln!(out, "{} {} {} .", quad.subject, quad.predicate, quad.object)?;
153        }
154        Ok(out)
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn insert_and_query_named_graph() -> std::result::Result<(), Box<dyn std::error::Error>> {
164        let store = ContentStore::new()?;
165        store.insert_triple_into(
166            "urn:geoff:content:blog/hello.md",
167            "http://schema.org/name",
168            &ObjectValue::Literal("Hello World".into()),
169            "urn:geoff:content:blog/hello.md",
170        )?;
171
172        let json = store.query_to_json(
173            "SELECT ?name WHERE { GRAPH <urn:geoff:content:blog/hello.md> { ?s <http://schema.org/name> ?name } }",
174        )?;
175
176        let rows = json.as_array().unwrap();
177        assert_eq!(rows.len(), 1);
178        assert_eq!(rows[0]["name"], "Hello World");
179        Ok(())
180    }
181
182    #[test]
183    fn insert_iri_object() -> std::result::Result<(), Box<dyn std::error::Error>> {
184        let store = ContentStore::new()?;
185        store.insert_triple_into(
186            "urn:geoff:content:blog/hello.md",
187            "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
188            &ObjectValue::Iri("http://schema.org/BlogPosting".into()),
189            "urn:geoff:content:blog/hello.md",
190        )?;
191
192        let json = store.query_to_json(
193            "ASK { GRAPH <urn:geoff:content:blog/hello.md> { <urn:geoff:content:blog/hello.md> a <http://schema.org/BlogPosting> } }",
194        )?;
195        assert_eq!(json, Value::Bool(true));
196        Ok(())
197    }
198
199    #[test]
200    fn insert_typed_literal() -> std::result::Result<(), Box<dyn std::error::Error>> {
201        let store = ContentStore::new()?;
202        store.insert_triple_into(
203            "urn:geoff:content:blog/hello.md",
204            "http://schema.org/datePublished",
205            &ObjectValue::TypedLiteral {
206                value: "2026-04-01".into(),
207                datatype: "http://www.w3.org/2001/XMLSchema#date".into(),
208            },
209            "urn:geoff:content:blog/hello.md",
210        )?;
211
212        // xsd:date typed literals are queryable with SPARQL date functions
213        let json = store.query_to_json(
214            "SELECT ?d WHERE { GRAPH <urn:geoff:content:blog/hello.md> { ?s <http://schema.org/datePublished> ?d } }",
215        )?;
216        let rows = json.as_array().unwrap();
217        assert_eq!(rows.len(), 1);
218        assert_eq!(rows[0]["d"], "2026-04-01");
219        Ok(())
220    }
221
222    #[test]
223    fn clear_empties_store() -> std::result::Result<(), Box<dyn std::error::Error>> {
224        let store = ContentStore::new()?;
225        store.insert_triple_into(
226            "urn:geoff:content:a",
227            "http://example.org/p",
228            &ObjectValue::Literal("v".into()),
229            "urn:geoff:site",
230        )?;
231        store.clear()?;
232
233        let json = store.query_to_json("SELECT ?s ?p ?o WHERE { GRAPH ?g { ?s ?p ?o } }")?;
234        let rows = json.as_array().unwrap();
235        assert!(rows.is_empty());
236        Ok(())
237    }
238}