Skip to main content

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}