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/// Wraps a driver error without letting row data into the message.
101fn engine(error: &rusqlite::Error) -> StoreError {
102    StoreError::Engine {
103        engine: ENGINE,
104        // `to_string` on a rusqlite error gives the SQLite message, which names
105        // constraints and columns and not values. The one exception SQLite
106        // makes is a `CHECK` message, which is why this schema's constraints
107        // carry no interpolated values.
108        message: error.to_string(),
109    }
110}
111
112impl Store for SqliteStore {
113    fn engine(&self) -> &'static str {
114        ENGINE
115    }
116
117    fn install(&mut self) -> Result<()> {
118        self.connection
119            .execute_batch(&ddl_script(&SqliteDialect))
120            .map_err(|e| engine(&e))
121    }
122
123    fn create_ehr(&mut self, ehr: &Ehr) -> Result<()> {
124        let id = ehr.ehr_id().to_string();
125        let existing: Option<String> = self
126            .connection
127            .query_row(
128                "SELECT ehr_id FROM openehr_ehr WHERE ehr_id = ?1",
129                params![id],
130                |row| row.get(0),
131            )
132            .optional()
133            .map_err(|e| engine(&e))?;
134        if existing.is_some() {
135            return Err(StoreError::Conflict { kind: "ehr", id });
136        }
137        let created = StoredInstant::from_date_time(ehr.time_created().value());
138        self.connection
139            .execute(
140                "INSERT INTO openehr_ehr \
141                 (ehr_id, system_id, time_created_text, time_created_utc, ehr_status_uid, ehr_access_uid) \
142                 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
143                params![
144                    id,
145                    ehr.system_id().to_string(),
146                    created.text,
147                    created.utc_seconds,
148                    ehr.ehr_status().id().to_string(),
149                    ehr.ehr_access().id().to_string(),
150                ],
151            )
152            .map_err(|e| engine(&e))?;
153        Ok(())
154    }
155
156    fn get_ehr(&self, ehr_id: &HierObjectId) -> Result<Ehr> {
157        let id = ehr_id.to_string();
158        let row: Option<(String, String, String, String)> = self
159            .connection
160            .query_row(
161                "SELECT system_id, time_created_text, ehr_status_uid, ehr_access_uid \
162                 FROM openehr_ehr WHERE ehr_id = ?1",
163                params![id],
164                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
165            )
166            .optional()
167            .map_err(|e| engine(&e))?;
168        let Some((system_id, created, status_uid, access_uid)) = row else {
169            return Err(StoreError::NotFound { kind: "ehr", id });
170        };
171        let reference = |uid: &str, ty: &'static str| -> Result<ObjectRef> {
172            Ok(ObjectRef::new(
173                "local",
174                ty,
175                ObjectId::HierObjectId(uid.parse()?),
176            )?)
177        };
178        Ok(Ehr::new(
179            system_id.parse()?,
180            ehr_id.clone(),
181            reference(&status_uid, "VERSIONED_EHR_STATUS")?,
182            reference(&access_uid, "VERSIONED_EHR_ACCESS")?,
183            DvDateTime::new(&created)?,
184        ))
185    }
186
187    fn create_contribution(
188        &mut self,
189        ehr_id: &HierObjectId,
190        contribution: &Contribution,
191    ) -> Result<()> {
192        let uid = contribution.uid().to_string();
193        let audit = contribution.audit();
194        let committed = StoredInstant::from_date_time(audit.time_committed().value());
195        self.connection
196            .execute(
197                "INSERT INTO openehr_contribution \
198                 (uid, ehr_id, audit_change_type_code, audit_system_id, audit_committer_name, \
199                  audit_time_committed_text, audit_time_committed_utc) \
200                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
201                params![
202                    uid,
203                    ehr_id.to_string(),
204                    audit.change_type_code(),
205                    audit.system_id(),
206                    audit.committer().name(),
207                    committed.text,
208                    committed.utc_seconds,
209                ],
210            )
211            .map_err(|e| match e {
212                rusqlite::Error::SqliteFailure(f, _)
213                    if f.code == rusqlite::ErrorCode::ConstraintViolation =>
214                {
215                    StoreError::Conflict {
216                        kind: "contribution",
217                        id: uid.clone(),
218                    }
219                }
220                ref other => engine(other),
221            })?;
222        Ok(())
223    }
224
225    // Long because it is the whole commit path: two gates, the head lookup, the
226    // container, the version, and the index — in one transaction. The order is
227    // the safety property, so it stays visible in one place.
228    #[allow(clippy::too_many_lines)]
229    fn commit_composition(
230        &mut self,
231        ehr_id: &HierObjectId,
232        version: &Version<Composition>,
233        contribution_uid: &str,
234    ) -> Result<CommitOutcome> {
235        // Gate one: the content must satisfy the Reference Model. A store that
236        // accepted an invalid composition would make every later reader's
237        // `validate()` fail on data it cannot fix.
238        if let Some(composition) = version.data() {
239            composition.validate_ok()?;
240        }
241
242        // The EHR must exist. Without this the foreign key would fire on the
243        // container insert with a message about a constraint rather than about
244        // a missing record.
245        self.get_ehr(ehr_id)?;
246
247        let container_uid = version.uid().object_id().to_string();
248        let head: Option<(String, i64)> = self
249            .connection
250            .query_row(
251                "SELECT uid, trunk_version FROM openehr_version \
252                 WHERE versioned_object_uid = ?1 \
253                 ORDER BY trunk_version DESC, branch_number DESC, branch_version DESC LIMIT 1",
254                params![container_uid],
255                |row| Ok((row.get(0)?, row.get(1)?)),
256            )
257            .optional()
258            .map_err(|e| engine(&e))?;
259
260        // Gate two: the same commit rules the library enforces, in the same
261        // order, so a caller sees the same refusal whether the history is in
262        // memory or in a database (V8.1–V8.5).
263        let uid = version.uid().to_string();
264        let already: Option<String> = self
265            .connection
266            .query_row(
267                "SELECT uid FROM openehr_version WHERE uid = ?1",
268                params![uid],
269                |row| row.get(0),
270            )
271            .optional()
272            .map_err(|e| engine(&e))?;
273        if already.is_some() {
274            return Err(StoreError::Commit(CommitError::DuplicateVersion));
275        }
276        match (&head, version.preceding_version_uid()) {
277            (None, None) => {}
278            (None, Some(_)) | (Some(_), None) => {
279                return Err(StoreError::Commit(CommitError::PrecedingVersionMismatch));
280            }
281            (Some((latest, _)), Some(preceding)) => {
282                if latest != &preceding.to_string() {
283                    return Err(StoreError::Commit(CommitError::NotLatest));
284                }
285            }
286        }
287
288        let row = VersionRow::project(version, contribution_uid)?;
289        let created_container = head.is_none();
290        let transaction = self
291            .connection
292            .unchecked_transaction()
293            .map_err(|e| engine(&e))?;
294
295        if created_container {
296            let created =
297                StoredInstant::from_date_time(version.commit_audit().time_committed().value());
298            transaction
299                .execute(
300                    "INSERT INTO openehr_versioned_object \
301                     (uid, ehr_id, rm_type, time_created_text, time_created_utc) \
302                     VALUES (?1, ?2, ?3, ?4, ?5)",
303                    params![
304                        container_uid,
305                        ehr_id.to_string(),
306                        "COMPOSITION",
307                        created.text,
308                        created.utc_seconds
309                    ],
310                )
311                .map_err(|e| engine(&e))?;
312        }
313
314        transaction
315            .execute(
316                "INSERT INTO openehr_version \
317                 (uid, versioned_object_uid, creating_system_id, trunk_version, branch_number, \
318                  branch_version, preceding_version_uid, lifecycle_state_code, is_deleted, \
319                  contribution_uid, audit_system_id, audit_change_type_code, \
320                  audit_committer_name, audit_time_committed_text, audit_time_committed_utc, \
321                  data_json) \
322                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
323                params![
324                    row.uid,
325                    row.versioned_object_uid,
326                    row.creating_system_id,
327                    row.trunk_version,
328                    row.branch_number,
329                    row.branch_version,
330                    row.preceding_version_uid,
331                    row.lifecycle_state_code,
332                    i64::from(row.is_deleted),
333                    row.contribution_uid,
334                    row.audit_system_id,
335                    row.audit_change_type_code,
336                    row.audit_committer_name,
337                    row.audit_time_committed.text,
338                    row.audit_time_committed.utc_seconds,
339                    row.data_json,
340                ],
341            )
342            .map_err(|e| engine(&e))?;
343
344        if let Some(composition) = version.data() {
345            let index = CompositionIndexRow::project(&row.uid, &ehr_id.to_string(), composition)?;
346            transaction
347                .execute(
348                    "INSERT INTO openehr_composition_index \
349                     (version_uid, ehr_id, archetype_id, template_id, category_code, \
350                      composer_name, language_code, territory_code, setting_code, \
351                      context_start_text, context_start_utc, context_end_text, context_end_utc) \
352                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
353                    params![
354                        index.version_uid,
355                        index.ehr_id,
356                        index.archetype_id,
357                        index.template_id,
358                        index.category_code,
359                        index.composer_name,
360                        index.language_code,
361                        index.territory_code,
362                        index.setting_code,
363                        index.context_start.as_ref().map(|i| i.text.clone()),
364                        index.context_start.as_ref().and_then(|i| i.utc_seconds),
365                        index.context_end.as_ref().map(|i| i.text.clone()),
366                        index.context_end.as_ref().and_then(|i| i.utc_seconds),
367                    ],
368                )
369                .map_err(|e| engine(&e))?;
370        }
371
372        transaction.commit().map_err(|e| engine(&e))?;
373        Ok(CommitOutcome {
374            version_uid: version.uid().clone(),
375            created_container,
376        })
377    }
378
379    fn get_version(&self, uid: &ObjectVersionId) -> Result<VersionRow> {
380        let id = uid.to_string();
381        self.connection
382            .query_row(
383                &format!(
384                    "SELECT {} FROM openehr_version WHERE uid = ?1",
385                    Self::VERSION_COLUMNS
386                ),
387                params![id],
388                Self::read_version,
389            )
390            .optional()
391            .map_err(|e| engine(&e))?
392            .ok_or(StoreError::NotFound {
393                kind: "version",
394                id,
395            })
396    }
397
398    fn latest_version(&self, versioned_object_uid: &HierObjectId) -> Result<VersionRow> {
399        let id = versioned_object_uid.to_string();
400        self.connection
401            .query_row(
402                &format!(
403                    "SELECT {} FROM openehr_version WHERE versioned_object_uid = ?1 \
404                     ORDER BY trunk_version DESC, branch_number DESC, branch_version DESC LIMIT 1",
405                    Self::VERSION_COLUMNS
406                ),
407                params![id],
408                Self::read_version,
409            )
410            .optional()
411            .map_err(|e| engine(&e))?
412            .ok_or(StoreError::NotFound {
413                kind: "versioned_object",
414                id,
415            })
416    }
417
418    fn version_at_time(
419        &self,
420        versioned_object_uid: &HierObjectId,
421        at: &DvDateTime,
422    ) -> Result<VersionRow> {
423        let id = versioned_object_uid.to_string();
424        let Some(at_seconds) = StoredInstant::from_date_time(at.value()).utc_seconds else {
425            // The query instant is not established — a local time with no
426            // offset. Answering with *some* version would be a guess about the
427            // zone, so this refuses, exactly as
428            // `VersionedObject::version_at_time` returns `None` (V8.6).
429            return Err(StoreError::NotFound {
430                kind: "version",
431                id,
432            });
433        };
434        // `audit_time_committed_utc IS NOT NULL` is not redundant: a version
435        // whose commit time carries no offset has a NULL here, and SQLite's
436        // comparison would exclude it anyway — but stating it makes the
437        // skipping deliberate rather than incidental.
438        self.connection
439            .query_row(
440                &format!(
441                    "SELECT {} FROM openehr_version \
442                     WHERE versioned_object_uid = ?1 \
443                       AND audit_time_committed_utc IS NOT NULL \
444                       AND audit_time_committed_utc <= ?2 \
445                     ORDER BY audit_time_committed_utc DESC, trunk_version DESC LIMIT 1",
446                    Self::VERSION_COLUMNS
447                ),
448                params![id, at_seconds],
449                Self::read_version,
450            )
451            .optional()
452            .map_err(|e| engine(&e))?
453            .ok_or(StoreError::NotFound {
454                kind: "version",
455                id,
456            })
457    }
458
459    fn all_versions(&self, versioned_object_uid: &HierObjectId) -> Result<Vec<VersionRow>> {
460        let mut statement = self
461            .connection
462            .prepare(&format!(
463                "SELECT {} FROM openehr_version WHERE versioned_object_uid = ?1 \
464                 ORDER BY trunk_version ASC, branch_number ASC, branch_version ASC",
465                Self::VERSION_COLUMNS
466            ))
467            .map_err(|e| engine(&e))?;
468        let rows = statement
469            .query_map(
470                params![versioned_object_uid.to_string()],
471                Self::read_version,
472            )
473            .map_err(|e| engine(&e))?;
474        rows.collect::<rusqlite::Result<Vec<_>>>()
475            .map_err(|e| engine(&e))
476    }
477
478    fn find_compositions_by_archetype(
479        &self,
480        ehr_id: &HierObjectId,
481        archetype_id: &str,
482    ) -> Result<Vec<CompositionIndexRow>> {
483        let mut statement = self
484            .connection
485            .prepare(
486                "SELECT version_uid, ehr_id, archetype_id, template_id, category_code, \
487                        composer_name, language_code, territory_code, setting_code, \
488                        context_start_text, context_start_utc, context_end_text, context_end_utc \
489                 FROM openehr_composition_index \
490                 WHERE ehr_id = ?1 AND archetype_id = ?2 \
491                 ORDER BY version_uid",
492            )
493            .map_err(|e| engine(&e))?;
494        let rows = statement
495            .query_map(params![ehr_id.to_string(), archetype_id], |row| {
496                let instant = |text: Option<String>, utc: Option<i64>| {
497                    text.map(|text| StoredInstant {
498                        text,
499                        utc_seconds: utc,
500                    })
501                };
502                Ok(CompositionIndexRow {
503                    version_uid: row.get("version_uid")?,
504                    ehr_id: row.get("ehr_id")?,
505                    archetype_id: row.get("archetype_id")?,
506                    template_id: row.get("template_id")?,
507                    category_code: row.get("category_code")?,
508                    composer_name: row.get("composer_name")?,
509                    language_code: row.get("language_code")?,
510                    territory_code: row.get("territory_code")?,
511                    setting_code: row.get("setting_code")?,
512                    context_start: instant(
513                        row.get("context_start_text")?,
514                        row.get("context_start_utc")?,
515                    ),
516                    context_end: instant(row.get("context_end_text")?, row.get("context_end_utc")?),
517                })
518            })
519            .map_err(|e| engine(&e))?;
520        rows.collect::<rusqlite::Result<Vec<_>>>()
521            .map_err(|e| engine(&e))
522    }
523}