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#[derive(Clone)]
12pub struct ContentStore {
13 store: Store,
14}
15
16impl ContentStore {
17 pub fn new() -> std::result::Result<Self, Box<dyn std::error::Error>> {
19 Ok(Self {
20 store: Store::new()?,
21 })
22 }
23
24 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 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 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 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 pub fn clear(&self) -> std::result::Result<(), Box<dyn std::error::Error>> {
140 self.store.clear()?;
141 Ok(())
142 }
143
144 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 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 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 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}