use boltr::error::BoltError;
use kglite::api::KgError;
pub fn kg_to_bolt(err: KgError) -> BoltError {
BoltError::Query {
code: err.code().neo4j_status_code().into(),
message: wire_message(&err),
}
}
pub fn read_only_refusal(message: &str) -> BoltError {
kg_to_bolt(KgError::read_only(message))
}
fn wire_message(err: &KgError) -> String {
match err {
KgError::OntologyViolation {
rule,
entity,
entity_type,
property,
message,
..
} => format!(
"[kglite.OntologyViolation rule={rule} entity={entity} type={} property={}] {message}",
json_string(entity_type),
property.as_deref().map_or("null".to_string(), json_string),
),
other => other.to_string(),
}
}
fn json_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
#[cfg(test)]
mod tests {
use super::*;
use kglite::api::KgErrorCode;
#[test]
fn syntax_error_maps_to_neo_clienterror_statement_syntaxerror() {
let err = KgError::CypherSyntax {
message: "unexpected token 'NOT'".into(),
line: Some(1),
col: Some(7),
};
let bolt = kg_to_bolt(err);
match bolt {
BoltError::Query { code, .. } => {
assert_eq!(code, "Neo.ClientError.Statement.SyntaxError");
}
other => panic!("expected Query, got {other:?}"),
}
}
#[test]
fn an_execution_failure_is_published_as_a_client_error() {
let bolt = kg_to_bolt(KgError::CypherExecution {
message: "valid_at(): the date argument 'garbage' is not a date".into(),
position: None,
});
match bolt {
BoltError::Query { code, .. } => {
assert_eq!(code, "Neo.ClientError.Statement.ArgumentError");
}
other => panic!("expected Query, got {other:?}"),
}
}
#[test]
fn transaction_conflict_is_published_in_the_retriable_class() {
let code = KgErrorCode::TransactionConflict.neo4j_status_code();
assert!(
code.starts_with("Neo.TransientError."),
"OCC conflicts must be published in the driver-retriable class, got: {code}"
);
}
#[test]
fn every_code_has_a_neo4j_string_starting_with_neo_dot() {
for &code in KgErrorCode::ALL {
let s = code.neo4j_status_code();
assert!(
s.starts_with("Neo."),
"code {:?} mapped to non-Neo.* string: {}",
code,
s
);
assert_eq!(
s.split('.').count(),
4,
"code {:?} mapped to wrong-shaped string: {} (want 4 dotted segments)",
code,
s
);
}
}
#[test]
fn table_documents_every_code_and_its_status() {
let source = include_str!("error_map.rs");
let rows: Vec<&str> = source
.lines()
.filter(|l| l.starts_with("//! | `"))
.collect();
for &code in KgErrorCode::ALL {
let status = code.neo4j_status_code();
let class = status.split('.').nth(1).unwrap();
let name = format!("`{}`", code.as_str());
let row = rows
.iter()
.find(|row| {
row.split('|')
.nth(1)
.is_some_and(|first| first.trim() == name)
})
.unwrap_or_else(|| panic!("no table row for {name}"));
assert!(
row.contains(&format!("`{status}`")),
"{name}: the table row does not carry its status {status}: {row}"
);
assert!(
row.trim_end()
.trim_end_matches('|')
.trim_end()
.ends_with(class),
"{name}: the table row does not end with its class {class}: {row}"
);
}
}
fn ontology_sample() -> KgError {
KgError::OntologyViolation {
rule: "required_property",
entity: "node",
entity_type: "T".into(),
property: Some("p".into()),
message: "m".into(),
report: Vec::new(),
}
}
#[test]
fn kg_to_bolt_publishes_the_core_status_for_the_lease_and_read_only_codes() {
let lease = KgError::WriterLeaseHeld {
message: "held".into(),
holder: kglite::api::io::LeaseHolder::default(),
};
for (err, status) in [
(lease, "Neo.TransientError.General.DatabaseUnavailable"),
(KgError::read_only("ro"), "Neo.ClientError.General.ReadOnly"),
(
KgError::not_durable("no log"),
"Neo.ClientError.Request.Invalid",
),
(
KgError::DurabilityFailed {
message: "m".into(),
},
"Neo.DatabaseError.General.UnknownError",
),
] {
let BoltError::Query { code, .. } = kg_to_bolt(err) else {
panic!("Query");
};
assert_eq!(code, status);
}
}
#[test]
fn an_ontology_violation_is_told_from_a_constraint_violation_by_its_prefix() {
let ontology = kg_to_bolt(ontology_sample());
let constraint = kg_to_bolt(KgError::ConstraintViolation {
kind: "UNIQUE",
node_type: "T".into(),
properties: vec!["p".into()],
descriptor: "T.p".into(),
message: "m".into(),
});
let (
BoltError::Query {
code: oc,
message: om,
},
BoltError::Query {
code: cc,
message: cm,
},
) = (ontology, constraint)
else {
panic!("both map to Query");
};
assert_eq!(oc, cc, "they share the Neo4j status");
assert_eq!(
om,
"[kglite.OntologyViolation rule=required_property entity=node type=\"T\" property=\"p\"] m"
);
assert!(!cm.starts_with("[kglite."), "{cm}");
}
#[test]
fn the_prefix_quotes_awkward_labels_and_null_properties() {
let err = KgError::OntologyViolation {
rule: "domain",
entity: "relationship",
entity_type: "has \"] space".into(),
property: None,
message: "m".into(),
report: Vec::new(),
};
let BoltError::Query { message, .. } = kg_to_bolt(err) else {
panic!("Query");
};
assert_eq!(
message,
"[kglite.OntologyViolation rule=domain entity=relationship \
type=\"has \\\"] space\" property=null] m"
);
}
#[test]
fn read_only_refusal_is_the_core_read_only_status() {
let BoltError::Query { code, message } = read_only_refusal("nope") else {
panic!("Query");
};
assert_eq!(code, "Neo.ClientError.General.ReadOnly");
assert_eq!(message, "nope");
}
}