use crate::error::Result;
use openehr::base::iso8601;
use openehr::rm::common::{Locatable as _, PartyProxy, Version};
use openehr::rm::ehr::Composition;
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>,
}
impl VersionRow {
pub fn project<T: Serialize>(version: &Version<T>, contribution_uid: &str) -> Result<Self> {
refuse_unpersistable(version)?;
let uid = version.uid();
let audit = version.commit_audit();
let data_json = version
.data()
.map(openehr::security::to_canonical_string)
.transpose()?;
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,
})
}
}
fn refuse_unpersistable<T>(version: &Version<T>) -> Result<()> {
let unsupported = |what: &'static str| crate::StoreError::Unsupported {
engine: "openehr-store",
what,
spec_ref: "spec/databases/audit.md D-07",
};
if version.commit_audit().description().is_some() {
return Err(unsupported("AUDIT_DETAILS.description has no column"));
}
if !version.attestations().is_empty() {
return Err(unsupported("ORIGINAL_VERSION.attestations has no column"));
}
if !version.other_input_version_uids().is_empty() {
return Err(unsupported(
"ORIGINAL_VERSION.other_input_version_uids has no column",
));
}
if version.signature().is_some() {
return Err(unsupported("VERSION.signature has no column"));
}
Ok(())
}
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};
use openehr::rm::data_types::Text;
use openehr::rm::data_types::DvDateTime;
use openehr::rm::ehr::Composition;
use openehr::terminology::{audit_change_type, version_lifecycle_state};
#[test]
fn an_audit_description_is_refused_rather_than_dropped() {
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 owner = crate::conformance::sample_ehr().ehr_status().clone();
let version: openehr::rm::common::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,
owner,
)
.expect("literal")
.into();
let error = VersionRow::project(&version, "c1")
.expect_err("a description with no column must be refused");
assert!(
matches!(error, crate::StoreError::Unsupported { .. }),
"must be Unsupported, not a silent success or an engine error: {error}"
);
}
#[test]
fn a_signature_is_refused_rather_than_dropped() {
let version: openehr::rm::common::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")),
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"),
crate::conformance::sample_ehr().ehr_status().clone(),
)
.expect("literal")
.with_signature("-----BEGIN PGP SIGNATURE-----")
.into();
let error = VersionRow::project(&version, "c1")
.expect_err("a signature with no column must be refused");
assert!(matches!(error, crate::StoreError::Unsupported { .. }), "{error}");
}
#[test]
fn a_version_with_no_unpersistable_attribute_projects() {
let version = crate::conformance::sample_version(1, None, 0);
VersionRow::project(&version, "c1").expect("must project");
}
}