use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
pub enum ColTy {
Id(u16),
Text(u16),
LongText,
Json,
Instant,
InstantUtc,
Int,
Bool,
}
#[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)",
),
],
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 TABLES: &[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
);
}
}
}
}
#[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");
}
}