openehr_postgresql/lib.rs
1//! openEHR persistence for **`PostgreSQL` 18**.
2//!
3//! This crate supplies one [`Dialect`]. Everything else — the storage model,
4//! the projection from openEHR objects onto rows, the commit rules, the
5//! conformance suite — lives in [`openehr_store`], which all five engine crates
6//! share. A dialect owns four things and no more: type spellings, identifier
7//! quoting, placeholder style, and how the engine enforces append-only.
8//!
9//! # Conformance level: **Schema**
10//!
11//! This crate emits DDL, and that DDL has been executed against
12//! **`PostgreSQL` 18**: five tables and seven indexes created, the script
13//! re-applied as a no-op, foreign keys enforced, and both append-only tables
14//! observed refusing `UPDATE` and `DELETE` with a row present and unchanged
15//! afterwards. `openehr-store/scripts/verify-schema.sh postgresql` reproduces
16//! it from a fresh container.
17//!
18//! It does **not** contain a store: there is no driver dependency, no
19//! connection handling, and no implementation of [`openehr_store::Store`].
20//!
21//! That is stated plainly because the sibling FHIR monorepo in this repository
22//! carries an audit finding (**F-01**) for six READMEs that claimed a working
23//! store, a CLI, and 7,399 round-tripped resources in ports where none of it
24//! existed. See `spec/conformance.md` for what each level means.
25//!
26//! ```
27//! use openehr_postgresql::PostgresqlDialect;
28//! use openehr_store::{ColTy, Dialect, ddl_script};
29//!
30//! let sql = ddl_script(&PostgresqlDialect);
31//! assert!(sql.contains("CREATE TABLE IF NOT EXISTS \"openehr_version\""));
32//! // Canonical JSON is text, not `jsonb` — see `col_sql` (`M3.43`, `D-08`).
33//! assert_eq!(PostgresqlDialect.col_sql(ColTy::Json), "text");
34//! assert_eq!(PostgresqlDialect.col_sql(ColTy::InstantUtc), "timestamptz");
35//! // …while the authoritative one is text, so the lexical form survives.
36//! assert_eq!(PostgresqlDialect.col_sql(ColTy::Instant), "text");
37//! ```
38
39use openehr_store::schema::Table;
40use openehr_store::{ColTy, Dialect, Placeholder};
41
42/// The `PostgreSQL` dialect.
43#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
44pub struct PostgresqlDialect;
45
46impl Dialect for PostgresqlDialect {
47 fn name(&self) -> &'static str {
48 "PostgreSQL"
49 }
50
51 // `ColTy::Json` and the general text arm now spell the same type, and they
52 // stay separate arms (`M3.43`). They coincide for unrelated reasons: one is
53 // "this engine has no reason to bound these", the other is "canonical JSON
54 // must return the bytes it was given". Merging them would delete the second
55 // reason and silently couple the two, so that changing the text arm would
56 // move the JSON column with it — which is how `D-08` happened in reverse.
57 #[allow(clippy::match_same_arms)]
58 fn col_sql(&self, ty: ColTy) -> String {
59 match ty {
60 // `text`, not `varchar(n)`. PostgreSQL stores both identically and
61 // `varchar(n)` only adds a length check that would reject a long
62 // but legal `ARCHETYPE_ID` — and rejecting conformant data is a
63 // worse failure than storing a few extra bytes.
64 ColTy::Id(_) | ColTy::Text(_) | ColTy::LongText | ColTy::Instant => "text",
65 // `text`, and emphatically **not** `jsonb` (`M3.43`).
66 //
67 // This said `jsonb`, reasoning that the byte form never had to come
68 // back out because the canonical form was regenerated from the
69 // parsed object. That stopped being true when `D-07` wired the
70 // chain: the content digest is SHA-256 over the canonical bytes, so
71 // those bytes must be reproducible *from storage*.
72 //
73 // `jsonb` reorders keys and inserts whitespace — measured on
74 // PostgreSQL 18, not assumed — so what came back was a different
75 // document that happened to be equivalent, and the digest could not
76 // be recomputed. Canonical means these bytes in this order
77 // (`lib:J9.12`).
78 //
79 // What this gives up is `jsonb` operators and GIN indexing over
80 // content. Real, and unused: the relational columns are this
81 // schema's index and the JSON is never queried as structure
82 // (`M3.20`).
83 ColTy::Json => "text",
84 ColTy::InstantUtc => "timestamptz",
85 ColTy::Int => "bigint",
86 ColTy::Bool => "boolean",
87 // `bytea`, PostgreSQL's only binary type. Fixed width is not
88 // expressible, so length is enforced in Rust (`M3.41` departure).
89 ColTy::Digest => "bytea",
90 }
91 .to_owned()
92 }
93
94 fn quote(&self, identifier: &str) -> String {
95 format!("\"{}\"", identifier.replace('"', "\"\""))
96 }
97
98 fn placeholder(&self) -> Placeholder {
99 Placeholder::Dollar
100 }
101
102 fn append_only_sql(&self, table: &Table) -> Vec<String> {
103 // Enforced in the database, not only in the application. A guarantee
104 // that lives in application code ends the first time somebody opens
105 // psql, and openEHR's whole change-control model rests on this one
106 // (`V8.10`).
107 let name = self.quote(table.name);
108 let function = format!("openehr_refuse_mutation_{}", table.name);
109 vec![
110 format!(
111 "CREATE OR REPLACE FUNCTION {}() RETURNS trigger AS $$\n\
112 BEGIN\n \
113 RAISE EXCEPTION '{} is append-only (openEHR V8.10)';\n\
114 END;\n$$ LANGUAGE plpgsql",
115 self.quote(&function),
116 table.name
117 ),
118 format!(
119 "CREATE OR REPLACE TRIGGER {} BEFORE UPDATE OR DELETE ON {} \
120 FOR EACH ROW EXECUTE FUNCTION {}()",
121 self.quote(&format!("trg_append_only_{}", table.name)),
122 name,
123 self.quote(&function)
124 ),
125 ]
126 }
127}