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(&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 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 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}