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_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 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}