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 32-byte digest column.
68    ///
69    /// A wrong length is a conversion failure rather than a silent truncation:
70    /// a digest that is not 32 bytes did not come from SHA-256, and padding or
71    /// clipping it would produce a value that compares cleanly against nothing.
72    fn digest_column(row: &rusqlite::Row<'_>, name: &str) -> rusqlite::Result<[u8; 32]> {
73        let raw: Vec<u8> = row.get(name)?;
74        <[u8; 32]>::try_from(raw.as_slice()).map_err(|_| {
75            rusqlite::Error::FromSqlConversionFailure(
76                0,
77                rusqlite::types::Type::Blob,
78                format!("{name} is not 32 bytes").into(),
79            )
80        })
81    }
82
83    /// Reads a version row from a query row.
84    fn read_version(row: &rusqlite::Row<'_>) -> rusqlite::Result<VersionRow> {
85        Ok(VersionRow {
86            uid: row.get("uid")?,
87            versioned_object_uid: row.get("versioned_object_uid")?,
88            creating_system_id: row.get("creating_system_id")?,
89            trunk_version: row.get("trunk_version")?,
90            branch_number: row.get("branch_number")?,
91            branch_version: row.get("branch_version")?,
92            preceding_version_uid: row.get("preceding_version_uid")?,
93            lifecycle_state_code: row.get("lifecycle_state_code")?,
94            is_deleted: row.get::<_, i64>("is_deleted")? != 0,
95            contribution_uid: row.get("contribution_uid")?,
96            audit_system_id: row.get("audit_system_id")?,
97            audit_change_type_code: row.get("audit_change_type_code")?,
98            audit_committer_name: row.get("audit_committer_name")?,
99            audit_time_committed: StoredInstant {
100                text: row.get("audit_time_committed_text")?,
101                utc_seconds: row.get("audit_time_committed_utc")?,
102            },
103            data_json: row.get("data_json")?,
104            audit_description: row.get("audit_description")?,
105            signature: row.get("signature")?,
106            attestations_json: row.get("attestations_json")?,
107            other_input_version_uids_json: row.get("other_input_version_uids_json")?,
108            chain: openehr_store::record::ChainColumns {
109                previous: Self::digest_column(row, "chain_previous")?,
110                content: Self::digest_column(row, "chain_content")?,
111                digest: Self::digest_column(row, "chain_digest")?,
112                tag_key_id: row.get("chain_tag_key_id")?,
113                tag_mac: row
114                    .get::<_, Option<Vec<u8>>>("chain_tag_mac")?
115                    .map(|v| <[u8; 32]>::try_from(v.as_slice()))
116                    .transpose()
117                    .map_err(|_| {
118                        rusqlite::Error::FromSqlConversionFailure(
119                            0,
120                            rusqlite::types::Type::Blob,
121                            "chain_tag_mac is not 32 bytes".into(),
122                        )
123                    })?,
124            },
125        })
126    }
127
128    /// Every column of `openehr_version`, in one place so the two read paths
129    /// cannot select different sets.
130    const VERSION_COLUMNS: &'static str = "uid, versioned_object_uid, creating_system_id, \
131        trunk_version, branch_number, branch_version, preceding_version_uid, \
132        lifecycle_state_code, is_deleted, contribution_uid, audit_system_id, \
133        audit_change_type_code, audit_committer_name, audit_time_committed_text, \
134        audit_time_committed_utc, data_json, audit_description, signature, \
135        attestations_json, other_input_version_uids_json, chain_previous, \
136        chain_content, chain_digest, chain_tag_key_id, chain_tag_mac";
137
138    /// Refuses a database installed under a different schema version.
139    ///
140    /// Three states, and the third is the one that matters:
141    ///
142    /// - **No version table** and **no data** — a fresh database. Proceed.
143    /// - **No version table** but `openehr_ehr` has rows — a database from
144    ///   before versioning existed. Refuse: its shape is unknown and its columns
145    ///   are certainly not these.
146    /// - **A version that is not ours** — refuse, naming both.
147    fn check_schema_version(&self) -> Result<()> {
148        let recorded: Option<i64> = self
149            .connection
150            .query_row(
151                "SELECT version FROM openehr_schema_version LIMIT 1",
152                [],
153                |row| row.get(0),
154            )
155            .optional()
156            .unwrap_or(None);
157
158        if let Some(found) = recorded {
159            if found != openehr_store::SCHEMA_VERSION {
160                return Err(StoreError::SchemaVersionMismatch {
161                    found,
162                    expected: openehr_store::SCHEMA_VERSION,
163                });
164            }
165            return Ok(());
166        }
167
168        // No version recorded. Either fresh, or older than versioning itself.
169        let legacy: Option<i64> = self
170            .connection
171            .query_row("SELECT count(*) FROM openehr_ehr", [], |row| row.get(0))
172            .optional()
173            .unwrap_or(None);
174        if legacy.is_some_and(|n| n > 0) {
175            return Err(StoreError::SchemaVersionMismatch {
176                found: 0,
177                expected: openehr_store::SCHEMA_VERSION,
178            });
179        }
180        Ok(())
181    }
182
183    /// Records the schema version, once.
184    fn record_schema_version(&self) -> Result<()> {
185        let now = StoredInstant::from_date_time(&"1970-01-01T00:00:00Z".parse().expect("literal"));
186        self.connection
187            .execute(
188                "INSERT OR IGNORE INTO openehr_schema_version (version, applied_text, applied_utc) \
189                 VALUES (?1, ?2, ?3)",
190                params![openehr_store::SCHEMA_VERSION, now.text, now.utc_seconds],
191            )
192            .map(|_| ())
193            .map_err(|e| engine(&e))
194    }
195
196    /// The chain digest of one version, for linking the next.
197    fn chain_digest_of(&self, uid: &str) -> Result<[u8; 32]> {
198        let raw: Vec<u8> = self
199            .connection
200            .query_row(
201                "SELECT chain_digest FROM openehr_version WHERE uid = ?1",
202                params![uid],
203                |row| row.get(0),
204            )
205            .map_err(|e| engine(&e))?;
206        <[u8; 32]>::try_from(raw.as_slice()).map_err(|_| StoreError::Engine {
207            engine: ENGINE,
208            message: "chain_digest is not 32 bytes".to_owned(),
209        })
210    }
211}
212
213/// Translates a uniqueness violation on the version table into the commit
214/// refusal it actually is.
215///
216/// The single-threaded path checks the commit rules before inserting, so this
217/// only fires under **concurrency**: two writers both read the same head, both
218/// pass the check, and the database refuses the second. That is the unique
219/// index of `db:H5.10` doing its job — the rule holds in the database and not
220/// only in the library.
221///
222/// Reporting it as `Engine` would satisfy the guarantee and fail the caller.
223/// `db:H5.9` requires refusals to be **distinguishable**: a caller told
224/// `Commit` knows another writer won and can re-read the head and retry, while
225/// a caller told "UNIQUE constraint failed" knows only that something went
226/// wrong — and a version tree is precisely where guessing is not allowed.
227///
228/// The two indexes mean different things and map differently:
229///
230/// - `openehr_version.uid` — the same version identity was committed twice.
231/// - `ix_version_container_trunk` — a *different* identity took that position
232///   in the tree, which is a concurrent modification rather than a duplicate.
233fn commit_conflict(error: &rusqlite::Error) -> Option<StoreError> {
234    use rusqlite::ErrorCode::ConstraintViolation;
235    let rusqlite::Error::SqliteFailure(code, Some(message)) = error else {
236        return None;
237    };
238    if code.code != ConstraintViolation {
239        return None;
240    }
241    if message.contains("openehr_version.uid") {
242        Some(StoreError::Commit(CommitError::DuplicateVersion))
243    } else if message.contains("ix_version_container_trunk") {
244        Some(StoreError::Commit(CommitError::NotLatest))
245    } else {
246        None
247    }
248}
249
250/// Lower-case hex for a digest, matching `Digest256`'s own rendering.
251///
252/// Hex is correct here and wrong in a column: this is a value for a human and a
253/// log, not a value to compare in SQL (`M3.40`).
254fn hex32(bytes: &[u8; 32]) -> String {
255    use std::fmt::Write as _;
256    let mut out = String::with_capacity(64);
257    for b in bytes {
258        let _ = write!(out, "{b:02x}");
259    }
260    out
261}
262
263/// Wraps a driver error without letting row data into the message.
264fn engine(error: &rusqlite::Error) -> StoreError {
265    if let Some(conflict) = commit_conflict(error) {
266        return conflict;
267    }
268    StoreError::Engine {
269        engine: ENGINE,
270        // `to_string` on a rusqlite error gives the SQLite message, which names
271        // constraints and columns and not values. The one exception SQLite
272        // makes is a `CHECK` message, which is why this schema's constraints
273        // carry no interpolated values.
274        message: error.to_string(),
275    }
276}
277
278impl Store for SqliteStore {
279    fn engine(&self) -> &'static str {
280        ENGINE
281    }
282
283    fn install(&mut self) -> Result<()> {
284        // Check *before* creating anything. Running the DDL first would create
285        // the version table on an old database and make the mismatch look like
286        // a fresh install.
287        self.check_schema_version()?;
288        self.connection
289            .execute_batch(&ddl_script(&SqliteDialect))
290            .map_err(|e| engine(&e))?;
291        self.record_schema_version()
292    }
293
294    fn create_ehr(&mut self, ehr: &Ehr) -> Result<()> {
295        // As for a version (`lib:A-23`): `Ehr::new` checks two of these six
296        // reference types and deserialization checks none, so an EHR read from
297        // JSON reached no check at all. A record whose `compositions` list
298        // names a CONTRIBUTION is one every later reader has to cope with.
299        ehr.validate_ok()?;
300        let id = ehr.ehr_id().to_string();
301        let existing: Option<String> = self
302            .connection
303            .query_row(
304                "SELECT ehr_id FROM openehr_ehr WHERE ehr_id = ?1",
305                params![id],
306                |row| row.get(0),
307            )
308            .optional()
309            .map_err(|e| engine(&e))?;
310        if existing.is_some() {
311            return Err(StoreError::Conflict { kind: "ehr", id });
312        }
313        let created = StoredInstant::from_date_time(ehr.time_created().value());
314        self.connection
315            .execute(
316                "INSERT INTO openehr_ehr \
317                 (ehr_id, system_id, time_created_text, time_created_utc, ehr_status_uid, ehr_access_uid) \
318                 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
319                params![
320                    id,
321                    ehr.system_id().to_string(),
322                    created.text,
323                    created.utc_seconds,
324                    ehr.ehr_status().id().to_string(),
325                    ehr.ehr_access().id().to_string(),
326                ],
327            )
328            .map_err(|e| engine(&e))?;
329        Ok(())
330    }
331
332    fn get_ehr(&self, ehr_id: &HierObjectId) -> Result<Ehr> {
333        let id = ehr_id.to_string();
334        let row: Option<(String, String, String, String)> = self
335            .connection
336            .query_row(
337                "SELECT system_id, time_created_text, ehr_status_uid, ehr_access_uid \
338                 FROM openehr_ehr WHERE ehr_id = ?1",
339                params![id],
340                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
341            )
342            .optional()
343            .map_err(|e| engine(&e))?;
344        let Some((system_id, created, status_uid, access_uid)) = row else {
345            return Err(StoreError::NotFound { kind: "ehr", id });
346        };
347        let reference = |uid: &str, ty: &'static str| -> Result<ObjectRef> {
348            Ok(ObjectRef::new(
349                "local",
350                ty,
351                ObjectId::HierObjectId(uid.parse()?),
352            )?)
353        };
354        Ok(Ehr::new(
355            system_id.parse()?,
356            ehr_id.clone(),
357            reference(&status_uid, "VERSIONED_EHR_STATUS")?,
358            reference(&access_uid, "VERSIONED_EHR_ACCESS")?,
359            DvDateTime::new(&created)?,
360        )?)
361    }
362
363    fn create_contribution(
364        &mut self,
365        ehr_id: &HierObjectId,
366        contribution: &Contribution,
367    ) -> Result<()> {
368        let uid = contribution.uid().to_string();
369        let audit = contribution.audit();
370        let committed = StoredInstant::from_date_time(audit.time_committed().value());
371        self.connection
372            .execute(
373                "INSERT INTO openehr_contribution \
374                 (uid, ehr_id, audit_change_type_code, audit_system_id, audit_committer_name, \
375                  audit_time_committed_text, audit_time_committed_utc) \
376                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
377                params![
378                    uid,
379                    ehr_id.to_string(),
380                    audit.change_type_code(),
381                    audit.system_id(),
382                    audit.committer().name(),
383                    committed.text,
384                    committed.utc_seconds,
385                ],
386            )
387            .map_err(|e| match e {
388                rusqlite::Error::SqliteFailure(f, _)
389                    if f.code == rusqlite::ErrorCode::ConstraintViolation =>
390                {
391                    StoreError::Conflict {
392                        kind: "contribution",
393                        id: uid.clone(),
394                    }
395                }
396                ref other => engine(other),
397            })?;
398        Ok(())
399    }
400
401    // Long because it is the whole commit path: two gates, the head lookup, the
402    // container, the version, and the index — in one transaction. The order is
403    // the safety property, so it stays visible in one place.
404    #[allow(clippy::too_many_lines)]
405    fn commit_composition(
406        &mut self,
407        ehr_id: &HierObjectId,
408        version: &Version<Composition>,
409        contribution_uid: &str,
410    ) -> Result<CommitOutcome> {
411        // Gate one: the content must satisfy the Reference Model. A store that
412        // accepted an invalid composition would make every later reader's
413        // `validate()` fail on data it cannot fix.
414        // The **version**, not just the composition inside it. This validated
415        // `version.data()` alone, which meant the envelope was never checked
416        // anywhere: `OriginalVersion::new` checks it and deserialization does
417        // not, so a version arriving as JSON could name a lifecycle state
418        // openEHR does not define, or claim `complete` and carry no content
419        // (`A-23`). Validating the version covers its data too.
420        version.validate_ok()?;
421
422        // The EHR must exist. Without this the foreign key would fire on the
423        // container insert with a message about a constraint rather than about
424        // a missing record.
425        self.get_ehr(ehr_id)?;
426
427        let container_uid = version.uid().object_id().to_string();
428        let head: Option<(String, i64)> = self
429            .connection
430            .query_row(
431                "SELECT uid, trunk_version FROM openehr_version \
432                 WHERE versioned_object_uid = ?1 \
433                 ORDER BY trunk_version DESC, branch_number DESC, branch_version DESC LIMIT 1",
434                params![container_uid],
435                |row| Ok((row.get(0)?, row.get(1)?)),
436            )
437            .optional()
438            .map_err(|e| engine(&e))?;
439
440        // Gate two: the same commit rules the library enforces, in the same
441        // order, so a caller sees the same refusal whether the history is in
442        // memory or in a database (V8.1–V8.5).
443        let uid = version.uid().to_string();
444        let already: Option<String> = self
445            .connection
446            .query_row(
447                "SELECT uid FROM openehr_version WHERE uid = ?1",
448                params![uid],
449                |row| row.get(0),
450            )
451            .optional()
452            .map_err(|e| engine(&e))?;
453        if already.is_some() {
454            return Err(StoreError::Commit(CommitError::DuplicateVersion));
455        }
456        match (&head, version.preceding_version_uid()) {
457            (None, None) => {}
458            (None, Some(_)) | (Some(_), None) => {
459                return Err(StoreError::Commit(CommitError::PrecedingVersionMismatch));
460            }
461            (Some((latest, _)), Some(preceding)) => {
462                if latest != &preceding.to_string() {
463                    return Err(StoreError::Commit(CommitError::NotLatest));
464                }
465            }
466        }
467
468        // The chain links to the previous version *in this container*, which is
469        // the head we already resolved for the commit rules. Reading it here
470        // rather than re-querying keeps the two from disagreeing about which
471        // version this one follows.
472        let previous_digest = head
473            .as_ref()
474            .map(|(uid, _)| self.chain_digest_of(uid))
475            .transpose()?;
476        let row = VersionRow::project(version, contribution_uid, previous_digest, None)?;
477        let created_container = head.is_none();
478        let transaction = self
479            .connection
480            .unchecked_transaction()
481            .map_err(|e| engine(&e))?;
482
483        if created_container {
484            let created =
485                StoredInstant::from_date_time(version.commit_audit().time_committed().value());
486            transaction
487                .execute(
488                    "INSERT INTO openehr_versioned_object \
489                     (uid, ehr_id, rm_type, time_created_text, time_created_utc) \
490                     VALUES (?1, ?2, ?3, ?4, ?5)",
491                    params![
492                        container_uid,
493                        ehr_id.to_string(),
494                        "COMPOSITION",
495                        created.text,
496                        created.utc_seconds
497                    ],
498                )
499                .map_err(|e| engine(&e))?;
500        }
501
502        transaction
503            .execute(
504                "INSERT INTO openehr_version \
505                 (uid, versioned_object_uid, creating_system_id, trunk_version, branch_number, \
506                  branch_version, preceding_version_uid, lifecycle_state_code, is_deleted, \
507                  contribution_uid, audit_system_id, audit_change_type_code, \
508                  audit_committer_name, audit_time_committed_text, audit_time_committed_utc, \
509                  data_json, audit_description, signature, attestations_json, \
510                  other_input_version_uids_json, chain_previous, chain_content, chain_digest, \
511                  chain_tag_key_id, chain_tag_mac) \
512                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, \
513                         ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25)",
514                params![
515                    row.uid,
516                    row.versioned_object_uid,
517                    row.creating_system_id,
518                    row.trunk_version,
519                    row.branch_number,
520                    row.branch_version,
521                    row.preceding_version_uid,
522                    row.lifecycle_state_code,
523                    i64::from(row.is_deleted),
524                    row.contribution_uid,
525                    row.audit_system_id,
526                    row.audit_change_type_code,
527                    row.audit_committer_name,
528                    row.audit_time_committed.text,
529                    row.audit_time_committed.utc_seconds,
530                    row.data_json,
531                    row.audit_description,
532                    row.signature,
533                    row.attestations_json,
534                    row.other_input_version_uids_json,
535                    row.chain.previous.as_slice(),
536                    row.chain.content.as_slice(),
537                    row.chain.digest.as_slice(),
538                    row.chain.tag_key_id,
539                    row.chain.tag_mac.map(|m| m.to_vec()),
540                ],
541            )
542            .map_err(|e| engine(&e))?;
543
544        if let Some(composition) = version.data() {
545            let index = CompositionIndexRow::project(&row.uid, &ehr_id.to_string(), composition)?;
546            transaction
547                .execute(
548                    "INSERT INTO openehr_composition_index \
549                     (version_uid, ehr_id, archetype_id, template_id, category_code, \
550                      composer_name, language_code, territory_code, setting_code, \
551                      context_start_text, context_start_utc, context_end_text, context_end_utc) \
552                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
553                    params![
554                        index.version_uid,
555                        index.ehr_id,
556                        index.archetype_id,
557                        index.template_id,
558                        index.category_code,
559                        index.composer_name,
560                        index.language_code,
561                        index.territory_code,
562                        index.setting_code,
563                        index.context_start.as_ref().map(|i| i.text.clone()),
564                        index.context_start.as_ref().and_then(|i| i.utc_seconds),
565                        index.context_end.as_ref().map(|i| i.text.clone()),
566                        index.context_end.as_ref().and_then(|i| i.utc_seconds),
567                    ],
568                )
569                .map_err(|e| engine(&e))?;
570        }
571
572        transaction.commit().map_err(|e| engine(&e))?;
573        Ok(CommitOutcome {
574            version_uid: version.uid().clone(),
575            created_container,
576        })
577    }
578
579    fn get_version(&self, uid: &ObjectVersionId) -> Result<VersionRow> {
580        let id = uid.to_string();
581        self.connection
582            .query_row(
583                &format!(
584                    "SELECT {} FROM openehr_version WHERE uid = ?1",
585                    Self::VERSION_COLUMNS
586                ),
587                params![id],
588                Self::read_version,
589            )
590            .optional()
591            .map_err(|e| engine(&e))?
592            .ok_or(StoreError::NotFound {
593                kind: "version",
594                id,
595            })
596    }
597
598    fn latest_version(&self, versioned_object_uid: &HierObjectId) -> Result<VersionRow> {
599        let id = versioned_object_uid.to_string();
600        self.connection
601            .query_row(
602                &format!(
603                    "SELECT {} FROM openehr_version WHERE versioned_object_uid = ?1 \
604                     ORDER BY trunk_version DESC, branch_number DESC, branch_version DESC LIMIT 1",
605                    Self::VERSION_COLUMNS
606                ),
607                params![id],
608                Self::read_version,
609            )
610            .optional()
611            .map_err(|e| engine(&e))?
612            .ok_or(StoreError::NotFound {
613                kind: "versioned_object",
614                id,
615            })
616    }
617
618    fn version_at_time(
619        &self,
620        versioned_object_uid: &HierObjectId,
621        at: &DvDateTime,
622    ) -> Result<VersionRow> {
623        let id = versioned_object_uid.to_string();
624        let Some(at_seconds) = StoredInstant::from_date_time(at.value()).utc_seconds else {
625            // The query instant is not established — a local time with no
626            // offset. Answering with *some* version would be a guess about the
627            // zone, so this refuses, exactly as
628            // `VersionedObject::version_at_time` returns `None` (V8.6).
629            return Err(StoreError::NotFound {
630                kind: "version",
631                id,
632            });
633        };
634        // `audit_time_committed_utc IS NOT NULL` is not redundant: a version
635        // whose commit time carries no offset has a NULL here, and SQLite's
636        // comparison would exclude it anyway — but stating it makes the
637        // skipping deliberate rather than incidental.
638        self.connection
639            .query_row(
640                &format!(
641                    "SELECT {} FROM openehr_version \
642                     WHERE versioned_object_uid = ?1 \
643                       AND audit_time_committed_utc IS NOT NULL \
644                       AND audit_time_committed_utc <= ?2 \
645                     ORDER BY audit_time_committed_utc DESC, trunk_version DESC LIMIT 1",
646                    Self::VERSION_COLUMNS
647                ),
648                params![id, at_seconds],
649                Self::read_version,
650            )
651            .optional()
652            .map_err(|e| engine(&e))?
653            .ok_or(StoreError::NotFound {
654                kind: "version",
655                id,
656            })
657    }
658
659    fn all_versions(&self, versioned_object_uid: &HierObjectId) -> Result<Vec<VersionRow>> {
660        let mut statement = self
661            .connection
662            .prepare(&format!(
663                "SELECT {} FROM openehr_version WHERE versioned_object_uid = ?1 \
664                 ORDER BY trunk_version ASC, branch_number ASC, branch_version ASC",
665                Self::VERSION_COLUMNS
666            ))
667            .map_err(|e| engine(&e))?;
668        let rows = statement
669            .query_map(
670                params![versioned_object_uid.to_string()],
671                Self::read_version,
672            )
673            .map_err(|e| engine(&e))?;
674        rows.collect::<rusqlite::Result<Vec<_>>>()
675            .map_err(|e| engine(&e))
676    }
677
678    fn chain_checkpoint(&self, versioned_object_uid: &HierObjectId) -> Result<String> {
679        // Computed from the stored rows in the same order `all_versions` reads
680        // them, and formatted exactly as `Chain::checkpoint` formats one, so a
681        // checkpoint taken from the database and one recomputed from a rebuilt
682        // chain are the same string. If they were merely equivalent, comparing
683        // them would need a parser, and a witness that needs a parser is a
684        // witness nobody runs.
685        let versions = self.all_versions(versioned_object_uid)?;
686        let head = versions
687            .last()
688            .map_or_else(|| "0".repeat(64), |v| hex32(&v.chain.digest));
689        Ok(format!(
690            "entries={} head={} last_version={}",
691            versions.len(),
692            head,
693            versions.last().map_or("-", |v| v.uid.as_str())
694        ))
695    }
696
697    fn find_compositions_by_archetype(
698        &self,
699        ehr_id: &HierObjectId,
700        archetype_id: &str,
701    ) -> Result<Vec<CompositionIndexRow>> {
702        let mut statement = self
703            .connection
704            .prepare(
705                "SELECT version_uid, ehr_id, archetype_id, template_id, category_code, \
706                        composer_name, language_code, territory_code, setting_code, \
707                        context_start_text, context_start_utc, context_end_text, context_end_utc \
708                 FROM openehr_composition_index \
709                 WHERE ehr_id = ?1 AND archetype_id = ?2 \
710                 ORDER BY version_uid",
711            )
712            .map_err(|e| engine(&e))?;
713        let rows = statement
714            .query_map(params![ehr_id.to_string(), archetype_id], |row| {
715                let instant = |text: Option<String>, utc: Option<i64>| {
716                    text.map(|text| StoredInstant {
717                        text,
718                        utc_seconds: utc,
719                    })
720                };
721                Ok(CompositionIndexRow {
722                    version_uid: row.get("version_uid")?,
723                    ehr_id: row.get("ehr_id")?,
724                    archetype_id: row.get("archetype_id")?,
725                    template_id: row.get("template_id")?,
726                    category_code: row.get("category_code")?,
727                    composer_name: row.get("composer_name")?,
728                    language_code: row.get("language_code")?,
729                    territory_code: row.get("territory_code")?,
730                    setting_code: row.get("setting_code")?,
731                    context_start: instant(
732                        row.get("context_start_text")?,
733                        row.get("context_start_utc")?,
734                    ),
735                    context_end: instant(row.get("context_end_text")?, row.get("context_end_utc")?),
736                })
737            })
738            .map_err(|e| engine(&e))?;
739        rows.collect::<rusqlite::Result<Vec<_>>>()
740            .map_err(|e| engine(&e))
741    }
742}