Skip to main content

oxirs_core/model/
star.rs

1//! RDF-star (RDF*) support for statement annotations
2//!
3//! This module implements RDF-star extensions for SPARQL 1.2 compliance,
4//! allowing triples to be used as subjects or objects in other triples.
5
6use crate::model::{
7    NamedNode, Object, ObjectTerm, Predicate, RdfTerm, Subject, SubjectTerm, Triple,
8};
9use crate::query::algebra::{AlgebraTriplePattern, TermPattern};
10use crate::OxirsError;
11use std::fmt;
12use std::sync::Arc;
13
14/// A quoted triple that can be used as a subject or object in RDF-star
15#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
16pub struct QuotedTriple {
17    /// The inner triple being quoted
18    inner: Arc<Triple>,
19}
20
21impl QuotedTriple {
22    /// Create a new quoted triple
23    pub fn new(triple: Triple) -> Self {
24        QuotedTriple {
25            inner: Arc::new(triple),
26        }
27    }
28
29    /// Create from an existing `Arc<Triple>`
30    pub fn from_arc(triple: Arc<Triple>) -> Self {
31        QuotedTriple { inner: triple }
32    }
33
34    /// Get the inner triple
35    pub fn inner(&self) -> &Triple {
36        &self.inner
37    }
38
39    /// Get the subject of the quoted triple
40    pub fn subject(&self) -> &Subject {
41        self.inner.subject()
42    }
43
44    /// Get the predicate of the quoted triple
45    pub fn predicate(&self) -> &Predicate {
46        self.inner.predicate()
47    }
48
49    /// Get the object of the quoted triple
50    pub fn object(&self) -> &Object {
51        self.inner.object()
52    }
53
54    /// Convert to a triple reference
55    pub fn as_ref(&self) -> QuotedTripleRef<'_> {
56        QuotedTripleRef { inner: &self.inner }
57    }
58}
59
60impl fmt::Display for QuotedTriple {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        write!(f, "<< {} >>", self.inner)
63    }
64}
65
66impl RdfTerm for QuotedTriple {
67    fn as_str(&self) -> &str {
68        // For quoted triples, we return a synthetic string representation
69        "<<quoted-triple>>"
70    }
71
72    fn is_quoted_triple(&self) -> bool {
73        true
74    }
75}
76
77impl SubjectTerm for QuotedTriple {}
78impl ObjectTerm for QuotedTriple {}
79
80// Custom serialization for QuotedTriple to handle Arc<Triple>
81#[cfg(feature = "serde")]
82impl serde::Serialize for QuotedTriple {
83    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
84    where
85        S: serde::Serializer,
86    {
87        // Serialize the inner triple directly
88        self.inner.as_ref().serialize(serializer)
89    }
90}
91
92#[cfg(feature = "serde")]
93impl<'de> serde::Deserialize<'de> for QuotedTriple {
94    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
95    where
96        D: serde::Deserializer<'de>,
97    {
98        let triple = Triple::deserialize(deserializer)?;
99        Ok(QuotedTriple::new(triple))
100    }
101}
102
103/// A borrowed quoted triple reference
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
105pub struct QuotedTripleRef<'a> {
106    inner: &'a Triple,
107}
108
109impl<'a> QuotedTripleRef<'a> {
110    /// Create a new quoted triple reference
111    pub fn new(triple: &'a Triple) -> Self {
112        QuotedTripleRef { inner: triple }
113    }
114
115    /// Get the inner triple
116    pub fn inner(&self) -> &'a Triple {
117        self.inner
118    }
119
120    /// Convert to owned quoted triple
121    pub fn to_owned(&self) -> QuotedTriple {
122        QuotedTriple::new(self.inner.clone())
123    }
124}
125
126impl<'a> fmt::Display for QuotedTripleRef<'a> {
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        write!(f, "<< {} >>", self.inner)
129    }
130}
131
132impl<'a> RdfTerm for QuotedTripleRef<'a> {
133    fn as_str(&self) -> &str {
134        "<<quoted-triple>>"
135    }
136
137    fn is_quoted_triple(&self) -> bool {
138        true
139    }
140}
141
142/// RDF-star annotation syntax support
143pub struct Annotation {
144    /// The statement being annotated (as a quoted triple)
145    pub statement: QuotedTriple,
146    /// The annotation property
147    pub property: NamedNode,
148    /// The annotation value
149    pub value: Object,
150}
151
152impl Annotation {
153    /// Create a new annotation
154    pub fn new(statement: Triple, property: NamedNode, value: Object) -> Self {
155        Annotation {
156            statement: QuotedTriple::new(statement),
157            property,
158            value,
159        }
160    }
161
162    /// Convert annotation to a regular triple with quoted triple as subject
163    pub fn to_triple(&self) -> Triple {
164        Triple::new(
165            Subject::QuotedTriple(Box::new(self.statement.clone())),
166            self.property.clone(),
167            self.value.clone(),
168        )
169    }
170}
171
172/// RDF-star pattern for SPARQL queries
173#[derive(Debug, Clone, PartialEq, Eq, Hash)]
174pub enum StarPattern {
175    /// A regular triple pattern
176    Triple(AlgebraTriplePattern),
177    /// A quoted triple pattern
178    QuotedTriple {
179        /// Subject pattern (can be nested quoted triple)
180        subject: Box<StarPattern>,
181        /// Predicate pattern
182        predicate: TermPattern,
183        /// Object pattern (can be nested quoted triple)
184        object: Box<StarPattern>,
185    },
186    /// An annotation pattern
187    Annotation {
188        /// The annotated statement pattern
189        statement: Box<StarPattern>,
190        /// Annotation property pattern
191        property: TermPattern,
192        /// Annotation value pattern
193        value: TermPattern,
194    },
195}
196
197impl StarPattern {
198    /// Check if pattern contains variables
199    pub fn has_variables(&self) -> bool {
200        match self {
201            StarPattern::Triple(pattern) => {
202                matches!(pattern.subject, TermPattern::Variable(_))
203                    || matches!(pattern.predicate, TermPattern::Variable(_))
204                    || matches!(pattern.object, TermPattern::Variable(_))
205            }
206            StarPattern::QuotedTriple {
207                subject,
208                predicate: _,
209                object,
210            } => subject.has_variables() || object.has_variables(),
211            StarPattern::Annotation {
212                statement,
213                property: _,
214                value: _,
215            } => statement.has_variables(),
216        }
217    }
218
219    /// Get all variables in the pattern
220    pub fn variables(&self) -> Vec<crate::model::Variable> {
221        let mut vars = Vec::new();
222        self.collect_variables(&mut vars);
223        vars
224    }
225
226    fn collect_variables(&self, _vars: &mut Vec<crate::model::Variable>) {
227        match self {
228            StarPattern::Triple(pattern) => {
229                if let TermPattern::Variable(ref v) = pattern.subject {
230                    _vars.push(v.clone());
231                }
232                if let TermPattern::Variable(ref v) = pattern.predicate {
233                    _vars.push(v.clone());
234                }
235                if let TermPattern::Variable(ref v) = pattern.object {
236                    _vars.push(v.clone());
237                }
238            }
239            StarPattern::QuotedTriple {
240                subject,
241                predicate: _,
242                object,
243            } => {
244                subject.collect_variables(_vars);
245                object.collect_variables(_vars);
246            }
247            StarPattern::Annotation {
248                statement,
249                property: _,
250                value: _,
251            } => {
252                statement.collect_variables(_vars);
253            }
254        }
255    }
256}
257
258/// RDF-star serialization format extensions
259pub mod serialization {
260    use super::*;
261
262    /// Turtle-star syntax extensions
263    pub mod turtle_star {
264        use super::*;
265
266        /// Serialize a quoted triple in Turtle-star syntax
267        pub fn serialize_quoted_triple(qt: &QuotedTriple) -> String {
268            format!("<< {} {} {} >>", qt.subject(), qt.predicate(), qt.object())
269        }
270
271        /// Parse a quoted triple from Turtle-star syntax.
272        ///
273        /// Supports the RDF-star subject/object term grammar: absolute IRIs
274        /// (`<...>`), blank nodes (`_:id`), plain/typed/language-tagged
275        /// literals (`"..."`, `"..."^^<...>`, `"..."@lang`), and nested
276        /// quoted triples (`<< ... >>`). This is a self-contained
277        /// tokenizer scoped to the `<< s p o >>` grammar rather than a
278        /// full Turtle document parser; prefixed names (`ex:foo`) are not
279        /// supported since there is no prefix map in scope here.
280        pub fn parse_quoted_triple(input: &str) -> Result<QuotedTriple, OxirsError> {
281            let trimmed = input.trim();
282            if !trimmed.starts_with("<<") || !trimmed.ends_with(">>") || trimmed.len() < 4 {
283                return Err(OxirsError::Parse(
284                    "Invalid quoted triple syntax: expected '<< subject predicate object >>'"
285                        .to_string(),
286                ));
287            }
288
289            let inner = trimmed[2..trimmed.len() - 2].trim();
290            let terms = split_star_terms(inner)?;
291            if terms.len() != 3 {
292                return Err(OxirsError::Parse(format!(
293                    "Invalid quoted triple: expected exactly 3 terms (subject, predicate, object), found {}",
294                    terms.len()
295                )));
296            }
297
298            let subject = parse_star_subject(terms[0])?;
299            let predicate = parse_star_predicate(terms[1])?;
300            let object = parse_star_object(terms[2])?;
301
302            Ok(QuotedTriple::new(Triple::new(subject, predicate, object)))
303        }
304
305        /// Split the space-separated `subject predicate object` term list
306        /// of a quoted triple, respecting nesting of `<< ... >>`, `<...>`,
307        /// and `"..."` (including escaped quotes) so that whitespace
308        /// inside those constructs does not cause a spurious split.
309        fn split_star_terms(input: &str) -> Result<Vec<&str>, OxirsError> {
310            let bytes = input.as_bytes();
311            let mut terms = Vec::new();
312            let mut i = 0usize;
313            let mut depth = 0i32;
314            let mut in_string = false;
315            let mut escape = false;
316            let mut term_start: Option<usize> = None;
317
318            while i < bytes.len() {
319                let c = bytes[i] as char;
320                if in_string {
321                    if escape {
322                        escape = false;
323                    } else if c == '\\' {
324                        escape = true;
325                    } else if c == '"' {
326                        in_string = false;
327                    }
328                    i += 1;
329                    continue;
330                }
331
332                match c {
333                    '"' => {
334                        in_string = true;
335                        if term_start.is_none() {
336                            term_start = Some(i);
337                        }
338                    }
339                    '<' if input[i..].starts_with("<<") => {
340                        depth += 1;
341                        if term_start.is_none() {
342                            term_start = Some(i);
343                        }
344                        i += 1; // consume the extra '<' of "<<"
345                    }
346                    '>' if input[i..].starts_with(">>") => {
347                        depth -= 1;
348                        if depth < 0 {
349                            return Err(OxirsError::Parse(
350                                "Unbalanced '>>' in quoted triple term".to_string(),
351                            ));
352                        }
353                        i += 1; // consume the extra '>' of ">>"
354                    }
355                    '<' => {
356                        if term_start.is_none() {
357                            term_start = Some(i);
358                        }
359                    }
360                    c if c.is_whitespace() && depth == 0 => {
361                        if let Some(start) = term_start.take() {
362                            terms.push(input[start..i].trim());
363                        }
364                    }
365                    _ => {
366                        if term_start.is_none() {
367                            term_start = Some(i);
368                        }
369                    }
370                }
371                i += 1;
372            }
373            if in_string {
374                return Err(OxirsError::Parse(
375                    "Unterminated string literal in quoted triple".to_string(),
376                ));
377            }
378            if depth != 0 {
379                return Err(OxirsError::Parse(
380                    "Unbalanced '<<'/'>>' nesting in quoted triple".to_string(),
381                ));
382            }
383            if let Some(start) = term_start {
384                terms.push(input[start..].trim());
385            }
386            Ok(terms.into_iter().filter(|t| !t.is_empty()).collect())
387        }
388
389        fn parse_star_iri(term: &str) -> Result<NamedNode, OxirsError> {
390            let inner = term
391                .strip_prefix('<')
392                .and_then(|s| s.strip_suffix('>'))
393                .ok_or_else(|| OxirsError::Parse(format!("Invalid IRI term: {term}")))?;
394            NamedNode::new(inner)
395        }
396
397        fn parse_star_literal(term: &str) -> Result<crate::model::Literal, OxirsError> {
398            use crate::model::Literal;
399
400            // Locate the closing quote of the (possibly escaped) string body.
401            let bytes = term.as_bytes();
402            if bytes.first() != Some(&b'"') {
403                return Err(OxirsError::Parse(format!("Invalid literal term: {term}")));
404            }
405            let mut end = None;
406            let mut escape = false;
407            for (idx, b) in bytes.iter().enumerate().skip(1) {
408                if escape {
409                    escape = false;
410                } else if *b == b'\\' {
411                    escape = true;
412                } else if *b == b'"' {
413                    end = Some(idx);
414                    break;
415                }
416            }
417            let end =
418                end.ok_or_else(|| OxirsError::Parse(format!("Unterminated literal: {term}")))?;
419            let raw_value = &term[1..end];
420            let value = unescape_star_string(raw_value)?;
421            let suffix = &term[end + 1..];
422
423            if let Some(lang) = suffix.strip_prefix('@') {
424                Literal::new_language_tagged_literal(value, lang)
425                    .map_err(|e| OxirsError::Parse(format!("Invalid language tag: {e}")))
426            } else if let Some(datatype) = suffix.strip_prefix("^^") {
427                let datatype = parse_star_iri(datatype)?;
428                Ok(Literal::new_typed(value, datatype))
429            } else if suffix.is_empty() {
430                Ok(Literal::new(value))
431            } else {
432                Err(OxirsError::Parse(format!(
433                    "Invalid literal suffix in term: {term}"
434                )))
435            }
436        }
437
438        fn unescape_star_string(raw: &str) -> Result<String, OxirsError> {
439            let mut result = String::with_capacity(raw.len());
440            let mut chars = raw.chars();
441            while let Some(c) = chars.next() {
442                if c != '\\' {
443                    result.push(c);
444                    continue;
445                }
446                match chars.next() {
447                    Some('n') => result.push('\n'),
448                    Some('t') => result.push('\t'),
449                    Some('r') => result.push('\r'),
450                    Some('"') => result.push('"'),
451                    Some('\\') => result.push('\\'),
452                    Some(other) => {
453                        return Err(OxirsError::Parse(format!(
454                            "Unsupported escape sequence '\\{other}' in quoted triple literal"
455                        )))
456                    }
457                    None => {
458                        return Err(OxirsError::Parse(
459                            "Trailing backslash in quoted triple literal".to_string(),
460                        ))
461                    }
462                }
463            }
464            Ok(result)
465        }
466
467        fn parse_star_subject(term: &str) -> Result<Subject, OxirsError> {
468            if term.starts_with("<<") {
469                Ok(Subject::QuotedTriple(Box::new(parse_quoted_triple(term)?)))
470            } else if let Some(id) = term.strip_prefix("_:") {
471                Ok(Subject::BlankNode(crate::model::BlankNode::new(id)?))
472            } else if term.starts_with('<') {
473                Ok(Subject::NamedNode(parse_star_iri(term)?))
474            } else {
475                Err(OxirsError::Parse(format!(
476                    "Unsupported subject term in quoted triple: {term}"
477                )))
478            }
479        }
480
481        fn parse_star_predicate(term: &str) -> Result<Predicate, OxirsError> {
482            if term.starts_with('<') {
483                Ok(Predicate::NamedNode(parse_star_iri(term)?))
484            } else {
485                Err(OxirsError::Parse(format!(
486                    "Unsupported predicate term in quoted triple: {term}"
487                )))
488            }
489        }
490
491        fn parse_star_object(term: &str) -> Result<Object, OxirsError> {
492            if term.starts_with("<<") {
493                Ok(Object::QuotedTriple(Box::new(parse_quoted_triple(term)?)))
494            } else if let Some(id) = term.strip_prefix("_:") {
495                Ok(Object::BlankNode(crate::model::BlankNode::new(id)?))
496            } else if term.starts_with('<') {
497                Ok(Object::NamedNode(parse_star_iri(term)?))
498            } else if term.starts_with('"') {
499                Ok(Object::Literal(parse_star_literal(term)?))
500            } else {
501                Err(OxirsError::Parse(format!(
502                    "Unsupported object term in quoted triple: {term}"
503                )))
504            }
505        }
506    }
507
508    /// SPARQL-star syntax extensions
509    pub mod sparql_star {
510        use super::*;
511
512        /// Format a star pattern for SPARQL
513        pub fn format_star_pattern(pattern: &StarPattern) -> String {
514            match pattern {
515                StarPattern::Triple(pattern) => pattern.to_string(),
516                StarPattern::QuotedTriple {
517                    subject,
518                    predicate: _,
519                    object,
520                } => {
521                    format!(
522                        "<< {} {} {} >>",
523                        format_star_pattern(subject),
524                        "PREDICATE",
525                        format_star_pattern(object)
526                    )
527                }
528                StarPattern::Annotation {
529                    statement,
530                    property: _,
531                    value: _,
532                } => {
533                    format!(
534                        "{} {} {}",
535                        format_star_pattern(statement),
536                        "PROPERTY",
537                        "VALUE"
538                    )
539                }
540            }
541        }
542    }
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548    use crate::model::{Literal, NamedNode};
549
550    #[test]
551    fn test_quoted_triple() {
552        let subject = NamedNode::new("http://example.org/alice").expect("valid IRI");
553        let predicate = NamedNode::new("http://example.org/says").expect("valid IRI");
554        let object = Object::Literal(Literal::new("Hello"));
555
556        let triple = Triple::new(subject, predicate, object);
557        let quoted = QuotedTriple::new(triple.clone());
558
559        assert_eq!(quoted.inner(), &triple);
560        assert_eq!(
561            format!("{quoted}"),
562            "<< <http://example.org/alice> <http://example.org/says> \"Hello\" . >>"
563        );
564    }
565
566    #[test]
567    fn test_annotation() {
568        let subject = NamedNode::new("http://example.org/alice").expect("valid IRI");
569        let predicate = NamedNode::new("http://example.org/age").expect("valid IRI");
570        let object = Object::Literal(Literal::new_typed(
571            "30",
572            NamedNode::new("http://www.w3.org/2001/XMLSchema#integer").expect("valid IRI"),
573        ));
574
575        let statement = Triple::new(subject, predicate, object);
576        let ann_property = NamedNode::new("http://example.org/confidence").expect("valid IRI");
577        let ann_value = Object::Literal(Literal::new_typed(
578            "0.9",
579            NamedNode::new("http://www.w3.org/2001/XMLSchema#double").expect("valid IRI"),
580        ));
581
582        let annotation = Annotation::new(statement, ann_property, ann_value);
583        let ann_triple = annotation.to_triple();
584
585        assert!(matches!(ann_triple.subject(), Subject::QuotedTriple(_)));
586    }
587
588    #[test]
589    fn test_parse_quoted_triple_basic() {
590        use serialization::turtle_star::parse_quoted_triple;
591
592        let parsed = parse_quoted_triple(
593            "<< <http://example.org/alice> <http://example.org/says> \"Hello\" >>",
594        )
595        .expect("valid quoted triple");
596
597        assert_eq!(
598            parsed.subject(),
599            &Subject::NamedNode(NamedNode::new("http://example.org/alice").expect("valid IRI"))
600        );
601        assert_eq!(
602            parsed.predicate(),
603            &Predicate::NamedNode(NamedNode::new("http://example.org/says").expect("valid IRI"))
604        );
605        assert_eq!(parsed.object(), &Object::Literal(Literal::new("Hello")));
606    }
607
608    #[test]
609    fn test_parse_quoted_triple_roundtrip_with_serialize() {
610        use serialization::turtle_star::{parse_quoted_triple, serialize_quoted_triple};
611
612        let subject = NamedNode::new("http://example.org/alice").expect("valid IRI");
613        let predicate = NamedNode::new("http://example.org/age").expect("valid IRI");
614        let object = Object::Literal(Literal::new_typed(
615            "30",
616            NamedNode::new("http://www.w3.org/2001/XMLSchema#integer").expect("valid IRI"),
617        ));
618        let original = QuotedTriple::new(Triple::new(subject, predicate, object));
619
620        let text = serialize_quoted_triple(&original);
621        let parsed = parse_quoted_triple(&text).expect("round-trip parse");
622        assert_eq!(parsed, original);
623    }
624
625    #[test]
626    fn test_parse_quoted_triple_nested() {
627        use serialization::turtle_star::parse_quoted_triple;
628
629        let input = "<< << <http://example.org/a> <http://example.org/p> <http://example.org/b> >> <http://example.org/certainty> \"0.9\"^^<http://www.w3.org/2001/XMLSchema#double> >>";
630        let parsed = parse_quoted_triple(input).expect("valid nested quoted triple");
631        assert!(matches!(parsed.subject(), Subject::QuotedTriple(_)));
632    }
633
634    #[test]
635    fn test_parse_quoted_triple_rejects_bad_syntax() {
636        use serialization::turtle_star::parse_quoted_triple;
637
638        assert!(parse_quoted_triple("not a quoted triple").is_err());
639        assert!(
640            parse_quoted_triple("<< <http://example.org/a> <http://example.org/b> >>").is_err()
641        );
642    }
643}