use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum RdfTerm {
Iri(String),
Literal {
value: String,
language: Option<String>,
datatype: Option<String>,
},
BlankNode(String),
Unbound,
}
impl RdfTerm {
pub fn iri(s: impl Into<String>) -> Self {
RdfTerm::Iri(s.into())
}
pub fn literal(value: impl Into<String>) -> Self {
RdfTerm::Literal {
value: value.into(),
language: None,
datatype: None,
}
}
pub fn lang_literal(value: impl Into<String>, lang: impl Into<String>) -> Self {
RdfTerm::Literal {
value: value.into(),
language: Some(lang.into()),
datatype: None,
}
}
pub fn typed_literal(value: impl Into<String>, datatype: impl Into<String>) -> Self {
RdfTerm::Literal {
value: value.into(),
language: None,
datatype: Some(datatype.into()),
}
}
pub fn blank_node(id: impl Into<String>) -> Self {
RdfTerm::BlankNode(id.into())
}
}
#[derive(Debug, Clone)]
pub struct ResultRow {
pub values: Vec<RdfTerm>,
}
impl ResultRow {
pub fn new(values: Vec<RdfTerm>) -> Self {
Self { values }
}
}
#[derive(Debug, Clone)]
pub struct SparqlResultSet {
pub variables: Vec<String>,
pub rows: Vec<ResultRow>,
}
impl SparqlResultSet {
pub fn new(variables: Vec<String>) -> Self {
Self {
variables,
rows: Vec::new(),
}
}
pub fn add_row(&mut self, row: ResultRow) {
self.rows.push(row);
}
pub fn width(&self) -> usize {
self.variables.len()
}
pub fn height(&self) -> usize {
self.rows.len()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResultFormat {
Csv,
Tsv,
}
#[derive(Debug, Clone)]
pub struct SerializerConfig {
pub format: ResultFormat,
pub include_header: bool,
pub tsv_bracket_iris: bool,
}
impl Default for SerializerConfig {
fn default() -> Self {
Self {
format: ResultFormat::Csv,
include_header: true,
tsv_bracket_iris: true,
}
}
}
pub fn serialize_csv(result_set: &SparqlResultSet) -> String {
let config = SerializerConfig {
format: ResultFormat::Csv,
..Default::default()
};
serialize(result_set, &config)
}
pub fn serialize_tsv(result_set: &SparqlResultSet) -> String {
let config = SerializerConfig {
format: ResultFormat::Tsv,
..Default::default()
};
serialize(result_set, &config)
}
pub fn serialize(result_set: &SparqlResultSet, config: &SerializerConfig) -> String {
let separator = match config.format {
ResultFormat::Csv => ',',
ResultFormat::Tsv => '\t',
};
let mut output = String::new();
if config.include_header {
let header: Vec<String> = result_set
.variables
.iter()
.map(|v| match config.format {
ResultFormat::Csv => csv_escape(v),
ResultFormat::Tsv => format!("?{v}"),
})
.collect();
output.push_str(&header.join(&separator.to_string()));
output.push_str("\r\n");
}
for row in &result_set.rows {
let values: Vec<String> = row
.values
.iter()
.map(|term| format_term(term, config))
.collect();
output.push_str(&values.join(&separator.to_string()));
output.push_str("\r\n");
}
output
}
pub fn serialize_row(row: &ResultRow, config: &SerializerConfig) -> String {
let separator = match config.format {
ResultFormat::Csv => ',',
ResultFormat::Tsv => '\t',
};
let values: Vec<String> = row
.values
.iter()
.map(|term| format_term(term, config))
.collect();
let mut output = values.join(&separator.to_string());
output.push_str("\r\n");
output
}
pub fn serialize_header(variables: &[String], config: &SerializerConfig) -> String {
let separator = match config.format {
ResultFormat::Csv => ',',
ResultFormat::Tsv => '\t',
};
let header: Vec<String> = variables
.iter()
.map(|v| match config.format {
ResultFormat::Csv => csv_escape(v),
ResultFormat::Tsv => format!("?{v}"),
})
.collect();
let mut output = header.join(&separator.to_string());
output.push_str("\r\n");
output
}
fn format_term(term: &RdfTerm, config: &SerializerConfig) -> String {
match config.format {
ResultFormat::Csv => format_term_csv(term),
ResultFormat::Tsv => format_term_tsv(term, config.tsv_bracket_iris),
}
}
fn format_term_csv(term: &RdfTerm) -> String {
match term {
RdfTerm::Iri(iri) => csv_escape(iri),
RdfTerm::Literal {
value,
language,
datatype,
} => {
if language.is_some() || datatype.is_some() {
csv_escape(value)
} else {
csv_escape(value)
}
}
RdfTerm::BlankNode(id) => csv_escape(&format!("_:{id}")),
RdfTerm::Unbound => String::new(),
}
}
fn format_term_tsv(term: &RdfTerm, bracket_iris: bool) -> String {
match term {
RdfTerm::Iri(iri) => {
if bracket_iris {
format!("<{iri}>")
} else {
iri.clone()
}
}
RdfTerm::Literal {
value,
language: Some(lang),
..
} => {
format!("\"{}\"@{}", tsv_escape_string(value), lang)
}
RdfTerm::Literal {
value,
datatype: Some(dt),
..
} => {
format!("\"{}\"^^<{}>", tsv_escape_string(value), dt)
}
RdfTerm::Literal { value, .. } => {
format!("\"{}\"", tsv_escape_string(value))
}
RdfTerm::BlankNode(id) => format!("_:{id}"),
RdfTerm::Unbound => String::new(),
}
}
fn csv_escape(value: &str) -> String {
if value.contains(',') || value.contains('"') || value.contains('\n') || value.contains('\r') {
let escaped = value.replace('"', "\"\"");
format!("\"{escaped}\"")
} else {
value.to_string()
}
}
fn tsv_escape_string(value: &str) -> String {
let mut result = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'\t' => result.push_str("\\t"),
'\n' => result.push_str("\\n"),
'\r' => result.push_str("\\r"),
'\\' => result.push_str("\\\\"),
'"' => result.push_str("\\\""),
_ => result.push(ch),
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_result_set() -> SparqlResultSet {
let mut rs = SparqlResultSet::new(vec!["name".to_string(), "age".to_string()]);
rs.add_row(ResultRow::new(vec![
RdfTerm::literal("Alice"),
RdfTerm::typed_literal("30", "http://www.w3.org/2001/XMLSchema#integer"),
]));
rs.add_row(ResultRow::new(vec![
RdfTerm::literal("Bob"),
RdfTerm::typed_literal("25", "http://www.w3.org/2001/XMLSchema#integer"),
]));
rs
}
fn iri_result_set() -> SparqlResultSet {
let mut rs = SparqlResultSet::new(vec!["s".to_string(), "p".to_string(), "o".to_string()]);
rs.add_row(ResultRow::new(vec![
RdfTerm::iri("http://example.org/alice"),
RdfTerm::iri("http://xmlns.com/foaf/0.1/name"),
RdfTerm::literal("Alice"),
]));
rs.add_row(ResultRow::new(vec![
RdfTerm::iri("http://example.org/alice"),
RdfTerm::iri("http://xmlns.com/foaf/0.1/knows"),
RdfTerm::iri("http://example.org/bob"),
]));
rs
}
#[test]
fn test_rdf_term_iri() {
let term = RdfTerm::iri("http://example.org");
assert_eq!(term, RdfTerm::Iri("http://example.org".to_string()));
}
#[test]
fn test_rdf_term_literal() {
let term = RdfTerm::literal("hello");
assert!(matches!(term, RdfTerm::Literal { value, .. } if value == "hello"));
}
#[test]
fn test_rdf_term_lang_literal() {
let term = RdfTerm::lang_literal("hello", "en");
assert!(matches!(
term,
RdfTerm::Literal { language: Some(l), .. } if l == "en"
));
}
#[test]
fn test_rdf_term_typed_literal() {
let term = RdfTerm::typed_literal("42", "http://www.w3.org/2001/XMLSchema#integer");
assert!(matches!(
term,
RdfTerm::Literal { datatype: Some(dt), .. } if dt.contains("integer")
));
}
#[test]
fn test_rdf_term_blank_node() {
let term = RdfTerm::blank_node("b0");
assert_eq!(term, RdfTerm::BlankNode("b0".to_string()));
}
#[test]
fn test_result_set_dimensions() {
let rs = sample_result_set();
assert_eq!(rs.width(), 2);
assert_eq!(rs.height(), 2);
}
#[test]
fn test_result_set_empty() {
let rs = SparqlResultSet::new(vec!["x".to_string()]);
assert_eq!(rs.width(), 1);
assert_eq!(rs.height(), 0);
}
#[test]
fn test_csv_basic() {
let rs = sample_result_set();
let csv = serialize_csv(&rs);
assert!(csv.starts_with("name,age\r\n"));
assert!(csv.contains("Alice"));
assert!(csv.contains("30"));
}
#[test]
fn test_csv_header() {
let rs = sample_result_set();
let csv = serialize_csv(&rs);
let first_line = csv.lines().next().expect("first line");
assert_eq!(first_line, "name,age");
}
#[test]
fn test_csv_iri_values() {
let rs = iri_result_set();
let csv = serialize_csv(&rs);
assert!(csv.contains("http://example.org/alice"));
}
#[test]
fn test_csv_escaping_comma() {
let mut rs = SparqlResultSet::new(vec!["val".to_string()]);
rs.add_row(ResultRow::new(vec![RdfTerm::literal("hello, world")]));
let csv = serialize_csv(&rs);
assert!(csv.contains("\"hello, world\""));
}
#[test]
fn test_csv_escaping_quote() {
let mut rs = SparqlResultSet::new(vec!["val".to_string()]);
rs.add_row(ResultRow::new(vec![RdfTerm::literal("say \"hi\"")]));
let csv = serialize_csv(&rs);
assert!(csv.contains("\"say \"\"hi\"\"\""));
}
#[test]
fn test_csv_unbound() {
let mut rs = SparqlResultSet::new(vec!["a".to_string(), "b".to_string()]);
rs.add_row(ResultRow::new(vec![
RdfTerm::literal("x"),
RdfTerm::Unbound,
]));
let csv = serialize_csv(&rs);
assert!(csv.contains("x,\r\n"));
}
#[test]
fn test_csv_blank_node() {
let mut rs = SparqlResultSet::new(vec!["node".to_string()]);
rs.add_row(ResultRow::new(vec![RdfTerm::blank_node("b0")]));
let csv = serialize_csv(&rs);
assert!(csv.contains("_:b0"));
}
#[test]
fn test_csv_crlf_line_endings() {
let rs = sample_result_set();
let csv = serialize_csv(&rs);
assert!(csv.contains("\r\n"));
}
#[test]
fn test_csv_no_header() {
let rs = sample_result_set();
let config = SerializerConfig {
format: ResultFormat::Csv,
include_header: false,
..Default::default()
};
let csv = serialize(&rs, &config);
let first_line = csv.lines().next().expect("first line");
assert!(!first_line.contains("name"));
assert!(first_line.contains("Alice"));
}
#[test]
fn test_tsv_basic() {
let rs = sample_result_set();
let tsv = serialize_tsv(&rs);
assert!(tsv.starts_with("?name\t?age\r\n"));
}
#[test]
fn test_tsv_header_prefixed() {
let rs = sample_result_set();
let tsv = serialize_tsv(&rs);
let first_line = tsv.lines().next().expect("first line");
assert!(first_line.starts_with("?name"));
assert!(first_line.contains("\t?age"));
}
#[test]
fn test_tsv_iri_bracketed() {
let rs = iri_result_set();
let tsv = serialize_tsv(&rs);
assert!(tsv.contains("<http://example.org/alice>"));
}
#[test]
fn test_tsv_literal_quoted() {
let rs = sample_result_set();
let tsv = serialize_tsv(&rs);
assert!(tsv.contains("\"Alice\""));
}
#[test]
fn test_tsv_typed_literal() {
let rs = sample_result_set();
let tsv = serialize_tsv(&rs);
assert!(tsv.contains("^^<http://www.w3.org/2001/XMLSchema#integer>"));
}
#[test]
fn test_tsv_lang_literal() {
let mut rs = SparqlResultSet::new(vec!["label".to_string()]);
rs.add_row(ResultRow::new(vec![RdfTerm::lang_literal("chat", "fr")]));
let tsv = serialize_tsv(&rs);
assert!(tsv.contains("\"chat\"@fr"));
}
#[test]
fn test_tsv_blank_node() {
let mut rs = SparqlResultSet::new(vec!["node".to_string()]);
rs.add_row(ResultRow::new(vec![RdfTerm::blank_node("b1")]));
let tsv = serialize_tsv(&rs);
assert!(tsv.contains("_:b1"));
}
#[test]
fn test_tsv_escaping_tab() {
let mut rs = SparqlResultSet::new(vec!["val".to_string()]);
rs.add_row(ResultRow::new(vec![RdfTerm::literal("a\tb")]));
let tsv = serialize_tsv(&rs);
assert!(tsv.contains("a\\tb"));
}
#[test]
fn test_tsv_escaping_newline() {
let mut rs = SparqlResultSet::new(vec!["val".to_string()]);
rs.add_row(ResultRow::new(vec![RdfTerm::literal("line1\nline2")]));
let tsv = serialize_tsv(&rs);
assert!(tsv.contains("line1\\nline2"));
}
#[test]
fn test_serialize_header_csv() {
let vars = vec!["name".to_string(), "age".to_string()];
let config = SerializerConfig {
format: ResultFormat::Csv,
..Default::default()
};
let header = serialize_header(&vars, &config);
assert_eq!(header, "name,age\r\n");
}
#[test]
fn test_serialize_header_tsv() {
let vars = vec!["name".to_string(), "age".to_string()];
let config = SerializerConfig {
format: ResultFormat::Tsv,
..Default::default()
};
let header = serialize_header(&vars, &config);
assert_eq!(header, "?name\t?age\r\n");
}
#[test]
fn test_serialize_row_csv() {
let row = ResultRow::new(vec![RdfTerm::literal("Alice"), RdfTerm::literal("30")]);
let config = SerializerConfig {
format: ResultFormat::Csv,
..Default::default()
};
let output = serialize_row(&row, &config);
assert_eq!(output, "Alice,30\r\n");
}
#[test]
fn test_serialize_row_tsv() {
let row = ResultRow::new(vec![
RdfTerm::iri("http://example.org/a"),
RdfTerm::literal("hello"),
]);
let config = SerializerConfig {
format: ResultFormat::Tsv,
..Default::default()
};
let output = serialize_row(&row, &config);
assert!(output.contains("<http://example.org/a>"));
assert!(output.contains("\t"));
}
#[test]
fn test_csv_empty_results() {
let rs = SparqlResultSet::new(vec!["x".to_string(), "y".to_string()]);
let csv = serialize_csv(&rs);
assert_eq!(csv, "x,y\r\n");
}
#[test]
fn test_tsv_empty_results() {
let rs = SparqlResultSet::new(vec!["x".to_string()]);
let tsv = serialize_tsv(&rs);
assert_eq!(tsv, "?x\r\n");
}
#[test]
fn test_default_config() {
let config = SerializerConfig::default();
assert_eq!(config.format, ResultFormat::Csv);
assert!(config.include_header);
assert!(config.tsv_bracket_iris);
}
#[test]
fn test_tsv_no_bracket_iris() {
let rs = iri_result_set();
let config = SerializerConfig {
format: ResultFormat::Tsv,
tsv_bracket_iris: false,
include_header: true,
};
let tsv = serialize(&rs, &config);
assert!(!tsv.contains("<http://"));
assert!(tsv.contains("http://example.org/alice"));
}
}