use crate::dialect::Dialect;
use crate::error::Result;
use crate::store::Store;
use openehr::base::{HierObjectId, ObjectId, ObjectRef, ObjectVersionId};
use openehr::rm::common::{
Archetyped, AuditDetails, CommitError, Contribution, LocatableAttrs, OriginalVersion,
PartyIdentified, Version,
};
use openehr::rm::data_types::{CodePhrase, DvDateTime};
use openehr::rm::ehr::{Composition, Ehr};
use openehr::terminology::{audit_change_type, composition_category, version_lifecycle_state};
pub const RECORD: &str = "87284370-2D4B-4E3D-A3F3-F303D2F4F34B";
pub const SYSTEM: &str = "ehr1.example.org";
#[must_use]
pub fn sample_ehr() -> Ehr {
let uid = HierObjectId::from_uid_str(RECORD).expect("literal");
let status = ObjectRef::new(
"local",
"VERSIONED_EHR_STATUS",
ObjectId::HierObjectId(uid.clone()),
)
.expect("literal");
let access = ObjectRef::new(
"local",
"VERSIONED_EHR_ACCESS",
ObjectId::HierObjectId(uid.clone()),
)
.expect("literal");
Ehr::new(
HierObjectId::from_uid_str("11111111-2222-3333-4444-555555555555").expect("literal"),
uid,
status,
access,
DvDateTime::new("2026-08-01T09:00:00Z").expect("literal"),
)
.expect("literal")
}
#[must_use]
pub fn sample_composition(name: &str) -> Composition {
Composition::new(
LocatableAttrs::named(name, "openEHR-EHR-COMPOSITION.encounter.v1")
.expect("literal")
.with_archetype_details(
Archetyped::new("openEHR-EHR-COMPOSITION.encounter.v1", "1.1.0").expect("literal"),
),
composition_category::EVENT,
PartyIdentified::named("Dr A Nurse")
.expect("literal")
.into(),
CodePhrase::new("ISO_639-1", "en").expect("literal"),
CodePhrase::new("ISO_3166-1", "GB").expect("literal"),
)
.expect("literal")
}
#[must_use]
pub fn sample_version(n: u32, preceding: Option<u32>, minute: u32) -> Version<Composition> {
let id = |v: u32| -> ObjectVersionId {
format!("{RECORD}::{SYSTEM}::{v}").parse().expect("literal")
};
let owner = ObjectRef::new(
"local",
"EHR",
ObjectId::HierObjectId(HierObjectId::from_uid_str(RECORD).expect("literal")),
)
.expect("literal");
let audit = AuditDetails::new(
SYSTEM,
DvDateTime::new(&format!("2026-08-01T09:{minute:02}:00Z")).expect("literal"),
if preceding.is_none() {
audit_change_type::CREATION
} else {
audit_change_type::AMENDMENT
},
PartyIdentified::named("Dr A Nurse")
.expect("literal")
.into(),
)
.expect("literal");
OriginalVersion::new(
id(n),
preceding.map(id),
version_lifecycle_state::COMPLETE,
Some(sample_composition(&format!("Encounter {n}"))),
audit,
owner,
)
.expect("literal")
.into()
}
#[must_use]
pub fn sample_contribution(uid: &str, versions: &[u32]) -> Contribution {
Contribution::new(
HierObjectId::from_uid_str(uid).expect("literal"),
versions
.iter()
.map(|v| format!("{RECORD}::{SYSTEM}::{v}").parse().expect("literal"))
.collect(),
AuditDetails::new(
SYSTEM,
DvDateTime::new("2026-08-01T09:05:00Z").expect("literal"),
audit_change_type::CREATION,
PartyIdentified::named("Dr A Nurse")
.expect("literal")
.into(),
)
.expect("literal"),
)
.expect("literal")
}
#[allow(clippy::too_many_lines)]
pub fn run<S: Store>(store: &mut S) -> Result<()> {
let engine = store.engine();
store.install()?;
store.install()?;
let ehr = sample_ehr();
let ehr_id = ehr.ehr_id().clone();
store.create_ehr(&ehr)?;
let round_tripped = store.get_ehr(&ehr_id)?;
assert_eq!(round_tripped, ehr, "{engine}: an EHR did not round-trip");
assert!(
matches!(
store.create_ehr(&ehr),
Err(crate::StoreError::Conflict { .. })
),
"{engine}: creating an EHR twice did not conflict"
);
let contribution_uid = "22222222-3333-4444-5555-666666666666";
store.create_contribution(&ehr_id, &sample_contribution(contribution_uid, &[1, 2]))?;
let first = store.commit_composition(&ehr_id, &sample_version(1, None, 5), contribution_uid)?;
assert!(
first.created_container,
"{engine}: first commit did not create a container"
);
assert!(
matches!(
store.commit_composition(&ehr_id, &sample_version(1, None, 6), contribution_uid),
Err(crate::StoreError::Commit(CommitError::DuplicateVersion))
),
"{engine}: a duplicate version id was accepted"
);
let rootless = |container: &str| -> Version<Composition> {
let mut value: serde_json::Value =
serde_json::to_value(sample_version(2, Some(1), 7)).expect("the fixture serialises");
value
.as_object_mut()
.expect("a version is an object")
.remove("preceding_version_uid");
serde_json::from_str(
&serde_json::to_string(&value)
.expect("json")
.replace(RECORD, container),
)
.expect("deserialization is lenient by design")
};
assert!(
matches!(
store.commit_composition(
&ehr_id,
&rootless("3F2504E0-4F89-11D3-9A0C-0305E82C3301"),
contribution_uid
),
Err(crate::StoreError::Invalid(_))
),
"{engine}: a rootless successor was accepted into an empty container"
);
assert!(
matches!(
store.commit_composition(&ehr_id, &rootless(RECORD), contribution_uid),
Err(crate::StoreError::Invalid(_)
| crate::StoreError::Commit(CommitError::PrecedingVersionMismatch))
),
"{engine}: a rootless successor was accepted"
);
let second =
store.commit_composition(&ehr_id, &sample_version(2, Some(1), 10), contribution_uid)?;
assert!(!second.created_container);
assert!(
matches!(
store.commit_composition(&ehr_id, &sample_version(3, Some(1), 12), contribution_uid),
Err(crate::StoreError::Commit(CommitError::NotLatest))
),
"{engine}: a stale predecessor was accepted — concurrent writes are being lost"
);
let container = HierObjectId::from_uid_str(RECORD)?;
let latest = store.latest_version(&container)?;
assert_eq!(latest.trunk_version, 2, "{engine}: wrong head version");
let all = store.all_versions(&container)?;
assert_eq!(all.len(), 2, "{engine}: wrong version count");
assert_eq!(
all[0].trunk_version, 1,
"{engine}: all_versions must be oldest first (V8.7a)"
);
let by_id: ObjectVersionId = format!("{RECORD}::{SYSTEM}::1").parse()?;
let one = store.get_version(&by_id)?;
assert_eq!(one.uid, by_id.to_string());
assert!(one.data_json.is_some(), "{engine}: content was not stored");
assert!(!one.is_deleted);
assert_eq!(one.audit_change_type_code, audit_change_type::CREATION);
assert_eq!(
one.audit_time_committed.text, "2026-08-01T09:05:00Z",
"{engine}: the authoritative lexical form of a commit time was altered"
);
assert!(
one.audit_time_committed.utc_seconds.is_some(),
"{engine}: an anchored instant produced no derived value"
);
let at_0907 = store.version_at_time(&container, &DvDateTime::new("2026-08-01T09:07:00Z")?)?;
assert_eq!(
at_0907.trunk_version, 1,
"{engine}: version_at_time went forwards"
);
let at_0920 = store.version_at_time(&container, &DvDateTime::new("2026-08-01T09:20:00Z")?)?;
assert_eq!(at_0920.trunk_version, 2);
assert!(
matches!(
store.version_at_time(&container, &DvDateTime::new("2026-08-01T08:00:00Z")?),
Err(crate::StoreError::NotFound { .. })
),
"{engine}: version_at_time invented a version before the record existed"
);
let found =
store.find_compositions_by_archetype(&ehr_id, "openEHR-EHR-COMPOSITION.encounter.v1")?;
assert_eq!(
found.len(),
2,
"{engine}: archetype index did not find both versions"
);
assert_eq!(found[0].category_code, composition_category::EVENT);
assert_eq!(found[0].language_code, "en");
assert_eq!(found[0].composer_name.as_deref(), Some("Dr A Nurse"));
let none =
store.find_compositions_by_archetype(&ehr_id, "openEHR-EHR-COMPOSITION.report.v1")?;
assert!(
none.is_empty(),
"{engine}: archetype index matched the wrong archetype"
);
let chained = store.all_versions(&container)?;
assert!(
chained.len() >= 2,
"{engine}: the chain assertions need at least two versions"
);
assert_eq!(
chained[0].chain.previous, [0u8; 32],
"{engine}: the first version must link to the genesis digest"
);
for pair in chained.windows(2) {
assert_eq!(
pair[1].chain.previous, pair[0].chain.digest,
"{engine}: version {} does not link to {}",
pair[1].uid, pair[0].uid
);
}
for row in &chained {
assert_ne!(
row.chain.digest, [0u8; 32],
"{engine}: a version has no chain digest"
);
assert_ne!(
row.chain.content, row.chain.digest,
"{engine}: the content digest and the entry digest must differ"
);
}
let verdict = crate::integrity::verify_versions(&chained, &[]);
assert!(
verdict.is_intact(),
"{engine}: a freshly written history did not verify: {verdict:?}"
);
assert_eq!(
verdict,
crate::integrity::Integrity::Unkeyed,
"{engine}: an unsigned chain must not report as Verified"
);
assert!(
chained.iter().any(|row| row.data_json.is_some()),
"{engine}: the fixture must include content for the digest to cover"
);
let checkpoint = store.chain_checkpoint(&container)?;
assert!(
checkpoint.starts_with(&format!("entries={} ", chained.len())),
"{engine}: the checkpoint must count the versions it covers: {checkpoint}"
);
assert!(
!checkpoint.contains("Encounter"),
"{engine}: a checkpoint must carry no clinical content: {checkpoint}"
);
let head = store.latest_version(&container)?;
assert!(
head.audit_description.is_none(),
"{engine}: an absent audit description must read back absent"
);
assert!(
head.signature.is_none(),
"{engine}: an absent signature must read back absent"
);
assert!(
head.attestations_json.is_none(),
"{engine}: no attestations must be NULL, not an empty array"
);
assert!(
head.other_input_version_uids_json.is_none(),
"{engine}: a non-merge must be NULL, not an empty array"
);
let absent = HierObjectId::from_uid_str("99999999-9999-4999-8999-999999999999")?;
assert!(matches!(
store.get_ehr(&absent),
Err(crate::StoreError::NotFound { .. })
));
assert!(matches!(
store.latest_version(&absent),
Err(crate::StoreError::NotFound { .. })
));
Ok(())
}
pub fn check_dialect<D: Dialect + ?Sized>(dialect: &D) {
let name = dialect.name();
let statements = dialect.ddl();
let index_statements = if dialect.index_idempotence() == crate::Idempotence::Inline {
0
} else {
crate::TABLES.iter().map(|t| t.indexes.len()).sum::<usize>()
};
assert_eq!(
statements.len(),
crate::TABLES.len()
+ index_statements
+ crate::TABLES
.iter()
.filter(|t| t.append_only)
.map(|t| dialect.append_only_sql(t).len())
.sum::<usize>(),
"{name}: unexpected statement count"
);
let json_column = dialect.col_sql(crate::ColTy::Json).to_ascii_lowercase();
for normalizing in ["jsonb", "json"] {
assert_ne!(
json_column, normalizing,
"{name}: ColTy::Json is `{json_column}`, a type whose contract permits \
reordering keys or rewriting numbers; canonical JSON must round-trip \
byte for byte (M3.43, D-08)"
);
}
for (kind, idempotence) in [
(crate::ObjectKind::Table, dialect.table_idempotence()),
(crate::ObjectKind::Index, dialect.index_idempotence()),
] {
if idempotence == crate::Idempotence::Guard {
let bare = "CREATE SOMETHING x";
assert_ne!(
dialect.guard(kind, "x", bare),
bare,
"{name}: declares Guard for {kind:?} but guard() does not wrap"
);
}
}
for table in crate::TABLES.iter().filter(|t| t.append_only) {
assert!(
!dialect.append_only_sql(table).is_empty(),
"{name}: {} is append-only but the dialect enforces nothing",
table.name
);
}
let script_all = crate::ddl_script(dialect);
for table in crate::TABLES {
for index in table.indexes {
assert!(
script_all.contains(&dialect.quote(index.name)),
"{name}: index {} never reaches the DDL",
index.name
);
}
}
let script = crate::ddl_script(dialect);
for table in crate::TABLES {
assert!(
script.contains(&dialect.quote(table.name)),
"{name}: {} is missing from the DDL",
table.name
);
for column in table.columns {
assert!(
script.contains(&dialect.quote(column.name)),
"{name}: {}.{} is missing",
table.name,
column.name
);
}
}
for ty in [
crate::ColTy::Id(255),
crate::ColTy::Text(255),
crate::ColTy::LongText,
crate::ColTy::Json,
crate::ColTy::Instant,
crate::ColTy::InstantUtc,
crate::ColTy::Int,
crate::ColTy::Bool,
] {
assert!(
!dialect.col_sql(ty).is_empty(),
"{name}: {ty:?} maps to nothing"
);
}
assert_ne!(
dialect.col_sql(crate::ColTy::Instant),
dialect.col_sql(crate::ColTy::InstantUtc),
"{name}: the authoritative and derived instant columns have the same type, \
so the lexical form is not being preserved (D3.10)"
);
}
pub fn dialects_are_distinct(dialects: &[&dyn Dialect]) {
for (i, a) in dialects.iter().enumerate() {
for b in dialects.iter().skip(i + 1) {
assert_ne!(
crate::ddl_script(*a),
crate::ddl_script(*b),
"{} and {} emit identical DDL — one of them is not its own engine",
a.name(),
b.name()
);
}
}
}
pub fn check_quote<D: Dialect + ?Sized>(dialect: &D, identifier: &str) {
let empty = dialect.quote("");
let mut delim = empty.chars();
let (Some(open), Some(close)) = (delim.next(), empty.chars().last()) else {
panic!("{}: quote(\"\") produced no delimiters", dialect.name());
};
let quoted = dialect.quote(identifier);
assert!(
quoted.starts_with(open),
"{}: quote({identifier:?}) does not open with {open:?}",
dialect.name()
);
assert!(
quoted.len() > open.len_utf8(),
"{}: quote({identifier:?}) is shorter than its own delimiters",
dialect.name()
);
assert!(
quoted.ends_with(close),
"{}: quote({identifier:?}) does not close with {close:?}",
dialect.name()
);
let body = "ed[open.len_utf8()..quoted.len() - close.len_utf8()];
let mut chars = body.chars();
let mut unescaped = String::with_capacity(body.len());
while let Some(c) = chars.next() {
if c == close {
assert_eq!(
chars.next(),
Some(close),
"{}: quote({identifier:?}) leaves an unescaped {close:?} in the body — \
the identifier terminates its own quoting",
dialect.name()
);
}
unescaped.push(c);
}
assert_eq!(
unescaped,
identifier,
"{}: quote({identifier:?}) does not round-trip",
dialect.name()
);
}
pub fn check_col_sql<D: Dialect + ?Sized>(dialect: &D, ty: crate::ColTy) {
let sql = dialect.col_sql(ty);
assert!(
!sql.trim().is_empty(),
"{}: {ty:?} maps to an empty SQL type",
dialect.name()
);
assert!(
!sql.contains('\n'),
"{}: {ty:?} maps to a multi-line SQL type: {sql:?}",
dialect.name()
);
if matches!(ty, crate::ColTy::Instant) {
assert_ne!(
sql,
dialect.col_sql(crate::ColTy::InstantUtc),
"{}: the authoritative and derived instant columns share a type",
dialect.name()
);
}
}
#[cfg(test)]
mod property_tests {
use super::{check_col_sql, check_quote};
use crate::{ColTy, Dialect, Placeholder};
struct Unescaping;
impl Dialect for Unescaping {
fn name(&self) -> &'static str {
"unescaping"
}
fn col_sql(&self, _ty: ColTy) -> String {
"TEXT".to_owned()
}
fn quote(&self, identifier: &str) -> String {
format!("\"{identifier}\"")
}
fn placeholder(&self) -> Placeholder {
Placeholder::Question
}
}
#[test]
fn an_identifier_with_no_delimiter_is_accepted() {
check_quote(&Unescaping, "openehr_version");
check_quote(&Unescaping, "");
}
#[test]
#[should_panic(expected = "unescaped")]
fn an_identifier_that_escapes_its_own_quoting_is_caught() {
check_quote(&Unescaping, "a\"; DROP TABLE openehr_version; --");
}
struct CollapsedInstants;
impl Dialect for CollapsedInstants {
fn name(&self) -> &'static str {
"collapsed"
}
fn col_sql(&self, _ty: ColTy) -> String {
"TIMESTAMP".to_owned()
}
fn quote(&self, identifier: &str) -> String {
format!("\"{}\"", identifier.replace('"', "\"\""))
}
fn placeholder(&self) -> Placeholder {
Placeholder::Question
}
}
#[test]
#[should_panic(expected = "share a type")]
fn collapsing_the_two_instant_columns_is_caught() {
check_col_sql(&CollapsedInstants, ColTy::Instant);
}
struct EmptyType;
impl Dialect for EmptyType {
fn name(&self) -> &'static str {
"empty"
}
fn col_sql(&self, _ty: ColTy) -> String {
String::new()
}
fn quote(&self, identifier: &str) -> String {
format!("\"{}\"", identifier.replace('"', "\"\""))
}
fn placeholder(&self) -> Placeholder {
Placeholder::Question
}
}
#[test]
#[should_panic(expected = "empty SQL type")]
fn a_type_that_maps_to_nothing_is_caught() {
check_col_sql(&EmptyType, ColTy::Json);
}
#[test]
fn every_real_dialect_quotes_the_adversarial_identifiers() {
for id in [
"",
"openehr_version",
"\"",
"``",
"]",
"[",
"a\"; DROP TABLE openehr_version; --",
"a`; DROP TABLE openehr_version; --",
"a]; DROP TABLE openehr_version; --",
"\u{0}",
"\u{1F600}",
] {
check_quote(&CollapsedInstants, id);
}
}
}