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 ee0aede8c9..123c474cc5 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 @@ -19,6 +19,8 @@ package com.google.android.fhir.db.impl import android.content.Context import androidx.test.core.app.ApplicationProvider import androidx.test.filters.MediumTest +import ca.uhn.fhir.context.FhirContext +import ca.uhn.fhir.context.FhirVersionEnum import ca.uhn.fhir.rest.gclient.StringClientParam import ca.uhn.fhir.rest.param.ParamPrefixEnum import com.google.android.fhir.DateProvider @@ -4209,6 +4211,142 @@ class DatabaseImplTest { assertThat(searchedObservations[0].logicalId).isEqualTo(locallyCreatedObservationResourceId) } + @Test + fun updateResourcePostSync_shouldUpdateResourceId() = runBlocking { + val preSyncPatient = Patient().apply { id = "patient1" } + database.insert(preSyncPatient) + val postSyncResourceId = "patient2" + val newVersionId = "1" + val lastUpdatedRemote = Instant.now() + + database.updateResourcePostSync( + preSyncPatient.logicalId, + postSyncResourceId, + preSyncPatient.resourceType, + newVersionId, + lastUpdatedRemote, + ) + + val patientResourceEntityPostSync = + database.selectEntity(preSyncPatient.resourceType, postSyncResourceId) + assertThat(patientResourceEntityPostSync.resourceId).isEqualTo(postSyncResourceId) + } + + @Test + fun updateResourcePostSync_shouldUpdateResourceMeta() = runBlocking { + val preSyncPatient = Patient().apply { id = "patient1" } + database.insert(preSyncPatient) + val postSyncResourceId = "patient2" + val newVersionId = "1" + val lastUpdatedRemote = Instant.now() + + database.updateResourcePostSync( + preSyncPatient.logicalId, + postSyncResourceId, + preSyncPatient.resourceType, + newVersionId, + lastUpdatedRemote, + ) + + val patientResourceEntityPostSync = + database.selectEntity(preSyncPatient.resourceType, postSyncResourceId) + assertThat(patientResourceEntityPostSync.versionId).isEqualTo(newVersionId) + assertThat(patientResourceEntityPostSync.lastUpdatedRemote?.toEpochMilli()) + .isEqualTo(lastUpdatedRemote.toEpochMilli()) + } + + @Test + fun updateResourcePostSync_shouldDeleteOldResourceId() = runBlocking { + val preSyncPatient = Patient().apply { id = "patient1" } + database.insert(preSyncPatient) + val postSyncResourceId = "patient2" + + database.updateResourcePostSync( + preSyncPatient.logicalId, + postSyncResourceId, + preSyncPatient.resourceType, + null, + null, + ) + + val exception = + assertThrows(ResourceNotFoundException::class.java) { + runBlocking { database.select(ResourceType.Patient, "patient1") } + } + assertThat(exception.message).isEqualTo("Resource not found with type Patient and id patient1!") + } + + @Test + fun updateResourcePostSync_shouldUpdateReferringResourceReferenceValue() = runBlocking { + val preSyncPatient = Patient().apply { id = "patient1" } + val observation = + Observation().apply { + id = "observation1" + subject = Reference().apply { reference = "Patient/patient1" } + } + database.insert(preSyncPatient, observation) + val postSyncResourceId = "patient2" + val newVersionId = "1" + val lastUpdatedRemote = Instant.now() + + database.updateResourcePostSync( + preSyncPatient.logicalId, + postSyncResourceId, + preSyncPatient.resourceType, + newVersionId, + lastUpdatedRemote, + ) + + assertThat( + (database.select(ResourceType.Observation, "observation1") as Observation) + .subject + .reference, + ) + .isEqualTo("Patient/patient2") + } + + @Test + fun updateResourcePostSync_shouldUpdateReferringResourceReferenceValueInLocalChange() = + runBlocking { + val preSyncPatient = Patient().apply { id = "patient1" } + val observation = + Observation().apply { + id = "observation1" + subject = Reference().apply { reference = "Patient/patient1" } + } + database.insert(preSyncPatient, observation) + val postSyncResourceId = "patient2" + val newVersionId = "1" + val lastUpdatedRemote = Instant.now() + + database.updateResourcePostSync( + preSyncPatient.logicalId, + postSyncResourceId, + preSyncPatient.resourceType, + newVersionId, + lastUpdatedRemote, + ) + + assertThat( + (database.select(ResourceType.Observation, "observation1") as Observation) + .subject + .reference, + ) + .isEqualTo("Patient/patient2") + val observationLocalChanges = + database.getLocalChanges( + observation.resourceType, + observation.logicalId, + ) + val observationReferenceValue = + (FhirContext.forCached(FhirVersionEnum.R4) + .newJsonParser() + .parseResource(observationLocalChanges.first().payload) as Observation) + .subject + .reference + assertThat(observationReferenceValue).isEqualTo("Patient/$postSyncResourceId") + } + @Test // https://github.com/google/android-fhir/issues/2512 fun included_results_sort_ascending_should_have_distinct_resources() = runBlocking { /** diff --git a/engine/src/androidTest/java/com/google/android/fhir/sync/upload/HttpPostResourceConsolidatorTest.kt b/engine/src/androidTest/java/com/google/android/fhir/sync/upload/HttpPostResourceConsolidatorTest.kt index 67b1bb258c..0fa872aa93 100644 --- a/engine/src/androidTest/java/com/google/android/fhir/sync/upload/HttpPostResourceConsolidatorTest.kt +++ b/engine/src/androidTest/java/com/google/android/fhir/sync/upload/HttpPostResourceConsolidatorTest.kt @@ -20,15 +20,20 @@ import android.content.Context import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import ca.uhn.fhir.context.FhirContext +import ca.uhn.fhir.context.FhirVersionEnum import com.google.android.fhir.FhirServices import com.google.android.fhir.db.Database import com.google.android.fhir.db.ResourceNotFoundException import com.google.android.fhir.logicalId import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.runBlocking -import org.hl7.fhir.r4.model.DomainResource +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.Bundle.BundleEntryComponent import org.hl7.fhir.r4.model.InstantType +import org.hl7.fhir.r4.model.Meta import org.hl7.fhir.r4.model.Observation +import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.Reference import org.hl7.fhir.r4.model.ResourceType import org.junit.After import org.junit.Assert.assertThrows @@ -65,35 +70,21 @@ class HttpPostResourceConsolidatorTest { } @Test - fun consolidate_shouldUpdateResourceId() = runBlocking { - val patientJsonString = - """ - { - "resourceType": "Patient", - "id": "patient1" - } - """ - .trimIndent() - val patient = - FhirContext.forR4Cached().newJsonParser().parseResource(patientJsonString) as DomainResource - database.insert(patient) - val localChanges = database.getLocalChanges(patient.resourceType, patient.logicalId) - - val postSyncPatientJsonString = - """ - { - "resourceType": "Patient", - "id": "patient2", - "meta": { - "versionId": "1" - } - } - """ - .trimIndent() + fun resourceConsolidator_singleResourceUpload_shouldUpdateNewResourceId() = runBlocking { + val preSyncPatient = Patient().apply { id = "patient1" } + database.insert(preSyncPatient) + val localChanges = + database.getLocalChanges(preSyncPatient.resourceType, preSyncPatient.logicalId) val postSyncPatient = - FhirContext.forR4Cached().newJsonParser().parseResource(postSyncPatientJsonString) - as DomainResource - postSyncPatient.meta.lastUpdatedElement = InstantType.now() + Patient().apply { + id = "patient2" + meta = + Meta().apply { + versionId = "1" + lastUpdatedElement = InstantType.now() + } + } + val uploadRequestResult = UploadRequestResult.Success( listOf(ResourceUploadResponseMapping(localChanges, postSyncPatient)), @@ -102,130 +93,264 @@ class HttpPostResourceConsolidatorTest { assertThat(database.select(ResourceType.Patient, "patient2").logicalId) .isEqualTo(postSyncPatient.logicalId) - val exception = assertThrows(ResourceNotFoundException::class.java) { runBlocking { database.select(ResourceType.Patient, "patient1") } } - assertThat(exception.message).isEqualTo("Resource not found with type Patient and id patient1!") } @Test - fun consolidate_dependentResources_shouldUpdateReferenceValue() = runBlocking { - val patientJsonString = - """ - { - "resourceType": "Patient", - "id": "patient1" + fun resourceConsolidator_singleResourceUpload_shouldUpdateReferenceValueOfReferencingResources() = + runBlocking { + val preSyncPatient = Patient().apply { id = "patient1" } + val observation = + Observation().apply { + id = "observation1" + subject = Reference().apply { reference = "Patient/patient1" } } - """ - .trimIndent() - val patient = - FhirContext.forR4Cached().newJsonParser().parseResource(patientJsonString) as DomainResource - val observationJsonString = + database.insert(preSyncPatient, observation) + val postSyncPatient = + Patient().apply { + id = "patient2" + meta = + Meta().apply { + versionId = "1" + lastUpdatedElement = InstantType.now() + } + } + val localChanges = + database.getLocalChanges(preSyncPatient.resourceType, preSyncPatient.logicalId) + val uploadRequestResult = + UploadRequestResult.Success( + listOf(ResourceUploadResponseMapping(localChanges, postSyncPatient)), + ) + + resourceConsolidator.consolidate(uploadRequestResult) + + assertThat( + (database.select(ResourceType.Observation, "observation1") as Observation) + .subject + .reference, + ) + .isEqualTo("Patient/patient2") + } + + @Test + fun resourceConsolidator_singleResourceUpload_shouldUpdateReferenceValueOfLocalReferencingResources() = + runBlocking { + val preSyncPatient = Patient().apply { id = "patient1" } + val observation = + Observation().apply { + id = "observation1" + subject = Reference().apply { reference = "Patient/patient1" } + } + database.insert(preSyncPatient, observation) + val postSyncPatient = + Patient().apply { + id = "patient2" + meta = + Meta().apply { + versionId = "1" + lastUpdatedElement = InstantType.now() + } + } + val localChanges = + database.getLocalChanges(preSyncPatient.resourceType, preSyncPatient.logicalId) + val uploadRequestResult = + UploadRequestResult.Success( + listOf(ResourceUploadResponseMapping(localChanges, postSyncPatient)), + ) + + resourceConsolidator.consolidate(uploadRequestResult) + + val localChange = database.getLocalChanges(ResourceType.Observation, "observation1").last() + assertThat( + (FhirContext.forR4Cached().newJsonParser().parseResource(localChange.payload) + as Observation) + .subject + .reference, + ) + .isEqualTo("Patient/patient2") + } + + @Test + fun resourceConsolidator_bundleComponentUploadResponse_shouldUpdateNewResourceId() = runBlocking { + val preSyncPatient = Patient().apply { id = "patient1" } + database.insert(preSyncPatient) + val localChanges = + database.getLocalChanges(preSyncPatient.resourceType, preSyncPatient.logicalId) + val bundleEntryComponentJsonString = """ { - "resourceType": "Observation", - "id": "observation1", - "subject": { - "reference": "Patient/patient1" - } + "resourceType": "Bundle", + "id": "bundle1", + "type": "transaction-response", + "entry": [ + { + "response": { + "status": "201 Created", + "location": "Patient/patient2/_history/1", + "etag": "1", + "lastModified": "2024-04-08T11:15:42.648+00:00", + "outcome": { + "resourceType": "OperationOutcome" + } + } + }, + { + "response": { + "status": "201 Created", + "location": "Encounter/8055/_history/1", + "etag": "1", + "lastModified": "2024-04-08T11:15:42.648+00:00", + "outcome": { + "resourceType": "OperationOutcome" + } + } + } + ] } """ .trimIndent() - val observation = - FhirContext.forR4Cached().newJsonParser().parseResource(observationJsonString) - as DomainResource - database.insert(patient, observation) - val postSyncPatientJsonString = - """ - { - "resourceType": "Patient", - "id": "patient2", - "meta": { - "versionId": "1" - } - } - """ - .trimIndent() - val postSyncPatient = - FhirContext.forR4Cached().newJsonParser().parseResource(postSyncPatientJsonString) - as DomainResource - postSyncPatient.meta.lastUpdatedElement = InstantType.now() - val localChanges = database.getLocalChanges(patient.resourceType, patient.logicalId) + + val postSyncResponseBundle = + FhirContext.forCached(FhirVersionEnum.R4) + .newJsonParser() + .parseResource(bundleEntryComponentJsonString) as Bundle + val patientResponseEntry = + (postSyncResponseBundle.entry.first() as BundleEntryComponent).response val uploadRequestResult = UploadRequestResult.Success( - listOf(ResourceUploadResponseMapping(localChanges, postSyncPatient)), + listOf(BundleComponentUploadResponseMapping(localChanges, patientResponseEntry)), ) resourceConsolidator.consolidate(uploadRequestResult) - assertThat( - (database.select(ResourceType.Observation, "observation1") as Observation) - .subject - .reference, - ) - .isEqualTo("Patient/patient2") + assertThat(database.select(ResourceType.Patient, "patient2").logicalId) + .isEqualTo(patientResponseEntry.resourceIdAndType?.first) + + val exception = + assertThrows(ResourceNotFoundException::class.java) { + runBlocking { database.select(ResourceType.Patient, "patient1") } + } + + assertThat(exception.message).isEqualTo("Resource not found with type Patient and id patient1!") } @Test - fun consolidate_localChanges_shouldUpdateReferenceValue() = runBlocking { - val patientJsonString = - """ + fun resourceConsolidator_bundleComponentUploadResponse_shouldUpdateReferenceValueOfReferencingResources() = + runBlocking { + val preSyncPatient = Patient().apply { id = "patient1" } + val preSyncObservation = + Observation().apply { + id = "observation1" + subject = Reference("Patient/patient1") + } + database.insert(preSyncPatient, preSyncObservation) + val patientLocalChanges = + database.getLocalChanges(preSyncPatient.resourceType, preSyncPatient.logicalId) + val observationLocalChanges = + database.getLocalChanges(preSyncObservation.resourceType, preSyncObservation.logicalId) + val bundleEntryComponentJsonString = + """ { - "resourceType": "Patient", - "id": "patient1" + "resourceType": "Bundle", + "id": "bundle1", + "type": "transaction-response", + "entry": [ + { + "response": { + "status": "201 Created", + "location": "Patient/patient2/_history/1", + "etag": "1", + "lastModified": "2024-04-08T11:15:42.648+00:00", + "outcome": { + "resourceType": "OperationOutcome" + } + } + }, + { + "response": { + "status": "201 Created", + "location": "Observation/observation2/_history/1", + "etag": "1", + "lastModified": "2024-04-08T11:15:42.648+00:00", + "outcome": { + "resourceType": "OperationOutcome" + } + } + } + ] } """ - .trimIndent() - val patient = - FhirContext.forR4Cached().newJsonParser().parseResource(patientJsonString) as DomainResource - val observationJsonString = + .trimIndent() + + val postSyncResponseBundle = + FhirContext.forCached(FhirVersionEnum.R4) + .newJsonParser() + .parseResource(bundleEntryComponentJsonString) as Bundle + + val patientResponseEntry = + (postSyncResponseBundle.entry.first() as BundleEntryComponent).response + val observationResponseEntry = + (postSyncResponseBundle.entry[1] as BundleEntryComponent).response + + val uploadRequestResult = + UploadRequestResult.Success( + listOf( + BundleComponentUploadResponseMapping(patientLocalChanges, patientResponseEntry), + BundleComponentUploadResponseMapping(observationLocalChanges, observationResponseEntry), + ), + ) + + resourceConsolidator.consolidate(uploadRequestResult) + + assertThat( + (database.select(ResourceType.Observation, "observation2") as Observation) + .subject + .reference, + ) + .isEqualTo("Patient/patient2") + } + + @Test + fun resourceConsolidator_bundleComponentUploadResponse_shouldDiscardLocalChanges() = runBlocking { + val preSyncPatient = Patient().apply { id = "patient1" } + database.insert(preSyncPatient) + val localChanges = + database.getLocalChanges(preSyncPatient.resourceType, preSyncPatient.logicalId) + val bundleEntryComponentJsonString = """ { - "resourceType": "Observation", - "id": "observation1", - "subject": { - "reference": "Patient/patient1" - } + "resourceType": "Bundle", + "id": "bundle1", + "type": "transaction-response", + "entry": [ + { + "response": { + "status": "201 Created", + "location": "Patient/patient2/_history/1", + "etag": "1" + } + } + ] } """ .trimIndent() - val observation = - FhirContext.forR4Cached().newJsonParser().parseResource(observationJsonString) - as DomainResource - database.insert(patient, observation) - val postSyncPatientJsonString = - """ - { - "resourceType": "Patient", - "id": "patient2", - "meta": { - "versionId": "1" - } - } - """ - .trimIndent() - val postSyncPatient = - FhirContext.forR4Cached().newJsonParser().parseResource(postSyncPatientJsonString) - as DomainResource - postSyncPatient.meta.lastUpdatedElement = InstantType.now() - val localChanges = database.getLocalChanges(patient.resourceType, patient.logicalId) + val postSyncResponseBundle = + FhirContext.forCached(FhirVersionEnum.R4) + .newJsonParser() + .parseResource(bundleEntryComponentJsonString) as Bundle + val patientResponseEntry = + (postSyncResponseBundle.entry.first() as BundleEntryComponent).response val uploadRequestResult = UploadRequestResult.Success( - listOf(ResourceUploadResponseMapping(localChanges, postSyncPatient)), + listOf(BundleComponentUploadResponseMapping(localChanges, patientResponseEntry)), ) resourceConsolidator.consolidate(uploadRequestResult) - val localChange = database.getLocalChanges(ResourceType.Observation, "observation1").last() - assertThat( - (FhirContext.forR4Cached().newJsonParser().parseResource(localChange.payload) - as Observation) - .subject - .reference, - ) - .isEqualTo("Patient/patient2") + assertThat(database.getAllLocalChanges()).isEmpty() } } 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 58f1807e7e..bb4dfb0241 100644 --- a/engine/src/main/java/com/google/android/fhir/FhirEngine.kt +++ b/engine/src/main/java/com/google/android/fhir/FhirEngine.kt @@ -112,7 +112,7 @@ interface FhirEngine { * This function initiates multiple server calls to upload local changes. The results of each call * are emitted as [UploadRequestResult] objects, which can be collected using a [Flow]. * - * @param localChangesFetchMode Specifies how to fetch local changes for upload. + * @param uploadStrategy Defines strategies for uploading FHIR resource. * @param upload A suspending function that takes a list of [LocalChange] objects and returns a * [Flow] of [UploadRequestResult] objects. * @return A [Flow] that emits the progress of the synchronization process as [SyncUploadProgress] diff --git a/engine/src/main/java/com/google/android/fhir/MoreResources.kt b/engine/src/main/java/com/google/android/fhir/MoreResources.kt index f444acbe19..e4e84a141d 100644 --- a/engine/src/main/java/com/google/android/fhir/MoreResources.kt +++ b/engine/src/main/java/com/google/android/fhir/MoreResources.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,6 +17,10 @@ package com.google.android.fhir import java.lang.reflect.InvocationTargetException +import java.time.Instant +import java.util.Date +import org.hl7.fhir.r4.model.IdType +import org.hl7.fhir.r4.model.InstantType import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType @@ -58,7 +62,20 @@ fun getResourceClass(resourceType: String): Class { } internal val Resource.versionId: String? - get() = meta.versionId + get() = if (hasMeta()) meta.versionId else null internal val Resource.lastUpdated get() = if (hasMeta()) meta.lastUpdated?.toInstant() else null + +/** + * Updates the meta information of a FHIR [Resource] with the provided version ID and last updated + * timestamp. This extension function sets the version ID and last updated time in the resource's + * metadata. If the provided values are null, the respective fields in the meta will remain + * unchanged. + */ +internal fun Resource.updateMeta(versionId: String?, lastUpdatedRemote: Instant?) { + meta.apply { + versionId?.let { versionIdElement = IdType(it) } + lastUpdatedRemote?.let { lastUpdatedElement = InstantType(Date.from(it)) } + } +} 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 2c72e0d401..7f321ae712 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 @@ -59,8 +59,20 @@ internal interface Database { suspend fun updateVersionIdAndLastUpdated( resourceId: String, resourceType: ResourceType, - versionId: String, - lastUpdated: Instant, + versionId: String?, + lastUpdated: Instant?, + ) + + /** + * Updates the existing [oldResourceId] with the new [newResourceId]. Even if [oldResourceId] and + * [newResourceId] are the same, it is still necessary to update the resource meta. + */ + suspend fun updateResourcePostSync( + oldResourceId: String, + newResourceId: String, + resourceType: ResourceType, + versionId: String?, + lastUpdated: Instant?, ) /** 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 cf8ab22c07..408c3a7054 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 @@ -33,13 +33,16 @@ import com.google.android.fhir.db.impl.DatabaseImpl.Companion.UNENCRYPTED_DATABA import com.google.android.fhir.db.impl.dao.ForwardIncludeSearchResult import com.google.android.fhir.db.impl.dao.LocalChangeDao.Companion.SQLITE_LIMIT_MAX_VARIABLE_NUMBER import com.google.android.fhir.db.impl.dao.ReverseIncludeSearchResult +import com.google.android.fhir.db.impl.entities.LocalChangeEntity import com.google.android.fhir.db.impl.entities.ResourceEntity import com.google.android.fhir.index.ResourceIndexer import com.google.android.fhir.logicalId import com.google.android.fhir.search.SearchQuery import com.google.android.fhir.toLocalChange +import com.google.android.fhir.updateMeta import java.time.Instant import java.util.UUID +import org.hl7.fhir.r4.model.IdType import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType @@ -161,19 +164,38 @@ internal class DatabaseImpl( override suspend fun updateVersionIdAndLastUpdated( resourceId: String, resourceType: ResourceType, - versionId: String, - lastUpdated: Instant, + versionId: String?, + lastUpdatedRemote: Instant?, ) { db.withTransaction { resourceDao.updateAndIndexRemoteVersionIdAndLastUpdate( resourceId, resourceType, versionId, - lastUpdated, + lastUpdatedRemote, ) } } + override suspend fun updateResourcePostSync( + oldResourceId: String, + newResourceId: String, + resourceType: ResourceType, + versionId: String?, + lastUpdatedRemote: Instant?, + ) { + db.withTransaction { + resourceDao.getResourceEntity(oldResourceId, resourceType)?.let { oldResourceEntity -> + val updatedResource = + (iParser.parseResource(oldResourceEntity.serializedResource) as Resource).apply { + idElement = IdType(newResourceId) + updateMeta(versionId, lastUpdatedRemote) + } + updateResourceAndReferences(oldResourceId, updatedResource) + } + } + } + override suspend fun select(type: ResourceType, id: String): Resource { return resourceDao.getResource(resourceId = id, resourceType = type)?.let { iParser.parseResource(it) as Resource @@ -290,6 +312,10 @@ internal class DatabaseImpl( val resourceUuid = currentResourceEntity.resourceUuid updateResourceEntity(resourceUuid, updatedResource) + if (currentResourceId == updatedResource.logicalId) { + return@withTransaction + } + /** * Update LocalChange records and identify referring resources. * diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/JsonUtils.kt b/engine/src/main/java/com/google/android/fhir/db/impl/JsonUtils.kt index 31be98d1e1..637c17b136 100644 --- a/engine/src/main/java/com/google/android/fhir/db/impl/JsonUtils.kt +++ b/engine/src/main/java/com/google/android/fhir/db/impl/JsonUtils.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 2023-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/engine/src/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 7201905aa9..68069b8ab7 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 @@ -421,7 +421,7 @@ internal abstract class LocalChangeDao { * @return A list of distinct resource UUIDs for all `LocalChangeEntity` records that referenced * the old resource. */ - private suspend fun updateReferencesInLocalChange( + internal suspend fun updateReferencesInLocalChange( oldResource: Resource, updatedResourceId: String, ): List { diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/dao/ResourceDao.kt b/engine/src/main/java/com/google/android/fhir/db/impl/dao/ResourceDao.kt index 449277f31d..0f219d84dd 100644 --- a/engine/src/main/java/com/google/android/fhir/db/impl/dao/ResourceDao.kt +++ b/engine/src/main/java/com/google/android/fhir/db/impl/dao/ResourceDao.kt @@ -37,11 +37,11 @@ import com.google.android.fhir.db.impl.entities.StringIndexEntity import com.google.android.fhir.db.impl.entities.TokenIndexEntity import com.google.android.fhir.db.impl.entities.UriIndexEntity import com.google.android.fhir.index.ResourceIndexer -import com.google.android.fhir.index.ResourceIndexer.Companion.createLastUpdatedIndex import com.google.android.fhir.index.ResourceIndexer.Companion.createLocalLastUpdatedIndex import com.google.android.fhir.index.ResourceIndices import com.google.android.fhir.lastUpdated import com.google.android.fhir.logicalId +import com.google.android.fhir.updateMeta import com.google.android.fhir.versionId import java.time.Instant import java.util.Date @@ -87,8 +87,8 @@ internal abstract class ResourceDao { it.copy( resourceId = updatedResource.logicalId, serializedResource = iParser.encodeResourceToString(updatedResource), - lastUpdatedRemote = updatedResource.meta.lastUpdated?.toInstant() ?: it.lastUpdatedRemote, - versionId = updatedResource.meta.versionId, + lastUpdatedRemote = updatedResource.lastUpdated ?: it.lastUpdatedRemote, + versionId = updatedResource.versionId ?: it.versionId, ) updateChanges(entity, updatedResource) } @@ -181,8 +181,8 @@ internal abstract class ResourceDao { abstract suspend fun updateRemoteVersionIdAndLastUpdate( resourceId: String, resourceType: ResourceType, - versionId: String, - lastUpdatedRemote: Instant, + versionId: String?, + lastUpdatedRemote: Instant?, ) @Query( @@ -293,21 +293,13 @@ internal abstract class ResourceDao { suspend fun updateAndIndexRemoteVersionIdAndLastUpdate( resourceId: String, resourceType: ResourceType, - versionId: String, - lastUpdated: Instant, + versionId: String?, + lastUpdatedRemote: Instant?, ) { - updateRemoteVersionIdAndLastUpdate(resourceId, resourceType, versionId, lastUpdated) - // update the remote lastUpdated index - getResourceEntity(resourceId, resourceType)?.let { - val indicesToUpdate = - ResourceIndices.Builder(resourceType, resourceId) - .apply { - addDateTimeIndex( - createLastUpdatedIndex(resourceType, InstantType(Date.from(lastUpdated))), - ) - } - .build() - updateIndicesForResource(indicesToUpdate, resourceType, it.resourceUuid) + getResourceEntity(resourceId, resourceType)?.let { oldResourceEntity -> + val resource = iParser.parseResource(oldResourceEntity.serializedResource) as Resource + resource.updateMeta(versionId, lastUpdatedRemote) + updateResourceWithUuid(oldResourceEntity.resourceUuid, resource) } } 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 index ea8870b5f5..e946096eef 100644 --- 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 @@ -18,10 +18,11 @@ package com.google.android.fhir.sync.upload import com.google.android.fhir.LocalChangeToken import com.google.android.fhir.db.Database +import com.google.android.fhir.lastUpdated import com.google.android.fhir.sync.upload.request.UploadRequestGeneratorMode +import com.google.android.fhir.versionId import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.DomainResource -import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType import org.hl7.fhir.r4.model.codesystems.HttpVerb @@ -56,8 +57,12 @@ internal class DefaultResourceConsolidator(private val database: Database) : Res ) uploadRequestResult.successfulUploadResponseMappings.forEach { when (it) { - is BundleComponentUploadResponseMapping -> updateVersionIdAndLastUpdated(it.output) - is ResourceUploadResponseMapping -> updateVersionIdAndLastUpdated(it.output) + is BundleComponentUploadResponseMapping -> { + updateResourceMeta(it.output) + } + is ResourceUploadResponseMapping -> { + updateResourceMeta(it.output) + } } } } @@ -68,52 +73,57 @@ internal class DefaultResourceConsolidator(private val database: Database) : Res } } - 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: DomainResource) { - if (resource.hasMeta() && resource.meta.hasVersionId() && resource.meta.hasLastUpdated()) { + private suspend fun updateResourceMeta(response: Bundle.BundleEntryResponseComponent) { + response.resourceIdAndType?.let { (id, type) -> database.updateVersionIdAndLastUpdated( - resource.id, - resource.resourceType, - resource.meta.versionId, - resource.meta.lastUpdated.toInstant(), + id, + type, + response.etag?.let { getVersionFromETag(response.etag) }, + response.lastModified?.let { it.toInstant() }, ) } } + + private suspend fun updateResourceMeta(resource: DomainResource) { + database.updateVersionIdAndLastUpdated( + resource.id, + resource.resourceType, + resource.versionId, + resource.lastUpdated, + ) + } } internal class HttpPostResourceConsolidator(private val database: Database) : ResourceConsolidator { override suspend fun consolidate(uploadRequestResult: UploadRequestResult) = when (uploadRequestResult) { is UploadRequestResult.Success -> { - database.deleteUpdates( - LocalChangeToken( - uploadRequestResult.successfulUploadResponseMappings.flatMap { - it.localChanges.flatMap { localChange -> localChange.token.ids } - }, - ), - ) - uploadRequestResult.successfulUploadResponseMappings.forEach { - when (it) { + uploadRequestResult.successfulUploadResponseMappings.forEach { responseMapping -> + when (responseMapping) { is BundleComponentUploadResponseMapping -> { - // TODO https://github.com/google/android-fhir/issues/2499 - throw NotImplementedError() + responseMapping.localChanges.firstOrNull()?.resourceId?.let { preSyncResourceId -> + database.deleteUpdates( + LocalChangeToken( + responseMapping.localChanges.flatMap { localChange -> localChange.token.ids }, + ), + ) + updateResourcePostSync( + preSyncResourceId, + responseMapping.output, + ) + } } is ResourceUploadResponseMapping -> { - val preSyncResourceId = it.localChanges.firstOrNull()?.resourceId - preSyncResourceId?.let { preSyncResourceId -> - updateResourcePostSync(preSyncResourceId, it.output) + database.deleteUpdates( + LocalChangeToken( + responseMapping.localChanges.flatMap { localChange -> localChange.token.ids }, + ), + ) + responseMapping.localChanges.firstOrNull()?.resourceId?.let { preSyncResourceId -> + database.updateResourceAndReferences( + preSyncResourceId, + responseMapping.output, + ) } } } @@ -128,16 +138,15 @@ internal class HttpPostResourceConsolidator(private val database: Database) : Re private suspend fun updateResourcePostSync( preSyncResourceId: String, - postSyncResource: Resource, + response: Bundle.BundleEntryResponseComponent, ) { - if ( - postSyncResource.hasMeta() && - postSyncResource.meta.hasVersionId() && - postSyncResource.meta.hasLastUpdated() - ) { - database.updateResourceAndReferences( + response.resourceIdAndType?.let { (postSyncResourceID, resourceType) -> + database.updateResourcePostSync( preSyncResourceId, - postSyncResource, + postSyncResourceID, + resourceType, + response.etag?.let { getVersionFromETag(response.etag) }, + response.lastModified?.let { response.lastModified.toInstant() }, ) } } @@ -165,7 +174,7 @@ private fun getVersionFromETag(eTag: String) = * 1. absolute path: `///_history/` * 2. relative path: `//_history/` */ -private val Bundle.BundleEntryResponseComponent.resourceIdAndType: Pair? +internal val Bundle.BundleEntryResponseComponent.resourceIdAndType: Pair? get() = location ?.split("/") diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/UploadStrategy.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/UploadStrategy.kt index a926aac516..cb23ce6d3a 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/upload/UploadStrategy.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/UploadStrategy.kt @@ -90,7 +90,7 @@ private constructor( * Not yet implemented - Fetches all local changes, generates one patch per resource, and uploads * them in a single bundled POST request. */ - private object AllChangesSquashedBundlePost : + object AllChangesSquashedBundlePost : UploadStrategy( LocalChangesFetchMode.AllChanges, PatchGeneratorMode.PerResource, diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/request/BundleEntryComponentGeneratorImplementations.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/request/BundleEntryComponentGeneratorImplementations.kt index 28486b559d..9997b473d6 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/upload/request/BundleEntryComponentGeneratorImplementations.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/request/BundleEntryComponentGeneratorImplementations.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 2023-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -31,6 +31,13 @@ internal class HttpPutForCreateEntryComponentGenerator(useETagForUpload: Boolean } } +internal class HttpPostForCreateEntryComponentGenerator(useETagForUpload: Boolean) : + BundleEntryComponentGenerator(Bundle.HTTPVerb.POST, useETagForUpload) { + override fun getEntryResource(patch: Patch): IBaseResource { + return FhirContext.forCached(FhirVersionEnum.R4).newJsonParser().parseResource(patch.payload) + } +} + internal class HttpPatchForUpdateEntryComponentGenerator(useETagForUpload: Boolean) : BundleEntryComponentGenerator(Bundle.HTTPVerb.PATCH, useETagForUpload) { override fun getEntryResource(patch: Patch): IBaseResource { 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 d622eee5d9..91735dd616 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 @@ -97,6 +97,7 @@ internal class TransactionBundleGenerator( private val createMapping = mapOf( Bundle.HTTPVerb.PUT to this::putForCreateBasedBundleComponentMapper, + Bundle.HTTPVerb.POST to this::postForCreateBasedBundleComponentMapper, ) private val updateMapping = @@ -143,6 +144,10 @@ internal class TransactionBundleGenerator( useETagForUpload: Boolean, ): BundleEntryComponentGenerator = HttpPutForCreateEntryComponentGenerator(useETagForUpload) + private fun postForCreateBasedBundleComponentMapper( + useETagForUpload: Boolean, + ): BundleEntryComponentGenerator = HttpPostForCreateEntryComponentGenerator(useETagForUpload) + private fun patchForUpdateBasedBundleComponentMapper( useETagForUpload: Boolean, ): BundleEntryComponentGenerator = HttpPatchForUpdateEntryComponentGenerator(useETagForUpload) diff --git a/engine/src/test/java/com/google/android/fhir/MoreResourcesTest.kt b/engine/src/test/java/com/google/android/fhir/MoreResourcesTest.kt index 9290dacedc..acec8ff664 100644 --- a/engine/src/test/java/com/google/android/fhir/MoreResourcesTest.kt +++ b/engine/src/test/java/com/google/android/fhir/MoreResourcesTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,6 +18,10 @@ package com.google.android.fhir import android.os.Build import com.google.common.truth.Truth.assertThat +import java.time.Instant +import java.util.* +import org.hl7.fhir.r4.model.InstantType +import org.hl7.fhir.r4.model.Meta import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType @@ -43,4 +47,37 @@ class MoreResourcesTest { fun `getResourceClass() by resource type should return resource class`() { assertThat(getResourceClass(ResourceType.Patient)).isEqualTo(Patient::class.java) } + + @Test + fun `updateMeta should update resource meta with given versionId and lastUpdated`() { + val versionId = "1" + val instantValue = Instant.now() + val resource = Patient().apply { id = "patient" } + + resource.updateMeta(versionId, instantValue) + + assertThat(resource.meta.versionId).isEqualTo(versionId) + assertThat(resource.meta.lastUpdatedElement.value) + .isEqualTo(InstantType(Date.from(instantValue)).value) + } + + @Test + fun `updateMeta should not change existing meta if new values are null`() { + val versionId = "1" + val instantValue = InstantType(Date.from(Instant.now())) + val resource = + Patient().apply { + id = "patient" + meta = + Meta().apply { + this.versionId = versionId + lastUpdatedElement = instantValue + } + } + + resource.updateMeta(null, null) + + assertThat(resource.meta.versionId).isEqualTo(versionId) + assertThat(resource.meta.lastUpdatedElement.value).isEqualTo(instantValue.value) + } } 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 998ffcb4e5..a4050cb791 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 @@ -103,6 +103,23 @@ class TransactionBundleGeneratorTest { } } + @Test + fun `getGenerator() should not throw exception for create by POST`() = runBlocking { + val exception = + kotlin + .runCatching { + TransactionBundleGenerator.Factory.getGenerator( + Bundle.HTTPVerb.POST, + Bundle.HTTPVerb.PATCH, + generatedBundleSize = 500, + useETagForUpload = true, + ) + } + .exceptionOrNull() + + assert(exception !is IllegalArgumentException) { "IllegalArgumentException was thrown" } + } + @Test fun `generate() should return Bundle Entry without if-match when useETagForUpload is false`() = runBlocking { @@ -259,22 +276,6 @@ class TransactionBundleGeneratorTest { 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 =