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_turtle(&self) -> std::result::Result<String, Box<dyn std::error::Error>> {
148 let mut out = String::new();
149 for quad in self.store.iter() {
150 let quad = quad?;
151 use std::fmt::Write;
152 writeln!(out, "{} {} {} .", quad.subject, quad.predicate, quad.object)?;
153 }
154 Ok(out)
155 }
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161
162 #[test]
163 fn insert_and_query_named_graph() -> std::result::Result<(), Box<dyn std::error::Error>> {
164 let store = ContentStore::new()?;
165 store.insert_triple_into(
166 "urn:geoff:content:blog/hello.md",
167 "http://schema.org/name",
168 &ObjectValue::Literal("Hello World".into()),
169 "urn:geoff:content:blog/hello.md",
170 )?;
171
172 let json = store.query_to_json(
173 "SELECT ?name WHERE { GRAPH <urn:geoff:content:blog/hello.md> { ?s <http://schema.org/name> ?name } }",
174 )?;
175
176 let rows = json.as_array().unwrap();
177 assert_eq!(rows.len(), 1);
178 assert_eq!(rows[0]["name"], "Hello World");
179 Ok(())
180 }
181
182 #[test]
183 fn insert_iri_object() -> std::result::Result<(), Box<dyn std::error::Error>> {
184 let store = ContentStore::new()?;
185 store.insert_triple_into(
186 "urn:geoff:content:blog/hello.md",
187 "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
188 &ObjectValue::Iri("http://schema.org/BlogPosting".into()),
189 "urn:geoff:content:blog/hello.md",
190 )?;
191
192 let json = store.query_to_json(
193 "ASK { GRAPH <urn:geoff:content:blog/hello.md> { <urn:geoff:content:blog/hello.md> a <http://schema.org/BlogPosting> } }",
194 )?;
195 assert_eq!(json, Value::Bool(true));
196 Ok(())
197 }
198
199 #[test]
200 fn insert_typed_literal() -> 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://schema.org/datePublished",
205 &ObjectValue::TypedLiteral {
206 value: "2026-04-01".into(),
207 datatype: "http://www.w3.org/2001/XMLSchema#date".into(),
208 },
209 "urn:geoff:content:blog/hello.md",
210 )?;
211
212 let json = store.query_to_json(
214 "SELECT ?d WHERE { GRAPH <urn:geoff:content:blog/hello.md> { ?s <http://schema.org/datePublished> ?d } }",
215 )?;
216 let rows = json.as_array().unwrap();
217 assert_eq!(rows.len(), 1);
218 assert_eq!(rows[0]["d"], "2026-04-01");
219 Ok(())
220 }
221
222 #[test]
223 fn clear_empties_store() -> std::result::Result<(), Box<dyn std::error::Error>> {
224 let store = ContentStore::new()?;
225 store.insert_triple_into(
226 "urn:geoff:content:a",
227 "http://example.org/p",
228 &ObjectValue::Literal("v".into()),
229 "urn:geoff:site",
230 )?;
231 store.clear()?;
232
233 let json = store.query_to_json("SELECT ?s ?p ?o WHERE { GRAPH ?g { ?s ?p ?o } }")?;
234 let rows = json.as_array().unwrap();
235 assert!(rows.is_empty());
236 Ok(())
237 }
238}