Skip to main content

openehr_sqlite/
store.rs

1//! The `SQLite` store.
2
3use crate::dialect::SqliteDialect;
4use openehr::base::{HierObjectId, ObjectId, ObjectRef, ObjectVersionId};
5use openehr::rm::common::{CommitError, Contribution, Version};
6use openehr::rm::data_types::DvDateTime;
7use openehr::rm::ehr::{Composition, Ehr};
8use openehr::validation::Validate as _;
9use openehr_store::record::{CompositionIndexRow, StoredInstant, VersionRow};
10use openehr_store::{CommitOutcome, Result, Store, StoreError, ddl_script};
11use rusqlite::{Connection, OptionalExtension as _, params};
12
13/// The engine name used in errors.
14const ENGINE: &str = "SQLite";
15
16/// An openEHR repository in a `SQLite` database.
17///
18/// # Foreign keys are switched on explicitly
19///
20/// `SQLite` disables foreign-key enforcement by default, per connection, for
21/// backward compatibility. A store that did not enable it would accept a
22/// version pointing at a container that does not exist — and would do so
23/// silently, which is worse than not having the constraint, because the schema
24/// says it is there.
25pub struct SqliteStore {
26    connection: Connection,
27}
28
29impl SqliteStore {
30    /// Opens an in-memory database.
31    ///
32    /// # Errors
33    ///
34    /// Returns [`StoreError::Engine`] if `SQLite` cannot be opened or configured.
35    pub fn in_memory() -> Result<Self> {
36        Self::from_connection(Connection::open_in_memory().map_err(|e| engine(&e))?)
37    }
38
39    /// Opens a database file, creating it if absent.
40    ///
41    /// # Errors
42    ///
43    /// Returns [`StoreError::Engine`] if the file cannot be opened.
44    pub fn open(path: &std::path::Path) -> Result<Self> {
45        Self::from_connection(Connection::open(path).map_err(|e| engine(&e))?)
46    }
47
48    /// Wraps an existing connection.
49    ///
50    /// # Errors
51    ///
52    /// Returns [`StoreError::Engine`] if the connection cannot be configured.
53    pub fn from_connection(connection: Connection) -> Result<Self> {
54        connection
55            .execute_batch("PRAGMA foreign_keys = ON;")
56            .map_err(|e| engine(&e))?;
57        Ok(Self { connection })
58    }
59
60    /// The underlying connection, for callers that need a query this trait does
61    /// not offer.
62    #[must_use]
63    pub fn connection(&self) -> &Connection {
64        &self.connection
65    }
66
67    /// Reads a version row from a query row.
68    fn read_version(row: &rusqlite::Row<'_>) -> rusqlite::Result<VersionRow> {
69        Ok(VersionRow {
70            uid: row.get("uid")?,
71            versioned_object_uid: row.get("versioned_object_uid")?,
72            creating_system_id: row.get("creating_system_id")?,
73            trunk_version: row.get("trunk_version")?,
74            branch_number: row.get("branch_number")?,
75            branch_version: row.get("branch_version")?,
76            preceding_version_uid: row.get("preceding_version_uid")?,
77            lifecycle_state_code: row.get("lifecycle_state_code")?,
78            is_deleted: row.get::<_, i64>("is_deleted")? != 0,
79            contribution_uid: row.get("contribution_uid")?,
80            audit_system_id: row.get("audit_system_id")?,
81            audit_change_type_code: row.get("audit_change_type_code")?,
82            audit_committer_name: row.get("audit_committer_name")?,
83            audit_time_committed: StoredInstant {
84                text: row.get("audit_time_committed_text")?,
85                utc_seconds: row.get("audit_time_committed_utc")?,
86            },
87            data_json: row.get("data_json")?,
88        })
89    }
90
91    /// Every column of `openehr_version`, in one place so the two read paths
92    /// cannot select different sets.
93    const VERSION_COLUMNS: &'static str = "uid, versioned_object_uid, creating_system_id, \
94        trunk_version, branch_number, branch_version, preceding_version_uid, \
95        lifecycle_state_code, is_deleted, contribution_uid, audit_system_id, \
96        audit_change_type_code, audit_committer_name, audit_time_committed_text, \
97        audit_time_committed_utc, data_json";
98}
99
100/// Translates a uniqueness violation on the version table into the commit
101/// refusal it actually is.
102///
103/// The single-threaded path checks the commit rules before inserting, so this
104/// only fires under **concurrency**: two writers both read the same head, both
105/// pass the check, and the database refuses the second. That is the unique
106/// index of `db:H5.10` doing its job — the rule holds in the database and not
107/// only in the library.
108///
109/// Reporting it as `Engine` would satisfy the guarantee and fail the caller.
110/// `db:H5.9` requires refusals to be **distinguishable**: a caller told
111/// `Commit` knows another writer won and can re-read the head and retry, while
112/// a caller told "UNIQUE constraint failed" knows only that something went
113/// wrong — and a version tree is precisely where guessing is not allowed.
114///
115/// The two indexes mean different things and map differently:
116///
117/// - `openehr_version.uid` — the same version identity was committed twice.
118/// - `ix_version_container_trunk` — a *different* identity took that position
119///   in the tree, which is a concurrent modification rather than a duplicate.
120fn commit_conflict(error: &rusqlite::Error) -> Option<StoreError> {
121    use rusqlite::ErrorCode::ConstraintViolation;
122    let rusqlite::Error::SqliteFailure(code, Some(message)) = error else {
123        return None;
124    };
125    if code.code != ConstraintViolation {
126        return None;
127    }
128    if message.contains("openehr_version.uid") {
129        Some(StoreError::Commit(CommitError::DuplicateVersion))
130    } else if message.contains("ix_version_container_trunk") {
131        Some(StoreError::Commit(CommitError::NotLatest))
132    } else {
133        None
134    }
135}
136
137/// Wraps a driver error without letting row data into the message.
138fn engine(error: &rusqlite::Error) -> StoreError {
139    if let Some(conflict) = commit_conflict(error) {
140        return conflict;
141    }
142    StoreError::Engine {
143        engine: ENGINE,
144        // `to_string` on a rusqlite error gives the SQLite message, which names
145        // constraints and columns and not values. The one exception SQLite
146        // makes is a `CHECK` message, which is why this schema's constraints
147        // carry no interpolated values.
148        message: error.to_string(),
149    }
150}
151
152impl Store for SqliteStore {
153    fn engine(&self) -> &'static str {
154        ENGINE
155    }
156
157    fn install(&mut self) -> Result<()> {
158        self.connection
159            .execute_batch(&ddl_script(&SqliteDialect))
160            .map_err(|e| engine(&e))
161    }
162
163    fn create_ehr(&mut self, ehr: &Ehr) -> Result<()> {
164        let id = ehr.ehr_id().to_string();
165        let existing: Option<String> = self
166            .connection
167            .query_row(
168                "SELECT ehr_id FROM openehr_ehr WHERE ehr_id = ?1",
169                params![id],
170                |row| row.get(0),
171            )
172            .optional()
173            .map_err(|e| engine(&e))?;
174        if existing.is_some() {
175            return Err(StoreError::Conflict { kind: "ehr", id });
176        }
177        let created = StoredInstant::from_date_time(ehr.time_created().value());
178        self.connection
179            .execute(
180                "INSERT INTO openehr_ehr \
181                 (ehr_id, system_id, time_created_text, time_created_utc, ehr_status_uid, ehr_access_uid) \
182                 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
183                params![
184                    id,
185                    ehr.system_id().to_string(),
186                    created.text,
187                    created.utc_seconds,
188                    ehr.ehr_status().id().to_string(),
189                    ehr.ehr_access().id().to_string(),
190                ],
191            )
192            .map_err(|e| engine(&e))?;
193        Ok(())
194    }
195
196    fn get_ehr(&self, ehr_id: &HierObjectId) -> Result<Ehr> {
197        let id = ehr_id.to_string();
198        let row: Option<(String, String, String, String)> = self
199            .connection
200            .query_row(
201                "SELECT system_id, time_created_text, ehr_status_uid, ehr_access_uid \
202                 FROM openehr_ehr WHERE ehr_id = ?1",
203                params![id],
204                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
205            )
206            .optional()
207            .map_err(|e| engine(&e))?;
208        let Some((system_id, created, status_uid, access_uid)) = row else {
209            return Err(StoreError::NotFound { kind: "ehr", id });
210        };
211        let reference = |uid: &str, ty: &'static str| -> Result<ObjectRef> {
212            Ok(ObjectRef::new(
213                "local",
214                ty,
215                ObjectId::HierObjectId(uid.parse()?),
216            )?)
217        };
218        Ok(Ehr::new(
219            system_id.parse()?,
220            ehr_id.clone(),
221            reference(&status_uid, "VERSIONED_EHR_STATUS")?,
222            reference(&access_uid, "VERSIONED_EHR_ACCESS")?,
223            DvDateTime::new(&created)?,
224        ))
225    }
226
227    fn create_contribution(
228        &mut self,
229        ehr_id: &HierObjectId,
230        contribution: &Contribution,
231    ) -> Result<()> {
232        let uid = contribution.uid().to_string();
233        let audit = contribution.audit();
234        let committed = StoredInstant::from_date_time(audit.time_committed().value());
235        self.connection
236            .execute(
237                "INSERT INTO openehr_contribution \
238                 (uid, ehr_id, audit_change_type_code, audit_system_id, audit_committer_name, \
239                  audit_time_committed_text, audit_time_committed_utc) \
240                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
241                params![
242                    uid,
243                    ehr_id.to_string(),
244                    audit.change_type_code(),
245                    audit.system_id(),
246                    audit.committer().name(),
247                    committed.text,
248                    committed.utc_seconds,
249                ],
250            )
251            .map_err(|e| match e {
252                rusqlite::Error::SqliteFailure(f, _)
253                    if f.code == rusqlite::ErrorCode::ConstraintViolation =>
254                {
255                    StoreError::Conflict {
256                        kind: "contribution",
257                        id: uid.clone(),
258                    }
259                }
260                ref other => engine(other),
261            })?;
262        Ok(())
263    }
264
265    // Long because it is the whole commit path: two gates, the head lookup, the
266    // container, the version, and the index — in one transaction. The order is
267    // the safety property, so it stays visible in one place.
268    #[allow(clippy::too_many_lines)]
269    fn commit_composition(
270        &mut self,
271        ehr_id: &HierObjectId,
272        version: &Version<Composition>,
273        contribution_uid: &str,
274    ) -> Result<CommitOutcome> {
275        // Gate one: the content must satisfy the Reference Model. A store that
276        // accepted an invalid composition would make every later reader's
277        // `validate()` fail on data it cannot fix.
278        if let Some(composition) = version.data() {
279            composition.validate_ok()?;
280        }
281
282        // The EHR must exist. Without this the foreign key would fire on the
283        // container insert with a message about a constraint rather than about
284        // a missing record.
285        self.get_ehr(ehr_id)?;
286
287        let container_uid = version.uid().object_id().to_string();
288        let head: Option<(String, i64)> = self
289            .connection
290            .query_row(
291                "SELECT uid, trunk_version FROM openehr_version \
292                 WHERE versioned_object_uid = ?1 \
293                 ORDER BY trunk_version DESC, branch_number DESC, branch_version DESC LIMIT 1",
294                params![container_uid],
295                |row| Ok((row.get(0)?, row.get(1)?)),
296            )
297            .optional()
298            .map_err(|e| engine(&e))?;
299
300        // Gate two: the same commit rules the library enforces, in the same
301        // order, so a caller sees the same refusal whether the history is in
302        // memory or in a database (V8.1–V8.5).
303        let uid = version.uid().to_string();
304        let already: Option<String> = self
305            .connection
306            .query_row(
307                "SELECT uid FROM openehr_version WHERE uid = ?1",
308                params![uid],
309                |row| row.get(0),
310            )
311            .optional()
312            .map_err(|e| engine(&e))?;
313        if already.is_some() {
314            return Err(StoreError::Commit(CommitError::DuplicateVersion));
315        }
316        match (&head, version.preceding_version_uid()) {
317            (None, None) => {}
318            (None, Some(_)) | (Some(_), None) => {
319                return Err(StoreError::Commit(CommitError::PrecedingVersionMismatch));
320            }
321            (Some((latest, _)), Some(preceding)) => {
322                if latest != &preceding.to_string() {
323                    return Err(StoreError::Commit(CommitError::NotLatest));
324                }
325            }
326        }
327
328        let row = VersionRow::project(version, contribution_uid)?;
329        let created_container = head.is_none();
330        let transaction = self
331            .connection
332            .unchecked_transaction()
333            .map_err(|e| engine(&e))?;
334
335        if created_container {
336            let created =
337                StoredInstant::from_date_time(version.commit_audit().time_committed().value());
338            transaction
339                .execute(
340                    "INSERT INTO openehr_versioned_object \
341                     (uid, ehr_id, rm_type, time_created_text, time_created_utc) \
342                     VALUES (?1, ?2, ?3, ?4, ?5)",
343                    params![
344                        container_uid,
345                        ehr_id.to_string(),
346                        "COMPOSITION",
347                        created.text,
348                        created.utc_seconds
349                    ],
350                )
351                .map_err(|e| engine(&e))?;
352        }
353
354        transaction
355            .execute(
356                "INSERT INTO openehr_version \
357                 (uid, versioned_object_uid, creating_system_id, trunk_version, branch_number, \
358                  branch_version, preceding_version_uid, lifecycle_state_code, is_deleted, \
359                  contribution_uid, audit_system_id, audit_change_type_code, \
360                  audit_committer_name, audit_time_committed_text, audit_time_committed_utc, \
361                  data_json) \
362                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
363                params![
364                    row.uid,
365                    row.versioned_object_uid,
366                    row.creating_system_id,
367                    row.trunk_version,
368                    row.branch_number,
369                    row.branch_version,
370                    row.preceding_version_uid,
371                    row.lifecycle_state_code,
372                    i64::from(row.is_deleted),
373                    row.contribution_uid,
374                    row.audit_system_id,
375                    row.audit_change_type_code,
376                    row.audit_committer_name,
377                    row.audit_time_committed.text,
378                    row.audit_time_committed.utc_seconds,
379                    row.data_json,
380                ],
381            )
382            .map_err(|e| engine(&e))?;
383
384        if let Some(composition) = version.data() {
385            let index = CompositionIndexRow::project(&row.uid, &ehr_id.to_string(), composition)?;
386            transaction
387                .execute(
388                    "INSERT INTO openehr_composition_index \
389                     (version_uid, ehr_id, archetype_id, template_id, category_code, \
390                      composer_name, language_code, territory_code, setting_code, \
391                      context_start_text, context_start_utc, context_end_text, context_end_utc) \
392                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
393                    params![
394                        index.version_uid,
395                        index.ehr_id,
396                        index.archetype_id,
397                        index.template_id,
398                        index.category_code,
399                        index.composer_name,
400                        index.language_code,
401                        index.territory_code,
402                        index.setting_code,
403                        index.context_start.as_ref().map(|i| i.text.clone()),
404                        index.context_start.as_ref().and_then(|i| i.utc_seconds),
405                        index.context_end.as_ref().map(|i| i.text.clone()),
406                        index.context_end.as_ref().and_then(|i| i.utc_seconds),
407                    ],
408                )
409                .map_err(|e| engine(&e))?;
410        }
411
412        transaction.commit().map_err(|e| engine(&e))?;
413        Ok(CommitOutcome {
414            version_uid: version.uid().clone(),
415            created_container,
416        })
417    }
418
419    fn get_version(&self, uid: &ObjectVersionId) -> Result<VersionRow> {
420        let id = uid.to_string();
421        self.connection
422            .query_row(
423                &format!(
424                    "SELECT {} FROM openehr_version WHERE uid = ?1",
425                    Self::VERSION_COLUMNS
426                ),
427                params![id],
428                Self::read_version,
429            )
430            .optional()
431            .map_err(|e| engine(&e))?
432            .ok_or(StoreError::NotFound {
433                kind: "version",
434                id,
435            })
436    }
437
438    fn latest_version(&self, versioned_object_uid: &HierObjectId) -> Result<VersionRow> {
439        let id = versioned_object_uid.to_string();
440        self.connection
441            .query_row(
442                &format!(
443                    "SELECT {} FROM openehr_version WHERE versioned_object_uid = ?1 \
444                     ORDER BY trunk_version DESC, branch_number DESC, branch_version DESC LIMIT 1",
445                    Self::VERSION_COLUMNS
446                ),
447                params![id],
448                Self::read_version,
449            )
450            .optional()
451            .map_err(|e| engine(&e))?
452            .ok_or(StoreError::NotFound {
453                kind: "versioned_object",
454                id,
455            })
456    }
457
458    fn version_at_time(
459        &self,
460        versioned_object_uid: &HierObjectId,
461        at: &DvDateTime,
462    ) -> Result<VersionRow> {
463        let id = versioned_object_uid.to_string();
464        let Some(at_seconds) = StoredInstant::from_date_time(at.value()).utc_seconds else {
465            // The query instant is not established — a local time with no
466            // offset. Answering with *some* version would be a guess about the
467            // zone, so this refuses, exactly as
468            // `VersionedObject::version_at_time` returns `None` (V8.6).
469            return Err(StoreError::NotFound {
470                kind: "version",
471                id,
472            });
473        };
474        // `audit_time_committed_utc IS NOT NULL` is not redundant: a version
475        // whose commit time carries no offset has a NULL here, and SQLite's
476        // comparison would exclude it anyway — but stating it makes the
477        // skipping deliberate rather than incidental.
478        self.connection
479            .query_row(
480                &format!(
481                    "SELECT {} FROM openehr_version \
482                     WHERE versioned_object_uid = ?1 \
483                       AND audit_time_committed_utc IS NOT NULL \
484                       AND audit_time_committed_utc <= ?2 \
485                     ORDER BY audit_time_committed_utc DESC, trunk_version DESC LIMIT 1",
486                    Self::VERSION_COLUMNS
487                ),
488                params![id, at_seconds],
489                Self::read_version,
490            )
491            .optional()
492            .map_err(|e| engine(&e))?
493            .ok_or(StoreError::NotFound {
494                kind: "version",
495                id,
496            })
497    }
498
499    fn all_versions(&self, versioned_object_uid: &HierObjectId) -> Result<Vec<VersionRow>> {
500        let mut statement = self
501            .connection
502            .prepare(&format!(
503                "SELECT {} FROM openehr_version WHERE versioned_object_uid = ?1 \
504                 ORDER BY trunk_version ASC, branch_number ASC, branch_version ASC",
505                Self::VERSION_COLUMNS
506            ))
507            .map_err(|e| engine(&e))?;
508        let rows = statement
509            .query_map(
510                params![versioned_object_uid.to_string()],
511                Self::read_version,
512            )
513            .map_err(|e| engine(&e))?;
514        rows.collect::<rusqlite::Result<Vec<_>>>()
515            .map_err(|e| engine(&e))
516    }
517
518    fn find_compositions_by_archetype(
519        &self,
520        ehr_id: &HierObjectId,
521        archetype_id: &str,
522    ) -> Result<Vec<CompositionIndexRow>> {
523        let mut statement = self
524            .connection
525            .prepare(
526                "SELECT version_uid, ehr_id, archetype_id, template_id, category_code, \
527                        composer_name, language_code, territory_code, setting_code, \
528                        context_start_text, context_start_utc, context_end_text, context_end_utc \
529                 FROM openehr_composition_index \
530                 WHERE ehr_id = ?1 AND archetype_id = ?2 \
531                 ORDER BY version_uid",
532            )
533            .map_err(|e| engine(&e))?;
534        let rows = statement
535            .query_map(params![ehr_id.to_string(), archetype_id], |row| {
536                let instant = |text: Option<String>, utc: Option<i64>| {
537                    text.map(|text| StoredInstant {
538                        text,
539                        utc_seconds: utc,
540                    })
541                };
542                Ok(CompositionIndexRow {
543                    version_uid: row.get("version_uid")?,
544                    ehr_id: row.get("ehr_id")?,
545                    archetype_id: row.get("archetype_id")?,
546                    template_id: row.get("template_id")?,
547                    category_code: row.get("category_code")?,
548                    composer_name: row.get("composer_name")?,
549                    language_code: row.get("language_code")?,
550                    territory_code: row.get("territory_code")?,
551                    setting_code: row.get("setting_code")?,
552                    context_start: instant(
553                        row.get("context_start_text")?,
554                        row.get("context_start_utc")?,
555                    ),
556                    context_end: instant(row.get("context_end_text")?, row.get("context_end_utc")?),
557                })
558            })
559            .map_err(|e| engine(&e))?;
560        rows.collect::<rusqlite::Result<Vec<_>>>()
561            .map_err(|e| engine(&e))
562    }
563}