Skip to main content

oxirs_arq/
construct_builder.rs

1/// SPARQL CONSTRUCT query builder.
2///
3/// Implements template triple instantiation from SPARQL CONSTRUCT queries,
4/// including blank node generation, variable binding, deduplication, and
5/// CONSTRUCT WHERE shorthand support.
6use std::collections::{HashMap, HashSet};
7
8use thiserror::Error;
9
10// ── Error type ────────────────────────────────────────────────────────────────
11
12/// Errors that can occur during CONSTRUCT processing.
13#[derive(Debug, Clone, PartialEq, Eq, Error)]
14pub enum ConstructError {
15    /// A variable used in the template was not found in the solution.
16    #[error("unbound variable in template: ?{name}")]
17    UnboundVariable { name: String },
18
19    /// A triple term (subject, predicate, or object) was empty.
20    #[error("empty term in template triple at position {position}")]
21    EmptyTerm { position: &'static str },
22
23    /// Predicate position contained a blank node (SPARQL prohibits this).
24    #[error("blank node in predicate position: {node}")]
25    BlankNodeInPredicate { node: String },
26}
27
28// ── Core data structures ──────────────────────────────────────────────────────
29
30/// A single RDF term (IRI, literal, blank node, or variable).
31#[derive(Debug, Clone, PartialEq, Eq, Hash)]
32pub enum RdfTerm {
33    /// An IRI reference (angle-bracket form or prefixed name resolved to full IRI).
34    Iri(String),
35    /// A plain or typed literal.
36    Literal {
37        value: String,
38        datatype: Option<String>,
39        lang_tag: Option<String>,
40    },
41    /// A blank node with a locally-scoped label.
42    BlankNode(String),
43    /// A SPARQL variable (without the `?` sigil).
44    Variable(String),
45}
46
47impl RdfTerm {
48    /// Returns `true` if this term is a variable.
49    pub fn is_variable(&self) -> bool {
50        matches!(self, RdfTerm::Variable(_))
51    }
52
53    /// Returns `true` if this term is a blank node.
54    pub fn is_blank_node(&self) -> bool {
55        matches!(self, RdfTerm::BlankNode(_))
56    }
57
58    /// Returns the variable name, if this is a variable.
59    pub fn variable_name(&self) -> Option<&str> {
60        match self {
61            RdfTerm::Variable(n) => Some(n.as_str()),
62            _ => None,
63        }
64    }
65}
66
67/// A triple pattern in the CONSTRUCT template.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct TemplateTriple {
70    pub subject: RdfTerm,
71    pub predicate: RdfTerm,
72    pub object: RdfTerm,
73}
74
75impl TemplateTriple {
76    /// Construct a new template triple.
77    pub fn new(subject: RdfTerm, predicate: RdfTerm, object: RdfTerm) -> Self {
78        TemplateTriple {
79            subject,
80            predicate,
81            object,
82        }
83    }
84}
85
86/// A concrete (ground) RDF triple produced after binding all variables.
87#[derive(Debug, Clone, PartialEq, Eq, Hash)]
88pub struct GroundTriple {
89    pub subject: String,
90    pub predicate: String,
91    pub object: String,
92}
93
94/// A single solution row from the WHERE clause evaluation.
95/// Maps variable name → bound value (string serialisation of the term).
96#[derive(Debug, Clone, Default)]
97pub struct SolutionRow {
98    bindings: HashMap<String, String>,
99}
100
101impl SolutionRow {
102    /// Create an empty solution row.
103    pub fn new() -> Self {
104        SolutionRow {
105            bindings: HashMap::new(),
106        }
107    }
108
109    /// Bind a variable to a value.
110    pub fn bind(&mut self, var: impl Into<String>, value: impl Into<String>) {
111        self.bindings.insert(var.into(), value.into());
112    }
113
114    /// Look up a variable binding.
115    pub fn get(&self, var: &str) -> Option<&str> {
116        self.bindings.get(var).map(String::as_str)
117    }
118
119    /// Returns `true` if the variable is bound.
120    pub fn is_bound(&self, var: &str) -> bool {
121        self.bindings.contains_key(var)
122    }
123
124    /// Returns all variable names bound in this row.
125    pub fn bound_vars(&self) -> impl Iterator<Item = &str> {
126        self.bindings.keys().map(String::as_str)
127    }
128}
129
130/// Statistics about the constructed graph.
131#[derive(Debug, Clone, Default)]
132pub struct ConstructStats {
133    /// Number of solution rows processed.
134    pub rows_processed: usize,
135    /// Number of raw triples generated before deduplication.
136    pub raw_triple_count: usize,
137    /// Number of triples skipped because a variable was unbound.
138    pub skipped_unbound: usize,
139    /// Number of duplicate triples eliminated.
140    pub duplicates_eliminated: usize,
141    /// Number of fresh blank nodes generated.
142    pub blank_nodes_generated: usize,
143}
144
145// ── Blank-node allocator ──────────────────────────────────────────────────────
146
147/// Generates unique blank node identifiers.
148pub struct BlankNodeAllocator {
149    counter: u64,
150}
151
152impl BlankNodeAllocator {
153    fn new() -> Self {
154        BlankNodeAllocator { counter: 0 }
155    }
156
157    /// Produce a fresh blank node label scoped to a given solution row.
158    ///
159    /// The `template_label` is the label used in the CONSTRUCT template; a
160    /// distinct identifier is generated for each (row_index, template_label)
161    /// pair so that blank nodes are kept separate across solution rows.
162    fn fresh(&mut self, row_index: usize, template_label: &str) -> String {
163        self.counter += 1;
164        format!("_:b{}_{}_r{}", self.counter, template_label, row_index)
165    }
166}
167
168// ── ConstructBuilder ──────────────────────────────────────────────────────────
169
170/// Builds a CONSTRUCT result graph from a template and a set of solution rows.
171pub struct ConstructBuilder {
172    template: Vec<TemplateTriple>,
173    skip_on_unbound: bool,
174}
175
176impl ConstructBuilder {
177    /// Create a builder with the given CONSTRUCT template.
178    pub fn new(template: Vec<TemplateTriple>) -> Self {
179        ConstructBuilder {
180            template,
181            skip_on_unbound: true,
182        }
183    }
184
185    /// Create a builder using CONSTRUCT WHERE shorthand.
186    ///
187    /// In the shorthand form the template is identical to the WHERE basic
188    /// graph pattern, expressed as a list of triple patterns.
189    pub fn from_where_shorthand(pattern: Vec<TemplateTriple>) -> Self {
190        Self::new(pattern)
191    }
192
193    /// If `true` (the default), template triples that contain an unbound
194    /// variable are silently skipped.  If `false`, an error is returned.
195    pub fn skip_unbound(mut self, skip: bool) -> Self {
196        self.skip_on_unbound = skip;
197        self
198    }
199
200    /// Instantiate the CONSTRUCT template for a single solution row.
201    ///
202    /// Each blank-node label in the template is mapped to a fresh identifier
203    /// scoped to `row_index` so that blank nodes from different rows are never
204    /// merged.
205    pub fn instantiate_row(
206        &self,
207        row: &SolutionRow,
208        row_index: usize,
209        alloc: &mut BlankNodeAllocator,
210        stats: &mut ConstructStats,
211    ) -> Result<Vec<GroundTriple>, ConstructError> {
212        // Per-row blank-node mapping (template label → fresh label).
213        let mut bnode_map: HashMap<String, String> = HashMap::new();
214        let mut triples = Vec::new();
215
216        for tpl in &self.template {
217            let s =
218                self.resolve_term(&tpl.subject, row, row_index, alloc, &mut bnode_map, stats)?;
219            let p =
220                self.resolve_term(&tpl.predicate, row, row_index, alloc, &mut bnode_map, stats)?;
221            let o = self.resolve_term(&tpl.object, row, row_index, alloc, &mut bnode_map, stats)?;
222
223            match (s, p, o) {
224                (Some(s_val), Some(p_val), Some(o_val)) => {
225                    // Validate: predicate must not be a blank node.
226                    if p_val.starts_with("_:") {
227                        return Err(ConstructError::BlankNodeInPredicate { node: p_val });
228                    }
229                    triples.push(GroundTriple {
230                        subject: s_val,
231                        predicate: p_val,
232                        object: o_val,
233                    });
234                }
235                _ => {
236                    // One or more terms resolved to None (unbound variable, skip-mode).
237                    stats.skipped_unbound += 1;
238                }
239            }
240        }
241        Ok(triples)
242    }
243
244    /// Resolve a single `RdfTerm` from the template into a ground string, or
245    /// `None` if the term is an unbound variable and `skip_on_unbound` is set.
246    fn resolve_term(
247        &self,
248        term: &RdfTerm,
249        row: &SolutionRow,
250        row_index: usize,
251        alloc: &mut BlankNodeAllocator,
252        bnode_map: &mut HashMap<String, String>,
253        stats: &mut ConstructStats,
254    ) -> Result<Option<String>, ConstructError> {
255        match term {
256            RdfTerm::Iri(iri) => Ok(Some(format!("<{}>", iri))),
257            RdfTerm::Literal {
258                value,
259                datatype,
260                lang_tag,
261            } => {
262                let serialised = if let Some(dt) = datatype {
263                    format!("\"{}\"^^<{}>", value, dt)
264                } else if let Some(lang) = lang_tag {
265                    format!("\"{}\"@{}", value, lang)
266                } else {
267                    format!("\"{}\"", value)
268                };
269                Ok(Some(serialised))
270            }
271            RdfTerm::BlankNode(label) => {
272                let fresh = bnode_map.entry(label.clone()).or_insert_with(|| {
273                    stats.blank_nodes_generated += 1;
274                    alloc.fresh(row_index, label)
275                });
276                Ok(Some(fresh.clone()))
277            }
278            RdfTerm::Variable(name) => {
279                if let Some(val) = row.get(name) {
280                    Ok(Some(val.to_owned()))
281                } else if self.skip_on_unbound {
282                    Ok(None)
283                } else {
284                    Err(ConstructError::UnboundVariable { name: name.clone() })
285                }
286            }
287        }
288    }
289
290    /// Build the full constructed graph from all solution rows.
291    ///
292    /// Returns deduplicated ground triples and accompanying statistics.
293    pub fn build(
294        &self,
295        solutions: &[SolutionRow],
296    ) -> Result<(Vec<GroundTriple>, ConstructStats), ConstructError> {
297        let mut stats = ConstructStats::default();
298        let mut alloc = BlankNodeAllocator::new();
299        let mut seen: HashSet<GroundTriple> = HashSet::new();
300        let mut result: Vec<GroundTriple> = Vec::new();
301
302        for (row_index, row) in solutions.iter().enumerate() {
303            stats.rows_processed += 1;
304            let row_triples = self.instantiate_row(row, row_index, &mut alloc, &mut stats)?;
305            stats.raw_triple_count += row_triples.len();
306
307            for triple in row_triples {
308                if seen.insert(triple.clone()) {
309                    result.push(triple);
310                } else {
311                    stats.duplicates_eliminated += 1;
312                }
313            }
314        }
315
316        Ok((result, stats))
317    }
318
319    /// Check whether a variable is present (bound) across *all* solution rows.
320    pub fn variable_present_in_all(var: &str, solutions: &[SolutionRow]) -> bool {
321        solutions.iter().all(|r| r.is_bound(var))
322    }
323
324    /// Check whether a variable is present in *any* solution row.
325    pub fn variable_present_in_any(var: &str, solutions: &[SolutionRow]) -> bool {
326        solutions.iter().any(|r| r.is_bound(var))
327    }
328
329    /// Return the template triples.
330    pub fn template(&self) -> &[TemplateTriple] {
331        &self.template
332    }
333}
334
335// ── Convenience constructors for `RdfTerm` ────────────────────────────────────
336
337impl RdfTerm {
338    /// Create an IRI term.
339    pub fn iri(iri: impl Into<String>) -> Self {
340        RdfTerm::Iri(iri.into())
341    }
342
343    /// Create a plain string literal.
344    pub fn string_literal(value: impl Into<String>) -> Self {
345        RdfTerm::Literal {
346            value: value.into(),
347            datatype: None,
348            lang_tag: None,
349        }
350    }
351
352    /// Create a typed literal.
353    pub fn typed_literal(value: impl Into<String>, datatype: impl Into<String>) -> Self {
354        RdfTerm::Literal {
355            value: value.into(),
356            datatype: Some(datatype.into()),
357            lang_tag: None,
358        }
359    }
360
361    /// Create a language-tagged literal.
362    pub fn lang_literal(value: impl Into<String>, lang: impl Into<String>) -> Self {
363        RdfTerm::Literal {
364            value: value.into(),
365            datatype: None,
366            lang_tag: Some(lang.into()),
367        }
368    }
369
370    /// Create a boolean typed literal.
371    pub fn boolean(v: bool) -> Self {
372        RdfTerm::typed_literal(v.to_string(), "http://www.w3.org/2001/XMLSchema#boolean")
373    }
374
375    /// Create an integer typed literal.
376    pub fn integer(v: i64) -> Self {
377        RdfTerm::typed_literal(v.to_string(), "http://www.w3.org/2001/XMLSchema#integer")
378    }
379
380    /// Create a floating-point typed literal.
381    pub fn double(v: f64) -> Self {
382        RdfTerm::typed_literal(v.to_string(), "http://www.w3.org/2001/XMLSchema#double")
383    }
384
385    /// Create a variable term.
386    pub fn var(name: impl Into<String>) -> Self {
387        RdfTerm::Variable(name.into())
388    }
389
390    /// Create a blank node term.
391    pub fn blank(label: impl Into<String>) -> Self {
392        RdfTerm::BlankNode(label.into())
393    }
394}
395
396// ── Tests ─────────────────────────────────────────────────────────────────────
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    fn make_row(pairs: &[(&str, &str)]) -> SolutionRow {
403        let mut row = SolutionRow::new();
404        for (k, v) in pairs {
405            row.bind(*k, *v);
406        }
407        row
408    }
409
410    // ── Basic instantiation ───────────────────────────────────────────────────
411
412    #[test]
413    fn test_single_iri_triple() {
414        let template = vec![TemplateTriple::new(
415            RdfTerm::iri("http://example.org/s"),
416            RdfTerm::iri("http://example.org/p"),
417            RdfTerm::iri("http://example.org/o"),
418        )];
419        let builder = ConstructBuilder::new(template);
420        let row = SolutionRow::new();
421        let (triples, stats) = builder.build(&[row]).expect("build");
422        assert_eq!(triples.len(), 1);
423        assert_eq!(stats.rows_processed, 1);
424        assert_eq!(stats.raw_triple_count, 1);
425    }
426
427    #[test]
428    fn test_variable_binding() {
429        let template = vec![TemplateTriple::new(
430            RdfTerm::var("s"),
431            RdfTerm::iri("http://example.org/type"),
432            RdfTerm::var("t"),
433        )];
434        let builder = ConstructBuilder::new(template);
435        let row = make_row(&[
436            ("s", "<http://example.org/Alice>"),
437            ("t", "<http://example.org/Person>"),
438        ]);
439        let (triples, _stats) = builder.build(&[row]).expect("build");
440        assert_eq!(triples.len(), 1);
441        assert_eq!(triples[0].subject, "<http://example.org/Alice>");
442        assert_eq!(triples[0].object, "<http://example.org/Person>");
443    }
444
445    #[test]
446    fn test_unbound_variable_skipped_by_default() {
447        let template = vec![TemplateTriple::new(
448            RdfTerm::var("s"),
449            RdfTerm::iri("http://example.org/p"),
450            RdfTerm::var("o"),
451        )];
452        let builder = ConstructBuilder::new(template);
453        // ?o is not bound
454        let row = make_row(&[("s", "<http://example.org/Alice>")]);
455        let (triples, stats) = builder.build(&[row]).expect("build");
456        assert_eq!(triples.len(), 0);
457        assert_eq!(stats.skipped_unbound, 1);
458    }
459
460    #[test]
461    fn test_unbound_variable_error_mode() {
462        let template = vec![TemplateTriple::new(
463            RdfTerm::var("s"),
464            RdfTerm::iri("http://example.org/p"),
465            RdfTerm::var("o"),
466        )];
467        let builder = ConstructBuilder::new(template).skip_unbound(false);
468        let row = make_row(&[("s", "<http://example.org/Alice>")]);
469        let result = builder.build(&[row]);
470        assert!(result.is_err());
471        assert!(
472            matches!(result.unwrap_err(), ConstructError::UnboundVariable { name } if name == "o")
473        );
474    }
475
476    // ── Blank node generation ─────────────────────────────────────────────────
477
478    #[test]
479    fn test_blank_node_unique_per_row() {
480        let template = vec![TemplateTriple::new(
481            RdfTerm::blank("b"),
482            RdfTerm::iri("http://example.org/value"),
483            RdfTerm::var("v"),
484        )];
485        let builder = ConstructBuilder::new(template);
486        let rows = vec![make_row(&[("v", "\"1\"")]), make_row(&[("v", "\"2\"")])];
487        let (triples, stats) = builder.build(&rows).expect("build");
488        assert_eq!(triples.len(), 2);
489        // The two blank nodes must be distinct.
490        assert_ne!(triples[0].subject, triples[1].subject);
491        assert_eq!(stats.blank_nodes_generated, 2);
492    }
493
494    #[test]
495    fn test_blank_node_shared_within_row() {
496        // Two triples in the same template both reference blank node `b`.
497        // Within the same row they should resolve to the same label.
498        let template = vec![
499            TemplateTriple::new(
500                RdfTerm::blank("b"),
501                RdfTerm::iri("http://example.org/type"),
502                RdfTerm::iri("http://example.org/Thing"),
503            ),
504            TemplateTriple::new(
505                RdfTerm::blank("b"),
506                RdfTerm::iri("http://example.org/name"),
507                RdfTerm::string_literal("test"),
508            ),
509        ];
510        let builder = ConstructBuilder::new(template);
511        let row = SolutionRow::new();
512        let (triples, stats) = builder.build(&[row]).expect("build");
513        assert_eq!(triples.len(), 2);
514        assert_eq!(triples[0].subject, triples[1].subject);
515        // Only one blank node was allocated (shared within the row).
516        assert_eq!(stats.blank_nodes_generated, 1);
517    }
518
519    // ── Deduplication ─────────────────────────────────────────────────────────
520
521    #[test]
522    fn test_duplicate_triple_elimination() {
523        let template = vec![TemplateTriple::new(
524            RdfTerm::iri("http://example.org/s"),
525            RdfTerm::iri("http://example.org/p"),
526            RdfTerm::iri("http://example.org/o"),
527        )];
528        let builder = ConstructBuilder::new(template);
529        // Same IRI triple produced by three solution rows → only one in output.
530        let rows = vec![SolutionRow::new(), SolutionRow::new(), SolutionRow::new()];
531        let (triples, stats) = builder.build(&rows).expect("build");
532        assert_eq!(triples.len(), 1);
533        assert_eq!(stats.duplicates_eliminated, 2);
534        assert_eq!(stats.raw_triple_count, 3);
535    }
536
537    #[test]
538    fn test_no_duplicates_when_all_distinct() {
539        let template = vec![TemplateTriple::new(
540            RdfTerm::var("s"),
541            RdfTerm::iri("http://example.org/p"),
542            RdfTerm::iri("http://example.org/o"),
543        )];
544        let builder = ConstructBuilder::new(template);
545        let rows = vec![
546            make_row(&[("s", "<http://example.org/A>")]),
547            make_row(&[("s", "<http://example.org/B>")]),
548        ];
549        let (triples, stats) = builder.build(&rows).expect("build");
550        assert_eq!(triples.len(), 2);
551        assert_eq!(stats.duplicates_eliminated, 0);
552    }
553
554    // ── CONSTRUCT WHERE shorthand ──────────────────────────────────────────────
555
556    #[test]
557    fn test_construct_where_shorthand() {
558        let pattern = vec![TemplateTriple::new(
559            RdfTerm::var("x"),
560            RdfTerm::iri("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
561            RdfTerm::var("t"),
562        )];
563        let builder = ConstructBuilder::from_where_shorthand(pattern.clone());
564        assert_eq!(builder.template().len(), pattern.len());
565    }
566
567    // ── Literal binding ───────────────────────────────────────────────────────
568
569    #[test]
570    fn test_string_literal_binding() {
571        let template = vec![TemplateTriple::new(
572            RdfTerm::iri("http://example.org/s"),
573            RdfTerm::iri("http://example.org/name"),
574            RdfTerm::string_literal("Alice"),
575        )];
576        let builder = ConstructBuilder::new(template);
577        let (triples, _stats) = builder.build(&[SolutionRow::new()]).expect("build");
578        assert_eq!(triples[0].object, "\"Alice\"");
579    }
580
581    #[test]
582    fn test_integer_literal_binding() {
583        let template = vec![TemplateTriple::new(
584            RdfTerm::iri("http://example.org/s"),
585            RdfTerm::iri("http://example.org/age"),
586            RdfTerm::integer(42),
587        )];
588        let builder = ConstructBuilder::new(template);
589        let (triples, _stats) = builder.build(&[SolutionRow::new()]).expect("build");
590        assert!(triples[0].object.contains("42"));
591        assert!(triples[0].object.contains("integer"));
592    }
593
594    #[test]
595    fn test_boolean_literal_binding() {
596        let term = RdfTerm::boolean(true);
597        if let RdfTerm::Literal {
598            value, datatype, ..
599        } = &term
600        {
601            assert_eq!(value, "true");
602            assert!(datatype.as_deref().unwrap_or("").contains("boolean"));
603        } else {
604            panic!("expected Literal");
605        }
606    }
607
608    #[test]
609    #[allow(clippy::approx_constant)]
610    fn test_double_literal_binding() {
611        let term = RdfTerm::double(3.14);
612        if let RdfTerm::Literal {
613            value, datatype, ..
614        } = &term
615        {
616            assert!(value.contains("3.14"));
617            assert!(datatype.as_deref().unwrap_or("").contains("double"));
618        } else {
619            panic!("expected Literal");
620        }
621    }
622
623    #[test]
624    fn test_lang_tagged_literal() {
625        let template = vec![TemplateTriple::new(
626            RdfTerm::iri("http://example.org/s"),
627            RdfTerm::iri("http://example.org/label"),
628            RdfTerm::lang_literal("Hallo", "de"),
629        )];
630        let builder = ConstructBuilder::new(template);
631        let (triples, _) = builder.build(&[SolutionRow::new()]).expect("build");
632        assert_eq!(triples[0].object, "\"Hallo\"@de");
633    }
634
635    // ── Variable presence checking ────────────────────────────────────────────
636
637    #[test]
638    fn test_variable_present_in_all() {
639        let rows = vec![
640            make_row(&[("x", "1"), ("y", "2")]),
641            make_row(&[("x", "3"), ("y", "4")]),
642        ];
643        assert!(ConstructBuilder::variable_present_in_all("x", &rows));
644        assert!(ConstructBuilder::variable_present_in_all("y", &rows));
645        assert!(!ConstructBuilder::variable_present_in_all("z", &rows));
646    }
647
648    #[test]
649    fn test_variable_present_in_any() {
650        let rows = vec![make_row(&[("x", "1")]), make_row(&[("y", "2")])];
651        assert!(ConstructBuilder::variable_present_in_any("x", &rows));
652        assert!(ConstructBuilder::variable_present_in_any("y", &rows));
653        assert!(!ConstructBuilder::variable_present_in_any("z", &rows));
654    }
655
656    #[test]
657    fn test_variable_present_partial_binding() {
658        let rows = vec![
659            make_row(&[("x", "1"), ("y", "2")]),
660            make_row(&[("x", "3")]), // y missing
661        ];
662        // y is not present in all rows
663        assert!(!ConstructBuilder::variable_present_in_all("y", &rows));
664        // y is present in at least one row
665        assert!(ConstructBuilder::variable_present_in_any("y", &rows));
666    }
667
668    // ── Graph statistics ──────────────────────────────────────────────────────
669
670    #[test]
671    fn test_construct_stats_populated() {
672        let template = vec![
673            TemplateTriple::new(
674                RdfTerm::var("s"),
675                RdfTerm::iri("http://example.org/p"),
676                RdfTerm::var("o"),
677            ),
678            TemplateTriple::new(
679                RdfTerm::var("s"),
680                RdfTerm::iri("http://example.org/q"),
681                RdfTerm::var("missing"),
682            ),
683        ];
684        let builder = ConstructBuilder::new(template);
685        let rows = vec![
686            make_row(&[
687                ("s", "<http://example.org/A>"),
688                ("o", "<http://example.org/B>"),
689            ]),
690            make_row(&[
691                ("s", "<http://example.org/A>"),
692                ("o", "<http://example.org/B>"),
693            ]),
694        ];
695        let (triples, stats) = builder.build(&rows).expect("build");
696        assert_eq!(stats.rows_processed, 2);
697        // One triple per row (second triple skipped due to ?missing unbound).
698        assert_eq!(stats.raw_triple_count, 2);
699        assert_eq!(stats.skipped_unbound, 2);
700        // Both rows produce the same triple → one eliminated.
701        assert_eq!(triples.len(), 1);
702        assert_eq!(stats.duplicates_eliminated, 1);
703    }
704
705    #[test]
706    fn test_empty_solution_set() {
707        let template = vec![TemplateTriple::new(
708            RdfTerm::iri("http://example.org/s"),
709            RdfTerm::iri("http://example.org/p"),
710            RdfTerm::iri("http://example.org/o"),
711        )];
712        let builder = ConstructBuilder::new(template);
713        let (triples, stats) = builder.build(&[]).expect("build");
714        assert_eq!(triples.len(), 0);
715        assert_eq!(stats.rows_processed, 0);
716    }
717
718    #[test]
719    fn test_blank_node_in_predicate_rejected() {
720        let template = vec![TemplateTriple::new(
721            RdfTerm::iri("http://example.org/s"),
722            RdfTerm::blank("b"),
723            RdfTerm::iri("http://example.org/o"),
724        )];
725        let builder = ConstructBuilder::new(template);
726        let result = builder.build(&[SolutionRow::new()]);
727        assert!(result.is_err());
728        assert!(matches!(
729            result.unwrap_err(),
730            ConstructError::BlankNodeInPredicate { .. }
731        ));
732    }
733
734    #[test]
735    fn test_multiple_variables_multiple_rows() {
736        let template = vec![
737            TemplateTriple::new(
738                RdfTerm::var("person"),
739                RdfTerm::iri("http://schema.org/name"),
740                RdfTerm::var("name"),
741            ),
742            TemplateTriple::new(
743                RdfTerm::var("person"),
744                RdfTerm::iri("http://schema.org/age"),
745                RdfTerm::var("age"),
746            ),
747        ];
748        let builder = ConstructBuilder::new(template);
749        let rows = vec![
750            make_row(&[
751                ("person", "<http://example.org/Alice>"),
752                ("name", "\"Alice\""),
753                ("age", "\"30\"^^<http://www.w3.org/2001/XMLSchema#integer>"),
754            ]),
755            make_row(&[
756                ("person", "<http://example.org/Bob>"),
757                ("name", "\"Bob\""),
758                ("age", "\"25\"^^<http://www.w3.org/2001/XMLSchema#integer>"),
759            ]),
760        ];
761        let (triples, stats) = builder.build(&rows).expect("build");
762        assert_eq!(triples.len(), 4);
763        assert_eq!(stats.rows_processed, 2);
764        assert_eq!(stats.duplicates_eliminated, 0);
765    }
766
767    #[test]
768    fn test_typed_literal_serialisation() {
769        let term = RdfTerm::typed_literal("2024-01-01", "http://www.w3.org/2001/XMLSchema#date");
770        let template = vec![TemplateTriple::new(
771            RdfTerm::iri("http://example.org/s"),
772            RdfTerm::iri("http://example.org/date"),
773            term,
774        )];
775        let builder = ConstructBuilder::new(template);
776        let (triples, _) = builder.build(&[SolutionRow::new()]).expect("build");
777        assert_eq!(
778            triples[0].object,
779            "\"2024-01-01\"^^<http://www.w3.org/2001/XMLSchema#date>"
780        );
781    }
782
783    #[test]
784    fn test_solution_row_is_bound() {
785        let mut row = SolutionRow::new();
786        row.bind("x", "value");
787        assert!(row.is_bound("x"));
788        assert!(!row.is_bound("y"));
789    }
790
791    #[test]
792    fn test_solution_row_bound_vars_iteration() {
793        let row = make_row(&[("a", "1"), ("b", "2"), ("c", "3")]);
794        let vars: Vec<&str> = row.bound_vars().collect();
795        assert_eq!(vars.len(), 3);
796    }
797
798    #[test]
799    fn test_large_result_set_deduplication() {
800        // One template triple, 100 rows all binding same values → 1 unique triple.
801        let template = vec![TemplateTriple::new(
802            RdfTerm::iri("http://example.org/s"),
803            RdfTerm::iri("http://example.org/p"),
804            RdfTerm::iri("http://example.org/o"),
805        )];
806        let builder = ConstructBuilder::new(template);
807        let rows: Vec<SolutionRow> = (0..100).map(|_| SolutionRow::new()).collect();
808        let (triples, stats) = builder.build(&rows).expect("build");
809        assert_eq!(triples.len(), 1);
810        assert_eq!(stats.duplicates_eliminated, 99);
811    }
812
813    // ── RdfTerm helpers ───────────────────────────────────────────────────────
814
815    #[test]
816    fn test_rdf_term_is_variable() {
817        assert!(RdfTerm::var("x").is_variable());
818        assert!(!RdfTerm::iri("http://example.org/x").is_variable());
819    }
820
821    #[test]
822    fn test_rdf_term_is_blank_node() {
823        assert!(RdfTerm::blank("b0").is_blank_node());
824        assert!(!RdfTerm::var("x").is_blank_node());
825    }
826
827    #[test]
828    fn test_rdf_term_variable_name() {
829        assert_eq!(RdfTerm::var("foo").variable_name(), Some("foo"));
830        assert_eq!(RdfTerm::iri("http://example.org/").variable_name(), None);
831    }
832
833    #[test]
834    fn test_iri_term_serialisation() {
835        let template = vec![TemplateTriple::new(
836            RdfTerm::iri("http://example.org/s"),
837            RdfTerm::iri("http://example.org/p"),
838            RdfTerm::iri("http://example.org/o"),
839        )];
840        let builder = ConstructBuilder::new(template);
841        let (triples, _) = builder.build(&[SolutionRow::new()]).expect("build");
842        // IRI terms should be wrapped in angle brackets.
843        assert!(triples[0].subject.starts_with('<'));
844        assert!(triples[0].subject.ends_with('>'));
845    }
846
847    // ── GroundTriple equality ─────────────────────────────────────────────────
848
849    #[test]
850    fn test_ground_triple_equality() {
851        let t1 = GroundTriple {
852            subject: "s".into(),
853            predicate: "p".into(),
854            object: "o".into(),
855        };
856        let t2 = GroundTriple {
857            subject: "s".into(),
858            predicate: "p".into(),
859            object: "o".into(),
860        };
861        assert_eq!(t1, t2);
862    }
863
864    #[test]
865    fn test_ground_triple_inequality() {
866        let t1 = GroundTriple {
867            subject: "s1".into(),
868            predicate: "p".into(),
869            object: "o".into(),
870        };
871        let t2 = GroundTriple {
872            subject: "s2".into(),
873            predicate: "p".into(),
874            object: "o".into(),
875        };
876        assert_ne!(t1, t2);
877    }
878
879    // ── BlankNodeAllocator uniqueness ─────────────────────────────────────────
880
881    #[test]
882    fn test_blank_node_allocator_monotonic() {
883        let mut alloc = BlankNodeAllocator::new();
884        let a = alloc.fresh(0, "b");
885        let b = alloc.fresh(0, "b");
886        // Even with the same row and label, fresh() must generate distinct IDs.
887        assert_ne!(a, b);
888    }
889
890    #[test]
891    fn test_blank_node_allocator_different_rows() {
892        let mut alloc = BlankNodeAllocator::new();
893        let r0 = alloc.fresh(0, "same");
894        let r1 = alloc.fresh(1, "same");
895        assert_ne!(r0, r1);
896    }
897
898    // ── TemplateTriple ────────────────────────────────────────────────────────
899
900    #[test]
901    fn test_template_triple_fields() {
902        let t = TemplateTriple::new(
903            RdfTerm::iri("http://s"),
904            RdfTerm::iri("http://p"),
905            RdfTerm::iri("http://o"),
906        );
907        assert!(matches!(t.subject, RdfTerm::Iri(_)));
908        assert!(matches!(t.predicate, RdfTerm::Iri(_)));
909        assert!(matches!(t.object, RdfTerm::Iri(_)));
910    }
911
912    // ── Stats accumulation ────────────────────────────────────────────────────
913
914    #[test]
915    fn test_stats_blank_nodes_counted_across_rows() {
916        let template = vec![TemplateTriple::new(
917            RdfTerm::blank("b"),
918            RdfTerm::iri("http://p"),
919            RdfTerm::iri("http://o"),
920        )];
921        let builder = ConstructBuilder::new(template);
922        let rows = vec![SolutionRow::new(), SolutionRow::new(), SolutionRow::new()];
923        let (_, stats) = builder.build(&rows).expect("build");
924        // One blank node per row.
925        assert_eq!(stats.blank_nodes_generated, 3);
926    }
927
928    #[test]
929    fn test_template_clone() {
930        let t = TemplateTriple::new(RdfTerm::var("x"), RdfTerm::iri("p"), RdfTerm::var("y"));
931        let t2 = t.clone();
932        assert_eq!(t, t2);
933    }
934
935    // ── construct_builder builder method ──────────────────────────────────────
936
937    #[test]
938    fn test_template_accessor() {
939        let template = vec![
940            TemplateTriple::new(RdfTerm::var("a"), RdfTerm::iri("b"), RdfTerm::var("c")),
941            TemplateTriple::new(RdfTerm::iri("x"), RdfTerm::iri("y"), RdfTerm::iri("z")),
942        ];
943        let builder = ConstructBuilder::new(template);
944        assert_eq!(builder.template().len(), 2);
945    }
946
947    #[test]
948    fn test_skip_unbound_chained() {
949        let builder = ConstructBuilder::new(vec![]).skip_unbound(false);
950        // Just verify the builder doesn't panic; an empty template with
951        // skip_on_unbound=false on an empty solution set should succeed.
952        let (triples, _) = builder.build(&[]).expect("build empty");
953        assert!(triples.is_empty());
954    }
955}