use crate::error::Result;
use openehr::base::iso8601;
use openehr::rm::common::{Locatable as _, PartyProxy, Version};
use openehr::rm::ehr::Composition;
use openehr::security::audit_chain::{Chain, ChainKey};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StoredInstant {
pub text: String,
pub utc_seconds: Option<i64>,
}
impl StoredInstant {
#[must_use]
pub fn from_date_time(value: &iso8601::DateTime) -> Self {
let epoch: iso8601::DateTime = "1970-01-01T00:00:00Z".parse().expect("literal");
Self {
text: value.as_str().to_owned(),
utc_seconds: value.diff_seconds(&epoch),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct VersionRow {
pub uid: String,
pub versioned_object_uid: String,
pub creating_system_id: String,
pub trunk_version: i64,
pub branch_number: Option<i64>,
pub branch_version: Option<i64>,
pub preceding_version_uid: Option<String>,
pub lifecycle_state_code: String,
pub is_deleted: bool,
pub contribution_uid: String,
pub audit_system_id: String,
pub audit_change_type_code: String,
pub audit_committer_name: Option<String>,
pub audit_time_committed: StoredInstant,
pub data_json: Option<String>,
pub audit_description: Option<String>,
pub signature: Option<String>,
pub attestations_json: Option<String>,
pub other_input_version_uids_json: Option<String>,
pub chain: ChainColumns,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChainColumns {
pub previous: [u8; 32],
pub content: [u8; 32],
pub digest: [u8; 32],
pub tag_key_id: Option<String>,
pub tag_mac: Option<[u8; 32]>,
}
impl VersionRow {
pub fn project<T: Serialize>(
version: &Version<T>,
contribution_uid: &str,
previous: Option<[u8; 32]>,
key: Option<&ChainKey>,
) -> Result<Self> {
let uid = version.uid();
let audit = version.commit_audit();
let data_json = version
.data()
.map(openehr::security::to_canonical_string)
.transpose()?;
let mut chain_builder = previous.map_or_else(Chain::new, |head| {
Chain::resume_from(openehr::security::Digest256::from_bytes(head))
});
chain_builder.append(uid.to_string(), &version.data(), key)?;
let entry = chain_builder
.entries()
.last()
.expect("append pushed an entry");
let chain = ChainColumns {
previous: *entry.previous.as_bytes(),
content: *entry.content.as_bytes(),
digest: *entry.digest.as_bytes(),
tag_key_id: entry.tag.as_ref().map(|t| t.key_id().to_owned()),
tag_mac: entry
.tag
.as_ref()
.and_then(|t| <[u8; 32]>::try_from(t.mac()).ok()),
};
Ok(Self {
uid: uid.to_string(),
versioned_object_uid: uid.object_id().to_string(),
creating_system_id: uid.creating_system_id().to_string(),
trunk_version: i64::from(uid.version_tree_id().trunk_version()),
branch_number: uid.version_tree_id().branch_number().map(i64::from),
branch_version: uid.version_tree_id().branch_version().map(i64::from),
preceding_version_uid: version.preceding_version_uid().map(ToString::to_string),
lifecycle_state_code: version.lifecycle_state_code().to_owned(),
is_deleted: version.is_deleted(),
contribution_uid: contribution_uid.to_owned(),
audit_system_id: audit.system_id().to_owned(),
audit_change_type_code: audit.change_type_code().to_owned(),
audit_committer_name: party_name(audit.committer()),
audit_time_committed: StoredInstant::from_date_time(audit.time_committed().value()),
data_json,
audit_description: audit.description().map(ToString::to_string),
signature: version.signature().map(ToOwned::to_owned),
attestations_json: encode_if_any(version.attestations())?,
other_input_version_uids_json: encode_if_any(version.other_input_version_uids())?,
chain,
})
}
}
fn encode_if_any<T: Serialize>(items: &[T]) -> Result<Option<String>> {
if items.is_empty() {
return Ok(None);
}
Ok(Some(openehr::security::to_canonical_string(&items)?))
}
fn party_name(party: &PartyProxy) -> Option<String> {
party.name().map(ToOwned::to_owned)
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CompositionIndexRow {
pub version_uid: String,
pub ehr_id: String,
pub archetype_id: String,
pub template_id: Option<String>,
pub category_code: String,
pub composer_name: Option<String>,
pub language_code: String,
pub territory_code: String,
pub setting_code: Option<String>,
pub context_start: Option<StoredInstant>,
pub context_end: Option<StoredInstant>,
}
impl CompositionIndexRow {
pub fn project(version_uid: &str, ehr_id: &str, composition: &Composition) -> Result<Self> {
let details = composition.archetype_details().ok_or_else(|| {
let mut report = openehr::ValidationReport::new();
report.push(openehr::Violation {
path: String::new(),
class: "COMPOSITION",
invariant: "Is_archetype_root",
detail: "cannot index a composition with no archetype_details",
});
crate::StoreError::Invalid(report)
})?;
let context = composition.context();
Ok(Self {
version_uid: version_uid.to_owned(),
ehr_id: ehr_id.to_owned(),
archetype_id: details.archetype_id().to_string(),
template_id: details.template_id().map(ToString::to_string),
category_code: composition.category_code().to_owned(),
composer_name: party_name(composition.composer()),
language_code: composition.language().code_string().to_owned(),
territory_code: composition.territory().code_string().to_owned(),
setting_code: context.map(|c| c.setting().defining_code().code_string().to_owned()),
context_start: context.map(|c| StoredInstant::from_date_time(c.start_time().value())),
context_end: context
.and_then(|c| c.end_time())
.map(|t| StoredInstant::from_date_time(t.value())),
})
}
}
#[cfg(test)]
mod tests {
use super::VersionRow;
use openehr::rm::common::{AuditDetails, OriginalVersion, PartyIdentified, Version};
use openehr::rm::data_types::{DvDateTime, Text};
use openehr::rm::ehr::Composition;
use openehr::terminology::{audit_change_type, version_lifecycle_state};
fn version(
build: impl FnOnce(OriginalVersion<Composition>) -> OriginalVersion<Composition>,
) -> Version<Composition> {
let audit = AuditDetails::new(
"ehr1.example.org",
DvDateTime::new("2026-08-01T09:00:00Z").expect("literal"),
audit_change_type::CREATION,
PartyIdentified::named("Dr A Nurse")
.expect("literal")
.into(),
)
.expect("literal");
let original = OriginalVersion::new(
format!("{}::ehr1.example.org::1", crate::conformance::RECORD)
.parse()
.expect("literal"),
None,
version_lifecycle_state::COMPLETE,
Some(crate::conformance::sample_composition("Encounter")),
audit,
crate::conformance::sample_ehr().ehr_status().clone(),
)
.expect("literal");
build(original).into()
}
#[test]
fn the_attributes_that_used_to_be_dropped_are_persisted() {
let audit = AuditDetails::new(
"ehr1.example.org",
DvDateTime::new("2026-08-01T09:00:00Z").expect("literal"),
audit_change_type::AMENDMENT,
PartyIdentified::named("Dr A Nurse")
.expect("literal")
.into(),
)
.expect("literal")
.with_description(
Text::plain("corrected after telephone call with the lab").expect("literal"),
);
let v: Version<Composition> = OriginalVersion::new(
format!("{}::ehr1.example.org::2", crate::conformance::RECORD)
.parse()
.expect("literal"),
Some(
format!("{}::ehr1.example.org::1", crate::conformance::RECORD)
.parse()
.expect("literal"),
),
version_lifecycle_state::COMPLETE,
Some(crate::conformance::sample_composition("Encounter")),
audit,
crate::conformance::sample_ehr().ehr_status().clone(),
)
.expect("literal")
.with_signature("-----BEGIN PGP SIGNATURE-----")
.into();
let attested = openehr::rm::common::Attestation::new(
AuditDetails::new(
"ehr1.example.org",
DvDateTime::new("2026-08-01T10:00:00Z").expect("literal"),
audit_change_type::AMENDMENT,
PartyIdentified::named("Dr B Consultant")
.expect("literal")
.into(),
)
.expect("literal"),
Text::plain("countersigned").expect("literal"),
false,
);
let merged: openehr::base::ObjectVersionId =
format!("{}::other.example.org::1", crate::conformance::RECORD)
.parse()
.expect("literal");
let v: Version<Composition> = match v {
Version::Original(original) => original
.with_attestation(attested)
.with_other_input_version_uid(merged.clone())
.into(),
imported @ Version::Imported(_) => imported,
};
let row = VersionRow::project(&v, "c1", None, None).expect("projects");
assert_eq!(
row.audit_description.as_deref(),
Some("corrected after telephone call with the lab"),
);
assert_eq!(
row.signature.as_deref(),
Some("-----BEGIN PGP SIGNATURE-----")
);
assert!(
row.attestations_json
.as_deref()
.is_some_and(|j| j.contains("countersigned")),
"attestations were not persisted: {:?}",
row.attestations_json
);
assert!(
row.other_input_version_uids_json
.as_deref()
.is_some_and(|j| j.contains(&merged.to_string())),
"merged version ids were not persisted: {:?}",
row.other_input_version_uids_json
);
}
#[test]
fn an_anonymous_committer_is_null_and_a_named_one_is_written() {
let with_committer = |party: openehr::rm::common::PartyProxy| {
let audit = AuditDetails::new(
"ehr1.example.org",
DvDateTime::new("2026-08-01T09:00:00Z").expect("literal"),
audit_change_type::CREATION,
party,
)
.expect("literal");
let v: Version<Composition> = OriginalVersion::new(
format!("{}::ehr1.example.org::1", crate::conformance::RECORD)
.parse()
.expect("literal"),
None,
version_lifecycle_state::COMPLETE,
Some(crate::conformance::sample_composition("Encounter")),
audit,
crate::conformance::sample_ehr().ehr_status().clone(),
)
.expect("literal")
.into();
VersionRow::project(&v, "c1", None, None)
.expect("projects")
.audit_committer_name
};
assert_eq!(
with_committer(
PartyIdentified::named("Dr A Nurse")
.expect("literal")
.into()
),
Some("Dr A Nurse".to_owned())
);
assert_eq!(
with_committer(openehr::rm::common::PartySelf::anonymous().into()),
None
);
}
#[test]
fn empty_collections_are_null_rather_than_an_empty_array() {
let row = VersionRow::project(&version(|v| v), "c1", None, None).expect("projects");
assert!(row.attestations_json.is_none());
assert!(row.other_input_version_uids_json.is_none());
assert!(row.audit_description.is_none());
assert!(row.signature.is_none());
}
#[test]
fn the_first_version_chains_to_genesis() {
let row = VersionRow::project(&version(|v| v), "c1", None, None).expect("projects");
assert_eq!(row.chain.previous, [0u8; 32], "first entry follows genesis");
assert_ne!(row.chain.digest, [0u8; 32], "the entry has its own digest");
assert_ne!(
row.chain.content, row.chain.digest,
"the content digest and the entry digest are different values"
);
}
#[test]
fn a_successor_links_to_its_predecessor() {
let first = VersionRow::project(&version(|v| v), "c1", None, None).expect("projects");
let second = VersionRow::project(&version(|v| v), "c1", Some(first.chain.digest), None)
.expect("projects");
assert_eq!(second.chain.previous, first.chain.digest);
assert_eq!(
second.chain.content, first.chain.content,
"same content digests the same"
);
assert_ne!(
second.chain.digest, first.chain.digest,
"identical content at a different position must not share a digest, \
or a version could be moved without detection"
);
}
#[test]
fn chaining_is_deterministic() {
let a = VersionRow::project(&version(|v| v), "c1", None, None).expect("projects");
let b = VersionRow::project(&version(|v| v), "c1", None, None).expect("projects");
assert_eq!(a.chain.digest, b.chain.digest);
}
#[test]
fn the_chain_is_unkeyed_unless_a_key_is_given() {
let row = VersionRow::project(&version(|v| v), "c1", None, None).expect("projects");
assert!(row.chain.tag_key_id.is_none());
assert!(row.chain.tag_mac.is_none());
}
}