ggen_core/graph/core.rs
1//! Core Graph type with SPARQL query caching
2//!
3//! The `Graph` type provides a high-level interface to an in-memory RDF triple store
4//! with intelligent query caching. It wraps Oxigraph's `Store` and adds:
5//!
6//! - **Query result caching**: LRU cache for SPARQL query results
7//! - **Query plan caching**: Cached query plans for faster execution
8//! - **Epoch-based invalidation**: Automatic cache invalidation when graph changes
9//! - **Thread safety**: Cheap cloning via `Arc` for concurrent access
10
11use crate::graph::types::CachedResult;
12use ahash::AHasher;
13use ggen_utils::error::{Error, Result};
14use lru::LruCache;
15use oxigraph::io::RdfFormat;
16use oxigraph::model::{GraphName, NamedNode, NamedOrBlankNode, Quad, Term};
17use oxigraph::sparql::{QueryResults, SparqlEvaluator};
18use oxigraph::store::Store;
19use std::collections::BTreeMap;
20use std::fs::File;
21use std::hash::{Hash, Hasher};
22use std::io::BufReader;
23use std::num::NonZeroUsize;
24use std::path::Path;
25use std::sync::{
26 atomic::{AtomicU64, Ordering},
27 Arc, Mutex,
28};
29
30/// Default size for SPARQL query plan cache
31const DEFAULT_PLAN_CACHE_SIZE: usize = 100;
32
33/// Default size for SPARQL query result cache
34const DEFAULT_RESULT_CACHE_SIZE: usize = 1000;
35
36/// Initial epoch value for cache invalidation
37///
38/// **Kaizen improvement**: Extracted magic number to named constant for clarity.
39/// The epoch starts at 1 and increments on each graph modification to invalidate caches.
40const INITIAL_EPOCH: u64 = 1;
41
42/// Epoch increment amount
43///
44/// **Kaizen improvement**: Extracted magic number to named constant for consistency.
45/// The epoch increments by 1 on each graph modification to invalidate caches.
46const EPOCH_INCREMENT: u64 = 1;
47
48/// Thread-safe Oxigraph wrapper with SPARQL caching.
49///
50/// The `Graph` type provides a high-level interface to an in-memory RDF triple store
51/// with intelligent query caching. It wraps Oxigraph's `Store` and adds:
52///
53/// - **Query result caching**: LRU cache for SPARQL query results
54/// - **Query plan caching**: Cached query plans for faster execution
55/// - **Epoch-based invalidation**: Automatic cache invalidation when graph changes
56/// - **Thread safety**: Cheap cloning via `Arc` for concurrent access
57///
58/// # Thread Safety
59///
60/// `Graph` is designed for concurrent use. Cloning a `Graph` is cheap (O(1)) as it
61/// shares the underlying store via `Arc`. Multiple threads can safely query the same
62/// graph concurrently.
63///
64/// # Cache Invalidation
65///
66/// The graph maintains an epoch counter that increments whenever data is inserted.
67/// This automatically invalidates cached query results, ensuring consistency.
68///
69/// # Examples
70///
71/// ## Basic usage
72///
73/// ```rust,no_run
74/// use ggen_core::graph::Graph;
75///
76/// # fn main() -> ggen_utils::error::Result<()> {
77/// // Create a new graph
78/// let graph = Graph::new()?;
79///
80/// // Load RDF data
81/// graph.insert_turtle(r#"
82/// @prefix ex: <http://example.org/> .
83/// ex:alice a ex:Person ;
84/// ex:name "Alice" .
85/// "#)?;
86///
87/// // Query the graph
88/// let results = graph.query("SELECT ?name WHERE { ?s ex:name ?name }")?;
89/// # Ok(())
90/// # }
91/// ```
92pub struct Graph {
93 inner: Arc<Store>,
94 epoch: Arc<AtomicU64>,
95 plan_cache: Arc<Mutex<LruCache<u64, String>>>,
96 result_cache: Arc<Mutex<LruCache<(u64, u64), CachedResult>>>,
97}
98
99impl Graph {
100 /// Create a new empty graph
101 ///
102 /// # Example
103 ///
104 /// ```rust
105 /// use ggen_core::graph::Graph;
106 ///
107 /// let graph = Graph::new().unwrap();
108 /// assert!(graph.is_empty());
109 /// ```
110 pub fn new() -> Result<Self> {
111 let plan_cache_size = NonZeroUsize::new(DEFAULT_PLAN_CACHE_SIZE)
112 .ok_or_else(|| Error::new("Invalid cache size"))?;
113 let result_cache_size = NonZeroUsize::new(DEFAULT_RESULT_CACHE_SIZE)
114 .ok_or_else(|| Error::new("Invalid cache size"))?;
115
116 // **Root Cause Fix**: Use explicit `.map_err()` for oxigraph error conversion
117 let store =
118 Store::new().map_err(|e| Error::new(&format!("Failed to create store: {}", e)))?;
119 Ok(Self {
120 inner: Arc::new(store),
121 epoch: Arc::new(AtomicU64::new(INITIAL_EPOCH)),
122 plan_cache: Arc::new(Mutex::new(LruCache::new(plan_cache_size))),
123 result_cache: Arc::new(Mutex::new(LruCache::new(result_cache_size))),
124 })
125 }
126
127 /// Load RDF data from a file into a new Graph
128 ///
129 /// # Errors
130 ///
131 /// Returns an error if:
132 /// - The file cannot be opened or read
133 /// - The file format is unsupported
134 /// - The RDF syntax is invalid
135 pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
136 let graph = Self::new()?;
137 graph.load_path(path)?;
138 Ok(graph)
139 }
140
141 /// Get the current epoch (for cache invalidation)
142 pub(crate) fn current_epoch(&self) -> u64 {
143 self.epoch.load(Ordering::Relaxed)
144 }
145
146 /// Increment epoch (invalidates cache)
147 pub(crate) fn bump_epoch(&self) {
148 self.epoch.fetch_add(EPOCH_INCREMENT, Ordering::Relaxed);
149 }
150
151 /// Get reference to inner Store (for use by other modules)
152 pub(crate) fn inner(&self) -> &Store {
153 &self.inner
154 }
155
156 /// Create a Graph from an existing Store (for persistent stores)
157 pub(crate) fn from_store(store: Arc<Store>) -> Result<Self> {
158 let plan_cache_size = NonZeroUsize::new(DEFAULT_PLAN_CACHE_SIZE)
159 .ok_or_else(|| Error::new("Invalid cache size"))?;
160 let result_cache_size = NonZeroUsize::new(DEFAULT_RESULT_CACHE_SIZE)
161 .ok_or_else(|| Error::new("Invalid cache size"))?;
162
163 Ok(Self {
164 inner: store,
165 epoch: Arc::new(AtomicU64::new(INITIAL_EPOCH)),
166 plan_cache: Arc::new(Mutex::new(LruCache::new(plan_cache_size))),
167 result_cache: Arc::new(Mutex::new(LruCache::new(result_cache_size))),
168 })
169 }
170
171 fn hash_query(&self, sparql: &str) -> u64 {
172 let mut hasher = AHasher::default();
173 sparql.hash(&mut hasher);
174 hasher.finish()
175 }
176
177 /// Materialize SPARQL query results into CachedResult.
178 ///
179 /// **Root Cause Fix**: Uses explicit `.map_err()` for error conversion instead of `?`
180 /// operator, because Oxigraph solution iterator errors don't implement `From` for
181 /// `ggen_utils::error::Error`. Pattern: Always use `.map_err()` for external library
182 /// errors that don't have `From` implementations.
183 fn materialize_results(&self, results: QueryResults) -> Result<CachedResult> {
184 match results {
185 QueryResults::Boolean(b) => Ok(CachedResult::Boolean(b)),
186 QueryResults::Solutions(solutions) => {
187 let mut rows = Vec::new();
188 for solution in solutions {
189 // Explicit error conversion: Oxigraph errors don't implement From
190 let solution = solution
191 .map_err(|e| Error::new(&format!("SPARQL solution error: {}", e)))?;
192 let mut row = BTreeMap::new();
193 for (var, term) in solution.iter() {
194 row.insert(var.as_str().to_string(), term.to_string());
195 }
196 rows.push(row);
197 }
198 Ok(CachedResult::Solutions(rows))
199 }
200 QueryResults::Graph(quads) => {
201 let mut triples = Vec::new();
202 for q in quads {
203 let quad = q.map_err(|e| Error::new(&format!("Quad error: {}", e)))?;
204 triples.push(quad.to_string());
205 }
206 Ok(CachedResult::Graph(triples))
207 }
208 }
209 }
210
211 /// Insert RDF data in Turtle format
212 ///
213 /// Loads RDF triples from a Turtle string into the graph. The graph's
214 /// epoch counter is incremented, invalidating cached query results.
215 pub fn insert_turtle(&self, turtle: &str) -> Result<()> {
216 // Use higher-level load_from_reader API (oxigraph's recommended way to load RDF)
217 self.inner
218 .load_from_reader(RdfFormat::Turtle, turtle.as_bytes())
219 .map_err(|e| Error::new(&format!("Failed to load Turtle: {}", e)))?;
220 self.bump_epoch();
221 Ok(())
222 }
223
224 /// Insert RDF data in Turtle format with a base IRI
225 ///
226 /// Loads RDF triples from a Turtle string with a specified base IRI.
227 /// Relative IRIs in the Turtle data will be resolved against this base.
228 pub fn insert_turtle_with_base(&self, turtle: &str, base_iri: &str) -> Result<()> {
229 // Prepend BASE declaration to Turtle string to ensure base IRI is used
230 let base_iri_trimmed = base_iri.trim();
231 let turtle_with_base = if turtle.trim_start().starts_with("BASE")
232 || turtle.trim_start().starts_with("@base")
233 {
234 // Base already declared, use as-is
235 turtle.to_string()
236 } else {
237 format!("BASE <{}>\n{}", base_iri_trimmed, turtle)
238 };
239 // Use higher-level load_from_reader API (oxigraph's recommended way to load RDF)
240 self.inner
241 .load_from_reader(RdfFormat::Turtle, turtle_with_base.as_bytes())
242 .map_err(|e| Error::new(&format!("Failed to load Turtle with base IRI: {}", e)))?;
243 self.bump_epoch();
244 Ok(())
245 }
246
247 /// Insert RDF data in Turtle format into a named graph
248 ///
249 /// Loads RDF triples from a Turtle string into a specific named graph.
250 pub fn insert_turtle_in(&self, turtle: &str, graph_iri: &str) -> Result<()> {
251 // Parse Turtle into a temporary store, then extract quads and insert with named graph
252 // **Note**: Temp store approach is necessary because oxigraph's load_from_reader doesn't
253 // support loading directly into a named graph. This is the recommended pattern.
254 // **Root Cause Fix**: Use explicit `.map_err()` for oxigraph error conversion
255 let temp_store = Store::new()
256 .map_err(|e| Error::new(&format!("Failed to create temporary store: {}", e)))?;
257 temp_store
258 .load_from_reader(RdfFormat::Turtle, turtle.as_bytes())
259 .map_err(|e| Error::new(&format!("Failed to parse Turtle: {}", e)))?;
260
261 // Extract all quads from temporary store and insert with named graph
262 let graph_name = GraphName::NamedNode(
263 NamedNode::new(graph_iri)
264 .map_err(|e| Error::new(&format!("Invalid graph IRI: {}", e)))?,
265 );
266
267 // Use higher-level quads_for_pattern API - returns iterator of Result<Quad, StorageError>
268 // StorageError has From implementation, so we can use ? after collect
269 let quads: Vec<Quad> = temp_store
270 .quads_for_pattern(None, None, None, None)
271 .collect::<std::result::Result<Vec<_>, _>>()?;
272
273 // Insert each quad with the named graph
274 for quad in quads {
275 // Quad has public fields: subject, predicate, object, graph_name
276 let named_quad = Quad {
277 subject: quad.subject.clone(),
278 predicate: quad.predicate.clone(),
279 object: quad.object.clone(),
280 graph_name: graph_name.clone(),
281 };
282 // **Root Cause Fix**: Use explicit `.map_err()` for oxigraph error conversion
283 self.inner.insert(&named_quad).map_err(|e| {
284 Error::new(&format!("Failed to insert quad into named graph: {}", e))
285 })?;
286 }
287
288 self.bump_epoch();
289 Ok(())
290 }
291
292 /// Insert a single RDF quad (triple) into the graph
293 ///
294 /// Adds a single triple to the graph. All components must be valid IRIs.
295 /// The graph's epoch counter is incremented, invalidating cached query results.
296 pub fn insert_quad(&self, s: &str, p: &str, o: &str) -> Result<()> {
297 let s =
298 NamedNode::new(s).map_err(|e| Error::new(&format!("Invalid subject IRI: {}", e)))?;
299 let p =
300 NamedNode::new(p).map_err(|e| Error::new(&format!("Invalid predicate IRI: {}", e)))?;
301 let o = NamedNode::new(o).map_err(|e| Error::new(&format!("Invalid object IRI: {}", e)))?;
302 // **Root Cause Fix**: Use explicit `.map_err()` for oxigraph error conversion
303 self.inner
304 .insert(&Quad::new(s, p, o, GraphName::DefaultGraph))
305 .map_err(|e| Error::new(&format!("Failed to insert quad: {}", e)))?;
306 self.bump_epoch();
307 Ok(())
308 }
309
310 /// Insert a quad with a named graph
311 ///
312 /// Adds a quad to a specific named graph.
313 pub fn insert_quad_in(&self, s: &str, p: &str, o: &str, graph_iri: &str) -> Result<()> {
314 let s =
315 NamedNode::new(s).map_err(|e| Error::new(&format!("Invalid subject IRI: {}", e)))?;
316 let p =
317 NamedNode::new(p).map_err(|e| Error::new(&format!("Invalid predicate IRI: {}", e)))?;
318 let o = NamedNode::new(o).map_err(|e| Error::new(&format!("Invalid object IRI: {}", e)))?;
319 let g = GraphName::NamedNode(
320 NamedNode::new(graph_iri)
321 .map_err(|e| Error::new(&format!("Invalid graph IRI: {}", e)))?,
322 );
323 // **Root Cause Fix**: Use explicit `.map_err()` for oxigraph error conversion
324 self.inner
325 .insert(&Quad::new(s, p, o, g))
326 .map_err(|e| Error::new(&format!("Failed to insert quad into named graph: {}", e)))?;
327 self.bump_epoch();
328 Ok(())
329 }
330
331 /// Insert a Quad directly
332 ///
333 /// Adds a quad object to the graph.
334 pub fn insert_quad_object(&self, quad: &Quad) -> Result<()> {
335 // **Root Cause Fix**: Use explicit `.map_err()` for oxigraph error conversion
336 self.inner
337 .insert(quad)
338 .map_err(|e| Error::new(&format!("Failed to insert quad: {}", e)))?;
339 self.bump_epoch();
340 Ok(())
341 }
342
343 /// Remove a quad from the graph
344 ///
345 /// Removes a specific quad from the graph.
346 pub fn remove_quad(&self, s: &str, p: &str, o: &str) -> Result<()> {
347 let s =
348 NamedNode::new(s).map_err(|e| Error::new(&format!("Invalid subject IRI: {}", e)))?;
349 let p =
350 NamedNode::new(p).map_err(|e| Error::new(&format!("Invalid predicate IRI: {}", e)))?;
351 let o = NamedNode::new(o).map_err(|e| Error::new(&format!("Invalid object IRI: {}", e)))?;
352 // **Root Cause Fix**: Use explicit `.map_err()` for oxigraph error conversion
353 self.inner
354 .remove(&Quad::new(s, p, o, GraphName::DefaultGraph))
355 .map_err(|e| Error::new(&format!("Failed to remove quad: {}", e)))?;
356 self.bump_epoch();
357 Ok(())
358 }
359
360 /// Remove a quad from a named graph
361 ///
362 /// Removes a quad from a specific named graph.
363 pub fn remove_quad_from(&self, s: &str, p: &str, o: &str, graph_iri: &str) -> Result<()> {
364 let s =
365 NamedNode::new(s).map_err(|e| Error::new(&format!("Invalid subject IRI: {}", e)))?;
366 let p =
367 NamedNode::new(p).map_err(|e| Error::new(&format!("Invalid predicate IRI: {}", e)))?;
368 let o = NamedNode::new(o).map_err(|e| Error::new(&format!("Invalid object IRI: {}", e)))?;
369 let g = GraphName::NamedNode(
370 NamedNode::new(graph_iri)
371 .map_err(|e| Error::new(&format!("Invalid graph IRI: {}", e)))?,
372 );
373 // **Root Cause Fix**: Use explicit `.map_err()` for oxigraph error conversion
374 self.inner
375 .remove(&Quad::new(s, p, o, g))
376 .map_err(|e| Error::new(&format!("Failed to remove quad from named graph: {}", e)))?;
377 self.bump_epoch();
378 Ok(())
379 }
380
381 /// Remove a Quad directly
382 ///
383 /// Removes a quad object from the graph.
384 pub fn remove_quad_object(&self, quad: &Quad) -> Result<()> {
385 // **Root Cause Fix**: Use explicit `.map_err()` for oxigraph error conversion
386 self.inner
387 .remove(quad)
388 .map_err(|e| Error::new(&format!("Failed to remove quad: {}", e)))?;
389 self.bump_epoch();
390 Ok(())
391 }
392
393 /// Remove all quads matching a pattern
394 ///
395 /// Removes all quads that match the specified pattern.
396 pub fn remove_for_pattern(
397 &self, s: Option<&NamedOrBlankNode>, p: Option<&NamedNode>, o: Option<&Term>,
398 g: Option<&GraphName>,
399 ) -> Result<usize> {
400 let quads: Vec<Quad> = self
401 .inner
402 .quads_for_pattern(
403 s.map(|x| x.as_ref()),
404 p.map(|x| x.as_ref()),
405 o.map(|x| x.as_ref()),
406 g.map(|x| x.as_ref()),
407 )
408 .collect::<std::result::Result<Vec<_>, _>>()
409 .map_err(|e| Error::new(&format!("Failed to collect quads: {}", e)))?;
410
411 let count = quads.len();
412 // **Root Cause Fix**: Use explicit `.map_err()` for oxigraph error conversion
413 for quad in &quads {
414 self.inner
415 .remove(quad)
416 .map_err(|e| Error::new(&format!("Failed to remove quad: {}", e)))?;
417 }
418 self.bump_epoch();
419 Ok(count)
420 }
421
422 /// Get an iterator over all quads in the graph
423 ///
424 /// Returns an iterator that yields all quads in the graph.
425 pub fn quads(&self) -> impl Iterator<Item = Result<Quad>> + '_ {
426 self.inner
427 .quads_for_pattern(None, None, None, None)
428 .map(|r| r.map_err(|e| Error::new(&format!("Oxigraph error: {}", e))))
429 }
430
431 /// Load RDF data from a file path
432 ///
433 /// Automatically detects the RDF format from the file extension.
434 pub fn load_path<P: AsRef<Path>>(&self, path: P) -> Result<()> {
435 let path = path.as_ref();
436 let ext = path
437 .extension()
438 .and_then(|e| e.to_str())
439 .map(|s| s.to_ascii_lowercase())
440 .unwrap_or_default();
441
442 let fmt = match ext.as_str() {
443 "ttl" | "turtle" => RdfFormat::Turtle,
444 "nt" | "ntriples" => RdfFormat::NTriples,
445 "rdf" | "xml" => RdfFormat::RdfXml,
446 "trig" => RdfFormat::TriG,
447 "nq" | "nquads" => RdfFormat::NQuads,
448 other => return Err(Error::new(&format!("unsupported RDF format: {}", other))),
449 };
450
451 let file = File::open(path)?;
452 let reader = BufReader::new(file);
453 // Use higher-level load_from_reader API (oxigraph's recommended way to load RDF)
454 self.inner
455 .load_from_reader(fmt, reader)
456 .map_err(|e| Error::new(&format!("Failed to load RDF from file: {}", e)))?;
457 self.bump_epoch();
458 Ok(())
459 }
460
461 /// Execute a SPARQL query with caching
462 ///
463 /// Results are cached based on query string and graph epoch.
464 /// Cache is automatically invalidated when the graph changes.
465 pub fn query_cached(&self, sparql: &str) -> Result<CachedResult> {
466 let query_hash = self.hash_query(sparql);
467 let epoch = self.current_epoch();
468 let cache_key = (query_hash, epoch);
469
470 // Check result cache
471 if let Some(cached) = self
472 .result_cache
473 .lock()
474 .map_err(|_| Error::new("Cache lock poisoned"))?
475 .get(&cache_key)
476 .cloned()
477 {
478 return Ok(cached);
479 }
480
481 // Re-check epoch after cache miss
482 let final_epoch = self.current_epoch();
483 let final_cache_key = if final_epoch != epoch {
484 let new_cache_key = (query_hash, final_epoch);
485 if let Some(cached) = self
486 .result_cache
487 .lock()
488 .map_err(|_| Error::new("Cache lock poisoned"))?
489 .get(&new_cache_key)
490 .cloned()
491 {
492 return Ok(cached);
493 }
494 new_cache_key
495 } else {
496 cache_key
497 };
498
499 // Check plan cache or parse
500 let query_str = {
501 let mut cache = self
502 .plan_cache
503 .lock()
504 .map_err(|_| Error::new("Cache lock poisoned"))?;
505 if let Some(q) = cache.get(&query_hash).cloned() {
506 q
507 } else {
508 let q = sparql.to_string();
509 cache.put(query_hash, q.clone());
510 q
511 }
512 };
513
514 // Execute and materialize using SparqlEvaluator
515 // **Root Cause Fix**: Use explicit `.map_err()` for oxigraph error conversion
516 let results = SparqlEvaluator::new()
517 .parse_query(&query_str)
518 .map_err(|e| Error::new(&format!("SPARQL parse error: {}", e)))?
519 .on_store(&self.inner)
520 .execute()
521 .map_err(|e| Error::new(&format!("SPARQL execution error: {}", e)))?;
522 let cached = self.materialize_results(results)?;
523
524 // Store in cache
525 self.result_cache
526 .lock()
527 .map_err(|_| Error::new("Cache lock poisoned"))?
528 .put(final_cache_key, cached.clone());
529
530 Ok(cached)
531 }
532
533 /// Execute a SPARQL query (returns raw QueryResults)
534 ///
535 /// This method provides direct access to Oxigraph's QueryResults.
536 /// For full caching, use `query_cached` instead.
537 pub fn query<'a>(&'a self, sparql: &str) -> Result<QueryResults<'a>> {
538 let cached = self.query_cached(sparql)?;
539
540 match cached {
541 CachedResult::Boolean(b) => Ok(QueryResults::Boolean(b)),
542 CachedResult::Solutions(_) | CachedResult::Graph(_) => {
543 // Fall back to direct query for non-boolean results
544 // Note: parse_query returns SparqlSyntaxError which doesn't have From impl
545 // **Root Cause Fix**: Use explicit `.map_err()` for oxigraph error conversion
546 Ok(SparqlEvaluator::new()
547 .parse_query(sparql)
548 .map_err(|e| Error::new(&format!("SPARQL parse error: {}", e)))?
549 .on_store(&self.inner)
550 .execute()
551 .map_err(|e| Error::new(&format!("SPARQL execution error: {}", e)))?)
552 }
553 }
554 }
555
556 /// Execute a SPARQL query with PREFIX and BASE declarations
557 ///
558 /// This method automatically prepends PREFIX and BASE declarations to the
559 /// SPARQL query based on the provided prefixes and base IRI.
560 pub fn query_with_prolog<'a>(
561 &'a self, sparql: &str, prefixes: &BTreeMap<String, String>, base: Option<&str>,
562 ) -> Result<QueryResults<'a>> {
563 let head = crate::graph::build_prolog(prefixes, base);
564 let q = if head.is_empty() {
565 sparql.into()
566 } else {
567 format!("{head}\n{sparql}")
568 };
569 self.query(&q)
570 }
571
572 /// Execute a prepared SPARQL query (low-level API)
573 ///
574 /// This method provides direct access to Oxigraph's query API.
575 /// For most use cases, prefer `query()` or `query_cached()` instead.
576 pub fn query_prepared<'a>(&'a self, q: &str) -> Result<QueryResults<'a>> {
577 // **Root Cause Fix**: Use explicit `.map_err()` for oxigraph error conversion
578 SparqlEvaluator::new()
579 .parse_query(q)
580 .map_err(|e| Error::new(&format!("SPARQL parse error: {}", e)))?
581 .on_store(&self.inner)
582 .execute()
583 .map_err(|e| Error::new(&format!("SPARQL execution error: {}", e)))
584 }
585
586 /// Find quads matching a pattern
587 ///
588 /// Searches for quads (triples) in the graph that match the specified pattern.
589 /// Any component can be `None` to match any value (wildcard).
590 pub fn quads_for_pattern(
591 &self, s: Option<&NamedOrBlankNode>, p: Option<&NamedNode>, o: Option<&Term>,
592 g: Option<&GraphName>,
593 ) -> Result<Vec<Quad>> {
594 self.inner
595 .quads_for_pattern(
596 s.map(|x| x.as_ref()),
597 p.map(|x| x.as_ref()),
598 o.map(|x| x.as_ref()),
599 g.map(|x| x.as_ref()),
600 )
601 .map(|r| r.map_err(|e| Error::new(&format!("Quad error: {}", e))))
602 .collect::<Result<Vec<_>>>()
603 }
604
605 /// Clear all data from the graph
606 ///
607 /// Removes all triples from the graph and increments the epoch counter,
608 /// invalidating all cached query results.
609 pub fn clear(&self) -> Result<()> {
610 // **Root Cause Fix**: Use explicit `.map_err()` for oxigraph error conversion
611 self.inner
612 .clear()
613 .map_err(|e| Error::new(&format!("Failed to clear graph: {}", e)))?;
614 self.bump_epoch();
615 Ok(())
616 }
617
618 /// Get the number of triples in the graph
619 ///
620 /// Returns the total count of triples (quads) stored in the graph.
621 ///
622 /// # Note
623 ///
624 /// If an error occurs while getting the length, this method returns 0.
625 /// For explicit error handling, use `len_result()` instead.
626 pub fn len(&self) -> usize {
627 self.len_result().unwrap_or(0)
628 }
629
630 /// Get the number of triples in the graph with explicit error handling
631 ///
632 /// Returns the total count of triples (quads) stored in the graph.
633 /// Returns an error if the length cannot be determined.
634 pub fn len_result(&self) -> Result<usize> {
635 self.inner.len().map_err(Into::into)
636 }
637
638 /// Check if the graph is empty
639 ///
640 /// Returns `true` if the graph contains no triples, `false` otherwise.
641 pub fn is_empty(&self) -> bool {
642 self.len() == 0
643 }
644}
645
646impl Clone for Graph {
647 fn clone(&self) -> Self {
648 Self {
649 inner: Arc::clone(&self.inner),
650 epoch: Arc::clone(&self.epoch),
651 plan_cache: Arc::clone(&self.plan_cache),
652 result_cache: Arc::clone(&self.result_cache),
653 }
654 }
655}
656
657/// Build SPARQL prolog (PREFIX and BASE declarations) from a prefix map.
658///
659/// Constructs the prolog section of a SPARQL query by generating PREFIX
660/// declarations for each entry in the prefix map, and optionally a BASE
661/// declaration if a base IRI is provided.
662///
663/// # Arguments
664///
665/// * `prefixes` - Map of prefix names (e.g., "ex") to namespace URIs (e.g., `<http://example.org/>`)
666/// * `base` - Optional base IRI for relative IRI resolution
667///
668/// # Returns
669///
670/// A string containing the SPARQL prolog with PREFIX and BASE declarations.
671///
672/// # Examples
673///
674/// ## With prefixes only
675///
676/// ```rust
677/// use ggen_core::graph::build_prolog;
678/// use std::collections::BTreeMap;
679///
680/// let mut prefixes = BTreeMap::new();
681/// prefixes.insert("ex".to_string(), "http://example.org/".to_string());
682/// prefixes.insert("rdf".to_string(), "http://www.w3.org/1999/02/22-rdf-syntax-ns#".to_string());
683///
684/// let prolog = build_prolog(&prefixes, None);
685/// assert!(prolog.contains("PREFIX ex: <http://example.org/>"));
686/// assert!(prolog.contains("PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>"));
687/// ```
688///
689/// ## With base IRI
690///
691/// ```rust
692/// use ggen_core::graph::build_prolog;
693/// use std::collections::BTreeMap;
694///
695/// let prefixes = BTreeMap::new();
696/// let prolog = build_prolog(&prefixes, Some("http://example.org/"));
697/// assert!(prolog.contains("BASE <http://example.org/>"));
698/// ```
699///
700/// ## Combined
701///
702/// ```rust
703/// use ggen_core::graph::build_prolog;
704/// use std::collections::BTreeMap;
705///
706/// let mut prefixes = BTreeMap::new();
707/// prefixes.insert("ex".to_string(), "http://example.org/".to_string());
708///
709/// let prolog = build_prolog(&prefixes, Some("http://example.org/base/"));
710/// assert!(prolog.contains("BASE <http://example.org/base/>"));
711/// assert!(prolog.contains("PREFIX ex: <http://example.org/>"));
712/// ```
713pub fn build_prolog(prefixes: &BTreeMap<String, String>, base: Option<&str>) -> String {
714 let mut s = String::new();
715 if let Some(b) = base {
716 // write_fmt on String never fails, so result can be safely ignored
717 let _ = std::fmt::Write::write_fmt(&mut s, format_args!("BASE <{}>\n", b));
718 }
719 for (pfx, iri) in prefixes {
720 // write_fmt on String never fails, so result can be safely ignored
721 let _ = std::fmt::Write::write_fmt(&mut s, format_args!("PREFIX {}: <{}>\n", pfx, iri));
722 }
723 s
724}
725
726#[cfg(test)]
727mod tests {
728 use super::*;
729
730 #[test]
731 fn test_graph_new() {
732 // Arrange & Act
733 let graph = Graph::new().unwrap();
734
735 // Assert
736 assert!(graph.is_empty());
737 assert_eq!(graph.len(), 0);
738 }
739
740 #[test]
741 fn test_graph_insert_turtle() {
742 // Arrange
743 let graph = Graph::new().unwrap();
744
745 // Act
746 graph
747 .insert_turtle(
748 r#"
749 @prefix ex: <http://example.org/> .
750 ex:alice a ex:Person .
751 "#,
752 )
753 .unwrap();
754
755 // Assert
756 assert!(!graph.is_empty());
757 assert!(graph.len() > 0);
758 }
759
760 #[test]
761 fn test_graph_query_cached() {
762 // Arrange
763 let graph = Graph::new().unwrap();
764 graph
765 .insert_turtle(
766 r#"
767 @prefix ex: <http://example.org/> .
768 ex:alice a ex:Person ;
769 ex:name "Alice" .
770 "#,
771 )
772 .unwrap();
773
774 // Act - Use full IRI since we're not declaring prefixes in the query
775 let result = graph
776 .query_cached("SELECT ?name WHERE { ?s <http://example.org/name> ?name }")
777 .unwrap();
778
779 // Assert
780 match result {
781 CachedResult::Solutions(rows) => {
782 assert!(!rows.is_empty());
783 assert!(rows[0].contains_key("name"));
784 }
785 _ => panic!("Expected solutions"),
786 }
787 }
788
789 #[test]
790 fn test_build_prolog_with_prefixes() {
791 // Arrange
792 let mut prefixes = BTreeMap::new();
793 prefixes.insert("ex".to_string(), "http://example.org/".to_string());
794 prefixes.insert(
795 "rdf".to_string(),
796 "http://www.w3.org/1999/02/22-rdf-syntax-ns#".to_string(),
797 );
798
799 // Act
800 let prolog = build_prolog(&prefixes, None);
801
802 // Assert
803 assert!(prolog.contains("PREFIX ex: <http://example.org/>"));
804 assert!(prolog.contains("PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>"));
805 }
806
807 #[test]
808 fn test_build_prolog_with_base() {
809 // Arrange
810 let prefixes = BTreeMap::new();
811
812 // Act
813 let prolog = build_prolog(&prefixes, Some("http://example.org/"));
814
815 // Assert
816 assert!(prolog.contains("BASE <http://example.org/>"));
817 }
818}