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