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 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 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
100fn 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
137fn engine(error: &rusqlite::Error) -> StoreError {
139 if let Some(conflict) = commit_conflict(error) {
140 return conflict;
141 }
142 StoreError::Engine {
143 engine: ENGINE,
144 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 #[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 if let Some(composition) = version.data() {
279 composition.validate_ok()?;
280 }
281
282 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 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 return Err(StoreError::NotFound {
470 kind: "version",
471 id,
472 });
473 };
474 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}