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 triples grouped by a partition function.
162    ///
163    /// The `partition_fn` receives (graph_name) and returns an optional partition key.
164    /// Triples whose graph returns `None` go into the empty-key bucket (main search.nt).
165    /// The design tokens graph always partitions to `"design-tokens"`.
166    pub fn export_partitioned_ntriples(
167        &self,
168        partition_fn: &dyn Fn(&str) -> Option<String>,
169    ) -> std::result::Result<std::collections::HashMap<String, String>, Box<dyn std::error::Error>>
170    {
171        use std::collections::HashMap;
172        use std::fmt::Write;
173
174        let mut partitions: HashMap<String, String> = HashMap::new();
175        for quad in self.store.iter() {
176            let quad = quad?;
177            let graph_name = quad.graph_name.to_string();
178            let key = if graph_name.contains("urn:geoff:design-tokens") {
179                Some("design-tokens".to_string())
180            } else {
181                partition_fn(&graph_name)
182            };
183            let bucket = partitions.entry(key.unwrap_or_default()).or_default();
184            writeln!(
185                bucket,
186                "{} {} {} .",
187                quad.subject, quad.predicate, quad.object
188            )?;
189        }
190        Ok(partitions)
191    }
192
193    /// Export all triples (flattened from all named graphs) as NTriples.
194    ///
195    /// This is useful for SHACL validation, which operates on a flat graph.
196    pub fn export_turtle(&self) -> std::result::Result<String, Box<dyn std::error::Error>> {
197        let mut out = String::new();
198        for quad in self.store.iter() {
199            let quad = quad?;
200            use std::fmt::Write;
201            writeln!(out, "{} {} {} .", quad.subject, quad.predicate, quad.object)?;
202        }
203        Ok(out)
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn insert_and_query_named_graph() -> std::result::Result<(), Box<dyn std::error::Error>> {
213        let store = ContentStore::new()?;
214        store.insert_triple_into(
215            "urn:geoff:content:blog/hello.md",
216            "https://schema.org/name",
217            &ObjectValue::Literal("Hello World".into()),
218            "urn:geoff:content:blog/hello.md",
219        )?;
220
221        let json = store.query_to_json(
222            "SELECT ?name WHERE { GRAPH <urn:geoff:content:blog/hello.md> { ?s <https://schema.org/name> ?name } }",
223        )?;
224
225        let rows = json.as_array().unwrap();
226        assert_eq!(rows.len(), 1);
227        assert_eq!(rows[0]["name"], "Hello World");
228        Ok(())
229    }
230
231    #[test]
232    fn insert_iri_object() -> std::result::Result<(), Box<dyn std::error::Error>> {
233        let store = ContentStore::new()?;
234        store.insert_triple_into(
235            "urn:geoff:content:blog/hello.md",
236            "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
237            &ObjectValue::Iri("https://schema.org/BlogPosting".into()),
238            "urn:geoff:content:blog/hello.md",
239        )?;
240
241        let json = store.query_to_json(
242            "ASK { GRAPH <urn:geoff:content:blog/hello.md> { <urn:geoff:content:blog/hello.md> a <https://schema.org/BlogPosting> } }",
243        )?;
244        assert_eq!(json, Value::Bool(true));
245        Ok(())
246    }
247
248    #[test]
249    fn insert_typed_literal() -> std::result::Result<(), Box<dyn std::error::Error>> {
250        let store = ContentStore::new()?;
251        store.insert_triple_into(
252            "urn:geoff:content:blog/hello.md",
253            "https://schema.org/datePublished",
254            &ObjectValue::TypedLiteral {
255                value: "2026-04-01".into(),
256                datatype: "http://www.w3.org/2001/XMLSchema#date".into(),
257            },
258            "urn:geoff:content:blog/hello.md",
259        )?;
260
261        // xsd:date typed literals are queryable with SPARQL date functions
262        let json = store.query_to_json(
263            "SELECT ?d WHERE { GRAPH <urn:geoff:content:blog/hello.md> { ?s <https://schema.org/datePublished> ?d } }",
264        )?;
265        let rows = json.as_array().unwrap();
266        assert_eq!(rows.len(), 1);
267        assert_eq!(rows[0]["d"], "2026-04-01");
268        Ok(())
269    }
270
271    #[test]
272    fn clear_empties_store() -> std::result::Result<(), Box<dyn std::error::Error>> {
273        let store = ContentStore::new()?;
274        store.insert_triple_into(
275            "urn:geoff:content:a",
276            "http://example.org/p",
277            &ObjectValue::Literal("v".into()),
278            "urn:geoff:site",
279        )?;
280        store.clear()?;
281
282        let json = store.query_to_json("SELECT ?s ?p ?o WHERE { GRAPH ?g { ?s ?p ?o } }")?;
283        let rows = json.as_array().unwrap();
284        assert!(rows.is_empty());
285        Ok(())
286    }
287}