use std::path::Path;
use crate::ontology::rdf::{iri, Graph, Term, PROV_ENTITY, RDF_TYPE};
use crate::ontology::shapes::expand;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExtractError {
RefUnreadable {
contract: String,
path: String,
why: String,
},
NotJson {
contract: String,
path: String,
why: String,
},
VocabularyIncomplete { contract: String, what: String },
Unmapped { contract: String, key: String },
}
impl std::fmt::Display for ExtractError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::RefUnreadable { contract, path, why } => {
write!(f, "extract:json {contract}: entity.ref `{path}` cannot be read: {why}")
}
Self::NotJson { contract, path, why } => {
write!(f, "extract:json {contract}: `{path}` is not JSON: {why}")
}
Self::VocabularyIncomplete { contract, what } => {
write!(f, "extract:json {contract}: vocabulary is incomplete: {what}")
}
Self::Unmapped { contract, key } => write!(
f,
"extract:json {contract}: nested key `{key}` is not in vocabulary.nested — name its class or drop it"
),
}
}
}
impl std::error::Error for ExtractError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Warning {
pub contract: String,
pub path: String,
pub line: usize,
pub why: String,
}
impl std::fmt::Display for Warning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"extract:json {}: `{}` line {} unparsable: {}",
self.contract, self.path, self.line, self.why
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Vocabulary {
prefix: String,
root_class: String,
nested: Vec<(String, String)>,
}
fn scalar(v: Option<&serde_yaml::Value>) -> Option<String> {
match v? {
serde_yaml::Value::String(s) => Some(s.clone()),
serde_yaml::Value::Number(n) => Some(n.to_string()),
serde_yaml::Value::Bool(b) => Some(b.to_string()),
_ => None,
}
}
#[must_use]
pub fn applies(doc: &serde_yaml::Value) -> bool {
scalar(doc.get("entity").and_then(|e| e.get("type"))).as_deref() == Some("json")
}
fn vocabulary(stem: &str, doc: &serde_yaml::Value) -> Result<Vocabulary, ExtractError> {
let v = doc.get("vocabulary");
let need = |what: &str| ExtractError::VocabularyIncomplete {
contract: stem.to_string(),
what: what.to_string(),
};
let prefix = scalar(v.and_then(|v| v.get("prefix")))
.filter(|s| !s.is_empty())
.ok_or_else(|| need("no `prefix`"))?;
let root_class = scalar(v.and_then(|v| v.get("root_class")))
.filter(|s| !s.is_empty())
.ok_or_else(|| need("no `root_class`"))?;
let mut nested = Vec::new();
if let Some(serde_yaml::Value::Mapping(m)) = v.and_then(|v| v.get("nested")) {
for (k, class) in m {
let (Some(k), Some(class)) = (scalar(Some(k)), scalar(Some(class))) else {
return Err(need("`nested` must map key → class"));
};
nested.push((k, class));
}
}
Ok(Vocabulary {
prefix,
root_class,
nested,
})
}
pub fn extract_into(
g: &mut Graph,
stem: &str,
doc: &serde_yaml::Value,
root: &Path,
) -> Result<Vec<Warning>, ExtractError> {
let path = scalar(doc.get("entity").and_then(|e| e.get("ref"))).ok_or_else(|| {
ExtractError::RefUnreadable {
contract: stem.to_string(),
path: String::new(),
why: "entity.ref is absent".into(),
}
})?;
let vocab = vocabulary(stem, doc)?;
let full = root.join(&path);
let text = std::fs::read_to_string(&full).map_err(|e| ExtractError::RefUnreadable {
contract: stem.to_string(),
path: path.clone(),
why: e.to_string(),
})?;
extract_text(g, stem, &path, &text, &vocab)
}
fn extract_text(
g: &mut Graph,
stem: &str,
path: &str,
text: &str,
vocab: &Vocabulary,
) -> Result<Vec<Warning>, ExtractError> {
if path.ends_with(".jsonl") {
return extract_jsonl(g, stem, path, text, vocab);
}
let value: serde_json::Value =
serde_json::from_str(text).map_err(|e| ExtractError::NotJson {
contract: stem.to_string(),
path: path.to_string(),
why: e.to_string(),
})?;
node(g, stem, stem, &vocab.root_class, true, &value, vocab)?;
Ok(Vec::new())
}
#[must_use]
pub fn positive_control() -> bool {
let vocab = Vocabulary {
prefix: "pc".into(),
root_class: "pc:Root".into(),
nested: vec![("child".into(), "pc:Child".into())],
};
let mut g = Graph::new();
let mapped = extract_text(
&mut g,
"__pc_extract__",
"pc.json",
r#"{"a":1,"child":{"b":true}}"#,
&vocab,
)
.is_ok()
&& g.objects(&iri("pc", "__pc_extract__.child"), RDF_TYPE)
.iter()
.any(|t| t.as_iri() == Some(expand("pc:Child").as_str()));
let planted = extract_text(
&mut Graph::new(),
"__pc_extract__",
"pc.json",
r#"{"a":1,"orphan":{"b":true}}"#,
&vocab,
);
let refused = matches!(planted, Err(ExtractError::Unmapped { ref key, .. }) if key == "orphan");
mapped && refused
}
fn extract_jsonl(
g: &mut Graph,
stem: &str,
path: &str,
text: &str,
vocab: &Vocabulary,
) -> Result<Vec<Warning>, ExtractError> {
let mut warnings = Vec::new();
let mut parsed = 0usize;
let mut staged = Graph::new();
for (i, line) in text.lines().enumerate() {
let line = line.trim_matches('\0');
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<serde_json::Value>(line) {
Ok(value) => {
parsed += 1;
node(
&mut staged,
stem,
&format!("{stem}.{}", i + 1),
&vocab.root_class,
true,
&value,
vocab,
)?;
}
Err(e) => warnings.push(Warning {
contract: stem.to_string(),
path: path.to_string(),
line: i + 1,
why: e.to_string(),
}),
}
}
if parsed == 0 {
return Err(ExtractError::NotJson {
contract: stem.to_string(),
path: path.to_string(),
why: match warnings.first() {
Some(w) => format!("no line parses; line {}: {}", w.line, w.why),
None => "the file is empty".into(),
},
});
}
g.extend(&staged);
Ok(warnings)
}
fn node(
g: &mut Graph,
stem: &str,
id: &str,
class: &str,
is_root: bool,
value: &serde_json::Value,
vocab: &Vocabulary,
) -> Result<(), ExtractError> {
let serde_json::Value::Object(map) = value else {
return Err(ExtractError::NotJson {
contract: stem.to_string(),
path: id.to_string(),
why: "the root is not a JSON object".into(),
});
};
let s = iri(&vocab.prefix, id);
g.insert(s.clone(), RDF_TYPE, Term::iri(expand(class)));
if is_root {
g.insert(s.clone(), RDF_TYPE, Term::iri(PROV_ENTITY));
}
for (key, v) in map {
let pred = expand(&format!("{}:{key}", vocab.prefix));
match v {
serde_json::Value::Null => {}
serde_json::Value::Object(_) => {
let child_class = nested_class(stem, key, vocab)?;
let child_id = format!("{id}.{key}");
node(g, stem, &child_id, &child_class, false, v, vocab)?;
g.insert(s.clone(), pred, Term::iri(iri(&vocab.prefix, &child_id)));
}
serde_json::Value::Array(items) => {
for (i, item) in items.iter().enumerate() {
match item {
serde_json::Value::Null => {}
serde_json::Value::Object(_) => {
let child_class = nested_class(stem, key, vocab)?;
let child_id = format!("{id}.{key}.{i}");
node(g, stem, &child_id, &child_class, false, item, vocab)?;
g.insert(
s.clone(),
pred.clone(),
Term::iri(iri(&vocab.prefix, &child_id)),
);
}
serde_json::Value::Array(_) => {
return Err(ExtractError::Unmapped {
contract: stem.to_string(),
key: format!("{key}[{i}] (an array of arrays has no node shape)"),
})
}
other => g.insert(s.clone(), pred.clone(), literal(other)),
}
}
}
other => g.insert(s.clone(), pred, literal(other)),
}
}
Ok(())
}
fn nested_class(stem: &str, key: &str, vocab: &Vocabulary) -> Result<String, ExtractError> {
vocab
.nested
.iter()
.find(|(k, _)| k == key)
.map(|(_, c)| c.clone())
.ok_or_else(|| ExtractError::Unmapped {
contract: stem.to_string(),
key: key.to_string(),
})
}
fn literal(v: &serde_json::Value) -> Term {
match v {
serde_json::Value::Bool(b) => Term::boolean(*b),
serde_json::Value::Number(n) => {
if let Some(u) = n.as_u64() {
Term::integer(u)
} else if let Some(i) = n.as_i64() {
Term::signed(i)
} else {
Term::double(n.as_f64().unwrap_or(f64::NAN))
}
}
serde_json::Value::String(s) => Term::string(s.clone()),
other => Term::string(other.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ontology::rdf::{XSD_BOOLEAN, XSD_INTEGER, XSD_STRING};
fn contract(vocab: &str) -> serde_yaml::Value {
serde_yaml::from_str(&format!(
"name: t\nentity: {{type: json, ref: doc.json}}\nvocabulary:\n{vocab}\n"
))
.unwrap()
}
fn tmp(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("pv-extract-json-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
const VOCAB: &str = " prefix: arb\n root_class: arb:Status\n nested:\n push: arb:Push\n findings: arb:SensorFindings\n";
#[test]
fn a_document_becomes_typed_nodes_and_typed_literals() {
let d = tmp("ok");
std::fs::write(
d.join("doc.json"),
r#"{"schema":"s","n":3,"neg":-2,"ok":true,"none":null,"push":{"state":"ok","attempts":0},"findings":[{"sensor":"s-self","open":1},{"sensor":"s-drift","open":0}],"tags":["a","b"]}"#,
)
.unwrap();
let mut g = Graph::new();
let w = extract_into(&mut g, "t", &contract(VOCAB), &d).unwrap();
assert!(w.is_empty());
let root = iri("arb", "t");
assert!(g
.instances_of(&expand("arb:Status"))
.contains(&root.as_str()));
assert!(g.instances_of(PROV_ENTITY).contains(&root.as_str()));
assert_eq!(
g.objects(&root, &expand("arb:schema")),
vec![&Term::string("s")]
);
assert_eq!(
g.objects(&root, &expand("arb:n"))[0].as_literal(),
Some(("3", XSD_INTEGER))
);
assert_eq!(
g.objects(&root, &expand("arb:neg"))[0].as_literal(),
Some(("-2", XSD_INTEGER))
);
assert_eq!(
g.objects(&root, &expand("arb:ok"))[0].as_literal(),
Some(("true", XSD_BOOLEAN))
);
assert!(
g.objects(&root, &expand("arb:none")).is_empty(),
"null is absent"
);
let push = iri("arb", "t.push");
assert_eq!(
g.objects(&root, &expand("arb:push")),
vec![&Term::iri(push.clone())]
);
assert!(g.instances_of(&expand("arb:Push")).contains(&push.as_str()));
assert!(
!g.instances_of(PROV_ENTITY).contains(&push.as_str()),
"only roots are prov:Entity"
);
assert_eq!(
g.objects(&push, &expand("arb:state"))[0].as_literal(),
Some(("ok", XSD_STRING))
);
assert_eq!(g.instances_of(&expand("arb:SensorFindings")).len(), 2);
assert_eq!(g.objects(&root, &expand("arb:tags")).len(), 2);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn two_extractions_are_byte_identical() {
let d = tmp("det");
std::fs::write(
d.join("doc.json"),
r#"{"b":{"y":1,"x":2},"a":[{"k":"v"}],"z":true}"#,
)
.unwrap();
let v = " prefix: p\n root_class: p:R\n nested:\n b: p:B\n a: p:A\n";
let mut g1 = Graph::new();
extract_into(&mut g1, "t", &contract(v), &d).unwrap();
let mut g2 = Graph::new();
extract_into(&mut g2, "t", &contract(v), &d).unwrap();
assert_eq!(g1.to_ntriples(), g2.to_ntriples());
assert!(!g1.to_ntriples().contains("_:"), "no blank nodes");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn an_unmapped_nested_key_is_an_error_naming_the_key_never_a_guess() {
let d = tmp("unmapped");
std::fs::write(
d.join("doc.json"),
r#"{"push":{"state":"ok"},"surprise":{"a":1}}"#,
)
.unwrap();
let mut g = Graph::new();
let err = extract_into(&mut g, "t", &contract(VOCAB), &d).unwrap_err();
assert_eq!(
err,
ExtractError::Unmapped {
contract: "t".into(),
key: "surprise".into()
}
);
assert!(err.to_string().contains("`surprise`"));
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_missing_ref_and_a_non_json_file_are_the_declarations_fault() {
let d = tmp("missing");
let mut g = Graph::new();
let err = extract_into(&mut g, "t", &contract(VOCAB), &d).unwrap_err();
assert!(
matches!(err, ExtractError::RefUnreadable { ref path, .. } if path == "doc.json"),
"{err}"
);
std::fs::write(d.join("doc.json"), "not json at all").unwrap();
let err = extract_into(&mut g, "t", &contract(VOCAB), &d).unwrap_err();
assert!(matches!(err, ExtractError::NotJson { .. }), "{err}");
assert!(g.is_empty(), "nothing is emitted on an error");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn an_incomplete_vocabulary_is_refused_by_name() {
let d = tmp("vocab");
std::fs::write(d.join("doc.json"), "{}").unwrap();
let mut g = Graph::new();
let err = extract_into(&mut g, "t", &contract(" prefix: p\n"), &d).unwrap_err();
assert!(err.to_string().contains("no `root_class`"), "{err}");
let err = extract_into(&mut g, "t", &contract(" root_class: p:R\n"), &d).unwrap_err();
assert!(err.to_string().contains("no `prefix`"), "{err}");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_torn_jsonl_line_is_a_warning_naming_it_and_the_other_rows_are_emitted() {
let d = tmp("jsonl");
let mut bytes = Vec::new();
bytes.extend_from_slice(br#"{"kind":"tick","tick":1}"#);
bytes.push(b'\n');
bytes.extend_from_slice(&[0u8; 12]);
bytes.extend_from_slice(br#"{"kind":"tick","tick":2}"#);
bytes.push(b'\n');
bytes.extend_from_slice(br#"{"kind":"tick","ti"#);
bytes.push(b'\n');
bytes.extend_from_slice(br#"{"kind":"push","tick":3}"#);
bytes.push(b'\n');
std::fs::write(d.join("doc.jsonl"), &bytes).unwrap();
let c: serde_yaml::Value = serde_yaml::from_str(
"name: l\nentity: {type: json, ref: doc.jsonl}\nvocabulary:\n prefix: led\n root_class: led:Row\n",
)
.unwrap();
let mut g = Graph::new();
let w = extract_into(&mut g, "l", &c, &d).unwrap();
assert_eq!(w.len(), 1);
assert_eq!(w[0].line, 3);
assert!(w[0].to_string().contains("line 3 unparsable"), "{}", w[0]);
assert_eq!(
g.instances_of(&expand("led:Row")).len(),
3,
"the NUL-prefixed line 2 is salvaged, line 3 is not"
);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_jsonl_with_no_parsable_line_is_not_json() {
let d = tmp("jsonl-none");
std::fs::write(d.join("doc.jsonl"), "nope\nstill no\n").unwrap();
let c: serde_yaml::Value = serde_yaml::from_str(
"name: l\nentity: {type: json, ref: doc.jsonl}\nvocabulary:\n prefix: led\n root_class: led:Row\n",
)
.unwrap();
let mut g = Graph::new();
let err = extract_into(&mut g, "l", &c, &d).unwrap_err();
assert!(matches!(err, ExtractError::NotJson { .. }), "{err}");
assert!(err.to_string().contains("no line parses; line 1"), "{err}");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn applies_only_to_json_entities() {
assert!(applies(&contract(VOCAB)));
let other: serde_yaml::Value = serde_yaml::from_str("entity: {type: pv-contract}").unwrap();
assert!(!applies(&other));
let none: serde_yaml::Value = serde_yaml::from_str("name: x").unwrap();
assert!(!applies(&none));
}
#[test]
fn the_positive_control_fires() {
assert!(positive_control());
}
}