use camino::Utf8Path;
use geoff_core::types::ObjectValue;
use oxigraph::io::{RdfFormat, RdfParser};
use oxigraph::model::{GraphNameRef, Literal, NamedNodeRef, QuadRef, Term};
use oxigraph::sparql::{QueryResults, SparqlEvaluator};
use oxigraph::store::Store;
use serde_json::{Map, Value};
#[derive(Clone)]
pub struct ContentStore {
store: Store,
}
impl ContentStore {
pub fn new() -> std::result::Result<Self, Box<dyn std::error::Error>> {
Ok(Self {
store: Store::new()?,
})
}
pub fn insert_triple_into(
&self,
subject: &str,
predicate: &str,
object: &ObjectValue,
graph: &str,
) -> std::result::Result<(), Box<dyn std::error::Error>> {
let s_node = NamedNodeRef::new(subject)?;
let p_node = NamedNodeRef::new(predicate)?;
let g_node = NamedNodeRef::new(graph)?;
match object {
ObjectValue::Iri(iri) => {
let o_node = NamedNodeRef::new(iri)?;
self.store.insert(QuadRef::new(
s_node,
p_node,
o_node,
GraphNameRef::NamedNode(g_node),
))?;
}
ObjectValue::Literal(value) => {
let lit = Literal::new_simple_literal(value);
self.store.insert(QuadRef::new(
s_node,
p_node,
lit.as_ref(),
GraphNameRef::NamedNode(g_node),
))?;
}
ObjectValue::TypedLiteral { value, datatype } => {
let dt_node = NamedNodeRef::new(datatype)?;
let lit = Literal::new_typed_literal(value, dt_node);
self.store.insert(QuadRef::new(
s_node,
p_node,
lit.as_ref(),
GraphNameRef::NamedNode(g_node),
))?;
}
}
Ok(())
}
pub fn query_to_json(
&self,
sparql: &str,
) -> std::result::Result<Value, Box<dyn std::error::Error>> {
let results = SparqlEvaluator::new()
.parse_query(sparql)?
.on_store(&self.store)
.execute()?;
match results {
QueryResults::Solutions(solutions) => {
let variables: Vec<String> = solutions
.variables()
.iter()
.map(|v| v.as_str().to_owned())
.collect();
let mut rows = Vec::new();
for solution in solutions {
let solution = solution?;
let mut row = Map::new();
for var in &variables {
let value =
solution
.get(var.as_str())
.map_or(Value::Null, |term| match term {
Term::Literal(lit) => Value::String(lit.value().to_string()),
other => Value::String(other.to_string()),
});
row.insert(var.clone(), value);
}
rows.push(Value::Object(row));
}
Ok(Value::Array(rows))
}
QueryResults::Boolean(b) => Ok(Value::Bool(b)),
QueryResults::Graph(_) => Err("CONSTRUCT/DESCRIBE queries not supported".into()),
}
}
pub fn load_turtle(
&self,
path: &Utf8Path,
) -> std::result::Result<(), Box<dyn std::error::Error>> {
let file = std::fs::File::open(path.as_std_path())?;
let reader = std::io::BufReader::new(file);
self.store.load_from_reader(RdfFormat::Turtle, reader)?;
Ok(())
}
pub fn load_turtle_into(
&self,
path: &Utf8Path,
graph: &str,
) -> std::result::Result<(), Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?;
let g_node = NamedNodeRef::new(graph)?;
self.store.load_from_reader(
RdfParser::from_format(RdfFormat::Turtle)
.without_named_graphs()
.with_default_graph(g_node),
content.as_bytes(),
)?;
Ok(())
}
pub fn clear(&self) -> std::result::Result<(), Box<dyn std::error::Error>> {
self.store.clear()?;
Ok(())
}
pub fn export_turtle(&self) -> std::result::Result<String, Box<dyn std::error::Error>> {
let mut out = String::new();
for quad in self.store.iter() {
let quad = quad?;
use std::fmt::Write;
writeln!(out, "{} {} {} .", quad.subject, quad.predicate, quad.object)?;
}
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn insert_and_query_named_graph() -> std::result::Result<(), Box<dyn std::error::Error>> {
let store = ContentStore::new()?;
store.insert_triple_into(
"urn:geoff:content:blog/hello.md",
"http://schema.org/name",
&ObjectValue::Literal("Hello World".into()),
"urn:geoff:content:blog/hello.md",
)?;
let json = store.query_to_json(
"SELECT ?name WHERE { GRAPH <urn:geoff:content:blog/hello.md> { ?s <http://schema.org/name> ?name } }",
)?;
let rows = json.as_array().unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0]["name"], "Hello World");
Ok(())
}
#[test]
fn insert_iri_object() -> std::result::Result<(), Box<dyn std::error::Error>> {
let store = ContentStore::new()?;
store.insert_triple_into(
"urn:geoff:content:blog/hello.md",
"http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
&ObjectValue::Iri("http://schema.org/BlogPosting".into()),
"urn:geoff:content:blog/hello.md",
)?;
let json = store.query_to_json(
"ASK { GRAPH <urn:geoff:content:blog/hello.md> { <urn:geoff:content:blog/hello.md> a <http://schema.org/BlogPosting> } }",
)?;
assert_eq!(json, Value::Bool(true));
Ok(())
}
#[test]
fn insert_typed_literal() -> std::result::Result<(), Box<dyn std::error::Error>> {
let store = ContentStore::new()?;
store.insert_triple_into(
"urn:geoff:content:blog/hello.md",
"http://schema.org/datePublished",
&ObjectValue::TypedLiteral {
value: "2026-04-01".into(),
datatype: "http://www.w3.org/2001/XMLSchema#date".into(),
},
"urn:geoff:content:blog/hello.md",
)?;
let json = store.query_to_json(
"SELECT ?d WHERE { GRAPH <urn:geoff:content:blog/hello.md> { ?s <http://schema.org/datePublished> ?d } }",
)?;
let rows = json.as_array().unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0]["d"], "2026-04-01");
Ok(())
}
#[test]
fn clear_empties_store() -> std::result::Result<(), Box<dyn std::error::Error>> {
let store = ContentStore::new()?;
store.insert_triple_into(
"urn:geoff:content:a",
"http://example.org/p",
&ObjectValue::Literal("v".into()),
"urn:geoff:site",
)?;
store.clear()?;
let json = store.query_to_json("SELECT ?s ?p ?o WHERE { GRAPH ?g { ?s ?p ?o } }")?;
let rows = json.as_array().unwrap();
assert!(rows.is_empty());
Ok(())
}
}