1use 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
13const ENGINE: &str = "SQLite";
15
16pub struct SqliteStore {
26 connection: Connection,
27}
28
29impl SqliteStore {
30 pub fn in_memory() -> Result<Self> {
36 Self::from_connection(Connection::open_in_memory().map_err(|e| engine(&e))?)
37 }
38
39 pub fn open(path: &std::path::Path) -> Result<Self> {
45 Self::from_connection(Connection::open(path).map_err(|e| engine(&e))?)
46 }
47
48 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 #[must_use]
63 pub fn connection(&self) -> &Connection {
64 &self.connection
65 }
66
67 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 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 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 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 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 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 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
213fn 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
250fn 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
263fn engine(error: &rusqlite::Error) -> StoreError {
265 if let Some(conflict) = commit_conflict(error) {
266 return conflict;
267 }
268 StoreError::Engine {
269 engine: ENGINE,
270 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 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 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 #[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 version.validate_ok()?;
421
422 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 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 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 return Err(StoreError::NotFound {
630 kind: "version",
631 id,
632 });
633 };
634 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 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}