use crate::commands::compress::{self, Codec};
use crate::commands::range_source::{open_local_eager, open_local_ranged};
use crate::commands::render::term_to_json;
use crate::commands::turtle::{Grouping, NamespaceSample, PrefixTable, TurtleWriter, RDF_TYPE};
const PREFIX_SAMPLE_STATEMENTS: usize = 100_000;
const MAX_LEARNED_PREFIXES: usize = 32;
const MAX_SAMPLE_SLOTS: usize = 32;
const MIN_PREFIX_OCCURRENCES: u64 = 16;
#[derive(Default, Clone)]
pub(crate) struct ExportFilter {
pub graph: Option<Option<String>>,
pub subject: Option<String>,
pub predicate: Option<String>,
pub object: Option<String>,
}
impl ExportFilter {
pub(crate) fn slots(&self, rete: &rete_core::Rete) -> Vec<Option<String>> {
match &self.graph {
None => std::iter::once(None)
.chain(rete.graph_names().iter().map(|g| Some((*g).to_string())))
.collect(),
Some(None) => vec![None],
Some(Some(g)) => vec![Some(canonical_graph(rete, g))],
}
}
fn terms(&self) -> (Option<&str>, Option<&str>, Option<&str>) {
(
self.subject.as_deref(),
self.predicate.as_deref(),
self.object.as_deref(),
)
}
}
pub(crate) fn canonical_graph(rete: &rete_core::Rete, name: &str) -> String {
if rete.graph_names().contains(&name) {
return name.to_string();
}
if name.starts_with('<') || name.starts_with("_:") {
return name.to_string();
}
format!("<{name}>")
}
pub(crate) fn canonical_term(term: &str) -> String {
if term.starts_with('<') || term.starts_with('"') || term.starts_with("_:") {
term.to_string()
} else {
format!("<{term}>")
}
}
pub(crate) use rete_core::ingest::QuotedTripleSurface as QuotedTripleSyntax;
pub(crate) fn parse_quoted_triple_syntax(s: &str) -> anyhow::Result<QuotedTripleSyntax> {
QuotedTripleSyntax::parse(s).ok_or_else(|| {
anyhow::anyhow!("unknown --quoted-triple-syntax: {s} (expected `rdf12` or `rdf-star`)")
})
}
pub(crate) fn object_surface<'a>(
syntax: QuotedTripleSyntax,
s: &str,
p: &str,
o: &'a str,
) -> anyhow::Result<std::borrow::Cow<'a, str>> {
use rete_core::terms::is_quoted_triple;
if syntax == QuotedTripleSyntax::RdfStar {
return Ok(std::borrow::Cow::Borrowed(o));
}
if is_quoted_triple(s) || is_quoted_triple(p) {
let slot = if is_quoted_triple(s) {
"subject"
} else {
"predicate"
};
anyhow::bail!(
"this graph has a quoted triple in the {slot} position of a statement, and RDF 1.2 \
has no syntax for one there: a triple term may stand in OBJECT position only \
(`ttSubject ::= iri | BlankNode`).\n\
statement: {s} {p} {o} .\n\
hint: `--quoted-triple-syntax rdf-star` writes the RDF-star surface `<<s p o>>`, \
which rete re-ingests losslessly — but current RDF 1.2 parsers reject it in \
N-Quads and read it as a reifier (a different graph) in Turtle/TriG."
);
}
rete_core::terms::rdf12_triple_term(o).ok_or_else(|| {
anyhow::anyhow!(
"this graph has a quoted triple nested in the SUBJECT of another quoted triple, and \
RDF 1.2 has no syntax for one there: `ttSubject ::= iri | BlankNode`.\n\
statement: {s} {p} {o} .\n\
hint: `--quoted-triple-syntax rdf-star` writes the RDF-star surface `<<s p o>>`, \
which rete re-ingests losslessly — but current RDF 1.2 parsers reject it."
)
})
}
pub(crate) struct Respell {
syntax: QuotedTripleSyntax,
active: bool,
refused: Option<anyhow::Error>,
rewrote: u64,
}
impl Respell {
pub(crate) fn new(syntax: QuotedTripleSyntax, header_has_quoted_triples: bool) -> Self {
Self {
syntax,
active: syntax == QuotedTripleSyntax::Rdf12 && header_has_quoted_triples,
refused: None,
rewrote: 0,
}
}
pub(crate) fn rewrote(&self) -> u64 {
self.rewrote
}
pub(crate) fn object<'a>(
&mut self,
s: &str,
p: &str,
o: &'a str,
) -> Option<std::borrow::Cow<'a, str>> {
if !self.active {
return Some(std::borrow::Cow::Borrowed(o));
}
if self.refused.is_some() {
return None;
}
match object_surface(self.syntax, s, p, o) {
Ok(o) => {
if matches!(o, std::borrow::Cow::Owned(_)) {
self.rewrote += 1;
}
Some(o)
}
Err(e) => {
self.refused = Some(e);
None
}
}
}
pub(crate) fn check(&mut self) -> anyhow::Result<()> {
match self.refused.take() {
Some(e) => Err(e),
None => Ok(()),
}
}
}
#[derive(Clone, Copy)]
pub(crate) struct ExportOptions<'a> {
pub sanitize_iris: bool,
pub no_prefixes: bool,
pub compress_with: &'a str,
pub compress_level: Option<i32>,
pub in_memory: bool,
pub memory_budget_mb: Option<u64>,
pub quoted_triple_syntax: &'a str,
}
pub(crate) fn export(
file: &str,
format: &str,
filter: &ExportFilter,
opts: &ExportOptions,
) -> anyhow::Result<()> {
let &ExportOptions {
sanitize_iris,
no_prefixes,
compress_with,
compress_level,
in_memory,
memory_budget_mb,
quoted_triple_syntax,
} = opts;
let codec = Codec::parse(compress_with)?;
let level = compress::check_level(codec, compress_level)?;
let qts = parse_quoted_triple_syntax(quoted_triple_syntax)?;
if format == "hdt" && codec != Codec::None {
anyhow::bail!(
"--compress cannot be used with --format hdt: HDT is designed to be queried \
in place, and a compression frame has to be decoded before anything can read it.\n\
hint: `--format trig --compress zstd` is the compact *text* option, or write the \
HDT uncompressed and compress it yourself if you only need to move it."
);
}
if codec != Codec::None {
eprintln!(
"note: writing {}-compressed output (level {level}) to stdout; redirect it to a \
file, conventionally `{}`",
codec.name(),
codec.extension()
);
}
let budget: Option<u64> = if in_memory {
if memory_budget_mb.is_some() {
eprintln!("note: --in-memory reads the whole file, so --memory-budget-mb is ignored");
}
None
} else {
match memory_budget_mb.unwrap_or(rete_core::DEFAULT_EXPORT_BUDGET_MB) {
0 => None, mb => Some(mb.saturating_mul(1 << 20)),
}
};
let mut rete = if in_memory {
open_local_eager(file)?
} else {
open_local_ranged(file, budget)?
};
let (s, p, o) = filter.terms();
let mut iris = sanitize_iris.then(rete_core::iri::IriReport::default);
let mut respell = Respell::new(qts, rete.header().has_quoted_triples());
match format {
"nq" => {
use std::io::Write;
let stdout = std::io::stdout();
let mut out = compress::open(stdout.lock(), codec, level)?;
let mut err: std::io::Result<()> = Ok(());
for slot in filter.slots(&rete) {
match &slot {
None => rete.dump_filtered_each(None, s, p, o, |s, p, o| {
if err.is_err() {
return;
}
let (s, p, o) = clean(&mut iris, s, p, o);
let Some(o) = respell.object(&s, &p, &o) else {
return;
};
err = writeln!(out, "{s} {p} {o} .");
}),
Some(g) => {
let label = match iris.as_mut() {
Some(r) => r.sanitize(g).into_owned(),
None => g.clone(),
};
rete.dump_filtered_each(Some(g), s, p, o, |s, p, o| {
if err.is_err() {
return;
}
let (s, p, o) = clean(&mut iris, s, p, o);
let Some(o) = respell.object(&s, &p, &o) else {
return;
};
err = writeln!(out, "{s} {p} {o} {label} .");
});
rete.release_named_graph(g);
}
}
std::mem::replace(&mut err, Ok(()))?;
respell.check()?;
}
if let Err(e) = compress::close(out) {
if e.kind() != std::io::ErrorKind::BrokenPipe {
return Err(e.into());
}
return Ok(());
}
if std::env::var("RETE_OPEN_DEBUG").is_ok() {
let d = rete.dict_cache_stats();
eprintln!(
"[export] dict cache: decoded_chunks={} decoded_bytes={} evictions={} resident_bytes={} hits={} misses={}",
d.decoded_chunks, d.decoded_bytes, d.evictions, d.resident_bytes, d.hits, d.misses,
);
}
}
"ttl" | "trig" => {
let slots: Vec<Option<String>> = if format == "trig" {
filter.slots(&rete)
} else {
vec![select_single_graph(&rete, filter, "Turtle")?]
};
let table = if no_prefixes {
PrefixTable::empty()
} else {
let sample = sample_namespaces(&rete, &slots, s, p, o, sanitize_iris);
PrefixTable::from_sample(&sample, MAX_LEARNED_PREFIXES, MIN_PREFIX_OCCURRENCES)
};
let grouping = subject_grouping(&rete, &slots, s, p, o);
let wrap = format == "trig";
match write_turtle_stream(
&mut rete,
&slots,
table,
grouping,
s,
p,
o,
&mut iris,
&mut respell,
wrap,
codec,
level,
) {
Ok(()) => {
respell.check()?;
if respell.rewrote() > 0 {
eprintln!(
"note: wrote {} RDF 1.2 triple term(s) `<<( s p o )>>`. Reading this \
dump back takes `rete build --quoted-triple-syntax rdf12` (the input \
default is `rdf-star`); or export with `--quoted-triple-syntax \
rdf-star`, or `--format nq`, either of which re-ingests with no flag.",
respell.rewrote()
);
}
}
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => return Ok(()),
Err(e) => return Err(e.into()),
}
}
"hdt" => {
use std::io::Write;
let limit = match memory_budget_mb {
Some(0) => u64::MAX,
Some(mb) => mb.saturating_mul(1 << 20),
None => rete_core::DEFAULT_EXPORT_BUDGET_MB.saturating_mul(1 << 20),
};
refuse_quoted_triples(&rete, "HDT")?;
let budget = crate::commands::hdt::check_limits(&rete, limit)?;
let g = select_single_graph(&rete, filter, "HDT")?;
eprintln!(
"note: building HDT in memory (~{} estimated from {} terms, {} triples, \
{} of dictionary; {} distinct objects) - HDT cannot be written in one pass",
crate::commands::hdt::human_bytes(budget.estimated_bytes),
budget.terms,
budget.quads,
crate::commands::hdt::human_bytes(budget.dict_bytes),
budget.object_ids,
);
let built =
crate::commands::hdt::build(&mut rete, g.as_deref(), s, p, o, &mut iris, file);
eprintln!(
"note: {} triples; dictionary {} shared / {} subjects / {} predicates / {} objects",
built.triples, built.shared, built.subjects, built.predicates, built.objects,
);
let stdout = std::io::stdout();
let mut out = std::io::BufWriter::new(stdout.lock());
match out.write_all(&built.bytes).and_then(|()| out.flush()) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => return Ok(()),
Err(e) => return Err(e.into()),
}
}
"jsonld" => {
refuse_quoted_triples(&rete, "expanded JSON-LD")?;
let g = select_single_graph(&rete, filter, "JSON-LD")?;
let mut triples = rete.query_in_graph(g.as_deref(), s, p, o);
if let Some(report) = iris.as_mut() {
for t in triples.iter_mut() {
let (s, p, o) = (
report.sanitize(&t.0).into_owned(),
report.sanitize(&t.1).into_owned(),
report.sanitize(&t.2).into_owned(),
);
*t = (s, p, o);
}
}
use std::io::Write;
let stdout = std::io::stdout();
let mut out = compress::open(stdout.lock(), codec, level)?;
writeln!(out, "{}", export_jsonld(&triples))?;
let _lock = compress::close(out)?;
}
other => anyhow::bail!("unknown export format: {other}"),
}
if let Some(report) = iris.as_ref() {
crate::commands::iri_report::report_sanitized(report);
}
Ok(())
}
fn refuse_quoted_triples(rete: &rete_core::Rete, format_label: &str) -> anyhow::Result<()> {
if !rete.header().has_quoted_triples() {
return Ok(());
}
anyhow::bail!(
"this file contains RDF-star quoted triples (header flag FLAG_HAS_QUOTED_TRIPLES), and \
{format_label} has no term kind for one.\n\
Written anyway, a quoted triple would be serialized as though it were an IRI — a file \
that loads cleanly and means something else.\n\
hint: `--format trig` (or `--format nq`) writes them as RDF 1.2 triple terms \
`<<( s p o )>>`; add `--quoted-triple-syntax rdf-star` for the `<<s p o>>` surface."
)
}
pub(crate) fn clean<'a>(
report: &mut Option<rete_core::iri::IriReport>,
s: &'a str,
p: &'a str,
o: &'a str,
) -> (
std::borrow::Cow<'a, str>,
std::borrow::Cow<'a, str>,
std::borrow::Cow<'a, str>,
) {
use std::borrow::Cow;
match report.as_mut() {
Some(r) => (r.sanitize(s), r.sanitize(p), r.sanitize(o)),
None => (Cow::Borrowed(s), Cow::Borrowed(p), Cow::Borrowed(o)),
}
}
#[allow(clippy::too_many_arguments)]
fn write_turtle_stream(
rete: &mut rete_core::Rete,
slots: &[Option<String>],
table: PrefixTable,
grouping: Grouping,
s: Option<&str>,
p: Option<&str>,
o: Option<&str>,
iris: &mut Option<rete_core::iri::IriReport>,
respell: &mut Respell,
wrap_graphs: bool,
codec: Codec,
level: i32,
) -> std::io::Result<()> {
let stdout = std::io::stdout();
let mut w = TurtleWriter::new(
compress::open(stdout.lock(), codec, level)?,
table,
grouping,
);
w.declare_all()?;
if std::env::var("RETE_OPEN_DEBUG").is_ok() {
eprintln!(
"[export] {} prefix binding(s), grouping={grouping:?}",
w.table().len()
);
for (prefix, ns) in w.table().bindings() {
eprintln!("[export] {prefix}: {ns}");
}
}
let mut err: std::io::Result<()> = Ok(());
for slot in slots {
match slot {
None => {
rete.dump_filtered_each(None, s, p, o, |s, p, o| {
if err.is_err() {
return;
}
let (s, p, o) = clean(iris, s, p, o);
let Some(o) = respell.object(&s, &p, &o) else {
return;
};
err = w.write_triple(&s, &p, &o);
});
}
Some(g) => {
if wrap_graphs {
let label = match iris.as_mut() {
Some(r) => r.sanitize(g).into_owned(),
None => g.clone(),
};
w.begin_graph(&label)?;
}
rete.dump_filtered_each(Some(g), s, p, o, |s, p, o| {
if err.is_err() {
return;
}
let (s, p, o) = clean(iris, s, p, o);
let Some(o) = respell.object(&s, &p, &o) else {
return;
};
err = w.write_triple(&s, &p, &o);
});
if wrap_graphs {
w.end_graph()?;
}
rete.release_named_graph(g);
}
}
std::mem::replace(&mut err, Ok(()))?;
}
compress::close(w.finish()?).map(|_| ())
}
fn select_single_graph(
rete: &rete_core::Rete,
filter: &ExportFilter,
format_label: &str,
) -> anyhow::Result<Option<String>> {
let names: Vec<String> = rete
.graph_names()
.iter()
.map(|g| (*g).to_string())
.collect();
match &filter.graph {
Some(Some(g)) => {
let canon = canonical_graph(rete, g);
if !names.contains(&canon) {
anyhow::bail!(
"no named graph {canon} in this file.\nnamed graphs: {}\n\
hint: `--format trig` writes every graph, losslessly.",
graph_list(&names)
);
}
eprintln!("note: {format_label} export of named graph {canon}");
Ok(Some(canon))
}
Some(None) => {
eprintln!("note: {format_label} export of the default graph");
Ok(None)
}
None => {
let (s, p, o) = filter.terms();
if rete.query_iter(None, s, p, o).next().is_some() {
eprintln!("note: {format_label} export of the default graph");
if !names.is_empty() {
eprintln!(
"note: {} named graph(s) are NOT included — {format_label} has no graph \
term. Use `--format trig` for a lossless dump, or `--graph <iri>` to \
pick one.",
names.len()
);
}
return Ok(None);
}
match names.len() {
0 => {
eprintln!("note: {format_label} export of the default graph (which is empty)");
Ok(None)
}
1 => {
eprintln!(
"note: the default graph is empty; exporting the only named graph, {}",
names[0]
);
Ok(Some(names[0].clone()))
}
_ => anyhow::bail!(
"the default graph is empty and this file has {} named graphs, so there is no \
single graph to write as {format_label}.\nnamed graphs: {}\n\
hint: `--graph <iri>` picks one, or `--format trig` writes them all, \
losslessly.",
names.len(),
graph_list(&names)
),
}
}
}
}
fn graph_list(names: &[String]) -> String {
const SHOWN: usize = 12;
let mut s = names
.iter()
.take(SHOWN)
.map(String::as_str)
.collect::<Vec<_>>()
.join(", ");
if names.len() > SHOWN {
s.push_str(&format!(", ... ({} more)", names.len() - SHOWN));
}
s
}
fn subject_grouping(
rete: &rete_core::Rete,
slots: &[Option<String>],
s: Option<&str>,
p: Option<&str>,
o: Option<&str>,
) -> Grouping {
let Some(scan) = slots
.iter()
.find_map(|slot| rete.dump_plan(slot.as_deref(), s, p, o).scan)
else {
return Grouping::BySubject;
};
for col in scan.permutation.name().chars() {
match col {
'S' => return Grouping::BySubject,
'P' if p.is_some() => continue,
'O' if o.is_some() => continue,
_ => return Grouping::PerStatement,
}
}
Grouping::PerStatement
}
fn sample_namespaces(
rete: &rete_core::Rete,
slots: &[Option<String>],
s: Option<&str>,
p: Option<&str>,
o: Option<&str>,
sanitize_iris: bool,
) -> NamespaceSample {
let mut sample = NamespaceSample::default();
let mut scratch = sanitize_iris.then(rete_core::iri::IriReport::default);
let n = slots.len().clamp(1, MAX_SAMPLE_SLOTS);
let per_slot = PREFIX_SAMPLE_STATEMENTS.div_ceil(n);
for slot in slots.iter().take(MAX_SAMPLE_SLOTS) {
for (ts, tp, to) in rete.query_iter(slot.as_deref(), s, p, o).take(per_slot) {
let tp = (tp != RDF_TYPE).then_some(tp);
match scratch.as_mut() {
Some(r) => {
sample.observe(&r.sanitize(&ts));
if let Some(tp) = &tp {
sample.observe(&r.sanitize(tp));
}
sample.observe(&r.sanitize(&to));
}
None => {
sample.observe(&ts);
if let Some(tp) = &tp {
sample.observe(tp);
}
sample.observe(&to);
}
}
}
}
sample
}
pub(crate) fn export_turtle(triples: &[(String, String, String)]) -> String {
let mut sorted: Vec<&(String, String, String)> = triples.iter().collect();
sorted.sort();
let mut sample = NamespaceSample::default();
for (s, p, o) in &sorted {
sample.observe(s);
if p != RDF_TYPE {
sample.observe(p);
}
sample.observe(o);
}
let table = PrefixTable::from_sample(&sample, MAX_LEARNED_PREFIXES, MIN_PREFIX_OCCURRENCES);
let mut w = TurtleWriter::new(Vec::new(), table, Grouping::BySubject);
let _ = w.declare_all();
for (s, p, o) in sorted {
let _ = w.write_triple(s, p, o);
}
String::from_utf8(w.finish().unwrap_or_default()).unwrap_or_default()
}
pub(crate) fn export_jsonld(triples: &[(String, String, String)]) -> String {
use serde_json::{json, Map, Value};
use std::collections::BTreeMap;
let mut nodes: BTreeMap<String, BTreeMap<String, Vec<Value>>> = BTreeMap::new();
for (s, p, o) in triples {
let id = node_id(s);
let pred = p
.strip_prefix('<')
.and_then(|x| x.strip_suffix('>'))
.unwrap_or(p)
.to_string();
nodes
.entry(id)
.or_default()
.entry(pred)
.or_default()
.push(object_to_jsonld(o));
}
let arr: Vec<Value> = nodes
.into_iter()
.map(|(id, preds)| {
let mut obj = Map::new();
obj.insert("@id".into(), json!(id));
for (pred, vals) in preds {
obj.insert(pred, Value::Array(vals));
}
Value::Object(obj)
})
.collect();
serde_json::to_string_pretty(&Value::Array(arr)).unwrap_or_default()
}
fn node_id(token: &str) -> String {
token
.strip_prefix('<')
.and_then(|s| s.strip_suffix('>'))
.map(str::to_string)
.unwrap_or_else(|| token.to_string())
}
fn object_to_jsonld(token: &str) -> serde_json::Value {
use serde_json::{json, Map, Value};
let t = term_to_json(token);
match t["type"].as_str() {
Some("uri") => json!({ "@id": t["value"] }),
Some("bnode") => json!({ "@id": format!("_:{}", t["value"].as_str().unwrap_or("")) }),
_ => {
let mut obj = Map::new();
obj.insert("@value".into(), t["value"].clone());
if let Some(dt) = t.get("datatype") {
obj.insert("@type".into(), dt.clone());
}
if let Some(lang) = t.get("xml:lang") {
obj.insert("@language".into(), lang.clone());
}
Value::Object(obj)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_triples() -> Vec<(String, String, String)> {
vec![
(
"<http://ex/Alice>".into(),
"<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>".into(),
"<http://ex/Person>".into(),
),
(
"<http://ex/Alice>".into(),
"<http://ex/age>".into(),
"\"30\"^^<http://www.w3.org/2001/XMLSchema#integer>".into(),
),
(
"<http://ex/Alice>".into(),
"<http://ex/label>".into(),
"\"héllo \\\"quote\\\"\"@en".into(),
),
(
"<http://ex/Alice>".into(),
"<http://ex/knows>".into(),
"_:b0".into(),
),
]
}
#[test]
fn turtle_export_groups_and_abbreviates() {
let ttl = export_turtle(&sample_triples());
assert!(ttl.contains("<http://ex/Alice>\n"), "got:\n{ttl}");
assert_eq!(
ttl.matches("<http://ex/Alice>").count(),
1,
"the subject is written once, not once per statement:\n{ttl}"
);
assert!(ttl.contains("a <http://ex/Person>"), "got:\n{ttl}");
assert!(
ttl.contains("@prefix xsd: <http://www.w3.org/2001/XMLSchema#> ."),
"got:\n{ttl}"
);
assert!(ttl.contains("\"30\"^^xsd:integer"), "got:\n{ttl}");
assert!(!ttl.contains("@prefix rdf:"), "got:\n{ttl}");
assert!(ttl.contains("\"héllo \\\"quote\\\"\"@en"), "got:\n{ttl}");
assert!(ttl.contains("_:b0"));
assert!(ttl.trim_end().ends_with(" ."));
}
#[test]
fn jsonld_export_expanded_shape() {
let v: serde_json::Value = serde_json::from_str(&export_jsonld(&sample_triples())).unwrap();
let node = &v[0];
assert_eq!(node["@id"], "http://ex/Alice");
assert_eq!(
node["http://www.w3.org/1999/02/22-rdf-syntax-ns#type"][0]["@id"],
"http://ex/Person"
);
let age = &node["http://ex/age"][0];
assert_eq!(age["@value"], "30");
assert_eq!(age["@type"], "http://www.w3.org/2001/XMLSchema#integer");
let label = &node["http://ex/label"][0];
assert_eq!(label["@value"], "héllo \"quote\"");
assert_eq!(label["@language"], "en");
assert_eq!(node["http://ex/knows"][0]["@id"], "_:b0");
}
}