From 8c97ba69dfc17542e6988f36a62ef998e2dc4d60 Mon Sep 17 00:00:00 2001 From: Roy <84201465+roywanyaga@users.noreply.github.com> Date: Wed, 13 Sep 2023 19:48:59 +0300 Subject: [PATCH 1/7] add tests to sync classes (#1505) sync/download/ResourceParamsBasedDownloadWorkManagerTest.kt, sync/upload/BundleUploaderTest.kt, /sync/upload/TransactionBundleGeneratorTest.kt Co-authored-by: Benjamin Mwalimu --- .../ResourceParamsBasedDownloadWorkManager.kt | 46 +-- .../request/TransactionBundleGenerator.kt | 2 +- ...ourceParamsBasedDownloadWorkManagerTest.kt | 350 +++++++++++------- .../fhir/sync/upload/UploaderImplTest.kt | 56 ++- .../request/TransactionBundleGeneratorTest.kt | 143 +++++++ 5 files changed, 420 insertions(+), 177 deletions(-) diff --git a/engine/src/main/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManager.kt b/engine/src/main/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManager.kt index de7052104f..94c95c0b23 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManager.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManager.kt @@ -102,30 +102,30 @@ class ResourceParamsBasedDownloadWorkManager( throw FHIRException(response.issueFirstRep.diagnostics) } - return if (response is Bundle && response.type == Bundle.BundleType.SEARCHSET) { - response.link - .firstOrNull { component -> component.relation == "next" } - ?.url?.let { next -> urlOfTheNextPagesToDownloadForAResource.add(next) } - - response.entry - .map { it.resource } - .also { resources -> - resources - .groupBy { it.resourceType } - .entries.map { map -> - map.value - .filter { it.meta.lastUpdated != null } - .let { - context.saveLastUpdatedTimestamp( - map.key, - it.maxOfOrNull { it.meta.lastUpdated }?.toTimeZoneString() - ) - } - } - } - } else { - emptyList() + if ((response !is Bundle || response.type != Bundle.BundleType.SEARCHSET)) { + return emptyList() } + + response.link + .firstOrNull { component -> component.relation == "next" } + ?.url?.let { next -> urlOfTheNextPagesToDownloadForAResource.add(next) } + + return response.entry + .map { it.resource } + .also { resources -> + resources + .groupBy { it.resourceType } + .entries.map { map -> + map.value + .filter { it.meta.lastUpdated != null } + .let { + context.saveLastUpdatedTimestamp( + map.key, + it.maxOfOrNull { it.meta.lastUpdated }?.toTimeZoneString() + ) + } + } + } } interface TimestampContext { diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/request/TransactionBundleGenerator.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/request/TransactionBundleGenerator.kt index 2f4a698b5f..b068c4a222 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/upload/request/TransactionBundleGenerator.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/request/TransactionBundleGenerator.kt @@ -76,7 +76,7 @@ class TransactionBundleGenerator( httpVerbToUseForCreate: Bundle.HTTPVerb, httpVerbToUseForUpdate: Bundle.HTTPVerb, generatedBundleSize: Int, - useETagForUpload: Boolean + useETagForUpload: Boolean, ): TransactionBundleGenerator { val createFunction = diff --git a/engine/src/test/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManagerTest.kt b/engine/src/test/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManagerTest.kt index 53dd0d79d2..e59cb98ae1 100644 --- a/engine/src/test/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManagerTest.kt +++ b/engine/src/test/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManagerTest.kt @@ -17,11 +17,14 @@ package com.google.android.fhir.sync.download import com.google.android.fhir.logicalId +import com.google.android.fhir.sync.DownloadRequest import com.google.android.fhir.sync.SyncDataParams import com.google.android.fhir.sync.UrlDownloadRequest import com.google.common.truth.Truth.assertThat -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest import org.hl7.fhir.exceptions.FHIRException +import org.hl7.fhir.r4.model.Binary import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.OperationOutcome import org.hl7.fhir.r4.model.Patient @@ -35,78 +38,80 @@ import org.robolectric.RobolectricTestRunner class ResourceParamsBasedDownloadWorkManagerTest { @Test - fun getNextRequestUrl_shouldReturnNextResourceUrls() = runBlockingTest { - val downloadManager = - ResourceParamsBasedDownloadWorkManager( - mapOf( - ResourceType.Patient to mapOf(Patient.ADDRESS_CITY.paramName to "NAIROBI"), - ResourceType.Immunization to emptyMap(), - ResourceType.Observation to emptyMap(), - ), - TestResourceParamsBasedDownloadWorkManagerContext("2022-03-20") - ) + fun getNextRequestUrl_shouldReturnNextResourceUrls() = + runTest(StandardTestDispatcher()) { + val downloadManager = + ResourceParamsBasedDownloadWorkManager( + mapOf( + ResourceType.Patient to mapOf(Patient.ADDRESS_CITY.paramName to "NAIROBI"), + ResourceType.Immunization to emptyMap(), + ResourceType.Observation to emptyMap(), + ), + TestResourceParamsBasedDownloadWorkManagerContext("2022-03-20") + ) - val urlsToDownload = mutableListOf() - do { - val url = downloadManager.getNextRequest()?.let { (it as UrlDownloadRequest).url } - if (url != null) { - urlsToDownload.add(url) - } - } while (url != null) + val urlsToDownload = mutableListOf() + do { + val url = downloadManager.getNextRequest()?.let { (it as UrlDownloadRequest).url } + if (url != null) { + urlsToDownload.add(url) + } + } while (url != null) - assertThat(urlsToDownload) - .containsExactly( - "Patient?address-city=NAIROBI&_sort=_lastUpdated&_lastUpdated=gt2022-03-20", - "Observation?_sort=_lastUpdated&_lastUpdated=gt2022-03-20", - "Immunization?_sort=_lastUpdated&_lastUpdated=gt2022-03-20" - ) - } + assertThat(urlsToDownload) + .containsExactly( + "Patient?address-city=NAIROBI&_sort=_lastUpdated&_lastUpdated=gt2022-03-20", + "Observation?_sort=_lastUpdated&_lastUpdated=gt2022-03-20", + "Immunization?_sort=_lastUpdated&_lastUpdated=gt2022-03-20" + ) + } @Test - fun getNextRequestUrl_shouldReturnResourceAndPageUrlsAsNextUrls() = runBlockingTest { - val downloadManager = - ResourceParamsBasedDownloadWorkManager( - mapOf(ResourceType.Patient to emptyMap(), ResourceType.Observation to emptyMap()), - TestResourceParamsBasedDownloadWorkManagerContext("2022-03-20") - ) + fun getNextRequestUrl_shouldReturnResourceAndPageUrlsAsNextUrls() = + runTest(StandardTestDispatcher()) { + val downloadManager = + ResourceParamsBasedDownloadWorkManager( + mapOf(ResourceType.Patient to emptyMap(), ResourceType.Observation to emptyMap()), + TestResourceParamsBasedDownloadWorkManagerContext("2022-03-20") + ) - val urlsToDownload = mutableListOf() - do { - val url = downloadManager.getNextRequest()?.let { (it as UrlDownloadRequest).url } - if (url != null) { - urlsToDownload.add(url) - } - // Call process response so that It can add the next page url to be downloaded next. - when (url) { - "Patient?_sort=_lastUpdated&_lastUpdated=gt2022-03-20", - "Observation?_sort=_lastUpdated&_lastUpdated=gt2022-03-20" -> { - downloadManager.processResponse( - Bundle().apply { - type = Bundle.BundleType.SEARCHSET - addLink( - Bundle.BundleLinkComponent().apply { - relation = "next" - this.url = "http://url-to-next-page?token=pageToken" - } - ) - } - ) + val urlsToDownload = mutableListOf() + do { + val url = downloadManager.getNextRequest()?.let { (it as UrlDownloadRequest).url } + if (url != null) { + urlsToDownload.add(url) } - } - } while (url != null) - - assertThat(urlsToDownload) - .containsExactly( - "Patient?_sort=_lastUpdated&_lastUpdated=gt2022-03-20", - "http://url-to-next-page?token=pageToken", - "Observation?_sort=_lastUpdated&_lastUpdated=gt2022-03-20", - "http://url-to-next-page?token=pageToken" - ) - } + // Call process response so that It can add the next page url to be downloaded next. + when (url) { + "Patient?_sort=_lastUpdated&_lastUpdated=gt2022-03-20", + "Observation?_sort=_lastUpdated&_lastUpdated=gt2022-03-20" -> { + downloadManager.processResponse( + Bundle().apply { + type = Bundle.BundleType.SEARCHSET + addLink( + Bundle.BundleLinkComponent().apply { + relation = "next" + this.url = "http://url-to-next-page?token=pageToken" + } + ) + } + ) + } + } + } while (url != null) + + assertThat(urlsToDownload) + .containsExactly( + "Patient?_sort=_lastUpdated&_lastUpdated=gt2022-03-20", + "http://url-to-next-page?token=pageToken", + "Observation?_sort=_lastUpdated&_lastUpdated=gt2022-03-20", + "http://url-to-next-page?token=pageToken" + ) + } @Test fun getNextRequestUrl_withLastUpdatedTimeProvidedInContext_ShouldAppendGtPrefixToLastUpdatedSearchParam() = - runBlockingTest { + runTest(StandardTestDispatcher()) { val downloadManager = ResourceParamsBasedDownloadWorkManager( mapOf(ResourceType.Patient to emptyMap()), @@ -118,7 +123,7 @@ class ResourceParamsBasedDownloadWorkManagerTest { @Test fun getNextRequestUrl_withLastUpdatedSyncParamProvided_shouldReturnUrlWithExactProvidedLastUpdatedSyncParam() = - runBlockingTest { + runTest(StandardTestDispatcher()) { val downloadManager = ResourceParamsBasedDownloadWorkManager( mapOf( @@ -136,7 +141,7 @@ class ResourceParamsBasedDownloadWorkManagerTest { @Test fun getNextRequestUrl_withLastUpdatedSyncParamHavingGtPrefix_shouldReturnUrlWithExactProvidedLastUpdatedSyncParam() = - runBlockingTest { + runTest(StandardTestDispatcher()) { val downloadManager = ResourceParamsBasedDownloadWorkManager( mapOf(ResourceType.Patient to mapOf(SyncDataParams.LAST_UPDATED_KEY to "gt2022-06-28")), @@ -148,7 +153,7 @@ class ResourceParamsBasedDownloadWorkManagerTest { @Test fun getNextRequestUrl_withNullUpdatedTimeStamp_shouldReturnUrlWithoutLastUpdatedQueryParam() = - runBlockingTest { + runTest(StandardTestDispatcher()) { val downloadManager = ResourceParamsBasedDownloadWorkManager( mapOf(ResourceType.Patient to mapOf(Patient.ADDRESS_CITY.paramName to "NAIROBI")), @@ -160,7 +165,7 @@ class ResourceParamsBasedDownloadWorkManagerTest { @Test fun getNextRequestUrl_withEmptyUpdatedTimeStamp_shouldReturnUrlWithoutLastUpdatedQueryParam() = - runBlockingTest { + runTest(StandardTestDispatcher()) { val downloadManager = ResourceParamsBasedDownloadWorkManager( mapOf(ResourceType.Patient to mapOf(Patient.ADDRESS_CITY.paramName to "NAIROBI")), @@ -171,82 +176,32 @@ class ResourceParamsBasedDownloadWorkManagerTest { } @Test - fun `getSummaryRequestUrls should return resource summary urls`() = runBlockingTest { - val downloadManager = - ResourceParamsBasedDownloadWorkManager( - mapOf( - ResourceType.Patient to mapOf(Patient.ADDRESS_CITY.paramName to "NAIROBI"), - ResourceType.Immunization to emptyMap(), - ResourceType.Observation to emptyMap(), - ), - TestResourceParamsBasedDownloadWorkManagerContext("2022-03-20") - ) - - val urls = downloadManager.getSummaryRequestUrls() - - assertThat(urls.map { it.key }) - .containsExactly(ResourceType.Patient, ResourceType.Immunization, ResourceType.Observation) - assertThat(urls.map { it.value }) - .containsExactly( - "Patient?address-city=NAIROBI&_sort=_lastUpdated&_lastUpdated=gt2022-03-20&_summary=count", - "Immunization?_sort=_lastUpdated&_lastUpdated=gt2022-03-20&_summary=count", - "Observation?_sort=_lastUpdated&_lastUpdated=gt2022-03-20&_summary=count" - ) - } - - @Test - fun processResponse_withBundleTypeSearchSet_shouldReturnPatient() = runBlockingTest { - val downloadManager = - ResourceParamsBasedDownloadWorkManager( - emptyMap(), - NoOpResourceParamsBasedDownloadWorkManagerContext - ) - val response = - Bundle().apply { - type = Bundle.BundleType.SEARCHSET - addEntry( - Bundle.BundleEntryComponent().apply { - resource = Patient().apply { id = "Patient-Id-001" } - } - ) - addEntry( - Bundle.BundleEntryComponent().apply { - resource = Patient().apply { id = "Patient-Id-002" } - } + fun `getSummaryRequestUrls should return resource summary urls`() = + runTest(StandardTestDispatcher()) { + val downloadManager = + ResourceParamsBasedDownloadWorkManager( + mapOf( + ResourceType.Patient to mapOf(Patient.ADDRESS_CITY.paramName to "NAIROBI"), + ResourceType.Immunization to emptyMap(), + ResourceType.Observation to emptyMap(), + ), + TestResourceParamsBasedDownloadWorkManagerContext("2022-03-20") ) - } - val resources = downloadManager.processResponse(response) - assertThat(resources.map { it.logicalId }).containsExactly("Patient-Id-001", "Patient-Id-002") - } - @Test - fun processResponse_withTransactionResponseBundle_shouldReturnEmptyList() = runBlockingTest { - val downloadManager = - ResourceParamsBasedDownloadWorkManager( - emptyMap(), - NoOpResourceParamsBasedDownloadWorkManagerContext - ) - val response = - Bundle().apply { - type = Bundle.BundleType.TRANSACTIONRESPONSE - addEntry( - Bundle.BundleEntryComponent().apply { - resource = Patient().apply { id = "Patient-Id-001" } - } - ) - addEntry( - Bundle.BundleEntryComponent().apply { - resource = Patient().apply { id = "Patient-Id-002" } - } - ) - } + val urls = downloadManager.getSummaryRequestUrls() - val resources = downloadManager.processResponse(response) - assertThat(resources).hasSize(0) - } + assertThat(urls.map { it.key }) + .containsExactly(ResourceType.Patient, ResourceType.Immunization, ResourceType.Observation) + assertThat(urls.map { it.value }) + .containsExactly( + "Patient?address-city=NAIROBI&_sort=_lastUpdated&_lastUpdated=gt2022-03-20&_summary=count", + "Immunization?_sort=_lastUpdated&_lastUpdated=gt2022-03-20&_summary=count", + "Observation?_sort=_lastUpdated&_lastUpdated=gt2022-03-20&_summary=count" + ) + } @Test - fun processResponse_withOperationOutcome_shouldThrowException() { + fun `processResponse should throw exception including diagnostics from operation outcome`() { val downloadManager = ResourceParamsBasedDownloadWorkManager( emptyMap(), @@ -263,10 +218,125 @@ class ResourceParamsBasedDownloadWorkManagerTest { val exception = assertThrows(FHIRException::class.java) { - runBlockingTest { downloadManager.processResponse(response) } + runTest(StandardTestDispatcher()) { downloadManager.processResponse(response) } } + assertThat(exception.localizedMessage).isEqualTo("Server couldn't fulfil the request.") } + + @Test + fun `processResponse should return empty list for resource that is not a bundle`() = + runTest(StandardTestDispatcher()) { + val downloadManager = + ResourceParamsBasedDownloadWorkManager( + emptyMap(), + NoOpResourceParamsBasedDownloadWorkManagerContext + ) + val response = Binary().apply { contentType = "application/json" } + + assertThat(downloadManager.processResponse(response)).isEmpty() + } + + @Test + fun `processResponse should return empty list for bundle that is not a search set`() = + runTest(StandardTestDispatcher()) { + val downloadManager = + ResourceParamsBasedDownloadWorkManager( + emptyMap(), + NoOpResourceParamsBasedDownloadWorkManagerContext + ) + val response = + Bundle().apply { + type = Bundle.BundleType.TRANSACTIONRESPONSE + addEntry( + Bundle.BundleEntryComponent().apply { + resource = Patient().apply { id = "Patient-Id-001" } + } + ) + addEntry( + Bundle.BundleEntryComponent().apply { + resource = Patient().apply { id = "Patient-Id-002" } + } + ) + } + + assertThat(downloadManager.processResponse(response)).isEmpty() + } + + @Test + fun `processResponse should return resources for bundle search set`() = + runTest(StandardTestDispatcher()) { + val downloadManager = + ResourceParamsBasedDownloadWorkManager( + emptyMap(), + NoOpResourceParamsBasedDownloadWorkManagerContext + ) + val response = + Bundle().apply { + type = Bundle.BundleType.SEARCHSET + addEntry( + Bundle.BundleEntryComponent().apply { + resource = Patient().apply { id = "Patient-Id-001" } + } + ) + addEntry( + Bundle.BundleEntryComponent().apply { + resource = Patient().apply { id = "Patient-Id-002" } + } + ) + } + + assertThat(downloadManager.processResponse(response).map { it.logicalId }) + .containsExactly("Patient-Id-001", "Patient-Id-002") + } + + @Test + fun `processResponse should add next request`() = + runTest(StandardTestDispatcher()) { + val downloadManager = + ResourceParamsBasedDownloadWorkManager( + emptyMap(), + NoOpResourceParamsBasedDownloadWorkManagerContext + ) + val response = + Bundle().apply { + type = Bundle.BundleType.SEARCHSET + link = + mutableListOf( + Bundle.BundleLinkComponent().apply { + relation = "next" + url = "next_url" + } + ) + } + + downloadManager.processResponse(response) + + assertThat(downloadManager.getNextRequest()).isEqualTo(DownloadRequest.of("next_url")) + } + + @Test + fun `processResponse should not add next request if next url is missing`() = + runTest(StandardTestDispatcher()) { + val downloadManager = + ResourceParamsBasedDownloadWorkManager( + emptyMap(), + NoOpResourceParamsBasedDownloadWorkManagerContext + ) + val response = + Bundle().apply { + type = Bundle.BundleType.SEARCHSET + addEntry( + Bundle.BundleEntryComponent().apply { + resource = Patient().apply { id = "Patient-Id-001" } + } + ) + } + + downloadManager.processResponse(response) + + assertThat(downloadManager.getNextRequest()).isNull() + } } val NoOpResourceParamsBasedDownloadWorkManagerContext = diff --git a/engine/src/test/java/com/google/android/fhir/sync/upload/UploaderImplTest.kt b/engine/src/test/java/com/google/android/fhir/sync/upload/UploaderImplTest.kt index 49b75a8eb7..bc2da80244 100644 --- a/engine/src/test/java/com/google/android/fhir/sync/upload/UploaderImplTest.kt +++ b/engine/src/test/java/com/google/android/fhir/sync/upload/UploaderImplTest.kt @@ -40,7 +40,17 @@ import org.robolectric.RobolectricTestRunner class UploaderImplTest { @Test - fun `upload Bundle transaction should emit Success`() = runBlocking { + fun `upload should start`() = runBlocking { + val result = + UploaderImpl(BundleDataSource { Bundle() }, SquashedChangesUploadWorkManager()) + .upload(localChanges) + .toList() + + assertThat(result.first()).isInstanceOf(UploadState.Started::class.java) + } + + @Test + fun `upload should succeed if response is transaction response`() = runBlocking { val result = UploaderImpl( BundleDataSource { Bundle().apply { type = Bundle.BundleType.TRANSACTIONRESPONSE } }, @@ -59,17 +69,7 @@ class UploaderImplTest { } @Test - fun `upload Bundle transaction should emit Started state`() = runBlocking { - val result = - UploaderImpl(BundleDataSource { Bundle() }, SquashedChangesUploadWorkManager()) - .upload(localChanges) - .toList() - - assertThat(result.first()).isInstanceOf(UploadState.Started::class.java) - } - - @Test - fun `upload Bundle Transaction server error should emit Failure`() = runBlocking { + fun `upload should fail if response is operation outcome with issue`() = runBlocking { val result = UploaderImpl( BundleDataSource { @@ -93,7 +93,36 @@ class UploaderImplTest { } @Test - fun `upload Bundle transaction error during upload should emit Failure`() = runBlocking { + fun `upload should fail if response is empty operation outcome`() = runBlocking { + val result = + UploaderImpl( + BundleDataSource { OperationOutcome() }, + SquashedChangesUploadWorkManager(), + ) + .upload(localChanges) + .toList() + + assertThat(result).hasSize(2) + assertThat(result.last()).isInstanceOf(UploadState.Failure::class.java) + } + + @Test + fun `upload should fail if response is neither transaction response nor operation outcome`() = + runBlocking { + val result = + UploaderImpl( + BundleDataSource { Bundle().apply { type = Bundle.BundleType.SEARCHSET } }, + SquashedChangesUploadWorkManager(), + ) + .upload(localChanges) + .toList() + + assertThat(result).hasSize(2) + assertThat(result.last()).isInstanceOf(UploadState.Failure::class.java) + } + + @Test + fun `upload should fail if there is connection exception`() = runBlocking { val result = UploaderImpl( BundleDataSource { throw ConnectException("Failed to connect to server.") }, @@ -105,6 +134,7 @@ class UploaderImplTest { assertThat(result).hasSize(2) assertThat(result.last()).isInstanceOf(UploadState.Failure::class.java) } + companion object { val localChanges = listOf( diff --git a/engine/src/test/java/com/google/android/fhir/sync/upload/request/TransactionBundleGeneratorTest.kt b/engine/src/test/java/com/google/android/fhir/sync/upload/request/TransactionBundleGeneratorTest.kt index e9db8c43cf..56e41e72f9 100644 --- a/engine/src/test/java/com/google/android/fhir/sync/upload/request/TransactionBundleGeneratorTest.kt +++ b/engine/src/test/java/com/google/android/fhir/sync/upload/request/TransactionBundleGeneratorTest.kt @@ -27,6 +27,7 @@ import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.HumanName import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.ResourceType +import org.junit.Assert.assertThrows import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -292,4 +293,146 @@ class TransactionBundleGeneratorTest { assertThat(result.first().resource.entry[0].request.ifMatch).isNull() assertThat(result.first().resource.entry[1].request.ifMatch).isNull() } + + @Test + fun `getGenerator() with supported Bundle HTTPVerbs should return TransactionBundleGenerator`() = + runBlocking { + val generator = + TransactionBundleGenerator.Factory.getGenerator( + Bundle.HTTPVerb.PUT, + Bundle.HTTPVerb.PATCH, + generatedBundleSize = 500, + useETagForUpload = true, + ) + + assertThat(generator).isInstanceOf(TransactionBundleGenerator::class.java) + } + + @Test + fun `getGenerator() should through exception for create by DELETE`() { + val exception = + assertThrows(IllegalArgumentException::class.java) { + runBlocking { + TransactionBundleGenerator.Factory.getGenerator( + Bundle.HTTPVerb.DELETE, + Bundle.HTTPVerb.PATCH, + generatedBundleSize = 500, + useETagForUpload = true, + ) + } + } + assertThat(exception.localizedMessage).isEqualTo("Creation using DELETE is not supported.") + } + + @Test + fun `getGenerator() should through exception for create by GET`() { + val exception = + assertThrows(IllegalArgumentException::class.java) { + runBlocking { + TransactionBundleGenerator.Factory.getGenerator( + Bundle.HTTPVerb.GET, + Bundle.HTTPVerb.PATCH, + generatedBundleSize = 500, + useETagForUpload = true, + ) + } + } + assertThat(exception.localizedMessage).isEqualTo("Creation using GET is not supported.") + } + + @Test + fun `getGenerator() should through exception for create by PATCH`() { + val exception = + assertThrows(IllegalArgumentException::class.java) { + runBlocking { + TransactionBundleGenerator.Factory.getGenerator( + Bundle.HTTPVerb.PATCH, + Bundle.HTTPVerb.PATCH, + generatedBundleSize = 500, + useETagForUpload = true, + ) + } + } + assertThat(exception.localizedMessage).isEqualTo("Creation using PATCH is not supported.") + } + + @Test + fun `getGenerator() should through exception for create by POST`() { + val exception = + assertThrows(IllegalArgumentException::class.java) { + runBlocking { + TransactionBundleGenerator.Factory.getGenerator( + Bundle.HTTPVerb.POST, + Bundle.HTTPVerb.PATCH, + generatedBundleSize = 500, + useETagForUpload = true, + ) + } + } + assertThat(exception.localizedMessage).isEqualTo("Creation using POST is not supported.") + } + + @Test + fun `getGenerator() should through exception for update by DELETE`() { + val exception = + assertThrows(IllegalArgumentException::class.java) { + runBlocking { + TransactionBundleGenerator.Factory.getGenerator( + Bundle.HTTPVerb.PUT, + Bundle.HTTPVerb.DELETE, + generatedBundleSize = 500, + useETagForUpload = true, + ) + } + } + assertThat(exception.localizedMessage).isEqualTo("Update using DELETE is not supported.") + } + + @Test + fun `getGenerator() should through exception for update by GET`() { + val exception = + assertThrows(IllegalArgumentException::class.java) { + runBlocking { + TransactionBundleGenerator.Factory.getGenerator( + Bundle.HTTPVerb.PUT, + Bundle.HTTPVerb.GET, + generatedBundleSize = 500, + useETagForUpload = true, + ) + } + } + assertThat(exception.localizedMessage).isEqualTo("Update using GET is not supported.") + } + + @Test + fun `getGenerator() should through exception for update by POST`() { + val exception = + assertThrows(IllegalArgumentException::class.java) { + runBlocking { + TransactionBundleGenerator.Factory.getGenerator( + Bundle.HTTPVerb.PUT, + Bundle.HTTPVerb.POST, + generatedBundleSize = 500, + useETagForUpload = true, + ) + } + } + assertThat(exception.localizedMessage).isEqualTo("Update using POST is not supported.") + } + + @Test + fun `getGenerator() should through exception for update by PUT`() { + val exception = + assertThrows(IllegalArgumentException::class.java) { + runBlocking { + TransactionBundleGenerator.Factory.getGenerator( + Bundle.HTTPVerb.PUT, + Bundle.HTTPVerb.PUT, + generatedBundleSize = 500, + useETagForUpload = true, + ) + } + } + assertThat(exception.localizedMessage).isEqualTo("Update using PUT is not supported.") + } } From 22e8a9c103c1f11201b5dd884b398cce1c0dce89 Mon Sep 17 00:00:00 2001 From: Omar Ismail <44980219+omarismail94@users.noreply.github.com> Date: Wed, 13 Sep 2023 18:26:34 +0100 Subject: [PATCH 2/7] update spotless (#2167) --- buildSrc/build.gradle.kts | 2 +- buildSrc/src/main/kotlin/SpotlessConfig.kt | 17 +++-------------- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index d423ebb078..495fa527bb 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -9,7 +9,7 @@ repositories { } dependencies { - implementation("com.diffplug.spotless:spotless-plugin-gradle:6.6.0") + implementation("com.diffplug.spotless:spotless-plugin-gradle:6.21.0") implementation("com.android.tools.build:gradle:8.1.1") diff --git a/buildSrc/src/main/kotlin/SpotlessConfig.kt b/buildSrc/src/main/kotlin/SpotlessConfig.kt index 6f699f22bc..9869d97e61 100644 --- a/buildSrc/src/main/kotlin/SpotlessConfig.kt +++ b/buildSrc/src/main/kotlin/SpotlessConfig.kt @@ -19,7 +19,7 @@ import org.gradle.kotlin.dsl.apply import org.gradle.kotlin.dsl.configure fun Project.configureSpotless() { - val ktlintVersion = "0.41.0" + val ktlintVersion = "0.50.0" val ktlintOptions = mapOf("indent_size" to "2", "continuation_indent_size" to "2") apply(plugin = Plugins.BuildPlugins.spotless) configure { @@ -32,10 +32,10 @@ fun Project.configureSpotless() { ktfmt().googleStyle() licenseHeaderFile( "${project.rootProject.projectDir}/license-header.txt", - "package|import|class|object|sealed|open|interface|abstract " + "package|import|class|object|sealed|open|interface|abstract ", // It is necessary to tell spotless the top level of a file in order to apply config to it // See: https://github.com/diffplug/spotless/issues/135 - ) + ) toggleOffOn() } kotlinGradle { @@ -49,16 +49,5 @@ fun Project.configureSpotless() { prettier(mapOf("prettier" to "2.0.5", "@prettier/plugin-xml" to "0.13.0")) .config(mapOf("parser" to "xml", "tabWidth" to 4)) } - // Creates one off SpotlessApply task for generated files - com.diffplug.gradle.spotless.KotlinExtension(this).apply { - target("**/*_Generated.kt") - ktlint(ktlintVersion).userData(ktlintOptions) - ktfmt().googleStyle() - licenseHeaderFile( - "${project.rootProject.projectDir}/license-header.txt", - "package|import|class|object|sealed|open|interface|abstract " - ) - createIndependentApplyTask("spotlessGenerated") - } } } From 66accf7ef239bce3ecb80ecb63f26f44cac36b7c Mon Sep 17 00:00:00 2001 From: Maimoona Kausar <4829880+maimoonak@users.noreply.github.com> Date: Thu, 14 Sep 2023 00:48:04 +0500 Subject: [PATCH 3/7] Allow usage of variable and context in answer expression (#2039) * Allow usage of variable and context in answer expression * Add test * resolve conflicts * resolve conflicts and handle enabled items options * Extend variable context test * Resolve merge conflicts * Breakdown fhirpath supplement context test --------- Co-authored-by: Francis Odhiambo <4540684+f-odhiambo@users.noreply.github.com> Co-authored-by: Jing Tang --- .../EnabledAnswerOptionsEvaluator.kt | 56 +- .../datacapture/QuestionnaireViewModelTest.kt | 1391 +++++++++-------- 2 files changed, 811 insertions(+), 636 deletions(-) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/expressions/EnabledAnswerOptionsEvaluator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/expressions/EnabledAnswerOptionsEvaluator.kt index fa11cca5ab..73a46304cc 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/expressions/EnabledAnswerOptionsEvaluator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/expressions/EnabledAnswerOptionsEvaluator.kt @@ -48,7 +48,7 @@ import org.hl7.fhir.r4.model.ValueSet * @param questionnaireResponse the [QuestionnaireResponse] related to the [Questionnaire] * @param xFhirQueryResolver the [XFhirQueryResolver] to resolve resources based on the X-FHIR-Query * @param externalValueSetResolver the [ExternalAnswerValueSetResolver] to resolve value sets - * externally/outside of the [Questionnaire] + * externally/outside of the [Questionnaire] * @param questionnaireItemParentMap the [Map] of items parent * @param questionnaireLaunchContextMap the [Map] of launchContext names to their resource values */ @@ -68,7 +68,7 @@ internal class EnabledAnswerOptionsEvaluator( questionnaire, questionnaireResponse, questionnaireItemParentMap, - questionnaireLaunchContextMap + questionnaireLaunchContextMap, ) private val answerValueSetMap = @@ -94,13 +94,19 @@ internal class EnabledAnswerOptionsEvaluator( questionnaireResponseItem: QuestionnaireResponseItemComponent, ): Pair< List, - List + List, > { + val resolvedAnswerOptions = + answerOptions( + questionnaireItem, + questionnaireResponseItem, + questionnaireResponse, + questionnaireItemParentMap, + ) - val resolvedAnswerOptions = answerOptions(questionnaireItem) - - if (questionnaireItem.answerOptionsToggleExpressions.isEmpty()) + if (questionnaireItem.answerOptionsToggleExpressions.isEmpty()) { return Pair(resolvedAnswerOptions, emptyList()) + } val enabledQuestionnaireAnswerOptions = evaluateAnswerOptionsToggleExpressions( @@ -121,29 +127,37 @@ internal class EnabledAnswerOptionsEvaluator( /** * In a `choice` or `open-choice` type question, the answer options are defined in one of the * three elements in the questionnaire: - * * - `Questionnaire.item.answerOption`: a list of permitted answers to the question * - `Questionnaire.item.answerValueSet`: a reference to a value set containing a list of - * permitted answers to the question + * permitted answers to the question * - `Extension answer-expression`: an expression based extension which defines the x-fhir-query - * or fhirpath to evaluate permitted answer options + * or fhirpath to evaluate permitted answer options * * Returns the answer options defined in one of the sources above. If the answer options are * defined in `Questionnaire.item.answerValueSet`, the answer value set will be expanded. */ private suspend fun answerOptions( questionnaireItem: QuestionnaireItemComponent, + questionnaireResponseItem: QuestionnaireResponseItemComponent, + questionnaireResponse: QuestionnaireResponse, + questionnaireItemParentMap: Map, ): List = when { questionnaireItem.answerOption.isNotEmpty() -> questionnaireItem.answerOption !questionnaireItem.answerValueSet.isNullOrEmpty() -> resolveAnswerValueSet(questionnaireItem.answerValueSet) - questionnaireItem.answerExpression != null -> resolveAnswerExpression(questionnaireItem) + questionnaireItem.answerExpression != null -> + resolveAnswerExpression( + questionnaireItem, + questionnaireResponseItem, + questionnaireResponse, + questionnaireItemParentMap, + ) else -> emptyList() } private suspend fun resolveAnswerValueSet( - uri: String + uri: String, ): List { // If cache hit, return it if (answerValueSetMap.contains(uri)) { @@ -164,7 +178,7 @@ internal class EnabledAnswerOptionsEvaluator( .filterNot { it.abstract || it.inactive } .map { component -> Questionnaire.QuestionnaireItemAnswerOptionComponent( - Coding(component.system, component.code, component.display) + Coding(component.system, component.code, component.display), ) } } @@ -185,6 +199,9 @@ internal class EnabledAnswerOptionsEvaluator( // https://build.fhir.org/ig/HL7/sdc/expressions.html#x-fhir-query-enhancements private suspend fun resolveAnswerExpression( item: QuestionnaireItemComponent, + responseItem: QuestionnaireResponseItemComponent, + questionnaireResponse: QuestionnaireResponse, + questionnaireItemParentMap: Map, ): List { // Check cache first for database queries val answerExpression = item.answerExpression ?: return emptyList() @@ -208,16 +225,16 @@ internal class EnabledAnswerOptionsEvaluator( options } ?: error( - "XFhirQueryResolver cannot be null. Please provide the XFhirQueryResolver via DataCaptureConfig." + "XFhirQueryResolver cannot be null. Please provide the XFhirQueryResolver via DataCaptureConfig.", ) } answerExpression.isFhirPath -> { - val data = fhirPathEngine.evaluate(questionnaireResponse, answerExpression.expression) + val data = expressionEvaluator.evaluateExpression(item, responseItem, answerExpression) item.extractAnswerOptions(data) } else -> throw UnsupportedOperationException( - "${answerExpression.language} not supported for answer-expression yet" + "${answerExpression.language} not supported for answer-expression yet", ) } } @@ -232,18 +249,19 @@ internal class EnabledAnswerOptionsEvaluator( .map { val (expression, toggleOptions) = it val evaluationResult = - if (expression.isFhirPath) + if (expression.isFhirPath) { fhirPathEngine.convertToBoolean( expressionEvaluator.evaluateExpression( item, questionnaireResponseItem, expression, - ) + ), ) - else + } else { throw UnsupportedOperationException( - "${expression.language} not supported yet for answer-options-toggle-expression" + "${expression.language} not supported yet for answer-options-toggle-expression", ) + } evaluationResult to toggleOptions } .partition { it.first } diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelTest.kt index b6a4c190d0..e24202e82b 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelTest.kt @@ -141,8 +141,10 @@ class QuestionnaireViewModelTest { state = SavedStateHandle() check( ApplicationProvider.getApplicationContext() - is DataCaptureConfig.Provider - ) { "Few tests require a custom application class that implements DataCaptureConfig.Provider" } + is DataCaptureConfig.Provider, + ) { + "Few tests require a custom application class that implements DataCaptureConfig.Provider" + } ReflectionHelpers.setStaticField(DataCapture::class.java, "configuration", null) } @@ -160,7 +162,7 @@ class QuestionnaireViewModelTest { assertThat(errorMessage) .isEqualTo( - "Neither EXTRA_QUESTIONNAIRE_JSON_URI nor EXTRA_QUESTIONNAIRE_JSON_STRING is supplied." + "Neither EXTRA_QUESTIONNAIRE_JSON_URI nor EXTRA_QUESTIONNAIRE_JSON_STRING is supplied.", ) } @@ -177,7 +179,7 @@ class QuestionnaireViewModelTest { viewModel.getQuestionnaireResponse(), QuestionnaireResponse().apply { this.questionnaire = "http://www.sample-org/FHIR/Resources/Questionnaire/a-questionnaire" - } + }, ) } @@ -191,7 +193,7 @@ class QuestionnaireViewModelTest { linkId = "a-link-id" text = "Yes or no?" type = Questionnaire.QuestionnaireItemType.BOOLEAN - } + }, ) } @@ -204,9 +206,9 @@ class QuestionnaireViewModelTest { QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { linkId = "a-link-id" text = "Yes or no?" - } + }, ) - } + }, ) } @@ -225,9 +227,9 @@ class QuestionnaireViewModelTest { linkId = "another-link-id" text = "Name?" type = Questionnaire.QuestionnaireItemType.STRING - } + }, ) - } + }, ) } @@ -244,11 +246,11 @@ class QuestionnaireViewModelTest { QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { linkId = "another-link-id" text = "Name?" - } + }, ) - } + }, ) - } + }, ) } @@ -261,7 +263,7 @@ class QuestionnaireViewModelTest { Questionnaire.QuestionnaireItemComponent().apply { linkId = "a-link-id" text = "Basic question" - } + }, ) } @@ -269,7 +271,7 @@ class QuestionnaireViewModelTest { QuestionnaireResponse().apply { id = "a-questionnaire-response" addItem( - QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { linkId = "a-link-id" } + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { linkId = "a-link-id" }, ) } @@ -296,9 +298,9 @@ class QuestionnaireViewModelTest { mutableListOf( Questionnaire.QuestionnaireItemInitialComponent().apply { value = BooleanType(false) - } + }, ) - } + }, ) } @@ -314,11 +316,11 @@ class QuestionnaireViewModelTest { addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = BooleanType(false) - } + }, ) - } + }, ) - } + }, ) } @@ -334,9 +336,9 @@ class QuestionnaireViewModelTest { type = Questionnaire.QuestionnaireItemType.GROUP initial = mutableListOf( - Questionnaire.QuestionnaireItemInitialComponent().setValue(BooleanType(true)) + Questionnaire.QuestionnaireItemInitialComponent().setValue(BooleanType(true)), ) - } + }, ) } @@ -346,7 +348,7 @@ class QuestionnaireViewModelTest { assertThat(errorMessage) .isEqualTo( - "Questionnaire item a-link-id has initial value(s) and is a group or display item. See rule que-8 at https://www.hl7.org/fhir/questionnaire-definitions.html#Questionnaire.item.initial." + "Questionnaire item a-link-id has initial value(s) and is a group or display item. See rule que-8 at https://www.hl7.org/fhir/questionnaire-definitions.html#Questionnaire.item.initial.", ) } @@ -362,9 +364,9 @@ class QuestionnaireViewModelTest { type = Questionnaire.QuestionnaireItemType.DISPLAY initial = mutableListOf( - Questionnaire.QuestionnaireItemInitialComponent().setValue(BooleanType(true)) + Questionnaire.QuestionnaireItemInitialComponent().setValue(BooleanType(true)), ) - } + }, ) } @@ -374,7 +376,7 @@ class QuestionnaireViewModelTest { assertThat(errorMessage) .isEqualTo( - "Questionnaire item a-link-id has initial value(s) and is a group or display item. See rule que-8 at https://www.hl7.org/fhir/questionnaire-definitions.html#Questionnaire.item.initial." + "Questionnaire item a-link-id has initial value(s) and is a group or display item. See rule que-8 at https://www.hl7.org/fhir/questionnaire-definitions.html#Questionnaire.item.initial.", ) } @@ -392,9 +394,9 @@ class QuestionnaireViewModelTest { initial = mutableListOf( Questionnaire.QuestionnaireItemInitialComponent().setValue(BooleanType(true)), - Questionnaire.QuestionnaireItemInitialComponent().setValue(BooleanType(true)) + Questionnaire.QuestionnaireItemInitialComponent().setValue(BooleanType(true)), ) - } + }, ) } @@ -410,16 +412,16 @@ class QuestionnaireViewModelTest { addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = BooleanType(true) - } + }, ) addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = BooleanType(true) - } + }, ) - } + }, ) - } + }, ) } @@ -437,9 +439,9 @@ class QuestionnaireViewModelTest { initial = mutableListOf( Questionnaire.QuestionnaireItemInitialComponent().setValue(BooleanType(true)), - Questionnaire.QuestionnaireItemInitialComponent().setValue(BooleanType(true)) + Questionnaire.QuestionnaireItemInitialComponent().setValue(BooleanType(true)), ) - } + }, ) } @@ -449,7 +451,7 @@ class QuestionnaireViewModelTest { assertThat(errorMessage) .isEqualTo( - "Questionnaire item a-link-id can only have multiple initial values for repeating items. See rule que-13 at https://www.hl7.org/fhir/questionnaire-definitions.html#Questionnaire.item.initial." + "Questionnaire item a-link-id can only have multiple initial values for repeating items. See rule que-13 at https://www.hl7.org/fhir/questionnaire-definitions.html#Questionnaire.item.initial.", ) } @@ -498,7 +500,7 @@ class QuestionnaireViewModelTest { assertThat(errorMessage) .isEqualTo( - "Mismatching Questionnaire http://www.sample-org/FHIR/Resources/Questionnaire/questionnaire-1 and QuestionnaireResponse (for Questionnaire Questionnaire/a-questionnaire)" + "Mismatching Questionnaire http://www.sample-org/FHIR/Resources/Questionnaire/questionnaire-1 and QuestionnaireResponse (for Questionnaire Questionnaire/a-questionnaire)", ) } @@ -512,7 +514,7 @@ class QuestionnaireViewModelTest { linkId = "a-link-id" text = "Basic question" type = Questionnaire.QuestionnaireItemType.BOOLEAN - } + }, ) } val questionnaireResponse = @@ -524,9 +526,9 @@ class QuestionnaireViewModelTest { addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = BooleanType(true) - } + }, ) - } + }, ) } @@ -537,13 +539,13 @@ class QuestionnaireViewModelTest { QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { linkId = "a-link-id" text = "Basic question" - } + }, ) } assertResourceEquals( createQuestionnaireViewModel(questionnaire, questionnaireResponse).getQuestionnaireResponse(), - expectedQuestionnaireResponse + expectedQuestionnaireResponse, ) } @@ -557,7 +559,7 @@ class QuestionnaireViewModelTest { linkId = "a-link-id" text = "Basic question" type = Questionnaire.QuestionnaireItemType.BOOLEAN - } + }, ) } val questionnaireResponse = @@ -569,9 +571,9 @@ class QuestionnaireViewModelTest { addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = StringType("true") - } + }, ) - } + }, ) } @@ -596,7 +598,7 @@ class QuestionnaireViewModelTest { text = "Basic question which allows multiple answers" type = Questionnaire.QuestionnaireItemType.STRING repeats = true - } + }, ) } val questionnaireResponse = @@ -609,14 +611,14 @@ class QuestionnaireViewModelTest { addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = StringType("string 1") - } + }, ) addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = StringType("string 2") - } + }, ) - } + }, ) } @@ -636,7 +638,7 @@ class QuestionnaireViewModelTest { text = "Basic question" type = Questionnaire.QuestionnaireItemType.BOOLEAN repeats = false - } + }, ) } val questionnaireResponse = @@ -648,14 +650,14 @@ class QuestionnaireViewModelTest { addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = BooleanType(true) - } + }, ) addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = BooleanType(false) - } + }, ) - } + }, ) } @@ -684,9 +686,9 @@ class QuestionnaireViewModelTest { linkId = "another-link-id" text = "Is this true?" type = Questionnaire.QuestionnaireItemType.BOOLEAN - } + }, ) - } + }, ) } val questionnaireResponse = @@ -703,11 +705,11 @@ class QuestionnaireViewModelTest { addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = BooleanType(true) - } + }, ) - } + }, ) - } + }, ) } @@ -731,9 +733,9 @@ class QuestionnaireViewModelTest { linkId = "another-link-id" text = "Name?" type = Questionnaire.QuestionnaireItemType.STRING - } + }, ) - } + }, ) } val questionnaireResponse = @@ -753,13 +755,13 @@ class QuestionnaireViewModelTest { addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = StringType("a-name") - } + }, ) - } + }, ) - } + }, ) - } + }, ) } @@ -782,9 +784,9 @@ class QuestionnaireViewModelTest { Questionnaire.QuestionnaireItemComponent().apply { linkId = "nested-question" type = Questionnaire.QuestionnaireItemType.BOOLEAN - } + }, ) - } + }, ) } val questionnaireResponse = @@ -799,11 +801,11 @@ class QuestionnaireViewModelTest { addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = BooleanType(true) - } + }, ) - } + }, ) - } + }, ) addItem( QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { @@ -814,11 +816,11 @@ class QuestionnaireViewModelTest { addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = BooleanType(false) - } + }, ) - } + }, ) - } + }, ) } @@ -843,9 +845,9 @@ class QuestionnaireViewModelTest { answerOption = listOf( Questionnaire.QuestionnaireItemAnswerOptionComponent(testOption1), - Questionnaire.QuestionnaireItemAnswerOptionComponent(testOption2) + Questionnaire.QuestionnaireItemAnswerOptionComponent(testOption2), ) - } + }, ) } val questionnaireResponse = @@ -858,9 +860,9 @@ class QuestionnaireViewModelTest { addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = testOption1 - } + }, ) - } + }, ) } @@ -881,9 +883,9 @@ class QuestionnaireViewModelTest { type = Questionnaire.QuestionnaireItemType.GROUP initial = mutableListOf( - Questionnaire.QuestionnaireItemInitialComponent().apply { value = valueCoding } + Questionnaire.QuestionnaireItemInitialComponent().apply { value = valueCoding }, ) - } + }, ) } val questionnaireResponse = @@ -895,9 +897,9 @@ class QuestionnaireViewModelTest { addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = StringType("") - } + }, ) - } + }, ) } createQuestionnaireViewModel(questionnaire, questionnaireResponse) @@ -929,7 +931,8 @@ class QuestionnaireViewModelTest { } ] } - """.trimIndent() + """ + .trimIndent() val questionnaireResponseString = """ @@ -941,7 +944,8 @@ class QuestionnaireViewModelTest { } ] } - """.trimIndent() + """ + .trimIndent() val expectedResponseString = """ @@ -963,7 +967,8 @@ class QuestionnaireViewModelTest { } ] } - """.trimIndent() + """ + .trimIndent() state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, questionnaireString) state.set(EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING, questionnaireResponseString) @@ -1008,7 +1013,8 @@ class QuestionnaireViewModelTest { } ] } - """.trimIndent() + """ + .trimIndent() val questionnaireResponseString = """ @@ -1045,7 +1051,8 @@ class QuestionnaireViewModelTest { } ] } - """.trimIndent() + """ + .trimIndent() val expectedResponseString = """ @@ -1086,7 +1093,8 @@ class QuestionnaireViewModelTest { } ] } - """.trimIndent() + """ + .trimIndent() state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, questionnaireString) state.set(EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING, questionnaireResponseString) @@ -1120,9 +1128,9 @@ class QuestionnaireViewModelTest { linkId = "another-link-id" text = "Name?" type = Questionnaire.QuestionnaireItemType.STRING - } + }, ) - } + }, ) } @@ -1154,7 +1162,7 @@ class QuestionnaireViewModelTest { text = "Name?" type = Questionnaire.QuestionnaireItemType.STRING required = true - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire) @@ -1176,7 +1184,7 @@ class QuestionnaireViewModelTest { text = "Name?" type = Questionnaire.QuestionnaireItemType.STRING required = true - } + }, ) } @@ -1186,7 +1194,7 @@ class QuestionnaireViewModelTest { question.clearAnswer() assertThat( - viewModel.getQuestionnaireItemViewItemList().single().asQuestion().validationResult + viewModel.getQuestionnaireItemViewItemList().single().asQuestion().validationResult, ) .isEqualTo(Invalid(listOf("Missing answer for required field."))) } @@ -1202,7 +1210,7 @@ class QuestionnaireViewModelTest { linkId = "question-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN addInitial().apply { value = BooleanType(false) } - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -1213,7 +1221,7 @@ class QuestionnaireViewModelTest { question = "question-1" operator = Questionnaire.QuestionnaireItemOperator.EQUAL } - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire) @@ -1223,7 +1231,8 @@ class QuestionnaireViewModelTest { .getQuestionnaireItemViewItemList() .single() .asQuestion() - .questionnaireItem.linkId + .questionnaireItem + .linkId, ) .isEqualTo("question-1") } @@ -1239,7 +1248,7 @@ class QuestionnaireViewModelTest { linkId = "question-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN addInitial().apply { value = BooleanType(true) } - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -1250,7 +1259,7 @@ class QuestionnaireViewModelTest { question = "question-1" operator = Questionnaire.QuestionnaireItemOperator.EQUAL } - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire) @@ -1276,7 +1285,7 @@ class QuestionnaireViewModelTest { text = "a question" type = Questionnaire.QuestionnaireItemType.BOOLEAN addExtension(hiddenExtension) - } + }, ) } @@ -1302,7 +1311,7 @@ class QuestionnaireViewModelTest { setValue(BooleanType(false)) } addInitial().apply { value = BooleanType(true) } - } + }, ) } val serializedQuestionnaire = printer.encodeResourceToString(questionnaire) @@ -1315,7 +1324,8 @@ class QuestionnaireViewModelTest { .getQuestionnaireItemViewItemList() .single() .asQuestion() - .questionnaireItem.linkId + .questionnaireItem + .linkId, ) .isEqualTo("a-boolean-item-1") } @@ -1342,9 +1352,9 @@ class QuestionnaireViewModelTest { linkId = "page1-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 1" - } + }, ) - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -1356,9 +1366,9 @@ class QuestionnaireViewModelTest { linkId = "page2-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 2" - } + }, ) - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire) @@ -1370,10 +1380,10 @@ class QuestionnaireViewModelTest { pages = listOf( QuestionnairePage(0, enabled = true, hidden = false), - QuestionnairePage(1, enabled = true, hidden = false) + QuestionnairePage(1, enabled = true, hidden = false), ), - currentPageIndex = 0 - ) + currentPageIndex = 0, + ), ) assertThat(state.items).hasSize(2) state.items[0].asQuestion().questionnaireItem.let { groupItem -> @@ -1402,9 +1412,9 @@ class QuestionnaireViewModelTest { linkId = "page1-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 1" - } + }, ) - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -1416,9 +1426,9 @@ class QuestionnaireViewModelTest { linkId = "page2-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 2" - } + }, ) - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire) @@ -1426,7 +1436,7 @@ class QuestionnaireViewModelTest { viewModel.goToNextPage() assertThat( - (viewModel.questionnaireStateFlow.value.displayMode as DisplayMode.EditMode).pagination + (viewModel.questionnaireStateFlow.value.displayMode as DisplayMode.EditMode).pagination, ) .isEqualTo( QuestionnairePagination( @@ -1434,11 +1444,11 @@ class QuestionnaireViewModelTest { pages = listOf( QuestionnairePage(0, enabled = true, hidden = false), - QuestionnairePage(1, enabled = true, hidden = false) + QuestionnairePage(1, enabled = true, hidden = false), ), currentPageIndex = 1, - showSubmitButton = true - ) + showSubmitButton = true, + ), ) } } @@ -1458,9 +1468,9 @@ class QuestionnaireViewModelTest { linkId = "page1-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 1" - } + }, ) - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -1472,9 +1482,9 @@ class QuestionnaireViewModelTest { linkId = "page2-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 2" - } + }, ) - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire) @@ -1483,7 +1493,7 @@ class QuestionnaireViewModelTest { viewModel.goToPreviousPage() assertThat( - (viewModel.questionnaireStateFlow.value.displayMode as DisplayMode.EditMode).pagination + (viewModel.questionnaireStateFlow.value.displayMode as DisplayMode.EditMode).pagination, ) .isEqualTo( QuestionnairePagination( @@ -1491,10 +1501,10 @@ class QuestionnaireViewModelTest { pages = listOf( QuestionnairePage(0, enabled = true, hidden = false), - QuestionnairePage(1, enabled = true, hidden = false) + QuestionnairePage(1, enabled = true, hidden = false), ), - currentPageIndex = 0 - ) + currentPageIndex = 0, + ), ) } } @@ -1514,9 +1524,9 @@ class QuestionnaireViewModelTest { linkId = "page1-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 1" - } + }, ) - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -1528,14 +1538,14 @@ class QuestionnaireViewModelTest { linkId = "page2-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 2" - } + }, ) addEnableWhen().apply { answer = BooleanType(true) question = "page1-1" operator = Questionnaire.QuestionnaireItemOperator.EQUAL } - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -1547,16 +1557,16 @@ class QuestionnaireViewModelTest { linkId = "page3-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 3" - } + }, ) - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire) viewModel.runViewModelBlocking { viewModel.goToNextPage() assertThat( - (viewModel.questionnaireStateFlow.value.displayMode as DisplayMode.EditMode).pagination + (viewModel.questionnaireStateFlow.value.displayMode as DisplayMode.EditMode).pagination, ) .isEqualTo( QuestionnairePagination( @@ -1568,8 +1578,8 @@ class QuestionnaireViewModelTest { QuestionnairePage(2, enabled = true, hidden = false), ), currentPageIndex = 2, - showSubmitButton = true - ) + showSubmitButton = true, + ), ) } } @@ -1590,9 +1600,9 @@ class QuestionnaireViewModelTest { linkId = "page1-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 1" - } + }, ) - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -1604,9 +1614,9 @@ class QuestionnaireViewModelTest { linkId = "page2-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 2" - } + }, ) - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -1618,15 +1628,15 @@ class QuestionnaireViewModelTest { linkId = "page3-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 3" - } + }, ) - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire) viewModel.runViewModelBlocking { assertThat( - (viewModel.questionnaireStateFlow.value.displayMode as DisplayMode.EditMode).pagination + (viewModel.questionnaireStateFlow.value.displayMode as DisplayMode.EditMode).pagination, ) .isEqualTo( QuestionnairePagination( @@ -1635,10 +1645,10 @@ class QuestionnaireViewModelTest { listOf( QuestionnairePage(0, enabled = true, hidden = true), QuestionnairePage(1, enabled = true, hidden = false), - QuestionnairePage(2, enabled = true, hidden = false) + QuestionnairePage(2, enabled = true, hidden = false), ), - currentPageIndex = 1 - ) + currentPageIndex = 1, + ), ) } } @@ -1658,9 +1668,9 @@ class QuestionnaireViewModelTest { linkId = "page1-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 1" - } + }, ) - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -1673,9 +1683,9 @@ class QuestionnaireViewModelTest { linkId = "page2-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 2" - } + }, ) - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -1687,16 +1697,16 @@ class QuestionnaireViewModelTest { linkId = "page3-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 3" - } + }, ) - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire) viewModel.runViewModelBlocking { viewModel.goToNextPage() assertThat( - (viewModel.questionnaireStateFlow.value.displayMode as DisplayMode.EditMode).pagination + (viewModel.questionnaireStateFlow.value.displayMode as DisplayMode.EditMode).pagination, ) .isEqualTo( QuestionnairePagination( @@ -1705,11 +1715,11 @@ class QuestionnaireViewModelTest { listOf( QuestionnairePage(0, enabled = true, hidden = false), QuestionnairePage(1, enabled = true, hidden = true), - QuestionnairePage(2, enabled = true, hidden = false) + QuestionnairePage(2, enabled = true, hidden = false), ), currentPageIndex = 2, - showSubmitButton = true - ) + showSubmitButton = true, + ), ) } } @@ -1741,9 +1751,9 @@ class QuestionnaireViewModelTest { linkId = "page1-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 1" - } + }, ) - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -1755,9 +1765,9 @@ class QuestionnaireViewModelTest { linkId = "page2-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 2" - } + }, ) - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire) @@ -1765,15 +1775,15 @@ class QuestionnaireViewModelTest { viewModel.goToNextPage() assertThat(questionnaire.entryMode).isEqualTo(EntryMode.PRIOR_EDIT) assertThat( - (viewModel.questionnaireStateFlow.value.displayMode as DisplayMode.EditMode).pagination + (viewModel.questionnaireStateFlow.value.displayMode as DisplayMode.EditMode).pagination, ) .isEqualTo( QuestionnairePagination( isPaginated = true, pages = viewModel.pages!!, currentPageIndex = 1, - showSubmitButton = true - ) + showSubmitButton = true, + ), ) } } @@ -1799,9 +1809,9 @@ class QuestionnaireViewModelTest { linkId = "page1-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 1" - } + }, ) - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -1813,9 +1823,9 @@ class QuestionnaireViewModelTest { linkId = "page2-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 2" - } + }, ) - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire) @@ -1825,14 +1835,14 @@ class QuestionnaireViewModelTest { assertThat(questionnaire.entryMode).isEqualTo(EntryMode.PRIOR_EDIT) assertThat( - (viewModel.questionnaireStateFlow.value.displayMode as DisplayMode.EditMode).pagination + (viewModel.questionnaireStateFlow.value.displayMode as DisplayMode.EditMode).pagination, ) .isEqualTo( QuestionnairePagination( isPaginated = true, pages = viewModel.pages!!, - currentPageIndex = 0 - ) + currentPageIndex = 0, + ), ) } } @@ -1858,9 +1868,9 @@ class QuestionnaireViewModelTest { linkId = "page1-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN required = true - } + }, ) - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -1871,23 +1881,23 @@ class QuestionnaireViewModelTest { Questionnaire.QuestionnaireItemComponent().apply { linkId = "page2-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN - } + }, ) - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire) viewModel.runViewModelBlocking { viewModel.goToNextPage() assertThat( - (viewModel.questionnaireStateFlow.value.displayMode as DisplayMode.EditMode).pagination + (viewModel.questionnaireStateFlow.value.displayMode as DisplayMode.EditMode).pagination, ) .isEqualTo( QuestionnairePagination( isPaginated = true, pages = viewModel.pages!!, - currentPageIndex = 0 - ) + currentPageIndex = 0, + ), ) } } @@ -1912,9 +1922,9 @@ class QuestionnaireViewModelTest { Questionnaire.QuestionnaireItemComponent().apply { linkId = "page1-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN - } + }, ) - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -1926,9 +1936,9 @@ class QuestionnaireViewModelTest { linkId = "page2-1" required = true type = Questionnaire.QuestionnaireItemType.BOOLEAN - } + }, ) - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire, enableReviewPage = true) @@ -1936,15 +1946,15 @@ class QuestionnaireViewModelTest { viewModel.goToNextPage() viewModel.setReviewMode(true) assertThat( - (viewModel.questionnaireStateFlow.value.displayMode as DisplayMode.EditMode).pagination + (viewModel.questionnaireStateFlow.value.displayMode as DisplayMode.EditMode).pagination, ) .isEqualTo( QuestionnairePagination( isPaginated = true, pages = viewModel.pages!!, currentPageIndex = 1, - showReviewButton = true - ) + showReviewButton = true, + ), ) } } @@ -1970,9 +1980,9 @@ class QuestionnaireViewModelTest { linkId = "page1-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 1" - } + }, ) - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -1984,9 +1994,9 @@ class QuestionnaireViewModelTest { linkId = "page2-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 2" - } + }, ) - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire) @@ -2019,9 +2029,9 @@ class QuestionnaireViewModelTest { linkId = "page1-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 1" - } + }, ) - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -2033,9 +2043,9 @@ class QuestionnaireViewModelTest { linkId = "page2-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 2" - } + }, ) - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire) @@ -2063,9 +2073,9 @@ class QuestionnaireViewModelTest { linkId = "page1-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 1" - } + }, ) - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -2077,9 +2087,9 @@ class QuestionnaireViewModelTest { linkId = "page2-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 2" - } + }, ) - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire) @@ -2106,9 +2116,9 @@ class QuestionnaireViewModelTest { linkId = "page1-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 1" - } + }, ) - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -2120,9 +2130,9 @@ class QuestionnaireViewModelTest { linkId = "page2-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 2" - } + }, ) - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire) @@ -2156,9 +2166,9 @@ class QuestionnaireViewModelTest { linkId = "page1-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 1" - } + }, ) - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -2170,9 +2180,9 @@ class QuestionnaireViewModelTest { linkId = "page2-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 2" - } + }, ) - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire) @@ -2180,15 +2190,15 @@ class QuestionnaireViewModelTest { viewModel.goToNextPage() assertThat( - (viewModel.questionnaireStateFlow.value.displayMode as DisplayMode.EditMode).pagination + (viewModel.questionnaireStateFlow.value.displayMode as DisplayMode.EditMode).pagination, ) .isEqualTo( QuestionnairePagination( isPaginated = true, pages = viewModel.pages!!, currentPageIndex = 1, - showSubmitButton = true - ) + showSubmitButton = true, + ), ) } } @@ -2214,9 +2224,9 @@ class QuestionnaireViewModelTest { linkId = "page1-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN required = true - } + }, ) - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -2227,23 +2237,23 @@ class QuestionnaireViewModelTest { Questionnaire.QuestionnaireItemComponent().apply { linkId = "page2-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN - } + }, ) - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire) viewModel.runViewModelBlocking { viewModel.goToNextPage() assertThat( - (viewModel.questionnaireStateFlow.value.displayMode as DisplayMode.EditMode).pagination + (viewModel.questionnaireStateFlow.value.displayMode as DisplayMode.EditMode).pagination, ) .isEqualTo( QuestionnairePagination( isPaginated = true, pages = viewModel.pages!!, - currentPageIndex = 0 - ) + currentPageIndex = 0, + ), ) } } @@ -2269,9 +2279,9 @@ class QuestionnaireViewModelTest { linkId = "page1-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 1" - } + }, ) - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -2283,38 +2293,38 @@ class QuestionnaireViewModelTest { linkId = "page2-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 2" - } + }, ) - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire) viewModel.runViewModelBlocking { viewModel.goToNextPage() assertThat( - (viewModel.questionnaireStateFlow.value.displayMode as DisplayMode.EditMode).pagination + (viewModel.questionnaireStateFlow.value.displayMode as DisplayMode.EditMode).pagination, ) .isEqualTo( QuestionnairePagination( isPaginated = true, pages = viewModel.pages!!, currentPageIndex = 1, - showSubmitButton = true - ) + showSubmitButton = true, + ), ) viewModel.goToPreviousPage() assertThat( - (viewModel.questionnaireStateFlow.value.displayMode as DisplayMode.EditMode).pagination + (viewModel.questionnaireStateFlow.value.displayMode as DisplayMode.EditMode).pagination, ) .isEqualTo( QuestionnairePagination( isPaginated = true, pages = viewModel.pages!!, currentPageIndex = 1, - showSubmitButton = true - ) + showSubmitButton = true, + ), ) } } @@ -2335,13 +2345,14 @@ class QuestionnaireViewModelTest { Questionnaire.QuestionnaireItemComponent().apply { linkId = "a-link-id" type = Questionnaire.QuestionnaireItemType.BOOLEAN - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire, enableReviewPage = false) assertThat( (viewModel.questionnaireStateFlow.first().displayMode as DisplayMode.EditMode) - .pagination.showReviewButton + .pagination + .showReviewButton, ) .isFalse() } @@ -2357,18 +2368,19 @@ class QuestionnaireViewModelTest { Questionnaire.QuestionnaireItemComponent().apply { linkId = "a-link-id" type = Questionnaire.QuestionnaireItemType.BOOLEAN - } + }, ) } val viewModel = createQuestionnaireViewModel( questionnaire, enableReviewPage = false, - showReviewPageFirst = true + showReviewPageFirst = true, ) assertThat( (viewModel.questionnaireStateFlow.first().displayMode as DisplayMode.EditMode) - .pagination.showReviewButton + .pagination + .showReviewButton, ) .isFalse() } @@ -2384,13 +2396,14 @@ class QuestionnaireViewModelTest { Questionnaire.QuestionnaireItemComponent().apply { linkId = "a-link-id" type = Questionnaire.QuestionnaireItemType.BOOLEAN - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire, enableReviewPage = true) assertThat( (viewModel.questionnaireStateFlow.first().displayMode as DisplayMode.EditMode) - .pagination.showReviewButton + .pagination + .showReviewButton, ) .isTrue() } @@ -2406,14 +2419,14 @@ class QuestionnaireViewModelTest { Questionnaire.QuestionnaireItemComponent().apply { linkId = "a-link-id" type = Questionnaire.QuestionnaireItemType.BOOLEAN - } + }, ) } val viewModel = createQuestionnaireViewModel( questionnaire, enableReviewPage = true, - showSubmitButton = true + showSubmitButton = true, ) viewModel.setReviewMode(true) assertThat(viewModel.questionnaireStateFlow.first().displayMode) @@ -2433,19 +2446,19 @@ class QuestionnaireViewModelTest { Questionnaire.QuestionnaireItemComponent().apply { linkId = "a-link-id" type = Questionnaire.QuestionnaireItemType.BOOLEAN - } + }, ) } val viewModel = createQuestionnaireViewModel( questionnaire, enableReviewPage = true, - showReviewPageFirst = true + showReviewPageFirst = true, ) assertThat( (viewModel.questionnaireStateFlow.first().displayMode as DisplayMode.ReviewMode) - .showEditButton + .showEditButton, ) .isTrue() } @@ -2467,9 +2480,9 @@ class QuestionnaireViewModelTest { linkId = "page1-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 1" - } + }, ) - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -2481,9 +2494,9 @@ class QuestionnaireViewModelTest { linkId = "page2-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 2" - } + }, ) - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire, enableReviewPage = false) @@ -2491,7 +2504,8 @@ class QuestionnaireViewModelTest { viewModel.goToNextPage() assertThat( (viewModel.questionnaireStateFlow.value.displayMode as DisplayMode.EditMode) - .pagination.showReviewButton + .pagination + .showReviewButton, ) .isFalse() } @@ -2514,9 +2528,9 @@ class QuestionnaireViewModelTest { linkId = "page1-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 1" - } + }, ) - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -2528,16 +2542,17 @@ class QuestionnaireViewModelTest { linkId = "page2-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 2" - } + }, ) - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire, enableReviewPage = false) viewModel.runViewModelBlocking { assertThat( (viewModel.questionnaireStateFlow.value.displayMode as DisplayMode.EditMode) - .pagination.showReviewButton + .pagination + .showReviewButton, ) .isFalse() } @@ -2559,9 +2574,9 @@ class QuestionnaireViewModelTest { linkId = "page1-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 1" - } + }, ) - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -2573,9 +2588,9 @@ class QuestionnaireViewModelTest { linkId = "page2-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 2" - } + }, ) - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire, enableReviewPage = true) @@ -2583,7 +2598,8 @@ class QuestionnaireViewModelTest { viewModel.goToNextPage() assertThat( (viewModel.questionnaireStateFlow.value.displayMode as DisplayMode.EditMode) - .pagination.showReviewButton + .pagination + .showReviewButton, ) .isTrue() } @@ -2606,9 +2622,9 @@ class QuestionnaireViewModelTest { linkId = "page1-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 1" - } + }, ) - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -2620,16 +2636,17 @@ class QuestionnaireViewModelTest { linkId = "page2-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 2" - } + }, ) - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire, enableReviewPage = true) viewModel.runViewModelBlocking { assertThat( (viewModel.questionnaireStateFlow.value.displayMode as DisplayMode.EditMode) - .pagination.showReviewButton + .pagination + .showReviewButton, ) .isTrue() } @@ -2645,14 +2662,15 @@ class QuestionnaireViewModelTest { Questionnaire.QuestionnaireItemComponent().apply { linkId = "a-link-id" type = Questionnaire.QuestionnaireItemType.BOOLEAN - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire, enableReviewPage = true) viewModel.setReviewMode(false) assertThat( (viewModel.questionnaireStateFlow.first().displayMode as DisplayMode.EditMode) - .pagination.showReviewButton + .pagination + .showReviewButton, ) .isTrue() } @@ -2668,7 +2686,7 @@ class QuestionnaireViewModelTest { Questionnaire.QuestionnaireItemComponent().apply { linkId = "a-link-id" type = Questionnaire.QuestionnaireItemType.BOOLEAN - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire, enableReviewPage = true) @@ -2676,7 +2694,7 @@ class QuestionnaireViewModelTest { assertThat( (viewModel.questionnaireStateFlow.first().displayMode as DisplayMode.ReviewMode) - .showEditButton + .showEditButton, ) .isTrue() } @@ -2698,14 +2716,14 @@ class QuestionnaireViewModelTest { Questionnaire.QuestionnaireItemComponent().apply { linkId = "a-link-id" type = Questionnaire.QuestionnaireItemType.BOOLEAN - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire, readOnlyMode = true) assertThat( (viewModel.questionnaireStateFlow.first().displayMode as DisplayMode.ReviewMode) - .showEditButton + .showEditButton, ) .isFalse() } @@ -2726,13 +2744,14 @@ class QuestionnaireViewModelTest { Questionnaire.QuestionnaireItemComponent().apply { linkId = "a-link-id" type = Questionnaire.QuestionnaireItemType.BOOLEAN - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire, showSubmitButton = false) assertThat( (viewModel.questionnaireStateFlow.first().displayMode as DisplayMode.EditMode) - .pagination.showSubmitButton + .pagination + .showSubmitButton, ) .isFalse() } @@ -2746,13 +2765,14 @@ class QuestionnaireViewModelTest { Questionnaire.QuestionnaireItemComponent().apply { linkId = "a-link-id" type = Questionnaire.QuestionnaireItemType.BOOLEAN - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire, showSubmitButton = true) assertThat( (viewModel.questionnaireStateFlow.first().displayMode as DisplayMode.EditMode) - .pagination.showSubmitButton + .pagination + .showSubmitButton, ) .isTrue() } @@ -2766,13 +2786,14 @@ class QuestionnaireViewModelTest { Questionnaire.QuestionnaireItemComponent().apply { linkId = "a-link-id" type = Questionnaire.QuestionnaireItemType.BOOLEAN - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire, showSubmitButton = null) assertThat( (viewModel.questionnaireStateFlow.first().displayMode as DisplayMode.EditMode) - .pagination.showSubmitButton + .pagination + .showSubmitButton, ) .isTrue() } @@ -2794,9 +2815,11 @@ class QuestionnaireViewModelTest { text = "Basic question" type = Questionnaire.QuestionnaireItemType.BOOLEAN addInitial( - Questionnaire.QuestionnaireItemInitialComponent().apply { value = BooleanType(false) } + Questionnaire.QuestionnaireItemInitialComponent().apply { + value = BooleanType(false) + }, ) - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire) @@ -2811,11 +2834,11 @@ class QuestionnaireViewModelTest { addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = BooleanType(false) - } + }, ) - } + }, ) - } + }, ) } @@ -2832,14 +2855,16 @@ class QuestionnaireViewModelTest { Extension(ToolingExtensions.EXT_TRANSLATION).apply { addExtension(Extension("lang", StringType("en-US"))) addExtension(Extension("content", StringType("Basic Question"))) - } + }, ) } type = Questionnaire.QuestionnaireItemType.BOOLEAN addInitial( - Questionnaire.QuestionnaireItemInitialComponent().apply { value = BooleanType(false) } + Questionnaire.QuestionnaireItemInitialComponent().apply { + value = BooleanType(false) + }, ) - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire) @@ -2854,11 +2879,11 @@ class QuestionnaireViewModelTest { addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = BooleanType(false) - } + }, ) - } + }, ) - } + }, ) } @@ -2881,11 +2906,11 @@ class QuestionnaireViewModelTest { mutableListOf( Questionnaire.QuestionnaireItemInitialComponent().apply { value = StringType("Test Value") - } + }, ) - } + }, ) - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire) @@ -2905,13 +2930,13 @@ class QuestionnaireViewModelTest { listOf( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = StringType("Test Value") - } + }, ) - } + }, ) - } + }, ) - } + }, ) } @@ -2925,7 +2950,7 @@ class QuestionnaireViewModelTest { linkId = "question-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN addInitial().apply { value = BooleanType(false) } - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -2936,7 +2961,7 @@ class QuestionnaireViewModelTest { question = "question-1" operator = Questionnaire.QuestionnaireItemOperator.EQUAL } - } + }, ) } state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire)) @@ -2952,11 +2977,11 @@ class QuestionnaireViewModelTest { addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = BooleanType(false) - } + }, ) - } + }, ) - } + }, ) } @@ -2970,7 +2995,7 @@ class QuestionnaireViewModelTest { linkId = "question-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN addInitial().apply { value = BooleanType(true) } - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -2981,7 +3006,7 @@ class QuestionnaireViewModelTest { question = "question-1" operator = Questionnaire.QuestionnaireItemOperator.EQUAL } - } + }, ) } state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire)) @@ -2997,14 +3022,16 @@ class QuestionnaireViewModelTest { addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = BooleanType(true) - } + }, ) - } + }, ) addItem( - QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { linkId = "question-2" } + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "question-2" + }, ) - } + }, ) } @@ -3017,7 +3044,7 @@ class QuestionnaireViewModelTest { Questionnaire.QuestionnaireItemComponent().apply { linkId = "question-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -3028,7 +3055,7 @@ class QuestionnaireViewModelTest { question = "question-1" operator = Questionnaire.QuestionnaireItemOperator.EQUAL } - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -3039,7 +3066,7 @@ class QuestionnaireViewModelTest { question = "question-1" operator = Questionnaire.QuestionnaireItemOperator.EQUAL } - } + }, ) } state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire)) @@ -3053,9 +3080,9 @@ class QuestionnaireViewModelTest { addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = BooleanType(false) - } + }, ) - } + }, ) addItem( QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { @@ -3063,14 +3090,14 @@ class QuestionnaireViewModelTest { addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = BooleanType(true) - } + }, ) - } + }, ) } state.set( EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING, - printer.encodeResourceToString(questionnaireResponse) + printer.encodeResourceToString(questionnaireResponse), ) val viewModel = QuestionnaireViewModel(context, state) @@ -3088,7 +3115,7 @@ class QuestionnaireViewModelTest { Questionnaire.QuestionnaireItemComponent().apply { linkId = "question-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -3099,7 +3126,7 @@ class QuestionnaireViewModelTest { question = "question-1" operator = Questionnaire.QuestionnaireItemOperator.EQUAL } - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -3110,7 +3137,7 @@ class QuestionnaireViewModelTest { question = "question-2" operator = Questionnaire.QuestionnaireItemOperator.EQUAL } - } + }, ) } @@ -3123,9 +3150,9 @@ class QuestionnaireViewModelTest { addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = BooleanType(true) - } + }, ) - } + }, ) addItem( QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { @@ -3133,9 +3160,9 @@ class QuestionnaireViewModelTest { addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = BooleanType(true) - } + }, ) - } + }, ) addItem( QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { @@ -3143,9 +3170,9 @@ class QuestionnaireViewModelTest { addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = BooleanType(true) - } + }, ) - } + }, ) } @@ -3173,7 +3200,7 @@ class QuestionnaireViewModelTest { Questionnaire.QuestionnaireItemComponent().apply { linkId = "question-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -3184,7 +3211,7 @@ class QuestionnaireViewModelTest { question = "question-1" operator = Questionnaire.QuestionnaireItemOperator.EQUAL } - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -3195,7 +3222,7 @@ class QuestionnaireViewModelTest { question = "question-2" operator = Questionnaire.QuestionnaireItemOperator.EQUAL } - } + }, ) } @@ -3208,9 +3235,9 @@ class QuestionnaireViewModelTest { addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = BooleanType(true) - } + }, ) - } + }, ) addItem( QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { @@ -3218,9 +3245,9 @@ class QuestionnaireViewModelTest { addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = BooleanType(true) - } + }, ) - } + }, ) addItem( QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { @@ -3228,9 +3255,9 @@ class QuestionnaireViewModelTest { addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = BooleanType(true) - } + }, ) - } + }, ) } @@ -3248,9 +3275,9 @@ class QuestionnaireViewModelTest { addItem( QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { linkId = "question-1" - } + }, ) - } + }, ) // Setting the answer of "question-1" to true should enable question-2 that in turn enables @@ -3260,7 +3287,7 @@ class QuestionnaireViewModelTest { .setAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = BooleanType(true) - } + }, ) assertResourceEquals(viewModel.getQuestionnaireResponse(), questionnaireResponse) @@ -3426,7 +3453,8 @@ class QuestionnaireViewModelTest { ] } - """.trimIndent() + """ + .trimIndent() val questionnaireResponseString = """ @@ -3529,7 +3557,8 @@ class QuestionnaireViewModelTest { ] } - """.trimIndent() + """ + .trimIndent() val expectedResponseString = """ @@ -3623,7 +3652,8 @@ class QuestionnaireViewModelTest { ] } - """.trimIndent() + """ + .trimIndent() state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, questionnaireString) state.set(EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING, questionnaireResponseString) @@ -3657,9 +3687,9 @@ class QuestionnaireViewModelTest { linkId = "a-nested-item" text = "Basic question" type = Questionnaire.QuestionnaireItemType.BOOLEAN - } + }, ) - } + }, ) } val questionnaireResponse = @@ -3675,11 +3705,11 @@ class QuestionnaireViewModelTest { addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { this.value = valueBooleanType.setValue(false) - } + }, ) - } + }, ) - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire) @@ -3690,7 +3720,7 @@ class QuestionnaireViewModelTest { .setAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { this.value = valueBooleanType.setValue(false) - } + }, ) assertResourceEquals(viewModel.getQuestionnaireResponse(), questionnaireResponse) @@ -3713,16 +3743,16 @@ class QuestionnaireViewModelTest { linkId = "nested-item-a" text = "Basic question" type = Questionnaire.QuestionnaireItemType.BOOLEAN - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { linkId = "another-nested-item-a" text = "Basic question" type = Questionnaire.QuestionnaireItemType.BOOLEAN - } + }, ) - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -3735,16 +3765,16 @@ class QuestionnaireViewModelTest { linkId = "nested-item-b" text = "Basic question" type = Questionnaire.QuestionnaireItemType.BOOLEAN - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { linkId = "another-nested-item-b" text = "Basic question" type = Questionnaire.QuestionnaireItemType.BOOLEAN - } + }, ) - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire) @@ -3769,8 +3799,9 @@ class QuestionnaireViewModelTest { item = repeatedGroupA() .asQuestion() - .questionnaireItem.getNestedQuestionnaireResponseItems() - } + .questionnaireItem + .getNestedQuestionnaireResponseItems() + }, ) repeatedGroupB() .asQuestion() @@ -3779,15 +3810,16 @@ class QuestionnaireViewModelTest { item = repeatedGroupB() .asQuestion() - .questionnaireItem.getNestedQuestionnaireResponseItems() - } + .questionnaireItem + .getNestedQuestionnaireResponseItems() + }, ) } assertThat( viewModel.getQuestionnaireItemViewItemList().map { it.asQuestion().questionnaireItem.linkId - } + }, ) .containsExactly( "repeated-group-a", @@ -3799,7 +3831,7 @@ class QuestionnaireViewModelTest { "nested-item-b", "another-nested-item-b", "nested-item-b", - "another-nested-item-b" + "another-nested-item-b", ) .inOrder() @@ -3815,15 +3847,15 @@ class QuestionnaireViewModelTest { QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { linkId = "nested-item-a" text = "Basic question" - } + }, ) addItem( QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { linkId = "another-nested-item-a" text = "Basic question" - } + }, ) - } + }, ) addItem( QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { @@ -3833,15 +3865,15 @@ class QuestionnaireViewModelTest { QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { linkId = "nested-item-a" text = "Basic question" - } + }, ) addItem( QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { linkId = "another-nested-item-a" text = "Basic question" - } + }, ) - } + }, ) addItem( QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { @@ -3851,15 +3883,15 @@ class QuestionnaireViewModelTest { QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { linkId = "nested-item-b" text = "Basic question" - } + }, ) addItem( QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { linkId = "another-nested-item-b" text = "Basic question" - } + }, ) - } + }, ) addItem( QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { @@ -3869,17 +3901,17 @@ class QuestionnaireViewModelTest { QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { linkId = "nested-item-b" text = "Basic question" - } + }, ) addItem( QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { linkId = "another-nested-item-b" text = "Basic question" - } + }, ) - } + }, ) - } + }, ) } } @@ -3904,11 +3936,11 @@ class QuestionnaireViewModelTest { linkId = "a-nested-nested-boolean-item" text = "Nested nested question" type = Questionnaire.QuestionnaireItemType.BOOLEAN - } + }, ) - } + }, ) - } + }, ) } @@ -3934,17 +3966,17 @@ class QuestionnaireViewModelTest { text = "Nested nested question" addAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent() - .apply { this.value = valueBooleanType.setValue(false) } + .apply { this.value = valueBooleanType.setValue(false) }, ) - } + }, ) - } + }, ) - } + }, ) - } + }, ) - } + }, ) } @@ -3958,7 +3990,7 @@ class QuestionnaireViewModelTest { .setAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { this.value = valueBooleanType.setValue(false) - } + }, ) items = viewModel.getQuestionnaireItemViewItemList().map { it.asQuestion() } @@ -3970,7 +4002,7 @@ class QuestionnaireViewModelTest { .setAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { this.value = valueBooleanType.setValue(false) - } + }, ) items = viewModel.getQuestionnaireItemViewItemList().map { it.asQuestion() } @@ -3982,7 +4014,7 @@ class QuestionnaireViewModelTest { .setAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { this.value = valueBooleanType.setValue(false) - } + }, ) assertResourceEquals(viewModel.getQuestionnaireResponse(), questionnaireResponse) @@ -4010,7 +4042,7 @@ class QuestionnaireViewModelTest { system = CODE_SYSTEM_YES_NO code = "Y" display = "Yes" - } + }, ) addContains( @@ -4018,7 +4050,7 @@ class QuestionnaireViewModelTest { system = CODE_SYSTEM_YES_NO code = "N" display = "No" - } + }, ) addContains( @@ -4026,16 +4058,16 @@ class QuestionnaireViewModelTest { system = CODE_SYSTEM_YES_NO code = "asked-unknown" display = "Don't Know" - } + }, ) } - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { linkId = "q-yesnodontknow" answerValueSet = "#$valueSetId" - } + }, ) } @@ -4061,8 +4093,7 @@ class QuestionnaireViewModelTest { valueSetResolverExternal = object : ExternalAnswerValueSetResolver { override suspend fun resolve(uri: String): List { - - return if (uri == CODE_SYSTEM_YES_NO) + return if (uri == CODE_SYSTEM_YES_NO) { listOf( Coding().apply { system = CODE_SYSTEM_YES_NO @@ -4078,11 +4109,13 @@ class QuestionnaireViewModelTest { system = CODE_SYSTEM_YES_NO code = "asked-unknown" display = "Don't Know" - } + }, ) - else emptyList() + } else { + emptyList() + } } - } + }, ) val questionnaire = @@ -4092,7 +4125,7 @@ class QuestionnaireViewModelTest { Questionnaire.QuestionnaireItemComponent().apply { linkId = "q-codesystemyesno" answerValueSet = CODE_SYSTEM_YES_NO - } + }, ) } @@ -4125,7 +4158,7 @@ class QuestionnaireViewModelTest { xFhirQueryResolver = { xFhirQuery -> searchString = xFhirQuery emptyList() - } + }, ) val patientId = "123" @@ -4136,7 +4169,7 @@ class QuestionnaireViewModelTest { extension = listOf( Extension( - "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-launchContext" + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-launchContext", ) .apply { addExtension( @@ -4144,11 +4177,11 @@ class QuestionnaireViewModelTest { Coding( "http://hl7.org/fhir/uv/sdc/CodeSystem/launchContext", "patient", - "Patient" - ) + "Patient", + ), ) addExtension("type", CodeType("Patient")) - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -4162,16 +4195,16 @@ class QuestionnaireViewModelTest { Expression().apply { this.expression = "Observation?subject={{%patient.id}}" this.language = Expression.ExpressionLanguage.APPLICATION_XFHIRQUERY.toCode() - } - ) + }, + ), ) - } + }, ) } state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire)) state.set( EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_JSON_STRINGS, - listOf(printer.encodeResourceToString(patient)) + listOf(printer.encodeResourceToString(patient)), ) val viewModel = QuestionnaireViewModel(context, state) @@ -4212,18 +4245,18 @@ class QuestionnaireViewModelTest { Expression().apply { this.expression = "Practitioner?active=true" this.language = Expression.ExpressionLanguage.APPLICATION_XFHIRQUERY.toCode() - } + }, ), Extension( - "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-choiceColumn" + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-choiceColumn", ) .apply { this.addExtension(Extension("path", StringType("id"))) this.addExtension(Extension("label", StringType("name"))) this.addExtension(Extension("forDisplay", BooleanType(true))) - } + }, ) - } + }, ) } state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire)) @@ -4256,18 +4289,18 @@ class QuestionnaireViewModelTest { Expression().apply { this.expression = "Practitioner?active=true" this.language = Expression.ExpressionLanguage.APPLICATION_XFHIRQUERY.toCode() - } + }, ), Extension( - "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-choiceColumn" + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-choiceColumn", ) .apply { this.addExtension(Extension("path", StringType("id"))) this.addExtension(Extension("label", StringType("name"))) this.addExtension(Extension("forDisplay", BooleanType(true))) - } + }, ) - } + }, ) } state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire)) @@ -4278,55 +4311,166 @@ class QuestionnaireViewModelTest { } assertThat(exception.message) .isEqualTo( - "XFhirQueryResolver cannot be null. Please provide the XFhirQueryResolver via DataCaptureConfig." + "XFhirQueryResolver cannot be null. Please provide the XFhirQueryResolver via DataCaptureConfig.", ) } - // ==================================================================== // - // // - // Answer Options Toggle Expression // - // // - // ==================================================================== // @Test - fun `only answer options evaluating to true in answerOptionsToggleExpression occurrences should be enabled on initial load`() = + fun `should return questionnaire item answer options for answer expression with fhirpath variable`() = runTest { val questionnaire = Questionnaire().apply { addItem( - Questionnaire.QuestionnaireItemComponent().apply { - linkId = "b" + QuestionnaireItemComponent().apply { + linkId = "a" + text = "Question 1" type = Questionnaire.QuestionnaireItemType.CHOICE - text = "Select an option" - addExtension( - Extension(EXTENSION_ANSWER_OPTION_TOGGLE_EXPRESSION_URL).apply { - addExtension( - Extension( - EXTENSION_ANSWER_OPTION_TOGGLE_EXPRESSION_OPTION, - Coding().apply { - code = "option1" - display = "Option 1" - } - ) - ) - addExtension( - Extension( - EXTENSION_ANSWER_OPTION_TOGGLE_EXPRESSION_OPTION, - Coding().apply { - code = "option3" - display = "Option 3" - } - ) - ) - addExtension( - Extension( - EXTENSION_ANSWER_OPTION_TOGGLE_EXPRESSION, - Expression().apply { - this.expression = "false" - this.language = "text/fhirpath" - } - ) + repeats = true + initial = + listOf( + Questionnaire.QuestionnaireItemInitialComponent(Coding("test", "1", "One")), + Questionnaire.QuestionnaireItemInitialComponent(Coding("test", "2", "Two")), + ) + }, + ) + addItem( + QuestionnaireItemComponent().apply { + linkId = "b" + text = "Question 2" + type = Questionnaire.QuestionnaireItemType.STRING + extension = + listOf( + Extension( + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-answerExpression", + Expression().apply { + this.expression = "%resource.item[0].answer.value.select(%VAR1 + code)" + this.language = Expression.ExpressionLanguage.TEXT_FHIRPATH.toCode() + }, + ), + Extension( + "http://hl7.org/fhir/StructureDefinition/variable", + Expression().apply { + this.name = "VAR1" + this.expression = "'Class '" + this.language = Expression.ExpressionLanguage.TEXT_FHIRPATH.toCode() + }, + ), + ) + }, + ) + } + + state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire)) + val viewModel = QuestionnaireViewModel(context, state) + + viewModel.runViewModelBlocking { + val viewItem = + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "b" } + assertThat(viewItem.enabledAnswerOptions.map { it.valueStringType.value }) + .containsExactly("Class 1", "Class 2") + } + } + + @Test + fun `should return questionnaire item answer options for answer expression with fhirpath supplement context`() = + runTest { + val questionnaire = + Questionnaire().apply { + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + text = "Question 1" + type = Questionnaire.QuestionnaireItemType.CHOICE + repeats = true + initial = + listOf( + Questionnaire.QuestionnaireItemInitialComponent(Coding("test", "1", "One")), + Questionnaire.QuestionnaireItemInitialComponent(Coding("test", "2", "Two")), + ) + }, + ) + addItem( + QuestionnaireItemComponent().apply { + linkId = "b" + text = "Question 2" + type = Questionnaire.QuestionnaireItemType.STRING + extension = + listOf( + Extension( + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-answerExpression", + Expression().apply { + this.expression = + "%resource.item[0].answer.value.select('Code ' + code + '-' + %context.linkId)" + this.language = Expression.ExpressionLanguage.TEXT_FHIRPATH.toCode() + }, + ), + ) + }, + ) + } + + state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire)) + val viewModel = QuestionnaireViewModel(context, state) + + viewModel.runViewModelBlocking { + val viewItem = + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "b" } + assertThat(viewItem.enabledAnswerOptions.map { it.valueStringType.value }) + .containsExactly("Code 1-b", "Code 2-b") + } + } + + // ==================================================================== // + // // + // Answer Options Toggle Expression // + // // + // ==================================================================== // + @Test + fun `only answer options evaluating to true in answerOptionsToggleExpression occurrences should be enabled on initial load`() = + runTest { + val questionnaire = + Questionnaire().apply { + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "b" + type = Questionnaire.QuestionnaireItemType.CHOICE + text = "Select an option" + addExtension( + Extension(EXTENSION_ANSWER_OPTION_TOGGLE_EXPRESSION_URL).apply { + addExtension( + Extension( + EXTENSION_ANSWER_OPTION_TOGGLE_EXPRESSION_OPTION, + Coding().apply { + code = "option1" + display = "Option 1" + }, + ), ) - } + addExtension( + Extension( + EXTENSION_ANSWER_OPTION_TOGGLE_EXPRESSION_OPTION, + Coding().apply { + code = "option3" + display = "Option 3" + }, + ), + ) + addExtension( + Extension( + EXTENSION_ANSWER_OPTION_TOGGLE_EXPRESSION, + Expression().apply { + this.expression = "false" + this.language = "text/fhirpath" + }, + ), + ) + }, ) addExtension( Extension(EXTENSION_ANSWER_OPTION_TOGGLE_EXPRESSION_URL).apply { @@ -4336,8 +4480,8 @@ class QuestionnaireViewModelTest { Coding().apply { code = "option2" display = "Option 2" - } - ) + }, + ), ) addExtension( Extension( @@ -4345,10 +4489,10 @@ class QuestionnaireViewModelTest { Expression().apply { this.expression = "true" this.language = "text/fhirpath" - } - ) + }, + ), ) - } + }, ) answerOption = listOf( @@ -4372,9 +4516,9 @@ class QuestionnaireViewModelTest { code = "option3" display = "Option 3" } - } + }, ) - } + }, ) } @@ -4401,7 +4545,7 @@ class QuestionnaireViewModelTest { type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Is Married?" addInitial().apply { value = BooleanType(false) } - } + }, ) addItem( @@ -4417,8 +4561,8 @@ class QuestionnaireViewModelTest { Coding().apply { code = "option1" display = "Option 1" - } - ) + }, + ), ) addExtension( Extension( @@ -4426,8 +4570,8 @@ class QuestionnaireViewModelTest { Coding().apply { code = "option3" display = "Option 3" - } - ) + }, + ), ) addExtension( Extension( @@ -4436,10 +4580,10 @@ class QuestionnaireViewModelTest { this.expression = "%resource.repeat(item).where(linkId='a' and answer.empty().not()).select(answer.value)" this.language = "text/fhirpath" - } - ) + }, + ), ) - } + }, ) addExtension( Extension(EXTENSION_ANSWER_OPTION_TOGGLE_EXPRESSION_URL).apply { @@ -4449,8 +4593,8 @@ class QuestionnaireViewModelTest { Coding().apply { code = "option2" display = "Option 2" - } - ) + }, + ), ) addExtension( Extension( @@ -4459,10 +4603,10 @@ class QuestionnaireViewModelTest { this.expression = "%resource.repeat(item).where(linkId='a' and answer.empty().not()).select(answer.value.not())" this.language = "text/fhirpath" - } - ) + }, + ), ) - } + }, ) answerOption = listOf( @@ -4486,9 +4630,9 @@ class QuestionnaireViewModelTest { code = "option3" display = "Option 3" } - } + }, ) - } + }, ) } @@ -4509,7 +4653,7 @@ class QuestionnaireViewModelTest { .setAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = BooleanType(true) - } + }, ) assertThat( @@ -4517,7 +4661,9 @@ class QuestionnaireViewModelTest { .getQuestionnaireResponse() .item .single { it.linkId == "a" } - .answerFirstRep.value.primitiveValue() + .answerFirstRep + .value + .primitiveValue(), ) .isEqualTo(BooleanType(true).primitiveValue()) @@ -4550,8 +4696,8 @@ class QuestionnaireViewModelTest { Coding().apply { code = "option1" display = "Option 1" - } - ) + }, + ), ) addExtension( Extension( @@ -4559,8 +4705,8 @@ class QuestionnaireViewModelTest { Coding().apply { code = "option3" display = "Option 3" - } - ) + }, + ), ) addExtension( Extension( @@ -4568,10 +4714,10 @@ class QuestionnaireViewModelTest { Expression().apply { this.expression = "false" this.language = "text/fhirpath" - } - ) + }, + ), ) - } + }, ) addExtension( Extension(EXTENSION_ANSWER_OPTION_TOGGLE_EXPRESSION_URL).apply { @@ -4581,8 +4727,8 @@ class QuestionnaireViewModelTest { Coding().apply { code = "option2" display = "Option 2" - } - ) + }, + ), ) addExtension( Extension( @@ -4590,10 +4736,10 @@ class QuestionnaireViewModelTest { Expression().apply { this.expression = "true" this.language = "text/fhirpath" - } - ) + }, + ), ) - } + }, ) answerOption = listOf( @@ -4631,9 +4777,9 @@ class QuestionnaireViewModelTest { code = "option5" display = "Option 5" } - } + }, ) - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire) @@ -4667,8 +4813,8 @@ class QuestionnaireViewModelTest { Coding().apply { code = "option2" display = "Option 2" - } - ) + }, + ), ) addExtension( Extension( @@ -4676,8 +4822,8 @@ class QuestionnaireViewModelTest { Coding().apply { code = "option75" display = "Option 75" - } - ) + }, + ), ) addExtension( Extension( @@ -4685,10 +4831,10 @@ class QuestionnaireViewModelTest { Expression().apply { this.expression = "true" this.language = "text/fhirpath" - } - ) + }, + ), ) - } + }, ) addExtension( Extension(EXTENSION_ANSWER_OPTION_TOGGLE_EXPRESSION_URL).apply { @@ -4698,8 +4844,8 @@ class QuestionnaireViewModelTest { Coding().apply { code = "option1" display = "Option 1" - } - ) + }, + ), ) addExtension( Extension( @@ -4707,8 +4853,8 @@ class QuestionnaireViewModelTest { Coding().apply { code = "option3" display = "Option 3" - } - ) + }, + ), ) addExtension( Extension( @@ -4716,10 +4862,10 @@ class QuestionnaireViewModelTest { Expression().apply { this.expression = "false" this.language = "text/fhirpath" - } - ) + }, + ), ) - } + }, ) answerOption = listOf( @@ -4743,9 +4889,9 @@ class QuestionnaireViewModelTest { code = "option3" display = "Option 3" } - } + }, ) - } + }, ) } @@ -4780,8 +4926,8 @@ class QuestionnaireViewModelTest { Coding().apply { code = "option1" display = "Option 1" - } - ) + }, + ), ) addExtension( Extension( @@ -4789,10 +4935,10 @@ class QuestionnaireViewModelTest { Expression().apply { this.expression = "false" this.language = "text/fhirpath" - } - ) + }, + ), ) - } + }, ) addExtension( Extension(EXTENSION_ANSWER_OPTION_TOGGLE_EXPRESSION_URL).apply { @@ -4802,8 +4948,8 @@ class QuestionnaireViewModelTest { Coding().apply { code = "option1" display = "Option 1" - } - ) + }, + ), ) addExtension( Extension( @@ -4811,10 +4957,10 @@ class QuestionnaireViewModelTest { Expression().apply { this.expression = "true" this.language = "text/fhirpath" - } - ) + }, + ), ) - } + }, ) answerOption = listOf( @@ -4831,9 +4977,9 @@ class QuestionnaireViewModelTest { code = "option2" display = "Option 2" } - } + }, ) - } + }, ) } @@ -4859,9 +5005,9 @@ class QuestionnaireViewModelTest { name = "textVal" language = "text/fhirpath" expression = "10" - } + }, ) - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -4876,8 +5022,8 @@ class QuestionnaireViewModelTest { Coding().apply { code = "option1" display = "Option 1" - } - ) + }, + ), ) addExtension( Extension( @@ -4885,10 +5031,10 @@ class QuestionnaireViewModelTest { Expression().apply { this.expression = "%textVal > 10" this.language = "text/fhirpath" - } - ) + }, + ), ) - } + }, ) addExtension( @@ -4899,8 +5045,8 @@ class QuestionnaireViewModelTest { Coding().apply { code = "option2" display = "Option 2" - } - ) + }, + ), ) addExtension( Extension( @@ -4908,10 +5054,10 @@ class QuestionnaireViewModelTest { Expression().apply { this.expression = "%textVal = 10" this.language = "text/fhirpath" - } - ) + }, + ), ) - } + }, ) addAnswerOption( @@ -4921,7 +5067,7 @@ class QuestionnaireViewModelTest { code = "option1" display = "Option 1" } - } + }, ) addAnswerOption( Questionnaire.QuestionnaireItemAnswerOptionComponent().apply { @@ -4930,9 +5076,9 @@ class QuestionnaireViewModelTest { code = "option2" display = "Option 2" } - } + }, ) - } + }, ) } @@ -4959,7 +5105,7 @@ class QuestionnaireViewModelTest { type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Is Married?" addInitial().apply { value = BooleanType(false) } - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -4974,8 +5120,8 @@ class QuestionnaireViewModelTest { Coding().apply { code = "option1" display = "Option 1" - } - ) + }, + ), ) addExtension( Extension( @@ -4984,10 +5130,10 @@ class QuestionnaireViewModelTest { this.expression = "%resource.repeat(item).where(linkId='a' and answer.empty().not()).select(answer.value.not())" this.language = "text/fhirpath" - } - ) + }, + ), ) - } + }, ) addExtension( @@ -4998,8 +5144,8 @@ class QuestionnaireViewModelTest { Coding().apply { code = "option2" display = "Option 2" - } - ) + }, + ), ) addExtension( Extension( @@ -5008,10 +5154,10 @@ class QuestionnaireViewModelTest { this.expression = "%resource.repeat(item).where(linkId='a' and answer.empty().not()).select(answer.value)" this.language = "text/fhirpath" - } - ) + }, + ), ) - } + }, ) addAnswerOption( @@ -5021,7 +5167,7 @@ class QuestionnaireViewModelTest { code = "option1" display = "Option 1" } - } + }, ) addAnswerOption( Questionnaire.QuestionnaireItemAnswerOptionComponent().apply { @@ -5030,9 +5176,9 @@ class QuestionnaireViewModelTest { code = "option2" display = "Option 2" } - } + }, ) - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire) @@ -5050,7 +5196,7 @@ class QuestionnaireViewModelTest { code = "option1" display = "Option 1" } - } + }, ) } assertThat( @@ -5058,7 +5204,9 @@ class QuestionnaireViewModelTest { .getQuestionnaireResponse() .item .single { it.linkId == "b" } - .answerFirstRep.valueCoding.code + .answerFirstRep + .valueCoding + .code, ) .isEqualTo("option1") // change answer to depended question to trigger re-evaluation of @@ -5071,7 +5219,7 @@ class QuestionnaireViewModelTest { setAnswer( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = BooleanType(true) - } + }, ) } @@ -5111,19 +5259,19 @@ class QuestionnaireViewModelTest { this.language = "text/fhirpath" this.expression = "%resource.repeat(item).where(linkId='a-age-years' and answer.empty().not()).select(today() - answer.value)" - } + }, ) } - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { linkId = "a-age-years" type = Questionnaire.QuestionnaireItemType.QUANTITY addInitial( - Questionnaire.QuestionnaireItemInitialComponent(Quantity.fromUcum("1", "year")) + Questionnaire.QuestionnaireItemInitialComponent(Quantity.fromUcum("1", "year")), ) - } + }, ) } @@ -5134,7 +5282,9 @@ class QuestionnaireViewModelTest { .getQuestionnaireResponse() .item .single { it.linkId == "a-birthdate" } - .answerFirstRep.value.asStringValue() + .answerFirstRep + .value + .asStringValue(), ) .isEqualTo(DateType(Date()).apply { add(Calendar.YEAR, -1) }.asStringValue()) @@ -5143,7 +5293,10 @@ class QuestionnaireViewModelTest { .getQuestionnaireResponse() .item .single { it.linkId == "a-age-years" } - .answerFirstRep.valueQuantity.value.toString() + .answerFirstRep + .valueQuantity + .value + .toString(), ) .isEqualTo("1") } @@ -5166,16 +5319,16 @@ class QuestionnaireViewModelTest { this.language = "text/fhirpath" this.expression = "%resource.repeat(item).where(linkId='a-age-years' and answer.empty().not()).select(today() - answer.value)" - } + }, ) } - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { linkId = "a-age-years" type = Questionnaire.QuestionnaireItemType.INTEGER - } + }, ) } @@ -5200,14 +5353,14 @@ class QuestionnaireViewModelTest { listOf( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { this.value = Quantity.fromUcum("2", "years") - } + }, ), - null + null, ) } assertThat( - birthdateItem.getQuestionnaireResponseItem().answer.first().valueDateType.valueAsString + birthdateItem.getQuestionnaireResponseItem().answer.first().valueDateType.valueAsString, ) .isEqualTo(DateType(Date()).apply { add(Calendar.YEAR, -2) }.valueAsString) } @@ -5230,16 +5383,16 @@ class QuestionnaireViewModelTest { this.language = "text/fhirpath" this.expression = "%resource.repeat(item).where(linkId='a-age-years' and answer.empty().not()).select(today() - answer.value)" - } + }, ) } - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { linkId = "a-age-years" type = Questionnaire.QuestionnaireItemType.INTEGER - } + }, ) } @@ -5258,14 +5411,14 @@ class QuestionnaireViewModelTest { listOf( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { this.value = birthdateValue - } + }, ), - null + null, ) } assertThat( - birthdateItem.getQuestionnaireResponseItem().answer.first().valueDateType.valueAsString + birthdateItem.getQuestionnaireResponseItem().answer.first().valueDateType.valueAsString, ) .isEqualTo(birthdateValue.valueAsString) @@ -5280,14 +5433,14 @@ class QuestionnaireViewModelTest { listOf( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { this.value = Quantity.fromUcum("2", "years") - } + }, ), - null + null, ) } assertThat( - birthdateItem.getQuestionnaireResponseItem().answer.first().valueDateType.valueAsString + birthdateItem.getQuestionnaireResponseItem().answer.first().valueDateType.valueAsString, ) .isEqualTo(birthdateValue.valueAsString) } @@ -5304,8 +5457,8 @@ class QuestionnaireViewModelTest { type = Questionnaire.QuestionnaireItemType.DATE addInitial( Questionnaire.QuestionnaireItemInitialComponent( - DateType(Date()).apply { add(Calendar.YEAR, -2) } - ) + DateType(Date()).apply { add(Calendar.YEAR, -2) }, + ), ) addExtension().apply { url = EXTENSION_CALCULATED_EXPRESSION_URL @@ -5314,10 +5467,10 @@ class QuestionnaireViewModelTest { this.language = "text/fhirpath" this.expression = "%resource.repeat(item).where(linkId='a-age-years' and answer.empty().not()).select(today() - answer.value)" - } + }, ) } - } + }, ) addItem( @@ -5331,10 +5484,10 @@ class QuestionnaireViewModelTest { this.language = "text/fhirpath" this.expression = "today().toString().substring(0, 4).toInteger() - %resource.repeat(item).where(linkId='a-birthdate').answer.value.toString().substring(0, 4).toInteger()" - } + }, ) } - } + }, ) } @@ -5360,8 +5513,8 @@ class QuestionnaireViewModelTest { type = Questionnaire.QuestionnaireItemType.DATE addInitial( Questionnaire.QuestionnaireItemInitialComponent( - DateType(Date()).apply { add(Calendar.YEAR, -2) } - ) + DateType(Date()).apply { add(Calendar.YEAR, -2) }, + ), ) addExtension().apply { url = EXTENSION_CALCULATED_EXPRESSION_URL @@ -5370,10 +5523,10 @@ class QuestionnaireViewModelTest { this.language = "text/fhirpath" this.expression = "%resource.repeat(item).where(linkId='a-age-years' and answer.empty().not()).select(today() - answer.value)" - } + }, ) } - } + }, ) .addItem() .apply { @@ -5396,10 +5549,10 @@ class QuestionnaireViewModelTest { this.language = "text/fhirpath" this.expression = "today().toString().substring(0, 4).toInteger() - %resource.repeat(item).where(linkId='a-birthdate').answer.value.toString().substring(0, 4).toInteger()" - } + }, ) } - } + }, ) } @@ -5436,9 +5589,9 @@ class QuestionnaireViewModelTest { linkId = "nested-display-question" text = "subtitle text" type = Questionnaire.QuestionnaireItemType.DISPLAY - } + }, ) - } + }, ) } state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire)) @@ -5446,7 +5599,7 @@ class QuestionnaireViewModelTest { val viewModel = QuestionnaireViewModel(context, state) viewModel.runViewModelBlocking { assertThat( - viewModel.getQuestionnaireItemViewItemList().last().asQuestion().questionnaireItem.linkId + viewModel.getQuestionnaireItemViewItemList().last().asQuestion().questionnaireItem.linkId, ) .isEqualTo("nested-display-question") } @@ -5465,9 +5618,9 @@ class QuestionnaireViewModelTest { Coding().apply { code = EXTENSION_DISPLAY_CATEGORY_INSTRUCTIONS system = EXTENSION_DISPLAY_CATEGORY_SYSTEM - } + }, ) - } + }, ) } @@ -5486,9 +5639,9 @@ class QuestionnaireViewModelTest { text = "subtitle text" type = Questionnaire.QuestionnaireItemType.DISPLAY extension = listOf(displayCategoryExtension) - } + }, ) - } + }, ) } state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire)) @@ -5500,7 +5653,8 @@ class QuestionnaireViewModelTest { .getQuestionnaireItemViewItemList() .last() .asQuestion() - .questionnaireItem.linkId + .questionnaireItem + .linkId, ) .isEqualTo("parent-question") } @@ -5519,9 +5673,9 @@ class QuestionnaireViewModelTest { Coding().apply { code = "flyover" system = EXTENSION_ITEM_CONTROL_SYSTEM - } + }, ) - } + }, ) } @@ -5540,9 +5694,9 @@ class QuestionnaireViewModelTest { text = "flyover text" type = Questionnaire.QuestionnaireItemType.DISPLAY extension = listOf(itemControlExtensionWithFlyOverCode) - } + }, ) - } + }, ) } state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire)) @@ -5554,7 +5708,8 @@ class QuestionnaireViewModelTest { .getQuestionnaireItemViewItemList() .last() .asQuestion() - .questionnaireItem.linkId + .questionnaireItem + .linkId, ) .isEqualTo("parent-question") } @@ -5573,9 +5728,9 @@ class QuestionnaireViewModelTest { Coding().apply { code = DisplayItemControlType.HELP.extensionCode system = EXTENSION_ITEM_CONTROL_SYSTEM - } + }, ) - } + }, ) } @@ -5594,9 +5749,9 @@ class QuestionnaireViewModelTest { text = "help description" type = Questionnaire.QuestionnaireItemType.DISPLAY extension = listOf(itemControlExtensionWithHelpCode) - } + }, ) - } + }, ) } state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire)) @@ -5608,7 +5763,8 @@ class QuestionnaireViewModelTest { .getQuestionnaireItemViewItemList() .last() .asQuestion() - .questionnaireItem.linkId + .questionnaireItem + .linkId, ) .isEqualTo("parent-question") } @@ -5635,9 +5791,9 @@ class QuestionnaireViewModelTest { linkId = "page1-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 1" - } + }, ) - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -5649,9 +5805,9 @@ class QuestionnaireViewModelTest { linkId = "page2-1" type = Questionnaire.QuestionnaireItemType.BOOLEAN text = "Question on page 2" - } + }, ) - } + }, ) } val viewModel = createQuestionnaireViewModel(questionnaire) @@ -5673,7 +5829,7 @@ class QuestionnaireViewModelTest { language = Expression.ExpressionLanguage.TEXT_FHIRPATH.toCode() expression = "%resource.repeat(item).where(linkId='1' and answer.empty().not()).select(answer.value) = ${if (it) "true" else "false"}" - } + }, ) } } @@ -5687,9 +5843,9 @@ class QuestionnaireViewModelTest { Coding().apply { code = EXTENSION_DISPLAY_CATEGORY_INSTRUCTIONS system = EXTENSION_DISPLAY_CATEGORY_SYSTEM - } + }, ) - } + }, ) } val questionnaire: (List) -> Questionnaire = { @@ -5710,7 +5866,7 @@ class QuestionnaireViewModelTest { linkId = "1.1" text = "Text when no is selected" type = Questionnaire.QuestionnaireItemType.DISPLAY - } + }, ) .addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -5718,16 +5874,16 @@ class QuestionnaireViewModelTest { linkId = "1.2" text = "Text when yes is selected" type = Questionnaire.QuestionnaireItemType.DISPLAY - } + }, ) - } + }, ) } } state.set( EXTRA_QUESTIONNAIRE_JSON_STRING, - printer.encodeResourceToString(questionnaire(emptyList())) + printer.encodeResourceToString(questionnaire(emptyList())), ) // empty initial value @@ -5736,7 +5892,7 @@ class QuestionnaireViewModelTest { assertThat(viewModel.getQuestionnaireItemViewItemList().size).isEqualTo(1) // enabledDisplayItems is 0 when no choice is present assertThat( - viewModel.getQuestionnaireItemViewItemList()[0].asQuestion().enabledDisplayItems.size + viewModel.getQuestionnaireItemViewItemList()[0].asQuestion().enabledDisplayItems.size, ) .isEqualTo(0) } @@ -5747,10 +5903,10 @@ class QuestionnaireViewModelTest { printer.encodeResourceToString( questionnaire( listOf( - Questionnaire.QuestionnaireItemInitialComponent().apply { value = BooleanType(false) } - ) - ) - ) + Questionnaire.QuestionnaireItemInitialComponent().apply { value = BooleanType(false) }, + ), + ), + ), ) viewModel = QuestionnaireViewModel(context, state) @@ -5771,10 +5927,10 @@ class QuestionnaireViewModelTest { printer.encodeResourceToString( questionnaire( listOf( - Questionnaire.QuestionnaireItemInitialComponent().apply { value = BooleanType(true) } - ) - ) - ) + Questionnaire.QuestionnaireItemInitialComponent().apply { value = BooleanType(true) }, + ), + ), + ), ) viewModel = QuestionnaireViewModel(context, state) @@ -5799,7 +5955,7 @@ class QuestionnaireViewModelTest { Questionnaire.QuestionnaireItemComponent().apply { linkId = "a-age" type = Questionnaire.QuestionnaireItemType.INTEGER - } + }, ) addItem( Questionnaire.QuestionnaireItemComponent().apply { @@ -5812,10 +5968,10 @@ class QuestionnaireViewModelTest { this.language = "text/fhirpath" this.expression = "%resource.repeat(item).where(linkId='a-age' and answer.empty().not()).select('Notes for child of age ' + answer.value.toString() + ' years')" - } + }, ) } - } + }, ) } @@ -5832,7 +5988,8 @@ class QuestionnaireViewModelTest { viewModel.questionnaireStateFlow.collect { descriptionResponseItem = it.items - .find { it.asQuestion().questionnaireItem.linkId == "a-description" }!!.asQuestion() + .find { it.asQuestion().questionnaireItem.linkId == "a-description" }!! + .asQuestion() this@launch.cancel() } } @@ -5850,14 +6007,14 @@ class QuestionnaireViewModelTest { listOf( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { this.value = IntegerType(2) - } + }, ), - null + null, ) } assertThat( - ageItemUpdated.getQuestionnaireResponseItem().answer.first().valueIntegerType.value + ageItemUpdated.getQuestionnaireResponseItem().answer.first().valueIntegerType.value, ) .isEqualTo(2) @@ -5883,7 +6040,7 @@ class QuestionnaireViewModelTest { name = "A" language = "text/fhirpath" expression = "1" - } + }, ) } addItem( @@ -5897,7 +6054,7 @@ class QuestionnaireViewModelTest { name = "B" language = "text/fhirpath" expression = "2" - } + }, ) } textElement.addExtension().apply { @@ -5906,10 +6063,10 @@ class QuestionnaireViewModelTest { Expression().apply { this.language = "text/fhirpath" this.expression = "'Sum of variables is ' + ( %A + %B ).toString() " - } + }, ) } - } + }, ) } @@ -5931,14 +6088,14 @@ class QuestionnaireViewModelTest { enableReviewPage: Boolean = false, showReviewPageFirst: Boolean = false, readOnlyMode: Boolean = false, - showSubmitButton: Boolean? = null + showSubmitButton: Boolean? = null, ): QuestionnaireViewModel { state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire)) questionnaireResponse?.let { state.set( EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING, - printer.encodeResourceToString(questionnaireResponse) + printer.encodeResourceToString(questionnaireResponse), ) } enableReviewPage.let { state.set(EXTRA_ENABLE_REVIEW_PAGE, it) } @@ -5955,7 +6112,7 @@ class QuestionnaireViewModelTest { private fun QuestionnaireViewItem.getQuestionnaireResponseItem() = ReflectionHelpers.getField( this, - "questionnaireResponseItem" + "questionnaireResponseItem", ) /** @@ -5982,8 +6139,8 @@ class QuestionnaireViewModelTest { Coding().apply { code = "page" system = EXTENSION_ITEM_CONTROL_SYSTEM - } - ) + }, + ), ) } From f949093f2d8bab8009f708dbb8ee4b16126fd3ac Mon Sep 17 00:00:00 2001 From: Jing Tang Date: Thu, 14 Sep 2023 09:52:11 +0100 Subject: [PATCH 4/7] Remove UploadWorkManager (#2166) * Remove UploadWorkManager * Fix tests --- .../com/google/android/fhir/sync/Config.kt | 9 +- .../android/fhir/sync/FhirSyncWorker.kt | 24 +++-- .../android/fhir/sync/FhirSynchronizer.kt | 9 +- .../fhir/sync/{ => download}/Downloader.kt | 7 +- .../fhir/sync/download/DownloaderImpl.kt | 4 +- .../ResourceParamsBasedDownloadWorkManager.kt | 17 ++-- .../SquashedChangesUploadWorkManager.kt | 45 ---------- .../fhir/sync/upload/UploadWorkManager.kt | 36 -------- .../android/fhir/sync/upload/Uploader.kt | 81 +++++++++++++++-- .../android/fhir/sync/upload/UploaderImpl.kt | 90 ------------------- .../request/TransactionBundleGenerator.kt | 13 ++- .../fhir/sync/download/DownloaderImplTest.kt | 29 +++--- ...ourceParamsBasedDownloadWorkManagerTest.kt | 59 ++++++------ .../fhir/sync/upload/UploaderImplTest.kt | 30 +++---- .../patch/PerResourcePatchGeneratorTest.kt | 51 ++++++----- .../request/TransactionBundleGeneratorTest.kt | 54 +++++------ 16 files changed, 222 insertions(+), 336 deletions(-) rename engine/src/main/java/com/google/android/fhir/sync/{ => download}/Downloader.kt (85%) delete mode 100644 engine/src/main/java/com/google/android/fhir/sync/upload/SquashedChangesUploadWorkManager.kt delete mode 100644 engine/src/main/java/com/google/android/fhir/sync/upload/UploadWorkManager.kt delete mode 100644 engine/src/main/java/com/google/android/fhir/sync/upload/UploaderImpl.kt diff --git a/engine/src/main/java/com/google/android/fhir/sync/Config.kt b/engine/src/main/java/com/google/android/fhir/sync/Config.kt index 4ff02cd2b3..ba35177c0b 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/Config.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/Config.kt @@ -40,7 +40,6 @@ val defaultRetryConfiguration = object SyncDataParams { const val SORT_KEY = "_sort" const val LAST_UPDATED_KEY = "_lastUpdated" - const val ADDRESS_COUNTRY_KEY = "address-country" const val SUMMARY_KEY = "_summary" const val SUMMARY_COUNT_VALUE = "count" } @@ -60,14 +59,14 @@ class PeriodicSyncConfiguration( val repeat: RepeatInterval, /** Configuration for synchronization retry */ - val retryConfiguration: RetryConfiguration? = defaultRetryConfiguration + val retryConfiguration: RetryConfiguration? = defaultRetryConfiguration, ) data class RepeatInterval( /** The interval at which the sync should be triggered in */ val interval: Long, /** The time unit for the repeat interval */ - val timeUnit: TimeUnit + val timeUnit: TimeUnit, ) fun ParamMap.concatParams(): String { @@ -85,7 +84,7 @@ data class RetryConfiguration( val backoffCriteria: BackoffCriteria, /** Maximum retries for a failing [FhirSyncWorker] */ - val maxRetries: Int + val maxRetries: Int, ) /** @@ -104,5 +103,5 @@ data class BackoffCriteria( val backoffDelay: Long, /** The time unit for [backoffDelay] */ - val timeUnit: TimeUnit + val timeUnit: TimeUnit, ) diff --git a/engine/src/main/java/com/google/android/fhir/sync/FhirSyncWorker.kt b/engine/src/main/java/com/google/android/fhir/sync/FhirSyncWorker.kt index 07c3e644a1..e189daf48a 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/FhirSyncWorker.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/FhirSyncWorker.kt @@ -25,9 +25,7 @@ import com.google.android.fhir.FhirEngine import com.google.android.fhir.FhirEngineProvider import com.google.android.fhir.OffsetDateTimeTypeAdapter import com.google.android.fhir.sync.download.DownloaderImpl -import com.google.android.fhir.sync.upload.SquashedChangesUploadWorkManager -import com.google.android.fhir.sync.upload.UploadWorkManager -import com.google.android.fhir.sync.upload.UploaderImpl +import com.google.android.fhir.sync.upload.Uploader import com.google.gson.ExclusionStrategy import com.google.gson.FieldAttributes import com.google.gson.GsonBuilder @@ -43,10 +41,9 @@ import timber.log.Timber abstract class FhirSyncWorker(appContext: Context, workerParams: WorkerParameters) : CoroutineWorker(appContext, workerParams) { abstract fun getFhirEngine(): FhirEngine + abstract fun getDownloadWorkManager(): DownloadWorkManager - private fun getUploadWorkManager(): UploadWorkManager { - return SquashedChangesUploadWorkManager() - } + abstract fun getConflictResolver(): ConflictResolver private val gson = @@ -64,9 +61,9 @@ abstract class FhirSyncWorker(appContext: Context, workerParams: WorkerParameter ?: return Result.failure( buildWorkData( IllegalStateException( - "FhirEngineConfiguration.ServerConfiguration is not set. Call FhirEngineProvider.init to initialize with appropriate configuration." - ) - ) + "FhirEngineConfiguration.ServerConfiguration is not set. Call FhirEngineProvider.init to initialize with appropriate configuration.", + ), + ), ) val flow = MutableSharedFlow() @@ -88,9 +85,9 @@ abstract class FhirSyncWorker(appContext: Context, workerParams: WorkerParameter FhirSynchronizer( applicationContext, getFhirEngine(), - UploaderImpl(dataSource, getUploadWorkManager()), + Uploader(dataSource), DownloaderImpl(dataSource, getDownloadWorkManager()), - getConflictResolver() + getConflictResolver(), ) .apply { subscribe(flow) } .synchronize() @@ -125,7 +122,7 @@ abstract class FhirSyncWorker(appContext: Context, workerParams: WorkerParameter return workDataOf( // send serialized state and type so that consumer can convert it back "StateType" to state::class.java.name, - "State" to gson.toJson(state) + "State" to gson.toJson(state), ) } @@ -137,8 +134,9 @@ abstract class FhirSyncWorker(appContext: Context, workerParams: WorkerParameter * Exclusion strategy for [Gson] that handles field exclusions for [SyncJobStatus] returned by * FhirSynchronizer. It should skip serializing the exceptions to avoid exceeding WorkManager * WorkData limit + * * @see https://github.com/google/android-fhir/issues/707 + * href="https://github.com/google/android-fhir/issues/707">https://github.com/google/android-fhir/issues/707 */ internal class StateExclusionStrategy : ExclusionStrategy { override fun shouldSkipField(field: FieldAttributes) = field.name.equals("exceptions") diff --git a/engine/src/main/java/com/google/android/fhir/sync/FhirSynchronizer.kt b/engine/src/main/java/com/google/android/fhir/sync/FhirSynchronizer.kt index 5a4e2aa95d..c58857dfbb 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/FhirSynchronizer.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/FhirSynchronizer.kt @@ -19,6 +19,8 @@ package com.google.android.fhir.sync import android.content.Context import com.google.android.fhir.DatastoreUtil import com.google.android.fhir.FhirEngine +import com.google.android.fhir.sync.download.DownloadState +import com.google.android.fhir.sync.download.Downloader import com.google.android.fhir.sync.upload.UploadState import com.google.android.fhir.sync.upload.Uploader import java.time.OffsetDateTime @@ -28,13 +30,14 @@ import org.hl7.fhir.r4.model.ResourceType enum class SyncOperation { DOWNLOAD, - UPLOAD + UPLOAD, } private sealed class SyncResult { val timestamp: OffsetDateTime = OffsetDateTime.now() class Success : SyncResult() + data class Error(val exceptions: List) : SyncResult() } @@ -46,7 +49,7 @@ internal class FhirSynchronizer( private val fhirEngine: FhirEngine, private val uploader: Uploader, private val downloader: Downloader, - private val conflictResolver: ConflictResolver + private val conflictResolver: ConflictResolver, ) { private var syncState: MutableSharedFlow? = null private val datastoreUtil = DatastoreUtil(context) @@ -135,7 +138,7 @@ internal class FhirSynchronizer( is UploadState.Success -> emit(result.localChangeToken to result.resource).also { setSyncState( - SyncJobStatus.InProgress(SyncOperation.UPLOAD, result.total, result.completed) + SyncJobStatus.InProgress(SyncOperation.UPLOAD, result.total, result.completed), ) } is UploadState.Failure -> exceptions.add(result.syncError) diff --git a/engine/src/main/java/com/google/android/fhir/sync/Downloader.kt b/engine/src/main/java/com/google/android/fhir/sync/download/Downloader.kt similarity index 85% rename from engine/src/main/java/com/google/android/fhir/sync/Downloader.kt rename to engine/src/main/java/com/google/android/fhir/sync/download/Downloader.kt index 9be709402c..4e51e5809b 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/Downloader.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/download/Downloader.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 Google LLC + * Copyright 2022-2023 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,8 +14,9 @@ * limitations under the License. */ -package com.google.android.fhir.sync +package com.google.android.fhir.sync.download +import com.google.android.fhir.sync.ResourceSyncException import kotlinx.coroutines.flow.Flow import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType @@ -24,7 +25,7 @@ import org.hl7.fhir.r4.model.ResourceType internal interface Downloader { /** * @return Flow of the [DownloadState] which keeps emitting [Resource]s or Error based on the - * response of each page download request. It also updates progress if [ProgressCallback] exists + * response of each page download request. It also updates progress if [ProgressCallback] exists */ suspend fun download(): Flow } diff --git a/engine/src/main/java/com/google/android/fhir/sync/download/DownloaderImpl.kt b/engine/src/main/java/com/google/android/fhir/sync/download/DownloaderImpl.kt index 9efb3c73cd..72e2461d58 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/download/DownloaderImpl.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/download/DownloaderImpl.kt @@ -19,9 +19,7 @@ package com.google.android.fhir.sync.download import com.google.android.fhir.sync.BundleDownloadRequest import com.google.android.fhir.sync.DataSource import com.google.android.fhir.sync.DownloadRequest -import com.google.android.fhir.sync.DownloadState import com.google.android.fhir.sync.DownloadWorkManager -import com.google.android.fhir.sync.Downloader import com.google.android.fhir.sync.ResourceSyncException import com.google.android.fhir.sync.UrlDownloadRequest import kotlinx.coroutines.flow.Flow @@ -38,7 +36,7 @@ import timber.log.Timber */ internal class DownloaderImpl( private val dataSource: DataSource, - private val downloadWorkManager: DownloadWorkManager + private val downloadWorkManager: DownloadWorkManager, ) : Downloader { private val resourceTypeList = ResourceType.values().map { it.name } diff --git a/engine/src/main/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManager.kt b/engine/src/main/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManager.kt index 94c95c0b23..13efdffdef 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManager.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManager.kt @@ -31,6 +31,7 @@ import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType typealias ResourceSearchParams = Map + /** * [DownloadWorkManager] implementation based on the provided [ResourceSearchParams] to generate * [Resource] search queries and parse [Bundle.BundleType.SEARCHSET] type [Bundle]. This @@ -39,14 +40,15 @@ typealias ResourceSearchParams = Map */ class ResourceParamsBasedDownloadWorkManager( syncParams: ResourceSearchParams, - val context: TimestampContext + val context: TimestampContext, ) : DownloadWorkManager { private val resourcesToDownloadWithSearchParams = LinkedList(syncParams.entries) private val urlOfTheNextPagesToDownloadForAResource = LinkedList() override suspend fun getNextRequest(): DownloadRequest? { - if (urlOfTheNextPagesToDownloadForAResource.isNotEmpty()) + if (urlOfTheNextPagesToDownloadForAResource.isNotEmpty()) { return urlOfTheNextPagesToDownloadForAResource.poll()?.let { DownloadRequest.of(it) } + } return resourcesToDownloadWithSearchParams.poll()?.let { (resourceType, params) -> val newParams = @@ -74,7 +76,7 @@ class ResourceParamsBasedDownloadWorkManager( private suspend fun getLastUpdatedParam( resourceType: ResourceType, params: ParamMap, - context: TimestampContext + context: TimestampContext, ): MutableMap { val newParams = mutableMapOf() if (!params.containsKey(SyncDataParams.SORT_KEY)) { @@ -108,20 +110,22 @@ class ResourceParamsBasedDownloadWorkManager( response.link .firstOrNull { component -> component.relation == "next" } - ?.url?.let { next -> urlOfTheNextPagesToDownloadForAResource.add(next) } + ?.url + ?.let { next -> urlOfTheNextPagesToDownloadForAResource.add(next) } return response.entry .map { it.resource } .also { resources -> resources .groupBy { it.resourceType } - .entries.map { map -> + .entries + .map { map -> map.value .filter { it.meta.lastUpdated != null } .let { context.saveLastUpdatedTimestamp( map.key, - it.maxOfOrNull { it.meta.lastUpdated }?.toTimeZoneString() + it.maxOfOrNull { it.meta.lastUpdated }?.toTimeZoneString(), ) } } @@ -130,6 +134,7 @@ class ResourceParamsBasedDownloadWorkManager( interface TimestampContext { suspend fun saveLastUpdatedTimestamp(resourceType: ResourceType, timestamp: String?) + suspend fun getLasUpdateTimestamp(resourceType: ResourceType): String? } } diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/SquashedChangesUploadWorkManager.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/SquashedChangesUploadWorkManager.kt deleted file mode 100644 index 9705c56a85..0000000000 --- a/engine/src/main/java/com/google/android/fhir/sync/upload/SquashedChangesUploadWorkManager.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.fhir.sync.upload - -import com.google.android.fhir.LocalChange -import com.google.android.fhir.sync.UploadRequest -import com.google.android.fhir.sync.upload.patch.Patch -import com.google.android.fhir.sync.upload.patch.PerResourcePatchGenerator -import com.google.android.fhir.sync.upload.request.TransactionBundleGenerator - -/** - * [UploadWorkManager] implementation to squash all the changes at a resource level into one change - * and upload all resource level changes in a [BundleUploadRequest] - */ -class SquashedChangesUploadWorkManager : UploadWorkManager { - - private val bundleUploadRequestGenerator = TransactionBundleGenerator.getDefault() - - /** - * The implementation is to squash all the changes by resource type so that there is at most one - * patch to be uploaded per resource. - */ - override fun generatePatches(localChanges: List): List { - return PerResourcePatchGenerator.generate(localChanges) - } - - /** Use the [TransactionBundleGenerator] to bundle the [Patch]es into [BundleUploadRequest]s. */ - override fun generateRequests(patches: List): List { - return bundleUploadRequestGenerator.generateUploadRequests(patches) - } -} diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/UploadWorkManager.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/UploadWorkManager.kt deleted file mode 100644 index 88c1994a2f..0000000000 --- a/engine/src/main/java/com/google/android/fhir/sync/upload/UploadWorkManager.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.fhir.sync.upload - -import com.google.android.fhir.LocalChange -import com.google.android.fhir.sync.UploadRequest -import com.google.android.fhir.sync.upload.patch.Patch - -/** - * Manager that pre-processes the local FHIR changes and handles how to upload them to the server. - */ -internal interface UploadWorkManager { - /** - * Transforms the [LocalChange]s to the final set of [Patch]es that need to be uploaded to the - * server. The transformation can be of various types like grouping the [LocalChange]s by resource - * or filtering out certain [LocalChange]s. - */ - fun generatePatches(localChanges: List): List - - /** Generates a list of [UploadRequest]s from the [Patch]es to be uploaded to the server */ - fun generateRequests(patches: List): List -} diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/Uploader.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/Uploader.kt index 9c76abccfd..0e5653371b 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/upload/Uploader.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/Uploader.kt @@ -18,28 +18,91 @@ package com.google.android.fhir.sync.upload import com.google.android.fhir.LocalChange import com.google.android.fhir.LocalChangeToken +import com.google.android.fhir.sync.DataSource import com.google.android.fhir.sync.ResourceSyncException +import com.google.android.fhir.sync.upload.patch.PerResourcePatchGenerator +import com.google.android.fhir.sync.upload.request.TransactionBundleGenerator import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import org.hl7.fhir.exceptions.FHIRException +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.OperationOutcome import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.model.ResourceType +import timber.log.Timber -/** Module for uploading local changes to a [DataSource]. */ -internal interface Uploader { +/** + * Uploads changes made locally to FHIR resources to server in the following steps: + * 1. fetching local changes from the on-device SQLite database, + * 2. creating patches to be sent to the server using the local changes, + * 3. generating HTTP requests to be sent to the server, + * 4. processing the responses from the server and consolidate any changes (i.e. updates resource + * IDs). + */ +internal class Uploader( + private val dataSource: DataSource, +) { + private val patchGenerator = PerResourcePatchGenerator + private val requestGenerator = TransactionBundleGenerator.getDefault() + + suspend fun upload(localChanges: List): Flow = flow { + val patches = patchGenerator.generate(localChanges) + val requests = requestGenerator.generateUploadRequests(patches) + val token = LocalChangeToken(localChanges.flatMap { it.token.ids }) + val total = requests.size + emit(UploadState.Started(total)) + requests.forEachIndexed { index, uploadRequest -> + try { + val response = dataSource.upload(uploadRequest) + emit( + getUploadResult(uploadRequest.resource.resourceType, response, token, total, index + 1), + ) + } catch (e: Exception) { + Timber.e(e) + emit(UploadState.Failure(ResourceSyncException(ResourceType.Bundle, e))) + } + } + } - /** - * Uploads the local changes to the [DataSource]. Particular implementations should take care of - * transforming the [LocalChange]s to particular network operations. If [ProgressCallback] is - * provided it also reports the intermediate progress - */ - suspend fun upload(localChanges: List): Flow + private fun getUploadResult( + requestResourceType: ResourceType, + response: Resource, + localChangeToken: LocalChangeToken, + total: Int, + completed: Int, + ) = + when { + response is Bundle && response.type == Bundle.BundleType.TRANSACTIONRESPONSE -> { + UploadState.Success(localChangeToken, response, total, completed) + } + response is OperationOutcome && response.issue.isNotEmpty() -> { + UploadState.Failure( + ResourceSyncException( + requestResourceType, + FHIRException(response.issueFirstRep.diagnostics), + ), + ) + } + else -> { + UploadState.Failure( + ResourceSyncException( + requestResourceType, + FHIRException("Unknown response for ${response.resourceType}"), + ), + ) + } + } } internal sealed class UploadState { data class Started(val total: Int) : UploadState() + data class Success( val localChangeToken: LocalChangeToken, val resource: Resource, val total: Int, - val completed: Int + val completed: Int, ) : UploadState() + data class Failure(val syncError: ResourceSyncException) : UploadState() } diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/UploaderImpl.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/UploaderImpl.kt deleted file mode 100644 index 081fc8b430..0000000000 --- a/engine/src/main/java/com/google/android/fhir/sync/upload/UploaderImpl.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.fhir.sync.upload - -import com.google.android.fhir.LocalChange -import com.google.android.fhir.LocalChangeToken -import com.google.android.fhir.sync.DataSource -import com.google.android.fhir.sync.ResourceSyncException -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import org.hl7.fhir.exceptions.FHIRException -import org.hl7.fhir.r4.model.Bundle -import org.hl7.fhir.r4.model.OperationOutcome -import org.hl7.fhir.r4.model.Resource -import org.hl7.fhir.r4.model.ResourceType -import timber.log.Timber - -/** - * Implementation of the [Uploader]. It orchestrates the pre processing of [LocalChange] and - * constructing appropriate upload requests via [UploadWorkManager] and uploading of requests via - * [DataSource]. [Uploader] clients should call upload and listen to the various states emitted by - * [UploadWorkManager] as [UploadState]. - */ -internal class UploaderImpl( - private val dataSource: DataSource, - private val uploadWorkManager: UploadWorkManager, -) : Uploader { - - override suspend fun upload(localChanges: List): Flow = flow { - val patches = uploadWorkManager.generatePatches(localChanges) - val requests = uploadWorkManager.generateRequests(patches) - val token = LocalChangeToken(localChanges.flatMap { it.token.ids }) - val total = requests.size - emit(UploadState.Started(total)) - requests.forEachIndexed { index, uploadRequest -> - try { - val response = dataSource.upload(uploadRequest) - emit( - getUploadResult(uploadRequest.resource.resourceType, response, token, total, index + 1) - ) - } catch (e: Exception) { - Timber.e(e) - emit(UploadState.Failure(ResourceSyncException(ResourceType.Bundle, e))) - } - } - } - - private fun getUploadResult( - requestResourceType: ResourceType, - response: Resource, - localChangeToken: LocalChangeToken, - total: Int, - completed: Int - ) = - when { - response is Bundle && response.type == Bundle.BundleType.TRANSACTIONRESPONSE -> { - UploadState.Success(localChangeToken, response, total, completed) - } - response is OperationOutcome && response.issue.isNotEmpty() -> { - UploadState.Failure( - ResourceSyncException( - requestResourceType, - FHIRException(response.issueFirstRep.diagnostics) - ) - ) - } - else -> { - UploadState.Failure( - ResourceSyncException( - requestResourceType, - FHIRException("Unknown response for ${response.resourceType}") - ) - ) - } - } -} diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/request/TransactionBundleGenerator.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/request/TransactionBundleGenerator.kt index b068c4a222..9759481125 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/upload/request/TransactionBundleGenerator.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/request/TransactionBundleGenerator.kt @@ -29,7 +29,7 @@ class TransactionBundleGenerator( private val generatedBundleSize: Int, private val useETagForUpload: Boolean, private val getBundleEntryComponentGeneratorForLocalChangeType: - (type: Patch.Type, useETagForUpload: Boolean) -> BundleEntryComponentGenerator + (type: Patch.Type, useETagForUpload: Boolean) -> BundleEntryComponentGenerator, ) : UploadRequestGenerator { override fun generateUploadRequests(patches: List): List { @@ -43,7 +43,7 @@ class TransactionBundleGenerator( patches.forEach { this.addEntry( getBundleEntryComponentGeneratorForLocalChangeType(it.type, useETagForUpload) - .getEntry(it) + .getEntry(it), ) } } @@ -78,17 +78,16 @@ class TransactionBundleGenerator( generatedBundleSize: Int, useETagForUpload: Boolean, ): TransactionBundleGenerator { - val createFunction = createMapping[httpVerbToUseForCreate] ?: throw IllegalArgumentException( - "Creation using $httpVerbToUseForCreate is not supported." + "Creation using $httpVerbToUseForCreate is not supported.", ) val updateFunction = updateMapping[httpVerbToUseForUpdate] ?: throw IllegalArgumentException( - "Update using $httpVerbToUseForUpdate is not supported." + "Update using $httpVerbToUseForUpdate is not supported.", ) return TransactionBundleGenerator(generatedBundleSize, useETagForUpload) { type, useETag -> @@ -101,11 +100,11 @@ class TransactionBundleGenerator( } private fun putForCreateBasedBundleComponentMapper( - useETagForUpload: Boolean + useETagForUpload: Boolean, ): BundleEntryComponentGenerator = HttpPutForCreateEntryComponentGenerator(useETagForUpload) private fun patchForUpdateBasedBundleComponentMapper( - useETagForUpload: Boolean + useETagForUpload: Boolean, ): BundleEntryComponentGenerator = HttpPatchForUpdateEntryComponentGenerator(useETagForUpload) } } diff --git a/engine/src/test/java/com/google/android/fhir/sync/download/DownloaderImplTest.kt b/engine/src/test/java/com/google/android/fhir/sync/download/DownloaderImplTest.kt index 2a3a7d9a01..19367877a3 100644 --- a/engine/src/test/java/com/google/android/fhir/sync/download/DownloaderImplTest.kt +++ b/engine/src/test/java/com/google/android/fhir/sync/download/DownloaderImplTest.kt @@ -20,7 +20,6 @@ import com.google.android.fhir.logicalId import com.google.android.fhir.sync.BundleDownloadRequest import com.google.android.fhir.sync.DataSource import com.google.android.fhir.sync.DownloadRequest -import com.google.android.fhir.sync.DownloadState import com.google.android.fhir.sync.DownloadWorkManager import com.google.android.fhir.sync.UploadRequest import com.google.android.fhir.sync.UrlDownloadRequest @@ -53,7 +52,7 @@ class DownloaderImplTest { DownloadRequest.of("Patient"), DownloadRequest.of("Encounter"), DownloadRequest.of("Medication/med-123-that-fails"), - DownloadRequest.of(bundleOf("Observation/ob-123", "Condition/con-123")) + DownloadRequest.of(bundleOf("Observation/ob-123", "Condition/con-123")), ) val testDataSource: DataSource = @@ -66,7 +65,7 @@ class DownloaderImplTest { addEntry( Bundle.BundleEntryComponent().apply { resource = Patient().apply { id = "pa-123" } - } + }, ) } "Encounter" -> @@ -79,7 +78,7 @@ class DownloaderImplTest { id = "en-123" subject = Reference("Patient/pa-123") } - } + }, ) } "Medication/med-123-that-fails" -> @@ -88,7 +87,7 @@ class DownloaderImplTest { OperationOutcome.OperationOutcomeIssueComponent().apply { severity = OperationOutcome.IssueSeverity.FATAL diagnostics = "Resource not found." - } + }, ) } else -> OperationOutcome() @@ -105,7 +104,7 @@ class DownloaderImplTest { id = "ob-123" subject = Reference("Patient/pq-123") } - } + }, ) addEntry( Bundle.BundleEntryComponent().apply { @@ -114,7 +113,7 @@ class DownloaderImplTest { id = "con-123" subject = Reference("Patient/pq-123") } - } + }, ) } } @@ -152,7 +151,7 @@ class DownloaderImplTest { DownloadRequest.of("Patient"), DownloadRequest.of("Encounter"), DownloadRequest.of("Medication/med-123-that-fails"), - DownloadRequest.of(bundleOf("Observation/ob-123", "Condition/con-123")) + DownloadRequest.of(bundleOf("Observation/ob-123", "Condition/con-123")), ) val testDataSource: DataSource = @@ -165,7 +164,7 @@ class DownloaderImplTest { addEntry( Bundle.BundleEntryComponent().apply { resource = Patient().apply { id = "pa-123" } - } + }, ) } "Encounter" -> @@ -178,7 +177,7 @@ class DownloaderImplTest { id = "en-123" subject = Reference("Patient/pa-123") } - } + }, ) } "Medication/med-123-that-fails" -> @@ -187,7 +186,7 @@ class DownloaderImplTest { OperationOutcome.OperationOutcomeIssueComponent().apply { severity = OperationOutcome.IssueSeverity.FATAL diagnostics = "Resource not found." - } + }, ) } else -> OperationOutcome() @@ -204,7 +203,7 @@ class DownloaderImplTest { id = "ob-123" subject = Reference("Patient/pq-123") } - } + }, ) addEntry( Bundle.BundleEntryComponent().apply { @@ -213,7 +212,7 @@ class DownloaderImplTest { id = "con-123" subject = Reference("Patient/pq-123") } - } + }, ) } } @@ -239,7 +238,7 @@ class DownloaderImplTest { DownloadState.Success::class.java, DownloadState.Success::class.java, DownloadState.Failure::class.java, - DownloadState.Success::class.java + DownloadState.Success::class.java, ) .inOrder() @@ -261,7 +260,7 @@ class DownloaderImplTest { method = Bundle.HTTPVerb.GET url = it } - } + }, ) } } diff --git a/engine/src/test/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManagerTest.kt b/engine/src/test/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManagerTest.kt index e59cb98ae1..fb76798700 100644 --- a/engine/src/test/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManagerTest.kt +++ b/engine/src/test/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManagerTest.kt @@ -47,7 +47,7 @@ class ResourceParamsBasedDownloadWorkManagerTest { ResourceType.Immunization to emptyMap(), ResourceType.Observation to emptyMap(), ), - TestResourceParamsBasedDownloadWorkManagerContext("2022-03-20") + TestResourceParamsBasedDownloadWorkManagerContext("2022-03-20"), ) val urlsToDownload = mutableListOf() @@ -62,7 +62,7 @@ class ResourceParamsBasedDownloadWorkManagerTest { .containsExactly( "Patient?address-city=NAIROBI&_sort=_lastUpdated&_lastUpdated=gt2022-03-20", "Observation?_sort=_lastUpdated&_lastUpdated=gt2022-03-20", - "Immunization?_sort=_lastUpdated&_lastUpdated=gt2022-03-20" + "Immunization?_sort=_lastUpdated&_lastUpdated=gt2022-03-20", ) } @@ -72,7 +72,7 @@ class ResourceParamsBasedDownloadWorkManagerTest { val downloadManager = ResourceParamsBasedDownloadWorkManager( mapOf(ResourceType.Patient to emptyMap(), ResourceType.Observation to emptyMap()), - TestResourceParamsBasedDownloadWorkManagerContext("2022-03-20") + TestResourceParamsBasedDownloadWorkManagerContext("2022-03-20"), ) val urlsToDownload = mutableListOf() @@ -84,7 +84,7 @@ class ResourceParamsBasedDownloadWorkManagerTest { // Call process response so that It can add the next page url to be downloaded next. when (url) { "Patient?_sort=_lastUpdated&_lastUpdated=gt2022-03-20", - "Observation?_sort=_lastUpdated&_lastUpdated=gt2022-03-20" -> { + "Observation?_sort=_lastUpdated&_lastUpdated=gt2022-03-20", -> { downloadManager.processResponse( Bundle().apply { type = Bundle.BundleType.SEARCHSET @@ -92,9 +92,9 @@ class ResourceParamsBasedDownloadWorkManagerTest { Bundle.BundleLinkComponent().apply { relation = "next" this.url = "http://url-to-next-page?token=pageToken" - } + }, ) - } + }, ) } } @@ -105,7 +105,7 @@ class ResourceParamsBasedDownloadWorkManagerTest { "Patient?_sort=_lastUpdated&_lastUpdated=gt2022-03-20", "http://url-to-next-page?token=pageToken", "Observation?_sort=_lastUpdated&_lastUpdated=gt2022-03-20", - "http://url-to-next-page?token=pageToken" + "http://url-to-next-page?token=pageToken", ) } @@ -115,7 +115,7 @@ class ResourceParamsBasedDownloadWorkManagerTest { val downloadManager = ResourceParamsBasedDownloadWorkManager( mapOf(ResourceType.Patient to emptyMap()), - TestResourceParamsBasedDownloadWorkManagerContext("2022-06-28") + TestResourceParamsBasedDownloadWorkManagerContext("2022-06-28"), ) val url = downloadManager.getNextRequest()?.let { (it as UrlDownloadRequest).url } assertThat(url).isEqualTo("Patient?_sort=_lastUpdated&_lastUpdated=gt2022-06-28") @@ -130,10 +130,10 @@ class ResourceParamsBasedDownloadWorkManagerTest { ResourceType.Patient to mapOf( SyncDataParams.LAST_UPDATED_KEY to "2022-06-28", - SyncDataParams.SORT_KEY to "status" - ) + SyncDataParams.SORT_KEY to "status", + ), ), - TestResourceParamsBasedDownloadWorkManagerContext("2022-07-07") + TestResourceParamsBasedDownloadWorkManagerContext("2022-07-07"), ) val url = downloadManager.getNextRequest()?.let { (it as UrlDownloadRequest).url } assertThat(url).isEqualTo("Patient?_lastUpdated=2022-06-28&_sort=status") @@ -145,7 +145,7 @@ class ResourceParamsBasedDownloadWorkManagerTest { val downloadManager = ResourceParamsBasedDownloadWorkManager( mapOf(ResourceType.Patient to mapOf(SyncDataParams.LAST_UPDATED_KEY to "gt2022-06-28")), - TestResourceParamsBasedDownloadWorkManagerContext("2022-07-07") + TestResourceParamsBasedDownloadWorkManagerContext("2022-07-07"), ) val url = downloadManager.getNextRequest()?.let { (it as UrlDownloadRequest).url } assertThat(url).isEqualTo("Patient?_lastUpdated=gt2022-06-28&_sort=_lastUpdated") @@ -157,7 +157,7 @@ class ResourceParamsBasedDownloadWorkManagerTest { val downloadManager = ResourceParamsBasedDownloadWorkManager( mapOf(ResourceType.Patient to mapOf(Patient.ADDRESS_CITY.paramName to "NAIROBI")), - NoOpResourceParamsBasedDownloadWorkManagerContext + NoOpResourceParamsBasedDownloadWorkManagerContext, ) val actual = downloadManager.getNextRequest()?.let { (it as UrlDownloadRequest).url } assertThat(actual).isEqualTo("Patient?address-city=NAIROBI&_sort=_lastUpdated") @@ -169,7 +169,7 @@ class ResourceParamsBasedDownloadWorkManagerTest { val downloadManager = ResourceParamsBasedDownloadWorkManager( mapOf(ResourceType.Patient to mapOf(Patient.ADDRESS_CITY.paramName to "NAIROBI")), - TestResourceParamsBasedDownloadWorkManagerContext("") + TestResourceParamsBasedDownloadWorkManagerContext(""), ) val actual = downloadManager.getNextRequest()?.let { (it as UrlDownloadRequest).url } assertThat(actual).isEqualTo("Patient?address-city=NAIROBI&_sort=_lastUpdated") @@ -185,7 +185,7 @@ class ResourceParamsBasedDownloadWorkManagerTest { ResourceType.Immunization to emptyMap(), ResourceType.Observation to emptyMap(), ), - TestResourceParamsBasedDownloadWorkManagerContext("2022-03-20") + TestResourceParamsBasedDownloadWorkManagerContext("2022-03-20"), ) val urls = downloadManager.getSummaryRequestUrls() @@ -196,7 +196,7 @@ class ResourceParamsBasedDownloadWorkManagerTest { .containsExactly( "Patient?address-city=NAIROBI&_sort=_lastUpdated&_lastUpdated=gt2022-03-20&_summary=count", "Immunization?_sort=_lastUpdated&_lastUpdated=gt2022-03-20&_summary=count", - "Observation?_sort=_lastUpdated&_lastUpdated=gt2022-03-20&_summary=count" + "Observation?_sort=_lastUpdated&_lastUpdated=gt2022-03-20&_summary=count", ) } @@ -205,14 +205,14 @@ class ResourceParamsBasedDownloadWorkManagerTest { val downloadManager = ResourceParamsBasedDownloadWorkManager( emptyMap(), - NoOpResourceParamsBasedDownloadWorkManagerContext + NoOpResourceParamsBasedDownloadWorkManagerContext, ) val response = OperationOutcome().apply { addIssue( OperationOutcome.OperationOutcomeIssueComponent().apply { diagnostics = "Server couldn't fulfil the request." - } + }, ) } @@ -230,7 +230,7 @@ class ResourceParamsBasedDownloadWorkManagerTest { val downloadManager = ResourceParamsBasedDownloadWorkManager( emptyMap(), - NoOpResourceParamsBasedDownloadWorkManagerContext + NoOpResourceParamsBasedDownloadWorkManagerContext, ) val response = Binary().apply { contentType = "application/json" } @@ -243,7 +243,7 @@ class ResourceParamsBasedDownloadWorkManagerTest { val downloadManager = ResourceParamsBasedDownloadWorkManager( emptyMap(), - NoOpResourceParamsBasedDownloadWorkManagerContext + NoOpResourceParamsBasedDownloadWorkManagerContext, ) val response = Bundle().apply { @@ -251,12 +251,12 @@ class ResourceParamsBasedDownloadWorkManagerTest { addEntry( Bundle.BundleEntryComponent().apply { resource = Patient().apply { id = "Patient-Id-001" } - } + }, ) addEntry( Bundle.BundleEntryComponent().apply { resource = Patient().apply { id = "Patient-Id-002" } - } + }, ) } @@ -269,7 +269,7 @@ class ResourceParamsBasedDownloadWorkManagerTest { val downloadManager = ResourceParamsBasedDownloadWorkManager( emptyMap(), - NoOpResourceParamsBasedDownloadWorkManagerContext + NoOpResourceParamsBasedDownloadWorkManagerContext, ) val response = Bundle().apply { @@ -277,12 +277,12 @@ class ResourceParamsBasedDownloadWorkManagerTest { addEntry( Bundle.BundleEntryComponent().apply { resource = Patient().apply { id = "Patient-Id-001" } - } + }, ) addEntry( Bundle.BundleEntryComponent().apply { resource = Patient().apply { id = "Patient-Id-002" } - } + }, ) } @@ -296,7 +296,7 @@ class ResourceParamsBasedDownloadWorkManagerTest { val downloadManager = ResourceParamsBasedDownloadWorkManager( emptyMap(), - NoOpResourceParamsBasedDownloadWorkManagerContext + NoOpResourceParamsBasedDownloadWorkManagerContext, ) val response = Bundle().apply { @@ -306,7 +306,7 @@ class ResourceParamsBasedDownloadWorkManagerTest { Bundle.BundleLinkComponent().apply { relation = "next" url = "next_url" - } + }, ) } @@ -321,7 +321,7 @@ class ResourceParamsBasedDownloadWorkManagerTest { val downloadManager = ResourceParamsBasedDownloadWorkManager( emptyMap(), - NoOpResourceParamsBasedDownloadWorkManagerContext + NoOpResourceParamsBasedDownloadWorkManagerContext, ) val response = Bundle().apply { @@ -329,7 +329,7 @@ class ResourceParamsBasedDownloadWorkManagerTest { addEntry( Bundle.BundleEntryComponent().apply { resource = Patient().apply { id = "Patient-Id-001" } - } + }, ) } @@ -345,6 +345,7 @@ val NoOpResourceParamsBasedDownloadWorkManagerContext = class TestResourceParamsBasedDownloadWorkManagerContext(private val lastUpdatedTimeStamp: String?) : ResourceParamsBasedDownloadWorkManager.TimestampContext { override suspend fun saveLastUpdatedTimestamp(resourceType: ResourceType, timestamp: String?) {} + override suspend fun getLasUpdateTimestamp(resourceType: ResourceType): String? = lastUpdatedTimeStamp } diff --git a/engine/src/test/java/com/google/android/fhir/sync/upload/UploaderImplTest.kt b/engine/src/test/java/com/google/android/fhir/sync/upload/UploaderImplTest.kt index bc2da80244..f991679c6a 100644 --- a/engine/src/test/java/com/google/android/fhir/sync/upload/UploaderImplTest.kt +++ b/engine/src/test/java/com/google/android/fhir/sync/upload/UploaderImplTest.kt @@ -41,10 +41,7 @@ class UploaderImplTest { @Test fun `upload should start`() = runBlocking { - val result = - UploaderImpl(BundleDataSource { Bundle() }, SquashedChangesUploadWorkManager()) - .upload(localChanges) - .toList() + val result = Uploader(BundleDataSource { Bundle() }).upload(localChanges).toList() assertThat(result.first()).isInstanceOf(UploadState.Started::class.java) } @@ -52,9 +49,8 @@ class UploaderImplTest { @Test fun `upload should succeed if response is transaction response`() = runBlocking { val result = - UploaderImpl( + Uploader( BundleDataSource { Bundle().apply { type = Bundle.BundleType.TRANSACTIONRESPONSE } }, - SquashedChangesUploadWorkManager() ) .upload(localChanges) .toList() @@ -71,7 +67,7 @@ class UploaderImplTest { @Test fun `upload should fail if response is operation outcome with issue`() = runBlocking { val result = - UploaderImpl( + Uploader( BundleDataSource { OperationOutcome().apply { addIssue( @@ -79,11 +75,10 @@ class UploaderImplTest { severity = OperationOutcome.IssueSeverity.WARNING code = OperationOutcome.IssueType.CONFLICT diagnostics = "The resource has already been updated." - } + }, ) } }, - SquashedChangesUploadWorkManager() ) .upload(localChanges) .toList() @@ -95,9 +90,8 @@ class UploaderImplTest { @Test fun `upload should fail if response is empty operation outcome`() = runBlocking { val result = - UploaderImpl( + Uploader( BundleDataSource { OperationOutcome() }, - SquashedChangesUploadWorkManager(), ) .upload(localChanges) .toList() @@ -110,9 +104,8 @@ class UploaderImplTest { fun `upload should fail if response is neither transaction response nor operation outcome`() = runBlocking { val result = - UploaderImpl( + Uploader( BundleDataSource { Bundle().apply { type = Bundle.BundleType.SEARCHSET } }, - SquashedChangesUploadWorkManager(), ) .upload(localChanges) .toList() @@ -124,9 +117,8 @@ class UploaderImplTest { @Test fun `upload should fail if there is connection exception`() = runBlocking { val result = - UploaderImpl( + Uploader( BundleDataSource { throw ConnectException("Failed to connect to server.") }, - SquashedChangesUploadWorkManager() ) .upload(localChanges) .toList() @@ -153,14 +145,14 @@ class UploaderImplTest { HumanName().apply { addGiven("John") family = "Doe" - } + }, ) - } + }, ), - timestamp = Instant.now() + timestamp = Instant.now(), ) .toLocalChange() - .apply { LocalChangeToken(listOf(1)) } + .apply { LocalChangeToken(listOf(1)) }, ) } } diff --git a/engine/src/test/java/com/google/android/fhir/sync/upload/patch/PerResourcePatchGeneratorTest.kt b/engine/src/test/java/com/google/android/fhir/sync/upload/patch/PerResourcePatchGeneratorTest.kt index 54eccc499b..78d365ec02 100644 --- a/engine/src/test/java/com/google/android/fhir/sync/upload/patch/PerResourcePatchGeneratorTest.kt +++ b/engine/src/test/java/com/google/android/fhir/sync/upload/patch/PerResourcePatchGeneratorTest.kt @@ -23,7 +23,6 @@ import com.google.android.fhir.LocalChangeToken import com.google.android.fhir.db.impl.dao.diff import com.google.android.fhir.db.impl.entities.LocalChangeEntity import com.google.android.fhir.logicalId -import com.google.android.fhir.sync.upload.SquashedChangesUploadWorkManager import com.google.android.fhir.testing.assertJsonArrayEqualsIgnoringOrder import com.google.android.fhir.testing.jsonParser import com.google.android.fhir.testing.readFromFile @@ -147,11 +146,11 @@ class PerResourcePatchGeneratorTest { HumanName().apply { addGiven("John") family = "Doe" - } + }, ) - } + }, ), - timestamp = Instant.now() + timestamp = Instant.now(), ) .toLocalChange() .apply { LocalChangeToken(listOf(1)) }, @@ -161,10 +160,10 @@ class PerResourcePatchGeneratorTest { resourceId = "Patient-001", type = LocalChangeEntity.Type.DELETE, payload = "", - timestamp = Instant.now() + timestamp = Instant.now(), ) .toLocalChange() - .apply { LocalChangeToken(listOf(2)) } + .apply { LocalChangeToken(listOf(2)) }, ) val patchToUpload = PerResourcePatchGenerator.generate(changes) @@ -190,11 +189,11 @@ class PerResourcePatchGeneratorTest { HumanName().apply { addGiven("John") family = "Doe" - } + }, ) - } + }, ), - timestamp = Instant.now() + timestamp = Instant.now(), ) .toLocalChange() .apply { LocalChangeToken(listOf(1)) }, @@ -212,7 +211,7 @@ class PerResourcePatchGeneratorTest { HumanName().apply { addGiven("Jane") family = "Doe" - } + }, ) }, Patient().apply { @@ -221,12 +220,12 @@ class PerResourcePatchGeneratorTest { HumanName().apply { addGiven("Janet") family = "Doe" - } + }, ) - } + }, ) .toString(), - timestamp = Instant.now() + timestamp = Instant.now(), ) .toLocalChange() .apply { LocalChangeToken(listOf(1)) }, @@ -236,12 +235,12 @@ class PerResourcePatchGeneratorTest { resourceId = "Patient-001", type = LocalChangeEntity.Type.DELETE, payload = "", - timestamp = Instant.now() + timestamp = Instant.now(), ) .toLocalChange() .apply { LocalChangeToken(listOf(3)) }, ) - val patchToUpload = SquashedChangesUploadWorkManager().generatePatches(changes) + val patchToUpload = PerResourcePatchGenerator.generate(changes) assertThat(patchToUpload).isEmpty() } @@ -293,7 +292,7 @@ class PerResourcePatchGeneratorTest { val patches = PerResourcePatchGenerator.generate( - listOf(updateLocalChange1, updateLocalChange2, deleteLocalChange) + listOf(updateLocalChange1, updateLocalChange2, deleteLocalChange), ) with(patches.single()) { @@ -315,7 +314,7 @@ class PerResourcePatchGeneratorTest { resourceId = "Patient-001", type = LocalChangeEntity.Type.DELETE, payload = "", - timestamp = Instant.now() + timestamp = Instant.now(), ) .toLocalChange() .apply { LocalChangeToken(listOf(2)) }, @@ -325,7 +324,7 @@ class PerResourcePatchGeneratorTest { resourceId = "Patient-001", type = LocalChangeEntity.Type.UPDATE, payload = "", - timestamp = Instant.now() + timestamp = Instant.now(), ) .toLocalChange() .apply { LocalChangeToken(listOf(3)) }, @@ -350,7 +349,7 @@ class PerResourcePatchGeneratorTest { resourceId = "Patient-001", type = LocalChangeEntity.Type.UPDATE, payload = "", - timestamp = Instant.now() + timestamp = Instant.now(), ) .toLocalChange() .apply { LocalChangeToken(listOf(1)) }, @@ -369,11 +368,11 @@ class PerResourcePatchGeneratorTest { HumanName().apply { addGiven("John") family = "Doe" - } + }, ) - } + }, ), - timestamp = Instant.now() + timestamp = Instant.now(), ) .toLocalChange() .apply { LocalChangeToken(listOf(2)) }, @@ -391,7 +390,7 @@ class PerResourcePatchGeneratorTest { private fun createUpdateLocalChange( oldEntity: Resource, updatedResource: Resource, - currentChangeId: Long + currentChangeId: Long, ): LocalChange { val jsonDiff = diff(jsonParser, oldEntity, updatedResource) return LocalChange( @@ -401,7 +400,7 @@ class PerResourcePatchGeneratorTest { payload = jsonDiff.toString(), versionId = oldEntity.versionId, token = LocalChangeToken(listOf(currentChangeId + 1)), - timestamp = Instant.now() + timestamp = Instant.now(), ) } @@ -413,7 +412,7 @@ class PerResourcePatchGeneratorTest { payload = jsonParser.encodeResourceToString(entity), versionId = entity.versionId, token = LocalChangeToken(listOf(1L)), - timestamp = Instant.now() + timestamp = Instant.now(), ) } @@ -425,7 +424,7 @@ class PerResourcePatchGeneratorTest { payload = "", versionId = entity.versionId, token = LocalChangeToken(listOf(currentChangeId + 1)), - timestamp = Instant.now() + timestamp = Instant.now(), ) } } diff --git a/engine/src/test/java/com/google/android/fhir/sync/upload/request/TransactionBundleGeneratorTest.kt b/engine/src/test/java/com/google/android/fhir/sync/upload/request/TransactionBundleGeneratorTest.kt index 56e41e72f9..d21e30a4c6 100644 --- a/engine/src/test/java/com/google/android/fhir/sync/upload/request/TransactionBundleGeneratorTest.kt +++ b/engine/src/test/java/com/google/android/fhir/sync/upload/request/TransactionBundleGeneratorTest.kt @@ -61,11 +61,11 @@ class TransactionBundleGeneratorTest { HumanName().apply { addGiven("John") family = "Doe" - } + }, ) - } + }, ), - timestamp = Instant.now() + timestamp = Instant.now(), ), Patch( resourceType = ResourceType.Patient.name, @@ -80,7 +80,7 @@ class TransactionBundleGeneratorTest { HumanName().apply { addGiven("Jane") family = "Doe" - } + }, ) }, Patient().apply { @@ -89,12 +89,12 @@ class TransactionBundleGeneratorTest { HumanName().apply { addGiven("Janet") family = "Doe" - } + }, ) - } + }, ) .toString(), - timestamp = Instant.now() + timestamp = Instant.now(), ), Patch( resourceType = ResourceType.Patient.name, @@ -108,11 +108,11 @@ class TransactionBundleGeneratorTest { HumanName().apply { addGiven("John") family = "Roe" - } + }, ) - } + }, ), - timestamp = Instant.now() + timestamp = Instant.now(), ), ) val generator = TransactionBundleGenerator.Factory.getDefault() @@ -145,11 +145,11 @@ class TransactionBundleGeneratorTest { HumanName().apply { addGiven("John") family = "Doe" - } + }, ) - } + }, ), - timestamp = Instant.now() + timestamp = Instant.now(), ), Patch( resourceType = ResourceType.Patient.name, @@ -164,7 +164,7 @@ class TransactionBundleGeneratorTest { HumanName().apply { addGiven("Jane") family = "Doe" - } + }, ) }, Patient().apply { @@ -173,13 +173,13 @@ class TransactionBundleGeneratorTest { HumanName().apply { addGiven("Janet") family = "Doe" - } + }, ) - } + }, ) .toString(), versionId = "v-p002-01", - timestamp = Instant.now() + timestamp = Instant.now(), ), Patch( resourceType = ResourceType.Patient.name, @@ -193,12 +193,12 @@ class TransactionBundleGeneratorTest { HumanName().apply { addGiven("John") family = "Roe" - } + }, ) - } + }, ), versionId = "v-p003-01", - timestamp = Instant.now() + timestamp = Instant.now(), ), ) val generator = @@ -206,7 +206,7 @@ class TransactionBundleGeneratorTest { Bundle.HTTPVerb.PUT, Bundle.HTTPVerb.PATCH, 1, - true + true, ) val result = generator.generateUploadRequests(patches) @@ -235,8 +235,8 @@ class TransactionBundleGeneratorTest { type = Patch.Type.UPDATE, payload = "[]", versionId = "patient-002-version-1", - timestamp = Instant.now() - ) + timestamp = Instant.now(), + ), ) val generator = TransactionBundleGenerator.Factory.getDefault(useETagForUpload = false) val result = generator.generateUploadRequests(patches) @@ -255,8 +255,8 @@ class TransactionBundleGeneratorTest { type = Patch.Type.UPDATE, payload = "[]", versionId = "patient-002-version-1", - timestamp = Instant.now() - ) + timestamp = Instant.now(), + ), ) val generator = TransactionBundleGenerator.Factory.getDefault(useETagForUpload = true) val result = generator.generateUploadRequests(patches) @@ -276,7 +276,7 @@ class TransactionBundleGeneratorTest { type = Patch.Type.UPDATE, payload = "[]", versionId = "", - timestamp = Instant.now() + timestamp = Instant.now(), ), Patch( resourceType = ResourceType.Patient.name, @@ -284,7 +284,7 @@ class TransactionBundleGeneratorTest { type = Patch.Type.UPDATE, payload = "[]", versionId = null, - timestamp = Instant.now() + timestamp = Instant.now(), ), ) val generator = TransactionBundleGenerator.Factory.getDefault(useETagForUpload = true) From 0bbdeb92ebcdf46507ca98a84f68737e63e2ff38 Mon Sep 17 00:00:00 2001 From: Omar Ismail <44980219+omarismail94@users.noreply.github.com> Date: Thu, 14 Sep 2023 15:30:43 +0100 Subject: [PATCH 5/7] Add ResourceConsolidator (#2137) * Add ResourceConsolidator * Add ResourceConsolidator only with no upload mode * only keep one consolidator * spotless * add kdocs * update docos --- .../android/fhir/impl/FhirEngineImpl.kt | 92 +------------ .../fhir/sync/upload/ResourceConsolidator.kt | 122 ++++++++++++++++++ 2 files changed, 129 insertions(+), 85 deletions(-) create mode 100644 engine/src/main/java/com/google/android/fhir/sync/upload/ResourceConsolidator.kt 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 3d90ed3114..4c93efec9f 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 @@ -29,12 +29,11 @@ import com.google.android.fhir.search.count import com.google.android.fhir.search.execute import com.google.android.fhir.sync.ConflictResolver import com.google.android.fhir.sync.Resolved +import com.google.android.fhir.sync.upload.DefaultResourceConsolidator import java.time.OffsetDateTime import kotlinx.coroutines.flow.Flow -import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType -import timber.log.Timber /** Implementation of [FhirEngine]. */ internal class FhirEngineImpl(private val database: Database, private val context: Context) : @@ -81,7 +80,7 @@ internal class FhirEngineImpl(private val database: Database, private val contex override suspend fun syncDownload( conflictResolver: ConflictResolver, - download: suspend () -> Flow> + download: suspend () -> Flow>, ) { download().collect { resources -> database.withTransaction { @@ -89,7 +88,7 @@ internal class FhirEngineImpl(private val database: Database, private val contex resolveConflictingResources( resources, getConflictingResourceIds(resources), - conflictResolver + conflictResolver, ) database.insertSyncedResources(resources) saveResolvedResourcesToDatabase(resolved) @@ -107,7 +106,7 @@ internal class FhirEngineImpl(private val database: Database, private val contex private suspend fun resolveConflictingResources( resources: List, conflictingResourceIds: Set, - conflictResolver: ConflictResolver + conflictResolver: ConflictResolver, ) = resources .filter { conflictingResourceIds.contains(it.logicalId) } @@ -123,89 +122,12 @@ internal class FhirEngineImpl(private val database: Database, private val contex .intersect(database.getAllLocalChanges().map { it.resourceId }.toSet()) override suspend fun syncUpload( - upload: suspend (List) -> Flow> + upload: suspend (List) -> Flow>, ) { + val resourceConsolidator = DefaultResourceConsolidator(database) val localChanges = database.getAllLocalChanges() if (localChanges.isNotEmpty()) { - upload(localChanges).collect { - database.deleteUpdates(it.first) - when (it.second) { - is Bundle -> updateVersionIdAndLastUpdated(it.second as Bundle) - else -> updateVersionIdAndLastUpdated(it.second) - } - } + upload(localChanges).collect { resourceConsolidator.consolidate(it.first, it.second) } } } - - private suspend fun updateVersionIdAndLastUpdated(bundle: Bundle) { - when (bundle.type) { - Bundle.BundleType.TRANSACTIONRESPONSE -> { - bundle.entry.forEach { - when { - it.hasResource() -> updateVersionIdAndLastUpdated(it.resource) - it.hasResponse() -> updateVersionIdAndLastUpdated(it.response) - } - } - } - else -> { - // Leave it for now. - Timber.i("Received request to update meta values for ${bundle.type}") - } - } - } - - private suspend fun updateVersionIdAndLastUpdated(response: Bundle.BundleEntryResponseComponent) { - if (response.hasEtag() && response.hasLastModified() && response.hasLocation()) { - response.resourceIdAndType?.let { (id, type) -> - database.updateVersionIdAndLastUpdated( - id, - type, - getVersionFromETag(response.etag), - response.lastModified.toInstant() - ) - } - } - } - - private suspend fun updateVersionIdAndLastUpdated(resource: Resource) { - if (resource.hasMeta() && resource.meta.hasVersionId() && resource.meta.hasLastUpdated()) { - database.updateVersionIdAndLastUpdated( - resource.id, - resource.resourceType, - resource.meta.versionId, - resource.meta.lastUpdated.toInstant() - ) - } - } - - /** - * FHIR uses weak ETag that look something like W/"MTY4NDMyODE2OTg3NDUyNTAwMA", so we need to - * extract version from it. See https://hl7.org/fhir/http.html#Http-Headers. - */ - private fun getVersionFromETag(eTag: String) = - // The server should always return a weak etag that starts with W, but if it server returns a - // strong tag, we store it as-is. The http-headers for conditional upload like if-match will - // always add value as a weak tag. - if (eTag.startsWith("W/")) { - eTag.split("\"")[1] - } else { - eTag - } - - /** - * May return a Pair of versionId and resource type extracted from the - * [Bundle.BundleEntryResponseComponent.location]. - * - * [Bundle.BundleEntryResponseComponent.location] may be: - * - * 1. absolute path: `///_history/` - * - * 2. relative path: `//_history/` - */ - private val Bundle.BundleEntryResponseComponent.resourceIdAndType: Pair? - get() = - location - ?.split("/") - ?.takeIf { it.size > 3 } - ?.let { it[it.size - 3] to ResourceType.fromCode(it[it.size - 4]) } } diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/ResourceConsolidator.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/ResourceConsolidator.kt new file mode 100644 index 0000000000..d8f19fc5a2 --- /dev/null +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/ResourceConsolidator.kt @@ -0,0 +1,122 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.sync.upload + +import com.google.android.fhir.LocalChangeToken +import com.google.android.fhir.db.Database +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.model.ResourceType +import timber.log.Timber + +/** + * Represents a mechanism to consolidate resources after they are uploaded. + * + * INTERNAL ONLY. This interface should NEVER been exposed as an external API because it works + * together with other components in the upload package to fulfill a specific upload strategy. After + * a resource is uploaded to a remote FHIR server and a response is returned, we need to consolidate + * any changes in the database, Examples of this would be, updating the lastUpdated timestamp field, + * or deleting the local change from the database, or updating the resource IDs and payloads to + * correspond with the server’s feedback. + */ +internal fun interface ResourceConsolidator { + + /** Consolidates the local change token with the provided response from the FHIR server. */ + suspend fun consolidate(localChangeToken: LocalChangeToken, response: Resource) +} + +/** Default implementation of [ResourceConsolidator] that uses the database to aid consolidation. */ +internal class DefaultResourceConsolidator(private val database: Database) : ResourceConsolidator { + + override suspend fun consolidate(localChangeToken: LocalChangeToken, response: Resource) { + database.deleteUpdates(localChangeToken) + when (response) { + is Bundle -> updateVersionIdAndLastUpdated(response) + else -> updateVersionIdAndLastUpdated(response) + } + } + + private suspend fun updateVersionIdAndLastUpdated(bundle: Bundle) { + when (bundle.type) { + Bundle.BundleType.TRANSACTIONRESPONSE -> { + bundle.entry.forEach { + when { + it.hasResource() -> updateVersionIdAndLastUpdated(it.resource) + it.hasResponse() -> updateVersionIdAndLastUpdated(it.response) + } + } + } + else -> { + // Leave it for now. + Timber.i("Received request to update meta values for ${bundle.type}") + } + } + } + + private suspend fun updateVersionIdAndLastUpdated(response: Bundle.BundleEntryResponseComponent) { + if (response.hasEtag() && response.hasLastModified() && response.hasLocation()) { + response.resourceIdAndType?.let { (id, type) -> + database.updateVersionIdAndLastUpdated( + id, + type, + getVersionFromETag(response.etag), + response.lastModified.toInstant(), + ) + } + } + } + + private suspend fun updateVersionIdAndLastUpdated(resource: Resource) { + if (resource.hasMeta() && resource.meta.hasVersionId() && resource.meta.hasLastUpdated()) { + database.updateVersionIdAndLastUpdated( + resource.id, + resource.resourceType, + resource.meta.versionId, + resource.meta.lastUpdated.toInstant(), + ) + } + } + + /** + * FHIR uses weak ETag that look something like W/"MTY4NDMyODE2OTg3NDUyNTAwMA", so we need to + * extract version from it. See https://hl7.org/fhir/http.html#Http-Headers. + */ + private fun getVersionFromETag(eTag: String) = + // The server should always return a weak etag that starts with W, but if it server returns a + // strong tag, we store it as-is. The http-headers for conditional upload like if-match will + // always add value as a weak tag. + if (eTag.startsWith("W/")) { + eTag.split("\"")[1] + } else { + eTag + } + + /** + * May return a Pair of versionId and resource type extracted from the + * [Bundle.BundleEntryResponseComponent.location]. + * + * [Bundle.BundleEntryResponseComponent.location] may be: + * 1. absolute path: `///_history/` + * 2. relative path: `//_history/` + */ + private val Bundle.BundleEntryResponseComponent.resourceIdAndType: Pair? + get() = + location + ?.split("/") + ?.takeIf { it.size > 3 } + ?.let { it[it.size - 3] to ResourceType.fromCode(it[it.size - 4]) } +} From d639b63aa016a0df60d826ed38287716cd0ee3d0 Mon Sep 17 00:00:00 2001 From: Omar Ismail <44980219+omarismail94@users.noreply.github.com> Date: Thu, 14 Sep 2023 17:24:24 +0100 Subject: [PATCH 6/7] Add a LocalChangeFetcher (#2135) * DRAFT: add a localchangeselector * just have one class * move iterations to syncUpload * address comments * add tests for allchangeslocalchangefetcher * add progress state * spotless * add count api to db and refactor mode init * try change * spotless * Update engine/src/main/java/com/google/android/fhir/sync/upload/LocalChangeFetcher.kt Co-authored-by: Jing Tang --------- Co-authored-by: Jing Tang --- .../android/fhir/db/impl/DatabaseImplTest.kt | 574 +++++++++--------- .../com/google/android/fhir/FhirEngine.kt | 26 +- .../com/google/android/fhir/db/Database.kt | 17 +- .../android/fhir/db/impl/DatabaseImpl.kt | 31 +- .../fhir/db/impl/dao/LocalChangeDao.kt | 51 +- .../android/fhir/impl/FhirEngineImpl.kt | 11 +- .../android/fhir/sync/FhirSynchronizer.kt | 4 +- .../fhir/sync/upload/LocalChangeFetcher.kt | 93 +++ .../google/android/fhir/testing/Utilities.kt | 17 +- .../android/fhir/impl/FhirEngineImplTest.kt | 59 +- .../AllChangesLocalChangeFetcherTest.kt | 97 +++ 11 files changed, 620 insertions(+), 360 deletions(-) create mode 100644 engine/src/main/java/com/google/android/fhir/sync/upload/LocalChangeFetcher.kt create mode 100644 engine/src/test/java/com/google/android/fhir/sync/upload/AllChangesLocalChangeFetcherTest.kt 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 a5338bef1d..2e6055fd82 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 @@ -39,6 +39,7 @@ import com.google.android.fhir.search.getQuery import com.google.android.fhir.search.has import com.google.android.fhir.search.include import com.google.android.fhir.search.revInclude +import com.google.android.fhir.sync.upload.LocalChangesFetchMode import com.google.android.fhir.testing.assertJsonArrayEqualsIgnoringOrder import com.google.android.fhir.testing.assertResourceEquals import com.google.android.fhir.testing.readFromFile @@ -90,9 +91,9 @@ import org.junit.runners.Parameterized.Parameters * Integration tests for [DatabaseImpl]. There are written as integration tests as officially * recommend because: * * Different versions of android are shipped with different versions of SQLite. Integration tests - * allow for better coverage on them. + * allow for better coverage on them. * * Robolectric's SQLite implementation does not match Android, e.g.: - * https://github.com/robolectric/robolectric/blob/master/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSQLiteConnection.java#L97 + * https://github.com/robolectric/robolectric/blob/master/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSQLiteConnection.java#L97 */ @MediumTest @RunWith(Parameterized::class) @@ -267,7 +268,7 @@ class DatabaseImplTest { } assertThat(resourceNotFoundException.message) .isEqualTo( - "Resource not found with type ${TEST_PATIENT_1.resourceType.name} and id $TEST_PATIENT_1_ID!" + "Resource not found with type ${TEST_PATIENT_1.resourceType.name} and id $TEST_PATIENT_1_ID!", ) assertThat(database.getLocalChanges(ResourceType.Patient, TEST_PATIENT_1_ID)).isEmpty() } @@ -280,7 +281,7 @@ class DatabaseImplTest { } assertThat(resourceIllegalStateException.message) .isEqualTo( - "Resource with type ${TEST_PATIENT_1.resourceType.name} and id $TEST_PATIENT_1_ID has local changes, either sync with server or FORCE_PURGE required" + "Resource with type ${TEST_PATIENT_1.resourceType.name} and id $TEST_PATIENT_1_ID has local changes, either sync with server or FORCE_PURGE required", ) } @@ -326,7 +327,7 @@ class DatabaseImplTest { } assertThat(resourceNotFoundException.message) .isEqualTo( - "Resource not found with type ${TEST_PATIENT_1.resourceType.name} and id $TEST_PATIENT_2_ID!" + "Resource not found with type ${TEST_PATIENT_1.resourceType.name} and id $TEST_PATIENT_2_ID!", ) } @@ -336,12 +337,10 @@ class DatabaseImplTest { assertThrows(ResourceNotFoundException::class.java) { runBlocking { database.update(TEST_PATIENT_2) } } - /* ktlint-disable max-line-length */ assertThat(resourceNotFoundException.message) .isEqualTo( - "Resource not found with type ${TEST_PATIENT_2.resourceType.name} and id $TEST_PATIENT_2_ID!" - /* ktlint-enable max-line-length */ - ) + "Resource not found with type ${TEST_PATIENT_2.resourceType.name} and id $TEST_PATIENT_2_ID!", + ) } @Test @@ -383,7 +382,7 @@ class DatabaseImplTest { HumanName().apply { family = "FamilyName" addGiven("FirstName") - } + }, ) meta = Meta().apply { @@ -401,7 +400,7 @@ class DatabaseImplTest { HumanName().apply { family = "UpdatedFamilyName" addGiven("UpdatedFirstName") - } + }, ) } database.update(updatedPatient) @@ -438,7 +437,7 @@ class DatabaseImplTest { database .getAllLocalChanges() .map { it } - .none { it.type == LocalChange.Type.DELETE && it.resourceId == "nonexistent_patient" } + .none { it.type == LocalChange.Type.DELETE && it.resourceId == "nonexistent_patient" }, ) .isTrue() } @@ -512,7 +511,9 @@ class DatabaseImplTest { lastUpdated = Date() } database.insert(patient) - services.fhirEngine.syncUpload { it -> + // Delete the patient created in setup as we only want to upload the patient in this test + database.deleteUpdates(listOf(TEST_PATIENT_1)) + services.fhirEngine.syncUpload(LocalChangesFetchMode.AllChanges) { it .first { it.resourceId == "remote-patient-3" } .let { @@ -521,7 +522,7 @@ class DatabaseImplTest { Patient().apply { id = it.resourceId meta = remoteMeta - } + }, ) } } @@ -538,7 +539,7 @@ class DatabaseImplTest { database .getAllLocalChanges() .map { it } - .none { it.resourceId in listOf(patient.logicalId, TEST_PATIENT_2_ID) } + .none { it.resourceId in listOf(patient.logicalId, TEST_PATIENT_2_ID) }, ) .isTrue() } @@ -552,14 +553,14 @@ class DatabaseImplTest { HumanName().apply { addGiven("Jane") family = "Doe" - } + }, ) } database.insert(patient) val result = database.search( - Search(ResourceType.Patient).apply { filter(Patient.GIVEN, { value = "Jane" }) }.getQuery() + Search(ResourceType.Patient).apply { filter(Patient.GIVEN, { value = "Jane" }) }.getQuery(), ) assertThat(result.size).isEqualTo(1) @@ -570,14 +571,14 @@ class DatabaseImplTest { HumanName().apply { addGiven("John") family = "Doe" - } + }, ) } database.insert(updatedPatient) val updatedResult = database.search( - Search(ResourceType.Patient).apply { filter(Patient.GIVEN, { value = "Jane" }) }.getQuery() + Search(ResourceType.Patient).apply { filter(Patient.GIVEN, { value = "Jane" }) }.getQuery(), ) assertThat(updatedResult.size).isEqualTo(0) } @@ -591,14 +592,14 @@ class DatabaseImplTest { HumanName().apply { addGiven("Jane") family = "Doe" - } + }, ) } database.insertRemote(patient) val result = database.search( - Search(ResourceType.Patient).apply { filter(Patient.GIVEN, { value = "Jane" }) }.getQuery() + Search(ResourceType.Patient).apply { filter(Patient.GIVEN, { value = "Jane" }) }.getQuery(), ) assertThat(result.size).isEqualTo(1) @@ -609,14 +610,14 @@ class DatabaseImplTest { HumanName().apply { addGiven("John") family = "Doe" - } + }, ) } database.insertRemote(updatedPatient) val updatedResult = database.search( - Search(ResourceType.Patient).apply { filter(Patient.GIVEN, { value = "Jane" }) }.getQuery() + Search(ResourceType.Patient).apply { filter(Patient.GIVEN, { value = "Jane" }) }.getQuery(), ) assertThat(updatedResult.size).isEqualTo(0) } @@ -648,14 +649,14 @@ class DatabaseImplTest { HumanName().apply { addGiven("Jane") family = "Doe" - } + }, ) } database.insertRemote(patient) val result = database.search( - Search(ResourceType.Patient).apply { filter(Patient.GIVEN, { value = "Jane" }) }.getQuery() + Search(ResourceType.Patient).apply { filter(Patient.GIVEN, { value = "Jane" }) }.getQuery(), ) assertThat(result.size).isEqualTo(1) @@ -666,14 +667,14 @@ class DatabaseImplTest { HumanName().apply { addGiven("John") family = "Doe" - } + }, ) } database.update(updatedPatient) val updatedResult = database.search( - Search(ResourceType.Patient).apply { filter(Patient.GIVEN, { value = "Jane" }) }.getQuery() + Search(ResourceType.Patient).apply { filter(Patient.GIVEN, { value = "Jane" }) }.getQuery(), ) assertThat(updatedResult.size).isEqualTo(0) } @@ -694,20 +695,37 @@ class DatabaseImplTest { } } + @Test + fun getLocalChangesCount_noLocalChange_returnsZero() = runBlocking { + database.deleteUpdates(listOf(TEST_PATIENT_1)) + assertThat(database.getLocalChangesCount()).isEqualTo(0) + } + + @Test + fun getLocalChangesCount_oneLocalChange_returnsOne() = runBlocking { + assertThat(database.getLocalChangesCount()).isEqualTo(1) + } + + @Test + fun getLocalChangesCount_twoLocalChange_returnsTwo() = runBlocking { + database.insert(TEST_PATIENT_2) + assertThat(database.getLocalChangesCount()).isEqualTo(2) + } + @Test fun search_sortDescending_twoVeryCloseFloatingPointNumbers_orderedCorrectly() = runBlocking { val smallerId = "risk_assessment_1" val largerId = "risk_assessment_2" database.insert( riskAssessment(id = smallerId, probability = BigDecimal("0.3")), - riskAssessment(id = largerId, probability = BigDecimal("0.30000000001")) + riskAssessment(id = largerId, probability = BigDecimal("0.30000000001")), ) val results = database.search( Search(ResourceType.RiskAssessment) .apply { sort(RiskAssessment.PROBABILITY, Order.DESCENDING) } - .getQuery() + .getQuery(), ) val ids = results.map { it.id } @@ -723,7 +741,7 @@ class DatabaseImplTest { listOf( RiskAssessment.RiskAssessmentPredictionComponent().apply { setProbability(DecimalType(probability)) - } + }, ) } @@ -737,7 +755,7 @@ class DatabaseImplTest { database.insert(patient) val result = database.search( - Search(ResourceType.Patient).apply { filter(Patient.GIVEN, { value = "eve" }) }.getQuery() + Search(ResourceType.Patient).apply { filter(Patient.GIVEN, { value = "eve" }) }.getQuery(), ) assertThat(result.single().id).isEqualTo("Patient/${patient.id}") @@ -753,7 +771,7 @@ class DatabaseImplTest { database.insert(patient) val result = database.search( - Search(ResourceType.Patient).apply { filter(Patient.GIVEN, { value = "eve" }) }.getQuery() + Search(ResourceType.Patient).apply { filter(Patient.GIVEN, { value = "eve" }) }.getQuery(), ) assertThat(result).isEmpty() @@ -776,10 +794,10 @@ class DatabaseImplTest { { value = "Eve" modifier = StringFilterModifier.MATCHES_EXACTLY - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result.single().id).isEqualTo("Patient/${patient.id}") @@ -802,10 +820,10 @@ class DatabaseImplTest { { value = "Eve" modifier = StringFilterModifier.MATCHES_EXACTLY - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result).isEmpty() @@ -829,10 +847,10 @@ class DatabaseImplTest { { value = "Eve" modifier = StringFilterModifier.CONTAINS - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result.single().id).isEqualTo("Patient/${patient.id}") @@ -855,10 +873,10 @@ class DatabaseImplTest { { value = "eve" modifier = StringFilterModifier.CONTAINS - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result).isEmpty() @@ -870,7 +888,7 @@ class DatabaseImplTest { RiskAssessment().apply { id = "1" addPrediction( - RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(99.5)) + RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(99.5)), ) } @@ -884,10 +902,10 @@ class DatabaseImplTest { { prefix = ParamPrefixEnum.EQUAL value = BigDecimal("100") - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result.single().id).isEqualTo("RiskAssessment/${riskAssessment.id}") @@ -899,7 +917,7 @@ class DatabaseImplTest { RiskAssessment().apply { id = "1" addPrediction( - RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(100.5)) + RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(100.5)), ) } @@ -913,10 +931,10 @@ class DatabaseImplTest { { prefix = ParamPrefixEnum.EQUAL value = BigDecimal("100") - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result).isEmpty() @@ -928,7 +946,7 @@ class DatabaseImplTest { RiskAssessment().apply { id = "1" addPrediction( - RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(99.0)) + RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(99.0)), ) } @@ -942,10 +960,10 @@ class DatabaseImplTest { { prefix = ParamPrefixEnum.NOT_EQUAL value = BigDecimal("100") - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result.single().id).isEqualTo("RiskAssessment/${riskAssessment.id}") } @@ -956,7 +974,7 @@ class DatabaseImplTest { RiskAssessment().apply { id = "1" addPrediction( - RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(99.5)) + RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(99.5)), ) } @@ -970,10 +988,10 @@ class DatabaseImplTest { { prefix = ParamPrefixEnum.NOT_EQUAL value = BigDecimal("100") - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result).isEmpty() @@ -985,7 +1003,7 @@ class DatabaseImplTest { RiskAssessment().apply { id = "1" addPrediction( - RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(100)) + RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(100)), ) } @@ -999,10 +1017,10 @@ class DatabaseImplTest { { prefix = ParamPrefixEnum.GREATERTHAN value = BigDecimal("99.5") - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result.single().id).isEqualTo("RiskAssessment/${riskAssessment.id}") @@ -1014,7 +1032,7 @@ class DatabaseImplTest { RiskAssessment().apply { id = "1" addPrediction( - RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(99.5)) + RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(99.5)), ) } @@ -1028,10 +1046,10 @@ class DatabaseImplTest { { prefix = ParamPrefixEnum.GREATERTHAN value = BigDecimal("99.5") - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result).isEmpty() @@ -1043,7 +1061,7 @@ class DatabaseImplTest { RiskAssessment().apply { id = "1" addPrediction( - RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(99.5)) + RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(99.5)), ) } @@ -1057,10 +1075,10 @@ class DatabaseImplTest { { prefix = ParamPrefixEnum.GREATERTHAN_OR_EQUALS value = BigDecimal("99.5") - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result.single().id).isEqualTo("RiskAssessment/${riskAssessment.id}") @@ -1072,7 +1090,7 @@ class DatabaseImplTest { RiskAssessment().apply { id = "1" addPrediction( - RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(99.0)) + RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(99.0)), ) } @@ -1086,10 +1104,10 @@ class DatabaseImplTest { { prefix = ParamPrefixEnum.GREATERTHAN_OR_EQUALS value = BigDecimal("99.5") - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result).isEmpty() @@ -1101,7 +1119,7 @@ class DatabaseImplTest { RiskAssessment().apply { id = "1" addPrediction( - RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(99.0)) + RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(99.0)), ) } @@ -1115,10 +1133,10 @@ class DatabaseImplTest { { prefix = ParamPrefixEnum.LESSTHAN value = BigDecimal("99.5") - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result.single().id).isEqualTo("RiskAssessment/${riskAssessment.id}") @@ -1130,7 +1148,7 @@ class DatabaseImplTest { RiskAssessment().apply { id = "1" addPrediction( - RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(99.5)) + RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(99.5)), ) } @@ -1144,10 +1162,10 @@ class DatabaseImplTest { { prefix = ParamPrefixEnum.LESSTHAN value = BigDecimal("99.5") - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result).isEmpty() @@ -1159,7 +1177,7 @@ class DatabaseImplTest { RiskAssessment().apply { id = "1" addPrediction( - RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(99.5)) + RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(99.5)), ) } @@ -1173,10 +1191,10 @@ class DatabaseImplTest { { prefix = ParamPrefixEnum.LESSTHAN_OR_EQUALS value = BigDecimal("99.5") - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result.single().id).isEqualTo("RiskAssessment/${riskAssessment.id}") } @@ -1187,7 +1205,7 @@ class DatabaseImplTest { RiskAssessment().apply { id = "1" addPrediction( - RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(100)) + RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(100)), ) } @@ -1201,10 +1219,10 @@ class DatabaseImplTest { { prefix = ParamPrefixEnum.LESSTHAN_OR_EQUALS value = BigDecimal("99.5") - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result).isEmpty() @@ -1216,7 +1234,7 @@ class DatabaseImplTest { RiskAssessment().apply { id = "1" addPrediction( - RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(99.0)) + RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(99.0)), ) } @@ -1230,10 +1248,10 @@ class DatabaseImplTest { { prefix = ParamPrefixEnum.ENDS_BEFORE value = BigDecimal("99.5") - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result.single().id).isEqualTo("RiskAssessment/${riskAssessment.id}") @@ -1245,7 +1263,7 @@ class DatabaseImplTest { RiskAssessment().apply { id = "1" addPrediction( - RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(99.5)) + RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(99.5)), ) } @@ -1259,10 +1277,10 @@ class DatabaseImplTest { { prefix = ParamPrefixEnum.ENDS_BEFORE value = BigDecimal("99.5") - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result).isEmpty() @@ -1274,7 +1292,7 @@ class DatabaseImplTest { RiskAssessment().apply { id = "1" addPrediction( - RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(100)) + RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(100)), ) } @@ -1288,10 +1306,10 @@ class DatabaseImplTest { { prefix = ParamPrefixEnum.STARTS_AFTER value = BigDecimal("99.5") - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result.single().id).isEqualTo("RiskAssessment/${riskAssessment.id}") @@ -1303,7 +1321,7 @@ class DatabaseImplTest { RiskAssessment().apply { id = "1" addPrediction( - RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(99.5)) + RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(99.5)), ) } @@ -1317,10 +1335,10 @@ class DatabaseImplTest { { prefix = ParamPrefixEnum.STARTS_AFTER value = BigDecimal("99.5") - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result).isEmpty() @@ -1332,7 +1350,7 @@ class DatabaseImplTest { RiskAssessment().apply { id = "1" addPrediction( - RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(93)) + RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(93)), ) } @@ -1346,10 +1364,10 @@ class DatabaseImplTest { { prefix = ParamPrefixEnum.APPROXIMATE value = BigDecimal("100") - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result.single().id).isEqualTo("RiskAssessment/${riskAssessment.id}") @@ -1361,7 +1379,7 @@ class DatabaseImplTest { RiskAssessment().apply { id = "1" addPrediction( - RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(120)) + RiskAssessment.RiskAssessmentPredictionComponent().setProbability(DecimalType(120)), ) } @@ -1375,10 +1393,10 @@ class DatabaseImplTest { { prefix = ParamPrefixEnum.APPROXIMATE value = BigDecimal("100") - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result).isEmpty() @@ -1402,10 +1420,10 @@ class DatabaseImplTest { { value = of(DateTimeType("2013-03-14")) prefix = ParamPrefixEnum.APPROXIMATE - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result.single().id).isEqualTo("Patient/1") } @@ -1428,10 +1446,10 @@ class DatabaseImplTest { { value = of(DateTimeType("2020-03-14")) prefix = ParamPrefixEnum.APPROXIMATE - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result).isEmpty() } @@ -1454,10 +1472,10 @@ class DatabaseImplTest { { value = of(DateType("2013-03-14")) prefix = ParamPrefixEnum.APPROXIMATE - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result.single().id).isEqualTo("Patient/1") } @@ -1480,10 +1498,10 @@ class DatabaseImplTest { { value = of(DateType("2020-03-14")) prefix = ParamPrefixEnum.APPROXIMATE - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result).isEmpty() } @@ -1505,10 +1523,10 @@ class DatabaseImplTest { { value = of(DateTimeType("2013-03-14")) prefix = ParamPrefixEnum.STARTS_AFTER - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result.single().id).isEqualTo("Patient/1") } @@ -1530,10 +1548,10 @@ class DatabaseImplTest { { value = of(DateTimeType("2013-03-14")) prefix = ParamPrefixEnum.STARTS_AFTER - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result).isEmpty() } @@ -1555,10 +1573,10 @@ class DatabaseImplTest { { value = of(DateTimeType("2013-03-14")) prefix = ParamPrefixEnum.ENDS_BEFORE - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result.single().id).isEqualTo("Patient/1") } @@ -1580,10 +1598,10 @@ class DatabaseImplTest { { value = of(DateTimeType("2013-03-14")) prefix = ParamPrefixEnum.ENDS_BEFORE - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result).isEmpty() } @@ -1605,10 +1623,10 @@ class DatabaseImplTest { { value = of(DateTimeType("2013-03-14")) prefix = ParamPrefixEnum.NOT_EQUAL - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result.single().id).isEqualTo("Patient/1") } @@ -1630,10 +1648,10 @@ class DatabaseImplTest { { value = of(DateTimeType("2013-03-14")) prefix = ParamPrefixEnum.NOT_EQUAL - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result).isEmpty() } @@ -1655,10 +1673,10 @@ class DatabaseImplTest { { value = of(DateTimeType("2013-03-14")) prefix = ParamPrefixEnum.EQUAL - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result.single().id).isEqualTo("Patient/1") } @@ -1680,10 +1698,10 @@ class DatabaseImplTest { { value = of(DateTimeType("2013-03-14")) prefix = ParamPrefixEnum.EQUAL - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result).isEmpty() } @@ -1705,10 +1723,10 @@ class DatabaseImplTest { { value = of(DateTimeType("2013-03-14")) prefix = ParamPrefixEnum.GREATERTHAN - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result.single().id).isEqualTo("Patient/1") } @@ -1730,10 +1748,10 @@ class DatabaseImplTest { { value = of(DateTimeType("2013-03-14")) prefix = ParamPrefixEnum.GREATERTHAN - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result).isEmpty() } @@ -1755,10 +1773,10 @@ class DatabaseImplTest { { value = of(DateTimeType("2013-03-14")) prefix = ParamPrefixEnum.GREATERTHAN_OR_EQUALS - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result.single().id).isEqualTo("Patient/1") } @@ -1780,10 +1798,10 @@ class DatabaseImplTest { { value = of(DateTimeType("2013-03-14")) prefix = ParamPrefixEnum.GREATERTHAN_OR_EQUALS - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result).isEmpty() } @@ -1805,10 +1823,10 @@ class DatabaseImplTest { { value = of(DateTimeType("2013-03-14")) prefix = ParamPrefixEnum.LESSTHAN - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result.single().id).isEqualTo("Patient/1") } @@ -1830,10 +1848,10 @@ class DatabaseImplTest { { value = of(DateTimeType("2013-03-14")) prefix = ParamPrefixEnum.LESSTHAN - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result).isEmpty() } @@ -1855,10 +1873,10 @@ class DatabaseImplTest { { value = of(DateTimeType("2013-03-14")) prefix = ParamPrefixEnum.LESSTHAN_OR_EQUALS - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result.single().id).isEqualTo("Patient/1") } @@ -1880,10 +1898,10 @@ class DatabaseImplTest { { value = of(DateTimeType("2013-03-14T00:00:00-00:00")) prefix = ParamPrefixEnum.LESSTHAN_OR_EQUALS - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result).isEmpty() } @@ -1912,10 +1930,10 @@ class DatabaseImplTest { value = BigDecimal("5.403") system = "http://unitsofmeasure.org" unit = "g" - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result.single().id).isEqualTo("Observation/1") } @@ -1944,10 +1962,10 @@ class DatabaseImplTest { value = BigDecimal("5.403") system = "http://unitsofmeasure.org" unit = "g" - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result).isEmpty() } @@ -1976,10 +1994,10 @@ class DatabaseImplTest { value = BigDecimal("5.403") system = "http://unitsofmeasure.org" unit = "g" - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result.single().id).isEqualTo("Observation/1") } @@ -2008,10 +2026,10 @@ class DatabaseImplTest { value = BigDecimal("5.403") system = "http://unitsofmeasure.org" unit = "g" - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result).isEmpty() } @@ -2040,10 +2058,10 @@ class DatabaseImplTest { value = BigDecimal("5.403") system = "http://unitsofmeasure.org" unit = "g" - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result.single().id).isEqualTo("Observation/1") } @@ -2072,10 +2090,10 @@ class DatabaseImplTest { value = BigDecimal("5.403") system = "http://unitsofmeasure.org" unit = "g" - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result).isEmpty() } @@ -2104,10 +2122,10 @@ class DatabaseImplTest { value = BigDecimal("5.403") system = "http://unitsofmeasure.org" unit = "g" - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result.single().id).isEqualTo("Observation/1") } @@ -2136,10 +2154,10 @@ class DatabaseImplTest { value = BigDecimal("5.403") system = "http://unitsofmeasure.org" unit = "g" - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result).isEmpty() } @@ -2168,10 +2186,10 @@ class DatabaseImplTest { value = BigDecimal("5.403") system = "http://unitsofmeasure.org" unit = "g" - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result.single().id).isEqualTo("Observation/1") } @@ -2200,10 +2218,10 @@ class DatabaseImplTest { value = BigDecimal("5.403") system = "http://unitsofmeasure.org" unit = "g" - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result).isEmpty() } @@ -2232,10 +2250,10 @@ class DatabaseImplTest { value = BigDecimal("5.403") system = "http://unitsofmeasure.org" unit = "g" - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result.single().id).isEqualTo("Observation/1") } @@ -2264,10 +2282,10 @@ class DatabaseImplTest { value = BigDecimal("5.403") system = "http://unitsofmeasure.org" unit = "g" - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result).isEmpty() } @@ -2296,10 +2314,10 @@ class DatabaseImplTest { value = BigDecimal("5.403") system = "http://unitsofmeasure.org" unit = "g" - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result.single().id).isEqualTo("Observation/1") } @@ -2328,10 +2346,10 @@ class DatabaseImplTest { value = BigDecimal("5.403") system = "http://unitsofmeasure.org" unit = "g" - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result).isEmpty() } @@ -2360,17 +2378,17 @@ class DatabaseImplTest { value = BigDecimal("5403") system = "http://unitsofmeasure.org" unit = "mg" - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result.single().id).isEqualTo("Observation/1") } @Test fun search_nameGivenDuplicate_deduplicatePatient() = runBlocking { - var patient: Patient = readFromFile(Patient::class.java, "/patient_name_given_duplicate.json") + val patient: Patient = readFromFile(Patient::class.java, "/patient_name_given_duplicate.json") database.insertRemote(patient) val result = database.search( @@ -2380,7 +2398,7 @@ class DatabaseImplTest { count = 100 from = 0 } - .getQuery() + .getQuery(), ) assertThat(result.filter { it.id == patient.id }).hasSize(1) } @@ -2401,8 +2419,8 @@ class DatabaseImplTest { Coding( "http://hl7.org/fhir/sid/cvx", "140", - "Influenza, seasonal, injectable, preservative free" - ) + "Influenza, seasonal, injectable, preservative free", + ), ) status = Immunization.ImmunizationStatus.COMPLETED } @@ -2420,10 +2438,10 @@ class DatabaseImplTest { Coding( "http://hl7.org/fhir/sid/cvx", "140", - "Influenza, seasonal, injectable, preservative free" - ) + "Influenza, seasonal, injectable, preservative free", + ), ) - } + }, ) // Follow Immunization.ImmunizationStatus @@ -2431,7 +2449,7 @@ class DatabaseImplTest { Immunization.STATUS, { value = of(Coding("http://hl7.org/fhir/event-status", "completed", "Body Weight")) - } + }, ) } @@ -2440,10 +2458,10 @@ class DatabaseImplTest { { modifier = StringFilterModifier.MATCHES_EXACTLY value = "IN" - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result.map { it.logicalId }).containsExactly("100").inOrder() } @@ -2464,8 +2482,8 @@ class DatabaseImplTest { category = listOf( CodeableConcept( - Coding("http://snomed.info/sct", "698360004", "Diabetes self management plan") - ) + Coding("http://snomed.info/sct", "698360004", "Diabetes self management plan"), + ), ) } database.insert(patient, TEST_PATIENT_1, carePlan) @@ -2479,13 +2497,17 @@ class DatabaseImplTest { { value = of( - Coding("http://snomed.info/sct", "698360004", "Diabetes self management plan") + Coding( + "http://snomed.info/sct", + "698360004", + "Diabetes self management plan", + ), ) - } + }, ) } } - .getQuery() + .getQuery(), ) assertThat(result.map { it.logicalId }).containsExactly("100").inOrder() } @@ -2496,14 +2518,14 @@ class DatabaseImplTest { Patient().apply { id = "older-patient" birthDateElement = DateType("2020-12-12") - } + }, ) database.insert( Patient().apply { id = "younger-patient" birthDateElement = DateType("2020-12-13") - } + }, ) assertThat( @@ -2511,9 +2533,9 @@ class DatabaseImplTest { .search( Search(ResourceType.Patient) .apply { sort(Patient.BIRTHDATE, Order.DESCENDING) } - .getQuery() + .getQuery(), ) - .map { it.id } + .map { it.id }, ) .containsExactly("Patient/younger-patient", "Patient/older-patient", "Patient/test_patient_1") } @@ -2524,14 +2546,14 @@ class DatabaseImplTest { Patient().apply { id = "older-patient" birthDateElement = DateType("2020-12-12") - } + }, ) database.insert( Patient().apply { id = "younger-patient" birthDateElement = DateType("2020-12-13") - } + }, ) assertThat( @@ -2539,9 +2561,9 @@ class DatabaseImplTest { .search( Search(ResourceType.Patient) .apply { sort(Patient.BIRTHDATE, Order.ASCENDING) } - .getQuery() + .getQuery(), ) - .map { it.id } + .map { it.id }, ) .containsExactly("Patient/test_patient_1", "Patient/older-patient", "Patient/younger-patient") } @@ -2554,7 +2576,11 @@ class DatabaseImplTest { id = "immunization-1" vaccineCode = CodeableConcept( - Coding("http://id.who.int/icd11/mms", "XM1NL1", "COVID-19 vaccine, inactivated virus") + Coding( + "http://id.who.int/icd11/mms", + "XM1NL1", + "COVID-19 vaccine, inactivated virus", + ), ) status = Immunization.ImmunizationStatus.COMPLETED }, @@ -2565,8 +2591,8 @@ class DatabaseImplTest { Coding( "http://id.who.int/icd11/mms", "XM5DF6", - "COVID-19 vaccine, live attenuated virus" - ) + "COVID-19 vaccine, live attenuated virus", + ), ) status = Immunization.ImmunizationStatus.COMPLETED }, @@ -2574,7 +2600,7 @@ class DatabaseImplTest { id = "immunization-3" vaccineCode = CodeableConcept( - Coding("http://id.who.int/icd11/mms", "XM6AT1", "COVID-19 vaccine, DNA based") + Coding("http://id.who.int/icd11/mms", "XM6AT1", "COVID-19 vaccine, DNA based"), ) status = Immunization.ImmunizationStatus.COMPLETED }, @@ -2585,11 +2611,11 @@ class DatabaseImplTest { Coding( "http://hl7.org/fhir/sid/cvx", "140", - "Influenza, seasonal, injectable, preservative free" - ) + "Influenza, seasonal, injectable, preservative free", + ), ) status = Immunization.ImmunizationStatus.COMPLETED - } + }, ) database.insert(*resources.toTypedArray()) @@ -2606,8 +2632,8 @@ class DatabaseImplTest { Coding( "http://id.who.int/icd11/mms", "XM1NL1", - "COVID-19 vaccine, inactivated virus" - ) + "COVID-19 vaccine, inactivated virus", + ), ) }, { @@ -2616,14 +2642,14 @@ class DatabaseImplTest { Coding( "http://id.who.int/icd11/mms", "XM5DF6", - "COVID-19 vaccine, inactivated virus" - ) + "COVID-19 vaccine, inactivated virus", + ), ) }, - operation = Operation.OR + operation = Operation.OR, ) } - .getQuery() + .getQuery(), ) assertThat(result.map { it.vaccineCode.codingFirstRep.code }) @@ -2639,7 +2665,11 @@ class DatabaseImplTest { id = "immunization-1" vaccineCode = CodeableConcept( - Coding("http://id.who.int/icd11/mms", "XM1NL1", "COVID-19 vaccine, inactivated virus") + Coding( + "http://id.who.int/icd11/mms", + "XM1NL1", + "COVID-19 vaccine, inactivated virus", + ), ) status = Immunization.ImmunizationStatus.COMPLETED }, @@ -2650,8 +2680,8 @@ class DatabaseImplTest { Coding( "http://id.who.int/icd11/mms", "XM5DF6", - "COVID-19 vaccine, live attenuated virus" - ) + "COVID-19 vaccine, live attenuated virus", + ), ) status = Immunization.ImmunizationStatus.COMPLETED }, @@ -2659,7 +2689,7 @@ class DatabaseImplTest { id = "immunization-3" vaccineCode = CodeableConcept( - Coding("http://id.who.int/icd11/mms", "XM6AT1", "COVID-19 vaccine, DNA based") + Coding("http://id.who.int/icd11/mms", "XM6AT1", "COVID-19 vaccine, DNA based"), ) status = Immunization.ImmunizationStatus.COMPLETED }, @@ -2670,11 +2700,11 @@ class DatabaseImplTest { Coding( "http://hl7.org/fhir/sid/cvx", "140", - "Influenza, seasonal, injectable, preservative free" - ) + "Influenza, seasonal, injectable, preservative free", + ), ) status = Immunization.ImmunizationStatus.COMPLETED - } + }, ) database.insert(*resources.toTypedArray()) @@ -2685,16 +2715,16 @@ class DatabaseImplTest { .apply { filter( Immunization.VACCINE_CODE, - { value = of(Coding("http://id.who.int/icd11/mms", "XM1NL1", "")) } + { value = of(Coding("http://id.who.int/icd11/mms", "XM1NL1", "")) }, ) filter( Immunization.VACCINE_CODE, - { value = of(Coding("http://id.who.int/icd11/mms", "XM5DF6", "")) } + { value = of(Coding("http://id.who.int/icd11/mms", "XM5DF6", "")) }, ) operation = Operation.OR } - .getQuery() + .getQuery(), ) assertThat(result.map { it.vaccineCode.codingFirstRep.code }) @@ -2712,7 +2742,7 @@ class DatabaseImplTest { HumanName().apply { addGiven("John") family = "Doe" - } + }, ) }, Patient().apply { @@ -2721,7 +2751,7 @@ class DatabaseImplTest { HumanName().apply { addGiven("Jane") family = "Doe" - } + }, ) }, Patient().apply { @@ -2730,7 +2760,7 @@ class DatabaseImplTest { HumanName().apply { addGiven("John") family = "Roe" - } + }, ) }, Patient().apply { @@ -2739,7 +2769,7 @@ class DatabaseImplTest { HumanName().apply { addGiven("Jane") family = "Roe" - } + }, ) }, Patient().apply { @@ -2748,9 +2778,9 @@ class DatabaseImplTest { HumanName().apply { addGiven("Rocky") family = "Balboa" - } + }, ) - } + }, ) database.insert(*resources.toTypedArray()) @@ -2768,7 +2798,7 @@ class DatabaseImplTest { value = "Jane" modifier = StringFilterModifier.MATCHES_EXACTLY }, - operation = Operation.OR + operation = Operation.OR, ) filter( @@ -2781,12 +2811,12 @@ class DatabaseImplTest { value = "Roe" modifier = StringFilterModifier.MATCHES_EXACTLY }, - operation = Operation.OR + operation = Operation.OR, ) operation = Operation.AND } - .getQuery() + .getQuery(), ) assertThat(result.map { it.nameFirstRep.nameAsSingleString }) @@ -2813,7 +2843,7 @@ class DatabaseImplTest { Identifier().apply { system = "https://custom-identifier-namespace" value = "OfficialIdentifier_DarcySmith_0001" - } + }, ) addName( @@ -2823,14 +2853,14 @@ class DatabaseImplTest { addGiven("Darcy") gender = Enumerations.AdministrativeGender.FEMALE birthDateElement = DateType("1970-01-01") - } + }, ) addExtension( Extension().apply { url = "http://hl7.org/fhir/StructureDefinition/patient-mothersMaidenName" setValue(StringType("Marca")) - } + }, ) } // Get rid of the default service and create one with search params @@ -2847,10 +2877,10 @@ class DatabaseImplTest { { value = "Marca" modifier = StringFilterModifier.MATCHES_EXACTLY - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result.map { it.nameFirstRep.nameAsSingleString }).contains("Darcy Smith") @@ -2864,7 +2894,7 @@ class DatabaseImplTest { Identifier().apply { system = "https://custom-identifier-namespace" value = "OfficialIdentifier_DarcySmith_0001" - } + }, ) addName( @@ -2874,14 +2904,14 @@ class DatabaseImplTest { addGiven("Darcy") gender = Enumerations.AdministrativeGender.FEMALE birthDateElement = DateType("1970-01-01") - } + }, ) addExtension( Extension().apply { url = "http://hl7.org/fhir/StructureDefinition/patient-mothersMaidenName" setValue(StringType("Marca")) - } + }, ) } val identifierPartialSearchParameter = @@ -2908,10 +2938,10 @@ class DatabaseImplTest { { value = "OfficialIdentifier_" modifier = StringFilterModifier.STARTS_WITH - } + }, ) } - .getQuery() + .getQuery(), ) assertThat(result.map { it.nameFirstRep.nameAsSingleString }).contains("Darcy Smith") @@ -2922,21 +2952,21 @@ class DatabaseImplTest { database.insert( Patient().apply { id = "patient-test-001" }, Patient().apply { id = "patient-test-002" }, - Patient().apply { id = "patient-test-003" } + Patient().apply { id = "patient-test-003" }, ) database.update( Patient().apply { id = "patient-test-002" gender = Enumerations.AdministrativeGender.FEMALE - } + }, ) val result = database.search( Search(ResourceType.Patient) .apply { sort(LOCAL_LAST_UPDATED_PARAM, Order.DESCENDING) } - .getQuery() + .getQuery(), ) assertThat(result.map { it.logicalId }) @@ -2953,7 +2983,7 @@ class DatabaseImplTest { HumanName().apply { addGiven("James") family = "Gorden" - } + }, ) addGeneralPractitioner(Reference("Practitioner/gp-01")) addGeneralPractitioner(Reference("Practitioner/gp-02")) @@ -2966,7 +2996,7 @@ class DatabaseImplTest { HumanName().apply { addGiven("James") family = "Bond" - } + }, ) addGeneralPractitioner(Reference("Practitioner/gp-02")) addGeneralPractitioner(Reference("Practitioner/gp-03")) @@ -2980,7 +3010,7 @@ class DatabaseImplTest { HumanName().apply { family = "Practitioner-01" addGiven("General-01") - } + }, ) active = true } @@ -2991,7 +3021,7 @@ class DatabaseImplTest { HumanName().apply { family = "Practitioner-02" addGiven("General-02") - } + }, ) active = false } @@ -3002,7 +3032,7 @@ class DatabaseImplTest { HumanName().apply { family = "Practitioner-03" addGiven("General-03") - } + }, ) active = true } @@ -3019,7 +3049,7 @@ class DatabaseImplTest { { value = "James" modifier = StringFilterModifier.MATCHES_EXACTLY - } + }, ) include(Patient.GENERAL_PRACTITIONER) { @@ -3034,14 +3064,14 @@ class DatabaseImplTest { SearchResult( patient01, included = mapOf(Patient.GENERAL_PRACTITIONER.paramName to listOf(gp01)), - revIncluded = null + revIncluded = null, ), SearchResult( patient02, included = mapOf(Patient.GENERAL_PRACTITIONER.paramName to listOf(gp03)), - revIncluded = null - ) - ) + revIncluded = null, + ), + ), ) } @@ -3054,7 +3084,7 @@ class DatabaseImplTest { HumanName().apply { addGiven("James") family = "Gorden" - } + }, ) addGeneralPractitioner(Reference("Practitioner/gp-01")) } @@ -3066,7 +3096,7 @@ class DatabaseImplTest { HumanName().apply { addGiven("James") family = "Bond" - } + }, ) addGeneralPractitioner(Reference("Practitioner/gp-02")) } @@ -3108,7 +3138,7 @@ class DatabaseImplTest { { value = "James" modifier = StringFilterModifier.MATCHES_EXACTLY - } + }, ) revInclude(Condition.SUBJECT) { filter(Condition.CODE, { value = of(diabetesCodeableConcept) }) @@ -3125,15 +3155,15 @@ class DatabaseImplTest { patient01, included = null, revIncluded = - mapOf((ResourceType.Condition to Condition.SUBJECT.paramName) to listOf(con1)) + mapOf((ResourceType.Condition to Condition.SUBJECT.paramName) to listOf(con1)), ), SearchResult( patient02, included = null, revIncluded = - mapOf((ResourceType.Condition to Condition.SUBJECT.paramName) to listOf(con3)) - ) - ) + mapOf((ResourceType.Condition to Condition.SUBJECT.paramName) to listOf(con3)), + ), + ), ) } @@ -3154,7 +3184,7 @@ class DatabaseImplTest { HumanName().apply { addGiven("James") family = "Gorden" - } + }, ) addGeneralPractitioner(Reference("Practitioner/gp-01")) addGeneralPractitioner(Reference("Practitioner/gp-02")) @@ -3167,7 +3197,7 @@ class DatabaseImplTest { HumanName().apply { addGiven("James") family = "Bond" - } + }, ) addGeneralPractitioner(Reference("Practitioner/gp-01")) addGeneralPractitioner(Reference("Practitioner/gp-02")) @@ -3180,13 +3210,13 @@ class DatabaseImplTest { HumanName().apply { addGiven("James") family = "Doe" - } + }, ) addGeneralPractitioner(Reference("Practitioner/gp-01")) addGeneralPractitioner(Reference("Practitioner/gp-02")) addGeneralPractitioner(Reference("Practitioner/gp-03")) managingOrganization = Reference("Organization/org-03") - } + }, ) val practitioners = @@ -3197,7 +3227,7 @@ class DatabaseImplTest { HumanName().apply { family = "Practitioner-01" addGiven("General-01") - } + }, ) active = true }, @@ -3207,7 +3237,7 @@ class DatabaseImplTest { HumanName().apply { family = "Practitioner-02" addGiven("General-02") - } + }, ) active = true }, @@ -3217,10 +3247,10 @@ class DatabaseImplTest { HumanName().apply { family = "Practitioner-03" addGiven("General-03") - } + }, ) active = false - } + }, ) val organizations = @@ -3239,7 +3269,7 @@ class DatabaseImplTest { id = "org-03" name = "Organization-03" active = false - } + }, ) val conditions = @@ -3373,7 +3403,7 @@ class DatabaseImplTest { start = DateType(2022, 2, 1).value end = DateType(2022, 11, 1).value } - } + }, ) // 3 Patients. // Each has 3 conditions, only 2 should match @@ -3394,7 +3424,7 @@ class DatabaseImplTest { { value = "James" modifier = StringFilterModifier.MATCHES_EXACTLY - } + }, ) include(Patient.GENERAL_PRACTITIONER) { @@ -3404,7 +3434,7 @@ class DatabaseImplTest { { value = "Practitioner" modifier = StringFilterModifier.STARTS_WITH - } + }, ) operation = Operation.AND } @@ -3414,7 +3444,7 @@ class DatabaseImplTest { { value = "Organization" modifier = StringFilterModifier.STARTS_WITH - } + }, ) filter(Practitioner.ACTIVE, { value = of(true) }) operation = Operation.AND @@ -3431,7 +3461,7 @@ class DatabaseImplTest { { value = of(DateTimeType("2023-01-01")) prefix = ParamPrefixEnum.GREATERTHAN_OR_EQUALS - } + }, ) } } @@ -3450,21 +3480,21 @@ class DatabaseImplTest { Pair(ResourceType.Condition, "subject") to listOf(resources["con-01-pa-01"]!!, resources["con-03-pa-01"]!!), Pair(ResourceType.Encounter, "subject") to - listOf(resources["en-01-pa-01"]!!, resources["en-02-pa-01"]!!) - ) + listOf(resources["en-01-pa-01"]!!, resources["en-02-pa-01"]!!), + ), ), SearchResult( resources["pa-02"]!!, mapOf( "general-practitioner" to listOf(resources["gp-01"]!!, resources["gp-02"]!!), - "organization" to listOf(resources["org-02"]!!) + "organization" to listOf(resources["org-02"]!!), ), mapOf( Pair(ResourceType.Condition, "subject") to listOf(resources["con-01-pa-02"]!!, resources["con-03-pa-02"]!!), Pair(ResourceType.Encounter, "subject") to - listOf(resources["en-01-pa-02"]!!, resources["en-02-pa-02"]!!) - ) + listOf(resources["en-01-pa-02"]!!, resources["en-02-pa-02"]!!), + ), ), SearchResult( resources["pa-03"]!!, @@ -3475,10 +3505,10 @@ class DatabaseImplTest { Pair(ResourceType.Condition, "subject") to listOf(resources["con-01-pa-03"]!!, resources["con-03-pa-03"]!!), Pair(ResourceType.Encounter, "subject") to - listOf(resources["en-01-pa-03"]!!, resources["en-02-pa-03"]!!) - ) - ) - ) + listOf(resources["en-01-pa-03"]!!, resources["en-02-pa-03"]!!), + ), + ), + ), ) } 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 59a2a1322e..0b9a31b4fb 100644 --- a/engine/src/main/java/com/google/android/fhir/FhirEngine.kt +++ b/engine/src/main/java/com/google/android/fhir/FhirEngine.kt @@ -19,6 +19,7 @@ package com.google.android.fhir import com.google.android.fhir.db.ResourceNotFoundException import com.google.android.fhir.search.Search import com.google.android.fhir.sync.ConflictResolver +import com.google.android.fhir.sync.upload.LocalChangesFetchMode import java.time.OffsetDateTime import kotlinx.coroutines.flow.Flow import org.hl7.fhir.r4.model.Resource @@ -53,7 +54,8 @@ interface FhirEngine { * api caller should [Flow.collect] it. */ suspend fun syncUpload( - upload: (suspend (List) -> Flow>) + localChangesFetchMode: LocalChangesFetchMode, + upload: (suspend (List) -> Flow>), ) /** @@ -62,7 +64,7 @@ interface FhirEngine { */ suspend fun syncDownload( conflictResolver: ConflictResolver, - download: suspend () -> Flow> + download: suspend () -> Flow>, ) /** @@ -87,29 +89,32 @@ interface FhirEngine { * Retrieves a list of [LocalChange]s for [Resource] with given type and id, which can be used to * purge resource from database. If there is no local change for given [resourceType] and * [Resource.id], return an empty list. + * * @param type The [ResourceType] * @param id The resource id [Resource.id] * @return [List]<[LocalChange]> A list of local changes for given [resourceType] and - * [Resource.id] . If there is no local change for given [resourceType] and [Resource.id], return - * an empty list. + * [Resource.id] . If there is no local change for given [resourceType] and [Resource.id], + * return an empty list. */ suspend fun getLocalChanges(type: ResourceType, id: String): List /** * Purges a resource from the database based on resource type and id without any deletion of data * from the server. + * * @param type The [ResourceType] * @param id The resource id [Resource.id] * @param isLocalPurge default value is false here resource will not be deleted from - * LocalChangeEntity table but it will throw IllegalStateException("Resource has local changes - * either sync with server or FORCE_PURGE required") if local change exists. If true this API will - * delete resource entry from LocalChangeEntity table. + * LocalChangeEntity table but it will throw IllegalStateException("Resource has local changes + * either sync with server or FORCE_PURGE required") if local change exists. If true this API + * will delete resource entry from LocalChangeEntity table. */ suspend fun purge(type: ResourceType, id: String, forcePurge: Boolean = false) } /** * Returns a FHIR resource of type [R] with [id] from the local storage. + * * @param The resource type which should be a subtype of [Resource]. * @throws ResourceNotFoundException if the resource is not found */ @@ -120,6 +125,7 @@ suspend inline fun FhirEngine.get(id: String): R { /** * Deletes a FHIR resource of type [R] with [id] from the local storage. + * * @param The resource type which should be a subtype of [Resource]. */ suspend inline fun FhirEngine.delete(id: String) { @@ -138,7 +144,7 @@ data class SearchResult( /** Matching referenced resources as per the [Search.include] criteria in the query. */ val included: Map>?, /** Matching referenced resources as per the [Search.revInclude] criteria in the query. */ - val revIncluded: Map, List>? + val revIncluded: Map, List>?, ) { override fun equals(other: Any?) = other is SearchResult<*> && @@ -155,7 +161,7 @@ data class SearchResult( private fun equalsShallow( first: Map>?, - second: Map>? + second: Map>?, ) = if (first != null && second != null && first.size == second.size) { first.entries.asSequence().zip(second.entries.asSequence()).all { (x, y) -> @@ -168,7 +174,7 @@ data class SearchResult( @JvmName("equalsShallowRevInclude") private fun equalsShallow( first: Map, List>?, - second: Map, List>? + second: Map, List>?, ) = if (first != null && second != null && first.size == second.size) { first.entries.asSequence().zip(second.entries.asSequence()).all { (x, y) -> 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 b2cb416fcb..cbbc840b64 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 @@ -58,7 +58,7 @@ internal interface Database { resourceId: String, resourceType: ResourceType, versionId: String, - lastUpdated: Instant + lastUpdated: Instant, ) /** @@ -105,6 +105,9 @@ internal interface Database { */ suspend fun getAllLocalChanges(): List + /** Retrieves the count of [LocalChange]s stored in the database. */ + suspend fun getLocalChangesCount(): Int + /** Remove the [LocalChangeEntity] s with given ids. Call this after a successful sync. */ suspend fun deleteUpdates(token: LocalChangeToken) @@ -127,23 +130,25 @@ internal interface Database { * Retrieve a list of [LocalChange] for [Resource] with given type and id, which can be used to * purge resource from database. If there is no local change for given [resourceType] and * [Resource.id], return an empty list. + * * @param type The [ResourceType] * @param id The resource id [Resource.id] * @return [List]<[LocalChange]> A list of local changes for given [resourceType] and - * [Resource.id] . If there is no local change for given [resourceType] and [Resource.id], return - * empty list. + * [Resource.id] . If there is no local change for given [resourceType] and [Resource.id], + * return empty list. */ suspend fun getLocalChanges(type: ResourceType, id: String): List /** * Purge resource from database based on resource type and id without any deletion of data from * the server. + * * @param type The [ResourceType] * @param id The resource id [Resource.id] * @param isLocalPurge default value is false here resource will not be deleted from - * LocalChangeEntity table but it will throw IllegalStateException("Resource has local changes - * either sync with server or FORCE_PURGE required") if local change exists. If true this API will - * delete resource entry from LocalChangeEntity table. + * LocalChangeEntity table but it will throw IllegalStateException("Resource has local changes + * either sync with server or FORCE_PURGE required") if local change exists. If true this API + * will delete resource entry from LocalChangeEntity table. */ suspend fun purge(type: ResourceType, id: String, forcePurge: Boolean = false) } 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 8da36f8bce..8ca9fff23c 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 @@ -46,7 +46,7 @@ internal class DatabaseImpl( private val context: Context, private val iParser: IParser, databaseConfig: DatabaseConfig, - private val resourceIndexer: ResourceIndexer + private val resourceIndexer: ResourceIndexer, ) : com.google.android.fhir.db.Database { val db: ResourceDatabase @@ -88,8 +88,10 @@ internal class DatabaseImpl( openHelperFactory { SQLCipherSupportHelper( it, - databaseErrorStrategy = databaseConfig.databaseErrorStrategy - ) { DatabaseEncryptionKeyProvider.getOrCreatePassphrase(DATABASE_PASSPHRASE_NAME) } + databaseErrorStrategy = databaseConfig.databaseErrorStrategy, + ) { + DatabaseEncryptionKeyProvider.getOrCreatePassphrase(DATABASE_PASSPHRASE_NAME) + } } } @@ -115,7 +117,7 @@ internal class DatabaseImpl( val timeOfLocalChange = Instant.now() localChangeDao.addInsert(it, timeOfLocalChange) resourceDao.insertLocalResource(it, timeOfLocalChange) - } + }, ) } return logicalIds @@ -140,14 +142,14 @@ internal class DatabaseImpl( resourceId: String, resourceType: ResourceType, versionId: String, - lastUpdated: Instant + lastUpdated: Instant, ) { db.withTransaction { resourceDao.updateAndIndexRemoteVersionIdAndLastUpdate( resourceId, resourceType, versionId, - lastUpdated + lastUpdated, ) } } @@ -174,12 +176,13 @@ internal class DatabaseImpl( null } val rowsDeleted = resourceDao.deleteResource(resourceId = id, resourceType = type) - if (rowsDeleted > 0) + if (rowsDeleted > 0) { localChangeDao.addDelete( resourceId = id, resourceType = type, - remoteVersionId = remoteVersionId + remoteVersionId = remoteVersionId, ) + } } } @@ -200,7 +203,7 @@ internal class DatabaseImpl( IndexedIdAndResource( it.matchingIndex, it.idOfBaseResourceOnWhichThisMatchedInc ?: it.idOfBaseResourceOnWhichThisMatchedRev!!, - iParser.parseResource(it.serializedResource) as Resource + iParser.parseResource(it.serializedResource) as Resource, ) } } @@ -216,6 +219,10 @@ internal class DatabaseImpl( return db.withTransaction { localChangeDao.getAllLocalChanges().map { it.toLocalChange() } } } + override suspend fun getLocalChangesCount(): Int { + return db.withTransaction { localChangeDao.getLocalChangesCount() } + } + override suspend fun deleteUpdates(token: LocalChangeToken) { db.withTransaction { localChangeDao.discardLocalChanges(token) } } @@ -265,12 +272,12 @@ internal class DatabaseImpl( if (forcePurge) { resourceDao.deleteResource(resourceId = id, resourceType = type) localChangeDao.discardLocalChanges( - token = LocalChangeToken(localChangeEntityList.map { it.id }) + token = LocalChangeToken(localChangeEntityList.map { it.id }), ) } else { // local change is available but FORCE_PURGE = false then throw exception throw IllegalStateException( - "Resource with type $type and id $id has local changes, either sync with server or FORCE_PURGE required" + "Resource with type $type and id $id has local changes, either sync with server or FORCE_PURGE required", ) } } @@ -301,5 +308,5 @@ internal class DatabaseImpl( data class DatabaseConfig( val inMemory: Boolean, val enableEncryption: Boolean, - val databaseErrorStrategy: DatabaseErrorStrategy + val databaseErrorStrategy: DatabaseErrorStrategy, ) diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/dao/LocalChangeDao.kt b/engine/src/main/java/com/google/android/fhir/db/impl/dao/LocalChangeDao.kt index f8c9524439..d69684a9bd 100644 --- a/engine/src/main/java/com/google/android/fhir/db/impl/dao/LocalChangeDao.kt +++ b/engine/src/main/java/com/google/android/fhir/db/impl/dao/LocalChangeDao.kt @@ -64,8 +64,8 @@ internal abstract class LocalChangeDao { timestamp = timeOfLocalChange, type = Type.INSERT, payload = resourceString, - versionId = resource.versionId - ) + versionId = resource.versionId, + ), ) } @@ -73,11 +73,12 @@ internal abstract class LocalChangeDao { val resourceId = resource.logicalId val resourceType = resource.resourceType - if (!localChangeIsEmpty(resourceId, resourceType) && + if ( + !localChangeIsEmpty(resourceId, resourceType) && lastChangeType(resourceId, resourceType)!! == Type.DELETE ) { throw InvalidLocalChangeException( - "Unexpected DELETE when updating $resourceType/$resourceId. UPDATE failed." + "Unexpected DELETE when updating $resourceType/$resourceId. UPDATE failed.", ) } val jsonDiff = @@ -85,7 +86,7 @@ internal abstract class LocalChangeDao { if (jsonDiff.length() == 0) { Timber.i( "New resource ${resource.resourceType}/${resource.id} is same as old resource. " + - "Not inserting UPDATE LocalChange." + "Not inserting UPDATE LocalChange.", ) return } @@ -97,8 +98,8 @@ internal abstract class LocalChangeDao { timestamp = timeOfLocalChange, type = Type.UPDATE, payload = jsonDiff.toString(), - versionId = oldEntity.versionId - ) + versionId = oldEntity.versionId, + ), ) } @@ -111,8 +112,8 @@ internal abstract class LocalChangeDao { timestamp = Date().toInstant(), type = Type.DELETE, payload = "", - versionId = remoteVersionId - ) + versionId = remoteVersionId, + ), ) } @@ -124,7 +125,7 @@ internal abstract class LocalChangeDao { AND resourceType = :resourceType ORDER BY id ASC LIMIT 1 - """ + """, ) abstract suspend fun lastChangeType(resourceId: String, resourceType: ResourceType): Type? @@ -135,7 +136,7 @@ internal abstract class LocalChangeDao { WHERE resourceId = :resourceId AND resourceType = :resourceType LIMIT 1 - """ + """, ) abstract suspend fun countLastChange(resourceId: String, resourceType: ResourceType): Int @@ -146,15 +147,23 @@ internal abstract class LocalChangeDao { """ SELECT * FROM LocalChangeEntity - ORDER BY LocalChangeEntity.id ASC""" + ORDER BY LocalChangeEntity.id ASC""", ) abstract suspend fun getAllLocalChanges(): List + @Query( + """ + SELECT COUNT(*) + FROM LocalChangeEntity + """, + ) + abstract suspend fun getLocalChangesCount(): Int + @Query( """ DELETE FROM LocalChangeEntity WHERE LocalChangeEntity.id = (:id) - """ + """, ) abstract suspend fun discardLocalChanges(id: Long) @@ -168,7 +177,7 @@ internal abstract class LocalChangeDao { DELETE FROM LocalChangeEntity WHERE resourceId = (:resourceId) AND resourceType = :resourceType - """ + """, ) abstract suspend fun discardLocalChanges(resourceId: String, resourceType: ResourceType) @@ -181,11 +190,11 @@ internal abstract class LocalChangeDao { SELECT * FROM LocalChangeEntity WHERE resourceId = :resourceId AND resourceType = :resourceType - """ + """, ) abstract suspend fun getLocalChanges( resourceType: ResourceType, - resourceId: String + resourceId: String, ): List class InvalidLocalChangeException(message: String?) : Exception(message) @@ -197,8 +206,8 @@ internal fun diff(parser: IParser, source: Resource, target: Resource): JSONArra return getFilteredJSONArray( JsonDiff.asJson( objectMapper.readValue(parser.encodeResourceToString(source), JsonNode::class.java), - objectMapper.readValue(parser.encodeResourceToString(target), JsonNode::class.java) - ) + objectMapper.readValue(parser.encodeResourceToString(target), JsonNode::class.java), + ), ) } @@ -210,12 +219,14 @@ internal fun diff(parser: IParser, source: Resource, target: Resource): JSONArra * and causes the issue with server update. * * An unfiltered JSON Array for family name update looks like + * * ``` * [{"op":"remove","path":"/meta"}, {"op":"remove","path":"/text"}, * {"op":"replace","path":"/name/0/family","value":"Nucleus"}] * ``` * * A filtered JSON Array for family name update looks like + * * ``` * [{"op":"replace","path":"/name/0/family","value":"Nucleus"}] * ``` @@ -226,6 +237,8 @@ private fun getFilteredJSONArray(jsonDiff: JsonNode) = return@with JSONArray( (0 until length()) .map { optJSONObject(it) } - .filterNot { jsonObject -> ignorePaths.any { jsonObject.optString("path").startsWith(it) } } + .filterNot { jsonObject -> + ignorePaths.any { jsonObject.optString("path").startsWith(it) } + }, ) } 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 4c93efec9f..adf2611487 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 @@ -30,6 +30,8 @@ import com.google.android.fhir.search.execute import com.google.android.fhir.sync.ConflictResolver import com.google.android.fhir.sync.Resolved import com.google.android.fhir.sync.upload.DefaultResourceConsolidator +import com.google.android.fhir.sync.upload.LocalChangeFetcherFactory +import com.google.android.fhir.sync.upload.LocalChangesFetchMode import java.time.OffsetDateTime import kotlinx.coroutines.flow.Flow import org.hl7.fhir.r4.model.Resource @@ -122,12 +124,15 @@ internal class FhirEngineImpl(private val database: Database, private val contex .intersect(database.getAllLocalChanges().map { it.resourceId }.toSet()) override suspend fun syncUpload( + localChangesFetchMode: LocalChangesFetchMode, upload: suspend (List) -> Flow>, ) { val resourceConsolidator = DefaultResourceConsolidator(database) - val localChanges = database.getAllLocalChanges() - if (localChanges.isNotEmpty()) { - upload(localChanges).collect { resourceConsolidator.consolidate(it.first, it.second) } + val localChangeFetcher = LocalChangeFetcherFactory.byMode(localChangesFetchMode, database) + while (localChangeFetcher.hasNext()) { + upload(localChangeFetcher.next()).collect { + resourceConsolidator.consolidate(it.first, it.second) + } } } } diff --git a/engine/src/main/java/com/google/android/fhir/sync/FhirSynchronizer.kt b/engine/src/main/java/com/google/android/fhir/sync/FhirSynchronizer.kt index c58857dfbb..f2f6de79b0 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/FhirSynchronizer.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/FhirSynchronizer.kt @@ -21,6 +21,7 @@ import com.google.android.fhir.DatastoreUtil import com.google.android.fhir.FhirEngine import com.google.android.fhir.sync.download.DownloadState import com.google.android.fhir.sync.download.Downloader +import com.google.android.fhir.sync.upload.LocalChangesFetchMode import com.google.android.fhir.sync.upload.UploadState import com.google.android.fhir.sync.upload.Uploader import java.time.OffsetDateTime @@ -129,7 +130,8 @@ internal class FhirSynchronizer( private suspend fun upload(): SyncResult { val exceptions = mutableListOf() - fhirEngine.syncUpload { list -> + val localChangesFetchMode = LocalChangesFetchMode.AllChanges + fhirEngine.syncUpload(localChangesFetchMode) { list -> flow { uploader.upload(list).collect { result -> when (result) { diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/LocalChangeFetcher.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/LocalChangeFetcher.kt new file mode 100644 index 0000000000..34f8eee4d4 --- /dev/null +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/LocalChangeFetcher.kt @@ -0,0 +1,93 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.sync.upload + +import com.google.android.fhir.LocalChange +import com.google.android.fhir.db.Database +import kotlin.properties.Delegates + +/** + * Fetches local changes. + * + * This interface provides methods to check for the existence of further changes, retrieve the next + * batch of changes, and get the progress of fetched changes. + * + * It is marked as internal to keep [Database] unexposed to clients + */ +internal interface LocalChangeFetcher { + + /** Represents the initial total number of local changes to upload. */ + val total: Int + + /** Checks if there are more local changes to be fetched. */ + suspend fun hasNext(): Boolean + + /** Retrieves the next batch of local changes. */ + suspend fun next(): List + + /** + * Returns [FetchProgress], which contains the remaining changes left to upload and the initial + * total to upload. + */ + suspend fun getProgress(): FetchProgress +} + +data class FetchProgress( + val remaining: Int, + val initialTotal: Int, +) + +internal class AllChangesLocalChangeFetcher( + private val database: Database, +) : LocalChangeFetcher { + + override var total by Delegates.notNull() + + suspend fun initTotalCount() { + total = database.getLocalChangesCount() + } + + override suspend fun hasNext(): Boolean = database.getLocalChangesCount().isNotZero() + + override suspend fun next(): List = database.getAllLocalChanges() + + override suspend fun getProgress(): FetchProgress = + FetchProgress(database.getLocalChangesCount(), total) +} + +/** Represents the mode in which local changes should be fetched. */ +sealed class LocalChangesFetchMode { + object AllChanges : LocalChangesFetchMode() + + object PerResource : LocalChangesFetchMode() + + object EarliestChange : LocalChangesFetchMode() +} + +internal object LocalChangeFetcherFactory { + suspend fun byMode( + mode: LocalChangesFetchMode, + database: Database, + ): LocalChangeFetcher = + when (mode) { + is LocalChangesFetchMode.AllChanges -> + AllChangesLocalChangeFetcher(database).apply { initTotalCount() } + else -> throw NotImplementedError("$mode is not implemented yet.") + } +} + +private fun Int.isNotZero() = this != 0 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 6b7a27ae57..ebd90ab6dc 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 @@ -33,6 +33,7 @@ import com.google.android.fhir.sync.DownloadRequest import com.google.android.fhir.sync.DownloadWorkManager import com.google.android.fhir.sync.UploadRequest import com.google.android.fhir.sync.UrlDownloadRequest +import com.google.android.fhir.sync.upload.LocalChangesFetchMode import com.google.common.truth.Truth.assertThat import java.net.SocketTimeoutException import java.time.Instant @@ -111,7 +112,7 @@ object TestDataSourceImpl : DataSource { } open class TestDownloadManagerImpl( - private val queries: List = listOf("Patient?address-city=NAIROBI") + private val queries: List = listOf("Patient?address-city=NAIROBI"), ) : DownloadWorkManager { private val urls = LinkedList(queries) @@ -147,17 +148,17 @@ object TestFhirEngineImpl : FhirEngine { } override suspend fun syncUpload( - upload: suspend (List) -> Flow> - ) { - upload(getLocalChanges(ResourceType.Patient, "123")).collect() - } + localChangesFetchMode: LocalChangesFetchMode, + upload: suspend (List) -> Flow>, + ) = upload(getLocalChanges(ResourceType.Patient, "123")).collect() override suspend fun syncDownload( conflictResolver: ConflictResolver, - download: suspend () -> Flow> + download: suspend () -> Flow>, ) { download().collect() } + override suspend fun count(search: Search): Long { return 0 } @@ -176,8 +177,8 @@ object TestFhirEngineImpl : FhirEngine { payload = "{ 'resourceType' : 'Patient', 'id' : '123' }", token = LocalChangeToken(listOf()), type = LocalChange.Type.INSERT, - timestamp = Instant.now() - ) + timestamp = Instant.now(), + ), ) } 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 d19d1b8dfc..fbf400007e 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 @@ -29,6 +29,7 @@ import com.google.android.fhir.search.LOCAL_LAST_UPDATED_PARAM import com.google.android.fhir.search.search import com.google.android.fhir.sync.AcceptLocalConflictResolver import com.google.android.fhir.sync.AcceptRemoteConflictResolver +import com.google.android.fhir.sync.upload.LocalChangesFetchMode import com.google.android.fhir.testing.assertResourceEquals import com.google.android.fhir.testing.assertResourceNotEquals import com.google.android.fhir.testing.readFromFile @@ -85,7 +86,7 @@ class FhirEngineImplTest { HumanName().apply { family = "FamilyName" addGiven("GivenName") - } + }, ) } val ids = fhirEngine.create(patient.copy()) @@ -101,7 +102,7 @@ class FhirEngineImplTest { } assertThat(exception.message) .isEqualTo( - "Resource not found with type ${TEST_PATIENT_2.resourceType.name} and id $TEST_PATIENT_2_ID!" + "Resource not found with type ${TEST_PATIENT_2.resourceType.name} and id $TEST_PATIENT_2_ID!", ) } @@ -160,7 +161,7 @@ class FhirEngineImplTest { } assertThat(resourceNotFoundException.message) .isEqualTo( - "Resource not found with type ${ResourceType.Patient.name} and id nonexistent_patient!" + "Resource not found with type ${ResourceType.Patient.name} and id nonexistent_patient!", ) } @@ -175,7 +176,7 @@ class FhirEngineImplTest { listOf( buildPatient("3", "C", Enumerations.AdministrativeGender.FEMALE), buildPatient("2", "B", Enumerations.AdministrativeGender.FEMALE), - buildPatient("1", "A", Enumerations.AdministrativeGender.MALE) + buildPatient("1", "A", Enumerations.AdministrativeGender.MALE), ) fhirEngine.create(*patients.toTypedArray()) @@ -184,7 +185,7 @@ class FhirEngineImplTest { assertThat(result.size).isEqualTo(2) assertThat( - result.all { (it.resource as Patient).gender == Enumerations.AdministrativeGender.FEMALE } + result.all { (it.resource as Patient).gender == Enumerations.AdministrativeGender.FEMALE }, ) .isTrue() } @@ -195,7 +196,7 @@ class FhirEngineImplTest { listOf( buildPatient("3", "C", Enumerations.AdministrativeGender.FEMALE), buildPatient("2", "B", Enumerations.AdministrativeGender.FEMALE), - buildPatient("1", "A", Enumerations.AdministrativeGender.MALE) + buildPatient("1", "A", Enumerations.AdministrativeGender.MALE), ) fhirEngine.create(*patients.toTypedArray()) @@ -212,7 +213,7 @@ class FhirEngineImplTest { listOf( buildPatient("3", "C", Enumerations.AdministrativeGender.FEMALE), buildPatient("2", "B", Enumerations.AdministrativeGender.FEMALE), - buildPatient("1", "A", Enumerations.AdministrativeGender.MALE) + buildPatient("1", "A", Enumerations.AdministrativeGender.MALE), ) fhirEngine.create(*patients.toTypedArray()) @@ -258,7 +259,7 @@ class FhirEngineImplTest { }, buildPatient("2", "Patient2", Enumerations.AdministrativeGender.FEMALE).apply { meta = Meta().setTag(mutableListOf(Coding("http://d-tree.org/", "Tag2", "Tag 2"))) - } + }, ) fhirEngine.create(*patients.toTypedArray()) @@ -279,15 +280,15 @@ class FhirEngineImplTest { .setProfile( mutableListOf( CanonicalType( - "http://fhir.org/STU3/StructureDefinition/Example-Patient-Profile-1" - ) - ) + "http://fhir.org/STU3/StructureDefinition/Example-Patient-Profile-1", + ), + ), ) }, buildPatient("4", "C", Enumerations.AdministrativeGender.FEMALE).apply { meta = Meta().setProfile(mutableListOf(CanonicalType("http://d-tree.org/Diabetes-Patient"))) - } + }, ) fhirEngine.create(*patients.toTypedArray()) @@ -295,7 +296,7 @@ class FhirEngineImplTest { val result = fhirEngine .search( - "Patient?_profile=http://fhir.org/STU3/StructureDefinition/Example-Patient-Profile-1" + "Patient?_profile=http://fhir.org/STU3/StructureDefinition/Example-Patient-Profile-1", ) .map { it.resource as Patient } @@ -305,7 +306,7 @@ class FhirEngineImplTest { patient.meta.profile.all { it.value.equals("http://fhir.org/STU3/StructureDefinition/Example-Patient-Profile-1") } - } + }, ) .isTrue() } @@ -313,7 +314,7 @@ class FhirEngineImplTest { @Test fun syncUpload_uploadLocalChange() = runBlocking { val localChanges = mutableListOf() - fhirEngine.syncUpload { + fhirEngine.syncUpload(LocalChangesFetchMode.AllChanges) { flow { localChanges.addAll(it) emit(LocalChangeToken(it.flatMap { it.token.ids }) to TEST_PATIENT_1) @@ -321,7 +322,6 @@ class FhirEngineImplTest { } assertThat(localChanges).hasSize(1) - // val localChange = localChanges[0].localChange with(localChanges[0]) { assertThat(this.resourceType).isEqualTo(ResourceType.Patient.toString()) assertThat(this.resourceId).isEqualTo(TEST_PATIENT_1.id) @@ -340,7 +340,7 @@ class FhirEngineImplTest { private fun buildPatient( patientId: String, name: String, - patientGender: Enumerations.AdministrativeGender + patientGender: Enumerations.AdministrativeGender, ) = Patient().apply { id = patientId @@ -414,7 +414,7 @@ class FhirEngineImplTest { assertThat(get(0).payload).isEqualTo(patientString) } assertResourceEquals(patient, fhirEngine.get(ResourceType.Patient, patient.logicalId)) - // clear databse + // clear database runBlocking(Dispatchers.IO) { fhirEngine.clearDatabase() } // assert that previously present resource not available after clearing database assertThat(fhirEngine.getLocalChanges(patient.resourceType, patient.logicalId)).isEmpty() @@ -436,7 +436,7 @@ class FhirEngineImplTest { } assertThat(resourceNotFoundException.message) .isEqualTo( - "Resource not found with type ${TEST_PATIENT_1.resourceType.name} and id $TEST_PATIENT_1_ID!" + "Resource not found with type ${TEST_PATIENT_1.resourceType.name} and id $TEST_PATIENT_1_ID!", ) assertThat(fhirEngine.getLocalChanges(ResourceType.Patient, TEST_PATIENT_1_ID)).isEmpty() } @@ -450,7 +450,7 @@ class FhirEngineImplTest { } assertThat(resourceIllegalStateException.message) .isEqualTo( - "Resource with type ${TEST_PATIENT_1.resourceType.name} and id $TEST_PATIENT_1_ID has local changes, either sync with server or FORCE_PURGE required" + "Resource with type ${TEST_PATIENT_1.resourceType.name} and id $TEST_PATIENT_1_ID has local changes, either sync with server or FORCE_PURGE required", ) } @@ -462,9 +462,10 @@ class FhirEngineImplTest { } assertThat(resourceNotFoundException.message) .isEqualTo( - "Resource not found with type ${TEST_PATIENT_1.resourceType.name} and id nonexistent_patient!" + "Resource not found with type ${TEST_PATIENT_1.resourceType.name} and id nonexistent_patient!", ) } + fun syncDownload_conflictResolution_acceptRemote_shouldHaveNoLocalChangeAnymore() = runBlocking { val originalPatient = Patient().apply { @@ -478,7 +479,7 @@ class FhirEngineImplTest { HumanName().apply { family = "Stark" addGiven("Tony") - } + }, ) } fhirEngine.syncDownload(AcceptRemoteConflictResolver) { flowOf((listOf((originalPatient)))) } @@ -500,7 +501,7 @@ class FhirEngineImplTest { fhirEngine.syncDownload(AcceptRemoteConflictResolver) { flowOf((listOf(remoteChange))) } assertThat( - services.database.getAllLocalChanges().filter { it.resourceId == "Patient/original-001" } + services.database.getAllLocalChanges().filter { it.resourceId == "Patient/original-001" }, ) .isEmpty() assertResourceEquals(fhirEngine.get("original-001"), remoteChange) @@ -521,7 +522,7 @@ class FhirEngineImplTest { HumanName().apply { family = "Stark" addGiven("Tony") - } + }, ) } fhirEngine.syncDownload(AcceptLocalConflictResolver) { flowOf((listOf((originalPatient)))) } @@ -535,7 +536,7 @@ class FhirEngineImplTest { Address().apply { city = "Malibu" state = "California" - } + }, ) } fhirEngine.update(localChange) @@ -555,7 +556,7 @@ class FhirEngineImplTest { val localChangeDiff = """[{"op":"remove","path":"\/address\/0\/country"},{"op":"add","path":"\/address\/0\/city","value":"Malibu"},{"op":"add","path":"\/address\/-","value":{"city":"Malibu","state":"California"}}]""" assertThat( - services.database.getAllLocalChanges().first { it.resourceId == "original-002" }.payload + services.database.getAllLocalChanges().first { it.resourceId == "original-002" }.payload, ) .isEqualTo(localChangeDiff) assertResourceEquals(fhirEngine.get("original-002"), localChange) @@ -575,7 +576,7 @@ class FhirEngineImplTest { { value = of(DateTimeType(Date.from(localChangeTimestamp))) prefix = ParamPrefixEnum.EQUAL - } + }, ) } @@ -597,7 +598,7 @@ class FhirEngineImplTest { HumanName().apply { addGiven("John") family = "Doe" - } + }, ) } fhirEngine.update(patientUpdate) @@ -611,7 +612,7 @@ class FhirEngineImplTest { { value = of(DateTimeType(Date.from(localChangeTimestampWhenUpdated))) prefix = ParamPrefixEnum.EQUAL - } + }, ) } diff --git a/engine/src/test/java/com/google/android/fhir/sync/upload/AllChangesLocalChangeFetcherTest.kt b/engine/src/test/java/com/google/android/fhir/sync/upload/AllChangesLocalChangeFetcherTest.kt new file mode 100644 index 0000000000..b14637f446 --- /dev/null +++ b/engine/src/test/java/com/google/android/fhir/sync/upload/AllChangesLocalChangeFetcherTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.sync.upload + +import androidx.test.core.app.ApplicationProvider +import com.google.android.fhir.FhirServices +import com.google.common.truth.Truth.assertThat +import java.util.Date +import kotlinx.coroutines.test.runTest +import org.hl7.fhir.r4.model.Enumerations +import org.hl7.fhir.r4.model.Meta +import org.hl7.fhir.r4.model.Patient +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class AllChangesLocalChangeFetcherTest { + private val services = + FhirServices.builder(ApplicationProvider.getApplicationContext()).inMemory().build() + private val database = services.database + private lateinit var fetcher: AllChangesLocalChangeFetcher + + @Before + fun setup() = runTest { + database.insert(TEST_PATIENT_1, TEST_PATIENT_2) + fetcher = AllChangesLocalChangeFetcher(database).apply { initTotalCount() } + } + + @Test + fun `next returns all the localChanges`() = runTest { + val localChanges = fetcher.next() + assertThat(fetcher.next().size).isEqualTo(2) + assertThat(localChanges.map { it.resourceId }) + .containsExactly(TEST_PATIENT_1_ID, TEST_PATIENT_2_ID) + } + + @Test + fun `hasNext returns true when there are local changes`() = runTest { + assertThat(fetcher.hasNext()).isTrue() + } + + @Test + fun `hasNext returns false when there are no local changes`() = runTest { + database.deleteUpdates(listOf(TEST_PATIENT_1, TEST_PATIENT_2)) + assertThat(fetcher.hasNext()).isFalse() + } + + @Test + fun `getProgress when all local changes are removed`() = runTest { + database.deleteUpdates(listOf(TEST_PATIENT_1, TEST_PATIENT_2)) + assertThat(fetcher.getProgress()).isEqualTo(FetchProgress(0, 2)) + } + + @Test + fun `getProgress when half the local changes are removed`() = runTest { + database.deleteUpdates(listOf(TEST_PATIENT_1)) + assertThat(fetcher.getProgress()).isEqualTo(FetchProgress(1, 2)) + } + + @Test + fun `getProgress when none of the local changes are removed`() = runTest { + assertThat(fetcher.getProgress()).isEqualTo(FetchProgress(2, 2)) + } + + companion object { + private const val TEST_PATIENT_1_ID = "test_patient_1" + private var TEST_PATIENT_1 = + Patient().apply { + id = TEST_PATIENT_1_ID + gender = Enumerations.AdministrativeGender.MALE + } + + private const val TEST_PATIENT_2_ID = "test_patient_2" + private var TEST_PATIENT_2 = + Patient().apply { + id = TEST_PATIENT_2_ID + gender = Enumerations.AdministrativeGender.MALE + meta = Meta().apply { lastUpdated = Date() } + } + } +} From 8c1c9e795978f5b5afd4c6adc0cc2903bd78a5d0 Mon Sep 17 00:00:00 2001 From: anchita-g <109063673+anchita-g@users.noreply.github.com> Date: Fri, 15 Sep 2023 20:52:48 +0530 Subject: [PATCH 7/7] Refactoring UploadRequestGenerator for Upload Sync Redesign (#2168) * refactoring for upload request generator * spotless application * spotless application * added UploadRequestGeneratorFactory * Spotless applications * review comments --- .../TimestampBasedDownloadWorkManagerImpl.kt | 9 +-- .../fhir/benchmark/FhirSyncWorkerBenchmark.kt | 65 +++++++++++-------- engine/build.gradle.kts | 4 +- .../google/android/fhir/sync/DataSource.kt | 6 +- .../android/fhir/sync/DownloadWorkManager.kt | 3 +- .../DownloadRequest.kt} | 33 +--------- .../fhir/sync/download/DownloaderImpl.kt | 3 - .../ResourceParamsBasedDownloadWorkManager.kt | 1 - .../fhir/sync/remote/FhirHttpDataSource.kt | 13 ++-- .../request/TransactionBundleGenerator.kt | 26 +++----- .../fhir/sync/upload/request/UploadRequest.kt | 48 ++++++++++++++ .../upload/request/UploadRequestGenerator.kt | 31 ++++++++- ...estGenerator.kt => UrlRequestGenerator.kt} | 30 ++++----- .../google/android/fhir/testing/Utilities.kt | 10 +-- .../fhir/sync/download/DownloaderImplTest.kt | 5 +- ...ourceParamsBasedDownloadWorkManagerTest.kt | 2 - .../sync/remote/FhirHttpDataSourceTest.kt | 13 ++-- .../upload/request/IndividualGeneratorTest.kt | 52 +++++++-------- 18 files changed, 200 insertions(+), 154 deletions(-) rename engine/src/main/java/com/google/android/fhir/sync/{Request.kt => download/DownloadRequest.kt} (75%) create mode 100644 engine/src/main/java/com/google/android/fhir/sync/upload/request/UploadRequest.kt rename engine/src/main/java/com/google/android/fhir/sync/upload/request/{IndividualRequestGenerator.kt => UrlRequestGenerator.kt} (81%) diff --git a/demo/src/main/java/com/google/android/fhir/demo/data/TimestampBasedDownloadWorkManagerImpl.kt b/demo/src/main/java/com/google/android/fhir/demo/data/TimestampBasedDownloadWorkManagerImpl.kt index 144f11162c..62ac55348b 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/data/TimestampBasedDownloadWorkManagerImpl.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/data/TimestampBasedDownloadWorkManagerImpl.kt @@ -17,9 +17,9 @@ package com.google.android.fhir.demo.data import com.google.android.fhir.demo.DemoDataStore -import com.google.android.fhir.sync.DownloadRequest import com.google.android.fhir.sync.DownloadWorkManager import com.google.android.fhir.sync.SyncDataParams +import com.google.android.fhir.sync.download.DownloadRequest import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Date @@ -98,14 +98,15 @@ class TimestampBasedDownloadWorkManagerImpl(private val dataStore: DemoDataStore } private suspend fun extractAndSaveLastUpdateTimestampToFetchFutureUpdates( - resources: List + resources: List, ) { resources .groupBy { it.resourceType } - .entries.map { map -> + .entries + .map { map -> dataStore.saveLastUpdatedTimestamp( map.key, - map.value.maxOfOrNull { it.meta.lastUpdated }?.toTimeZoneString() ?: "" + map.value.maxOfOrNull { it.meta.lastUpdated }?.toTimeZoneString() ?: "", ) } } diff --git a/engine/benchmark/src/androidTest/java/com/google/android/fhir/benchmark/FhirSyncWorkerBenchmark.kt b/engine/benchmark/src/androidTest/java/com/google/android/fhir/benchmark/FhirSyncWorkerBenchmark.kt index 50b17a2cda..e74dd7028a 100644 --- a/engine/benchmark/src/androidTest/java/com/google/android/fhir/benchmark/FhirSyncWorkerBenchmark.kt +++ b/engine/benchmark/src/androidTest/java/com/google/android/fhir/benchmark/FhirSyncWorkerBenchmark.kt @@ -32,9 +32,9 @@ import com.google.android.fhir.FhirEngineConfiguration import com.google.android.fhir.FhirEngineProvider import com.google.android.fhir.ServerConfiguration import com.google.android.fhir.sync.AcceptRemoteConflictResolver -import com.google.android.fhir.sync.DownloadRequest import com.google.android.fhir.sync.DownloadWorkManager import com.google.android.fhir.sync.FhirSyncWorker +import com.google.android.fhir.sync.download.DownloadRequest import com.google.common.truth.Truth.assertThat import java.math.BigDecimal import java.util.LinkedList @@ -77,13 +77,15 @@ class FhirSyncWorkerBenchmark { class BenchmarkTestOneTimeSyncWorker( private val appContext: Context, - workerParams: WorkerParameters + workerParams: WorkerParameters, ) : FhirSyncWorker(appContext, workerParams) { override fun getFhirEngine(): FhirEngine { return FhirEngineProvider.getInstance(appContext) } + override fun getDownloadWorkManager(): DownloadWorkManager = BenchmarkTestDownloadManagerImpl() + override fun getConflictResolver() = AcceptRemoteConflictResolver } @@ -92,6 +94,7 @@ class FhirSyncWorkerBenchmark { private val urls = LinkedList(queries) override suspend fun getNextRequest() = urls.poll()?.let { DownloadRequest.of(it) } + override suspend fun getSummaryRequestUrls(): Map { return emptyMap() } @@ -142,7 +145,7 @@ class FhirSyncWorkerBenchmark { private fun setupMockServerDispatcher( numberPatients: Int, numberObservations: Int, - numberEncounters: Int + numberEncounters: Int, ) { mockWebServer.dispatcher = object : Dispatcher() { @@ -209,14 +212,17 @@ class FhirSyncWorkerBenchmark { return Patient().apply { id = patientId gender = - if (patientId.last().isDigit()) Enumerations.AdministrativeGender.FEMALE - else Enumerations.AdministrativeGender.MALE + if (patientId.last().isDigit()) { + Enumerations.AdministrativeGender.FEMALE + } else { + Enumerations.AdministrativeGender.MALE + } name = listOf( HumanName().apply { given = listOf(StringType("Test patient Name $patientId")) family = "Patient Family" - } + }, ) address = listOf( @@ -227,7 +233,7 @@ class FhirSyncWorkerBenchmark { state = "Vic" postalCode = "postalCode" line = listOf(StringType("534 Erewhon St")) - } + }, ) contact = listOf( @@ -242,11 +248,14 @@ class FhirSyncWorkerBenchmark { family = "Patient Family" } gender = - if (patientId.last().isDigit()) Enumerations.AdministrativeGender.MALE - else Enumerations.AdministrativeGender.FEMALE - } + if (patientId.last().isDigit()) { + Enumerations.AdministrativeGender.MALE + } else { + Enumerations.AdministrativeGender.FEMALE + } + }, ) - } + }, ) telecom = listOf( @@ -259,7 +268,7 @@ class FhirSyncWorkerBenchmark { system = ContactPoint.ContactPointSystem.PHONE value = "(03) 3410 5613" use = ContactPoint.ContactPointUse.WORK - } + }, ) } } @@ -298,7 +307,7 @@ class FhirSyncWorkerBenchmark { code = "kPa" system = "http://unitsofmeasure.org" } - } + }, ) interpretation = listOf( @@ -306,9 +315,9 @@ class FhirSyncWorkerBenchmark { Coding( "http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation", "H", - "high" - ) - ) + "high", + ), + ), ) } } @@ -328,7 +337,7 @@ class FhirSyncWorkerBenchmark { CodeableConcept().apply { coding = listOf(Coding("http://snomed.info/sct", "183807002", "Inpatient stay for nine days")) - } + }, ) subject = Reference("Patient/$patientId") episodeOfCare = listOf(Reference("Episode/${UUID.randomUUID()}")) @@ -345,12 +354,12 @@ class FhirSyncWorkerBenchmark { Coding( "http://terminology.hl7.org/CodeSystem/v3-ParticipationType", "PART", - "Participant" - ) + "Participant", + ), ) - } + }, ) - } + }, ) diagnosis = listOf( @@ -363,8 +372,8 @@ class FhirSyncWorkerBenchmark { Coding( "http://terminology.hl7.org/CodeSystem/diagnosis-role", "AD", - "Admission diagnosis" - ) + "Admission diagnosis", + ), ) } }, @@ -377,11 +386,11 @@ class FhirSyncWorkerBenchmark { Coding( "http://terminology.hl7.org/CodeSystem/diagnosis-role", "DD", - "Discharge diagnosis" - ) + "Discharge diagnosis", + ), ) } - } + }, ) } } @@ -397,8 +406,8 @@ class FhirSyncWorkerBenchmark { FhirEngineProvider.init( FhirEngineConfiguration( serverConfiguration = ServerConfiguration("http://127.0.0.1:$mockServerPort/fhir/"), - testMode = true - ) + testMode = true, + ), ) mockWebServer.start(mockServerPort) } diff --git a/engine/build.gradle.kts b/engine/build.gradle.kts index 2d00dfa484..819e805e55 100644 --- a/engine/build.gradle.kts +++ b/engine/build.gradle.kts @@ -145,14 +145,14 @@ tasks.dokkaHtml.configure { sourceLink { localDirectory.set(file("src/main/java")) remoteUrl.set( - URL("https://github.com/google/android-fhir/tree/master/engine/src/main/java") + URL("https://github.com/google/android-fhir/tree/master/engine/src/main/java"), ) remoteLineSuffix.set("#L") } externalDocumentationLink { url.set(URL("https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-structures-r4/")) packageListUrl.set( - URL("https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-structures-r4/element-list") + URL("https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-structures-r4/element-list"), ) } } diff --git a/engine/src/main/java/com/google/android/fhir/sync/DataSource.kt b/engine/src/main/java/com/google/android/fhir/sync/DataSource.kt index 34a8600fd2..e324bcd9e0 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/DataSource.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/DataSource.kt @@ -16,6 +16,8 @@ package com.google.android.fhir.sync +import com.google.android.fhir.sync.download.DownloadRequest +import com.google.android.fhir.sync.upload.request.UploadRequest import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.Bundle.BundleType import org.hl7.fhir.r4.model.OperationOutcome @@ -28,8 +30,8 @@ internal interface DataSource { /** * @return [Bundle] of type [BundleType.TRANSACTIONRESPONSE] for a successful operation, - * [OperationOutcome] otherwise. Call this api with the [Bundle] that needs to be uploaded to the - * server. + * [OperationOutcome] otherwise. Call this api with the [Bundle] that needs to be uploaded to + * the server. */ suspend fun upload(request: UploadRequest): Resource } diff --git a/engine/src/main/java/com/google/android/fhir/sync/DownloadWorkManager.kt b/engine/src/main/java/com/google/android/fhir/sync/DownloadWorkManager.kt index ff7dc64a77..6571a4f586 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/DownloadWorkManager.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/DownloadWorkManager.kt @@ -16,6 +16,7 @@ package com.google.android.fhir.sync +import com.google.android.fhir.sync.download.DownloadRequest import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType @@ -23,7 +24,7 @@ import org.hl7.fhir.r4.model.ResourceType * Manager that generates the FHIR requests and handles the FHIR responses of a download job. * * TODO(jingtang10): What happens after the end of a download job. Should a new download work - * manager be created or should there be an API to restart a new download job. + * manager be created or should there be an API to restart a new download job. */ interface DownloadWorkManager { /** diff --git a/engine/src/main/java/com/google/android/fhir/sync/Request.kt b/engine/src/main/java/com/google/android/fhir/sync/download/DownloadRequest.kt similarity index 75% rename from engine/src/main/java/com/google/android/fhir/sync/Request.kt rename to engine/src/main/java/com/google/android/fhir/sync/download/DownloadRequest.kt index 7cc693ecd2..cdaecba39a 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/Request.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/download/DownloadRequest.kt @@ -14,11 +14,9 @@ * limitations under the License. */ -package com.google.android.fhir.sync +package com.google.android.fhir.sync.download import org.hl7.fhir.r4.model.Bundle -import org.hl7.fhir.r4.model.Resource -import org.hl7.fhir.r4.model.codesystems.HttpVerb /** * Structure represents a request that can be made to download resources from the FHIR server. The @@ -32,9 +30,11 @@ import org.hl7.fhir.r4.model.codesystems.HttpVerb * * The application developer may use a request like below to get an update on Patient/123 since it * was last downloaded. + * * ``` * Request.of("/Patient/123", mapOf("If-Modified-Since" to "knownLastUpdatedOfPatient123")) * ``` + * * **BundleRequest** * * The application developer may use a request like below to download multiple resources in a single @@ -84,30 +84,3 @@ internal constructor(val url: String, override val headers: Map data class BundleDownloadRequest internal constructor(val bundle: Bundle, override val headers: Map = emptyMap()) : DownloadRequest(headers) - -/** - * Structure represents a request that can be made to upload resources/resource modifications to the - * FHIR server. - */ -sealed class UploadRequest( - open val url: String, - open val headers: Map = emptyMap(), - open val resource: Resource, -) - -/** - * A FHIR [Bundle] based request for uploads. Multiple resources/resource modifications can be - * uploaded as a single request using this. - */ -data class BundleUploadRequest( - override val headers: Map = emptyMap(), - override val resource: Bundle, -) : UploadRequest(".", headers, resource) - -/** A [url] based FHIR request to upload resources to the server. */ -data class UrlUploadRequest( - val httpVerb: HttpVerb, - override val url: String, - override val resource: Resource, - override val headers: Map = emptyMap() -) : UploadRequest(url, headers, resource) diff --git a/engine/src/main/java/com/google/android/fhir/sync/download/DownloaderImpl.kt b/engine/src/main/java/com/google/android/fhir/sync/download/DownloaderImpl.kt index 72e2461d58..958e1c82fe 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/download/DownloaderImpl.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/download/DownloaderImpl.kt @@ -16,12 +16,9 @@ package com.google.android.fhir.sync.download -import com.google.android.fhir.sync.BundleDownloadRequest import com.google.android.fhir.sync.DataSource -import com.google.android.fhir.sync.DownloadRequest import com.google.android.fhir.sync.DownloadWorkManager import com.google.android.fhir.sync.ResourceSyncException -import com.google.android.fhir.sync.UrlDownloadRequest import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import org.hl7.fhir.r4.model.Bundle diff --git a/engine/src/main/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManager.kt b/engine/src/main/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManager.kt index 13efdffdef..be770a27e1 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManager.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManager.kt @@ -16,7 +16,6 @@ package com.google.android.fhir.sync.download -import com.google.android.fhir.sync.DownloadRequest import com.google.android.fhir.sync.DownloadWorkManager import com.google.android.fhir.sync.GREATER_THAN_PREFIX import com.google.android.fhir.sync.ParamMap diff --git a/engine/src/main/java/com/google/android/fhir/sync/remote/FhirHttpDataSource.kt b/engine/src/main/java/com/google/android/fhir/sync/remote/FhirHttpDataSource.kt index bd969c7c7d..9d2d9727ba 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/remote/FhirHttpDataSource.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/remote/FhirHttpDataSource.kt @@ -18,19 +18,20 @@ package com.google.android.fhir.sync.remote import com.fasterxml.jackson.databind.ObjectMapper import com.github.fge.jsonpatch.JsonPatch -import com.google.android.fhir.sync.BundleDownloadRequest -import com.google.android.fhir.sync.BundleUploadRequest import com.google.android.fhir.sync.DataSource -import com.google.android.fhir.sync.DownloadRequest -import com.google.android.fhir.sync.UploadRequest -import com.google.android.fhir.sync.UrlDownloadRequest -import com.google.android.fhir.sync.UrlUploadRequest +import com.google.android.fhir.sync.download.BundleDownloadRequest +import com.google.android.fhir.sync.download.DownloadRequest +import com.google.android.fhir.sync.download.UrlDownloadRequest +import com.google.android.fhir.sync.upload.request.BundleUploadRequest +import com.google.android.fhir.sync.upload.request.UploadRequest +import com.google.android.fhir.sync.upload.request.UrlUploadRequest import org.hl7.fhir.r4.model.Binary import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.codesystems.HttpVerb /** * Implementation of [DataSource] to sync data with the FHIR server using HTTP method calls. + * * @param fhirHttpService Http service to make requests to the server. */ internal class FhirHttpDataSource(private val fhirHttpService: FhirHttpService) : DataSource { diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/request/TransactionBundleGenerator.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/request/TransactionBundleGenerator.kt index 9759481125..8753aad164 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/upload/request/TransactionBundleGenerator.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/request/TransactionBundleGenerator.kt @@ -16,20 +16,15 @@ package com.google.android.fhir.sync.upload.request -import com.google.android.fhir.LocalChangeToken -import com.google.android.fhir.sync.BundleUploadRequest import com.google.android.fhir.sync.upload.patch.Patch import org.hl7.fhir.r4.model.Bundle -/** - * Generates list of [BundleUploadRequest] with Transaction [Bundle] and [LocalChangeToken]s - * associated with the resources present in the transaction bundle. - */ -class TransactionBundleGenerator( +/** Generates list of [BundleUploadRequest] of type Transaction [Bundle] from the [Patch]es */ +internal class TransactionBundleGenerator( private val generatedBundleSize: Int, private val useETagForUpload: Boolean, - private val getBundleEntryComponentGeneratorForLocalChangeType: - (type: Patch.Type, useETagForUpload: Boolean) -> BundleEntryComponentGenerator, + private val getBundleEntryComponentGeneratorForPatch: + (patch: Patch, useETagForUpload: Boolean) -> BundleEntryComponentGenerator, ) : UploadRequestGenerator { override fun generateUploadRequests(patches: List): List { @@ -41,10 +36,7 @@ class TransactionBundleGenerator( Bundle().apply { type = Bundle.BundleType.TRANSACTION patches.forEach { - this.addEntry( - getBundleEntryComponentGeneratorForLocalChangeType(it.type, useETagForUpload) - .getEntry(it), - ) + this.addEntry(getBundleEntryComponentGeneratorForPatch(it, useETagForUpload).getEntry(it)) } } return BundleUploadRequest( @@ -75,8 +67,8 @@ class TransactionBundleGenerator( fun getGenerator( httpVerbToUseForCreate: Bundle.HTTPVerb, httpVerbToUseForUpdate: Bundle.HTTPVerb, - generatedBundleSize: Int, - useETagForUpload: Boolean, + generatedBundleSize: Int = 500, + useETagForUpload: Boolean = true, ): TransactionBundleGenerator { val createFunction = createMapping[httpVerbToUseForCreate] @@ -90,8 +82,8 @@ class TransactionBundleGenerator( "Update using $httpVerbToUseForUpdate is not supported.", ) - return TransactionBundleGenerator(generatedBundleSize, useETagForUpload) { type, useETag -> - when (type) { + return TransactionBundleGenerator(generatedBundleSize, useETagForUpload) { patch, useETag -> + when (patch.type) { Patch.Type.INSERT -> createFunction(useETag) Patch.Type.UPDATE -> updateFunction(useETag) Patch.Type.DELETE -> HttpDeleteEntryComponentGenerator(useETag) diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/request/UploadRequest.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/request/UploadRequest.kt new file mode 100644 index 0000000000..9d9270de17 --- /dev/null +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/request/UploadRequest.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.sync.upload.request + +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.model.codesystems.HttpVerb + +/** + * Structure represents a request that can be made to upload resources/resource modifications to the + * FHIR server. + */ +sealed class UploadRequest( + open val url: String, + open val headers: Map = emptyMap(), + open val resource: Resource, +) + +/** + * A FHIR [Bundle] based request for uploads. Multiple resources/resource modifications can be + * uploaded as a single request using this. + */ +data class BundleUploadRequest( + override val headers: Map = emptyMap(), + override val resource: Bundle, +) : UploadRequest(".", headers, resource) + +/** A [url] based FHIR request to upload resources to the server. */ +data class UrlUploadRequest( + val httpVerb: HttpVerb, + override val url: String, + override val resource: Resource, + override val headers: Map = emptyMap(), +) : UploadRequest(url, headers, resource) diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/request/UploadRequestGenerator.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/request/UploadRequestGenerator.kt index 5cad87079e..d5c344d338 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/upload/request/UploadRequestGenerator.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/request/UploadRequestGenerator.kt @@ -16,11 +16,40 @@ package com.google.android.fhir.sync.upload.request -import com.google.android.fhir.sync.UploadRequest import com.google.android.fhir.sync.upload.patch.Patch +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.codesystems.HttpVerb /** Generator that generates [UploadRequest]s from the [Patch]es */ internal interface UploadRequestGenerator { /** Generates a list of [UploadRequest] from the [Patch]es */ fun generateUploadRequests(patches: List): List } + +/** Mode to decide the type of [UploadRequest] that needs to be generated */ +internal sealed class UploadRequestGeneratorMode { + data class UrlRequest( + val httpVerbToUseForCreate: HttpVerb, + val httpVerbToUseForUpdate: HttpVerb, + ) : UploadRequestGeneratorMode() + + data class BundleRequest( + val httpVerbToUseForCreate: Bundle.HTTPVerb, + val httpVerbToUseForUpdate: Bundle.HTTPVerb, + ) : UploadRequestGeneratorMode() +} + +internal object UploadRequestGeneratorFactory { + fun byMode( + mode: UploadRequestGeneratorMode, + ): UploadRequestGenerator = + when (mode) { + is UploadRequestGeneratorMode.UrlRequest -> + UrlRequestGenerator.getGenerator(mode.httpVerbToUseForCreate, mode.httpVerbToUseForUpdate) + is UploadRequestGeneratorMode.BundleRequest -> + TransactionBundleGenerator.getGenerator( + mode.httpVerbToUseForCreate, + mode.httpVerbToUseForUpdate, + ) + } +} diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/request/IndividualRequestGenerator.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/request/UrlRequestGenerator.kt similarity index 81% rename from engine/src/main/java/com/google/android/fhir/sync/upload/request/IndividualRequestGenerator.kt rename to engine/src/main/java/com/google/android/fhir/sync/upload/request/UrlRequestGenerator.kt index b0e4b22e24..7898207b24 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/upload/request/IndividualRequestGenerator.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/request/UrlRequestGenerator.kt @@ -19,19 +19,18 @@ package com.google.android.fhir.sync.upload.request import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.context.FhirVersionEnum import com.google.android.fhir.ContentTypes -import com.google.android.fhir.sync.UrlUploadRequest import com.google.android.fhir.sync.upload.patch.Patch import org.hl7.fhir.r4.model.Binary import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.codesystems.HttpVerb -/** Generates list of [UrlUploadRequest]s with for each [Patch] given. */ -class IndividualRequestGenerator( - private val getIndividualRequestForPatchType: (type: Patch.Type, patch: Patch) -> UrlUploadRequest +/** Generates list of [UrlUploadRequest]s for a list of [Patch]es. */ +internal class UrlRequestGenerator( + private val getUrlRequestForPatch: (patch: Patch) -> UrlUploadRequest, ) : UploadRequestGenerator { override fun generateUploadRequests(patches: List): List = - patches.map { getIndividualRequestForPatchType(it.type, it) } + patches.map { getUrlRequestForPatch(it) } companion object Factory { @@ -51,29 +50,28 @@ class IndividualRequestGenerator( fun getDefault() = getGenerator(HttpVerb.PUT, HttpVerb.PATCH) /** - * Returns a [IndividualRequestGenerator] based on the provided [HttpVerb]s for creating and - * updating resources. The function may throw an [IllegalArgumentException] if the provided - * [HttpVerb]s are not supported. + * Returns a [UrlRequestGenerator] based on the provided [HttpVerb]s for creating and updating + * resources. The function may throw an [IllegalArgumentException] if the provided [HttpVerb]s + * are not supported. */ fun getGenerator( httpVerbToUseForCreate: HttpVerb, - httpVerbToUseForUpdate: HttpVerb - ): IndividualRequestGenerator { - + httpVerbToUseForUpdate: HttpVerb, + ): UrlRequestGenerator { val createFunction = createMapping[httpVerbToUseForCreate] ?: throw IllegalArgumentException( - "Creation using $httpVerbToUseForCreate is not supported." + "Creation using $httpVerbToUseForCreate is not supported.", ) val updateFunction = updateMapping[httpVerbToUseForUpdate] ?: throw IllegalArgumentException( - "Update using $httpVerbToUseForUpdate is not supported." + "Update using $httpVerbToUseForUpdate is not supported.", ) - return IndividualRequestGenerator { type, patch -> - when (type) { + return UrlRequestGenerator { patch -> + when (patch.type) { Patch.Type.INSERT -> createFunction(patch) Patch.Type.UPDATE -> updateFunction(patch) Patch.Type.DELETE -> deleteFunction(patch) @@ -107,7 +105,7 @@ class IndividualRequestGenerator( httpVerb = HttpVerb.PATCH, url = "${patch.resourceType}/${patch.resourceId}", resource = Binary().apply { data = patch.payload.toByteArray() }, - headers = mapOf("Content-Type" to ContentTypes.APPLICATION_JSON_PATCH) + headers = mapOf("Content-Type" to ContentTypes.APPLICATION_JSON_PATCH), ) } } 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 ebd90ab6dc..b27519565e 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 @@ -25,15 +25,15 @@ import com.google.android.fhir.LocalChange import com.google.android.fhir.LocalChangeToken import com.google.android.fhir.SearchResult import com.google.android.fhir.search.Search -import com.google.android.fhir.sync.BundleDownloadRequest -import com.google.android.fhir.sync.BundleUploadRequest import com.google.android.fhir.sync.ConflictResolver import com.google.android.fhir.sync.DataSource -import com.google.android.fhir.sync.DownloadRequest import com.google.android.fhir.sync.DownloadWorkManager -import com.google.android.fhir.sync.UploadRequest -import com.google.android.fhir.sync.UrlDownloadRequest +import com.google.android.fhir.sync.download.BundleDownloadRequest +import com.google.android.fhir.sync.download.DownloadRequest +import com.google.android.fhir.sync.download.UrlDownloadRequest import com.google.android.fhir.sync.upload.LocalChangesFetchMode +import com.google.android.fhir.sync.upload.request.BundleUploadRequest +import com.google.android.fhir.sync.upload.request.UploadRequest import com.google.common.truth.Truth.assertThat import java.net.SocketTimeoutException import java.time.Instant diff --git a/engine/src/test/java/com/google/android/fhir/sync/download/DownloaderImplTest.kt b/engine/src/test/java/com/google/android/fhir/sync/download/DownloaderImplTest.kt index 19367877a3..52b6a03b32 100644 --- a/engine/src/test/java/com/google/android/fhir/sync/download/DownloaderImplTest.kt +++ b/engine/src/test/java/com/google/android/fhir/sync/download/DownloaderImplTest.kt @@ -17,12 +17,9 @@ package com.google.android.fhir.sync.download import com.google.android.fhir.logicalId -import com.google.android.fhir.sync.BundleDownloadRequest import com.google.android.fhir.sync.DataSource -import com.google.android.fhir.sync.DownloadRequest import com.google.android.fhir.sync.DownloadWorkManager -import com.google.android.fhir.sync.UploadRequest -import com.google.android.fhir.sync.UrlDownloadRequest +import com.google.android.fhir.sync.upload.request.UploadRequest import com.google.common.truth.Truth.assertThat import java.util.LinkedList import java.util.Queue diff --git a/engine/src/test/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManagerTest.kt b/engine/src/test/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManagerTest.kt index fb76798700..7dfa4d3190 100644 --- a/engine/src/test/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManagerTest.kt +++ b/engine/src/test/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManagerTest.kt @@ -17,9 +17,7 @@ package com.google.android.fhir.sync.download import com.google.android.fhir.logicalId -import com.google.android.fhir.sync.DownloadRequest import com.google.android.fhir.sync.SyncDataParams -import com.google.android.fhir.sync.UrlDownloadRequest import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runTest diff --git a/engine/src/test/java/com/google/android/fhir/sync/remote/FhirHttpDataSourceTest.kt b/engine/src/test/java/com/google/android/fhir/sync/remote/FhirHttpDataSourceTest.kt index 053da3c6df..ee8c19d6dd 100644 --- a/engine/src/test/java/com/google/android/fhir/sync/remote/FhirHttpDataSourceTest.kt +++ b/engine/src/test/java/com/google/android/fhir/sync/remote/FhirHttpDataSourceTest.kt @@ -17,9 +17,9 @@ import ca.uhn.fhir.context.FhirContext import com.google.android.fhir.ContentTypes import com.google.android.fhir.NetworkConfiguration -import com.google.android.fhir.sync.UrlUploadRequest import com.google.android.fhir.sync.remote.FhirHttpDataSource import com.google.android.fhir.sync.remote.RetrofitHttpService +import com.google.android.fhir.sync.upload.request.UrlUploadRequest import com.google.android.fhir.testing.assertResourceEquals import com.google.common.truth.Truth.assertThat import java.net.HttpURLConnection @@ -55,8 +55,8 @@ internal class FhirHttpDataSourceTest { Bundle().apply { id = "transaction-response-1" type = Bundle.BundleType.TRANSACTIONRESPONSE - } - ) + }, + ), ) } @@ -82,7 +82,7 @@ internal class FhirHttpDataSourceTest { HumanName().apply { addGiven("John") family = "Doe" - } + }, ) } val request = UrlUploadRequest(HttpVerb.POST, "Patient", patient, emptyMap()) @@ -104,7 +104,7 @@ internal class FhirHttpDataSourceTest { HumanName().apply { addGiven("John") family = "Doe" - } + }, ) } val request = @@ -128,7 +128,8 @@ internal class FhirHttpDataSourceTest { val patchToApply = Binary().apply { data = - "[{\"op\":\"replace\",\"path\":\"\\/name\\/0\\/given\\/0\",\"value\":\"Janet\"}]".toByteArray() + "[{\"op\":\"replace\",\"path\":\"\\/name\\/0\\/given\\/0\",\"value\":\"Janet\"}]" + .toByteArray() } val request = diff --git a/engine/src/test/java/com/google/android/fhir/sync/upload/request/IndividualGeneratorTest.kt b/engine/src/test/java/com/google/android/fhir/sync/upload/request/IndividualGeneratorTest.kt index 211fdb4da0..37d5baf49f 100644 --- a/engine/src/test/java/com/google/android/fhir/sync/upload/request/IndividualGeneratorTest.kt +++ b/engine/src/test/java/com/google/android/fhir/sync/upload/request/IndividualGeneratorTest.kt @@ -38,14 +38,14 @@ class IndividualGeneratorTest { @Test fun `should return empty list if there are no local changes`() = runTest { - val generator = IndividualRequestGenerator.getDefault() + val generator = UrlRequestGenerator.getDefault() val requests = generator.generateUploadRequests(listOf()) assertThat(requests).isEmpty() } @Test fun `should create a POST request for insert`() = runTest { - val generator = IndividualRequestGenerator.getGenerator(HttpVerb.POST, HttpVerb.PATCH) + val generator = UrlRequestGenerator.getGenerator(HttpVerb.POST, HttpVerb.PATCH) val requests = generator.generateUploadRequests( listOf( @@ -61,13 +61,13 @@ class IndividualGeneratorTest { HumanName().apply { addGiven("John") family = "Doe" - } + }, ) - } + }, ), - timestamp = Instant.now() + timestamp = Instant.now(), ), - ) + ), ) with(requests.single()) { @@ -78,7 +78,7 @@ class IndividualGeneratorTest { @Test fun `should create a PUT request for insert`() = runTest { - val generator = IndividualRequestGenerator.getDefault() + val generator = UrlRequestGenerator.getDefault() val requests = generator.generateUploadRequests( listOf( @@ -94,13 +94,13 @@ class IndividualGeneratorTest { HumanName().apply { addGiven("John") family = "Doe" - } + }, ) - } + }, ), - timestamp = Instant.now() + timestamp = Instant.now(), ), - ) + ), ) with(requests.single()) { @@ -119,10 +119,10 @@ class IndividualGeneratorTest { type = Patch.Type.UPDATE, payload = "[{\"op\":\"replace\",\"path\":\"\\/name\\/0\\/given\\/0\",\"value\":\"Janet\"}]", - timestamp = Instant.now() + timestamp = Instant.now(), ), ) - val generator = IndividualRequestGenerator.Factory.getDefault() + val generator = UrlRequestGenerator.Factory.getDefault() val requests = generator.generateUploadRequests(patches) with(requests.single()) { assertThat(requests.size).isEqualTo(1) @@ -130,7 +130,7 @@ class IndividualGeneratorTest { assertThat(url).isEqualTo("Patient/Patient-002") assertThat((resource as Binary).data.toString(Charsets.UTF_8)) .isEqualTo( - "[{\"op\":\"replace\",\"path\":\"\\/name\\/0\\/given\\/0\",\"value\":\"Janet\"}]" + "[{\"op\":\"replace\",\"path\":\"\\/name\\/0\\/given\\/0\",\"value\":\"Janet\"}]", ) } } @@ -151,14 +151,14 @@ class IndividualGeneratorTest { HumanName().apply { addGiven("John") family = "Doe" - } + }, ) - } + }, ), - timestamp = Instant.now() + timestamp = Instant.now(), ), ) - val generator = IndividualRequestGenerator.Factory.getDefault() + val generator = UrlRequestGenerator.Factory.getDefault() val requests = generator.generateUploadRequests(patches) with(requests.single()) { assertThat(httpVerb).isEqualTo(HttpVerb.DELETE) @@ -182,11 +182,11 @@ class IndividualGeneratorTest { HumanName().apply { addGiven("John") family = "Doe" - } + }, ) - } + }, ), - timestamp = Instant.now() + timestamp = Instant.now(), ), Patch( resourceType = ResourceType.Patient.name, @@ -194,7 +194,7 @@ class IndividualGeneratorTest { type = Patch.Type.UPDATE, payload = "[{\"op\":\"replace\",\"path\":\"\\/name\\/0\\/given\\/0\",\"value\":\"Janet\"}]", - timestamp = Instant.now() + timestamp = Instant.now(), ), Patch( resourceType = ResourceType.Patient.name, @@ -208,14 +208,14 @@ class IndividualGeneratorTest { HumanName().apply { addGiven("John") family = "Roe" - } + }, ) - } + }, ), - timestamp = Instant.now() + timestamp = Instant.now(), ), ) - val generator = IndividualRequestGenerator.Factory.getDefault() + val generator = UrlRequestGenerator.Factory.getDefault() val result = generator.generateUploadRequests(patches) assertThat(result).hasSize(3) assertThat(result.map { it.httpVerb })