use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
pub enum ColTy {
Id(u16),
Text(u16),
LongText,
Json,
Instant,
InstantUtc,
Int,
Bool,
Digest,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Column {
pub name: &'static str,
pub ty: ColTy,
pub nullable: bool,
pub note: &'static str,
}
impl Column {
#[must_use]
pub const fn required(name: &'static str, ty: ColTy, note: &'static str) -> Self {
Self {
name,
ty,
nullable: false,
note,
}
}
#[must_use]
pub const fn optional(name: &'static str, ty: ColTy, note: &'static str) -> Self {
Self {
name,
ty,
nullable: true,
note,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ForeignKey {
pub column: &'static str,
pub table: &'static str,
pub references: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Index {
pub name: &'static str,
pub columns: &'static [&'static str],
pub unique: bool,
pub note: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Table {
pub name: &'static str,
pub note: &'static str,
pub columns: &'static [Column],
pub primary_key: &'static [&'static str],
pub foreign_keys: &'static [ForeignKey],
pub indexes: &'static [Index],
pub append_only: bool,
}
pub const EHR: Table = Table {
name: "openehr_ehr",
note: "One row per EHR. Holds only what the EHR class itself fixes; \
everything else is a reference, as in the model (E6.1).",
columns: &[
Column::required("ehr_id", ColTy::Id(255), "HIER_OBJECT_ID of the record"),
Column::required("system_id", ColTy::Id(255), "the system managing it"),
Column::required(
"time_created_text",
ColTy::Instant,
"authoritative: the exact ISO 8601 form",
),
Column::optional(
"time_created_utc",
ColTy::InstantUtc,
"derived for ordering; NULL when the instant is not established",
),
Column::required(
"ehr_status_uid",
ColTy::Id(255),
"versioned object holding EHR_STATUS",
),
Column::required(
"ehr_access_uid",
ColTy::Id(255),
"versioned object holding EHR_ACCESS",
),
],
primary_key: &["ehr_id"],
foreign_keys: &[],
indexes: &[],
append_only: false,
};
pub const VERSIONED_OBJECT: Table = Table {
name: "openehr_versioned_object",
note: "One row per VERSIONED_OBJECT. `rm_type` says what the versions \
contain — COMPOSITION, EHR_STATUS, FOLDER — because openEHR versions \
all of them the same way.",
columns: &[
Column::required("uid", ColTy::Id(255), "HIER_OBJECT_ID of the container"),
Column::required("ehr_id", ColTy::Id(255), "owning record"),
Column::required(
"rm_type",
ColTy::Id(64),
"COMPOSITION | EHR_STATUS | EHR_ACCESS | FOLDER",
),
Column::required(
"time_created_text",
ColTy::Instant,
"authoritative lexical form",
),
Column::optional("time_created_utc", ColTy::InstantUtc, "derived"),
],
primary_key: &["uid"],
foreign_keys: &[ForeignKey {
column: "ehr_id",
table: "openehr_ehr",
references: "ehr_id",
}],
indexes: &[Index {
name: "ix_versioned_object_ehr",
columns: &["ehr_id", "rm_type"],
unique: false,
note: "list a record's compositions without scanning every version",
}],
append_only: false,
};
pub const VERSION: Table = Table {
name: "openehr_version",
note: "One row per VERSION. Append-only: a correction is a new row, and the \
row it corrects stays (V8.10). The version identity is stored \
decomposed because the commit rules are checked on its parts (V8.1).",
columns: &[
Column::required(
"uid",
ColTy::Id(255),
"full OBJECT_VERSION_ID, object::system::tree",
),
Column::required("versioned_object_uid", ColTy::Id(255), "container"),
Column::required(
"creating_system_id",
ColTy::Id(255),
"the middle part of the version id — what keeps two offline systems' \
version 2 distinct",
),
Column::required("trunk_version", ColTy::Int, "version tree trunk number"),
Column::optional("branch_number", ColTy::Int, "NULL on the trunk"),
Column::optional("branch_version", ColTy::Int, "NULL on the trunk"),
Column::optional(
"preceding_version_uid",
ColTy::Id(255),
"NULL only for the first version (V8.3)",
),
Column::required(
"lifecycle_state_code",
ColTy::Id(16),
"openEHR version_lifecycle_state code",
),
Column::required(
"is_deleted",
ColTy::Bool,
"derived from lifecycle_state; indexed so 'current content' does not \
need a code comparison",
),
Column::required("contribution_uid", ColTy::Id(255), "the change set"),
Column::required(
"audit_system_id",
ColTy::Text(255),
"AUDIT_DETAILS.system_id",
),
Column::required(
"audit_change_type_code",
ColTy::Id(16),
"openEHR audit_change_type code",
),
Column::optional(
"audit_committer_name",
ColTy::Text(255),
"NULL for an anonymous PARTY_SELF committer — which is legitimate \
(M5.16), not missing data",
),
Column::required(
"audit_time_committed_text",
ColTy::Instant,
"authoritative lexical form",
),
Column::optional(
"audit_time_committed_utc",
ColTy::InstantUtc,
"derived; NULL when the commit time carries no UTC offset",
),
Column::optional(
"data_json",
ColTy::Json,
"canonical JSON of the version's content; NULL only when the version \
is a logical deletion (V8.9)",
),
Column::optional(
"audit_description",
ColTy::LongText,
"AUDIT_DETAILS.description — the free-text reason for a change, often \
the only record of why a correction exists",
),
Column::optional(
"signature",
ColTy::LongText,
"VERSION.signature — carried, never verified (S1.11)",
),
Column::optional(
"attestations_json",
ColTy::Json,
"ORIGINAL_VERSION.attestations, canonical JSON; NULL when there are \
none. A clinician's assertion that content is what they signed off",
),
Column::optional(
"other_input_version_uids_json",
ColTy::Json,
"ORIGINAL_VERSION.other_input_version_uids, canonical JSON; NULL when \
this version is not a merge",
),
Column::required(
"chain_previous",
ColTy::Digest,
"digest of the preceding version's chain entry, or the genesis digest",
),
Column::required(
"chain_content",
ColTy::Digest,
"SHA-256 over the canonical form of this version's content",
),
Column::required(
"chain_digest",
ColTy::Digest,
"this entry's own digest, over previous || content || uid",
),
Column::optional(
"chain_tag_key_id",
ColTy::Text(255),
"which key produced the tag; NULL when the chain is unkeyed",
),
Column::optional(
"chain_tag_mac",
ColTy::Digest,
"HMAC-SHA-256 over the same pre-image; NULL when unkeyed. An unkeyed \
digest over a published pre-image is reproducible by anyone who can \
write the rows it covers",
),
],
primary_key: &["uid"],
foreign_keys: &[ForeignKey {
column: "versioned_object_uid",
table: "openehr_versioned_object",
references: "uid",
}],
indexes: &[
Index {
name: "ix_version_container_trunk",
columns: &[
"versioned_object_uid",
"trunk_version",
"branch_number",
"branch_version",
],
unique: true,
note: "one row per position in a version tree; also the uniqueness \
that makes a duplicate commit fail in the database and not \
only in the library (V8.2)",
},
Index {
name: "ix_version_time",
columns: &["versioned_object_uid", "audit_time_committed_utc"],
unique: false,
note: "version_at_time without scanning a container's whole history \
(V8.6)",
},
Index {
name: "ix_version_preceding",
columns: &["preceding_version_uid"],
unique: false,
note: "walk a version tree forwards",
},
],
append_only: true,
};
pub const CONTRIBUTION: Table = Table {
name: "openehr_contribution",
note: "One row per CONTRIBUTION — the unit a user recognises as 'I saved \
the consultation', which is one change set over several versions.",
columns: &[
Column::required("uid", ColTy::Id(255), "HIER_OBJECT_ID"),
Column::required("ehr_id", ColTy::Id(255), "owning record"),
Column::required(
"audit_change_type_code",
ColTy::Id(16),
"restricted to creation | amendment | deleted (V8.15)",
),
Column::required("audit_system_id", ColTy::Text(255), ""),
Column::optional("audit_committer_name", ColTy::Text(255), ""),
Column::required("audit_time_committed_text", ColTy::Instant, "authoritative"),
Column::optional("audit_time_committed_utc", ColTy::InstantUtc, "derived"),
],
primary_key: &["uid"],
foreign_keys: &[ForeignKey {
column: "ehr_id",
table: "openehr_ehr",
references: "ehr_id",
}],
indexes: &[Index {
name: "ix_contribution_ehr_time",
columns: &["ehr_id", "audit_time_committed_utc"],
unique: false,
note: "a record's change history in commit order",
}],
append_only: true,
};
pub const COMPOSITION_INDEX: Table = Table {
name: "openehr_composition_index",
note: "The RM-level projection of a COMPOSITION version. Every column here \
is an attribute the Reference Model fixes, so it can be indexed \
without an archetype (see the module header). Anything archetype-\
defined stays in the JSON.",
columns: &[
Column::required("version_uid", ColTy::Id(255), "the version indexed"),
Column::required("ehr_id", ColTy::Id(255), "owning record"),
Column::required(
"archetype_id",
ColTy::Id(255),
"COMPOSITION.archetype_details.archetype_id — the commonest AQL \
predicate there is",
),
Column::optional("template_id", ColTy::Id(255), "if a template was used"),
Column::required(
"category_code",
ColTy::Id(16),
"persistent | event | episodic | report",
),
Column::optional("composer_name", ColTy::Text(255), "NULL when anonymous"),
Column::required("language_code", ColTy::Id(32), "ISO 639-1"),
Column::required("territory_code", ColTy::Id(32), "ISO 3166-1"),
Column::optional("setting_code", ColTy::Id(16), "EVENT_CONTEXT.setting"),
Column::optional(
"context_start_text",
ColTy::Instant,
"authoritative lexical form",
),
Column::optional("context_start_utc", ColTy::InstantUtc, "derived"),
Column::optional("context_end_text", ColTy::Instant, "authoritative"),
Column::optional("context_end_utc", ColTy::InstantUtc, "derived"),
],
primary_key: &["version_uid"],
foreign_keys: &[ForeignKey {
column: "version_uid",
table: "openehr_version",
references: "uid",
}],
indexes: &[
Index {
name: "ix_composition_archetype",
columns: &["ehr_id", "archetype_id"],
unique: false,
note: "AQL's `CONTAINS COMPOSITION c[openEHR-EHR-COMPOSITION.x.v1]`",
},
Index {
name: "ix_composition_context_start",
columns: &["ehr_id", "context_start_utc"],
unique: false,
note: "encounters in a date range — the second commonest AQL filter",
},
],
append_only: false,
};
pub const SCHEMA_VERSION: i64 = 4;
pub const SCHEMA_VERSION_TABLE: Table = Table {
name: "openehr_schema_version",
note: "One row. The schema version this database was installed under, so a \
mismatched binary refuses rather than half-working.",
columns: &[
Column::required("version", ColTy::Int, "matches SCHEMA_VERSION"),
Column::required(
"applied_text",
ColTy::Instant,
"authoritative: when this version was applied",
),
Column::optional("applied_utc", ColTy::InstantUtc, "derived"),
],
primary_key: &["version"],
foreign_keys: &[],
indexes: &[],
append_only: false,
};
pub const TABLES: &[Table] = &[
SCHEMA_VERSION_TABLE,
EHR,
VERSIONED_OBJECT,
VERSION,
CONTRIBUTION,
COMPOSITION_INDEX,
];
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
#[test]
fn every_table_is_internally_consistent() {
let mut names = HashSet::new();
for table in TABLES {
assert!(names.insert(table.name), "duplicate table {}", table.name);
let columns: HashSet<&str> = table.columns.iter().map(|c| c.name).collect();
assert_eq!(
columns.len(),
table.columns.len(),
"duplicate column in {}",
table.name
);
for key in table.primary_key {
assert!(
columns.contains(key),
"{}: pk {key} is not a column",
table.name
);
}
for fk in table.foreign_keys {
assert!(
columns.contains(fk.column),
"{}: fk {} is not a column",
table.name,
fk.column
);
}
for index in table.indexes {
for column in index.columns {
assert!(
columns.contains(column),
"{}: index {} names {column}, which is not a column",
table.name,
index.name
);
}
}
}
}
#[allow(clippy::match_same_arms)]
const fn every_engine_can_search(ty: ColTy) -> bool {
match ty {
ColTy::Id(_) | ColTy::Text(_) => true,
ColTy::Digest => true,
ColTy::Instant | ColTy::InstantUtc | ColTy::Int | ColTy::Bool => true,
ColTy::LongText | ColTy::Json => false,
}
}
#[test]
fn every_indexed_column_is_one_every_engine_can_search() {
for table in TABLES {
for index in table.indexes {
for name in index.columns {
let column = table
.columns
.iter()
.find(|c| c.name == *name)
.expect("checked by every_table_is_internally_consistent");
assert!(
every_engine_can_search(column.ty),
"{}: index {} covers {name}, whose type {:?} cannot be \
indexed or compared on every engine. Either index a \
different column or specify its adjuncts \
(spec/databases/search-adjuncts.md AD16), and note that \
no adjunct is emitted anywhere yet (db:P6.18).",
table.name,
index.name,
column.ty
);
}
}
}
}
#[test]
fn every_index_records_the_query_it_exists_for() {
for table in TABLES {
for index in table.indexes {
assert!(
!index.note.trim().is_empty(),
"{}: index {} records no query",
table.name,
index.name
);
}
}
}
#[test]
fn foreign_keys_only_point_backwards() {
let mut seen: HashSet<&str> = HashSet::new();
for table in TABLES {
for fk in table.foreign_keys {
assert!(
seen.contains(fk.table) || fk.table == table.name,
"{} references {} before it is defined",
table.name,
fk.table
);
}
seen.insert(table.name);
}
}
#[test]
fn every_instant_has_a_derived_partner_and_the_partner_is_nullable() {
for table in TABLES {
for column in table.columns {
if let Some(stem) = column.name.strip_suffix("_text") {
let partner = format!("{stem}_utc");
let found = table
.columns
.iter()
.find(|c| c.name == partner)
.unwrap_or_else(|| {
panic!("{}: {} has no {partner}", table.name, column.name)
});
assert_eq!(found.ty, ColTy::InstantUtc);
assert!(found.nullable, "{}: {partner} must be nullable", table.name);
assert_eq!(column.ty, ColTy::Instant);
}
}
}
}
#[allow(clippy::assertions_on_constants)]
#[test]
fn the_version_table_is_append_only() {
assert!(VERSION.append_only);
assert!(CONTRIBUTION.append_only);
assert!(!EHR.append_only, "an EHR's status references do change");
}
}