Skip to main content

shifty_engine/
frozen.rs

1//! Immutable dictionary-encoded RDF dataset built at the inference→validation
2//! boundary. Implements `spareval::QueryableDataset` so Oxigraph's prepared
3//! SPARQL evaluator can execute queries against it without a mutable Store.
4//!
5//! Stage 2 of `docs/05-sparql-execution.md`.
6//!
7//! This file is the read-mostly dataset boundary used after inference. Loading
8//! assigns compact `TermId`s once, then builds three sorted triple permutations
9//! for the prefix scans that paths and native SPARQL need. Evaluation stays in
10//! ids until a result crosses back into RDF terms; avoiding repeated `Term`
11//! hashing and cloning is the point of the representation.
12//!
13//! Query-only terms and reachability results may be cached behind interior
14//! mutability, but neither changes the RDF snapshot. The only real mutation,
15//! `extend_triples`, updates every index/statistic and discards derived closures.
16//! Thus every scan sees one coherent snapshot and every cached closure belongs
17//! to that snapshot.
18
19use std::cell::RefCell;
20use std::collections::{HashMap, HashSet};
21use std::convert::Infallible;
22use std::rc::Rc;
23
24use crate::path_plan::ReachStep;
25use oxrdf::{Graph, NamedNode, Term};
26use shifty_opt::ClosureKind;
27use spareval::{InternalQuad, QueryableDataset};
28
29/// IRI under which the shapes graph is loaded into the named-graph slot.
30pub(crate) const SHAPES_GRAPH_IRI: &str = "urn:x-shacl:shapes-graph";
31
32/// Dictionary index for a term. Chosen as `u32` to keep triple arrays at 12
33/// bytes each; the 4B ceiling is unreachable in practice.
34pub type TermId = u32;
35
36/// Which graph a scan reads from.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
38pub(crate) enum GraphSel {
39    /// The default (data) graph.
40    Default,
41    /// A specific named graph, identified by its graph-IRI's `TermId`.
42    Named(TermId),
43}
44
45// ── Term dictionary ──────────────────────────────────────────────────────────
46
47struct TermDictInner {
48    id_to_term: Vec<Term>,
49    term_to_id: HashMap<Term, TermId>,
50}
51
52/// Bidirectional Term ↔ TermId map. Interior-mutable so that
53/// `QueryableDataset::internalize_term` (which takes `&self`) can lazily
54/// assign IDs to query constants that did not appear in the loaded triples.
55struct TermDictionary(RefCell<TermDictInner>);
56
57impl TermDictionary {
58    fn new() -> Self {
59        Self(RefCell::new(TermDictInner {
60            id_to_term: Vec::new(),
61            term_to_id: HashMap::new(),
62        }))
63    }
64
65    /// Return existing ID or assign a new one.
66    fn intern(&self, term: Term) -> TermId {
67        let mut inner = self.0.borrow_mut();
68        if let Some(&id) = inner.term_to_id.get(&term) {
69            return id;
70        }
71        let id = inner.id_to_term.len() as TermId;
72        inner.term_to_id.insert(term.clone(), id);
73        inner.id_to_term.push(term);
74        id
75    }
76
77    #[cfg(test)]
78    fn get(&self, term: &Term) -> Option<TermId> {
79        self.0.borrow().term_to_id.get(term).copied()
80    }
81
82    fn externalize(&self, id: TermId) -> Option<Term> {
83        self.0.borrow().id_to_term.get(id as usize).cloned()
84    }
85}
86
87// ── Triple index ─────────────────────────────────────────────────────────────
88
89/// Three sorted arrays giving the six access patterns documented in §161:
90/// `spo` covers S-first and membership; `pos` covers P-first lookups;
91/// `osp` covers O-first lookups.
92///
93/// This is deliberately not six hash indexes. All query and path operations
94/// need a contiguous prefix range, and three permutations cover their useful
95/// leading pairs while keeping triples compact (three `u32`s per copy). The
96/// resulting scans borrow a slice directly; no iterator allocates a matching
97/// triple list just to traverse it once.
98struct TripleIndex {
99    spo: Vec<[TermId; 3]>, // elements are [s, p, o]
100    pos: Vec<[TermId; 3]>, // elements are [p, o, s]
101    osp: Vec<[TermId; 3]>, // elements are [o, s, p]
102}
103
104impl TripleIndex {
105    fn build(mut triples: Vec<[TermId; 3]>) -> Self {
106        triples.sort_unstable();
107        triples.dedup();
108
109        let mut pos: Vec<[TermId; 3]> = triples.iter().map(|&[s, p, o]| [p, o, s]).collect();
110        pos.sort_unstable();
111
112        let mut osp: Vec<[TermId; 3]> = triples.iter().map(|&[s, p, o]| [o, s, p]).collect();
113        osp.sort_unstable();
114
115        Self {
116            spo: triples,
117            pos,
118            osp,
119        }
120    }
121
122    fn contains(&self, s: TermId, p: TermId, o: TermId) -> bool {
123        self.spo.binary_search(&[s, p, o]).is_ok()
124    }
125
126    fn extend(&mut self, triples: Vec<[TermId; 3]>) {
127        if triples.is_empty() {
128            return;
129        }
130        self.spo.extend(triples.iter().copied());
131        self.spo.sort_unstable();
132        self.spo.dedup();
133
134        self.pos.extend(triples.iter().map(|&[s, p, o]| [p, o, s]));
135        self.pos.sort_unstable();
136        self.pos.dedup();
137
138        self.osp
139            .extend(triples.into_iter().map(|[s, p, o]| [o, s, p]));
140        self.osp.sort_unstable();
141        self.osp.dedup();
142    }
143
144    fn range_s(&self, s: TermId) -> &[[TermId; 3]] {
145        let lo = self.spo.partition_point(|t| t[0] < s);
146        let hi = self.spo.partition_point(|t| t[0] <= s);
147        &self.spo[lo..hi]
148    }
149
150    fn range_sp(&self, s: TermId, p: TermId) -> &[[TermId; 3]] {
151        let lo = self.spo.partition_point(|t| (t[0], t[1]) < (s, p));
152        let hi = self.spo.partition_point(|t| (t[0], t[1]) <= (s, p));
153        &self.spo[lo..hi]
154    }
155
156    fn range_p(&self, p: TermId) -> &[[TermId; 3]] {
157        let lo = self.pos.partition_point(|t| t[0] < p);
158        let hi = self.pos.partition_point(|t| t[0] <= p);
159        &self.pos[lo..hi]
160    }
161
162    fn range_po(&self, p: TermId, o: TermId) -> &[[TermId; 3]] {
163        let lo = self.pos.partition_point(|t| (t[0], t[1]) < (p, o));
164        let hi = self.pos.partition_point(|t| (t[0], t[1]) <= (p, o));
165        &self.pos[lo..hi]
166    }
167
168    fn range_o(&self, o: TermId) -> &[[TermId; 3]] {
169        let lo = self.osp.partition_point(|t| t[0] < o);
170        let hi = self.osp.partition_point(|t| t[0] <= o);
171        &self.osp[lo..hi]
172    }
173}
174
175// ── Statistics ───────────────────────────────────────────────────────────────
176
177/// Dataset statistics used in stage 3+ for join-order planning (doc §198).
178pub struct DatasetStatistics {
179    pub triple_count: u64,
180    pub distinct_subjects: u64,
181    pub distinct_objects: u64,
182    /// Triples per predicate ID.
183    pub predicate_cardinality: HashMap<TermId, u64>,
184    subjects: HashSet<TermId>,
185    objects: HashSet<TermId>,
186}
187
188impl DatasetStatistics {
189    fn compute(triples: &TripleIndex) -> Self {
190        let triple_count = triples.spo.len() as u64;
191        let subjects: HashSet<TermId> = triples.spo.iter().map(|t| t[0]).collect();
192        let objects: HashSet<TermId> = triples.spo.iter().map(|t| t[2]).collect();
193        let mut predicate_cardinality: HashMap<TermId, u64> = HashMap::new();
194        for &[_, p, _] in &triples.spo {
195            *predicate_cardinality.entry(p).or_insert(0) += 1;
196        }
197
198        Self {
199            triple_count,
200            distinct_subjects: subjects.len() as u64,
201            distinct_objects: objects.len() as u64,
202            predicate_cardinality,
203            subjects,
204            objects,
205        }
206    }
207
208    fn extend(&mut self, triples: &[[TermId; 3]]) {
209        self.triple_count += triples.len() as u64;
210        for &[subject, predicate, object] in triples {
211            self.subjects.insert(subject);
212            self.objects.insert(object);
213            *self.predicate_cardinality.entry(predicate).or_insert(0) += 1;
214        }
215        self.distinct_subjects = self.subjects.len() as u64;
216        self.distinct_objects = self.objects.len() as u64;
217    }
218}
219
220// ── FrozenIndexedDataset ─────────────────────────────────────────────────────
221
222/// Immutable, dictionary-encoded snapshot of a post-inference RDF dataset.
223/// Intended to be built once at the inference→validation boundary and shared
224/// across all per-focus-node SPARQL evaluations.
225pub struct FrozenIndexedDataset {
226    terms: TermDictionary,
227    default_graph: TripleIndex,
228    /// Named graphs keyed by the graph-IRI's TermId. Each value is a
229    /// simple SPO-sorted vec (named graphs are typically small).
230    named_graphs: HashMap<TermId, Vec<[TermId; 3]>>,
231    reach_cache: RefCell<ReachCache>,
232    pub stats: DatasetStatistics,
233}
234
235/// A closure cache is an acceleration, never part of query semantics. Bound it
236/// by the number of retained result ids rather than by entry count: one large
237/// `p*` closure is the memory risk, while many empty/small closures are cheap.
238/// On saturation we simply stop admitting new entries, retaining predictable
239/// memory use and the same indexed evaluation behavior for later requests.
240const MAX_CACHED_REACH_IDS: usize = 1_000_000;
241
242#[derive(PartialEq, Eq, Hash)]
243struct ReachCacheKey {
244    node: TermId,
245    step: ReachStep,
246    kind: ClosureKind,
247    graph: GraphSel,
248}
249
250#[derive(Default)]
251struct ReachCache {
252    /// Shared so callers can cheaply keep a result while the cache remains
253    /// borrowable for another query. Results are immutable sets, making this
254    /// aliasing safe and avoiding a clone proportional to the closure size.
255    entries: HashMap<ReachCacheKey, Rc<HashSet<TermId>>>,
256    cached_ids: usize,
257}
258
259impl FrozenIndexedDataset {
260    /// Build from a single graph loaded into the default graph slot.
261    pub fn from_graph(graph: &Graph) -> Self {
262        let terms = TermDictionary::new();
263        let triples = intern_graph(graph, &terms);
264        let default_graph = TripleIndex::build(triples);
265        let stats = DatasetStatistics::compute(&default_graph);
266        Self {
267            terms,
268            default_graph,
269            named_graphs: HashMap::new(),
270            reach_cache: RefCell::new(ReachCache::default()),
271            stats,
272        }
273    }
274
275    /// Build a default graph from the set union of two source graphs without
276    /// materializing an intermediate `Graph`.
277    pub fn from_graph_union(left: &Graph, right: &Graph) -> Self {
278        let terms = TermDictionary::new();
279        let mut triples = intern_graph(left, &terms);
280        triples.extend(intern_graph(right, &terms));
281        let default_graph = TripleIndex::build(triples);
282        let stats = DatasetStatistics::compute(&default_graph);
283        Self {
284            terms,
285            default_graph,
286            named_graphs: HashMap::new(),
287            reach_cache: RefCell::new(ReachCache::default()),
288            stats,
289        }
290    }
291
292    /// Intern a term against this dataset's dictionary, returning its `TermId`.
293    /// Unknown terms (e.g. query constants absent from the data) receive a fresh
294    /// id that matches no stored triple — exactly the semantics a scan needs.
295    pub(crate) fn intern(&self, term: &Term) -> TermId {
296        self.terms.intern(term.clone())
297    }
298
299    /// Build a [`PlanStats`] for use by the query planner. Converts TermId-keyed
300    /// statistics to Term-keyed form so the planner can look up predicate
301    /// cardinalities without touching the dictionary directly.
302    pub(crate) fn plan_stats(&self) -> shifty_opt::PlanStats {
303        let predicate_cardinality: HashMap<Term, u64> = self
304            .stats
305            .predicate_cardinality
306            .iter()
307            .filter_map(|(&id, &count)| Some((self.externalize(id)?, count)))
308            .collect();
309        let distinct_predicates = predicate_cardinality.len() as u64;
310        shifty_opt::PlanStats {
311            total_triples: self.stats.triple_count,
312            distinct_subjects: self.stats.distinct_subjects,
313            distinct_objects: self.stats.distinct_objects,
314            distinct_predicates,
315            predicate_cardinality,
316        }
317    }
318
319    /// Map a `TermId` back to its RDF term. Returns `None` only for ids that did
320    /// not originate from this dataset.
321    pub(crate) fn externalize(&self, id: TermId) -> Option<Term> {
322        self.terms.externalize(id)
323    }
324
325    /// Like [`externalize`](Self::externalize) but for ids that are guaranteed to
326    /// originate from this dataset (e.g. a [`scan`](Self::scan) result), so the
327    /// lookup cannot fail.
328    pub(crate) fn externalize_id(&self, id: TermId) -> Term {
329        self.terms
330            .externalize(id)
331            .expect("scanned TermId originates from this dataset")
332    }
333
334    pub(crate) fn contains_ids(&self, subject: TermId, predicate: TermId, object: TermId) -> bool {
335        self.default_graph.contains(subject, predicate, object)
336    }
337
338    pub(crate) fn encode_triple(&self, triple: &oxrdf::Triple) -> [TermId; 3] {
339        [
340            self.terms.intern(triple.subject.clone().into()),
341            self.terms.intern(Term::NamedNode(triple.predicate.clone())),
342            self.terms.intern(triple.object.clone()),
343        ]
344    }
345
346    pub(crate) fn cached_reach(
347        &self,
348        node: TermId,
349        step: &ReachStep,
350        kind: ClosureKind,
351        graph: GraphSel,
352    ) -> Option<Rc<HashSet<TermId>>> {
353        self.reach_cache
354            .borrow()
355            .entries
356            .get(&ReachCacheKey {
357                node,
358                step: step.clone(),
359                kind,
360                graph,
361            })
362            .cloned()
363    }
364
365    pub(crate) fn cache_reach(
366        &self,
367        node: TermId,
368        step: &ReachStep,
369        kind: ClosureKind,
370        graph: GraphSel,
371        result: Rc<HashSet<TermId>>,
372    ) {
373        let mut cache = self.reach_cache.borrow_mut();
374        // No eviction policy is needed: this snapshot is short-lived and an
375        // admission-only cap avoids turning a hot closure into repeated
376        // allocate-and-evict churn. A miss remains correct and index-backed.
377        if cache.cached_ids.saturating_add(result.len()) > MAX_CACHED_REACH_IDS {
378            return;
379        }
380        let key = ReachCacheKey {
381            node,
382            step: step.clone(),
383            kind,
384            graph,
385        };
386        if cache.entries.contains_key(&key) {
387            return;
388        }
389        cache.cached_ids += result.len();
390        cache.entries.insert(key, result);
391    }
392
393    /// Scan triples in the selected graph matching an optional S/P/O pattern,
394    /// yielding `(subject, predicate, object)` term-id triples. Reuses the same
395    /// sorted-index access paths as the `QueryableDataset` impl.
396    pub(crate) fn scan(
397        &self,
398        s: Option<TermId>,
399        p: Option<TermId>,
400        o: Option<TermId>,
401        graph: GraphSel,
402    ) -> Box<dyn Iterator<Item = [TermId; 3]> + '_> {
403        let iter = match graph {
404            GraphSel::Default => default_graph_quads(self, s, p, o),
405            GraphSel::Named(g) => named_graph_quads(self, g, s, p, o),
406        };
407        Box::new(iter.map(|q| {
408            let q = q.expect("infallible");
409            [q.subject, q.predicate, q.object]
410        }))
411    }
412
413    pub(crate) fn triples_for_predicate(
414        &self,
415        predicate: &NamedNode,
416    ) -> Box<dyn Iterator<Item = (Term, Term)> + '_> {
417        let p = self.intern(&Term::NamedNode(predicate.clone()));
418        Box::new(
419            self.scan(None, Some(p), None, GraphSel::Default)
420                .map(|[subject, _, object]| {
421                    (self.externalize_id(subject), self.externalize_id(object))
422                }),
423        )
424    }
425
426    pub(crate) fn outgoing(
427        &self,
428        subject: &Term,
429    ) -> Box<dyn Iterator<Item = (NamedNode, Term)> + '_> {
430        let s = self.intern(subject);
431        Box::new(
432            self.scan(Some(s), None, None, GraphSel::Default)
433                .filter_map(|[_, predicate, object]| {
434                    let Term::NamedNode(predicate) = self.externalize_id(predicate) else {
435                        return None;
436                    };
437                    Some((predicate, self.externalize_id(object)))
438                }),
439        )
440    }
441
442    /// Add a committed inference batch to the default-graph indexes.
443    pub(crate) fn extend_triples<'a>(
444        &mut self,
445        triples: impl IntoIterator<Item = &'a oxrdf::Triple>,
446    ) {
447        let mut encoded: Vec<_> = triples
448            .into_iter()
449            .map(|triple| self.encode_triple(triple))
450            .collect();
451        encoded.sort_unstable();
452        encoded.dedup();
453        encoded.retain(|&[s, p, o]| !self.default_graph.contains(s, p, o));
454        self.stats.extend(&encoded);
455        self.default_graph.extend(encoded);
456        *self.reach_cache.borrow_mut() = ReachCache::default();
457    }
458
459    /// Build with `context` in the default graph and `shapes` in the named
460    /// graph `urn:x-shacl:shapes-graph`, mirroring what `SparqlExecutor::build`
461    /// does with the Oxigraph Store.
462    pub fn from_graphs(context: &Graph, shapes: &Graph) -> Self {
463        let terms = TermDictionary::new();
464        let triples = intern_graph(context, &terms);
465        let default_graph = TripleIndex::build(triples);
466        let stats = DatasetStatistics::compute(&default_graph);
467
468        let shapes_iri = NamedNode::new(SHAPES_GRAPH_IRI).expect("static IRI is valid");
469        let graph_id = terms.intern(Term::NamedNode(shapes_iri));
470        let mut named_triples = intern_graph(shapes, &terms);
471        named_triples.sort_unstable();
472        named_triples.dedup();
473
474        let mut named_graphs = HashMap::new();
475        named_graphs.insert(graph_id, named_triples);
476
477        Self {
478            terms,
479            default_graph,
480            named_graphs,
481            reach_cache: RefCell::new(ReachCache::default()),
482            stats,
483        }
484    }
485
486    /// Build a union default graph while also exposing `shapes` through the
487    /// named `$shapesGraph` slot.
488    pub fn from_graph_union_with_shapes(data: &Graph, shapes: &Graph) -> Self {
489        let terms = TermDictionary::new();
490        let mut triples = intern_graph(data, &terms);
491        triples.extend(intern_graph(shapes, &terms));
492        let default_graph = TripleIndex::build(triples);
493        let stats = DatasetStatistics::compute(&default_graph);
494
495        let shapes_iri = NamedNode::new(SHAPES_GRAPH_IRI).expect("static IRI is valid");
496        let graph_id = terms.intern(Term::NamedNode(shapes_iri));
497        let mut named_triples = intern_graph(shapes, &terms);
498        named_triples.sort_unstable();
499        named_triples.dedup();
500        let mut named_graphs = HashMap::new();
501        named_graphs.insert(graph_id, named_triples);
502
503        Self {
504            terms,
505            default_graph,
506            named_graphs,
507            reach_cache: RefCell::new(ReachCache::default()),
508            stats,
509        }
510    }
511}
512
513fn intern_graph(graph: &Graph, terms: &TermDictionary) -> Vec<[TermId; 3]> {
514    graph
515        .iter()
516        .map(|triple| {
517            let s = terms.intern(triple.subject.into_owned().into());
518            let p = terms.intern(Term::NamedNode(triple.predicate.into_owned()));
519            let o = terms.intern(triple.object.into_owned());
520            [s, p, o]
521        })
522        .collect()
523}
524
525// ── QueryableDataset impl ────────────────────────────────────────────────────
526
527#[allow(refining_impl_trait)]
528impl<'a> QueryableDataset<'a> for &'a FrozenIndexedDataset {
529    type InternalTerm = TermId;
530    type Error = Infallible;
531
532    fn internal_quads_for_pattern(
533        &self,
534        subject: Option<&TermId>,
535        predicate: Option<&TermId>,
536        object: Option<&TermId>,
537        graph_name: Option<Option<&TermId>>,
538    ) -> QuadIter<'a> {
539        // Dereference once to get &'a FrozenIndexedDataset, preserving the full
540        // 'a lifetime instead of the shorter borrow lifetime of &self. (Not an
541        // auto-deref: the explicit `*` is what keeps the `'a` lifetime.)
542        #[allow(clippy::explicit_auto_deref)]
543        let ds: &'a FrozenIndexedDataset = *self;
544        let s = subject.copied();
545        let p = predicate.copied();
546        let o = object.copied();
547        match graph_name {
548            Some(None) => default_graph_quads(ds, s, p, o),
549            Some(Some(&g)) => named_graph_quads(ds, g, s, p, o),
550            None => all_named_quads(ds, s, p, o),
551        }
552    }
553
554    fn internalize_term(&self, term: Term) -> Result<TermId, Infallible> {
555        // Lazily assigns IDs to query constants that weren't in the loaded triples.
556        // These will never match any triple index entry but get unique IDs so
557        // term-equality in SPARQL expressions is still correct.
558        Ok(self.terms.intern(term))
559    }
560
561    fn externalize_term(&self, id: TermId) -> Result<Term, Infallible> {
562        Ok(self
563            .terms
564            .externalize(id)
565            .expect("TermId always originates from this dataset's internalize_term or internal_quads_for_pattern"))
566    }
567}
568
569// ─── quad-iterator helpers ───────────────────────────────────────────────────
570
571type QuadIter<'a> = Box<dyn Iterator<Item = Result<InternalQuad<TermId>, Infallible>> + 'a>;
572
573fn mk_quad(s: TermId, p: TermId, o: TermId, g: Option<TermId>) -> InternalQuad<TermId> {
574    InternalQuad {
575        subject: s,
576        predicate: p,
577        object: o,
578        graph_name: g,
579    }
580}
581
582/// Quads from the default graph matching an optional S/P/O pattern.
583fn default_graph_quads<'a>(
584    ds: &'a FrozenIndexedDataset,
585    s: Option<TermId>,
586    p: Option<TermId>,
587    o: Option<TermId>,
588) -> QuadIter<'a> {
589    match (s, p, o) {
590        // Membership check
591        (Some(s), Some(p), Some(o)) => {
592            let hit = ds.default_graph.contains(s, p, o);
593            Box::new(hit.then(|| Ok(mk_quad(s, p, o, None))).into_iter())
594        }
595        // Subject + predicate lookup (SPO range for S,P prefix)
596        (Some(s), Some(p), None) => {
597            let slice = ds.default_graph.range_sp(s, p);
598            Box::new(slice.iter().map(|&t| Ok(mk_quad(t[0], t[1], t[2], None))))
599        }
600        // Subject + object: scan S range, filter by O
601        (Some(s), None, Some(o)) => {
602            let slice = ds.default_graph.range_s(s);
603            Box::new(
604                slice
605                    .iter()
606                    .filter(move |t| t[2] == o)
607                    .map(|&t| Ok(mk_quad(t[0], t[1], t[2], None))),
608            )
609        }
610        // Subject only
611        (Some(s), None, None) => {
612            let slice = ds.default_graph.range_s(s);
613            Box::new(slice.iter().map(|&t| Ok(mk_quad(t[0], t[1], t[2], None))))
614        }
615        // Predicate + object (POS range; elements are [p,o,s])
616        (None, Some(p), Some(o)) => {
617            let slice = ds.default_graph.range_po(p, o);
618            Box::new(slice.iter().map(|&t| Ok(mk_quad(t[2], t[0], t[1], None))))
619        }
620        // Predicate only (POS range; elements are [p,o,s])
621        (None, Some(p), None) => {
622            let slice = ds.default_graph.range_p(p);
623            Box::new(slice.iter().map(|&t| Ok(mk_quad(t[2], t[0], t[1], None))))
624        }
625        // Object only (OSP range; elements are [o,s,p])
626        (None, None, Some(o)) => {
627            let slice = ds.default_graph.range_o(o);
628            Box::new(slice.iter().map(|&t| Ok(mk_quad(t[1], t[2], t[0], None))))
629        }
630        // Full scan
631        (None, None, None) => Box::new(
632            ds.default_graph
633                .spo
634                .iter()
635                .map(|&t| Ok(mk_quad(t[0], t[1], t[2], None))),
636        ),
637    }
638}
639
640/// Quads from a specific named graph matching an optional S/P/O pattern.
641/// Named graphs are small (usually just the shapes graph), so a linear scan is
642/// sufficient for correctness; stage 5 can add indexes if profiling shows need.
643fn named_graph_quads<'a>(
644    ds: &'a FrozenIndexedDataset,
645    g: TermId,
646    s: Option<TermId>,
647    p: Option<TermId>,
648    o: Option<TermId>,
649) -> QuadIter<'a> {
650    let Some(triples) = ds.named_graphs.get(&g) else {
651        return Box::new(std::iter::empty());
652    };
653    Box::new(
654        triples
655            .iter()
656            .filter(move |&&[ts, tp, to]| {
657                s.is_none_or(|v| ts == v) && p.is_none_or(|v| tp == v) && o.is_none_or(|v| to == v)
658            })
659            .map(move |&[ts, tp, to]| Ok(mk_quad(ts, tp, to, Some(g)))),
660    )
661}
662
663/// Quads from ALL named graphs (but NOT the default graph). Used when the
664/// `graph_name` pattern is `None` (SPARQL: no GRAPH clause, any named graph).
665fn all_named_quads<'a>(
666    ds: &'a FrozenIndexedDataset,
667    s: Option<TermId>,
668    p: Option<TermId>,
669    o: Option<TermId>,
670) -> QuadIter<'a> {
671    Box::new(
672        ds.named_graphs
673            .keys()
674            .copied()
675            .flat_map(move |g| named_graph_quads(ds, g, s, p, o)),
676    )
677}
678
679// ── Tests ────────────────────────────────────────────────────────────────────
680
681#[cfg(test)]
682mod tests {
683    use super::*;
684    use oxrdf::{NamedNode, Triple};
685    use spareval::QueryableDataset;
686
687    fn nn(iri: &str) -> NamedNode {
688        NamedNode::new(iri).unwrap()
689    }
690
691    fn triple_nnn(s: &str, p: &str, o: &str) -> Triple {
692        Triple::new(nn(s), nn(p), nn(o))
693    }
694
695    fn small_graph() -> Graph {
696        let mut g = Graph::new();
697        g.insert(triple_nnn("http://ex/a", "http://ex/p", "http://ex/b").as_ref());
698        g.insert(triple_nnn("http://ex/a", "http://ex/p", "http://ex/c").as_ref());
699        g.insert(triple_nnn("http://ex/b", "http://ex/q", "http://ex/c").as_ref());
700        g
701    }
702
703    #[test]
704    fn intern_round_trips() {
705        let g = small_graph();
706        let ds = FrozenIndexedDataset::from_graph(&g);
707        let a = Term::NamedNode(nn("http://ex/a"));
708        let id = ds.terms.intern(a.clone());
709        assert_eq!(ds.terms.externalize(id), Some(a));
710    }
711
712    #[test]
713    fn contains_triple() {
714        let g = small_graph();
715        let ds = FrozenIndexedDataset::from_graph(&g);
716        let s = ds.terms.get(&Term::NamedNode(nn("http://ex/a"))).unwrap();
717        let p = ds.terms.get(&Term::NamedNode(nn("http://ex/p"))).unwrap();
718        let o = ds.terms.get(&Term::NamedNode(nn("http://ex/b"))).unwrap();
719        assert!(ds.default_graph.contains(s, p, o));
720    }
721
722    #[test]
723    fn missing_triple_not_found() {
724        let g = small_graph();
725        let ds = FrozenIndexedDataset::from_graph(&g);
726        let s = ds.terms.get(&Term::NamedNode(nn("http://ex/a"))).unwrap();
727        let p = ds.terms.get(&Term::NamedNode(nn("http://ex/q"))).unwrap();
728        let o = ds.terms.get(&Term::NamedNode(nn("http://ex/c"))).unwrap();
729        assert!(!ds.default_graph.contains(s, p, o));
730    }
731
732    #[test]
733    fn range_s_returns_correct_triples() {
734        let g = small_graph();
735        let ds = FrozenIndexedDataset::from_graph(&g);
736        let s = ds.terms.get(&Term::NamedNode(nn("http://ex/a"))).unwrap();
737        let slice = ds.default_graph.range_s(s);
738        assert_eq!(slice.len(), 2); // a has two triples
739    }
740
741    #[test]
742    fn extend_triples_updates_indexes_and_statistics() {
743        let g = small_graph();
744        let mut ds = FrozenIndexedDataset::from_graph(&g);
745        let added = triple_nnn("http://ex/new", "http://ex/p", "http://ex/b");
746        ds.extend_triples([&added, &added]);
747
748        let s = ds.intern(&Term::NamedNode(nn("http://ex/new")));
749        let p = ds.intern(&Term::NamedNode(nn("http://ex/p")));
750        let o = ds.intern(&Term::NamedNode(nn("http://ex/b")));
751        assert!(ds.contains_ids(s, p, o));
752        assert_eq!(ds.stats.triple_count, 4);
753        assert_eq!(ds.stats.predicate_cardinality[&p], 3);
754    }
755
756    #[test]
757    fn internalize_unknown_term_gets_unique_id() {
758        let g = small_graph();
759        let ds = FrozenIndexedDataset::from_graph(&g);
760        let rds: &FrozenIndexedDataset = &ds;
761        let unknown1 = Term::NamedNode(nn("http://ex/unknown1"));
762        let unknown2 = Term::NamedNode(nn("http://ex/unknown2"));
763        let id1 = rds.internalize_term(unknown1.clone()).unwrap();
764        let id2 = rds.internalize_term(unknown2.clone()).unwrap();
765        assert_ne!(id1, id2, "different unknown terms must get different IDs");
766        // Same term gets same ID (idempotent)
767        let id1b = rds.internalize_term(unknown1).unwrap();
768        assert_eq!(id1, id1b);
769    }
770
771    #[test]
772    fn queryable_dataset_default_graph_pattern() {
773        let g = small_graph();
774        let ds = FrozenIndexedDataset::from_graph(&g);
775        let rds: &FrozenIndexedDataset = &ds;
776        // Query: ?s ?p ?o in default graph → should return all 3 triples
777        let quads: Vec<_> = rds
778            .internal_quads_for_pattern(None, None, None, Some(None))
779            .map(|r| r.unwrap())
780            .collect();
781        assert_eq!(quads.len(), 3);
782    }
783
784    #[test]
785    fn queryable_dataset_named_graph_empty_when_not_loaded() {
786        let g = small_graph();
787        let ds = FrozenIndexedDataset::from_graph(&g);
788        let rds: &FrozenIndexedDataset = &ds;
789        // any named graph → nothing (no named graphs loaded)
790        let quads: Vec<_> = rds
791            .internal_quads_for_pattern(None, None, None, None)
792            .collect();
793        assert!(quads.is_empty());
794    }
795
796    #[test]
797    fn from_graphs_loads_both_default_and_named() {
798        let data = small_graph();
799        let mut shapes = Graph::new();
800        shapes.insert(
801            triple_nnn(
802                "http://ex/S",
803                "http://www.w3.org/ns/shacl#targetNode",
804                "http://ex/a",
805            )
806            .as_ref(),
807        );
808        let ds = FrozenIndexedDataset::from_graphs(&data, &shapes);
809        let rds: &FrozenIndexedDataset = &ds;
810
811        // default graph has 3 triples
812        let default_quads: Vec<_> = rds
813            .internal_quads_for_pattern(None, None, None, Some(None))
814            .map(|r| r.unwrap())
815            .collect();
816        assert_eq!(default_quads.len(), 3);
817
818        // named graph has 1 triple
819        let shapes_iri_id = ds
820            .terms
821            .get(&Term::NamedNode(nn(SHAPES_GRAPH_IRI)))
822            .expect("shapes graph IRI should be interned");
823        let named_quads: Vec<_> = rds
824            .internal_quads_for_pattern(None, None, None, Some(Some(&shapes_iri_id)))
825            .map(|r| r.unwrap())
826            .collect();
827        assert_eq!(named_quads.len(), 1);
828    }
829
830    #[test]
831    fn graph_union_builds_one_deduplicated_default_graph() {
832        let left = small_graph();
833        let mut right = Graph::new();
834        right.insert(triple_nnn("http://ex/a", "http://ex/p", "http://ex/b").as_ref());
835        right.insert(triple_nnn("http://ex/new", "http://ex/p", "http://ex/b").as_ref());
836
837        let ds = FrozenIndexedDataset::from_graph_union(&left, &right);
838
839        assert_eq!(ds.stats.triple_count, 4);
840    }
841}