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 search-relevant triples as N-Triples for client-side SPARQL search.
145    ///
146    /// Includes only predicates useful for search: name, description, date,
147    /// URL, keywords, and rdf:type. The output can be loaded into oxigraph's
148    /// WASM build in the browser.
149    pub fn export_search_ntriples(&self) -> std::result::Result<String, Box<dyn std::error::Error>> {
150        use std::fmt::Write;
151        let search_predicates: std::collections::HashSet<&str> = [
152            "http://schema.org/name",
153            "http://schema.org/description",
154            "http://schema.org/datePublished",
155            "http://schema.org/url",
156            "http://schema.org/keywords",
157            "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
158        ]
159        .into_iter()
160        .collect();
161
162        let mut out = String::new();
163        for quad in self.store.iter() {
164            let quad = quad?;
165            if search_predicates.contains(quad.predicate.as_str()) {
166                writeln!(out, "{} {} {} .", quad.subject, quad.predicate, quad.object)?;
167            }
168        }
169        Ok(out)
170    }
171
172    /// Export all triples (flattened from all named graphs) as NTriples.
173    ///
174    /// This is useful for SHACL validation, which operates on a flat graph.
175    pub fn export_turtle(&self) -> std::result::Result<String, Box<dyn std::error::Error>> {
176        let mut out = String::new();
177        for quad in self.store.iter() {
178            let quad = quad?;
179            use std::fmt::Write;
180            writeln!(out, "{} {} {} .", quad.subject, quad.predicate, quad.object)?;
181        }
182        Ok(out)
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn insert_and_query_named_graph() -> std::result::Result<(), Box<dyn std::error::Error>> {
192        let store = ContentStore::new()?;
193        store.insert_triple_into(
194            "urn:geoff:content:blog/hello.md",
195            "http://schema.org/name",
196            &ObjectValue::Literal("Hello World".into()),
197            "urn:geoff:content:blog/hello.md",
198        )?;
199
200        let json = store.query_to_json(
201            "SELECT ?name WHERE { GRAPH <urn:geoff:content:blog/hello.md> { ?s <http://schema.org/name> ?name } }",
202        )?;
203
204        let rows = json.as_array().unwrap();
205        assert_eq!(rows.len(), 1);
206        assert_eq!(rows[0]["name"], "Hello World");
207        Ok(())
208    }
209
210    #[test]
211    fn insert_iri_object() -> std::result::Result<(), Box<dyn std::error::Error>> {
212        let store = ContentStore::new()?;
213        store.insert_triple_into(
214            "urn:geoff:content:blog/hello.md",
215            "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
216            &ObjectValue::Iri("http://schema.org/BlogPosting".into()),
217            "urn:geoff:content:blog/hello.md",
218        )?;
219
220        let json = store.query_to_json(
221            "ASK { GRAPH <urn:geoff:content:blog/hello.md> { <urn:geoff:content:blog/hello.md> a <http://schema.org/BlogPosting> } }",
222        )?;
223        assert_eq!(json, Value::Bool(true));
224        Ok(())
225    }
226
227    #[test]
228    fn insert_typed_literal() -> std::result::Result<(), Box<dyn std::error::Error>> {
229        let store = ContentStore::new()?;
230        store.insert_triple_into(
231            "urn:geoff:content:blog/hello.md",
232            "http://schema.org/datePublished",
233            &ObjectValue::TypedLiteral {
234                value: "2026-04-01".into(),
235                datatype: "http://www.w3.org/2001/XMLSchema#date".into(),
236            },
237            "urn:geoff:content:blog/hello.md",
238        )?;
239
240        // xsd:date typed literals are queryable with SPARQL date functions
241        let json = store.query_to_json(
242            "SELECT ?d WHERE { GRAPH <urn:geoff:content:blog/hello.md> { ?s <http://schema.org/datePublished> ?d } }",
243        )?;
244        let rows = json.as_array().unwrap();
245        assert_eq!(rows.len(), 1);
246        assert_eq!(rows[0]["d"], "2026-04-01");
247        Ok(())
248    }
249
250    #[test]
251    fn clear_empties_store() -> std::result::Result<(), Box<dyn std::error::Error>> {
252        let store = ContentStore::new()?;
253        store.insert_triple_into(
254            "urn:geoff:content:a",
255            "http://example.org/p",
256            &ObjectValue::Literal("v".into()),
257            "urn:geoff:site",
258        )?;
259        store.clear()?;
260
261        let json = store.query_to_json("SELECT ?s ?p ?o WHERE { GRAPH ?g { ?s ?p ?o } }")?;
262        let rows = json.as_array().unwrap();
263        assert!(rows.is_empty());
264        Ok(())
265    }
266}