Skip to main content

oxirs_core/model/
sparql_binding_set.rs

1//! SPARQL Binding Set operations for MINUS and EXISTS evaluation.
2//!
3//! Implements the core set-theoretic operations used in SPARQL query processing,
4//! following the W3C SPARQL 1.1 specification for set algebra.
5
6use std::collections::{HashMap, HashSet};
7
8/// An RDF term that can appear in a SPARQL solution binding.
9#[derive(Debug, Clone, PartialEq, Eq, Hash)]
10pub enum RdfTerm {
11    /// An IRI (Internationalized Resource Identifier)
12    Iri(String),
13    /// A literal value with optional language tag or datatype
14    Literal {
15        value: String,
16        datatype: String,
17        lang: Option<String>,
18    },
19    /// A blank node with a local identifier
20    BlankNode(String),
21}
22
23impl RdfTerm {
24    /// Create an IRI term
25    pub fn iri(iri: impl Into<String>) -> Self {
26        RdfTerm::Iri(iri.into())
27    }
28
29    /// Create a plain string literal with xsd:string datatype
30    pub fn string_literal(value: impl Into<String>) -> Self {
31        RdfTerm::Literal {
32            value: value.into(),
33            datatype: "http://www.w3.org/2001/XMLSchema#string".to_string(),
34            lang: None,
35        }
36    }
37
38    /// Create a language-tagged literal
39    pub fn lang_literal(value: impl Into<String>, lang: impl Into<String>) -> Self {
40        RdfTerm::Literal {
41            value: value.into(),
42            datatype: "http://www.w3.org/1999/02/22-rdf-syntax-ns#langString".to_string(),
43            lang: Some(lang.into()),
44        }
45    }
46
47    /// Create a blank node
48    pub fn blank(id: impl Into<String>) -> Self {
49        RdfTerm::BlankNode(id.into())
50    }
51}
52
53impl std::fmt::Display for RdfTerm {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        match self {
56            RdfTerm::Iri(iri) => write!(f, "<{}>", iri),
57            RdfTerm::Literal {
58                value,
59                lang: Some(lang),
60                ..
61            } => write!(f, "\"{}\"@{}", value, lang),
62            RdfTerm::Literal {
63                value,
64                datatype,
65                lang: None,
66            } => write!(f, "\"{}\"^^<{}>", value, datatype),
67            RdfTerm::BlankNode(id) => write!(f, "_:{}", id),
68        }
69    }
70}
71
72/// A set of SPARQL solution mappings (bindings).
73///
74/// Each element is a mapping from variable names to RDF terms.
75/// Implements the SPARQL algebra operations: MINUS, EXISTS, UNION, PROJECT, JOIN, DISTINCT.
76#[derive(Debug, Clone, Default)]
77pub struct BindingSet {
78    bindings: Vec<HashMap<String, RdfTerm>>,
79}
80
81impl BindingSet {
82    /// Create an empty binding set.
83    pub fn new() -> Self {
84        Self::default()
85    }
86
87    /// Create a binding set from a vector of solution mappings.
88    pub fn from_vec(bindings: Vec<HashMap<String, RdfTerm>>) -> Self {
89        BindingSet { bindings }
90    }
91
92    /// Add a single solution mapping.
93    pub fn add(&mut self, binding: HashMap<String, RdfTerm>) {
94        self.bindings.push(binding);
95    }
96
97    /// Number of solution mappings in this set.
98    pub fn len(&self) -> usize {
99        self.bindings.len()
100    }
101
102    /// Returns true if this binding set contains no solutions.
103    pub fn is_empty(&self) -> bool {
104        self.bindings.is_empty()
105    }
106
107    /// SPARQL MINUS operation.
108    ///
109    /// Returns a binding set containing only those rows from `self` that are NOT
110    /// compatible with any row in `other` **that shares at least one variable**.
111    ///
112    /// W3C SPARQL 1.1 spec §18.5: A row μ₁ from Ω₁ is removed if there exists
113    /// μ₂ in Ω₂ such that:
114    /// - dom(μ₁) ∩ dom(μ₂) ≠ ∅  (they share at least one variable)
115    /// - μ₁ and μ₂ are compatible on shared variables
116    ///
117    /// If no row in `other` shares any variable with a row in `self`, the row is KEPT.
118    pub fn minus(&self, other: &BindingSet) -> BindingSet {
119        let kept = self
120            .bindings
121            .iter()
122            .filter(|row_self| {
123                !other.bindings.iter().any(|row_other| {
124                    let shared: HashSet<&String> = row_self
125                        .keys()
126                        .collect::<HashSet<_>>()
127                        .intersection(&row_other.keys().collect::<HashSet<_>>())
128                        .copied()
129                        .collect();
130                    !shared.is_empty() && Self::is_compatible(row_self, row_other)
131                })
132            })
133            .cloned()
134            .collect();
135        BindingSet { bindings: kept }
136    }
137
138    /// EXISTS filter: keep rows from `self` that are compatible with at least
139    /// one row in `pattern`.
140    ///
141    /// Two rows are compatible if all their shared variables agree in value.
142    /// Rows with no shared variables are considered compatible.
143    pub fn exists_filter(&self, pattern: &BindingSet) -> BindingSet {
144        let kept = self
145            .bindings
146            .iter()
147            .filter(|row_self| {
148                pattern
149                    .bindings
150                    .iter()
151                    .any(|row_pattern| Self::is_compatible(row_self, row_pattern))
152            })
153            .cloned()
154            .collect();
155        BindingSet { bindings: kept }
156    }
157
158    /// UNION: combine all rows from both binding sets.
159    pub fn union(&self, other: &BindingSet) -> BindingSet {
160        let mut result = self.bindings.clone();
161        result.extend(other.bindings.iter().cloned());
162        BindingSet { bindings: result }
163    }
164
165    /// PROJECT: keep only the named variables in each solution mapping.
166    ///
167    /// Variables not in `vars` are dropped. Solutions that become identical
168    /// after projection are NOT automatically deduplicated (call `distinct` separately).
169    pub fn project(&self, vars: &[&str]) -> BindingSet {
170        let projected = self
171            .bindings
172            .iter()
173            .map(|row| {
174                vars.iter()
175                    .filter_map(|v| row.get(*v).map(|term| (v.to_string(), term.clone())))
176                    .collect::<HashMap<String, RdfTerm>>()
177            })
178            .collect();
179        BindingSet {
180            bindings: projected,
181        }
182    }
183
184    /// Natural JOIN: for each pair of rows from `self` and `other` that are
185    /// compatible (agree on all shared variables), produce the merged row.
186    pub fn join(&self, other: &BindingSet) -> BindingSet {
187        let mut result = Vec::new();
188        for row_self in &self.bindings {
189            for row_other in &other.bindings {
190                if Self::is_compatible(row_self, row_other) {
191                    result.push(Self::merge_rows(row_self, row_other));
192                }
193            }
194        }
195        BindingSet { bindings: result }
196    }
197
198    /// DISTINCT: remove duplicate solution mappings.
199    pub fn distinct(&self) -> BindingSet {
200        let mut seen: HashSet<Vec<(String, RdfTerm)>> = HashSet::new();
201        let unique = self
202            .bindings
203            .iter()
204            .filter(|row| {
205                let mut sorted: Vec<(String, RdfTerm)> =
206                    row.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
207                sorted.sort_by(|a, b| a.0.cmp(&b.0));
208                seen.insert(sorted)
209            })
210            .cloned()
211            .collect();
212        BindingSet { bindings: unique }
213    }
214
215    /// Iterate over all solution mappings in this set.
216    pub fn iter(&self) -> impl Iterator<Item = &HashMap<String, RdfTerm>> {
217        self.bindings.iter()
218    }
219
220    /// Two solution mappings are **compatible** if, for every variable they
221    /// both bind, they agree on the value.
222    ///
223    /// Rows with no shared variables are always compatible.
224    pub fn is_compatible(a: &HashMap<String, RdfTerm>, b: &HashMap<String, RdfTerm>) -> bool {
225        a.iter()
226            .all(|(var, term_a)| b.get(var).map_or(true, |term_b| term_a == term_b))
227    }
228
229    /// Merge two compatible rows into one by taking the union of their variables.
230    fn merge_rows(
231        a: &HashMap<String, RdfTerm>,
232        b: &HashMap<String, RdfTerm>,
233    ) -> HashMap<String, RdfTerm> {
234        let mut merged = a.clone();
235        for (k, v) in b {
236            merged.entry(k.clone()).or_insert_with(|| v.clone());
237        }
238        merged
239    }
240}
241
242impl IntoIterator for BindingSet {
243    type Item = HashMap<String, RdfTerm>;
244    type IntoIter = std::vec::IntoIter<HashMap<String, RdfTerm>>;
245
246    fn into_iter(self) -> Self::IntoIter {
247        self.bindings.into_iter()
248    }
249}
250
251/// Helper to build a single solution mapping from pairs.
252pub fn solution(pairs: &[(&str, RdfTerm)]) -> HashMap<String, RdfTerm> {
253    pairs
254        .iter()
255        .map(|(k, v)| (k.to_string(), v.clone()))
256        .collect()
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    // ── helpers ──────────────────────────────────────────────────────────────
264
265    fn iri(s: &str) -> RdfTerm {
266        RdfTerm::iri(s)
267    }
268
269    fn lit(s: &str) -> RdfTerm {
270        RdfTerm::string_literal(s)
271    }
272
273    fn bnode(s: &str) -> RdfTerm {
274        RdfTerm::blank(s)
275    }
276
277    fn row(pairs: &[(&str, RdfTerm)]) -> HashMap<String, RdfTerm> {
278        solution(pairs)
279    }
280
281    fn single(var: &str, term: RdfTerm) -> HashMap<String, RdfTerm> {
282        let mut m = HashMap::new();
283        m.insert(var.to_string(), term);
284        m
285    }
286
287    // ── BindingSet::new ───────────────────────────────────────────────────────
288
289    #[test]
290    fn test_new_is_empty() {
291        let bs = BindingSet::new();
292        assert!(bs.is_empty());
293        assert_eq!(bs.len(), 0);
294    }
295
296    #[test]
297    fn test_from_vec_empty() {
298        let bs = BindingSet::from_vec(vec![]);
299        assert!(bs.is_empty());
300    }
301
302    #[test]
303    fn test_from_vec_non_empty() {
304        let bs = BindingSet::from_vec(vec![single("x", iri("http://a"))]);
305        assert_eq!(bs.len(), 1);
306    }
307
308    #[test]
309    fn test_add() {
310        let mut bs = BindingSet::new();
311        bs.add(single("x", iri("http://a")));
312        bs.add(single("x", iri("http://b")));
313        assert_eq!(bs.len(), 2);
314    }
315
316    // ── is_compatible ────────────────────────────────────────────────────────
317
318    #[test]
319    fn test_compatible_no_shared_vars() {
320        let a = single("x", iri("http://a"));
321        let b = single("y", iri("http://b"));
322        assert!(BindingSet::is_compatible(&a, &b));
323    }
324
325    #[test]
326    fn test_compatible_same_shared_var_same_value() {
327        let a = single("x", iri("http://a"));
328        let b = single("x", iri("http://a"));
329        assert!(BindingSet::is_compatible(&a, &b));
330    }
331
332    #[test]
333    fn test_incompatible_shared_var_different_value() {
334        let a = single("x", iri("http://a"));
335        let b = single("x", iri("http://b"));
336        assert!(!BindingSet::is_compatible(&a, &b));
337    }
338
339    #[test]
340    fn test_compatible_partial_overlap() {
341        let a = row(&[("x", iri("http://a")), ("y", iri("http://y"))]);
342        let b = row(&[("x", iri("http://a")), ("z", iri("http://z"))]);
343        assert!(BindingSet::is_compatible(&a, &b));
344    }
345
346    #[test]
347    fn test_incompatible_partial_overlap() {
348        let a = row(&[("x", iri("http://a")), ("y", iri("http://y"))]);
349        let b = row(&[("x", iri("http://DIFFERENT")), ("z", iri("http://z"))]);
350        assert!(!BindingSet::is_compatible(&a, &b));
351    }
352
353    #[test]
354    fn test_compatible_all_shared_vars_agree() {
355        let a = row(&[("x", iri("http://x")), ("y", lit("hello"))]);
356        let b = row(&[("x", iri("http://x")), ("y", lit("hello"))]);
357        assert!(BindingSet::is_compatible(&a, &b));
358    }
359
360    #[test]
361    fn test_compatible_empty_rows() {
362        let a: HashMap<String, RdfTerm> = HashMap::new();
363        let b: HashMap<String, RdfTerm> = HashMap::new();
364        assert!(BindingSet::is_compatible(&a, &b));
365    }
366
367    // ── MINUS semantics ───────────────────────────────────────────────────────
368
369    #[test]
370    fn test_minus_empty_self() {
371        let s = BindingSet::new();
372        let o = BindingSet::from_vec(vec![single("x", iri("http://a"))]);
373        assert!(s.minus(&o).is_empty());
374    }
375
376    #[test]
377    fn test_minus_empty_other() {
378        let s = BindingSet::from_vec(vec![single("x", iri("http://a"))]);
379        let o = BindingSet::new();
380        assert_eq!(s.minus(&o).len(), 1);
381    }
382
383    #[test]
384    fn test_minus_removes_compatible_row_with_shared_var() {
385        // x=a in self, x=a in other → row is removed
386        let s = BindingSet::from_vec(vec![single("x", iri("http://a"))]);
387        let o = BindingSet::from_vec(vec![single("x", iri("http://a"))]);
388        assert_eq!(s.minus(&o).len(), 0);
389    }
390
391    #[test]
392    fn test_minus_keeps_row_different_value_shared_var() {
393        // x=a in self, x=b in other → incompatible, row is kept
394        let s = BindingSet::from_vec(vec![single("x", iri("http://a"))]);
395        let o = BindingSet::from_vec(vec![single("x", iri("http://b"))]);
396        assert_eq!(s.minus(&o).len(), 1);
397    }
398
399    #[test]
400    fn test_minus_keeps_row_no_shared_vars() {
401        // SPARQL MINUS rule: no shared vars → row is KEPT
402        let s = BindingSet::from_vec(vec![single("x", iri("http://a"))]);
403        let o = BindingSet::from_vec(vec![single("y", iri("http://b"))]);
404        assert_eq!(s.minus(&o).len(), 1);
405    }
406
407    #[test]
408    fn test_minus_partial_filter() {
409        // Two rows in self: x=a (matches other) and x=b (does not)
410        let s = BindingSet::from_vec(vec![
411            single("x", iri("http://a")),
412            single("x", iri("http://b")),
413        ]);
414        let o = BindingSet::from_vec(vec![single("x", iri("http://a"))]);
415        let result = s.minus(&o);
416        assert_eq!(result.len(), 1);
417        assert_eq!(result.bindings[0].get("x"), Some(&iri("http://b")));
418    }
419
420    #[test]
421    fn test_minus_multiple_rows_in_other() {
422        let s = BindingSet::from_vec(vec![
423            single("x", iri("http://a")),
424            single("x", iri("http://b")),
425            single("x", iri("http://c")),
426        ]);
427        let o = BindingSet::from_vec(vec![
428            single("x", iri("http://a")),
429            single("x", iri("http://c")),
430        ]);
431        let result = s.minus(&o);
432        assert_eq!(result.len(), 1);
433        assert_eq!(result.bindings[0].get("x"), Some(&iri("http://b")));
434    }
435
436    #[test]
437    fn test_minus_multi_variable_rows() {
438        // Both rows share x, y but other has different y → row kept
439        let s = BindingSet::from_vec(vec![row(&[("x", iri("http://a")), ("y", lit("foo"))])]);
440        let o = BindingSet::from_vec(vec![row(&[("x", iri("http://a")), ("y", lit("bar"))])]);
441        assert_eq!(s.minus(&o).len(), 1);
442    }
443
444    #[test]
445    fn test_minus_row_with_no_vars_kept_always() {
446        // Empty binding row has no shared vars with anything → kept
447        let empty_row: HashMap<String, RdfTerm> = HashMap::new();
448        let s = BindingSet::from_vec(vec![empty_row]);
449        let o = BindingSet::from_vec(vec![single("x", iri("http://a"))]);
450        assert_eq!(s.minus(&o).len(), 1);
451    }
452
453    // ── EXISTS filter ─────────────────────────────────────────────────────────
454
455    #[test]
456    fn test_exists_filter_empty_pattern() {
457        let s = BindingSet::from_vec(vec![single("x", iri("http://a"))]);
458        let p = BindingSet::new();
459        // No row in pattern → nothing is compatible → result is empty
460        assert_eq!(s.exists_filter(&p).len(), 0);
461    }
462
463    #[test]
464    fn test_exists_filter_compatible_keeps_row() {
465        let s = BindingSet::from_vec(vec![single("x", iri("http://a"))]);
466        let p = BindingSet::from_vec(vec![single("x", iri("http://a"))]);
467        assert_eq!(s.exists_filter(&p).len(), 1);
468    }
469
470    #[test]
471    fn test_exists_filter_incompatible_removes_row() {
472        let s = BindingSet::from_vec(vec![single("x", iri("http://a"))]);
473        let p = BindingSet::from_vec(vec![single("x", iri("http://b"))]);
474        assert_eq!(s.exists_filter(&p).len(), 0);
475    }
476
477    #[test]
478    fn test_exists_filter_no_shared_vars_compatible() {
479        // No shared variables → compatible → row is kept
480        let s = BindingSet::from_vec(vec![single("x", iri("http://a"))]);
481        let p = BindingSet::from_vec(vec![single("y", iri("http://b"))]);
482        assert_eq!(s.exists_filter(&p).len(), 1);
483    }
484
485    #[test]
486    fn test_exists_filter_mixed() {
487        let s = BindingSet::from_vec(vec![
488            single("x", iri("http://a")),
489            single("x", iri("http://b")),
490        ]);
491        let p = BindingSet::from_vec(vec![single("x", iri("http://a"))]);
492        assert_eq!(s.exists_filter(&p).len(), 1);
493    }
494
495    // ── UNION ─────────────────────────────────────────────────────────────────
496
497    #[test]
498    fn test_union_empty_both() {
499        let a = BindingSet::new();
500        let b = BindingSet::new();
501        assert!(a.union(&b).is_empty());
502    }
503
504    #[test]
505    fn test_union_one_empty() {
506        let a = BindingSet::from_vec(vec![single("x", iri("http://a"))]);
507        let b = BindingSet::new();
508        assert_eq!(a.union(&b).len(), 1);
509    }
510
511    #[test]
512    fn test_union_both_non_empty() {
513        let a = BindingSet::from_vec(vec![single("x", iri("http://a"))]);
514        let b = BindingSet::from_vec(vec![single("y", iri("http://b"))]);
515        assert_eq!(a.union(&b).len(), 2);
516    }
517
518    #[test]
519    fn test_union_preserves_duplicates() {
520        let a = BindingSet::from_vec(vec![single("x", iri("http://a"))]);
521        let b = BindingSet::from_vec(vec![single("x", iri("http://a"))]);
522        assert_eq!(a.union(&b).len(), 2); // union does NOT deduplicate
523    }
524
525    // ── PROJECT ───────────────────────────────────────────────────────────────
526
527    #[test]
528    fn test_project_keeps_named_vars() {
529        let bs = BindingSet::from_vec(vec![row(&[
530            ("x", iri("http://x")),
531            ("y", iri("http://y")),
532            ("z", iri("http://z")),
533        ])]);
534        let proj = bs.project(&["x", "z"]);
535        assert_eq!(proj.len(), 1);
536        let r = &proj.bindings[0];
537        assert!(r.contains_key("x"));
538        assert!(!r.contains_key("y"));
539        assert!(r.contains_key("z"));
540    }
541
542    #[test]
543    fn test_project_no_vars() {
544        let bs = BindingSet::from_vec(vec![single("x", iri("http://x"))]);
545        let proj = bs.project(&[]);
546        assert_eq!(proj.len(), 1);
547        assert!(proj.bindings[0].is_empty());
548    }
549
550    #[test]
551    fn test_project_missing_var_omitted() {
552        let bs = BindingSet::from_vec(vec![single("x", iri("http://x"))]);
553        let proj = bs.project(&["x", "missing"]);
554        assert_eq!(proj.len(), 1);
555        assert!(proj.bindings[0].contains_key("x"));
556        assert!(!proj.bindings[0].contains_key("missing"));
557    }
558
559    #[test]
560    fn test_project_empty_set() {
561        let bs = BindingSet::new();
562        let proj = bs.project(&["x"]);
563        assert!(proj.is_empty());
564    }
565
566    // ── JOIN ─────────────────────────────────────────────────────────────────
567
568    #[test]
569    fn test_join_empty_left() {
570        let a = BindingSet::new();
571        let b = BindingSet::from_vec(vec![single("x", iri("http://a"))]);
572        assert!(a.join(&b).is_empty());
573    }
574
575    #[test]
576    fn test_join_empty_right() {
577        let a = BindingSet::from_vec(vec![single("x", iri("http://a"))]);
578        let b = BindingSet::new();
579        assert!(a.join(&b).is_empty());
580    }
581
582    #[test]
583    fn test_join_no_shared_vars_cross_product() {
584        let a = BindingSet::from_vec(vec![single("x", iri("http://1"))]);
585        let b = BindingSet::from_vec(vec![single("y", iri("http://2"))]);
586        let j = a.join(&b);
587        assert_eq!(j.len(), 1);
588        assert!(j.bindings[0].contains_key("x"));
589        assert!(j.bindings[0].contains_key("y"));
590    }
591
592    #[test]
593    fn test_join_shared_var_compatible() {
594        let a = BindingSet::from_vec(vec![row(&[("x", iri("http://a")), ("y", lit("foo"))])]);
595        let b = BindingSet::from_vec(vec![row(&[("x", iri("http://a")), ("z", lit("bar"))])]);
596        let j = a.join(&b);
597        assert_eq!(j.len(), 1);
598        assert_eq!(j.bindings[0].get("x"), Some(&iri("http://a")));
599        assert_eq!(j.bindings[0].get("y"), Some(&lit("foo")));
600        assert_eq!(j.bindings[0].get("z"), Some(&lit("bar")));
601    }
602
603    #[test]
604    fn test_join_shared_var_incompatible_excluded() {
605        let a = BindingSet::from_vec(vec![single("x", iri("http://a"))]);
606        let b = BindingSet::from_vec(vec![single("x", iri("http://b"))]);
607        assert!(a.join(&b).is_empty());
608    }
609
610    #[test]
611    fn test_join_multiple_rows() {
612        let a = BindingSet::from_vec(vec![
613            single("x", iri("http://1")),
614            single("x", iri("http://2")),
615        ]);
616        let b = BindingSet::from_vec(vec![
617            single("x", iri("http://1")),
618            single("x", iri("http://3")),
619        ]);
620        let j = a.join(&b);
621        // Only (x=1, x=1) is compatible
622        assert_eq!(j.len(), 1);
623        assert_eq!(j.bindings[0].get("x"), Some(&iri("http://1")));
624    }
625
626    // ── DISTINCT ─────────────────────────────────────────────────────────────
627
628    #[test]
629    fn test_distinct_empty() {
630        let bs = BindingSet::new();
631        assert!(bs.distinct().is_empty());
632    }
633
634    #[test]
635    fn test_distinct_no_duplicates() {
636        let bs = BindingSet::from_vec(vec![
637            single("x", iri("http://a")),
638            single("x", iri("http://b")),
639        ]);
640        assert_eq!(bs.distinct().len(), 2);
641    }
642
643    #[test]
644    fn test_distinct_with_duplicates() {
645        let bs = BindingSet::from_vec(vec![
646            single("x", iri("http://a")),
647            single("x", iri("http://a")),
648            single("x", iri("http://b")),
649        ]);
650        assert_eq!(bs.distinct().len(), 2);
651    }
652
653    #[test]
654    fn test_distinct_multi_var_duplicates() {
655        let r1 = row(&[("x", iri("http://x")), ("y", lit("foo"))]);
656        let r2 = row(&[("x", iri("http://x")), ("y", lit("foo"))]);
657        let r3 = row(&[("x", iri("http://x")), ("y", lit("bar"))]);
658        let bs = BindingSet::from_vec(vec![r1, r2, r3]);
659        assert_eq!(bs.distinct().len(), 2);
660    }
661
662    // ── iter ─────────────────────────────────────────────────────────────────
663
664    #[test]
665    fn test_iter() {
666        let bs = BindingSet::from_vec(vec![
667            single("x", iri("http://1")),
668            single("x", iri("http://2")),
669        ]);
670        let collected: Vec<_> = bs.iter().collect();
671        assert_eq!(collected.len(), 2);
672    }
673
674    // ── RdfTerm display / helpers ─────────────────────────────────────────────
675
676    #[test]
677    fn test_rdf_term_iri_display() {
678        let t = iri("http://example.org/thing");
679        assert_eq!(format!("{}", t), "<http://example.org/thing>");
680    }
681
682    #[test]
683    fn test_rdf_term_literal_display() {
684        let t = lit("hello");
685        assert!(format!("{}", t).contains("hello"));
686    }
687
688    #[test]
689    fn test_rdf_term_lang_display() {
690        let t = RdfTerm::lang_literal("hello", "en");
691        assert!(format!("{}", t).contains("@en"));
692    }
693
694    #[test]
695    fn test_rdf_term_blank_display() {
696        let t = bnode("b0");
697        assert_eq!(format!("{}", t), "_:b0");
698    }
699
700    #[test]
701    fn test_rdf_term_eq() {
702        assert_eq!(iri("http://a"), iri("http://a"));
703        assert_ne!(iri("http://a"), iri("http://b"));
704        assert_ne!(iri("http://a"), lit("http://a"));
705        assert_ne!(iri("http://a"), bnode("b0"));
706    }
707
708    // ── Edge cases ────────────────────────────────────────────────────────────
709
710    #[test]
711    fn test_minus_all_kept_when_no_shared_vars() {
712        // All rows in self have no shared variables with other → all kept
713        let s = BindingSet::from_vec(vec![
714            single("x", iri("http://1")),
715            single("x", iri("http://2")),
716            single("x", iri("http://3")),
717        ]);
718        let o = BindingSet::from_vec(vec![single("y", iri("http://y"))]);
719        assert_eq!(s.minus(&o).len(), 3);
720    }
721
722    #[test]
723    fn test_minus_both_empty() {
724        let s = BindingSet::new();
725        let o = BindingSet::new();
726        assert!(s.minus(&o).is_empty());
727    }
728
729    #[test]
730    fn test_exists_filter_self_empty() {
731        let s = BindingSet::new();
732        let p = BindingSet::from_vec(vec![single("x", iri("http://a"))]);
733        assert!(s.exists_filter(&p).is_empty());
734    }
735
736    #[test]
737    fn test_join_all_compatible_no_shared() {
738        // 2×2 = 4 rows in cross product
739        let a = BindingSet::from_vec(vec![
740            single("x", iri("http://1")),
741            single("x", iri("http://2")),
742        ]);
743        let b = BindingSet::from_vec(vec![
744            single("y", iri("http://a")),
745            single("y", iri("http://b")),
746        ]);
747        assert_eq!(a.join(&b).len(), 4);
748    }
749
750    #[test]
751    fn test_project_and_distinct() {
752        // Project x,y then distinct: two identical projected rows become one
753        let bs = BindingSet::from_vec(vec![
754            row(&[("x", iri("http://x")), ("y", lit("foo")), ("z", lit("A"))]),
755            row(&[("x", iri("http://x")), ("y", lit("foo")), ("z", lit("B"))]),
756        ]);
757        let proj = bs.project(&["x", "y"]).distinct();
758        assert_eq!(proj.len(), 1);
759    }
760
761    #[test]
762    fn test_union_order_preserved() {
763        let a = BindingSet::from_vec(vec![single("x", iri("http://1"))]);
764        let b = BindingSet::from_vec(vec![single("x", iri("http://2"))]);
765        let u = a.union(&b);
766        assert_eq!(u.bindings[0].get("x"), Some(&iri("http://1")));
767        assert_eq!(u.bindings[1].get("x"), Some(&iri("http://2")));
768    }
769
770    #[test]
771    fn test_minus_literal_terms() {
772        let s = BindingSet::from_vec(vec![single("label", lit("hello"))]);
773        let o = BindingSet::from_vec(vec![single("label", lit("hello"))]);
774        assert_eq!(s.minus(&o).len(), 0);
775    }
776
777    #[test]
778    fn test_minus_blank_node_terms() {
779        let s = BindingSet::from_vec(vec![single("b", bnode("b0"))]);
780        let o = BindingSet::from_vec(vec![single("b", bnode("b0"))]);
781        assert_eq!(s.minus(&o).len(), 0);
782    }
783
784    #[test]
785    fn test_exists_filter_multiple_pattern_rows() {
786        // Row is kept if ANY pattern row is compatible
787        let s = BindingSet::from_vec(vec![single("x", iri("http://c"))]);
788        let p = BindingSet::from_vec(vec![
789            single("x", iri("http://a")),
790            single("x", iri("http://b")),
791            single("x", iri("http://c")),
792        ]);
793        assert_eq!(s.exists_filter(&p).len(), 1);
794    }
795
796    #[test]
797    fn test_join_preserves_all_vars() {
798        let a = BindingSet::from_vec(vec![row(&[
799            ("subject", iri("http://s")),
800            ("predicate", iri("http://p")),
801        ])]);
802        let b = BindingSet::from_vec(vec![row(&[
803            ("predicate", iri("http://p")),
804            ("object", lit("value")),
805        ])]);
806        let j = a.join(&b);
807        assert_eq!(j.len(), 1);
808        let r = &j.bindings[0];
809        assert_eq!(r.get("subject"), Some(&iri("http://s")));
810        assert_eq!(r.get("predicate"), Some(&iri("http://p")));
811        assert_eq!(r.get("object"), Some(&lit("value")));
812    }
813}