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}