diff --git a/catalog/src/main/assets/component_modal.json b/catalog/src/main/assets/component_modal.json index 54b8dc9222..59c8332052 100644 --- a/catalog/src/main/assets/component_modal.json +++ b/catalog/src/main/assets/component_modal.json @@ -16,7 +16,7 @@ "text": "Check box" } }, - { + { "url": "https://github.com/google/android-fhir/StructureDefinition/dialog" } ], diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponentsTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponentsTest.kt index beaf16d400..e8f1ce7290 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponentsTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponentsTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 Google LLC + * Copyright 2023-2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/engine/src/androidTest/java/com/google/android/fhir/db/impl/DatabaseImplTest.kt b/engine/src/androidTest/java/com/google/android/fhir/db/impl/DatabaseImplTest.kt index 1807b4b2d6..7c2e93c03d 100644 --- a/engine/src/androidTest/java/com/google/android/fhir/db/impl/DatabaseImplTest.kt +++ b/engine/src/androidTest/java/com/google/android/fhir/db/impl/DatabaseImplTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 Google LLC + * Copyright 2023-2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -5206,6 +5206,28 @@ class DatabaseImplTest { assertThat(searchResults.size).isEqualTo(980) } + @Test + fun getResources_shouldReturnListOfResources() = runBlocking { + val patients = ArrayList() + patients.add(TEST_PATIENT_1) + patients.add(TEST_PATIENT_2) + database.insert(*patients.toTypedArray()) + assertThat( + database.selectResources(ResourceType.Patient, TEST_PATIENT_1_ID, TEST_PATIENT_2_ID).size, + ) + .isEqualTo(2) + } + + @Test + fun getResources_shouldThrowResourceNotFoundExceptionIfAllResourcesNotFound() = runBlocking { + val resourceNotFoundException = + assertThrows(ResourceNotFoundException::class.java) { + runBlocking { database.selectResources(ResourceType.Patient, "id1", "id2") } + } + assertThat(resourceNotFoundException.message) + .isEqualTo("Resources not found with type Patient and ids id1,id2!") + } + private companion object { const val mockEpochTimeStamp = 1628516301000 const val TEST_PATIENT_1_ID = "test_patient_1" diff --git a/engine/src/main/java/com/google/android/fhir/FhirEngine.kt b/engine/src/main/java/com/google/android/fhir/FhirEngine.kt index 4b7ff2cb45..ea348beec9 100644 --- a/engine/src/main/java/com/google/android/fhir/FhirEngine.kt +++ b/engine/src/main/java/com/google/android/fhir/FhirEngine.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 Google LLC + * Copyright 2023-2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,6 +24,7 @@ import com.google.android.fhir.sync.upload.SyncUploadProgress import com.google.android.fhir.sync.upload.UploadRequestResult import com.google.android.fhir.sync.upload.UploadStrategy import java.time.OffsetDateTime +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType @@ -83,6 +84,17 @@ interface FhirEngine { @Throws(ResourceNotFoundException::class) suspend fun get(type: ResourceType, id: String): Resource + /** + * Loads multiple FHIR resources given [ResourceType] and logical IDs. + * + * @param type The type of the resource to load. + * @param ids The logical IDs of the resources. + * @return The list of requested FHIR resources. + * @throws ResourceNotFoundException if the resources are not found. + */ + @Throws(ResourceNotFoundException::class) + suspend fun getResources(type: ResourceType, vararg ids: String): List + /** * Updates one or more FHIR [Resource]s in the local storage. * @@ -227,6 +239,19 @@ suspend inline fun FhirEngine.get(id: String): R { return get(getResourceType(R::class.java), id) as R } +/** + * Retrieves FHIR resources of type [R] with the given [ids] from the local storage. + * + * @param R The type of the FHIR resource to retrieve. + * @param ids The logical IDs of the resources to retrieve. + * @return The list of requested FHIR resources. + * @throws ResourceNotFoundException if the resource is not found. + */ +@Throws(ResourceNotFoundException::class) +suspend inline fun FhirEngine.getResources(vararg ids: String): List { + return getResources(getResourceType(R::class.java), *ids).pmap(Dispatchers.Default) { it as R } +} + /** * Deletes a FHIR resource of type [R] with the given [id] from the local storage. * diff --git a/engine/src/main/java/com/google/android/fhir/db/Database.kt b/engine/src/main/java/com/google/android/fhir/db/Database.kt index 24e5c300f0..677228253c 100644 --- a/engine/src/main/java/com/google/android/fhir/db/Database.kt +++ b/engine/src/main/java/com/google/android/fhir/db/Database.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 Google LLC + * Copyright 2023-2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -84,6 +84,15 @@ internal interface Database { @Throws(ResourceNotFoundException::class) suspend fun select(type: ResourceType, id: String): Resource + /** + * Selects the FHIR resources of type `clazz` with `ids`. + * + * @param The resource type + * @throws ResourceNotFoundException if the resources are not found in the database + */ + @Throws(ResourceNotFoundException::class) + suspend fun selectResources(type: ResourceType, vararg ids: String): List + /** * Selects the saved `ResourceEntity` of type `clazz` with `id`. * diff --git a/engine/src/main/java/com/google/android/fhir/db/ResourceNotFoundException.kt b/engine/src/main/java/com/google/android/fhir/db/ResourceNotFoundException.kt index a97c90c879..83b7055d93 100644 --- a/engine/src/main/java/com/google/android/fhir/db/ResourceNotFoundException.kt +++ b/engine/src/main/java/com/google/android/fhir/db/ResourceNotFoundException.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021-2023 Google LLC + * Copyright 2021-2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -41,6 +41,13 @@ class ResourceNotFoundException : Exception { this.id = id } + constructor( + type: String, + vararg ids: String, + ) : super("Resources not found with type $type and ids ${ids.joinToString(",")}!") { + this.type = type + } + constructor( uuid: UUID, ) : super("Resource not found with UUID $uuid!") { diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/DatabaseImpl.kt b/engine/src/main/java/com/google/android/fhir/db/impl/DatabaseImpl.kt index 1e8333ea5b..3f24105277 100644 --- a/engine/src/main/java/com/google/android/fhir/db/impl/DatabaseImpl.kt +++ b/engine/src/main/java/com/google/android/fhir/db/impl/DatabaseImpl.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 Google LLC + * Copyright 2023-2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -199,6 +199,15 @@ internal class DatabaseImpl( ?: throw ResourceNotFoundException(type.name, id) } + override suspend fun selectResources(type: ResourceType, vararg ids: String): List { + val resources = + resourceDao.getResources(resourceIds = ids, resourceType = type)?.takeIf { it.isNotEmpty() } + ?: throw ResourceNotFoundException(type.name, *ids) + return resources.pmap(Dispatchers.Default) { + FhirContext.forR4Cached().newJsonParser().parseResource(it) as Resource + } + } + override suspend fun insertSyncedResources(resources: List) { db.withTransaction { insertRemote(*resources.toTypedArray()) } } diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/dao/ResourceDao.kt b/engine/src/main/java/com/google/android/fhir/db/impl/dao/ResourceDao.kt index 65015fc9c8..beba6557f8 100644 --- a/engine/src/main/java/com/google/android/fhir/db/impl/dao/ResourceDao.kt +++ b/engine/src/main/java/com/google/android/fhir/db/impl/dao/ResourceDao.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 Google LLC + * Copyright 2023-2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -202,6 +202,17 @@ internal abstract class ResourceDao { ) abstract suspend fun getResource(resourceId: String, resourceType: ResourceType): String? + @Query( + """ + SELECT serializedResource + FROM ResourceEntity + WHERE resourceId IN (:resourceIds) AND resourceType = :resourceType""", + ) + abstract suspend fun getResources( + vararg resourceIds: String, + resourceType: ResourceType, + ): List? + @Query( """ SELECT * diff --git a/engine/src/main/java/com/google/android/fhir/impl/FhirEngineImpl.kt b/engine/src/main/java/com/google/android/fhir/impl/FhirEngineImpl.kt index 595f433d9d..4fc5223724 100644 --- a/engine/src/main/java/com/google/android/fhir/impl/FhirEngineImpl.kt +++ b/engine/src/main/java/com/google/android/fhir/impl/FhirEngineImpl.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 Google LLC + * Copyright 2023-2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -52,6 +52,9 @@ internal class FhirEngineImpl(private val database: Database, private val contex override suspend fun get(type: ResourceType, id: String) = withContext(Dispatchers.IO) { database.select(type, id) } + override suspend fun getResources(type: ResourceType, vararg ids: String) = + withContext(Dispatchers.IO) { database.selectResources(type, *ids) } + override suspend fun update(vararg resource: Resource) = withContext(Dispatchers.IO) { database.update(*resource) } diff --git a/engine/src/main/java/com/google/android/fhir/testing/Utilities.kt b/engine/src/main/java/com/google/android/fhir/testing/Utilities.kt index 1b85a71382..51abe6218c 100644 --- a/engine/src/main/java/com/google/android/fhir/testing/Utilities.kt +++ b/engine/src/main/java/com/google/android/fhir/testing/Utilities.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 Google LLC + * Copyright 2023-2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -151,6 +151,10 @@ internal object TestFhirEngineImpl : FhirEngine { return Patient() } + override suspend fun getResources(type: ResourceType, vararg ids: String): List { + return ids.map { Patient() } + } + override suspend fun delete(type: ResourceType, id: String) {} override suspend fun search(search: Search): List> { diff --git a/engine/src/test/java/com/google/android/fhir/impl/FhirEngineImplTest.kt b/engine/src/test/java/com/google/android/fhir/impl/FhirEngineImplTest.kt index 64bfc6ace4..5e8080d62b 100644 --- a/engine/src/test/java/com/google/android/fhir/impl/FhirEngineImplTest.kt +++ b/engine/src/test/java/com/google/android/fhir/impl/FhirEngineImplTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 Google LLC + * Copyright 2023-2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,11 +20,13 @@ import androidx.test.core.app.ApplicationProvider import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.rest.gclient.TokenClientParam import ca.uhn.fhir.rest.param.ParamPrefixEnum +import com.google.android.fhir.FhirEngine import com.google.android.fhir.FhirServices.Companion.builder import com.google.android.fhir.LocalChange import com.google.android.fhir.LocalChange.Type import com.google.android.fhir.db.ResourceNotFoundException import com.google.android.fhir.get +import com.google.android.fhir.getResources import com.google.android.fhir.lastUpdated import com.google.android.fhir.logicalId import com.google.android.fhir.search.LOCAL_LAST_UPDATED_PARAM @@ -68,6 +70,8 @@ import org.junit.Assert.assertThrows import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.Mockito.`when` +import org.mockito.kotlin.mock import org.robolectric.RobolectricTestRunner /** Unit tests for [FhirEngineImpl]. */ @@ -94,6 +98,31 @@ class FhirEngineImplTest { assertResourceEquals(TEST_PATIENT_2, fhirEngine.get(TEST_PATIENT_2_ID)) } + @Test + fun create_isLocalOnlyTrue_shouldCreateResourceWithoutFlaggingLocalChange() = runBlocking { + val totalLocalChangesPatient2 = + fhirEngine.getLocalChanges(ResourceType.Patient, TEST_PATIENT_2_ID).size + fhirEngine.create(TEST_PATIENT_2, isLocalOnly = true) + assertResourceEquals(TEST_PATIENT_2, fhirEngine.get(TEST_PATIENT_2_ID)) + assertThat(fhirEngine.getLocalChanges(ResourceType.Patient, TEST_PATIENT_2_ID).size) + .isEqualTo(totalLocalChangesPatient2) + } + + @Test + fun createAll_isLocalOnlyTrue_shouldCreateResourceWithoutFlaggingLocalChange() = runBlocking { + val totalLocalChangesPatient1 = + fhirEngine.getLocalChanges(ResourceType.Patient, TEST_PATIENT_1_ID).size + val totalLocalChangesPatient2 = + fhirEngine.getLocalChanges(ResourceType.Patient, TEST_PATIENT_2_ID).size + fhirEngine.create(TEST_PATIENT_1, TEST_PATIENT_2, isLocalOnly = true) + assertResourceEquals(TEST_PATIENT_1, fhirEngine.get(TEST_PATIENT_1_ID)) + assertResourceEquals(TEST_PATIENT_2, fhirEngine.get(TEST_PATIENT_2_ID)) + assertThat(fhirEngine.getLocalChanges(ResourceType.Patient, TEST_PATIENT_1_ID).size) + .isEqualTo(totalLocalChangesPatient1) + assertThat(fhirEngine.getLocalChanges(ResourceType.Patient, TEST_PATIENT_2_ID).size) + .isEqualTo(totalLocalChangesPatient2) + } + @Test fun create_resourceWithoutId_shouldCreateResourceWithAssignedId() = runBlocking { val patient = @@ -897,6 +926,49 @@ class FhirEngineImplTest { } } + @Test + fun `get returns a single resource`() = runTest { + val fhirEngine = mock() + val expectedPatient = Patient().apply { id = TEST_PATIENT_1_ID } + + `when`(fhirEngine.get(ResourceType.Patient, TEST_PATIENT_1_ID)).thenReturn(expectedPatient) + + assertThat(fhirEngine.get(TEST_PATIENT_1_ID)).isEqualTo(expectedPatient) + } + + @Test + fun `get throws ResourceNotFoundException when resource is not found`() = runTest { + val resourceNotFoundException = + assertThrows(ResourceNotFoundException::class.java) { + runBlocking { fhirEngine.get("id1") } + } + assertThat(resourceNotFoundException.message) + .isEqualTo("Resource not found with type Patient and id id1!") + } + + @Test + fun `getResources returns a list of resources`() = runTest { + val patientIds = arrayOf(TEST_PATIENT_1_ID, TEST_PATIENT_2_ID) + val expectedPatients = listOf(Patient(), Patient()) + val fhirEngine = mock() + + `when`(fhirEngine.getResources(ResourceType.Patient, *patientIds)).thenReturn(expectedPatients) + + val resources = fhirEngine.getResources(*patientIds) + assertThat(resources).isEqualTo(expectedPatients) + } + + @Test + fun `getResources throws ResourceNotFoundException when no resources with ids are found`() = + runTest { + val resourceNotFoundException = + assertThrows(ResourceNotFoundException::class.java) { + runBlocking { fhirEngine.getResources("id1", "id2") } + } + assertThat(resourceNotFoundException.message) + .isEqualTo("Resources not found with type Patient and ids id1,id2!") + } + companion object { private const val TEST_PATIENT_1_ID = "test_patient_1" private var TEST_PATIENT_1 =