From a46ce6fe48c1c783e623de831dae828e55e0c7dd Mon Sep 17 00:00:00 2001 From: anchita-g <109063673+anchita-g@users.noreply.github.com> Date: Tue, 26 Sep 2023 20:00:44 +0530 Subject: [PATCH 01/21] WIP --- .../6.json | 22 ++++++- .../db/impl/ResourceDatabaseMigrationTest.kt | 59 ++++++++++++++++++- .../android/fhir/db/impl/DatabaseImpl.kt | 36 ++++++----- .../android/fhir/db/impl/ResourceDatabase.kt | 22 +++++++ .../fhir/db/impl/dao/LocalChangeDao.kt | 13 +++- .../android/fhir/db/impl/dao/ResourceDao.kt | 29 +++++---- .../db/impl/entities/LocalChangeEntity.kt | 9 ++- .../google/android/fhir/LocalChangeTest.kt | 2 + .../fhir/sync/upload/UploaderImplTest.kt | 2 + .../patch/PerResourcePatchGeneratorTest.kt | 10 ++++ 10 files changed, 170 insertions(+), 34 deletions(-) diff --git a/engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/6.json b/engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/6.json index 50b7621d02..84ea240cbc 100644 --- a/engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/6.json +++ b/engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/6.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 6, - "identityHash": "619e476379cf20ce4d991173d7166b9a", + "identityHash": "f06ae2e84f7588be59916d32f8d5a6e4", "entities": [ { "tableName": "ResourceEntity", @@ -789,7 +789,7 @@ }, { "tableName": "LocalChangeEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceType` TEXT NOT NULL, `resourceId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `type` INTEGER NOT NULL, `payload` TEXT NOT NULL, `versionId` TEXT)", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceType` TEXT NOT NULL, `resourceId` TEXT NOT NULL, `resourceUuid` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, `type` INTEGER NOT NULL, `payload` TEXT NOT NULL, `versionId` TEXT)", "fields": [ { "fieldPath": "id", @@ -809,6 +809,12 @@ "affinity": "TEXT", "notNull": true }, + { + "fieldPath": "resourceUuid", + "columnName": "resourceUuid", + "affinity": "BLOB", + "notNull": true + }, { "fieldPath": "timestamp", "columnName": "timestamp", @@ -850,6 +856,16 @@ ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_LocalChangeEntity_resourceType_resourceId` ON `${TABLE_NAME}` (`resourceType`, `resourceId`)" + }, + { + "name": "index_LocalChangeEntity_resourceType_resourceUuid", + "unique": false, + "columnNames": [ + "resourceType", + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LocalChangeEntity_resourceType_resourceUuid` ON `${TABLE_NAME}` (`resourceType`, `resourceUuid`)" } ], "foreignKeys": [] @@ -935,7 +951,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '619e476379cf20ce4d991173d7166b9a')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f06ae2e84f7588be59916d32f8d5a6e4')" ] } } \ No newline at end of file diff --git a/engine/src/androidTest/java/com/google/android/fhir/db/impl/ResourceDatabaseMigrationTest.kt b/engine/src/androidTest/java/com/google/android/fhir/db/impl/ResourceDatabaseMigrationTest.kt index f5b069c960..b295dcd380 100644 --- a/engine/src/androidTest/java/com/google/android/fhir/db/impl/ResourceDatabaseMigrationTest.kt +++ b/engine/src/androidTest/java/com/google/android/fhir/db/impl/ResourceDatabaseMigrationTest.kt @@ -22,11 +22,14 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import ca.uhn.fhir.context.FhirContext import com.google.android.fhir.db.impl.entities.LocalChangeEntity +import com.google.android.fhir.db.impl.entities.ResourceEntity import com.google.android.fhir.toTimeZoneString import com.google.common.truth.Truth.assertThat import java.io.IOException +import java.nio.ByteBuffer import java.time.Instant import java.util.Date +import java.util.UUID import kotlinx.coroutines.runBlocking import org.hl7.fhir.r4.model.HumanName import org.hl7.fhir.r4.model.Patient @@ -235,13 +238,67 @@ class ResourceDatabaseMigrationTest { assertThat(Instant.ofEpochMilli(localChangeEntityCorruptedTimeStamp)).isEqualTo(Instant.EPOCH) } + @Test + fun migrate6To7_should_execute_with_no_exception(): Unit = runBlocking { + val taskId = "bed-net-001" + val taskResourceUuid = "e2c79e28-ed4d-4029-a12c-108d1eb5bedb" + val bedNetTask: String = + Task() + .apply { + id = taskId + description = "Issue bed net" + meta.lastUpdated = Date() + } + .let { iParser.encodeResourceToString(it) } + + helper.createDatabase(DB_NAME, 6).apply { + val date = Date() + execSQL( + "INSERT INTO ResourceEntity (resourceUuid, resourceType, resourceId, serializedResource, lastUpdatedLocal) VALUES ('$taskResourceUuid', 'Task', '$taskId', '$bedNetTask', '${DbTypeConverters.instantToLong(date.toInstant())}' );", + ) + + execSQL( + "INSERT INTO LocalChangeEntity (resourceType, resourceId, timestamp, type, payload) VALUES ('Task', '$taskId', '${date.toTimeZoneString()}', '${DbTypeConverters.localChangeTypeToInt(LocalChangeEntity.Type.INSERT)}', '$bedNetTask' );", + ) + close() + } + + helper.runMigrationsAndValidate(DB_NAME, 6, true, MIGRATION_6_7) + + val retrievedTask: ResourceEntity? + val localChangeUuid: UUID? + + getMigratedRoomDatabase().apply { + retrievedTask = this.resourceDao().getResourceEntity(taskResourceUuid, ResourceType.Task) + + query("SELECT resourceUuid FROM LocalChangeEntity", null).let { + it.moveToFirst() + it.moveToNext() + val byteBuffer = ByteBuffer.wrap(it.getBlob(0)) + localChangeUuid = UUID(byteBuffer.getLong(), byteBuffer.getLong()) + } + + openHelper.writableDatabase.close() + } + + assertThat(retrievedTask!!.resourceUuid).isEqualTo(taskResourceUuid) + assertThat(localChangeUuid).isEqualTo(taskResourceUuid) + } + private fun getMigratedRoomDatabase(): ResourceDatabase = Room.databaseBuilder( InstrumentationRegistry.getInstrumentation().targetContext, ResourceDatabase::class.java, DB_NAME, ) - .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6) + .addMigrations( + MIGRATION_1_2, + MIGRATION_2_3, + MIGRATION_3_4, + MIGRATION_4_5, + MIGRATION_5_6, + MIGRATION_6_7, + ) .build() companion object { 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 8ca9fff23c..b45d1e5c8f 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 @@ -95,7 +95,14 @@ internal class DatabaseImpl( } } - addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6) + addMigrations( + MIGRATION_1_2, + MIGRATION_2_3, + MIGRATION_3_4, + MIGRATION_4_5, + MIGRATION_5_6, + MIGRATION_6_7 + ) } .build() } @@ -115,8 +122,10 @@ internal class DatabaseImpl( logicalIds.addAll( resource.map { val timeOfLocalChange = Instant.now() - localChangeDao.addInsert(it, timeOfLocalChange) - resourceDao.insertLocalResource(it, timeOfLocalChange) + val resourceId = resourceDao.insertLocalResource(it, timeOfLocalChange) + val resourceEntity = selectEntity(it.resourceType, it.logicalId) + localChangeDao.addInsert(it, resourceEntity.resourceUuid, timeOfLocalChange) + resourceId }, ) } @@ -169,19 +178,16 @@ internal class DatabaseImpl( override suspend fun delete(type: ResourceType, id: String) { db.withTransaction { - val remoteVersionId: String? = - try { - selectEntity(type, id).versionId - } catch (e: ResourceNotFoundException) { - null + resourceDao.getResourceEntity(id, type)?.let { + val rowsDeleted = resourceDao.deleteResource(resourceId = id, resourceType = type) + if (rowsDeleted > 0) { + localChangeDao.addDelete( + resourceId = id, + resourceType = type, + resourceUuid = it.resourceUuid, + remoteVersionId = it.versionId, + ) } - val rowsDeleted = resourceDao.deleteResource(resourceId = id, resourceType = type) - if (rowsDeleted > 0) { - localChangeDao.addDelete( - resourceId = id, - resourceType = type, - remoteVersionId = remoteVersionId, - ) } } } diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt b/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt index 0878809ab3..1f4c661a36 100644 --- a/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt +++ b/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt @@ -127,3 +127,25 @@ val MIGRATION_5_6 = ) } } + +/** Add column resourceUuid in [LocalChangeEntity] */ +val MIGRATION_6_7 = + object : Migration(6, 7) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + "CREATE TABLE IF NOT EXISTS `_new_LocalChangeEntity` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceType` TEXT NOT NULL, `resourceId` TEXT NOT NULL, `resourceId` UUID NOT NULL, `timestamp` INTEGER NOT NULL, `type` INTEGER NOT NULL, `payload` TEXT NOT NULL, `versionId` TEXT)", + ) + database.execSQL( + "INSERT INTO `_new_LocalChangeEntity` (`id`,`resourceType`,`resourceId`,`resourceUuid`,`timestamp`,`type`,`payload`,`versionId`) " + + "SELECT localChange.id, localChange.resourceType, localChange.resourceId, resource.resourceUuid, localChange.timestamp, localChange.type, localChange.payload, localChange.versionId FROM `LocalChangeEntity` localChange LEFT JOIN ResourceEntity resource ON localChange.resourceId= resource.resourceId", + ) + database.execSQL("DROP TABLE `LocalChangeEntity`") + database.execSQL("ALTER TABLE `_new_LocalChangeEntity` RENAME TO `LocalChangeEntity`") + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_LocalChangeEntity_resourceType_resourceId` ON `LocalChangeEntity` (`resourceType`, `resourceId`)", + ) + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_LocalChangeEntity_resourceType_resourceUuid` ON `LocalChangeEntity` (`resourceType`, `resourceUuid`)", + ) + } + } 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 d69684a9bd..ecd24b4ece 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 @@ -32,6 +32,7 @@ import com.google.android.fhir.logicalId import com.google.android.fhir.versionId import java.time.Instant import java.util.Date +import java.util.UUID import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType import org.json.JSONArray @@ -51,7 +52,7 @@ internal abstract class LocalChangeDao { @Insert abstract suspend fun addLocalChange(localChangeEntity: LocalChangeEntity) @Transaction - open suspend fun addInsert(resource: Resource, timeOfLocalChange: Instant) { + open suspend fun addInsert(resource: Resource, resourceUuid: UUID, timeOfLocalChange: Instant) { val resourceId = resource.logicalId val resourceType = resource.resourceType val resourceString = iParser.encodeResourceToString(resource) @@ -61,6 +62,7 @@ internal abstract class LocalChangeDao { id = 0, resourceType = resourceType.name, resourceId = resourceId, + resourceUuid = resourceUuid, timestamp = timeOfLocalChange, type = Type.INSERT, payload = resourceString, @@ -95,6 +97,7 @@ internal abstract class LocalChangeDao { id = 0, resourceType = resourceType.name, resourceId = resourceId, + resourceUuid = oldEntity.resourceUuid, timestamp = timeOfLocalChange, type = Type.UPDATE, payload = jsonDiff.toString(), @@ -103,12 +106,18 @@ internal abstract class LocalChangeDao { ) } - suspend fun addDelete(resourceId: String, resourceType: ResourceType, remoteVersionId: String?) { + suspend fun addDelete( + resourceId: String, + resourceUuid: UUID, + resourceType: ResourceType, + remoteVersionId: String?, + ) { addLocalChange( LocalChangeEntity( id = 0, resourceType = resourceType.name, resourceId = resourceId, + resourceUuid = resourceUuid, timestamp = Date().toInstant(), type = Type.DELETE, payload = "", 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 f3518d0fca..4636957bff 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 @@ -58,7 +58,7 @@ internal abstract class ResourceDao { lateinit var iParser: IParser lateinit var resourceIndexer: ResourceIndexer - open suspend fun update(resource: Resource, timeOfLocalChange: Instant) { + open suspend fun update(resource: Resource, timeOfLocalChange: Instant?) { getResourceEntity(resource.logicalId, resource.resourceType)?.let { // In case the resource has lastUpdated meta data, use it, otherwise use the old value. val lastUpdatedRemote: Date? = resource.meta.lastUpdated @@ -76,12 +76,14 @@ internal abstract class ResourceDao { val index = ResourceIndices.Builder(resourceIndexer.index(resource)) .apply { - addDateTimeIndex( - createLocalLastUpdatedIndex( - resource.resourceType, - InstantType(Date.from(timeOfLocalChange)), - ), - ) + timeOfLocalChange?.let { + addDateTimeIndex( + createLocalLastUpdatedIndex( + resource.resourceType, + InstantType(Date.from(timeOfLocalChange)), + ), + ) + } lastUpdatedRemote?.let { date -> addDateTimeIndex(createLastUpdatedIndex(resource.resourceType, InstantType(date))) } @@ -183,11 +185,14 @@ internal abstract class ResourceDao { // Since the insert removes any old indexes and lastUpdatedLocal (data not contained in resource // itself), we extract the lastUpdatedLocal if any and then set it back again. - private suspend fun insertRemoteResource(resource: Resource) = - insertResource( - resource, - getResourceEntity(resource.logicalId, resource.resourceType)?.lastUpdatedLocal, - ) + private suspend fun insertRemoteResource(resource: Resource): String { + val existingResourceEntity = getResourceEntity(resource.logicalId, resource.resourceType) + if (existingResourceEntity != null) { + update(resource, existingResourceEntity.lastUpdatedLocal) + return resource.id + } + return insertResource(resource, null) + } private suspend fun insertResource(resource: Resource, lastUpdatedLocal: Instant?): String { val resourceUuid = UUID.randomUUID() diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeEntity.kt b/engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeEntity.kt index 2bd519933f..ac7e0ba4af 100644 --- a/engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeEntity.kt +++ b/engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeEntity.kt @@ -20,6 +20,7 @@ import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey import java.time.Instant +import java.util.UUID /** * When a local change to a resource happens, the lastUpdated timestamp in [ResourceEntity] is @@ -55,11 +56,17 @@ import java.time.Instant * * ] For resource that is fully synced with server this table should not have any rows. */ -@Entity(indices = [Index(value = ["resourceType", "resourceId"])]) +@Entity( + indices = + [ + Index(value = ["resourceType", "resourceId"]), Index(value = ["resourceType", "resourceUuid"]) + ], +) internal data class LocalChangeEntity( @PrimaryKey(autoGenerate = true) val id: Long, val resourceType: String, val resourceId: String, + val resourceUuid: UUID, val timestamp: Instant, val type: Type, val payload: String, diff --git a/engine/src/test/java/com/google/android/fhir/LocalChangeTest.kt b/engine/src/test/java/com/google/android/fhir/LocalChangeTest.kt index 6e25bd6779..9e4e8d74d1 100644 --- a/engine/src/test/java/com/google/android/fhir/LocalChangeTest.kt +++ b/engine/src/test/java/com/google/android/fhir/LocalChangeTest.kt @@ -22,6 +22,7 @@ import ca.uhn.fhir.parser.IParser import com.google.android.fhir.db.impl.entities.LocalChangeEntity import com.google.common.truth.Truth.assertThat import java.time.Instant +import java.util.UUID import junit.framework.TestCase import kotlinx.coroutines.runBlocking import org.hl7.fhir.r4.model.HumanName @@ -42,6 +43,7 @@ class LocalChangeTest : TestCase() { LocalChangeEntity( id = 1, resourceType = ResourceType.Patient.name, + resourceUuid = UUID.randomUUID(), resourceId = "Patient-001", type = LocalChangeEntity.Type.INSERT, payload = 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 f991679c6a..7a82d10740 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 @@ -25,6 +25,7 @@ import com.google.android.fhir.toLocalChange import com.google.common.truth.Truth.assertThat import java.net.ConnectException import java.time.Instant +import java.util.UUID import kotlinx.coroutines.flow.toList import kotlinx.coroutines.runBlocking import org.hl7.fhir.r4.model.Bundle @@ -133,6 +134,7 @@ class UploaderImplTest { LocalChangeEntity( id = 1, resourceType = ResourceType.Patient.name, + resourceUuid = UUID.randomUUID(), resourceId = "Patient-001", type = LocalChangeEntity.Type.INSERT, payload = 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 78d365ec02..f111e2d114 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 @@ -32,6 +32,7 @@ import com.google.android.fhir.versionId import com.google.common.truth.Truth.assertThat import java.time.Instant import java.util.Date +import java.util.UUID import org.hl7.fhir.r4.model.HumanName import org.hl7.fhir.r4.model.Meta import org.hl7.fhir.r4.model.Patient @@ -135,6 +136,7 @@ class PerResourcePatchGeneratorTest { id = 1, resourceType = ResourceType.Patient.name, resourceId = "Patient-001", + resourceUuid = UUID.randomUUID(), type = LocalChangeEntity.Type.INSERT, payload = FhirContext.forCached(FhirVersionEnum.R4) @@ -158,6 +160,7 @@ class PerResourcePatchGeneratorTest { id = 2, resourceType = ResourceType.Patient.name, resourceId = "Patient-001", + resourceUuid = UUID.randomUUID(), type = LocalChangeEntity.Type.DELETE, payload = "", timestamp = Instant.now(), @@ -178,6 +181,7 @@ class PerResourcePatchGeneratorTest { id = 1, resourceType = ResourceType.Patient.name, resourceId = "Patient-001", + resourceUuid = UUID.randomUUID(), type = LocalChangeEntity.Type.INSERT, payload = FhirContext.forCached(FhirVersionEnum.R4) @@ -201,6 +205,7 @@ class PerResourcePatchGeneratorTest { id = 2, resourceType = ResourceType.Patient.name, resourceId = "Patient-001", + resourceUuid = UUID.randomUUID(), type = LocalChangeEntity.Type.UPDATE, payload = diff( @@ -233,6 +238,7 @@ class PerResourcePatchGeneratorTest { id = 3, resourceType = ResourceType.Patient.name, resourceId = "Patient-001", + resourceUuid = UUID.randomUUID(), type = LocalChangeEntity.Type.DELETE, payload = "", timestamp = Instant.now(), @@ -314,6 +320,7 @@ class PerResourcePatchGeneratorTest { resourceId = "Patient-001", type = LocalChangeEntity.Type.DELETE, payload = "", + resourceUuid = UUID.randomUUID(), timestamp = Instant.now(), ) .toLocalChange() @@ -324,6 +331,7 @@ class PerResourcePatchGeneratorTest { resourceId = "Patient-001", type = LocalChangeEntity.Type.UPDATE, payload = "", + resourceUuid = UUID.randomUUID(), timestamp = Instant.now(), ) .toLocalChange() @@ -349,6 +357,7 @@ class PerResourcePatchGeneratorTest { resourceId = "Patient-001", type = LocalChangeEntity.Type.UPDATE, payload = "", + resourceUuid = UUID.randomUUID(), timestamp = Instant.now(), ) .toLocalChange() @@ -357,6 +366,7 @@ class PerResourcePatchGeneratorTest { id = 1, resourceType = ResourceType.Patient.name, resourceId = "Patient-001", + resourceUuid = UUID.randomUUID(), type = LocalChangeEntity.Type.INSERT, payload = FhirContext.forCached(FhirVersionEnum.R4) From ccbdfec24626e3970bd67dbc6d8d0938e156841b Mon Sep 17 00:00:00 2001 From: anchita-g <109063673+anchita-g@users.noreply.github.com> Date: Wed, 27 Sep 2023 10:52:57 +0530 Subject: [PATCH 02/21] test cases --- .../android/fhir/db/impl/DatabaseImplTest.kt | 15 +++++++++++++++ .../google/android/fhir/db/impl/DatabaseImpl.kt | 2 +- .../android/fhir/db/impl/ResourceDatabase.kt | 2 +- .../fhir/db/impl/entities/LocalChangeEntity.kt | 3 ++- 4 files changed, 19 insertions(+), 3 deletions(-) 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 2e6055fd82..2809b79bad 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 @@ -467,6 +467,21 @@ class DatabaseImplTest { .isTrue() } + @Test + fun insert_existingRemoteResource_shouldNotChangeResourceEntityUuidOrId() = runBlocking { + val patient: Patient = readFromFile(Patient::class.java, "/date_test_patient.json") + database.insertRemote(patient) + val patientEntityAfterFirstRemoteSync = + database.selectEntity(ResourceType.Patient, patient.logicalId) + database.insertRemote(patient) + val patientEntityAfterSecondRemoteSync = + database.selectEntity(ResourceType.Patient, patient.logicalId) + assertThat(patientEntityAfterSecondRemoteSync.resourceUuid) + .isEqualTo(patientEntityAfterFirstRemoteSync.resourceUuid) + assertThat(patientEntityAfterSecondRemoteSync.id) + .isEqualTo(patientEntityAfterFirstRemoteSync.id) + } + @Test fun insert_remoteResource_shouldSaveVersionIdAndLastUpdated() = runBlocking { val patient = 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 b45d1e5c8f..faf4050cd4 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 @@ -101,7 +101,7 @@ internal class DatabaseImpl( MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, - MIGRATION_6_7 + MIGRATION_6_7, ) } .build() diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt b/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt index 1f4c661a36..4ede9295c6 100644 --- a/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt +++ b/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt @@ -133,7 +133,7 @@ val MIGRATION_6_7 = object : Migration(6, 7) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL( - "CREATE TABLE IF NOT EXISTS `_new_LocalChangeEntity` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceType` TEXT NOT NULL, `resourceId` TEXT NOT NULL, `resourceId` UUID NOT NULL, `timestamp` INTEGER NOT NULL, `type` INTEGER NOT NULL, `payload` TEXT NOT NULL, `versionId` TEXT)", + "CREATE TABLE IF NOT EXISTS `_new_LocalChangeEntity` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceType` TEXT NOT NULL, `resourceId` TEXT NOT NULL, `resourceUuid` UUID NOT NULL, `timestamp` INTEGER NOT NULL, `type` INTEGER NOT NULL, `payload` TEXT NOT NULL, `versionId` TEXT)", ) database.execSQL( "INSERT INTO `_new_LocalChangeEntity` (`id`,`resourceType`,`resourceId`,`resourceUuid`,`timestamp`,`type`,`payload`,`versionId`) " + diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeEntity.kt b/engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeEntity.kt index ac7e0ba4af..29955365fb 100644 --- a/engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeEntity.kt +++ b/engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeEntity.kt @@ -59,7 +59,8 @@ import java.util.UUID @Entity( indices = [ - Index(value = ["resourceType", "resourceId"]), Index(value = ["resourceType", "resourceUuid"]) + Index(value = ["resourceType", "resourceId"]), + Index(value = ["resourceType", "resourceUuid"]), ], ) internal data class LocalChangeEntity( From e1c351caee8360152ce7e722b427df246ce6f17b Mon Sep 17 00:00:00 2001 From: anchita-g <109063673+anchita-g@users.noreply.github.com> Date: Tue, 26 Sep 2023 20:00:44 +0530 Subject: [PATCH 03/21] Adding resource UUID in LocalChangeEntity --- .../7.json | 957 ++++++++++++++++++ .../android/fhir/db/impl/DatabaseImplTest.kt | 15 + .../db/impl/ResourceDatabaseMigrationTest.kt | 65 +- .../android/fhir/db/impl/DatabaseImpl.kt | 36 +- .../android/fhir/db/impl/ResourceDatabase.kt | 24 +- .../fhir/db/impl/dao/LocalChangeDao.kt | 13 +- .../android/fhir/db/impl/dao/ResourceDao.kt | 29 +- .../db/impl/entities/LocalChangeEntity.kt | 10 +- .../google/android/fhir/LocalChangeTest.kt | 2 + .../fhir/sync/upload/UploaderImplTest.kt | 2 + .../patch/PerResourcePatchGeneratorTest.kt | 10 + 11 files changed, 1129 insertions(+), 34 deletions(-) create mode 100644 engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/7.json diff --git a/engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/7.json b/engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/7.json new file mode 100644 index 0000000000..0abe3607f1 --- /dev/null +++ b/engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/7.json @@ -0,0 +1,957 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "f06ae2e84f7588be59916d32f8d5a6e4", + "entities": [ + { + "tableName": "ResourceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceUuid` BLOB NOT NULL, `resourceType` TEXT NOT NULL, `resourceId` TEXT NOT NULL, `serializedResource` TEXT NOT NULL, `versionId` TEXT, `lastUpdatedRemote` INTEGER, `lastUpdatedLocal` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUuid", + "columnName": "resourceUuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "resourceId", + "columnName": "resourceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serializedResource", + "columnName": "serializedResource", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionId", + "columnName": "versionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdatedRemote", + "columnName": "lastUpdatedRemote", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastUpdatedLocal", + "columnName": "lastUpdatedLocal", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_ResourceEntity_resourceUuid", + "unique": true, + "columnNames": [ + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ResourceEntity_resourceUuid` ON `${TABLE_NAME}` (`resourceUuid`)" + }, + { + "name": "index_ResourceEntity_resourceType_resourceId", + "unique": true, + "columnNames": [ + "resourceType", + "resourceId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ResourceEntity_resourceType_resourceId` ON `${TABLE_NAME}` (`resourceType`, `resourceId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "StringIndexEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceUuid` BLOB NOT NULL, `resourceType` TEXT NOT NULL, `index_name` TEXT NOT NULL, `index_path` TEXT NOT NULL, `index_value` TEXT NOT NULL, FOREIGN KEY(`resourceUuid`) REFERENCES `ResourceEntity`(`resourceUuid`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUuid", + "columnName": "resourceUuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.name", + "columnName": "index_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.path", + "columnName": "index_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.value", + "columnName": "index_value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_StringIndexEntity_resourceType_index_name_index_value", + "unique": false, + "columnNames": [ + "resourceType", + "index_name", + "index_value" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_StringIndexEntity_resourceType_index_name_index_value` ON `${TABLE_NAME}` (`resourceType`, `index_name`, `index_value`)" + }, + { + "name": "index_StringIndexEntity_resourceUuid", + "unique": false, + "columnNames": [ + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_StringIndexEntity_resourceUuid` ON `${TABLE_NAME}` (`resourceUuid`)" + } + ], + "foreignKeys": [ + { + "table": "ResourceEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "resourceUuid" + ], + "referencedColumns": [ + "resourceUuid" + ] + } + ] + }, + { + "tableName": "ReferenceIndexEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceUuid` BLOB NOT NULL, `resourceType` TEXT NOT NULL, `index_name` TEXT NOT NULL, `index_path` TEXT NOT NULL, `index_value` TEXT NOT NULL, FOREIGN KEY(`resourceUuid`) REFERENCES `ResourceEntity`(`resourceUuid`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUuid", + "columnName": "resourceUuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.name", + "columnName": "index_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.path", + "columnName": "index_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.value", + "columnName": "index_value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_ReferenceIndexEntity_resourceType_index_name_index_value", + "unique": false, + "columnNames": [ + "resourceType", + "index_name", + "index_value" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ReferenceIndexEntity_resourceType_index_name_index_value` ON `${TABLE_NAME}` (`resourceType`, `index_name`, `index_value`)" + }, + { + "name": "index_ReferenceIndexEntity_resourceUuid", + "unique": false, + "columnNames": [ + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ReferenceIndexEntity_resourceUuid` ON `${TABLE_NAME}` (`resourceUuid`)" + } + ], + "foreignKeys": [ + { + "table": "ResourceEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "resourceUuid" + ], + "referencedColumns": [ + "resourceUuid" + ] + } + ] + }, + { + "tableName": "TokenIndexEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceUuid` BLOB NOT NULL, `resourceType` TEXT NOT NULL, `index_name` TEXT NOT NULL, `index_path` TEXT NOT NULL, `index_system` TEXT, `index_value` TEXT NOT NULL, FOREIGN KEY(`resourceUuid`) REFERENCES `ResourceEntity`(`resourceUuid`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUuid", + "columnName": "resourceUuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.name", + "columnName": "index_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.path", + "columnName": "index_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.system", + "columnName": "index_system", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "index.value", + "columnName": "index_value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_TokenIndexEntity_resourceType_index_name_index_system_index_value_resourceUuid", + "unique": false, + "columnNames": [ + "resourceType", + "index_name", + "index_system", + "index_value", + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TokenIndexEntity_resourceType_index_name_index_system_index_value_resourceUuid` ON `${TABLE_NAME}` (`resourceType`, `index_name`, `index_system`, `index_value`, `resourceUuid`)" + }, + { + "name": "index_TokenIndexEntity_resourceUuid", + "unique": false, + "columnNames": [ + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TokenIndexEntity_resourceUuid` ON `${TABLE_NAME}` (`resourceUuid`)" + } + ], + "foreignKeys": [ + { + "table": "ResourceEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "resourceUuid" + ], + "referencedColumns": [ + "resourceUuid" + ] + } + ] + }, + { + "tableName": "QuantityIndexEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceUuid` BLOB NOT NULL, `resourceType` TEXT NOT NULL, `index_name` TEXT NOT NULL, `index_path` TEXT NOT NULL, `index_system` TEXT NOT NULL, `index_code` TEXT NOT NULL, `index_value` REAL NOT NULL, FOREIGN KEY(`resourceUuid`) REFERENCES `ResourceEntity`(`resourceUuid`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUuid", + "columnName": "resourceUuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.name", + "columnName": "index_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.path", + "columnName": "index_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.system", + "columnName": "index_system", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.code", + "columnName": "index_code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.value", + "columnName": "index_value", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_QuantityIndexEntity_resourceType_index_name_index_value_index_code", + "unique": false, + "columnNames": [ + "resourceType", + "index_name", + "index_value", + "index_code" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_QuantityIndexEntity_resourceType_index_name_index_value_index_code` ON `${TABLE_NAME}` (`resourceType`, `index_name`, `index_value`, `index_code`)" + }, + { + "name": "index_QuantityIndexEntity_resourceUuid", + "unique": false, + "columnNames": [ + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_QuantityIndexEntity_resourceUuid` ON `${TABLE_NAME}` (`resourceUuid`)" + } + ], + "foreignKeys": [ + { + "table": "ResourceEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "resourceUuid" + ], + "referencedColumns": [ + "resourceUuid" + ] + } + ] + }, + { + "tableName": "UriIndexEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceUuid` BLOB NOT NULL, `resourceType` TEXT NOT NULL, `index_name` TEXT NOT NULL, `index_path` TEXT NOT NULL, `index_value` TEXT NOT NULL, FOREIGN KEY(`resourceUuid`) REFERENCES `ResourceEntity`(`resourceUuid`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUuid", + "columnName": "resourceUuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.name", + "columnName": "index_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.path", + "columnName": "index_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.value", + "columnName": "index_value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_UriIndexEntity_resourceType_index_name_index_value", + "unique": false, + "columnNames": [ + "resourceType", + "index_name", + "index_value" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UriIndexEntity_resourceType_index_name_index_value` ON `${TABLE_NAME}` (`resourceType`, `index_name`, `index_value`)" + }, + { + "name": "index_UriIndexEntity_resourceUuid", + "unique": false, + "columnNames": [ + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UriIndexEntity_resourceUuid` ON `${TABLE_NAME}` (`resourceUuid`)" + } + ], + "foreignKeys": [ + { + "table": "ResourceEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "resourceUuid" + ], + "referencedColumns": [ + "resourceUuid" + ] + } + ] + }, + { + "tableName": "DateIndexEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceUuid` BLOB NOT NULL, `resourceType` TEXT NOT NULL, `index_name` TEXT NOT NULL, `index_path` TEXT NOT NULL, `index_from` INTEGER NOT NULL, `index_to` INTEGER NOT NULL, FOREIGN KEY(`resourceUuid`) REFERENCES `ResourceEntity`(`resourceUuid`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUuid", + "columnName": "resourceUuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.name", + "columnName": "index_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.path", + "columnName": "index_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.from", + "columnName": "index_from", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "index.to", + "columnName": "index_to", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_DateIndexEntity_resourceType_index_name_resourceUuid_index_from_index_to", + "unique": false, + "columnNames": [ + "resourceType", + "index_name", + "resourceUuid", + "index_from", + "index_to" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DateIndexEntity_resourceType_index_name_resourceUuid_index_from_index_to` ON `${TABLE_NAME}` (`resourceType`, `index_name`, `resourceUuid`, `index_from`, `index_to`)" + }, + { + "name": "index_DateIndexEntity_resourceUuid", + "unique": false, + "columnNames": [ + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DateIndexEntity_resourceUuid` ON `${TABLE_NAME}` (`resourceUuid`)" + } + ], + "foreignKeys": [ + { + "table": "ResourceEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "resourceUuid" + ], + "referencedColumns": [ + "resourceUuid" + ] + } + ] + }, + { + "tableName": "DateTimeIndexEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceUuid` BLOB NOT NULL, `resourceType` TEXT NOT NULL, `index_name` TEXT NOT NULL, `index_path` TEXT NOT NULL, `index_from` INTEGER NOT NULL, `index_to` INTEGER NOT NULL, FOREIGN KEY(`resourceUuid`) REFERENCES `ResourceEntity`(`resourceUuid`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUuid", + "columnName": "resourceUuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.name", + "columnName": "index_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.path", + "columnName": "index_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.from", + "columnName": "index_from", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "index.to", + "columnName": "index_to", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_DateTimeIndexEntity_resourceType_index_name_resourceUuid_index_from_index_to", + "unique": false, + "columnNames": [ + "resourceType", + "index_name", + "resourceUuid", + "index_from", + "index_to" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DateTimeIndexEntity_resourceType_index_name_resourceUuid_index_from_index_to` ON `${TABLE_NAME}` (`resourceType`, `index_name`, `resourceUuid`, `index_from`, `index_to`)" + }, + { + "name": "index_DateTimeIndexEntity_resourceUuid", + "unique": false, + "columnNames": [ + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DateTimeIndexEntity_resourceUuid` ON `${TABLE_NAME}` (`resourceUuid`)" + } + ], + "foreignKeys": [ + { + "table": "ResourceEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "resourceUuid" + ], + "referencedColumns": [ + "resourceUuid" + ] + } + ] + }, + { + "tableName": "NumberIndexEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceUuid` BLOB NOT NULL, `resourceType` TEXT NOT NULL, `index_name` TEXT NOT NULL, `index_path` TEXT NOT NULL, `index_value` REAL NOT NULL, FOREIGN KEY(`resourceUuid`) REFERENCES `ResourceEntity`(`resourceUuid`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUuid", + "columnName": "resourceUuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.name", + "columnName": "index_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.path", + "columnName": "index_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.value", + "columnName": "index_value", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_NumberIndexEntity_resourceType_index_name_index_value", + "unique": false, + "columnNames": [ + "resourceType", + "index_name", + "index_value" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NumberIndexEntity_resourceType_index_name_index_value` ON `${TABLE_NAME}` (`resourceType`, `index_name`, `index_value`)" + }, + { + "name": "index_NumberIndexEntity_resourceUuid", + "unique": false, + "columnNames": [ + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NumberIndexEntity_resourceUuid` ON `${TABLE_NAME}` (`resourceUuid`)" + } + ], + "foreignKeys": [ + { + "table": "ResourceEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "resourceUuid" + ], + "referencedColumns": [ + "resourceUuid" + ] + } + ] + }, + { + "tableName": "LocalChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceType` TEXT NOT NULL, `resourceId` TEXT NOT NULL, `resourceUuid` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, `type` INTEGER NOT NULL, `payload` TEXT NOT NULL, `versionId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "resourceId", + "columnName": "resourceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "resourceUuid", + "columnName": "resourceUuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionId", + "columnName": "versionId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_LocalChangeEntity_resourceType_resourceId", + "unique": false, + "columnNames": [ + "resourceType", + "resourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LocalChangeEntity_resourceType_resourceId` ON `${TABLE_NAME}` (`resourceType`, `resourceId`)" + }, + { + "name": "index_LocalChangeEntity_resourceType_resourceUuid", + "unique": false, + "columnNames": [ + "resourceType", + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LocalChangeEntity_resourceType_resourceUuid` ON `${TABLE_NAME}` (`resourceType`, `resourceUuid`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PositionIndexEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceUuid` BLOB NOT NULL, `resourceType` TEXT NOT NULL, `index_latitude` REAL NOT NULL, `index_longitude` REAL NOT NULL, FOREIGN KEY(`resourceUuid`) REFERENCES `ResourceEntity`(`resourceUuid`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUuid", + "columnName": "resourceUuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.latitude", + "columnName": "index_latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "index.longitude", + "columnName": "index_longitude", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_PositionIndexEntity_resourceType_index_latitude_index_longitude", + "unique": false, + "columnNames": [ + "resourceType", + "index_latitude", + "index_longitude" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PositionIndexEntity_resourceType_index_latitude_index_longitude` ON `${TABLE_NAME}` (`resourceType`, `index_latitude`, `index_longitude`)" + }, + { + "name": "index_PositionIndexEntity_resourceUuid", + "unique": false, + "columnNames": [ + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PositionIndexEntity_resourceUuid` ON `${TABLE_NAME}` (`resourceUuid`)" + } + ], + "foreignKeys": [ + { + "table": "ResourceEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "resourceUuid" + ], + "referencedColumns": [ + "resourceUuid" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f06ae2e84f7588be59916d32f8d5a6e4')" + ] + } +} \ No newline at end of file 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 2e6055fd82..2809b79bad 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 @@ -467,6 +467,21 @@ class DatabaseImplTest { .isTrue() } + @Test + fun insert_existingRemoteResource_shouldNotChangeResourceEntityUuidOrId() = runBlocking { + val patient: Patient = readFromFile(Patient::class.java, "/date_test_patient.json") + database.insertRemote(patient) + val patientEntityAfterFirstRemoteSync = + database.selectEntity(ResourceType.Patient, patient.logicalId) + database.insertRemote(patient) + val patientEntityAfterSecondRemoteSync = + database.selectEntity(ResourceType.Patient, patient.logicalId) + assertThat(patientEntityAfterSecondRemoteSync.resourceUuid) + .isEqualTo(patientEntityAfterFirstRemoteSync.resourceUuid) + assertThat(patientEntityAfterSecondRemoteSync.id) + .isEqualTo(patientEntityAfterFirstRemoteSync.id) + } + @Test fun insert_remoteResource_shouldSaveVersionIdAndLastUpdated() = runBlocking { val patient = diff --git a/engine/src/androidTest/java/com/google/android/fhir/db/impl/ResourceDatabaseMigrationTest.kt b/engine/src/androidTest/java/com/google/android/fhir/db/impl/ResourceDatabaseMigrationTest.kt index f5b069c960..aeedc52620 100644 --- a/engine/src/androidTest/java/com/google/android/fhir/db/impl/ResourceDatabaseMigrationTest.kt +++ b/engine/src/androidTest/java/com/google/android/fhir/db/impl/ResourceDatabaseMigrationTest.kt @@ -163,7 +163,7 @@ class ResourceDatabaseMigrationTest { close() } - // Re-open the database with version 4 and provide MIGRATION_3_4 as the migration process. + // Re-open the database with version 5 and provide MIGRATION_4_5 as the migration process. helper.runMigrationsAndValidate(DB_NAME, 5, true, MIGRATION_4_5) val retrievedTask: String? @@ -213,7 +213,7 @@ class ResourceDatabaseMigrationTest { val localChangeEntityCorruptedTimeStamp: Long getMigratedRoomDatabase().apply { - retrievedTask = this.resourceDao().getResource(taskId, ResourceType.Task) + retrievedTask = this.resourceDao().getResource(taskId, ResourceType.Task) resourceEntityLastUpdatedLocal = query("Select lastUpdatedLocal from ResourceEntity", null).let { it.moveToFirst() @@ -235,13 +235,72 @@ class ResourceDatabaseMigrationTest { assertThat(Instant.ofEpochMilli(localChangeEntityCorruptedTimeStamp)).isEqualTo(Instant.EPOCH) } + @Test + fun migrate6To7_should_execute_with_no_exception(): Unit = runBlocking { + val taskId = "bed-net-001" + val taskResourceUuid = "e2c79e28-ed4d-4029-a12c-108d1eb5bedb" + val bedNetTask: String = + Task() + .apply { + id = taskId + description = "Issue bed net" + meta.lastUpdated = Date() + } + .let { iParser.encodeResourceToString(it) } + + helper.createDatabase(DB_NAME, 6).apply { + val date = Date() + execSQL( + "INSERT INTO ResourceEntity (resourceUuid, resourceType, resourceId, serializedResource, lastUpdatedLocal) VALUES ('$taskResourceUuid', 'Task', '$taskId', '$bedNetTask', '${DbTypeConverters.instantToLong(date.toInstant())}' );", + ) + + execSQL( + "INSERT INTO LocalChangeEntity (resourceType, resourceId, timestamp, type, payload) VALUES ('Task', '$taskId', '${date.toTimeZoneString()}', '${DbTypeConverters.localChangeTypeToInt(LocalChangeEntity.Type.INSERT)}', '$bedNetTask' );", + ) + close() + } + + helper.runMigrationsAndValidate(DB_NAME, 7, true, MIGRATION_6_7) + + val retrievedTaskResourceId: String? + val retrievedTaskResourceUuid: String? + val localChangeResourceUuid: String? + val localChangeResourceId: String? + + getMigratedRoomDatabase().apply { + query("SELECT resourceId, resourceUuid FROM ResourceEntity", null).let { + it.moveToFirst() + retrievedTaskResourceId = it.getString(0) + retrievedTaskResourceUuid = String(it.getBlob(1), Charsets.UTF_8) + } + + query("SELECT resourceId,resourceUuid FROM LocalChangeEntity", null).let { + it.moveToFirst() + localChangeResourceId = it.getString(0) + localChangeResourceUuid = String(it.getBlob(1), Charsets.UTF_8) + } + + openHelper.writableDatabase.close() + } + + assertThat(retrievedTaskResourceUuid).isEqualTo(localChangeResourceUuid) + assertThat(localChangeResourceId).isEqualTo(retrievedTaskResourceId) + } + private fun getMigratedRoomDatabase(): ResourceDatabase = Room.databaseBuilder( InstrumentationRegistry.getInstrumentation().targetContext, ResourceDatabase::class.java, DB_NAME, ) - .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6) + .addMigrations( + MIGRATION_1_2, + MIGRATION_2_3, + MIGRATION_3_4, + MIGRATION_4_5, + MIGRATION_5_6, + MIGRATION_6_7, + ) .build() companion object { 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 8ca9fff23c..faf4050cd4 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 @@ -95,7 +95,14 @@ internal class DatabaseImpl( } } - addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6) + addMigrations( + MIGRATION_1_2, + MIGRATION_2_3, + MIGRATION_3_4, + MIGRATION_4_5, + MIGRATION_5_6, + MIGRATION_6_7, + ) } .build() } @@ -115,8 +122,10 @@ internal class DatabaseImpl( logicalIds.addAll( resource.map { val timeOfLocalChange = Instant.now() - localChangeDao.addInsert(it, timeOfLocalChange) - resourceDao.insertLocalResource(it, timeOfLocalChange) + val resourceId = resourceDao.insertLocalResource(it, timeOfLocalChange) + val resourceEntity = selectEntity(it.resourceType, it.logicalId) + localChangeDao.addInsert(it, resourceEntity.resourceUuid, timeOfLocalChange) + resourceId }, ) } @@ -169,19 +178,16 @@ internal class DatabaseImpl( override suspend fun delete(type: ResourceType, id: String) { db.withTransaction { - val remoteVersionId: String? = - try { - selectEntity(type, id).versionId - } catch (e: ResourceNotFoundException) { - null + resourceDao.getResourceEntity(id, type)?.let { + val rowsDeleted = resourceDao.deleteResource(resourceId = id, resourceType = type) + if (rowsDeleted > 0) { + localChangeDao.addDelete( + resourceId = id, + resourceType = type, + resourceUuid = it.resourceUuid, + remoteVersionId = it.versionId, + ) } - val rowsDeleted = resourceDao.deleteResource(resourceId = id, resourceType = type) - if (rowsDeleted > 0) { - localChangeDao.addDelete( - resourceId = id, - resourceType = type, - remoteVersionId = remoteVersionId, - ) } } } diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt b/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt index 0878809ab3..45b3631da3 100644 --- a/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt +++ b/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt @@ -50,7 +50,7 @@ import com.google.android.fhir.db.impl.entities.UriIndexEntity LocalChangeEntity::class, PositionIndexEntity::class, ], - version = 6, + version = 7, exportSchema = true, ) @TypeConverters(DbTypeConverters::class) @@ -127,3 +127,25 @@ val MIGRATION_5_6 = ) } } + +/** Add column resourceUuid in [LocalChangeEntity] */ +val MIGRATION_6_7 = + object : Migration(6, 7) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + "CREATE TABLE IF NOT EXISTS `_new_LocalChangeEntity` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceType` TEXT NOT NULL, `resourceId` TEXT NOT NULL, `resourceUuid` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, `type` INTEGER NOT NULL, `payload` TEXT NOT NULL, `versionId` TEXT)", + ) + database.execSQL( + "INSERT INTO `_new_LocalChangeEntity` (`id`,`resourceType`,`resourceId`,`resourceUuid`,`timestamp`,`type`,`payload`,`versionId`) " + + "SELECT localChange.id, localChange.resourceType, localChange.resourceId, resource.resourceUuid, localChange.timestamp, localChange.type, localChange.payload, localChange.versionId FROM `LocalChangeEntity` localChange LEFT JOIN ResourceEntity resource ON localChange.resourceId= resource.resourceId", + ) + database.execSQL("DROP TABLE `LocalChangeEntity`") + database.execSQL("ALTER TABLE `_new_LocalChangeEntity` RENAME TO `LocalChangeEntity`") + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_LocalChangeEntity_resourceType_resourceId` ON `LocalChangeEntity` (`resourceType`, `resourceId`)", + ) + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_LocalChangeEntity_resourceType_resourceUuid` ON `LocalChangeEntity` (`resourceType`, `resourceUuid`)", + ) + } + } 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 d69684a9bd..ecd24b4ece 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 @@ -32,6 +32,7 @@ import com.google.android.fhir.logicalId import com.google.android.fhir.versionId import java.time.Instant import java.util.Date +import java.util.UUID import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType import org.json.JSONArray @@ -51,7 +52,7 @@ internal abstract class LocalChangeDao { @Insert abstract suspend fun addLocalChange(localChangeEntity: LocalChangeEntity) @Transaction - open suspend fun addInsert(resource: Resource, timeOfLocalChange: Instant) { + open suspend fun addInsert(resource: Resource, resourceUuid: UUID, timeOfLocalChange: Instant) { val resourceId = resource.logicalId val resourceType = resource.resourceType val resourceString = iParser.encodeResourceToString(resource) @@ -61,6 +62,7 @@ internal abstract class LocalChangeDao { id = 0, resourceType = resourceType.name, resourceId = resourceId, + resourceUuid = resourceUuid, timestamp = timeOfLocalChange, type = Type.INSERT, payload = resourceString, @@ -95,6 +97,7 @@ internal abstract class LocalChangeDao { id = 0, resourceType = resourceType.name, resourceId = resourceId, + resourceUuid = oldEntity.resourceUuid, timestamp = timeOfLocalChange, type = Type.UPDATE, payload = jsonDiff.toString(), @@ -103,12 +106,18 @@ internal abstract class LocalChangeDao { ) } - suspend fun addDelete(resourceId: String, resourceType: ResourceType, remoteVersionId: String?) { + suspend fun addDelete( + resourceId: String, + resourceUuid: UUID, + resourceType: ResourceType, + remoteVersionId: String?, + ) { addLocalChange( LocalChangeEntity( id = 0, resourceType = resourceType.name, resourceId = resourceId, + resourceUuid = resourceUuid, timestamp = Date().toInstant(), type = Type.DELETE, payload = "", 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 f3518d0fca..4636957bff 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 @@ -58,7 +58,7 @@ internal abstract class ResourceDao { lateinit var iParser: IParser lateinit var resourceIndexer: ResourceIndexer - open suspend fun update(resource: Resource, timeOfLocalChange: Instant) { + open suspend fun update(resource: Resource, timeOfLocalChange: Instant?) { getResourceEntity(resource.logicalId, resource.resourceType)?.let { // In case the resource has lastUpdated meta data, use it, otherwise use the old value. val lastUpdatedRemote: Date? = resource.meta.lastUpdated @@ -76,12 +76,14 @@ internal abstract class ResourceDao { val index = ResourceIndices.Builder(resourceIndexer.index(resource)) .apply { - addDateTimeIndex( - createLocalLastUpdatedIndex( - resource.resourceType, - InstantType(Date.from(timeOfLocalChange)), - ), - ) + timeOfLocalChange?.let { + addDateTimeIndex( + createLocalLastUpdatedIndex( + resource.resourceType, + InstantType(Date.from(timeOfLocalChange)), + ), + ) + } lastUpdatedRemote?.let { date -> addDateTimeIndex(createLastUpdatedIndex(resource.resourceType, InstantType(date))) } @@ -183,11 +185,14 @@ internal abstract class ResourceDao { // Since the insert removes any old indexes and lastUpdatedLocal (data not contained in resource // itself), we extract the lastUpdatedLocal if any and then set it back again. - private suspend fun insertRemoteResource(resource: Resource) = - insertResource( - resource, - getResourceEntity(resource.logicalId, resource.resourceType)?.lastUpdatedLocal, - ) + private suspend fun insertRemoteResource(resource: Resource): String { + val existingResourceEntity = getResourceEntity(resource.logicalId, resource.resourceType) + if (existingResourceEntity != null) { + update(resource, existingResourceEntity.lastUpdatedLocal) + return resource.id + } + return insertResource(resource, null) + } private suspend fun insertResource(resource: Resource, lastUpdatedLocal: Instant?): String { val resourceUuid = UUID.randomUUID() diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeEntity.kt b/engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeEntity.kt index 2bd519933f..29955365fb 100644 --- a/engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeEntity.kt +++ b/engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeEntity.kt @@ -20,6 +20,7 @@ import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey import java.time.Instant +import java.util.UUID /** * When a local change to a resource happens, the lastUpdated timestamp in [ResourceEntity] is @@ -55,11 +56,18 @@ import java.time.Instant * * ] For resource that is fully synced with server this table should not have any rows. */ -@Entity(indices = [Index(value = ["resourceType", "resourceId"])]) +@Entity( + indices = + [ + Index(value = ["resourceType", "resourceId"]), + Index(value = ["resourceType", "resourceUuid"]), + ], +) internal data class LocalChangeEntity( @PrimaryKey(autoGenerate = true) val id: Long, val resourceType: String, val resourceId: String, + val resourceUuid: UUID, val timestamp: Instant, val type: Type, val payload: String, diff --git a/engine/src/test/java/com/google/android/fhir/LocalChangeTest.kt b/engine/src/test/java/com/google/android/fhir/LocalChangeTest.kt index 6e25bd6779..9e4e8d74d1 100644 --- a/engine/src/test/java/com/google/android/fhir/LocalChangeTest.kt +++ b/engine/src/test/java/com/google/android/fhir/LocalChangeTest.kt @@ -22,6 +22,7 @@ import ca.uhn.fhir.parser.IParser import com.google.android.fhir.db.impl.entities.LocalChangeEntity import com.google.common.truth.Truth.assertThat import java.time.Instant +import java.util.UUID import junit.framework.TestCase import kotlinx.coroutines.runBlocking import org.hl7.fhir.r4.model.HumanName @@ -42,6 +43,7 @@ class LocalChangeTest : TestCase() { LocalChangeEntity( id = 1, resourceType = ResourceType.Patient.name, + resourceUuid = UUID.randomUUID(), resourceId = "Patient-001", type = LocalChangeEntity.Type.INSERT, payload = 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 f991679c6a..7a82d10740 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 @@ -25,6 +25,7 @@ import com.google.android.fhir.toLocalChange import com.google.common.truth.Truth.assertThat import java.net.ConnectException import java.time.Instant +import java.util.UUID import kotlinx.coroutines.flow.toList import kotlinx.coroutines.runBlocking import org.hl7.fhir.r4.model.Bundle @@ -133,6 +134,7 @@ class UploaderImplTest { LocalChangeEntity( id = 1, resourceType = ResourceType.Patient.name, + resourceUuid = UUID.randomUUID(), resourceId = "Patient-001", type = LocalChangeEntity.Type.INSERT, payload = 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 78d365ec02..f111e2d114 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 @@ -32,6 +32,7 @@ import com.google.android.fhir.versionId import com.google.common.truth.Truth.assertThat import java.time.Instant import java.util.Date +import java.util.UUID import org.hl7.fhir.r4.model.HumanName import org.hl7.fhir.r4.model.Meta import org.hl7.fhir.r4.model.Patient @@ -135,6 +136,7 @@ class PerResourcePatchGeneratorTest { id = 1, resourceType = ResourceType.Patient.name, resourceId = "Patient-001", + resourceUuid = UUID.randomUUID(), type = LocalChangeEntity.Type.INSERT, payload = FhirContext.forCached(FhirVersionEnum.R4) @@ -158,6 +160,7 @@ class PerResourcePatchGeneratorTest { id = 2, resourceType = ResourceType.Patient.name, resourceId = "Patient-001", + resourceUuid = UUID.randomUUID(), type = LocalChangeEntity.Type.DELETE, payload = "", timestamp = Instant.now(), @@ -178,6 +181,7 @@ class PerResourcePatchGeneratorTest { id = 1, resourceType = ResourceType.Patient.name, resourceId = "Patient-001", + resourceUuid = UUID.randomUUID(), type = LocalChangeEntity.Type.INSERT, payload = FhirContext.forCached(FhirVersionEnum.R4) @@ -201,6 +205,7 @@ class PerResourcePatchGeneratorTest { id = 2, resourceType = ResourceType.Patient.name, resourceId = "Patient-001", + resourceUuid = UUID.randomUUID(), type = LocalChangeEntity.Type.UPDATE, payload = diff( @@ -233,6 +238,7 @@ class PerResourcePatchGeneratorTest { id = 3, resourceType = ResourceType.Patient.name, resourceId = "Patient-001", + resourceUuid = UUID.randomUUID(), type = LocalChangeEntity.Type.DELETE, payload = "", timestamp = Instant.now(), @@ -314,6 +320,7 @@ class PerResourcePatchGeneratorTest { resourceId = "Patient-001", type = LocalChangeEntity.Type.DELETE, payload = "", + resourceUuid = UUID.randomUUID(), timestamp = Instant.now(), ) .toLocalChange() @@ -324,6 +331,7 @@ class PerResourcePatchGeneratorTest { resourceId = "Patient-001", type = LocalChangeEntity.Type.UPDATE, payload = "", + resourceUuid = UUID.randomUUID(), timestamp = Instant.now(), ) .toLocalChange() @@ -349,6 +357,7 @@ class PerResourcePatchGeneratorTest { resourceId = "Patient-001", type = LocalChangeEntity.Type.UPDATE, payload = "", + resourceUuid = UUID.randomUUID(), timestamp = Instant.now(), ) .toLocalChange() @@ -357,6 +366,7 @@ class PerResourcePatchGeneratorTest { id = 1, resourceType = ResourceType.Patient.name, resourceId = "Patient-001", + resourceUuid = UUID.randomUUID(), type = LocalChangeEntity.Type.INSERT, payload = FhirContext.forCached(FhirVersionEnum.R4) From 909c4157c14439b6e425f144ad2f9e251b803df5 Mon Sep 17 00:00:00 2001 From: anchita-g <109063673+anchita-g@users.noreply.github.com> Date: Wed, 27 Sep 2023 15:12:18 +0530 Subject: [PATCH 04/21] fixing migration tests --- .../db/impl/ResourceDatabaseMigrationTest.kt | 84 ++++++++----------- 1 file changed, 37 insertions(+), 47 deletions(-) diff --git a/engine/src/androidTest/java/com/google/android/fhir/db/impl/ResourceDatabaseMigrationTest.kt b/engine/src/androidTest/java/com/google/android/fhir/db/impl/ResourceDatabaseMigrationTest.kt index aeedc52620..6fbad4521e 100644 --- a/engine/src/androidTest/java/com/google/android/fhir/db/impl/ResourceDatabaseMigrationTest.kt +++ b/engine/src/androidTest/java/com/google/android/fhir/db/impl/ResourceDatabaseMigrationTest.kt @@ -16,7 +16,6 @@ package com.google.android.fhir.db.impl -import androidx.room.Room import androidx.room.testing.MigrationTestHelper import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry @@ -30,7 +29,6 @@ import java.util.Date import kotlinx.coroutines.runBlocking import org.hl7.fhir.r4.model.HumanName import org.hl7.fhir.r4.model.Patient -import org.hl7.fhir.r4.model.ResourceType import org.hl7.fhir.r4.model.Task import org.junit.Rule import org.junit.Test @@ -69,12 +67,14 @@ class ResourceDatabaseMigrationTest { // Open latest version of the database. Room will validate the schema // once all migrations execute. - helper.runMigrationsAndValidate(DB_NAME, 2, true, MIGRATION_1_2) + val migratedDatabase = helper.runMigrationsAndValidate(DB_NAME, 2, true, MIGRATION_1_2) val readPatientJson: String? - getMigratedRoomDatabase().apply { - readPatientJson = this.resourceDao().getResource("migrate1-2-test", ResourceType.Patient) - openHelper.writableDatabase.close() + migratedDatabase.apply { + query("SELECT serializedResource FROM ResourceEntity").let { + it.moveToFirst() + readPatientJson = it.getString(0) + } } assertThat(readPatientJson).isEqualTo(insertedPatientJson) @@ -101,12 +101,14 @@ class ResourceDatabaseMigrationTest { } // Re-open the database with version 3 and provide MIGRATION_2_3 as the migration process. - helper.runMigrationsAndValidate(DB_NAME, 3, true, MIGRATION_2_3) + val migratedDatabase = helper.runMigrationsAndValidate(DB_NAME, 3, true, MIGRATION_2_3) val retrievedTask: String? - getMigratedRoomDatabase().apply { - retrievedTask = this.resourceDao().getResource(taskId, ResourceType.Task) - openHelper.writableDatabase.close() + migratedDatabase.apply { + query("SELECT serializedResource FROM ResourceEntity").let { + it.moveToFirst() + retrievedTask = it.getString(0) + } } assertThat(retrievedTask).isEqualTo(bedNetTask) @@ -133,12 +135,14 @@ class ResourceDatabaseMigrationTest { } // Re-open the database with version 4 and provide MIGRATION_3_4 as the migration process. - helper.runMigrationsAndValidate(DB_NAME, 4, true, MIGRATION_3_4) + val migratedDatabase = helper.runMigrationsAndValidate(DB_NAME, 4, true, MIGRATION_3_4) val retrievedTask: String? - getMigratedRoomDatabase().apply { - retrievedTask = this.resourceDao().getResource(taskId, ResourceType.Task) - openHelper.writableDatabase.close() + migratedDatabase.apply { + query("SELECT serializedResource FROM ResourceEntity").let { + it.moveToFirst() + retrievedTask = it.getString(0) + } } assertThat(retrievedTask).isEqualTo(bedNetTask) @@ -164,12 +168,14 @@ class ResourceDatabaseMigrationTest { } // Re-open the database with version 5 and provide MIGRATION_4_5 as the migration process. - helper.runMigrationsAndValidate(DB_NAME, 5, true, MIGRATION_4_5) + val migratedDatabase = helper.runMigrationsAndValidate(DB_NAME, 5, true, MIGRATION_4_5) val retrievedTask: String? - getMigratedRoomDatabase().apply { - retrievedTask = this.resourceDao().getResource(taskId, ResourceType.Task) - openHelper.writableDatabase.close() + migratedDatabase.apply { + query("SELECT serializedResource FROM ResourceEntity").let { + it.moveToFirst() + retrievedTask = it.getString(0) + } } assertThat(retrievedTask).isEqualTo(bedNetTask) @@ -205,29 +211,31 @@ class ResourceDatabaseMigrationTest { close() } - helper.runMigrationsAndValidate(DB_NAME, 6, true, MIGRATION_5_6) + val migratedDatabase = helper.runMigrationsAndValidate(DB_NAME, 6, true, MIGRATION_5_6) val retrievedTask: String? val localChangeEntityTimeStamp: Long val resourceEntityLastUpdatedLocal: Long val localChangeEntityCorruptedTimeStamp: Long - getMigratedRoomDatabase().apply { - retrievedTask = this.resourceDao().getResource(taskId, ResourceType.Task) + migratedDatabase.apply { + query("SELECT serializedResource FROM ResourceEntity").let { + it.moveToFirst() + retrievedTask = it.getString(0) + } + resourceEntityLastUpdatedLocal = - query("Select lastUpdatedLocal from ResourceEntity", null).let { + query("Select lastUpdatedLocal from ResourceEntity").let { it.moveToFirst() it.getLong(0) } - query("SELECT timestamp FROM LocalChangeEntity", null).let { + query("SELECT timestamp FROM LocalChangeEntity").let { it.moveToFirst() localChangeEntityTimeStamp = it.getLong(0) it.moveToNext() localChangeEntityCorruptedTimeStamp = it.getLong(0) } - - openHelper.writableDatabase.close() } assertThat(retrievedTask).isEqualTo(bedNetTask) @@ -260,49 +268,31 @@ class ResourceDatabaseMigrationTest { close() } - helper.runMigrationsAndValidate(DB_NAME, 7, true, MIGRATION_6_7) + val migratedDatabase = helper.runMigrationsAndValidate(DB_NAME, 7, true, MIGRATION_6_7) val retrievedTaskResourceId: String? val retrievedTaskResourceUuid: String? val localChangeResourceUuid: String? val localChangeResourceId: String? - getMigratedRoomDatabase().apply { - query("SELECT resourceId, resourceUuid FROM ResourceEntity", null).let { + migratedDatabase.apply { + query("SELECT resourceId, resourceUuid FROM ResourceEntity").let { it.moveToFirst() retrievedTaskResourceId = it.getString(0) retrievedTaskResourceUuid = String(it.getBlob(1), Charsets.UTF_8) } - query("SELECT resourceId,resourceUuid FROM LocalChangeEntity", null).let { + query("SELECT resourceId,resourceUuid FROM LocalChangeEntity").let { it.moveToFirst() localChangeResourceId = it.getString(0) localChangeResourceUuid = String(it.getBlob(1), Charsets.UTF_8) } - - openHelper.writableDatabase.close() } assertThat(retrievedTaskResourceUuid).isEqualTo(localChangeResourceUuid) assertThat(localChangeResourceId).isEqualTo(retrievedTaskResourceId) } - private fun getMigratedRoomDatabase(): ResourceDatabase = - Room.databaseBuilder( - InstrumentationRegistry.getInstrumentation().targetContext, - ResourceDatabase::class.java, - DB_NAME, - ) - .addMigrations( - MIGRATION_1_2, - MIGRATION_2_3, - MIGRATION_3_4, - MIGRATION_4_5, - MIGRATION_5_6, - MIGRATION_6_7, - ) - .build() - companion object { const val DB_NAME = "migration_tests.db" } From 945babc0309226d390127a66665ae99129f9e200 Mon Sep 17 00:00:00 2001 From: anchita-g <109063673+anchita-g@users.noreply.github.com> Date: Thu, 28 Sep 2023 11:50:24 +0530 Subject: [PATCH 05/21] returning resource UUID for insert resource --- .../com/google/android/fhir/db/impl/DatabaseImpl.kt | 7 +++---- .../com/google/android/fhir/db/impl/dao/ResourceDao.kt | 10 +++++----- 2 files changed, 8 insertions(+), 9 deletions(-) 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 faf4050cd4..a5cf77719c 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 @@ -122,10 +122,9 @@ internal class DatabaseImpl( logicalIds.addAll( resource.map { val timeOfLocalChange = Instant.now() - val resourceId = resourceDao.insertLocalResource(it, timeOfLocalChange) - val resourceEntity = selectEntity(it.resourceType, it.logicalId) - localChangeDao.addInsert(it, resourceEntity.resourceUuid, timeOfLocalChange) - resourceId + val resourceUuid = resourceDao.insertLocalResource(it, timeOfLocalChange) + localChangeDao.addInsert(it, resourceUuid, timeOfLocalChange) + it.logicalId }, ) } 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 4636957bff..59584cbd79 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 @@ -94,7 +94,7 @@ internal abstract class ResourceDao { ?: throw ResourceNotFoundException(resource.resourceType.name, resource.id) } - open suspend fun insertAllRemote(resources: List): List { + open suspend fun insertAllRemote(resources: List): List { return resources.map { resource -> insertRemoteResource(resource) } } @@ -185,16 +185,16 @@ internal abstract class ResourceDao { // Since the insert removes any old indexes and lastUpdatedLocal (data not contained in resource // itself), we extract the lastUpdatedLocal if any and then set it back again. - private suspend fun insertRemoteResource(resource: Resource): String { + private suspend fun insertRemoteResource(resource: Resource): UUID { val existingResourceEntity = getResourceEntity(resource.logicalId, resource.resourceType) if (existingResourceEntity != null) { update(resource, existingResourceEntity.lastUpdatedLocal) - return resource.id + return existingResourceEntity.resourceUuid } return insertResource(resource, null) } - private suspend fun insertResource(resource: Resource, lastUpdatedLocal: Instant?): String { + private suspend fun insertResource(resource: Resource, lastUpdatedLocal: Instant?): UUID { val resourceUuid = UUID.randomUUID() // Use the local UUID as the logical ID of the resource @@ -228,7 +228,7 @@ internal abstract class ResourceDao { updateIndicesForResource(index, resource.resourceType, resourceUuid) - return resource.id + return entity.resourceUuid } suspend fun updateAndIndexRemoteVersionIdAndLastUpdate( From 5e52332b38b4a9a18c03ae14033148d6ec196a6d Mon Sep 17 00:00:00 2001 From: Omar Ismail <44980219+omarismail94@users.noreply.github.com> Date: Tue, 26 Sep 2023 17:55:34 +0100 Subject: [PATCH 06/21] Clean up SyncJobStatus and add FhirSynchronizer test (#2184) * Clean up SyncJobStatus and add FhirSynchronizer test * remove changes in MAVM * refactor more --- .../android/fhir/sync/FhirSyncWorker.kt | 35 ++-- .../android/fhir/sync/FhirSynchronizer.kt | 26 +-- .../google/android/fhir/sync/SyncJobStatus.kt | 7 +- .../android/fhir/sync/FhirSynchronizerTest.kt | 158 ++++++++++++++++++ 4 files changed, 180 insertions(+), 46 deletions(-) create mode 100644 engine/src/test/java/com/google/android/fhir/sync/FhirSynchronizerTest.kt 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 e189daf48a..fe5a78206b 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 @@ -33,7 +33,6 @@ import java.time.OffsetDateTime import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch import timber.log.Timber @@ -66,11 +65,18 @@ abstract class FhirSyncWorker(appContext: Context, workerParams: WorkerParameter ), ) - val flow = MutableSharedFlow() + val synchronizer = + FhirSynchronizer( + applicationContext, + getFhirEngine(), + Uploader(dataSource), + DownloaderImpl(dataSource, getDownloadWorkManager()), + getConflictResolver(), + ) val job = CoroutineScope(Dispatchers.IO).launch { - flow.collect { + synchronizer.syncState.collect { // now send Progress to work manager so caller app can listen setProgress(buildWorkData(it)) @@ -80,17 +86,7 @@ abstract class FhirSyncWorker(appContext: Context, workerParams: WorkerParameter } } - Timber.v("Subscribed to flow for progress") - val result = - FhirSynchronizer( - applicationContext, - getFhirEngine(), - Uploader(dataSource), - DownloaderImpl(dataSource, getDownloadWorkManager()), - getConflictResolver(), - ) - .apply { subscribe(flow) } - .synchronize() + val result = synchronizer.synchronize() val output = buildWorkData(result) // await/join is needed to collect states completely @@ -105,15 +101,10 @@ abstract class FhirSyncWorker(appContext: Context, workerParams: WorkerParameter * [RetryConfiguration.maxRetries] set by user. */ val retries = inputData.getInt(MAX_RETRIES_ALLOWED, 0) - return when { - result is SyncJobStatus.Finished -> { - Result.success(output) - } - retries > runAttemptCount -> { - Result.retry() - } + return when (result) { + is SyncJobStatus.Finished -> Result.success(output) else -> { - Result.failure(output) + if (retries > runAttemptCount) Result.retry() else Result.failure(output) } } } 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 f2f6de79b0..c878634010 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 @@ -26,6 +26,7 @@ import com.google.android.fhir.sync.upload.UploadState import com.google.android.fhir.sync.upload.Uploader import java.time.OffsetDateTime import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.flow import org.hl7.fhir.r4.model.ResourceType @@ -52,24 +53,13 @@ internal class FhirSynchronizer( private val downloader: Downloader, private val conflictResolver: ConflictResolver, ) { - private var syncState: MutableSharedFlow? = null - private val datastoreUtil = DatastoreUtil(context) - - private fun isSubscribed(): Boolean { - return syncState != null - } - fun subscribe(flow: MutableSharedFlow) { - if (isSubscribed()) { - throw IllegalStateException("Already subscribed to a flow") - } + private val _syncState = MutableSharedFlow() + val syncState: SharedFlow = _syncState - this.syncState = flow - } + private val datastoreUtil = DatastoreUtil(context) - private suspend fun setSyncState(state: SyncJobStatus) { - syncState?.emit(state) - } + private suspend fun setSyncState(state: SyncJobStatus) = _syncState.emit(state) private suspend fun setSyncState(result: SyncResult): SyncJobStatus { // todo: emit this properly instead of using datastore? @@ -77,7 +67,7 @@ internal class FhirSynchronizer( val state = when (result) { - is SyncResult.Success -> SyncJobStatus.Finished() + is SyncResult.Success -> SyncJobStatus.Finished is SyncResult.Error -> SyncJobStatus.Failed(result.exceptions) } @@ -86,7 +76,7 @@ internal class FhirSynchronizer( } suspend fun synchronize(): SyncJobStatus { - setSyncState(SyncJobStatus.Started()) + setSyncState(SyncJobStatus.Started) return listOf(download(), upload()) .filterIsInstance() @@ -123,7 +113,6 @@ internal class FhirSynchronizer( return if (exceptions.isEmpty()) { SyncResult.Success() } else { - setSyncState(SyncJobStatus.Glitch(exceptions)) SyncResult.Error(exceptions) } } @@ -151,7 +140,6 @@ internal class FhirSynchronizer( return if (exceptions.isEmpty()) { SyncResult.Success() } else { - setSyncState(SyncJobStatus.Glitch(exceptions)) SyncResult.Error(exceptions) } } diff --git a/engine/src/main/java/com/google/android/fhir/sync/SyncJobStatus.kt b/engine/src/main/java/com/google/android/fhir/sync/SyncJobStatus.kt index f567483a68..8c063a28df 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/SyncJobStatus.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/SyncJobStatus.kt @@ -22,7 +22,7 @@ sealed class SyncJobStatus { val timestamp: OffsetDateTime = OffsetDateTime.now() /** Sync job has been started on the client but the syncing is not necessarily in progress. */ - class Started : SyncJobStatus() + object Started : SyncJobStatus() /** Syncing in progress with the server. */ data class InProgress( @@ -31,11 +31,8 @@ sealed class SyncJobStatus { val completed: Int = 0, ) : SyncJobStatus() - /** Glitched but sync job is being retried. */ - data class Glitch(val exceptions: List) : SyncJobStatus() - /** Sync job finished successfully. */ - class Finished : SyncJobStatus() + object Finished : SyncJobStatus() /** Sync job failed. */ data class Failed(val exceptions: List) : SyncJobStatus() diff --git a/engine/src/test/java/com/google/android/fhir/sync/FhirSynchronizerTest.kt b/engine/src/test/java/com/google/android/fhir/sync/FhirSynchronizerTest.kt new file mode 100644 index 0000000000..a280cec4a3 --- /dev/null +++ b/engine/src/test/java/com/google/android/fhir/sync/FhirSynchronizerTest.kt @@ -0,0 +1,158 @@ +/* + * 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 + +import androidx.test.core.app.ApplicationProvider +import com.google.android.fhir.LocalChangeToken +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 com.google.android.fhir.testing.TestFhirEngineImpl +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.ResourceType +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class FhirSynchronizerTest { + + @Mock private lateinit var uploader: Uploader + + @Mock private lateinit var downloader: Downloader + + @Mock private lateinit var conflictResolver: ConflictResolver + + private lateinit var fhirSynchronizer: FhirSynchronizer + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + fhirSynchronizer = + FhirSynchronizer( + ApplicationProvider.getApplicationContext(), + TestFhirEngineImpl, + uploader, + downloader, + conflictResolver, + ) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `synchronize should return Success on successful download and upload`() = + runTest(UnconfinedTestDispatcher()) { + `when`(downloader.download()).thenReturn(flowOf(DownloadState.Success(listOf(), 10, 10))) + `when`(uploader.upload(any())) + .thenReturn( + flowOf( + UploadState.Success( + LocalChangeToken(listOf()), + Patient(), + 1, + 1, + ), + ), + ) + + val emittedValues = mutableListOf() + backgroundScope.launch { fhirSynchronizer.syncState.collect { emittedValues.add(it) } } + + val result = fhirSynchronizer.synchronize() + + assertThat(emittedValues) + .containsExactly( + SyncJobStatus.Started, + SyncJobStatus.InProgress(SyncOperation.DOWNLOAD, total = 10, completed = 10), + SyncJobStatus.InProgress(SyncOperation.UPLOAD, total = 1, completed = 1), + SyncJobStatus.Finished, + ) + + assertThat(SyncJobStatus.Finished::class.java).isEqualTo(result::class.java) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `synchronize should return Failed on failed download`() = + runTest(UnconfinedTestDispatcher()) { + val error = ResourceSyncException(ResourceType.Patient, Exception("Download error")) + `when`(downloader.download()).thenReturn(flowOf(DownloadState.Failure(error))) + `when`(uploader.upload(any())) + .thenReturn( + flowOf( + UploadState.Success( + LocalChangeToken(listOf()), + Patient(), + 1, + 1, + ), + ), + ) + + val emittedValues = mutableListOf() + backgroundScope.launch { fhirSynchronizer.syncState.collect { emittedValues.add(it) } } + + val result = fhirSynchronizer.synchronize() + + assertThat(emittedValues) + .containsExactly( + SyncJobStatus.Started, + SyncJobStatus.InProgress(SyncOperation.UPLOAD, total = 1, completed = 1), + SyncJobStatus.Failed(exceptions = listOf(error)), + ) + assertThat(result).isInstanceOf(SyncJobStatus.Failed::class.java) + assertThat(listOf(error)).isEqualTo((result as SyncJobStatus.Failed).exceptions) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `synchronize should return Failed on failed upload`() = + runTest(UnconfinedTestDispatcher()) { + `when`(downloader.download()).thenReturn(flowOf(DownloadState.Success(listOf(), 10, 10))) + val error = ResourceSyncException(ResourceType.Patient, Exception("Upload error")) + `when`(uploader.upload(any())) + .thenReturn( + flowOf(UploadState.Failure(error)), + ) + + val emittedValues = mutableListOf() + backgroundScope.launch { fhirSynchronizer.syncState.collect { emittedValues.add(it) } } + + val result = fhirSynchronizer.synchronize() + + assertThat(emittedValues) + .containsExactly( + SyncJobStatus.Started, + SyncJobStatus.InProgress(SyncOperation.DOWNLOAD, total = 10, completed = 10), + SyncJobStatus.Failed(exceptions = listOf(error)), + ) + assertThat(result).isInstanceOf(SyncJobStatus.Failed::class.java) + assertThat(listOf(error)).isEqualTo((result as SyncJobStatus.Failed).exceptions) + } +} From d67d0b5855e2b087a95eeb4c3e3ce4f81e7a2f0e Mon Sep 17 00:00:00 2001 From: anchita-g <109063673+anchita-g@users.noreply.github.com> Date: Tue, 26 Sep 2023 14:15:05 +0530 Subject: [PATCH 07/21] Consolidator done --- .../android/fhir/demo/FhirApplication.kt | 2 +- ...stPerResourceUrlRequestConsolidatorTest.kt | 278 ++++++++++++++++++ .../com/google/android/fhir/db/Database.kt | 57 ++++ .../fhir/db/ResourceNotFoundException.kt | 25 +- .../android/fhir/db/impl/DatabaseImpl.kt | 93 +++++- .../fhir/db/impl/dao/LocalChangeDao.kt | 40 ++- .../android/fhir/db/impl/dao/ResourceDao.kt | 136 +++++++-- .../android/fhir/impl/FhirEngineImpl.kt | 2 +- .../DefaultResourceConsolidator.kt} | 18 +- .../PostPerResourceUrlRequestConsolidator.kt | 200 +++++++++++++ .../consolidator/ResourceConsolidator.kt | 36 +++ 11 files changed, 828 insertions(+), 59 deletions(-) create mode 100644 engine/src/androidTest/java/com/google/android/fhir/sync/upload/consolidator/PostPerResourceUrlRequestConsolidatorTest.kt rename engine/src/main/java/com/google/android/fhir/sync/upload/{ResourceConsolidator.kt => consolidator/DefaultResourceConsolidator.kt} (81%) create mode 100644 engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/PostPerResourceUrlRequestConsolidator.kt create mode 100644 engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/ResourceConsolidator.kt diff --git a/demo/src/main/java/com/google/android/fhir/demo/FhirApplication.kt b/demo/src/main/java/com/google/android/fhir/demo/FhirApplication.kt index 2446d2226d..d143567060 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/FhirApplication.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/FhirApplication.kt @@ -45,7 +45,7 @@ class FhirApplication : Application(), DataCaptureConfig.Provider { } FhirEngineProvider.init( FhirEngineConfiguration( - enableEncryptionIfSupported = true, + enableEncryptionIfSupported = false, RECREATE_AT_OPEN, ServerConfiguration( "https://hapi.fhir.org/baseR4/", diff --git a/engine/src/androidTest/java/com/google/android/fhir/sync/upload/consolidator/PostPerResourceUrlRequestConsolidatorTest.kt b/engine/src/androidTest/java/com/google/android/fhir/sync/upload/consolidator/PostPerResourceUrlRequestConsolidatorTest.kt new file mode 100644 index 0000000000..a03340edfb --- /dev/null +++ b/engine/src/androidTest/java/com/google/android/fhir/sync/upload/consolidator/PostPerResourceUrlRequestConsolidatorTest.kt @@ -0,0 +1,278 @@ +/* + * 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.consolidator + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import ca.uhn.fhir.context.FhirContext +import com.google.android.fhir.FhirServices +import com.google.android.fhir.LocalChange +import com.google.android.fhir.LocalChangeToken +import com.google.android.fhir.db.Database +import com.google.android.fhir.logicalId +import com.google.android.fhir.search.Search +import com.google.android.fhir.search.getQuery +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.runBlocking +import org.hl7.fhir.r4.model.HumanName +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.hl7.fhir.r4.model.SearchParameter +import org.hl7.fhir.r4.model.StringType +import org.json.JSONArray +import org.json.JSONObject +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@MediumTest +@RunWith(AndroidJUnit4::class) +class PostPerResourceUrlRequestConsolidatorTest { + private val context: Context = ApplicationProvider.getApplicationContext() + private lateinit var services: FhirServices + private lateinit var database: Database + private lateinit var postPerResourceUrlRequestConsolidator: PostPerResourceUrlRequestConsolidator + private val iParser = FhirContext.forR4Cached().newJsonParser() + + @Before + fun setUp(): Unit = runBlocking { + buildFhirService() + postPerResourceUrlRequestConsolidator = PostPerResourceUrlRequestConsolidator(database) + } + + private fun buildFhirService(customSearchParameter: List? = null) { + services = + FhirServices.builder(context) + .inMemory() + .apply { setSearchParameters(customSearchParameter) } + .build() + database = services.database + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun insertPatientAndReferringResource_shouldUpdateReferencesAndUpdateResourceId() = runBlocking { + val locallyCreatedPatientResourceId = "local-patient-1" + val locallyCreatedPatient = + Patient().apply { + id = locallyCreatedPatientResourceId + name = listOf(HumanName().setFamily("Family").setGiven(listOf(StringType("First Name")))) + } + + val locallyCreatedObservationResourceId = "local-observation-1" + val locallyCreatedPatientObservation = + Observation().apply { + subject = Reference("Patient/$locallyCreatedPatientResourceId") + id = locallyCreatedObservationResourceId + } + + database.insert(locallyCreatedPatient) + database.insert(locallyCreatedPatientObservation) + val patientResourceEntity = + database.selectEntity(locallyCreatedPatient.resourceType, locallyCreatedPatientResourceId) + + val patientLocalChanges = + database.getLocalChanges(locallyCreatedPatient.resourceType, locallyCreatedPatientResourceId) + + // pretend that the resource has been created on the server with an updated ID + val remotelyCreatedPatientResourceId = "remote-patient-1" + val remotelyCreatedPatient = + locallyCreatedPatient.apply { id = remotelyCreatedPatientResourceId } + + postPerResourceUrlRequestConsolidator.consolidate( + LocalChangeToken(patientLocalChanges.flatMap { it.token.ids }), + remotelyCreatedPatient, + ) + + // check if resource is fetch-able by its new ID + val updatedPatientResourceEntity = + database.selectEntity(remotelyCreatedPatient.resourceType, remotelyCreatedPatient.id) + assertThat(updatedPatientResourceEntity.resourceUuid) + .isEqualTo(patientResourceEntity.resourceUuid) + + // verify that all the local changes are deleted for this synced resource + val patientLocalChangesAfterConsolidation = + database.getLocalChanges( + locallyCreatedPatient.resourceType, + updatedPatientResourceEntity.resourceUuid, + ) + assertThat(patientLocalChangesAfterConsolidation).isEmpty() + + // verify that Observation is updated + val updatedObservationResource = + database.select( + locallyCreatedPatientObservation.resourceType, + locallyCreatedObservationResourceId, + ) as Observation + assertThat(updatedObservationResource.subject.reference) + .isEqualTo("Patient/$remotelyCreatedPatientResourceId") + + // verify that Observation's LocalChanges are updated + val updatedObservationLocalChanges = + database.getLocalChanges( + locallyCreatedPatientObservation.resourceType, + locallyCreatedObservationResourceId + ) + assertThat(updatedObservationLocalChanges.size).isEqualTo(1) + val observationLocalChange = updatedObservationLocalChanges[0] + assertThat(observationLocalChange.type).isEqualTo(LocalChange.Type.INSERT) + val observationLocalChangePayload = + iParser.parseResource(observationLocalChange.payload) as Observation + assertThat(observationLocalChangePayload.subject.reference) + .isEqualTo("Patient/$remotelyCreatedPatientResourceId") + + // verify that Observation is searchable i.e. ReferenceIndex is updated + val searchedObservations = + database.search( + Search(ResourceType.Observation) + .apply { + filter( + Observation.SUBJECT, + { value = "Patient/$remotelyCreatedPatientResourceId" }, + ) + } + .getQuery(), + ) + assertThat(searchedObservations.size).isEqualTo(1) + assertThat(searchedObservations[0].logicalId).isEqualTo(locallyCreatedObservationResourceId) + } + + @Test + fun insertPatientAndInsertUpdateReferringResource_shouldUpdateReferencesAndUpdateResourceId() = + runBlocking { + val locallyCreatedPatientResourceId = "local-patient-1" + val locallyCreatedPatient = + Patient().apply { + id = locallyCreatedPatientResourceId + name = listOf(HumanName().setFamily("Family").setGiven(listOf(StringType("First Name")))) + } + database.insert(locallyCreatedPatient) + + val locallyCreatedObservationResourceId = "local-observation-1" + val locallyCreatedPatientObservation = + Observation().apply { + subject = Reference("Patient/$locallyCreatedPatientResourceId") + id = locallyCreatedObservationResourceId + } + database.insert(locallyCreatedPatientObservation) + database.update( + locallyCreatedPatientObservation.apply { + performer = listOf(Reference("Patient/$locallyCreatedPatientResourceId")) + } + ) + + val patientResourceEntity = + database.selectEntity(locallyCreatedPatient.resourceType, locallyCreatedPatientResourceId) + + val patientLocalChanges = + database.getLocalChanges( + locallyCreatedPatient.resourceType, + locallyCreatedPatientResourceId + ) + val observationLocalChanges = + database.getLocalChanges( + locallyCreatedPatientObservation.resourceType, + locallyCreatedObservationResourceId, + ) + assertThat(observationLocalChanges.size).isEqualTo(2) + + // pretend that the resource has been created on the server with an updated ID + val remotelyCreatedPatientResourceId = "remote-patient-1" + val remotelyCreatedPatient = + locallyCreatedPatient.apply { id = remotelyCreatedPatientResourceId } + + postPerResourceUrlRequestConsolidator.consolidate( + LocalChangeToken(patientLocalChanges.flatMap { it.token.ids }), + remotelyCreatedPatient, + ) + + // check if resource is fetch-able by its new ID + val updatedPatientResourceEntity = + database.selectEntity(remotelyCreatedPatient.resourceType, remotelyCreatedPatient.id) + assertThat(updatedPatientResourceEntity.resourceUuid) + .isEqualTo(patientResourceEntity.resourceUuid) + + // verify that all the local changes are deleted for this synced resource + val patientLocalChangesAfterConsolidation = + database.getLocalChanges( + locallyCreatedPatient.resourceType, + updatedPatientResourceEntity.resourceUuid, + ) + assertThat(patientLocalChangesAfterConsolidation).isEmpty() + + // verify that Observation is updated + val updatedObservationResource = + database.select( + locallyCreatedPatientObservation.resourceType, + locallyCreatedObservationResourceId, + ) as Observation + assertThat(updatedObservationResource.subject.reference) + .isEqualTo("Patient/$remotelyCreatedPatientResourceId") + + // verify that Observation's LocalChanges are updated + val updatedObservationLocalChanges = + database.getLocalChanges( + locallyCreatedPatientObservation.resourceType, + locallyCreatedObservationResourceId + ) + assertThat(updatedObservationLocalChanges.size).isEqualTo(2) + val observationLocalChange1 = updatedObservationLocalChanges[0] + assertThat(observationLocalChange1.type).isEqualTo(LocalChange.Type.INSERT) + val observationLocalChange1Payload = + iParser.parseResource(observationLocalChange1.payload) as Observation + assertThat(observationLocalChange1Payload.subject.reference) + .isEqualTo("Patient/$remotelyCreatedPatientResourceId") + + val observationLocalChange2 = updatedObservationLocalChanges[1] + assertThat(observationLocalChange2.type).isEqualTo(LocalChange.Type.UPDATE) + // payload = + // [{"op":"add","path":"\/performer","value":[{"reference":"Patient\/remote-patient-1"}]}] + val observationLocalChange2Payload = JSONArray(observationLocalChange2.payload) + val patch = observationLocalChange2Payload.get(0) as JSONObject + val patchValueReference = patch.getJSONArray("value").get(0) as JSONObject + assertThat(patchValueReference.getString("reference")) + .isEqualTo("Patient/$remotelyCreatedPatientResourceId") + + // verify that Observation is searchable i.e. ReferenceIndex is updated + val searchedObservations = + database.search( + Search(ResourceType.Observation) + .apply { + filter( + Observation.SUBJECT, + { value = "Patient/$remotelyCreatedPatientResourceId" }, + ) + } + .getQuery(), + ) + assertThat(searchedObservations.size).isEqualTo(1) + assertThat(searchedObservations[0].logicalId).isEqualTo(locallyCreatedObservationResourceId) + } + + // update and insert + // update and update + // +} 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 cbbc840b64..e95bcf2ca2 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 @@ -19,10 +19,12 @@ package com.google.android.fhir.db import com.google.android.fhir.LocalChange import com.google.android.fhir.LocalChangeToken import com.google.android.fhir.db.impl.dao.IndexedIdAndResource +import com.google.android.fhir.db.impl.dao.ReferringResource import com.google.android.fhir.db.impl.entities.LocalChangeEntity import com.google.android.fhir.db.impl.entities.ResourceEntity import com.google.android.fhir.search.SearchQuery import java.time.Instant +import java.util.UUID import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType @@ -53,6 +55,14 @@ internal interface Database { */ suspend fun update(vararg resources: Resource) + /** + * Updates the uploaded `resource` with the provided 'resourceUuid' in the FHIR resource database. + * + * @param resource: uploaded resource + * @param uuid: The UUID of the [ResourceEntity] associated with the resource + */ + suspend fun updateResourceWithUuid(resource: Resource, uuid: UUID) + /** Updates the `resource` meta in the FHIR resource database. */ suspend fun updateVersionIdAndLastUpdated( resourceId: String, @@ -97,6 +107,16 @@ internal interface Database { suspend fun searchReferencedResources(query: SearchQuery): List + /** + * Fetches all [ResourceEntity]s whose resource refers to the requested {resource} along with the + * respective list of FHIR paths in the referring resources where the requested resource is + * referred. + */ + suspend fun getAllResourcesReferringToResourceWithPath( + resourceType: ResourceType, + resourceId: String, + ): List + suspend fun count(query: SearchQuery): Long /** @@ -105,6 +125,9 @@ internal interface Database { */ suspend fun getAllLocalChanges(): List + /** Retrieves [LocalChange]s with the Ids provided in the {localChangeToken} */ + suspend fun getAllLocalChanges(localChangeToken: LocalChangeToken): List + /** Retrieves the count of [LocalChange]s stored in the database. */ suspend fun getLocalChangesCount(): Int @@ -114,6 +137,27 @@ internal interface Database { /** Remove the [LocalChangeEntity] s with matching resource ids. */ suspend fun deleteUpdates(resources: List) + /** + * Update the resource ID for [LocalChange]s for [ResourceEntity] with + * [ResourceEntity.resourceUuid] resourceUuid and [ResourceEntity.resourceType] resourceType. + */ + suspend fun updateResourceIdForResourceChanges( + resourceType: ResourceType, + resourceUuid: UUID, + updatedResourceId: String, + ) + + /** + * Removes all the existing [LocalChange] for the [Resource] with [ResourceEntity.resourceUuid] + * {resourceUuid} and [ResourceEntity.resourceType] resourceType. Adds the new {updatedChanges} + * for the resource. + */ + suspend fun replaceResourceChanges( + resourceType: ResourceType, + resourceUuid: UUID, + updatedChanges: List, + ) + /** Runs the block as a database transaction. */ suspend fun withTransaction(block: suspend () -> Unit) @@ -139,6 +183,19 @@ internal interface Database { */ suspend fun getLocalChanges(type: ResourceType, id: String): List + /** + * Retrieve a list of [LocalChange] for [ResourceEntity] with given type and UUID, which can be + * used to purge resource from database. If there is no local change for given [resourceType] and + * [ResourceEntity.resourceUuid], return an empty list. + * + * @param type The [ResourceType] + * @param resourceUuid The resource UUID [ResourceEntity.resourceUuid] + * @return [List]<[LocalChange]> A list of local changes for given [resourceType] and + * [Resource.id] . If there is no local change for given [resourceType] and + * [ResourceEntity.resourceUuid], return empty list. + */ + suspend fun getLocalChanges(type: ResourceType, resourceUuid: UUID): List + /** * Purge resource from database based on resource type and id without any deletion of data from * the server. diff --git a/engine/src/main/java/com/google/android/fhir/db/ResourceNotFoundException.kt b/engine/src/main/java/com/google/android/fhir/db/ResourceNotFoundException.kt index 7b204c1116..4e4f4c7c40 100644 --- a/engine/src/main/java/com/google/android/fhir/db/ResourceNotFoundException.kt +++ b/engine/src/main/java/com/google/android/fhir/db/ResourceNotFoundException.kt @@ -19,19 +19,32 @@ package com.google.android.fhir.db /** Thrown to indicate that the requested resource is not found. */ class ResourceNotFoundException : Exception { val type: String - val id: String + val identifierType: ResourceIdentifierType + val identifier: String constructor( type: String, - id: String, + identifier: String, + identifierType: ResourceIdentifierType, cause: Throwable, - ) : super("Resource not found with type $type and id $id!", cause) { + ) : super("Resource not found with type $type and $identifierType $identifier!", cause) { this.type = type - this.id = id + this.identifier = identifier + this.identifierType = identifierType } - constructor(type: String, id: String) : super("Resource not found with type $type and id $id!") { + constructor( + type: String, + identifierType: ResourceIdentifierType, + identifier: String, + ) : super("Resource not found with type $type and $identifierType $identifier!") { this.type = type - this.id = id + this.identifier = identifier + this.identifierType = identifierType } } + +enum class ResourceIdentifierType { + ID, + UUID, +} diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/DatabaseImpl.kt b/engine/src/main/java/com/google/android/fhir/db/impl/DatabaseImpl.kt index faf4050cd4..afe17115c6 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 @@ -25,15 +25,18 @@ import ca.uhn.fhir.parser.IParser import com.google.android.fhir.DatabaseErrorStrategy import com.google.android.fhir.LocalChange import com.google.android.fhir.LocalChangeToken +import com.google.android.fhir.db.ResourceIdentifierType import com.google.android.fhir.db.ResourceNotFoundException import com.google.android.fhir.db.impl.DatabaseImpl.Companion.UNENCRYPTED_DATABASE_NAME import com.google.android.fhir.db.impl.dao.IndexedIdAndResource +import com.google.android.fhir.db.impl.dao.ReferringResource 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 java.time.Instant +import java.util.UUID import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType @@ -147,6 +150,10 @@ internal class DatabaseImpl( } } + override suspend fun updateResourceWithUuid(resource: Resource, uuid: UUID) { + db.withTransaction { resourceDao.updateResourceWithUuid(resource, uuid) } + } + override suspend fun updateVersionIdAndLastUpdated( resourceId: String, resourceType: ResourceType, @@ -168,7 +175,7 @@ internal class DatabaseImpl( resourceDao.getResource(resourceId = id, resourceType = type)?.let { iParser.parseResource(it) } - ?: throw ResourceNotFoundException(type.name, id) + ?: throw ResourceNotFoundException(type.name, ResourceIdentifierType.ID, id) } as Resource } @@ -215,6 +222,48 @@ internal class DatabaseImpl( } } + override suspend fun getAllResourcesReferringToResourceWithPath( + resourceType: ResourceType, + resourceId: String, + ): List { + val searchForReferenceValue = "$resourceType/$resourceId" + val referringResourceSearchQuery = + SearchQuery( + query = + """ + SELECT refIndex.index_path, refIndex.resourceUuid, resource.resourceId, + resource.serializedResource, resource.resourceType + FROM ReferenceIndexEntity as refIndex + JOIN ResourceEntity as resource on refIndex.resourceUuid = resource.resourceUuid + WHERE refIndex.index_value = ? + """ + .trimIndent(), + args = listOf(searchForReferenceValue), + ) + return db.withTransaction { + resourceDao + .getReferringResources( + SimpleSQLiteQuery( + referringResourceSearchQuery.query, + referringResourceSearchQuery.args.toTypedArray(), + ), + ) + .groupBy { it.resourceUuid } + .map { (resourceUuid, referringResourceWithPath) -> + val paths = referringResourceWithPath.map { it.path } + val referringResource = referringResourceWithPath.first() + ReferringResource( + resourceUuid = resourceUuid, + resourceId = referringResource.resourceId, + resourceType = ResourceType.fromCode(referringResource.resourceType), + resource = iParser.parseResource(referringResource.serializedResource) as Resource, + referringPaths = paths, + referenceValue = searchForReferenceValue, + ) + } + } + } + override suspend fun count(query: SearchQuery): Long { return db.withTransaction { resourceDao.countResources(SimpleSQLiteQuery(query.query, query.args.toTypedArray())) @@ -225,6 +274,12 @@ internal class DatabaseImpl( return db.withTransaction { localChangeDao.getAllLocalChanges().map { it.toLocalChange() } } } + override suspend fun getAllLocalChanges(localChangeToken: LocalChangeToken): List { + return db + .withTransaction { localChangeDao.getLocalChanges(localChangeToken.ids) } + .map { it.toLocalChange() } + } + override suspend fun getLocalChangesCount(): Int { return db.withTransaction { localChangeDao.getLocalChangesCount() } } @@ -233,10 +288,36 @@ internal class DatabaseImpl( db.withTransaction { localChangeDao.discardLocalChanges(token) } } + override suspend fun updateResourceIdForResourceChanges( + resourceType: ResourceType, + resourceUuid: UUID, + updatedResourceId: String, + ) { + db.withTransaction { + val localChanges = localChangeDao.getLocalChanges(resourceType, resourceUuid) + localChanges + .map { localChangeEntity -> localChangeEntity.copy(resourceId = updatedResourceId) } + // Add LocalChangeEntity with replace strategy + .forEach { localChangeDao.addLocalChange(it) } + } + } + + override suspend fun replaceResourceChanges( + resourceType: ResourceType, + resourceUuid: UUID, + updatedChanges: List, + ) { + db.withTransaction { + val localChanges = localChangeDao.getLocalChanges(resourceType, resourceUuid) + localChangeDao.discardLocalChanges(localChanges.first().resourceId, resourceType) + updatedChanges.forEach { localChangeDao.createLocalChange(it, resourceUuid) } + } + } + override suspend fun selectEntity(type: ResourceType, id: String): ResourceEntity { return db.withTransaction { resourceDao.getResourceEntity(resourceId = id, resourceType = type) - ?: throw ResourceNotFoundException(type.name, id) + ?: throw ResourceNotFoundException(type.name, ResourceIdentifierType.ID, id) } } @@ -264,6 +345,14 @@ internal class DatabaseImpl( } } + override suspend fun getLocalChanges(type: ResourceType, resourceUuid: UUID): List { + return db.withTransaction { + localChangeDao.getLocalChanges(resourceType = type, resourceUuid = resourceUuid).map { + it.toLocalChange() + } + } + } + override suspend fun purge(type: ResourceType, id: String, forcePurge: Boolean) { db.withTransaction { // To check resource is present in DB else throw ResourceNotFoundException() 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 ecd24b4ece..8620dee16c 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 @@ -18,12 +18,14 @@ package com.google.android.fhir.db.impl.dao import androidx.room.Dao import androidx.room.Insert +import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction import ca.uhn.fhir.parser.IParser import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.ObjectMapper import com.github.fge.jsonpatch.diff.JsonDiff +import com.google.android.fhir.LocalChange import com.google.android.fhir.LocalChangeToken import com.google.android.fhir.db.impl.entities.LocalChangeEntity import com.google.android.fhir.db.impl.entities.LocalChangeEntity.Type @@ -49,7 +51,8 @@ internal abstract class LocalChangeDao { lateinit var iParser: IParser - @Insert abstract suspend fun addLocalChange(localChangeEntity: LocalChangeEntity) + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun addLocalChange(localChangeEntity: LocalChangeEntity) @Transaction open suspend fun addInsert(resource: Resource, resourceUuid: UUID, timeOfLocalChange: Instant) { @@ -71,6 +74,21 @@ internal abstract class LocalChangeDao { ) } + suspend fun createLocalChange(localChange: LocalChange, resourceUuid: UUID) { + addLocalChange( + LocalChangeEntity( + id = 0, + resourceType = localChange.resourceType, + resourceId = localChange.resourceId, + resourceUuid = resourceUuid, + timestamp = localChange.timestamp, + type = Type.from(localChange.type.value), + payload = localChange.payload, + versionId = localChange.versionId, + ), + ) + } + suspend fun addUpdate(oldEntity: ResourceEntity, resource: Resource, timeOfLocalChange: Instant) { val resourceId = resource.logicalId val resourceType = resource.resourceType @@ -160,6 +178,14 @@ internal abstract class LocalChangeDao { ) abstract suspend fun getAllLocalChanges(): List + @Query( + """ + SELECT * + FROM LocalChangeEntity + WHERE LocalChangeEntity.id IN (:ids)""", + ) + abstract suspend fun getLocalChanges(ids: List): List + @Query( """ SELECT COUNT(*) @@ -206,6 +232,18 @@ internal abstract class LocalChangeDao { resourceId: String, ): List + @Query( + """ + SELECT * + FROM LocalChangeEntity + WHERE resourceUuid = :resourceUuid AND resourceType = :resourceType + """, + ) + abstract suspend fun getLocalChanges( + resourceType: ResourceType, + resourceUuid: UUID, + ): List + class InvalidLocalChangeException(message: String?) : Exception(message) } 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 4636957bff..76706c1ca7 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 @@ -25,6 +25,7 @@ import androidx.room.Query import androidx.room.RawQuery import androidx.sqlite.db.SupportSQLiteQuery import ca.uhn.fhir.parser.IParser +import com.google.android.fhir.db.ResourceIdentifierType import com.google.android.fhir.db.ResourceNotFoundException import com.google.android.fhir.db.impl.entities.DateIndexEntity import com.google.android.fhir.db.impl.entities.DateTimeIndexEntity @@ -60,38 +61,69 @@ internal abstract class ResourceDao { open suspend fun update(resource: Resource, timeOfLocalChange: Instant?) { getResourceEntity(resource.logicalId, resource.resourceType)?.let { - // In case the resource has lastUpdated meta data, use it, otherwise use the old value. - val lastUpdatedRemote: Date? = resource.meta.lastUpdated - val entity = - it.copy( - serializedResource = iParser.encodeResourceToString(resource), - lastUpdatedLocal = timeOfLocalChange, - lastUpdatedRemote = lastUpdatedRemote?.toInstant() ?: it.lastUpdatedRemote, - ) - // The foreign key in Index entity tables is set with cascade delete constraint and - // insertResource has REPLACE conflict resolution. So, when we do an insert to update the - // resource, it deletes old resource and corresponding index entities (based on foreign key - // constrain) before inserting the new resource. - insertResource(entity) - val index = - ResourceIndices.Builder(resourceIndexer.index(resource)) - .apply { - timeOfLocalChange?.let { - addDateTimeIndex( - createLocalLastUpdatedIndex( - resource.resourceType, - InstantType(Date.from(timeOfLocalChange)), - ), - ) - } - lastUpdatedRemote?.let { date -> - addDateTimeIndex(createLastUpdatedIndex(resource.resourceType, InstantType(date))) - } - } - .build() - updateIndicesForResource(index, resource.resourceType, it.resourceUuid) + updateResourceEntity(it, resource, timeOfLocalChange) + } + ?: throw ResourceNotFoundException( + resource.resourceType.name, + ResourceIdentifierType.ID, + resource.id, + ) + } + + suspend fun updateResourceWithUuid(updatedResource: Resource, resourceUuid: UUID) { + getResourceEntity(resourceUuid)?.let { + updateResourceEntity(it, updatedResource, it.lastUpdatedLocal) } - ?: throw ResourceNotFoundException(resource.resourceType.name, resource.id) + ?: throw ResourceNotFoundException( + updatedResource.resourceType.name, + ResourceIdentifierType.UUID, + updatedResource.id, + ) + } + + private suspend fun updateResourceEntity( + existingResourceEntity: ResourceEntity, + updatedResource: Resource, + timeOfLocalChange: Instant?, + ) { + // In case the resource has lastUpdated meta data, use it, otherwise use the old value. + val lastUpdatedRemote: Date? = updatedResource.meta.lastUpdated + val entity = + existingResourceEntity.copy( + resourceId = updatedResource.logicalId, + serializedResource = iParser.encodeResourceToString(updatedResource), + lastUpdatedLocal = timeOfLocalChange, + lastUpdatedRemote = lastUpdatedRemote?.toInstant() + ?: existingResourceEntity.lastUpdatedRemote, + ) + // The foreign key in Index entity tables is set with cascade delete constraint and + // insertResource has REPLACE conflict resolution. So, when we do an insert to update the + // resource, it deletes old resource and corresponding index entities (based on foreign key + // constraints) before inserting the new resource. + insertResource(entity) + val index = + ResourceIndices.Builder(resourceIndexer.index(updatedResource)) + .apply { + timeOfLocalChange?.let { + addDateTimeIndex( + createLocalLastUpdatedIndex( + updatedResource.resourceType, + InstantType(Date.from(timeOfLocalChange)), + ), + ) + } + lastUpdatedRemote?.let { date -> + addDateTimeIndex( + createLastUpdatedIndex(updatedResource.resourceType, InstantType(date)), + ) + } + } + .build() + updateIndicesForResource( + index, + updatedResource.resourceType, + existingResourceEntity.resourceUuid, + ) } open suspend fun insertAllRemote(resources: List): List { @@ -171,6 +203,17 @@ internal abstract class ResourceDao { resourceType: ResourceType, ): ResourceEntity? + @Query( + """ + SELECT * + FROM ResourceEntity + WHERE resourceUuid = :resourceUuid + """, + ) + abstract suspend fun getResourceEntity( + resourceUuid: UUID, + ): ResourceEntity? + @RawQuery abstract suspend fun getResources(query: SupportSQLiteQuery): List @RawQuery @@ -178,6 +221,11 @@ internal abstract class ResourceDao { query: SupportSQLiteQuery, ): List + @RawQuery + abstract suspend fun getReferringResources( + query: SupportSQLiteQuery, + ): List + @RawQuery abstract suspend fun countResources(query: SupportSQLiteQuery): Long suspend fun insertLocalResource(resource: Resource, timeOfChange: Instant) = @@ -375,3 +423,29 @@ internal data class IndexedIdAndResource( val idOfBaseResourceOnWhichThisMatched: String, val resource: Resource, ) + +/** + * Data class representing a [ResourceEntity] which is referring to the requested resource, and the + * FHIR path of the referring [Resource]. The referring resource payload is serialized. + */ +internal data class ReferringSerialisedResourceWithReferringPath( + @ColumnInfo(name = "resourceUuid") val resourceUuid: UUID, + @ColumnInfo(name = "resourceId") val resourceId: String, + @ColumnInfo(name = "resourceType") val resourceType: String, + @ColumnInfo(name = "serializedResource") val serializedResource: String, + @ColumnInfo(name = "index_path") val path: String, +) + +/** + * Data class representing a [ResourceEntity] which is referring to the requested resource, and a + * list of the FHIR Paths of the referring [Resource] where the requested resource is referred. The + * referring resource payload is deserialized into a [Resource] object. + */ +internal data class ReferringResource( + val resourceUuid: UUID, + val resourceId: String, + val resourceType: ResourceType, + val resource: Resource, + val referenceValue: String, + val referringPaths: List, +) 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 adf2611487..e10a48225e 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,9 +29,9 @@ 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 com.google.android.fhir.sync.upload.LocalChangeFetcherFactory import com.google.android.fhir.sync.upload.LocalChangesFetchMode +import com.google.android.fhir.sync.upload.consolidator.DefaultResourceConsolidator import java.time.OffsetDateTime import kotlinx.coroutines.flow.Flow import org.hl7.fhir.r4.model.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/consolidator/DefaultResourceConsolidator.kt similarity index 81% rename from engine/src/main/java/com/google/android/fhir/sync/upload/ResourceConsolidator.kt rename to engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/DefaultResourceConsolidator.kt index d8f19fc5a2..b2e2b0bcdf 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/consolidator/DefaultResourceConsolidator.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.android.fhir.sync.upload +package com.google.android.fhir.sync.upload.consolidator import com.google.android.fhir.LocalChangeToken import com.google.android.fhir.db.Database @@ -23,22 +23,6 @@ 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 { diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/PostPerResourceUrlRequestConsolidator.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/PostPerResourceUrlRequestConsolidator.kt new file mode 100644 index 0000000000..e7d9c0d2d7 --- /dev/null +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/PostPerResourceUrlRequestConsolidator.kt @@ -0,0 +1,200 @@ +/* + * 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.consolidator + +import ca.uhn.fhir.context.FhirContext +import ca.uhn.fhir.context.FhirVersionEnum +import ca.uhn.fhir.parser.IParser +import com.google.android.fhir.LocalChange +import com.google.android.fhir.LocalChangeToken +import com.google.android.fhir.db.Database +import com.google.android.fhir.db.impl.dao.ReferringResource +import com.google.android.fhir.logicalId +import com.google.android.fhir.sync.upload.request.UrlUploadRequest +import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.model.ResourceType +import org.json.JSONArray +import org.json.JSONObject + +/** + * Implementation of [ResourceConsolidator] for POST mode of creation of resources. The + * [LocalChange]s are squashed at a resource level and each resource request is uploaded + * individually using [UrlUploadRequest]. + * + * Since we know that all the changes will be squashed at a [Resource] level even for the subsequent + * changes, it is safe to assume that we can update references for only those resources whose + * references exist in the [ReferenceIndex]. i.e. any stale reference which exists in the + * [LocalChange] but does not exist in the [ReferenceIndex] will eventually be removed as a result + * of squashing [LocalChange] at a resource level. + */ +internal class PostPerResourceUrlRequestConsolidator( + private val database: Database, +) : ResourceConsolidator { + + private val defaultConsolidator: DefaultResourceConsolidator = + DefaultResourceConsolidator(database) + + private val fhirContext: FhirContext = FhirContext.forCached(FhirVersionEnum.R4) + + private val iParser: IParser = fhirContext.newJsonParser() + + override suspend fun consolidate(localChangeToken: LocalChangeToken, response: Resource) { + val localChanges = database.getAllLocalChanges(localChangeToken) + decideConsolidationByChangesType(localChanges, localChangeToken, response) + } + + private suspend fun decideConsolidationByChangesType( + localChanges: List, + localChangeToken: LocalChangeToken, + response: Resource, + ) { + if (localChanges.first().resourceId != response.logicalId) { + consolidateForResourceCreation(localChanges, response) + } + defaultConsolidator.consolidate(localChangeToken, response) + } + + private suspend fun consolidateForResourceCreation( + localChanges: List, + updatedResource: Resource, + ) { + val createdResourceType = ResourceType.fromCode(localChanges.first().resourceType) + val createdResourceLocalId = localChanges.first().resourceId + val createdResourceUpdatedId = updatedResource.logicalId + val resourceEntity = database.selectEntity(createdResourceType, createdResourceLocalId) + + // update the resource + database.updateResourceWithUuid(updatedResource, resourceEntity.resourceUuid) + + // update all the local changes for this resource with the new resource Id + database.updateResourceIdForResourceChanges( + resourceEntity.resourceType, + resourceEntity.resourceUuid, + createdResourceUpdatedId, + ) + + // consolidate all resource which refer to the newly created resource + val referringResourceWithReferringPaths = + database.getAllResourcesReferringToResourceWithPath( + createdResourceType, + createdResourceLocalId, + ) + val updatedReference = "${createdResourceType.name}/$createdResourceUpdatedId" + referringResourceWithReferringPaths.forEach { referringResource -> + updateReferencesInResource( + referringResource, + referringResource.referenceValue, + updatedReference, + ) + } + } + + private suspend fun updateReferencesInResource( + referringResource: ReferringResource, + currentReference: String, + updatedReference: String, + ) { + val resourceWithUpdatedReferences = + addUpdatedReferenceToResource( + referringResource.resource, + referringResource.referenceValue, + updatedReference, + ) + database.updateResourceWithUuid(resourceWithUpdatedReferences, referringResource.resourceUuid) + + val referringResourceChanges = + database.getLocalChanges(referringResource.resourceType, referringResource.resourceUuid) + val updatedResourceChanges = + referringResourceChanges.map { + replaceReferencesInLocalChange(it, currentReference, updatedReference) + } + database.replaceResourceChanges( + referringResource.resourceType, + referringResource.resourceUuid, + updatedResourceChanges, + ) + } + + private fun addUpdatedReferenceToResource( + resource: Resource, + outdatedReference: String, + updatedReference: String, + ): Resource { + val resourceJsonObject = JSONObject(iParser.encodeResourceToString(resource)) + val updatedResource = replaceJsonValue(resourceJsonObject, outdatedReference, updatedReference) + return iParser.parseResource(updatedResource.toString()) as Resource + } + + private fun replaceReferencesInLocalChange( + localChange: LocalChange, + oldReference: String, + updatedReference: String, + ): LocalChange { + return when (localChange.type) { + LocalChange.Type.INSERT -> { + val insertResourcePayload = iParser.parseResource(localChange.payload) as Resource + val updatedResourcePayload = + addUpdatedReferenceToResource(insertResourcePayload, oldReference, updatedReference) + return localChange.copy(payload = iParser.encodeResourceToString(updatedResourcePayload)) + } + LocalChange.Type.UPDATE -> { + val patchArray = JSONArray(localChange.payload) + val updatedPatchArray = JSONArray() + for (i in 0 until patchArray.length()) { + val updatedPatch = + replaceJsonValue(patchArray.getJSONObject(i), oldReference, updatedReference) + updatedPatchArray.put(updatedPatch) + } + return localChange.copy(payload = updatedPatchArray.toString()) + } + LocalChange.Type.DELETE -> localChange + } + } + + private fun replaceJsonValue( + jsonObject: JSONObject, + currentValue: String, + newValue: String, + ): JSONObject { + val iterator: Iterator<*> = jsonObject.keys() + var key: String? + while (iterator.hasNext()) { + key = iterator.next() as String + // if object is just string we change value in key + if (jsonObject.optJSONArray(key) == null && jsonObject.optJSONObject(key) == null) { + if (jsonObject.optString(key) == currentValue) { + jsonObject.put(key, newValue) + return jsonObject + } + } + + // if it's jsonobject + if (jsonObject.optJSONObject(key) != null) { + replaceJsonValue(jsonObject.getJSONObject(key), currentValue, newValue) + } + + // if it's jsonarray + if (jsonObject.optJSONArray(key) != null) { + val jArray = jsonObject.getJSONArray(key) + for (i in 0 until jArray.length()) { + replaceJsonValue(jArray.getJSONObject(i), currentValue, newValue) + } + } + } + return jsonObject + } +} diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/ResourceConsolidator.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/ResourceConsolidator.kt new file mode 100644 index 0000000000..da623614c8 --- /dev/null +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/ResourceConsolidator.kt @@ -0,0 +1,36 @@ +/* + * 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.consolidator + +import com.google.android.fhir.LocalChangeToken +import org.hl7.fhir.r4.model.Resource + +/** + * 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) +} From 499e02c489ec762085fc9a93c689a24f8691f0bb Mon Sep 17 00:00:00 2001 From: anchita-g <109063673+anchita-g@users.noreply.github.com> Date: Fri, 29 Sep 2023 15:57:14 +0530 Subject: [PATCH 08/21] review comments --- .../7.json | 9 ++-- .../db/impl/ResourceDatabaseMigrationTest.kt | 42 +++++++++---------- .../android/fhir/db/impl/ResourceDatabase.kt | 2 +- .../android/fhir/db/impl/dao/ResourceDao.kt | 5 ++- .../db/impl/entities/LocalChangeEntity.kt | 2 +- 5 files changed, 30 insertions(+), 30 deletions(-) diff --git a/engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/7.json b/engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/7.json index 0abe3607f1..bee64366e3 100644 --- a/engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/7.json +++ b/engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/7.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 7, - "identityHash": "f06ae2e84f7588be59916d32f8d5a6e4", + "identityHash": "112cd4c5acec0220c1a0298571087d0d", "entities": [ { "tableName": "ResourceEntity", @@ -858,14 +858,13 @@ "createSql": "CREATE INDEX IF NOT EXISTS `index_LocalChangeEntity_resourceType_resourceId` ON `${TABLE_NAME}` (`resourceType`, `resourceId`)" }, { - "name": "index_LocalChangeEntity_resourceType_resourceUuid", + "name": "index_LocalChangeEntity_resourceUuid", "unique": false, "columnNames": [ - "resourceType", "resourceUuid" ], "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_LocalChangeEntity_resourceType_resourceUuid` ON `${TABLE_NAME}` (`resourceType`, `resourceUuid`)" + "createSql": "CREATE INDEX IF NOT EXISTS `index_LocalChangeEntity_resourceUuid` ON `${TABLE_NAME}` (`resourceUuid`)" } ], "foreignKeys": [] @@ -951,7 +950,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f06ae2e84f7588be59916d32f8d5a6e4')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '112cd4c5acec0220c1a0298571087d0d')" ] } } \ No newline at end of file diff --git a/engine/src/androidTest/java/com/google/android/fhir/db/impl/ResourceDatabaseMigrationTest.kt b/engine/src/androidTest/java/com/google/android/fhir/db/impl/ResourceDatabaseMigrationTest.kt index 6fbad4521e..2eb7877e7b 100644 --- a/engine/src/androidTest/java/com/google/android/fhir/db/impl/ResourceDatabaseMigrationTest.kt +++ b/engine/src/androidTest/java/com/google/android/fhir/db/impl/ResourceDatabaseMigrationTest.kt @@ -70,13 +70,13 @@ class ResourceDatabaseMigrationTest { val migratedDatabase = helper.runMigrationsAndValidate(DB_NAME, 2, true, MIGRATION_1_2) val readPatientJson: String? - migratedDatabase.apply { - query("SELECT serializedResource FROM ResourceEntity").let { + migratedDatabase.let { database -> + database.query("SELECT serializedResource FROM ResourceEntity").let { it.moveToFirst() readPatientJson = it.getString(0) } } - + migratedDatabase.close() assertThat(readPatientJson).isEqualTo(insertedPatientJson) } @@ -104,13 +104,13 @@ class ResourceDatabaseMigrationTest { val migratedDatabase = helper.runMigrationsAndValidate(DB_NAME, 3, true, MIGRATION_2_3) val retrievedTask: String? - migratedDatabase.apply { - query("SELECT serializedResource FROM ResourceEntity").let { + migratedDatabase.let { database -> + database.query("SELECT serializedResource FROM ResourceEntity").let { it.moveToFirst() retrievedTask = it.getString(0) } } - + migratedDatabase.close() assertThat(retrievedTask).isEqualTo(bedNetTask) } @@ -138,13 +138,13 @@ class ResourceDatabaseMigrationTest { val migratedDatabase = helper.runMigrationsAndValidate(DB_NAME, 4, true, MIGRATION_3_4) val retrievedTask: String? - migratedDatabase.apply { - query("SELECT serializedResource FROM ResourceEntity").let { + migratedDatabase.let { database -> + database.query("SELECT serializedResource FROM ResourceEntity").let { it.moveToFirst() retrievedTask = it.getString(0) } } - + migratedDatabase.close() assertThat(retrievedTask).isEqualTo(bedNetTask) } @@ -171,13 +171,13 @@ class ResourceDatabaseMigrationTest { val migratedDatabase = helper.runMigrationsAndValidate(DB_NAME, 5, true, MIGRATION_4_5) val retrievedTask: String? - migratedDatabase.apply { - query("SELECT serializedResource FROM ResourceEntity").let { + migratedDatabase.let { database -> + database.query("SELECT serializedResource FROM ResourceEntity").let { it.moveToFirst() retrievedTask = it.getString(0) } } - + migratedDatabase.close() assertThat(retrievedTask).isEqualTo(bedNetTask) } @@ -218,26 +218,26 @@ class ResourceDatabaseMigrationTest { val resourceEntityLastUpdatedLocal: Long val localChangeEntityCorruptedTimeStamp: Long - migratedDatabase.apply { - query("SELECT serializedResource FROM ResourceEntity").let { + migratedDatabase.let { database -> + database.query("SELECT serializedResource FROM ResourceEntity").let { it.moveToFirst() retrievedTask = it.getString(0) } resourceEntityLastUpdatedLocal = - query("Select lastUpdatedLocal from ResourceEntity").let { + database.query("Select lastUpdatedLocal from ResourceEntity").let { it.moveToFirst() it.getLong(0) } - query("SELECT timestamp FROM LocalChangeEntity").let { + database.query("SELECT timestamp FROM LocalChangeEntity").let { it.moveToFirst() localChangeEntityTimeStamp = it.getLong(0) it.moveToNext() localChangeEntityCorruptedTimeStamp = it.getLong(0) } } - + migratedDatabase.close() assertThat(retrievedTask).isEqualTo(bedNetTask) assertThat(localChangeEntityTimeStamp).isEqualTo(resourceEntityLastUpdatedLocal) assertThat(Instant.ofEpochMilli(localChangeEntityCorruptedTimeStamp)).isEqualTo(Instant.EPOCH) @@ -275,20 +275,20 @@ class ResourceDatabaseMigrationTest { val localChangeResourceUuid: String? val localChangeResourceId: String? - migratedDatabase.apply { - query("SELECT resourceId, resourceUuid FROM ResourceEntity").let { + migratedDatabase.let { database -> + database.query("SELECT resourceId, resourceUuid FROM ResourceEntity").let { it.moveToFirst() retrievedTaskResourceId = it.getString(0) retrievedTaskResourceUuid = String(it.getBlob(1), Charsets.UTF_8) } - query("SELECT resourceId,resourceUuid FROM LocalChangeEntity").let { + database.query("SELECT resourceId,resourceUuid FROM LocalChangeEntity").let { it.moveToFirst() localChangeResourceId = it.getString(0) localChangeResourceUuid = String(it.getBlob(1), Charsets.UTF_8) } } - + migratedDatabase.close() assertThat(retrievedTaskResourceUuid).isEqualTo(localChangeResourceUuid) assertThat(localChangeResourceId).isEqualTo(retrievedTaskResourceId) } diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt b/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt index 45b3631da3..f69998fd49 100644 --- a/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt +++ b/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt @@ -145,7 +145,7 @@ val MIGRATION_6_7 = "CREATE INDEX IF NOT EXISTS `index_LocalChangeEntity_resourceType_resourceId` ON `LocalChangeEntity` (`resourceType`, `resourceId`)", ) database.execSQL( - "CREATE INDEX IF NOT EXISTS `index_LocalChangeEntity_resourceType_resourceUuid` ON `LocalChangeEntity` (`resourceType`, `resourceUuid`)", + "CREATE INDEX IF NOT EXISTS `index_LocalChangeEntity_resourceUuid` ON `LocalChangeEntity` (`resourceUuid`)", ) } } 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 59584cbd79..d7f25d388f 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 @@ -183,8 +183,9 @@ internal abstract class ResourceDao { suspend fun insertLocalResource(resource: Resource, timeOfChange: Instant) = insertResource(resource, timeOfChange) - // Since the insert removes any old indexes and lastUpdatedLocal (data not contained in resource - // itself), we extract the lastUpdatedLocal if any and then set it back again. + // Check if the resource already exists using its logical ID, if it does, we just update the + // existing [ResourceEntity] + // Else, we insert with a new [ResourceEntity] private suspend fun insertRemoteResource(resource: Resource): UUID { val existingResourceEntity = getResourceEntity(resource.logicalId, resource.resourceType) if (existingResourceEntity != null) { diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeEntity.kt b/engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeEntity.kt index 29955365fb..eb278712ec 100644 --- a/engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeEntity.kt +++ b/engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeEntity.kt @@ -60,7 +60,7 @@ import java.util.UUID indices = [ Index(value = ["resourceType", "resourceId"]), - Index(value = ["resourceType", "resourceUuid"]), + Index(value = ["resourceUuid"]), ], ) internal data class LocalChangeEntity( From f28eaf7cace20bbe78b6fd047cce1266d7810545 Mon Sep 17 00:00:00 2001 From: anchita-g <109063673+anchita-g@users.noreply.github.com> Date: Tue, 3 Oct 2023 12:04:21 +0530 Subject: [PATCH 09/21] adding more tests --- ...stPerResourceUrlRequestConsolidatorTest.kt | 152 ++++++++++++++++-- 1 file changed, 137 insertions(+), 15 deletions(-) diff --git a/engine/src/androidTest/java/com/google/android/fhir/sync/upload/consolidator/PostPerResourceUrlRequestConsolidatorTest.kt b/engine/src/androidTest/java/com/google/android/fhir/sync/upload/consolidator/PostPerResourceUrlRequestConsolidatorTest.kt index a03340edfb..a16a1fe799 100644 --- a/engine/src/androidTest/java/com/google/android/fhir/sync/upload/consolidator/PostPerResourceUrlRequestConsolidatorTest.kt +++ b/engine/src/androidTest/java/com/google/android/fhir/sync/upload/consolidator/PostPerResourceUrlRequestConsolidatorTest.kt @@ -28,7 +28,10 @@ import com.google.android.fhir.db.Database import com.google.android.fhir.logicalId import com.google.android.fhir.search.Search import com.google.android.fhir.search.getQuery +import com.google.android.fhir.testing.assertResourceEquals import com.google.common.truth.Truth.assertThat +import java.time.Instant +import java.util.Date import kotlinx.coroutines.runBlocking import org.hl7.fhir.r4.model.HumanName import org.hl7.fhir.r4.model.Observation @@ -75,22 +78,24 @@ class PostPerResourceUrlRequestConsolidatorTest { @Test fun insertPatientAndReferringResource_shouldUpdateReferencesAndUpdateResourceId() = runBlocking { + // create a patient val locallyCreatedPatientResourceId = "local-patient-1" val locallyCreatedPatient = Patient().apply { id = locallyCreatedPatientResourceId name = listOf(HumanName().setFamily("Family").setGiven(listOf(StringType("First Name")))) } + database.insert(locallyCreatedPatient) + // create an observation for the patient val locallyCreatedObservationResourceId = "local-observation-1" val locallyCreatedPatientObservation = Observation().apply { subject = Reference("Patient/$locallyCreatedPatientResourceId") id = locallyCreatedObservationResourceId } - - database.insert(locallyCreatedPatient) database.insert(locallyCreatedPatientObservation) + val patientResourceEntity = database.selectEntity(locallyCreatedPatient.resourceType, locallyCreatedPatientResourceId) @@ -102,18 +107,19 @@ class PostPerResourceUrlRequestConsolidatorTest { val remotelyCreatedPatient = locallyCreatedPatient.apply { id = remotelyCreatedPatientResourceId } + // perform consolidation postPerResourceUrlRequestConsolidator.consolidate( LocalChangeToken(patientLocalChanges.flatMap { it.token.ids }), remotelyCreatedPatient, ) - // check if resource is fetch-able by its new ID + // check if resource is fetch-able by its new server assigned ID val updatedPatientResourceEntity = database.selectEntity(remotelyCreatedPatient.resourceType, remotelyCreatedPatient.id) assertThat(updatedPatientResourceEntity.resourceUuid) .isEqualTo(patientResourceEntity.resourceUuid) - // verify that all the local changes are deleted for this synced resource + // verify that all the local changes are deleted for this newly server created resource val patientLocalChangesAfterConsolidation = database.getLocalChanges( locallyCreatedPatient.resourceType, @@ -121,7 +127,7 @@ class PostPerResourceUrlRequestConsolidatorTest { ) assertThat(patientLocalChangesAfterConsolidation).isEmpty() - // verify that Observation is updated + // verify that Observation is updated with new patient ID reference val updatedObservationResource = database.select( locallyCreatedPatientObservation.resourceType, @@ -130,11 +136,11 @@ class PostPerResourceUrlRequestConsolidatorTest { assertThat(updatedObservationResource.subject.reference) .isEqualTo("Patient/$remotelyCreatedPatientResourceId") - // verify that Observation's LocalChanges are updated + // verify that Observation's LocalChanges are updated with new patient ID reference val updatedObservationLocalChanges = database.getLocalChanges( locallyCreatedPatientObservation.resourceType, - locallyCreatedObservationResourceId + locallyCreatedObservationResourceId, ) assertThat(updatedObservationLocalChanges.size).isEqualTo(1) val observationLocalChange = updatedObservationLocalChanges[0] @@ -144,7 +150,8 @@ class PostPerResourceUrlRequestConsolidatorTest { assertThat(observationLocalChangePayload.subject.reference) .isEqualTo("Patient/$remotelyCreatedPatientResourceId") - // verify that Observation is searchable i.e. ReferenceIndex is updated + // verify that Observation is searchable i.e. ReferenceIndex is updated with new patient ID + // reference val searchedObservations = database.search( Search(ResourceType.Observation) @@ -163,6 +170,7 @@ class PostPerResourceUrlRequestConsolidatorTest { @Test fun insertPatientAndInsertUpdateReferringResource_shouldUpdateReferencesAndUpdateResourceId() = runBlocking { + // create a new patient val locallyCreatedPatientResourceId = "local-patient-1" val locallyCreatedPatient = Patient().apply { @@ -171,6 +179,7 @@ class PostPerResourceUrlRequestConsolidatorTest { } database.insert(locallyCreatedPatient) + // create an observation for the new patient val locallyCreatedObservationResourceId = "local-observation-1" val locallyCreatedPatientObservation = Observation().apply { @@ -178,10 +187,12 @@ class PostPerResourceUrlRequestConsolidatorTest { id = locallyCreatedObservationResourceId } database.insert(locallyCreatedPatientObservation) + // update the observation resource (so that there are multiple local changes with references + // to the same patient) database.update( locallyCreatedPatientObservation.apply { performer = listOf(Reference("Patient/$locallyCreatedPatientResourceId")) - } + }, ) val patientResourceEntity = @@ -190,7 +201,7 @@ class PostPerResourceUrlRequestConsolidatorTest { val patientLocalChanges = database.getLocalChanges( locallyCreatedPatient.resourceType, - locallyCreatedPatientResourceId + locallyCreatedPatientResourceId, ) val observationLocalChanges = database.getLocalChanges( @@ -215,7 +226,7 @@ class PostPerResourceUrlRequestConsolidatorTest { assertThat(updatedPatientResourceEntity.resourceUuid) .isEqualTo(patientResourceEntity.resourceUuid) - // verify that all the local changes are deleted for this synced resource + // verify that all the local changes are deleted for this newly created resource val patientLocalChangesAfterConsolidation = database.getLocalChanges( locallyCreatedPatient.resourceType, @@ -236,7 +247,7 @@ class PostPerResourceUrlRequestConsolidatorTest { val updatedObservationLocalChanges = database.getLocalChanges( locallyCreatedPatientObservation.resourceType, - locallyCreatedObservationResourceId + locallyCreatedObservationResourceId, ) assertThat(updatedObservationLocalChanges.size).isEqualTo(2) val observationLocalChange1 = updatedObservationLocalChanges[0] @@ -272,7 +283,118 @@ class PostPerResourceUrlRequestConsolidatorTest { assertThat(searchedObservations[0].logicalId).isEqualTo(locallyCreatedObservationResourceId) } - // update and insert - // update and update - // + @Test + fun updatePatientAndInsertUpdateReferringResource_shouldUpdateReferencesAndUpdateResourceId() = + runBlocking { + // imitate syncing of a patient from the server + val serverCreatedPatientResourceId = "remote-patient-1" + val serverSyncedPatient = + Patient().apply { + id = serverCreatedPatientResourceId + name = listOf(HumanName().setFamily("Family").setGiven(listOf(StringType("First Name")))) + } + database.insertRemote(serverSyncedPatient) + + // update the patient resource + val updatedPatient = + serverSyncedPatient.apply { + name = + listOf( + HumanName().setFamily("Updated Family").setGiven(listOf(StringType("First Name"))) + ) + } + database.update(updatedPatient) + + // create an observation for the new patient + val locallyCreatedObservationResourceId = "local-observation-1" + val locallyCreatedPatientObservation = + Observation().apply { + subject = Reference("Patient/$serverCreatedPatientResourceId") + id = locallyCreatedObservationResourceId + } + database.insert(locallyCreatedPatientObservation) + // update the observation (so that there are multiple local changes with references to the + // same patient) + database.update( + locallyCreatedPatientObservation.apply { + performer = listOf(Reference("Patient/$serverCreatedPatientResourceId")) + }, + ) + + val patientResourceEntity = + database.selectEntity(serverSyncedPatient.resourceType, serverSyncedPatient.id) + + val patientLocalChanges = + database.getLocalChanges( + serverSyncedPatient.resourceType, + serverCreatedPatientResourceId, + ) + assertThat(patientLocalChanges.size).isEqualTo(1) + val observationLocalChanges = + database.getLocalChanges( + locallyCreatedPatientObservation.resourceType, + locallyCreatedObservationResourceId, + ) + assertThat(observationLocalChanges.size).isEqualTo(2) + + // pretend that the resource has been updated on the server + val serverUpdatedTime = Date.from(Instant.now()) + val serverImitatedResponse = updatedPatient.apply { meta.lastUpdated = serverUpdatedTime } + postPerResourceUrlRequestConsolidator.consolidate( + LocalChangeToken(patientLocalChanges.flatMap { it.token.ids }), + serverImitatedResponse, + ) + + // check if resource is fetch-able by its new ID + val updatedPatientResourceEntity = + database.selectEntity(updatedPatient.resourceType, serverImitatedResponse.id) + assertThat(updatedPatientResourceEntity.resourceUuid) + .isEqualTo(patientResourceEntity.resourceUuid) + + // verify that all the local changes are deleted for this newly created resource + val patientLocalChangesAfterConsolidation = + database.getLocalChanges( + serverImitatedResponse.resourceType, + updatedPatientResourceEntity.resourceUuid, + ) + assertThat(patientLocalChangesAfterConsolidation).isEmpty() + + // verify that Observation is still the same as the last version + val observationResource = + database.select( + locallyCreatedPatientObservation.resourceType, + locallyCreatedObservationResourceId, + ) as Observation + assertResourceEquals(locallyCreatedPatientObservation, observationResource) + + // verify that Observation's LocalChanges are retained + val observationLocalChangesPostConsolidation = + database.getLocalChanges( + locallyCreatedPatientObservation.resourceType, + locallyCreatedObservationResourceId, + ) + assertThat(observationLocalChanges.size) + .isEqualTo(observationLocalChangesPostConsolidation.size) + observationLocalChanges.forEachIndexed { changeIndex, originalObservationChange -> + assertThat(originalObservationChange.token.ids) + .containsExactlyElementsIn( + observationLocalChangesPostConsolidation.get(changeIndex).token.ids + ) + } + + // verify that Observation is searchable i.e. ReferenceIndex is updated + val searchedObservations = + database.search( + Search(ResourceType.Observation) + .apply { + filter( + Observation.SUBJECT, + { value = "Patient/$serverCreatedPatientResourceId" }, + ) + } + .getQuery(), + ) + assertThat(searchedObservations.size).isEqualTo(1) + assertThat(searchedObservations[0].logicalId).isEqualTo(locallyCreatedObservationResourceId) + } } From 8a687affe0d436a00d631dcb0308e39ed21d1c68 Mon Sep 17 00:00:00 2001 From: anchita-g <109063673+anchita-g@users.noreply.github.com> Date: Tue, 3 Oct 2023 15:01:02 +0530 Subject: [PATCH 10/21] fix failing test cases --- .../com/google/android/fhir/impl/FhirEngineImplTest.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 fbf400007e..2ecabd7ec5 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 @@ -102,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!", ) } @@ -161,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!", ) } @@ -423,7 +423,7 @@ class FhirEngineImplTest { runBlocking { fhirEngine.get(ResourceType.Patient, patient.logicalId) } } assertThat(resourceNotFoundException.message) - .isEqualTo("Resource not found with type Patient and id ${patient.logicalId}!") + .isEqualTo("Resource not found with type Patient and ID ${patient.logicalId}!") } @Test @@ -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() } @@ -462,7 +462,7 @@ 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!", ) } From 78ce39c188cbe95bb6e3c49324d59d479881887b Mon Sep 17 00:00:00 2001 From: anchita-g <109063673+anchita-g@users.noreply.github.com> Date: Fri, 6 Oct 2023 12:37:24 +0530 Subject: [PATCH 11/21] Introducing LocalChangeReference --- .../8.json | 1016 +++++++++++++++++ .../android/fhir/db/impl/DatabaseImplTest.kt | 316 ++++- .../db/impl/EncryptedDatabaseErrorTest.kt | 11 + ...stPerResourceUrlRequestConsolidatorTest.kt | 397 ------- .../com/google/android/fhir/FhirServices.kt | 3 + .../com/google/android/fhir/db/Database.kt | 46 +- .../fhir/db/ResourceNotFoundException.kt | 33 +- .../android/fhir/db/impl/DatabaseImpl.kt | 127 +-- .../google/android/fhir/db/impl/JsonUtils.kt | 65 ++ .../android/fhir/db/impl/ResourceDatabase.kt | 4 +- .../fhir/db/impl/dao/LocalChangeDao.kt | 233 +++- .../android/fhir/db/impl/dao/ResourceDao.kt | 39 +- .../LocalChangeResourceReferenceEntity.kt | 46 + .../android/fhir/impl/FhirEngineImpl.kt | 5 +- .../DefaultResourceConsolidator.kt | 26 +- .../PostPerResourceUrlRequestConsolidator.kt | 198 ---- .../consolidator/ResourceConsolidator.kt | 4 +- .../android/fhir/impl/FhirEngineImplTest.kt | 10 +- 18 files changed, 1754 insertions(+), 825 deletions(-) create mode 100644 engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/8.json delete mode 100644 engine/src/androidTest/java/com/google/android/fhir/sync/upload/consolidator/PostPerResourceUrlRequestConsolidatorTest.kt create mode 100644 engine/src/main/java/com/google/android/fhir/db/impl/JsonUtils.kt create mode 100644 engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeResourceReferenceEntity.kt delete mode 100644 engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/PostPerResourceUrlRequestConsolidator.kt diff --git a/engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/8.json b/engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/8.json new file mode 100644 index 0000000000..845e56977e --- /dev/null +++ b/engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/8.json @@ -0,0 +1,1016 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "8dddbb1e8b44cb78ebe88f61aeab9aeb", + "entities": [ + { + "tableName": "ResourceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceUuid` BLOB NOT NULL, `resourceType` TEXT NOT NULL, `resourceId` TEXT NOT NULL, `serializedResource` TEXT NOT NULL, `versionId` TEXT, `lastUpdatedRemote` INTEGER, `lastUpdatedLocal` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUuid", + "columnName": "resourceUuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "resourceId", + "columnName": "resourceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serializedResource", + "columnName": "serializedResource", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionId", + "columnName": "versionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdatedRemote", + "columnName": "lastUpdatedRemote", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastUpdatedLocal", + "columnName": "lastUpdatedLocal", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_ResourceEntity_resourceUuid", + "unique": true, + "columnNames": [ + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ResourceEntity_resourceUuid` ON `${TABLE_NAME}` (`resourceUuid`)" + }, + { + "name": "index_ResourceEntity_resourceType_resourceId", + "unique": true, + "columnNames": [ + "resourceType", + "resourceId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ResourceEntity_resourceType_resourceId` ON `${TABLE_NAME}` (`resourceType`, `resourceId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "StringIndexEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceUuid` BLOB NOT NULL, `resourceType` TEXT NOT NULL, `index_name` TEXT NOT NULL, `index_path` TEXT NOT NULL, `index_value` TEXT NOT NULL, FOREIGN KEY(`resourceUuid`) REFERENCES `ResourceEntity`(`resourceUuid`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUuid", + "columnName": "resourceUuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.name", + "columnName": "index_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.path", + "columnName": "index_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.value", + "columnName": "index_value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_StringIndexEntity_resourceType_index_name_index_value", + "unique": false, + "columnNames": [ + "resourceType", + "index_name", + "index_value" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_StringIndexEntity_resourceType_index_name_index_value` ON `${TABLE_NAME}` (`resourceType`, `index_name`, `index_value`)" + }, + { + "name": "index_StringIndexEntity_resourceUuid", + "unique": false, + "columnNames": [ + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_StringIndexEntity_resourceUuid` ON `${TABLE_NAME}` (`resourceUuid`)" + } + ], + "foreignKeys": [ + { + "table": "ResourceEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "resourceUuid" + ], + "referencedColumns": [ + "resourceUuid" + ] + } + ] + }, + { + "tableName": "ReferenceIndexEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceUuid` BLOB NOT NULL, `resourceType` TEXT NOT NULL, `index_name` TEXT NOT NULL, `index_path` TEXT NOT NULL, `index_value` TEXT NOT NULL, FOREIGN KEY(`resourceUuid`) REFERENCES `ResourceEntity`(`resourceUuid`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUuid", + "columnName": "resourceUuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.name", + "columnName": "index_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.path", + "columnName": "index_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.value", + "columnName": "index_value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_ReferenceIndexEntity_resourceType_index_name_index_value", + "unique": false, + "columnNames": [ + "resourceType", + "index_name", + "index_value" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ReferenceIndexEntity_resourceType_index_name_index_value` ON `${TABLE_NAME}` (`resourceType`, `index_name`, `index_value`)" + }, + { + "name": "index_ReferenceIndexEntity_resourceUuid", + "unique": false, + "columnNames": [ + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ReferenceIndexEntity_resourceUuid` ON `${TABLE_NAME}` (`resourceUuid`)" + } + ], + "foreignKeys": [ + { + "table": "ResourceEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "resourceUuid" + ], + "referencedColumns": [ + "resourceUuid" + ] + } + ] + }, + { + "tableName": "TokenIndexEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceUuid` BLOB NOT NULL, `resourceType` TEXT NOT NULL, `index_name` TEXT NOT NULL, `index_path` TEXT NOT NULL, `index_system` TEXT, `index_value` TEXT NOT NULL, FOREIGN KEY(`resourceUuid`) REFERENCES `ResourceEntity`(`resourceUuid`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUuid", + "columnName": "resourceUuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.name", + "columnName": "index_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.path", + "columnName": "index_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.system", + "columnName": "index_system", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "index.value", + "columnName": "index_value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_TokenIndexEntity_resourceType_index_name_index_system_index_value_resourceUuid", + "unique": false, + "columnNames": [ + "resourceType", + "index_name", + "index_system", + "index_value", + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TokenIndexEntity_resourceType_index_name_index_system_index_value_resourceUuid` ON `${TABLE_NAME}` (`resourceType`, `index_name`, `index_system`, `index_value`, `resourceUuid`)" + }, + { + "name": "index_TokenIndexEntity_resourceUuid", + "unique": false, + "columnNames": [ + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TokenIndexEntity_resourceUuid` ON `${TABLE_NAME}` (`resourceUuid`)" + } + ], + "foreignKeys": [ + { + "table": "ResourceEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "resourceUuid" + ], + "referencedColumns": [ + "resourceUuid" + ] + } + ] + }, + { + "tableName": "QuantityIndexEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceUuid` BLOB NOT NULL, `resourceType` TEXT NOT NULL, `index_name` TEXT NOT NULL, `index_path` TEXT NOT NULL, `index_system` TEXT NOT NULL, `index_code` TEXT NOT NULL, `index_value` REAL NOT NULL, FOREIGN KEY(`resourceUuid`) REFERENCES `ResourceEntity`(`resourceUuid`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUuid", + "columnName": "resourceUuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.name", + "columnName": "index_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.path", + "columnName": "index_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.system", + "columnName": "index_system", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.code", + "columnName": "index_code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.value", + "columnName": "index_value", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_QuantityIndexEntity_resourceType_index_name_index_value_index_code", + "unique": false, + "columnNames": [ + "resourceType", + "index_name", + "index_value", + "index_code" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_QuantityIndexEntity_resourceType_index_name_index_value_index_code` ON `${TABLE_NAME}` (`resourceType`, `index_name`, `index_value`, `index_code`)" + }, + { + "name": "index_QuantityIndexEntity_resourceUuid", + "unique": false, + "columnNames": [ + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_QuantityIndexEntity_resourceUuid` ON `${TABLE_NAME}` (`resourceUuid`)" + } + ], + "foreignKeys": [ + { + "table": "ResourceEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "resourceUuid" + ], + "referencedColumns": [ + "resourceUuid" + ] + } + ] + }, + { + "tableName": "UriIndexEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceUuid` BLOB NOT NULL, `resourceType` TEXT NOT NULL, `index_name` TEXT NOT NULL, `index_path` TEXT NOT NULL, `index_value` TEXT NOT NULL, FOREIGN KEY(`resourceUuid`) REFERENCES `ResourceEntity`(`resourceUuid`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUuid", + "columnName": "resourceUuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.name", + "columnName": "index_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.path", + "columnName": "index_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.value", + "columnName": "index_value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_UriIndexEntity_resourceType_index_name_index_value", + "unique": false, + "columnNames": [ + "resourceType", + "index_name", + "index_value" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UriIndexEntity_resourceType_index_name_index_value` ON `${TABLE_NAME}` (`resourceType`, `index_name`, `index_value`)" + }, + { + "name": "index_UriIndexEntity_resourceUuid", + "unique": false, + "columnNames": [ + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UriIndexEntity_resourceUuid` ON `${TABLE_NAME}` (`resourceUuid`)" + } + ], + "foreignKeys": [ + { + "table": "ResourceEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "resourceUuid" + ], + "referencedColumns": [ + "resourceUuid" + ] + } + ] + }, + { + "tableName": "DateIndexEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceUuid` BLOB NOT NULL, `resourceType` TEXT NOT NULL, `index_name` TEXT NOT NULL, `index_path` TEXT NOT NULL, `index_from` INTEGER NOT NULL, `index_to` INTEGER NOT NULL, FOREIGN KEY(`resourceUuid`) REFERENCES `ResourceEntity`(`resourceUuid`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUuid", + "columnName": "resourceUuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.name", + "columnName": "index_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.path", + "columnName": "index_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.from", + "columnName": "index_from", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "index.to", + "columnName": "index_to", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_DateIndexEntity_resourceType_index_name_resourceUuid_index_from_index_to", + "unique": false, + "columnNames": [ + "resourceType", + "index_name", + "resourceUuid", + "index_from", + "index_to" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DateIndexEntity_resourceType_index_name_resourceUuid_index_from_index_to` ON `${TABLE_NAME}` (`resourceType`, `index_name`, `resourceUuid`, `index_from`, `index_to`)" + }, + { + "name": "index_DateIndexEntity_resourceUuid", + "unique": false, + "columnNames": [ + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DateIndexEntity_resourceUuid` ON `${TABLE_NAME}` (`resourceUuid`)" + } + ], + "foreignKeys": [ + { + "table": "ResourceEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "resourceUuid" + ], + "referencedColumns": [ + "resourceUuid" + ] + } + ] + }, + { + "tableName": "DateTimeIndexEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceUuid` BLOB NOT NULL, `resourceType` TEXT NOT NULL, `index_name` TEXT NOT NULL, `index_path` TEXT NOT NULL, `index_from` INTEGER NOT NULL, `index_to` INTEGER NOT NULL, FOREIGN KEY(`resourceUuid`) REFERENCES `ResourceEntity`(`resourceUuid`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUuid", + "columnName": "resourceUuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.name", + "columnName": "index_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.path", + "columnName": "index_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.from", + "columnName": "index_from", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "index.to", + "columnName": "index_to", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_DateTimeIndexEntity_resourceType_index_name_resourceUuid_index_from_index_to", + "unique": false, + "columnNames": [ + "resourceType", + "index_name", + "resourceUuid", + "index_from", + "index_to" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DateTimeIndexEntity_resourceType_index_name_resourceUuid_index_from_index_to` ON `${TABLE_NAME}` (`resourceType`, `index_name`, `resourceUuid`, `index_from`, `index_to`)" + }, + { + "name": "index_DateTimeIndexEntity_resourceUuid", + "unique": false, + "columnNames": [ + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DateTimeIndexEntity_resourceUuid` ON `${TABLE_NAME}` (`resourceUuid`)" + } + ], + "foreignKeys": [ + { + "table": "ResourceEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "resourceUuid" + ], + "referencedColumns": [ + "resourceUuid" + ] + } + ] + }, + { + "tableName": "NumberIndexEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceUuid` BLOB NOT NULL, `resourceType` TEXT NOT NULL, `index_name` TEXT NOT NULL, `index_path` TEXT NOT NULL, `index_value` REAL NOT NULL, FOREIGN KEY(`resourceUuid`) REFERENCES `ResourceEntity`(`resourceUuid`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUuid", + "columnName": "resourceUuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.name", + "columnName": "index_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.path", + "columnName": "index_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.value", + "columnName": "index_value", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_NumberIndexEntity_resourceType_index_name_index_value", + "unique": false, + "columnNames": [ + "resourceType", + "index_name", + "index_value" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NumberIndexEntity_resourceType_index_name_index_value` ON `${TABLE_NAME}` (`resourceType`, `index_name`, `index_value`)" + }, + { + "name": "index_NumberIndexEntity_resourceUuid", + "unique": false, + "columnNames": [ + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NumberIndexEntity_resourceUuid` ON `${TABLE_NAME}` (`resourceUuid`)" + } + ], + "foreignKeys": [ + { + "table": "ResourceEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "resourceUuid" + ], + "referencedColumns": [ + "resourceUuid" + ] + } + ] + }, + { + "tableName": "LocalChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceType` TEXT NOT NULL, `resourceId` TEXT NOT NULL, `resourceUuid` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, `type` INTEGER NOT NULL, `payload` TEXT NOT NULL, `versionId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "resourceId", + "columnName": "resourceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "resourceUuid", + "columnName": "resourceUuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionId", + "columnName": "versionId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_LocalChangeEntity_resourceType_resourceId", + "unique": false, + "columnNames": [ + "resourceType", + "resourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LocalChangeEntity_resourceType_resourceId` ON `${TABLE_NAME}` (`resourceType`, `resourceId`)" + }, + { + "name": "index_LocalChangeEntity_resourceUuid", + "unique": false, + "columnNames": [ + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LocalChangeEntity_resourceUuid` ON `${TABLE_NAME}` (`resourceUuid`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PositionIndexEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceUuid` BLOB NOT NULL, `resourceType` TEXT NOT NULL, `index_latitude` REAL NOT NULL, `index_longitude` REAL NOT NULL, FOREIGN KEY(`resourceUuid`) REFERENCES `ResourceEntity`(`resourceUuid`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUuid", + "columnName": "resourceUuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "resourceType", + "columnName": "resourceType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index.latitude", + "columnName": "index_latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "index.longitude", + "columnName": "index_longitude", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_PositionIndexEntity_resourceType_index_latitude_index_longitude", + "unique": false, + "columnNames": [ + "resourceType", + "index_latitude", + "index_longitude" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PositionIndexEntity_resourceType_index_latitude_index_longitude` ON `${TABLE_NAME}` (`resourceType`, `index_latitude`, `index_longitude`)" + }, + { + "name": "index_PositionIndexEntity_resourceUuid", + "unique": false, + "columnNames": [ + "resourceUuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PositionIndexEntity_resourceUuid` ON `${TABLE_NAME}` (`resourceUuid`)" + } + ], + "foreignKeys": [ + { + "table": "ResourceEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "resourceUuid" + ], + "referencedColumns": [ + "resourceUuid" + ] + } + ] + }, + { + "tableName": "LocalChangeResourceReferenceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `localChangeId` INTEGER NOT NULL, `resourceReferenceValue` TEXT NOT NULL, `resourceReferenceName` TEXT NOT NULL, FOREIGN KEY(`localChangeId`) REFERENCES `LocalChangeEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localChangeId", + "columnName": "localChangeId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceReferenceValue", + "columnName": "resourceReferenceValue", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "resourceReferenceName", + "columnName": "resourceReferenceName", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_LocalChangeResourceReferenceEntity_resourceReferenceValue", + "unique": false, + "columnNames": [ + "resourceReferenceValue" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LocalChangeResourceReferenceEntity_resourceReferenceValue` ON `${TABLE_NAME}` (`resourceReferenceValue`)" + } + ], + "foreignKeys": [ + { + "table": "LocalChangeEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "localChangeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8dddbb1e8b44cb78ebe88f61aeab9aeb')" + ] + } +} \ No newline at end of file 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 b80c96891a..a220440af8 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,7 @@ 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.rest.gclient.StringClientParam import ca.uhn.fhir.rest.param.ParamPrefixEnum import com.google.android.fhir.DateProvider @@ -79,6 +80,7 @@ import org.hl7.fhir.r4.model.RiskAssessment import org.hl7.fhir.r4.model.SearchParameter import org.hl7.fhir.r4.model.StringType import org.json.JSONArray +import org.json.JSONObject import org.junit.After import org.junit.Assert.assertThrows import org.junit.Before @@ -104,6 +106,7 @@ class DatabaseImplTest { private val context: Context = ApplicationProvider.getApplicationContext() private lateinit var services: FhirServices private lateinit var database: Database + private val iParser = FhirContext.forR4Cached().newJsonParser() @Before fun setUp(): Unit = runBlocking { @@ -255,7 +258,7 @@ class DatabaseImplTest { runBlocking { database.select(ResourceType.Patient, patient.logicalId) } } assertThat(resourceNotFoundException.message) - .isEqualTo("Resource not found with type Patient and ID ${patient.logicalId}!") + .isEqualTo("Resource not found with type Patient and id ${patient.logicalId}!") } @Test @@ -268,7 +271,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() } @@ -299,7 +302,7 @@ class DatabaseImplTest { runBlocking { database.select(ResourceType.Patient, TEST_PATIENT_2_ID) } } assertThat(resourceNotFoundException.message) - .isEqualTo("Resource not found with type ${ResourceType.Patient} and ID $TEST_PATIENT_2_ID!") + .isEqualTo("Resource not found with type ${ResourceType.Patient} and id $TEST_PATIENT_2_ID!") } @Test @@ -316,7 +319,7 @@ class DatabaseImplTest { runBlocking { database.select(ResourceType.Patient, TEST_PATIENT_2_ID) } } assertThat(resourceNotFoundException.message) - .isEqualTo("Resource not found with type ${ResourceType.Patient} and ID $TEST_PATIENT_2_ID!") + .isEqualTo("Resource not found with type ${ResourceType.Patient} and id $TEST_PATIENT_2_ID!") } @Test @@ -327,7 +330,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!", ) } @@ -339,7 +342,7 @@ class DatabaseImplTest { } assertThat(resourceNotFoundException.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!", ) } @@ -350,7 +353,7 @@ class DatabaseImplTest { runBlocking { database.select(ResourceType.Patient, "nonexistent_patient") } } assertThat(resourceNotFoundException.message) - .isEqualTo("Resource not found with type Patient and ID nonexistent_patient!") + .isEqualTo("Resource not found with type Patient and id nonexistent_patient!") } @Test @@ -3527,6 +3530,305 @@ class DatabaseImplTest { ) } + @Test + fun updateResourceAndId_insertPatientAndReferringResource_shouldUpdateReferencesAndUpdateResourceId() = + runBlocking { + // create a patient + val locallyCreatedPatientResourceId = "local-patient-1" + val locallyCreatedPatient = + Patient().apply { + id = locallyCreatedPatientResourceId + name = listOf(HumanName().setFamily("Family").setGiven(listOf(StringType("First Name")))) + } + database.insert(locallyCreatedPatient) + + // create an observation for the patient + val locallyCreatedObservationResourceId = "local-observation-1" + val locallyCreatedPatientObservation = + Observation().apply { + subject = Reference("Patient/$locallyCreatedPatientResourceId") + addPerformer(Reference("Practitioner/123")) + id = locallyCreatedObservationResourceId + } + database.insert(locallyCreatedPatientObservation) + + val patientResourceEntity = + database.selectEntity(locallyCreatedPatient.resourceType, locallyCreatedPatientResourceId) + + // pretend that the resource has been created on the server with an updated ID + val remotelyCreatedPatientResourceId = "remote-patient-1" + val remotelyCreatedPatient = + locallyCreatedPatient.apply { id = remotelyCreatedPatientResourceId } + + // perform updates + database.updateResourceAndId( + locallyCreatedPatientResourceId, + remotelyCreatedPatient, + ) + + // check if resource is fetch-able by its new server assigned ID + val updatedPatientResourceEntity = + database.selectEntity(remotelyCreatedPatient.resourceType, remotelyCreatedPatient.id) + assertThat(updatedPatientResourceEntity.resourceUuid) + .isEqualTo(patientResourceEntity.resourceUuid) + + // verify that all the local changes for this resource have the new resource ID + val patientLocalChangesAfterUpdate = + database.getLocalChanges( + updatedPatientResourceEntity.resourceUuid, + ) + assertThat(patientLocalChangesAfterUpdate.size).isEqualTo(1) + val firstPatientLocalChange = patientLocalChangesAfterUpdate[0] + assertThat(firstPatientLocalChange.resourceId).isEqualTo(remotelyCreatedPatientResourceId) + + // verify that Observation is updated with new patient ID reference + val updatedObservationResource = + database.select( + locallyCreatedPatientObservation.resourceType, + locallyCreatedObservationResourceId, + ) as Observation + assertThat(updatedObservationResource.subject.reference) + .isEqualTo("Patient/$remotelyCreatedPatientResourceId") + + // verify that Observation's LocalChanges are updated with new patient ID reference + val updatedObservationLocalChanges = + database.getLocalChanges( + locallyCreatedPatientObservation.resourceType, + locallyCreatedObservationResourceId, + ) + assertThat(updatedObservationLocalChanges.size).isEqualTo(1) + val observationLocalChange = updatedObservationLocalChanges[0] + assertThat(observationLocalChange.type).isEqualTo(LocalChange.Type.INSERT) + val observationLocalChangePayload = + iParser.parseResource(observationLocalChange.payload) as Observation + assertThat(observationLocalChangePayload.subject.reference) + .isEqualTo("Patient/$remotelyCreatedPatientResourceId") + + // verify that Observation is searchable i.e. ReferenceIndex is updated with new patient ID + // reference + val searchedObservations = + database.search( + Search(ResourceType.Observation) + .apply { + filter( + Observation.SUBJECT, + { value = "Patient/$remotelyCreatedPatientResourceId" }, + ) + } + .getQuery(), + ) + assertThat(searchedObservations.size).isEqualTo(1) + assertThat(searchedObservations[0].logicalId).isEqualTo(locallyCreatedObservationResourceId) + } + + @Test + fun updateResourceAndId_insertPatientAndInsertUpdateReferringResource_shouldUpdateReferencesAndUpdateResourceId() = + runBlocking { + // create a new patient + val locallyCreatedPatientResourceId = "local-patient-1" + val locallyCreatedPatient = + Patient().apply { + id = locallyCreatedPatientResourceId + name = listOf(HumanName().setFamily("Family").setGiven(listOf(StringType("First Name")))) + } + database.insert(locallyCreatedPatient) + + // create an observation for the new patient + val locallyCreatedObservationResourceId = "local-observation-1" + val locallyCreatedPatientObservation = + Observation().apply { + subject = Reference("Patient/$locallyCreatedPatientResourceId") + id = locallyCreatedObservationResourceId + } + database.insert(locallyCreatedPatientObservation) + // update the observation resource (so that there are multiple local changes with references + // to the same patient) + database.update( + locallyCreatedPatientObservation.apply { + performer = listOf(Reference("Patient/$locallyCreatedPatientResourceId")) + }, + ) + + val patientResourceEntity = + database.selectEntity(locallyCreatedPatient.resourceType, locallyCreatedPatientResourceId) + + val observationLocalChanges = + database.getLocalChanges( + locallyCreatedPatientObservation.resourceType, + locallyCreatedObservationResourceId, + ) + assertThat(observationLocalChanges.size).isEqualTo(2) + + // pretend that the resource has been created on the server with an updated ID + val remotelyCreatedPatientResourceId = "remote-patient-1" + val remotelyCreatedPatient = + locallyCreatedPatient.apply { id = remotelyCreatedPatientResourceId } + + database.updateResourceAndId( + locallyCreatedPatientResourceId, + remotelyCreatedPatient, + ) + + // check if resource is fetch-able by its new ID + val updatedPatientResourceEntity = + database.selectEntity(remotelyCreatedPatient.resourceType, remotelyCreatedPatient.id) + assertThat(updatedPatientResourceEntity.resourceUuid) + .isEqualTo(patientResourceEntity.resourceUuid) + + // verify that all the local changes are deleted for this newly created resource + val patientLocalChangesAfterUpdate = + database.getLocalChanges( + updatedPatientResourceEntity.resourceUuid, + ) + assertThat(patientLocalChangesAfterUpdate.size).isEqualTo(1) + val firstPatientLocalChange = patientLocalChangesAfterUpdate[0] + assertThat(firstPatientLocalChange.resourceId).isEqualTo(remotelyCreatedPatientResourceId) + + // verify that Observation is updated + val updatedObservationResource = + database.select( + locallyCreatedPatientObservation.resourceType, + locallyCreatedObservationResourceId, + ) as Observation + assertThat(updatedObservationResource.subject.reference) + .isEqualTo("Patient/$remotelyCreatedPatientResourceId") + + // verify that Observation's LocalChanges are updated + val updatedObservationLocalChanges = + database.getLocalChanges( + locallyCreatedPatientObservation.resourceType, + locallyCreatedObservationResourceId, + ) + assertThat(updatedObservationLocalChanges.size).isEqualTo(2) + val observationLocalChange1 = updatedObservationLocalChanges[0] + assertThat(observationLocalChange1.type).isEqualTo(LocalChange.Type.INSERT) + val observationLocalChange1Payload = + iParser.parseResource(observationLocalChange1.payload) as Observation + assertThat(observationLocalChange1Payload.subject.reference) + .isEqualTo("Patient/$remotelyCreatedPatientResourceId") + + val observationLocalChange2 = updatedObservationLocalChanges[1] + assertThat(observationLocalChange2.type).isEqualTo(LocalChange.Type.UPDATE) + // payload = + // [{"op":"add","path":"\/performer","value":[{"reference":"Patient\/remote-patient-1"}]}] + val observationLocalChange2Payload = JSONArray(observationLocalChange2.payload) + val patch = observationLocalChange2Payload.get(0) as JSONObject + val patchValueReference = patch.getJSONArray("value").get(0) as JSONObject + assertThat(patchValueReference.getString("reference")) + .isEqualTo("Patient/$remotelyCreatedPatientResourceId") + + // verify that Observation is searchable i.e. ReferenceIndex is updated + val searchedObservations = + database.search( + Search(ResourceType.Observation) + .apply { + filter( + Observation.SUBJECT, + { value = "Patient/$remotelyCreatedPatientResourceId" }, + ) + } + .getQuery(), + ) + assertThat(searchedObservations.size).isEqualTo(1) + assertThat(searchedObservations[0].logicalId).isEqualTo(locallyCreatedObservationResourceId) + } + + @Test + fun updateResourceAndId_insertPatientAndInsertUpdateDeleteReferringResource_shouldUpdateReferencesAndUpdateResourceId() = + runBlocking { + // create a new patient + val locallyCreatedPatientResourceId = "local-patient-1" + val locallyCreatedPatient = + Patient().apply { + id = locallyCreatedPatientResourceId + name = listOf(HumanName().setFamily("Family").setGiven(listOf(StringType("First Name")))) + } + database.insert(locallyCreatedPatient) + + // create an observation for the new patient + val locallyCreatedObservationResourceId = "local-observation-1" + val locallyCreatedPatientObservation = + Observation().apply { + subject = Reference("Patient/$locallyCreatedPatientResourceId") + id = locallyCreatedObservationResourceId + } + database.insert(locallyCreatedPatientObservation) + // update the observation resource (so that there are multiple local changes with references + // to the same patient) + database.update( + locallyCreatedPatientObservation.apply { + performer = listOf(Reference("Patient/$locallyCreatedPatientResourceId")) + }, + ) + database.delete( + locallyCreatedPatientObservation.resourceType, + locallyCreatedObservationResourceId, + ) + + val patientResourceEntity = + database.selectEntity(locallyCreatedPatient.resourceType, locallyCreatedPatientResourceId) + + val observationLocalChanges = + database.getLocalChanges( + locallyCreatedPatientObservation.resourceType, + locallyCreatedObservationResourceId, + ) + assertThat(observationLocalChanges.size).isEqualTo(3) + + // pretend that the resource has been created on the server with an updated ID + val remotelyCreatedPatientResourceId = "remote-patient-1" + val remotelyCreatedPatient = + locallyCreatedPatient.apply { id = remotelyCreatedPatientResourceId } + + database.updateResourceAndId( + locallyCreatedPatientResourceId, + remotelyCreatedPatient, + ) + + // check if resource is fetch-able by its new ID + val updatedPatientResourceEntity = + database.selectEntity(remotelyCreatedPatient.resourceType, remotelyCreatedPatient.id) + assertThat(updatedPatientResourceEntity.resourceUuid) + .isEqualTo(patientResourceEntity.resourceUuid) + + // verify that all the local changes are deleted for this newly created resource + val patientLocalChangesAfterUpdate = + database.getLocalChanges( + updatedPatientResourceEntity.resourceUuid, + ) + assertThat(patientLocalChangesAfterUpdate.size).isEqualTo(1) + val firstPatientLocalChange = patientLocalChangesAfterUpdate[0] + assertThat(firstPatientLocalChange.resourceId).isEqualTo(remotelyCreatedPatientResourceId) + + // verify that Observation's LocalChanges are updated + val updatedObservationLocalChanges = + database.getLocalChanges( + locallyCreatedPatientObservation.resourceType, + locallyCreatedObservationResourceId, + ) + assertThat(updatedObservationLocalChanges.size).isEqualTo(3) + val observationLocalChange1 = updatedObservationLocalChanges[0] + assertThat(observationLocalChange1.type).isEqualTo(LocalChange.Type.INSERT) + val observationLocalChange1Payload = + iParser.parseResource(observationLocalChange1.payload) as Observation + assertThat(observationLocalChange1Payload.subject.reference) + .isEqualTo("Patient/$remotelyCreatedPatientResourceId") + + val observationLocalChange2 = updatedObservationLocalChanges[1] + assertThat(observationLocalChange2.type).isEqualTo(LocalChange.Type.UPDATE) + // payload = + // [{"op":"add","path":"\/performer","value":[{"reference":"Patient\/remote-patient-1"}]}] + val observationLocalChange2Payload = JSONArray(observationLocalChange2.payload) + val patch = observationLocalChange2Payload.get(0) as JSONObject + val patchValueReference = patch.getJSONArray("value").get(0) as JSONObject + assertThat(patchValueReference.getString("reference")) + .isEqualTo("Patient/$remotelyCreatedPatientResourceId") + + val observationLocalChange3 = updatedObservationLocalChanges[2] + assertThat(observationLocalChange3.type).isEqualTo(LocalChange.Type.DELETE) + assertThat(observationLocalChange3.payload).isEqualTo("") + } + private companion object { const val mockEpochTimeStamp = 1628516301000 const val TEST_PATIENT_1_ID = "test_patient_1" diff --git a/engine/src/androidTest/java/com/google/android/fhir/db/impl/EncryptedDatabaseErrorTest.kt b/engine/src/androidTest/java/com/google/android/fhir/db/impl/EncryptedDatabaseErrorTest.kt index fdf5df0ce7..412da27b45 100644 --- a/engine/src/androidTest/java/com/google/android/fhir/db/impl/EncryptedDatabaseErrorTest.kt +++ b/engine/src/androidTest/java/com/google/android/fhir/db/impl/EncryptedDatabaseErrorTest.kt @@ -22,6 +22,8 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import ca.uhn.fhir.context.FhirContext +import ca.uhn.fhir.context.FhirVersionEnum +import ca.uhn.fhir.util.FhirTerser import com.google.android.fhir.DatabaseErrorStrategy.RECREATE_AT_OPEN import com.google.android.fhir.DatabaseErrorStrategy.UNSPECIFIED import com.google.android.fhir.db.impl.DatabaseImpl.Companion.DATABASE_PASSPHRASE_NAME @@ -48,6 +50,7 @@ import org.junit.runner.RunWith class EncryptedDatabaseErrorTest { private val context: Context = ApplicationProvider.getApplicationContext() private val parser = FhirContext.forR4().newJsonParser() + private val terser = FhirTerser(FhirContext.forCached(FhirVersionEnum.R4)) private val resourceIndexer = ResourceIndexer(SearchParamDefinitionsProviderImpl()) @After @@ -64,6 +67,7 @@ class EncryptedDatabaseErrorTest { DatabaseImpl( context, parser, + terser, DatabaseConfig( inMemory = false, enableEncryption = false, @@ -81,6 +85,7 @@ class EncryptedDatabaseErrorTest { DatabaseImpl( context, parser, + terser, DatabaseConfig( inMemory = false, enableEncryption = true, @@ -111,6 +116,7 @@ class EncryptedDatabaseErrorTest { DatabaseImpl( context, parser, + terser, DatabaseConfig( inMemory = false, enableEncryption = true, @@ -134,6 +140,7 @@ class EncryptedDatabaseErrorTest { DatabaseImpl( context, parser, + terser, DatabaseConfig( inMemory = false, enableEncryption = true, @@ -163,6 +170,7 @@ class EncryptedDatabaseErrorTest { DatabaseImpl( context, parser, + terser, DatabaseConfig( inMemory = false, enableEncryption = true, @@ -186,6 +194,7 @@ class EncryptedDatabaseErrorTest { DatabaseImpl( context, parser, + terser, DatabaseConfig( inMemory = false, enableEncryption = true, @@ -218,6 +227,7 @@ class EncryptedDatabaseErrorTest { DatabaseImpl( context, parser, + terser, DatabaseConfig( inMemory = false, enableEncryption = true, @@ -235,6 +245,7 @@ class EncryptedDatabaseErrorTest { DatabaseImpl( context, parser, + terser, DatabaseConfig( inMemory = false, enableEncryption = false, diff --git a/engine/src/androidTest/java/com/google/android/fhir/sync/upload/consolidator/PostPerResourceUrlRequestConsolidatorTest.kt b/engine/src/androidTest/java/com/google/android/fhir/sync/upload/consolidator/PostPerResourceUrlRequestConsolidatorTest.kt deleted file mode 100644 index d6645b30fb..0000000000 --- a/engine/src/androidTest/java/com/google/android/fhir/sync/upload/consolidator/PostPerResourceUrlRequestConsolidatorTest.kt +++ /dev/null @@ -1,397 +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.consolidator - -import android.content.Context -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.filters.MediumTest -import ca.uhn.fhir.context.FhirContext -import com.google.android.fhir.FhirServices -import com.google.android.fhir.LocalChange -import com.google.android.fhir.LocalChangeToken -import com.google.android.fhir.db.Database -import com.google.android.fhir.logicalId -import com.google.android.fhir.search.Search -import com.google.android.fhir.search.getQuery -import com.google.android.fhir.testing.assertResourceEquals -import com.google.common.truth.Truth.assertThat -import java.time.Instant -import java.util.Date -import kotlinx.coroutines.runBlocking -import org.hl7.fhir.r4.model.HumanName -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.hl7.fhir.r4.model.SearchParameter -import org.hl7.fhir.r4.model.StringType -import org.json.JSONArray -import org.json.JSONObject -import org.junit.After -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith - -@MediumTest -@RunWith(AndroidJUnit4::class) -class PostPerResourceUrlRequestConsolidatorTest { - private val context: Context = ApplicationProvider.getApplicationContext() - private lateinit var services: FhirServices - private lateinit var database: Database - private lateinit var postPerResourceUrlRequestConsolidator: PostPerResourceUrlRequestConsolidator - private val iParser = FhirContext.forR4Cached().newJsonParser() - - @Before - fun setUp(): Unit = runBlocking { - buildFhirService() - postPerResourceUrlRequestConsolidator = PostPerResourceUrlRequestConsolidator(database) - } - - private fun buildFhirService(customSearchParameter: List? = null) { - services = - FhirServices.builder(context) - .inMemory() - .apply { setSearchParameters(customSearchParameter) } - .build() - database = services.database - } - - @After - fun tearDown() { - database.close() - } - - @Test - fun insertPatientAndReferringResource_shouldUpdateReferencesAndUpdateResourceId() = runBlocking { - // create a patient - val locallyCreatedPatientResourceId = "local-patient-1" - val locallyCreatedPatient = - Patient().apply { - id = locallyCreatedPatientResourceId - name = listOf(HumanName().setFamily("Family").setGiven(listOf(StringType("First Name")))) - } - database.insert(locallyCreatedPatient) - - // create an observation for the patient - val locallyCreatedObservationResourceId = "local-observation-1" - val locallyCreatedPatientObservation = - Observation().apply { - subject = Reference("Patient/$locallyCreatedPatientResourceId") - id = locallyCreatedObservationResourceId - } - database.insert(locallyCreatedPatientObservation) - - val patientResourceEntity = - database.selectEntity(locallyCreatedPatient.resourceType, locallyCreatedPatientResourceId) - - val patientLocalChanges = - database.getLocalChanges(locallyCreatedPatient.resourceType, locallyCreatedPatientResourceId) - - // pretend that the resource has been created on the server with an updated ID - val remotelyCreatedPatientResourceId = "remote-patient-1" - val remotelyCreatedPatient = - locallyCreatedPatient.apply { id = remotelyCreatedPatientResourceId } - - // perform consolidation - postPerResourceUrlRequestConsolidator.consolidate( - LocalChangeToken(patientLocalChanges.flatMap { it.token.ids }), - remotelyCreatedPatient, - ) - - // check if resource is fetch-able by its new server assigned ID - val updatedPatientResourceEntity = - database.selectEntity(remotelyCreatedPatient.resourceType, remotelyCreatedPatient.id) - assertThat(updatedPatientResourceEntity.resourceUuid) - .isEqualTo(patientResourceEntity.resourceUuid) - - // verify that all the local changes are deleted for this newly server created resource - val patientLocalChangesAfterConsolidation = - database.getLocalChanges( - updatedPatientResourceEntity.resourceUuid, - ) - assertThat(patientLocalChangesAfterConsolidation).isEmpty() - - // verify that Observation is updated with new patient ID reference - val updatedObservationResource = - database.select( - locallyCreatedPatientObservation.resourceType, - locallyCreatedObservationResourceId, - ) as Observation - assertThat(updatedObservationResource.subject.reference) - .isEqualTo("Patient/$remotelyCreatedPatientResourceId") - - // verify that Observation's LocalChanges are updated with new patient ID reference - val updatedObservationLocalChanges = - database.getLocalChanges( - locallyCreatedPatientObservation.resourceType, - locallyCreatedObservationResourceId, - ) - assertThat(updatedObservationLocalChanges.size).isEqualTo(1) - val observationLocalChange = updatedObservationLocalChanges[0] - assertThat(observationLocalChange.type).isEqualTo(LocalChange.Type.INSERT) - val observationLocalChangePayload = - iParser.parseResource(observationLocalChange.payload) as Observation - assertThat(observationLocalChangePayload.subject.reference) - .isEqualTo("Patient/$remotelyCreatedPatientResourceId") - - // verify that Observation is searchable i.e. ReferenceIndex is updated with new patient ID - // reference - val searchedObservations = - database.search( - Search(ResourceType.Observation) - .apply { - filter( - Observation.SUBJECT, - { value = "Patient/$remotelyCreatedPatientResourceId" }, - ) - } - .getQuery(), - ) - assertThat(searchedObservations.size).isEqualTo(1) - assertThat(searchedObservations[0].logicalId).isEqualTo(locallyCreatedObservationResourceId) - } - - @Test - fun insertPatientAndInsertUpdateReferringResource_shouldUpdateReferencesAndUpdateResourceId() = - runBlocking { - // create a new patient - val locallyCreatedPatientResourceId = "local-patient-1" - val locallyCreatedPatient = - Patient().apply { - id = locallyCreatedPatientResourceId - name = listOf(HumanName().setFamily("Family").setGiven(listOf(StringType("First Name")))) - } - database.insert(locallyCreatedPatient) - - // create an observation for the new patient - val locallyCreatedObservationResourceId = "local-observation-1" - val locallyCreatedPatientObservation = - Observation().apply { - subject = Reference("Patient/$locallyCreatedPatientResourceId") - id = locallyCreatedObservationResourceId - } - database.insert(locallyCreatedPatientObservation) - // update the observation resource (so that there are multiple local changes with references - // to the same patient) - database.update( - locallyCreatedPatientObservation.apply { - performer = listOf(Reference("Patient/$locallyCreatedPatientResourceId")) - }, - ) - - val patientResourceEntity = - database.selectEntity(locallyCreatedPatient.resourceType, locallyCreatedPatientResourceId) - - val patientLocalChanges = - database.getLocalChanges( - locallyCreatedPatient.resourceType, - locallyCreatedPatientResourceId, - ) - val observationLocalChanges = - database.getLocalChanges( - locallyCreatedPatientObservation.resourceType, - locallyCreatedObservationResourceId, - ) - assertThat(observationLocalChanges.size).isEqualTo(2) - - // pretend that the resource has been created on the server with an updated ID - val remotelyCreatedPatientResourceId = "remote-patient-1" - val remotelyCreatedPatient = - locallyCreatedPatient.apply { id = remotelyCreatedPatientResourceId } - - postPerResourceUrlRequestConsolidator.consolidate( - LocalChangeToken(patientLocalChanges.flatMap { it.token.ids }), - remotelyCreatedPatient, - ) - - // check if resource is fetch-able by its new ID - val updatedPatientResourceEntity = - database.selectEntity(remotelyCreatedPatient.resourceType, remotelyCreatedPatient.id) - assertThat(updatedPatientResourceEntity.resourceUuid) - .isEqualTo(patientResourceEntity.resourceUuid) - - // verify that all the local changes are deleted for this newly created resource - val patientLocalChangesAfterConsolidation = - database.getLocalChanges( - updatedPatientResourceEntity.resourceUuid, - ) - assertThat(patientLocalChangesAfterConsolidation).isEmpty() - - // verify that Observation is updated - val updatedObservationResource = - database.select( - locallyCreatedPatientObservation.resourceType, - locallyCreatedObservationResourceId, - ) as Observation - assertThat(updatedObservationResource.subject.reference) - .isEqualTo("Patient/$remotelyCreatedPatientResourceId") - - // verify that Observation's LocalChanges are updated - val updatedObservationLocalChanges = - database.getLocalChanges( - locallyCreatedPatientObservation.resourceType, - locallyCreatedObservationResourceId, - ) - assertThat(updatedObservationLocalChanges.size).isEqualTo(2) - val observationLocalChange1 = updatedObservationLocalChanges[0] - assertThat(observationLocalChange1.type).isEqualTo(LocalChange.Type.INSERT) - val observationLocalChange1Payload = - iParser.parseResource(observationLocalChange1.payload) as Observation - assertThat(observationLocalChange1Payload.subject.reference) - .isEqualTo("Patient/$remotelyCreatedPatientResourceId") - - val observationLocalChange2 = updatedObservationLocalChanges[1] - assertThat(observationLocalChange2.type).isEqualTo(LocalChange.Type.UPDATE) - // payload = - // [{"op":"add","path":"\/performer","value":[{"reference":"Patient\/remote-patient-1"}]}] - val observationLocalChange2Payload = JSONArray(observationLocalChange2.payload) - val patch = observationLocalChange2Payload.get(0) as JSONObject - val patchValueReference = patch.getJSONArray("value").get(0) as JSONObject - assertThat(patchValueReference.getString("reference")) - .isEqualTo("Patient/$remotelyCreatedPatientResourceId") - - // verify that Observation is searchable i.e. ReferenceIndex is updated - val searchedObservations = - database.search( - Search(ResourceType.Observation) - .apply { - filter( - Observation.SUBJECT, - { value = "Patient/$remotelyCreatedPatientResourceId" }, - ) - } - .getQuery(), - ) - assertThat(searchedObservations.size).isEqualTo(1) - assertThat(searchedObservations[0].logicalId).isEqualTo(locallyCreatedObservationResourceId) - } - - @Test - fun updatePatientAndInsertUpdateReferringResource_shouldUpdateReferencesAndUpdateResourceId() = - runBlocking { - // imitate syncing of a patient from the server - val serverCreatedPatientResourceId = "remote-patient-1" - val serverSyncedPatient = - Patient().apply { - id = serverCreatedPatientResourceId - name = listOf(HumanName().setFamily("Family").setGiven(listOf(StringType("First Name")))) - } - database.insertRemote(serverSyncedPatient) - - // update the patient resource - val updatedPatient = - serverSyncedPatient.apply { - name = - listOf( - HumanName().setFamily("Updated Family").setGiven(listOf(StringType("First Name"))), - ) - } - database.update(updatedPatient) - - // create an observation for the new patient - val locallyCreatedObservationResourceId = "local-observation-1" - val locallyCreatedPatientObservation = - Observation().apply { - subject = Reference("Patient/$serverCreatedPatientResourceId") - id = locallyCreatedObservationResourceId - } - database.insert(locallyCreatedPatientObservation) - // update the observation (so that there are multiple local changes with references to the - // same patient) - database.update( - locallyCreatedPatientObservation.apply { - performer = listOf(Reference("Patient/$serverCreatedPatientResourceId")) - }, - ) - - val patientResourceEntity = - database.selectEntity(serverSyncedPatient.resourceType, serverSyncedPatient.id) - - val patientLocalChanges = - database.getLocalChanges( - serverSyncedPatient.resourceType, - serverCreatedPatientResourceId, - ) - assertThat(patientLocalChanges.size).isEqualTo(1) - val observationLocalChanges = - database.getLocalChanges( - locallyCreatedPatientObservation.resourceType, - locallyCreatedObservationResourceId, - ) - assertThat(observationLocalChanges.size).isEqualTo(2) - - // pretend that the resource has been updated on the server - val serverUpdatedTime = Date.from(Instant.now()) - val serverImitatedResponse = updatedPatient.apply { meta.lastUpdated = serverUpdatedTime } - postPerResourceUrlRequestConsolidator.consolidate( - LocalChangeToken(patientLocalChanges.flatMap { it.token.ids }), - serverImitatedResponse, - ) - - // check if resource is fetch-able by its new ID - val updatedPatientResourceEntity = - database.selectEntity(updatedPatient.resourceType, serverImitatedResponse.id) - assertThat(updatedPatientResourceEntity.resourceUuid) - .isEqualTo(patientResourceEntity.resourceUuid) - - // verify that all the local changes are deleted for this newly created resource - val patientLocalChangesAfterConsolidation = - database.getLocalChanges( - updatedPatientResourceEntity.resourceUuid, - ) - assertThat(patientLocalChangesAfterConsolidation).isEmpty() - - // verify that Observation is still the same as the last version - val observationResource = - database.select( - locallyCreatedPatientObservation.resourceType, - locallyCreatedObservationResourceId, - ) as Observation - assertResourceEquals(locallyCreatedPatientObservation, observationResource) - - // verify that Observation's LocalChanges are retained - val observationLocalChangesPostConsolidation = - database.getLocalChanges( - locallyCreatedPatientObservation.resourceType, - locallyCreatedObservationResourceId, - ) - assertThat(observationLocalChanges.size) - .isEqualTo(observationLocalChangesPostConsolidation.size) - observationLocalChanges.forEachIndexed { changeIndex, originalObservationChange -> - assertThat(originalObservationChange.token.ids) - .containsExactlyElementsIn( - observationLocalChangesPostConsolidation.get(changeIndex).token.ids, - ) - } - - // verify that Observation is searchable i.e. ReferenceIndex is updated - val searchedObservations = - database.search( - Search(ResourceType.Observation) - .apply { - filter( - Observation.SUBJECT, - { value = "Patient/$serverCreatedPatientResourceId" }, - ) - } - .getQuery(), - ) - assertThat(searchedObservations.size).isEqualTo(1) - assertThat(searchedObservations[0].logicalId).isEqualTo(locallyCreatedObservationResourceId) - } -} diff --git a/engine/src/main/java/com/google/android/fhir/FhirServices.kt b/engine/src/main/java/com/google/android/fhir/FhirServices.kt index 75203b31ed..2f2e3c7ca6 100644 --- a/engine/src/main/java/com/google/android/fhir/FhirServices.kt +++ b/engine/src/main/java/com/google/android/fhir/FhirServices.kt @@ -20,6 +20,7 @@ import android.content.Context import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.context.FhirVersionEnum import ca.uhn.fhir.parser.IParser +import ca.uhn.fhir.util.FhirTerser import com.google.android.fhir.db.Database import com.google.android.fhir.db.impl.DatabaseConfig import com.google.android.fhir.db.impl.DatabaseEncryptionKeyProvider.isDatabaseEncryptionSupported @@ -70,12 +71,14 @@ internal data class FhirServices( fun build(): FhirServices { val parser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() + val terser = FhirTerser(FhirContext.forCached(FhirVersionEnum.R4)) val searchParamMap = searchParameters?.asMapOfResourceTypeToSearchParamDefinitions() ?: emptyMap() val db = DatabaseImpl( context = context, iParser = parser, + fhirTerser = terser, DatabaseConfig(inMemory, enableEncryption, databaseErrorStrategy), resourceIndexer = ResourceIndexer(SearchParamDefinitionsProviderImpl(searchParamMap)), ) 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 bc3076cd0b..0ff3696ef2 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 @@ -19,7 +19,6 @@ package com.google.android.fhir.db import com.google.android.fhir.LocalChange import com.google.android.fhir.LocalChangeToken import com.google.android.fhir.db.impl.dao.IndexedIdAndResource -import com.google.android.fhir.db.impl.dao.ReferringResource import com.google.android.fhir.db.impl.entities.LocalChangeEntity import com.google.android.fhir.db.impl.entities.ResourceEntity import com.google.android.fhir.search.SearchQuery @@ -55,14 +54,6 @@ internal interface Database { */ suspend fun update(vararg resources: Resource) - /** - * Updates the uploaded `resource` with the provided 'resourceUuid' in the FHIR resource database. - * - * @param resource: uploaded resource - * @param uuid: The UUID of the [ResourceEntity] associated with the resource - */ - suspend fun updateResourceWithUuid(resource: Resource, uuid: UUID) - /** Updates the `resource` meta in the FHIR resource database. */ suspend fun updateVersionIdAndLastUpdated( resourceId: String, @@ -107,16 +98,6 @@ internal interface Database { suspend fun searchReferencedResources(query: SearchQuery): List - /** - * Fetches all [ResourceEntity]s whose resource refers to the requested {resource} along with the - * respective list of FHIR paths in the referring resources where the requested resource is - * referred. - */ - suspend fun getAllResourcesReferringToResourceWithPath( - resourceType: ResourceType, - resourceId: String, - ): List - suspend fun count(query: SearchQuery): Long /** @@ -125,9 +106,6 @@ internal interface Database { */ suspend fun getAllLocalChanges(): List - /** Retrieves [LocalChange]s with the Ids provided in the {localChangeToken} */ - suspend fun getAllLocalChanges(localChangeToken: LocalChangeToken): List - /** Retrieves the count of [LocalChange]s stored in the database. */ suspend fun getLocalChangesCount(): Int @@ -138,23 +116,15 @@ internal interface Database { suspend fun deleteUpdates(resources: List) /** - * Update the resource ID for [LocalChange]s for [ResourceEntity] with - * [ResourceEntity.resourceUuid] resourceUuid. + * Updates the [ResourceEntity.serializedResource] and [ResourceEntity.resourceId] corresponding + * to the updatedResource. Updates all the [LocalChangeEntity] for this updated resource as well + * as all the [LocalChangeEntity] referring to this resource in their [LocalChangeEntity.payload] + * Updates the [ResourceEntity.serializedResource] for all the resources which refer to this + * updated resource. */ - suspend fun updateResourceIdForResourceChanges( - resourceUuid: UUID, - updatedResourceId: String, - ) - - /** - * Removes all the existing [LocalChange] for the [Resource] with [ResourceEntity.resourceUuid] - * {resourceUuid} and [ResourceEntity.resourceType] resourceType. Adds the new {updatedChanges} - * for the resource. - */ - suspend fun replaceResourceChanges( - resourceType: ResourceType, - resourceUuid: UUID, - updatedChanges: List, + suspend fun updateResourceAndId( + currentResourceId: String, + updatedResource: Resource, ) /** Runs the block as a database transaction. */ diff --git a/engine/src/main/java/com/google/android/fhir/db/ResourceNotFoundException.kt b/engine/src/main/java/com/google/android/fhir/db/ResourceNotFoundException.kt index 4e4f4c7c40..a97c90c879 100644 --- a/engine/src/main/java/com/google/android/fhir/db/ResourceNotFoundException.kt +++ b/engine/src/main/java/com/google/android/fhir/db/ResourceNotFoundException.kt @@ -16,35 +16,34 @@ package com.google.android.fhir.db +import java.util.UUID + /** Thrown to indicate that the requested resource is not found. */ class ResourceNotFoundException : Exception { - val type: String - val identifierType: ResourceIdentifierType - val identifier: String + lateinit var type: String + lateinit var id: String + lateinit var uuid: UUID constructor( type: String, - identifier: String, - identifierType: ResourceIdentifierType, + id: String, cause: Throwable, - ) : super("Resource not found with type $type and $identifierType $identifier!", cause) { + ) : super("Resource not found with type $type and id $id!", cause) { this.type = type - this.identifier = identifier - this.identifierType = identifierType + this.id = id } constructor( type: String, - identifierType: ResourceIdentifierType, - identifier: String, - ) : super("Resource not found with type $type and $identifierType $identifier!") { + id: String, + ) : super("Resource not found with type $type and id $id!") { this.type = type - this.identifier = identifier - this.identifierType = identifierType + this.id = id } -} -enum class ResourceIdentifierType { - ID, - UUID, + constructor( + uuid: UUID, + ) : super("Resource not found with UUID $uuid!") { + this.uuid = uuid + } } diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/DatabaseImpl.kt b/engine/src/main/java/com/google/android/fhir/db/impl/DatabaseImpl.kt index 40805ce37d..598483978b 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 @@ -22,14 +22,13 @@ import androidx.room.Room import androidx.room.withTransaction import androidx.sqlite.db.SimpleSQLiteQuery import ca.uhn.fhir.parser.IParser +import ca.uhn.fhir.util.FhirTerser import com.google.android.fhir.DatabaseErrorStrategy import com.google.android.fhir.LocalChange import com.google.android.fhir.LocalChangeToken -import com.google.android.fhir.db.ResourceIdentifierType import com.google.android.fhir.db.ResourceNotFoundException import com.google.android.fhir.db.impl.DatabaseImpl.Companion.UNENCRYPTED_DATABASE_NAME import com.google.android.fhir.db.impl.dao.IndexedIdAndResource -import com.google.android.fhir.db.impl.dao.ReferringResource import com.google.android.fhir.db.impl.entities.ResourceEntity import com.google.android.fhir.index.ResourceIndexer import com.google.android.fhir.logicalId @@ -48,6 +47,7 @@ import org.hl7.fhir.r4.model.ResourceType internal class DatabaseImpl( private val context: Context, private val iParser: IParser, + private val fhirTerser: FhirTerser, databaseConfig: DatabaseConfig, private val resourceIndexer: ResourceIndexer, ) : com.google.android.fhir.db.Database { @@ -117,7 +117,11 @@ internal class DatabaseImpl( } } - private val localChangeDao = db.localChangeDao().also { it.iParser = iParser } + private val localChangeDao = + db.localChangeDao().also { + it.iParser = iParser + it.fhirTerser = fhirTerser + } override suspend fun insert(vararg resource: R): List { val logicalIds = mutableListOf() @@ -149,10 +153,6 @@ internal class DatabaseImpl( } } - override suspend fun updateResourceWithUuid(resource: Resource, uuid: UUID) { - db.withTransaction { resourceDao.updateResourceWithUuid(resource, uuid) } - } - override suspend fun updateVersionIdAndLastUpdated( resourceId: String, resourceType: ResourceType, @@ -174,7 +174,7 @@ internal class DatabaseImpl( resourceDao.getResource(resourceId = id, resourceType = type)?.let { iParser.parseResource(it) } - ?: throw ResourceNotFoundException(type.name, ResourceIdentifierType.ID, id) + ?: throw ResourceNotFoundException(type.name, id) } as Resource } @@ -221,48 +221,6 @@ internal class DatabaseImpl( } } - override suspend fun getAllResourcesReferringToResourceWithPath( - resourceType: ResourceType, - resourceId: String, - ): List { - val searchForReferenceValue = "$resourceType/$resourceId" - val referringResourceSearchQuery = - SearchQuery( - query = - """ - SELECT refIndex.index_path, refIndex.resourceUuid, resource.resourceId, - resource.serializedResource, resource.resourceType - FROM ReferenceIndexEntity as refIndex - JOIN ResourceEntity as resource on refIndex.resourceUuid = resource.resourceUuid - WHERE refIndex.index_value = ? - """ - .trimIndent(), - args = listOf(searchForReferenceValue), - ) - return db.withTransaction { - resourceDao - .getReferringResources( - SimpleSQLiteQuery( - referringResourceSearchQuery.query, - referringResourceSearchQuery.args.toTypedArray(), - ), - ) - .groupBy { it.resourceUuid } - .map { (resourceUuid, referringResourceWithPath) -> - val paths = referringResourceWithPath.map { it.path } - val referringResource = referringResourceWithPath.first() - ReferringResource( - resourceUuid = resourceUuid, - resourceId = referringResource.resourceId, - resourceType = ResourceType.fromCode(referringResource.resourceType), - resource = iParser.parseResource(referringResource.serializedResource) as Resource, - referringPaths = paths, - referenceValue = searchForReferenceValue, - ) - } - } - } - override suspend fun count(query: SearchQuery): Long { return db.withTransaction { resourceDao.countResources(SimpleSQLiteQuery(query.query, query.args.toTypedArray())) @@ -273,12 +231,6 @@ internal class DatabaseImpl( return db.withTransaction { localChangeDao.getAllLocalChanges().map { it.toLocalChange() } } } - override suspend fun getAllLocalChanges(localChangeToken: LocalChangeToken): List { - return db - .withTransaction { localChangeDao.getLocalChanges(localChangeToken.ids) } - .map { it.toLocalChange() } - } - override suspend fun getLocalChangesCount(): Int { return db.withTransaction { localChangeDao.getLocalChangesCount() } } @@ -287,35 +239,10 @@ internal class DatabaseImpl( db.withTransaction { localChangeDao.discardLocalChanges(token) } } - override suspend fun updateResourceIdForResourceChanges( - resourceUuid: UUID, - updatedResourceId: String, - ) { - db.withTransaction { - val localChanges = localChangeDao.getLocalChanges(resourceUuid) - localChanges - .map { localChangeEntity -> localChangeEntity.copy(resourceId = updatedResourceId) } - // Add LocalChangeEntity with replace strategy - .forEach { localChangeDao.addLocalChange(it) } - } - } - - override suspend fun replaceResourceChanges( - resourceType: ResourceType, - resourceUuid: UUID, - updatedChanges: List, - ) { - db.withTransaction { - val localChanges = localChangeDao.getLocalChanges(resourceUuid) - localChangeDao.discardLocalChanges(localChanges.first().resourceId, resourceType) - updatedChanges.forEach { localChangeDao.createLocalChange(it, resourceUuid) } - } - } - override suspend fun selectEntity(type: ResourceType, id: String): ResourceEntity { return db.withTransaction { resourceDao.getResourceEntity(resourceId = id, resourceType = type) - ?: throw ResourceNotFoundException(type.name, ResourceIdentifierType.ID, id) + ?: throw ResourceNotFoundException(type.name, id) } } @@ -327,6 +254,42 @@ internal class DatabaseImpl( localChangeDao.discardLocalChanges(resources) } + override suspend fun updateResourceAndId(currentResourceId: String, updatedResource: Resource) { + val currentResourceEntity = selectEntity(updatedResource.resourceType, currentResourceId) + val oldResource = iParser.parseResource(currentResourceEntity.serializedResource) as Resource + val resourceUuid = currentResourceEntity.resourceUuid + val oldReferenceValue = "${oldResource.resourceType.name}/${oldResource.logicalId}" + val updatedReferenceValue = "${updatedResource.resourceType.name}/${updatedResource.logicalId}" + db.withTransaction { + // update the resource entity + resourceDao.updateResourceWithUuid(currentResourceEntity.resourceUuid, updatedResource) + + // update the local changes of this resource and any resource referring to the updated + // resource + val uuidsOfResourcesWithReferencesToResource = + localChangeDao.updateResourceId( + resourceUuid = resourceUuid, + oldResource = oldResource, + updatedResource = updatedResource, + ) + + // update the references in the resources referring to the updated resource + uuidsOfResourcesWithReferencesToResource.forEach { resourceUuid -> + resourceDao.getResourceEntity(resourceUuid)?.let { + val referringResource = iParser.parseResource(it.serializedResource) as Resource + val updatedReferringResource = + addUpdatedReferenceToResource( + iParser, + referringResource, + oldReferenceValue, + updatedReferenceValue, + ) + resourceDao.updateResourceWithUuid(resourceUuid, updatedReferringResource) + } + } + } + } + override fun close() { db.close() } 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 new file mode 100644 index 0000000000..d5d2089c75 --- /dev/null +++ b/engine/src/main/java/com/google/android/fhir/db/impl/JsonUtils.kt @@ -0,0 +1,65 @@ +/* + * 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.db.impl + +import ca.uhn.fhir.parser.IParser +import org.hl7.fhir.r4.model.Resource +import org.json.JSONObject + +fun addUpdatedReferenceToResource( + iParser: IParser, + resource: Resource, + outdatedReference: String, + updatedReference: String, +): Resource { + val resourceJsonObject = JSONObject(iParser.encodeResourceToString(resource)) + val updatedResource = replaceJsonValue(resourceJsonObject, outdatedReference, updatedReference) + return iParser.parseResource(updatedResource.toString()) as Resource +} + +fun replaceJsonValue( + jsonObject: JSONObject, + currentValue: String, + newValue: String, +): JSONObject { + val iterator: Iterator<*> = jsonObject.keys() + var key: String? + while (iterator.hasNext()) { + key = iterator.next() as String + // if object is just string we change value in key + if (jsonObject.optJSONArray(key) == null && jsonObject.optJSONObject(key) == null) { + if (jsonObject.optString(key) == currentValue) { + jsonObject.put(key, newValue) + return jsonObject + } + } + + // if it's jsonobject + if (jsonObject.optJSONObject(key) != null) { + replaceJsonValue(jsonObject.getJSONObject(key), currentValue, newValue) + } + + // if it's jsonarray + if (jsonObject.optJSONArray(key) != null) { + val jArray = jsonObject.getJSONArray(key) + for (i in 0 until jArray.length()) { + replaceJsonValue(jArray.getJSONObject(i), currentValue, newValue) + } + } + } + return jsonObject +} diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt b/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt index f69998fd49..8f93d89055 100644 --- a/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt +++ b/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt @@ -26,6 +26,7 @@ import com.google.android.fhir.db.impl.dao.ResourceDao import com.google.android.fhir.db.impl.entities.DateIndexEntity import com.google.android.fhir.db.impl.entities.DateTimeIndexEntity import com.google.android.fhir.db.impl.entities.LocalChangeEntity +import com.google.android.fhir.db.impl.entities.LocalChangeResourceReferenceEntity import com.google.android.fhir.db.impl.entities.NumberIndexEntity import com.google.android.fhir.db.impl.entities.PositionIndexEntity import com.google.android.fhir.db.impl.entities.QuantityIndexEntity @@ -49,8 +50,9 @@ import com.google.android.fhir.db.impl.entities.UriIndexEntity NumberIndexEntity::class, LocalChangeEntity::class, PositionIndexEntity::class, + LocalChangeResourceReferenceEntity::class, ], - version = 7, + version = 8, exportSchema = true, ) @TypeConverters(DbTypeConverters::class) 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 380adf5cd1..141308e0ff 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 @@ -22,14 +22,18 @@ import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction import ca.uhn.fhir.parser.IParser +import ca.uhn.fhir.util.FhirTerser +import ca.uhn.fhir.util.ResourceReferenceInfo import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.ObjectMapper import com.github.fge.jsonpatch.diff.JsonDiff -import com.google.android.fhir.LocalChange import com.google.android.fhir.LocalChangeToken +import com.google.android.fhir.db.impl.addUpdatedReferenceToResource import com.google.android.fhir.db.impl.entities.LocalChangeEntity import com.google.android.fhir.db.impl.entities.LocalChangeEntity.Type +import com.google.android.fhir.db.impl.entities.LocalChangeResourceReferenceEntity import com.google.android.fhir.db.impl.entities.ResourceEntity +import com.google.android.fhir.db.impl.replaceJsonValue import com.google.android.fhir.logicalId import com.google.android.fhir.versionId import java.time.Instant @@ -42,17 +46,16 @@ import timber.log.Timber /** * Dao for local changes made to a resource. One row in LocalChangeEntity corresponds to one change - * e.g. an INSERT or UPDATE. The UPDATES (diffs) are stored as RFC 6902 JSON patches. When a - * resource needs to be synced, all corresponding LocalChanges are 'squashed' to create a a single - * LocalChangeEntity to sync with the server. + * e.g. an INSERT or UPDATE. The UPDATES (diffs) are stored as RFC 6902 JSON patches. */ @Dao internal abstract class LocalChangeDao { lateinit var iParser: IParser + lateinit var fhirTerser: FhirTerser @Insert(onConflict = OnConflictStrategy.REPLACE) - abstract suspend fun addLocalChange(localChangeEntity: LocalChangeEntity) + abstract suspend fun addLocalChange(localChangeEntity: LocalChangeEntity): Long @Transaction open suspend fun addInsert(resource: Resource, resourceUuid: UUID, timeOfLocalChange: Instant) { @@ -60,7 +63,7 @@ internal abstract class LocalChangeDao { val resourceType = resource.resourceType val resourceString = iParser.encodeResourceToString(resource) - addLocalChange( + val localChangeEntity = LocalChangeEntity( id = 0, resourceType = resourceType.name, @@ -70,28 +73,39 @@ internal abstract class LocalChangeDao { type = Type.INSERT, payload = resourceString, versionId = resource.versionId, - ), - ) + ) + + val localChangeReferences = + extractResourceReferences(resource).map { resourceReferenceInfo -> + LocalChangeResourceReferenceEntity( + id = 0, + localChangeId = 0, + resourceReferenceName = resourceReferenceInfo.name, + resourceReferenceValue = resourceReferenceInfo.resourceReference.referenceElement.value, + ) + } + createLocalChange(localChangeEntity, localChangeReferences) } - suspend fun createLocalChange(localChange: LocalChange, resourceUuid: UUID) { - addLocalChange( - LocalChangeEntity( - id = 0, - resourceType = localChange.resourceType, - resourceId = localChange.resourceId, - resourceUuid = resourceUuid, - timestamp = localChange.timestamp, - type = Type.from(localChange.type.value), - payload = localChange.payload, - versionId = localChange.versionId, - ), - ) + suspend fun createLocalChange( + localChange: LocalChangeEntity, + localChangeReferences: List, + ) { + val localChangeId = addLocalChange(localChange) + if (localChangeReferences.isNotEmpty()) { + insertLocalChangeResourceReferences( + localChangeReferences.map { it.copy(localChangeId = localChangeId) }, + ) + } } - suspend fun addUpdate(oldEntity: ResourceEntity, resource: Resource, timeOfLocalChange: Instant) { - val resourceId = resource.logicalId - val resourceType = resource.resourceType + suspend fun addUpdate( + oldEntity: ResourceEntity, + updatedResource: Resource, + timeOfLocalChange: Instant, + ) { + val resourceId = updatedResource.logicalId + val resourceType = updatedResource.resourceType if ( !localChangeIsEmpty(resourceId, resourceType) && @@ -101,16 +115,16 @@ internal abstract class LocalChangeDao { "Unexpected DELETE when updating $resourceType/$resourceId. UPDATE failed.", ) } - val jsonDiff = - diff(iParser, iParser.parseResource(oldEntity.serializedResource) as Resource, resource) + val oldResource = iParser.parseResource(oldEntity.serializedResource) as Resource + val jsonDiff = diff(iParser, oldResource, updatedResource) if (jsonDiff.length() == 0) { Timber.i( - "New resource ${resource.resourceType}/${resource.id} is same as old resource. " + + "New resource ${updatedResource.resourceType}/${updatedResource.id} is same as old resource. " + "Not inserting UPDATE LocalChange.", ) return } - addLocalChange( + val localChangeEntity = LocalChangeEntity( id = 0, resourceType = resourceType.name, @@ -120,8 +134,18 @@ internal abstract class LocalChangeDao { type = Type.UPDATE, payload = jsonDiff.toString(), versionId = oldEntity.versionId, - ), - ) + ) + + val localChangeReferences = + extractReferencesDiff(oldResource, updatedResource).map { resourceReferenceInfo -> + LocalChangeResourceReferenceEntity( + id = 0, + localChangeId = 0, + resourceReferenceName = resourceReferenceInfo.name, + resourceReferenceValue = resourceReferenceInfo.resourceReference.referenceElement.value, + ) + } + createLocalChange(localChangeEntity, localChangeReferences) } suspend fun addDelete( @@ -130,7 +154,7 @@ internal abstract class LocalChangeDao { resourceType: ResourceType, remoteVersionId: String?, ) { - addLocalChange( + createLocalChange( LocalChangeEntity( id = 0, resourceType = resourceType.name, @@ -141,9 +165,24 @@ internal abstract class LocalChangeDao { payload = "", versionId = remoteVersionId, ), + emptyList(), ) } + private fun extractResourceReferences(resource: Resource) = + fhirTerser.getAllResourceReferences(resource).toSet() + + private fun extractReferencesDiff( + resource1: Resource, + resource2: Resource, + ): Set { + require(resource1.resourceType.equals(resource2.resourceType)) + val resource1References = extractResourceReferences(resource1).toSet() + val resource2References = extractResourceReferences(resource2).toSet() + return resource1References.minus(resource2References) + + resource2References.minus(resource1References) + } + @Query( """ SELECT type @@ -174,7 +213,7 @@ internal abstract class LocalChangeDao { """ SELECT * FROM LocalChangeEntity - ORDER BY LocalChangeEntity.id ASC""", + ORDER BY timestamp ASC""", ) abstract suspend fun getAllLocalChanges(): List @@ -182,14 +221,16 @@ internal abstract class LocalChangeDao { """ SELECT * FROM LocalChangeEntity - WHERE LocalChangeEntity.id IN (:ids)""", + WHERE LocalChangeEntity.id IN (:ids) + ORDER BY timestamp ASC""", ) abstract suspend fun getLocalChanges(ids: List): List @Query( """ SELECT COUNT(*) - FROM LocalChangeEntity + FROM LocalChangeEntity + ORDER BY timestamp ASC """, ) abstract suspend fun getLocalChangesCount(): Int @@ -224,7 +265,8 @@ internal abstract class LocalChangeDao { """ SELECT * FROM LocalChangeEntity - WHERE resourceId = :resourceId AND resourceType = :resourceType + WHERE resourceId = :resourceId AND resourceType = :resourceType + ORDER BY timestamp ASC """, ) abstract suspend fun getLocalChanges( @@ -236,13 +278,132 @@ internal abstract class LocalChangeDao { """ SELECT * FROM LocalChangeEntity - WHERE resourceUuid = :resourceUuid + WHERE resourceUuid = :resourceUuid + ORDER BY timestamp ASC """, ) abstract suspend fun getLocalChanges( resourceUuid: UUID, ): List + @Query( + """ + SELECT * + FROM LocalChangeResourceReferenceEntity + WHERE resourceReferenceValue = :resourceReferenceValue + """, + ) + abstract suspend fun getLocalChangeReferencesWithValue( + resourceReferenceValue: String, + ): List + + @Query( + """ + SELECT * + FROM LocalChangeResourceReferenceEntity + WHERE localChangeId = :localChangeId + """, + ) + abstract suspend fun getReferencesForLocalChange( + localChangeId: Long, + ): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insertLocalChangeResourceReferences( + resourceReferences: List, + ) + + /** + * Updates the [LocalChangeEntity]s for the updated resource by updating the + * [LocalChangeEntity.resourceId] Looks for [LocalChangeEntity] which refer to the updated + * resource through [LocalChangeResourceReferenceEntity]. For each [LocalChangeEntity] which + * contains reference to the updated resource in its payload, we update the payload with the + * reference and also update the corresponding [LocalChangeResourceReferenceEntity]. We delete the + * original [LocalChangeEntity] and create a new one with new + * [LocalChangeResourceReferenceEntity]s in its place. + */ + suspend fun updateResourceId( + resourceUuid: UUID, + oldResource: Resource, + updatedResource: Resource, + ): List { + // update the resource ID in the local change entity + val localChanges = getLocalChanges(resourceUuid) + localChanges + .map { localChangeEntity -> localChangeEntity.copy(resourceId = updatedResource.logicalId) } + // Add LocalChangeEntity with replace strategy, the references need not be updated + .forEach { addLocalChange(it) } + + // update all local changes referring to the resource + val oldReferenceValue = "${oldResource.resourceType.name}/${oldResource.logicalId}" + val updatedReferenceValue = "${updatedResource.resourceType.name}/${updatedResource.logicalId}" + val localChangeReferences = getLocalChangeReferencesWithValue(oldReferenceValue) + val localChangeIds = localChangeReferences.map { it.localChangeId }.distinct() + val localChangesWithReferences = getLocalChanges(localChangeIds).associateBy { it.id } + + localChangeIds.forEach { localChangeId -> + val existingLocalChangeEntity = localChangesWithReferences[localChangeId]!! + val updatedLocalChangeEntity = + replaceReferencesInLocalChangeEntity( + localChange = existingLocalChangeEntity, + oldReference = oldReferenceValue, + updatedReference = updatedReferenceValue, + ) + .copy(id = 0) + val updatedLocalChangeReferences = + getReferencesForLocalChange(localChangeId).map { reference -> + if (reference.resourceReferenceValue == oldReferenceValue) { + LocalChangeResourceReferenceEntity( + id = 0, + localChangeId = 0, + resourceReferenceName = reference.resourceReferenceName, + resourceReferenceValue = updatedReferenceValue, + ) + } else { + reference.copy(id = 0, localChangeId = 0) + } + } + discardLocalChanges(localChangeId) + createLocalChange(updatedLocalChangeEntity, updatedLocalChangeReferences) + } + return localChangesWithReferences.values.map { it.resourceUuid }.distinct() + } + + private fun replaceReferencesInLocalChangeEntity( + localChange: LocalChangeEntity, + oldReference: String, + updatedReference: String, + ): LocalChangeEntity { + return when (localChange.type) { + LocalChangeEntity.Type.INSERT -> { + val insertResourcePayload = iParser.parseResource(localChange.payload) as Resource + val updatedResourcePayload = + addUpdatedReferenceToResource( + iParser, + insertResourcePayload, + oldReference, + updatedReference, + ) + return localChange.copy( + payload = iParser.encodeResourceToString(updatedResourcePayload), + ) + } + LocalChangeEntity.Type.UPDATE -> { + val patchArray = JSONArray(localChange.payload) + val updatedPatchArray = JSONArray() + for (i in 0 until patchArray.length()) { + val updatedPatch = + replaceJsonValue(patchArray.getJSONObject(i), oldReference, updatedReference) + updatedPatchArray.put(updatedPatch) + } + return localChange.copy( + payload = updatedPatchArray.toString(), + ) + } + LocalChangeEntity.Type.DELETE -> localChange + } + } + class InvalidLocalChangeException(message: String?) : Exception(message) } 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 9a9994a8fd..fad7f98582 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 @@ -25,7 +25,6 @@ import androidx.room.Query import androidx.room.RawQuery import androidx.sqlite.db.SupportSQLiteQuery import ca.uhn.fhir.parser.IParser -import com.google.android.fhir.db.ResourceIdentifierType import com.google.android.fhir.db.ResourceNotFoundException import com.google.android.fhir.db.impl.entities.DateIndexEntity import com.google.android.fhir.db.impl.entities.DateTimeIndexEntity @@ -65,19 +64,16 @@ internal abstract class ResourceDao { } ?: throw ResourceNotFoundException( resource.resourceType.name, - ResourceIdentifierType.ID, resource.id, ) } - suspend fun updateResourceWithUuid(updatedResource: Resource, resourceUuid: UUID) { + suspend fun updateResourceWithUuid(resourceUuid: UUID, updatedResource: Resource) { getResourceEntity(resourceUuid)?.let { updateResourceEntity(it, updatedResource, it.lastUpdatedLocal) } ?: throw ResourceNotFoundException( - updatedResource.resourceType.name, - ResourceIdentifierType.UUID, - updatedResource.id, + resourceUuid, ) } @@ -221,11 +217,6 @@ internal abstract class ResourceDao { query: SupportSQLiteQuery, ): List - @RawQuery - abstract suspend fun getReferringResources( - query: SupportSQLiteQuery, - ): List - @RawQuery abstract suspend fun countResources(query: SupportSQLiteQuery): Long suspend fun insertLocalResource(resource: Resource, timeOfChange: Instant) = @@ -424,29 +415,3 @@ internal data class IndexedIdAndResource( val idOfBaseResourceOnWhichThisMatched: String, val resource: Resource, ) - -/** - * Data class representing a [ResourceEntity] which is referring to the requested resource, and the - * FHIR path of the referring [Resource]. The referring resource payload is serialized. - */ -internal data class ReferringSerialisedResourceWithReferringPath( - @ColumnInfo(name = "resourceUuid") val resourceUuid: UUID, - @ColumnInfo(name = "resourceId") val resourceId: String, - @ColumnInfo(name = "resourceType") val resourceType: String, - @ColumnInfo(name = "serializedResource") val serializedResource: String, - @ColumnInfo(name = "index_path") val path: String, -) - -/** - * Data class representing a [ResourceEntity] which is referring to the requested resource, and a - * list of the FHIR Paths of the referring [Resource] where the requested resource is referred. The - * referring resource payload is deserialized into a [Resource] object. - */ -internal data class ReferringResource( - val resourceUuid: UUID, - val resourceId: String, - val resourceType: ResourceType, - val resource: Resource, - val referenceValue: String, - val referringPaths: List, -) diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeResourceReferenceEntity.kt b/engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeResourceReferenceEntity.kt new file mode 100644 index 0000000000..2c65db22c0 --- /dev/null +++ b/engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeResourceReferenceEntity.kt @@ -0,0 +1,46 @@ +/* + * 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.db.impl.entities + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + indices = + [ + Index(value = ["resourceReferenceValue"]), + ], + foreignKeys = + [ + ForeignKey( + entity = LocalChangeEntity::class, + parentColumns = ["id"], + childColumns = ["localChangeId"], + onDelete = ForeignKey.CASCADE, + onUpdate = ForeignKey.NO_ACTION, + deferred = true, + ), + ], +) +internal data class LocalChangeResourceReferenceEntity( + @PrimaryKey(autoGenerate = true) val id: Long, + val localChangeId: Long, + val resourceReferenceValue: String, + val resourceReferenceName: String, +) 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 e10a48225e..a7522f074d 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 @@ -130,9 +130,8 @@ internal class FhirEngineImpl(private val database: Database, private val contex val resourceConsolidator = DefaultResourceConsolidator(database) val localChangeFetcher = LocalChangeFetcherFactory.byMode(localChangesFetchMode, database) while (localChangeFetcher.hasNext()) { - upload(localChangeFetcher.next()).collect { - resourceConsolidator.consolidate(it.first, it.second) - } + val localChanges = localChangeFetcher.next() + upload(localChanges).collect { resourceConsolidator.consolidate(localChanges, it.second) } } } } diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/DefaultResourceConsolidator.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/DefaultResourceConsolidator.kt index b2e2b0bcdf..e73486c544 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/DefaultResourceConsolidator.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/DefaultResourceConsolidator.kt @@ -16,8 +16,10 @@ package com.google.android.fhir.sync.upload.consolidator +import com.google.android.fhir.LocalChange import com.google.android.fhir.LocalChangeToken import com.google.android.fhir.db.Database +import com.google.android.fhir.logicalId import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType @@ -26,15 +28,18 @@ import timber.log.Timber /** 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) { + override suspend fun consolidate(localChanges: List, response: Resource) { + val localChangeToken = LocalChangeToken(localChanges.flatMap { it.token.ids }) database.deleteUpdates(localChangeToken) when (response) { is Bundle -> updateVersionIdAndLastUpdated(response) - else -> updateVersionIdAndLastUpdated(response) + else -> updateVersionIdAndLastUpdated(localChanges, response) } } private suspend fun updateVersionIdAndLastUpdated(bundle: Bundle) { + // TODO: Support POST in Bundle transactions. Assumption is that only PUT operations are used in + // Bundle requests when (bundle.type) { Bundle.BundleType.TRANSACTIONRESPONSE -> { bundle.entry.forEach { @@ -75,6 +80,23 @@ internal class DefaultResourceConsolidator(private val database: Database) : Res } } + private suspend fun updateVersionIdAndLastUpdated( + localChanges: List, + resource: Resource, + ) { + if (localChanges.first().resourceId != resource.logicalId) { + database.updateResourceAndId(localChanges.first().resourceId, 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. diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/PostPerResourceUrlRequestConsolidator.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/PostPerResourceUrlRequestConsolidator.kt deleted file mode 100644 index 87cd2b4180..0000000000 --- a/engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/PostPerResourceUrlRequestConsolidator.kt +++ /dev/null @@ -1,198 +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.consolidator - -import ca.uhn.fhir.context.FhirContext -import ca.uhn.fhir.context.FhirVersionEnum -import ca.uhn.fhir.parser.IParser -import com.google.android.fhir.LocalChange -import com.google.android.fhir.LocalChangeToken -import com.google.android.fhir.db.Database -import com.google.android.fhir.db.impl.dao.ReferringResource -import com.google.android.fhir.logicalId -import com.google.android.fhir.sync.upload.request.UrlUploadRequest -import org.hl7.fhir.r4.model.Resource -import org.hl7.fhir.r4.model.ResourceType -import org.json.JSONArray -import org.json.JSONObject - -/** - * Implementation of [ResourceConsolidator] for POST mode of creation of resources. The - * [LocalChange]s are squashed at a resource level and each resource request is uploaded - * individually using [UrlUploadRequest]. - * - * Since we know that all the changes will be squashed at a [Resource] level even for the subsequent - * changes, it is safe to assume that we can update references for only those resources whose - * references exist in the [ReferenceIndex]. i.e. any stale reference which exists in the - * [LocalChange] but does not exist in the [ReferenceIndex] will eventually be removed as a result - * of squashing [LocalChange] at a resource level. - */ -internal class PostPerResourceUrlRequestConsolidator( - private val database: Database, -) : ResourceConsolidator { - - private val defaultConsolidator: DefaultResourceConsolidator = - DefaultResourceConsolidator(database) - - private val fhirContext: FhirContext = FhirContext.forCached(FhirVersionEnum.R4) - - private val iParser: IParser = fhirContext.newJsonParser() - - override suspend fun consolidate(localChangeToken: LocalChangeToken, response: Resource) { - val localChanges = database.getAllLocalChanges(localChangeToken) - decideConsolidationByChangesType(localChanges, localChangeToken, response) - } - - private suspend fun decideConsolidationByChangesType( - localChanges: List, - localChangeToken: LocalChangeToken, - response: Resource, - ) { - if (localChanges.first().resourceId != response.logicalId) { - consolidateForResourceCreation(localChanges, response) - } - defaultConsolidator.consolidate(localChangeToken, response) - } - - private suspend fun consolidateForResourceCreation( - localChanges: List, - updatedResource: Resource, - ) { - val createdResourceType = ResourceType.fromCode(localChanges.first().resourceType) - val createdResourceLocalId = localChanges.first().resourceId - val createdResourceUpdatedId = updatedResource.logicalId - val resourceEntity = database.selectEntity(createdResourceType, createdResourceLocalId) - - // update the resource - database.updateResourceWithUuid(updatedResource, resourceEntity.resourceUuid) - - // update all the local changes for this resource with the new resource Id - database.updateResourceIdForResourceChanges( - resourceEntity.resourceUuid, - createdResourceUpdatedId, - ) - - // consolidate all resource which refer to the newly created resource - val referringResourceWithReferringPaths = - database.getAllResourcesReferringToResourceWithPath( - createdResourceType, - createdResourceLocalId, - ) - val updatedReference = "${createdResourceType.name}/$createdResourceUpdatedId" - referringResourceWithReferringPaths.forEach { referringResource -> - updateReferencesInResource( - referringResource, - referringResource.referenceValue, - updatedReference, - ) - } - } - - private suspend fun updateReferencesInResource( - referringResource: ReferringResource, - currentReference: String, - updatedReference: String, - ) { - val resourceWithUpdatedReferences = - addUpdatedReferenceToResource( - referringResource.resource, - referringResource.referenceValue, - updatedReference, - ) - database.updateResourceWithUuid(resourceWithUpdatedReferences, referringResource.resourceUuid) - - val referringResourceChanges = database.getLocalChanges(referringResource.resourceUuid) - val updatedResourceChanges = - referringResourceChanges.map { - replaceReferencesInLocalChange(it, currentReference, updatedReference) - } - database.replaceResourceChanges( - referringResource.resourceType, - referringResource.resourceUuid, - updatedResourceChanges, - ) - } - - private fun addUpdatedReferenceToResource( - resource: Resource, - outdatedReference: String, - updatedReference: String, - ): Resource { - val resourceJsonObject = JSONObject(iParser.encodeResourceToString(resource)) - val updatedResource = replaceJsonValue(resourceJsonObject, outdatedReference, updatedReference) - return iParser.parseResource(updatedResource.toString()) as Resource - } - - private fun replaceReferencesInLocalChange( - localChange: LocalChange, - oldReference: String, - updatedReference: String, - ): LocalChange { - return when (localChange.type) { - LocalChange.Type.INSERT -> { - val insertResourcePayload = iParser.parseResource(localChange.payload) as Resource - val updatedResourcePayload = - addUpdatedReferenceToResource(insertResourcePayload, oldReference, updatedReference) - return localChange.copy(payload = iParser.encodeResourceToString(updatedResourcePayload)) - } - LocalChange.Type.UPDATE -> { - val patchArray = JSONArray(localChange.payload) - val updatedPatchArray = JSONArray() - for (i in 0 until patchArray.length()) { - val updatedPatch = - replaceJsonValue(patchArray.getJSONObject(i), oldReference, updatedReference) - updatedPatchArray.put(updatedPatch) - } - return localChange.copy(payload = updatedPatchArray.toString()) - } - LocalChange.Type.DELETE -> localChange - } - } - - private fun replaceJsonValue( - jsonObject: JSONObject, - currentValue: String, - newValue: String, - ): JSONObject { - val iterator: Iterator<*> = jsonObject.keys() - var key: String? - while (iterator.hasNext()) { - key = iterator.next() as String - // if object is just string we change value in key - if (jsonObject.optJSONArray(key) == null && jsonObject.optJSONObject(key) == null) { - if (jsonObject.optString(key) == currentValue) { - jsonObject.put(key, newValue) - return jsonObject - } - } - - // if it's jsonobject - if (jsonObject.optJSONObject(key) != null) { - replaceJsonValue(jsonObject.getJSONObject(key), currentValue, newValue) - } - - // if it's jsonarray - if (jsonObject.optJSONArray(key) != null) { - val jArray = jsonObject.getJSONArray(key) - for (i in 0 until jArray.length()) { - replaceJsonValue(jArray.getJSONObject(i), currentValue, newValue) - } - } - } - return jsonObject - } -} diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/ResourceConsolidator.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/ResourceConsolidator.kt index da623614c8..dd01f2636b 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/ResourceConsolidator.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/ResourceConsolidator.kt @@ -16,7 +16,7 @@ package com.google.android.fhir.sync.upload.consolidator -import com.google.android.fhir.LocalChangeToken +import com.google.android.fhir.LocalChange import org.hl7.fhir.r4.model.Resource /** @@ -32,5 +32,5 @@ import org.hl7.fhir.r4.model.Resource internal fun interface ResourceConsolidator { /** Consolidates the local change token with the provided response from the FHIR server. */ - suspend fun consolidate(localChangeToken: LocalChangeToken, response: Resource) + suspend fun consolidate(localChanges: List, response: Resource) } 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 2ecabd7ec5..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 @@ -102,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!", ) } @@ -161,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!", ) } @@ -423,7 +423,7 @@ class FhirEngineImplTest { runBlocking { fhirEngine.get(ResourceType.Patient, patient.logicalId) } } assertThat(resourceNotFoundException.message) - .isEqualTo("Resource not found with type Patient and ID ${patient.logicalId}!") + .isEqualTo("Resource not found with type Patient and id ${patient.logicalId}!") } @Test @@ -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() } @@ -462,7 +462,7 @@ 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!", ) } From 0e5e482c43262ec23f9efa53f9cfa7aa609e5bbd Mon Sep 17 00:00:00 2001 From: anchita-g <109063673+anchita-g@users.noreply.github.com> Date: Fri, 6 Oct 2023 14:23:53 +0530 Subject: [PATCH 12/21] Adding LocalChangeDaoTest --- .../android/fhir/db/impl/DatabaseImplTest.kt | 8 +- .../fhir/db/impl/dao/LocalChangeDaoTest.kt | 359 ++++++++++++++++++ .../fhir/db/impl/dao/LocalChangeDao.kt | 20 +- .../consolidator/ResourceConsolidator.kt | 2 +- 4 files changed, 381 insertions(+), 8 deletions(-) create mode 100644 engine/src/androidTest/java/com/google/android/fhir/db/impl/dao/LocalChangeDaoTest.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 a220440af8..e9604361b8 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,7 +19,6 @@ 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.rest.gclient.StringClientParam import ca.uhn.fhir.rest.param.ParamPrefixEnum import com.google.android.fhir.DateProvider @@ -106,7 +105,6 @@ class DatabaseImplTest { private val context: Context = ApplicationProvider.getApplicationContext() private lateinit var services: FhirServices private lateinit var database: Database - private val iParser = FhirContext.forR4Cached().newJsonParser() @Before fun setUp(): Unit = runBlocking { @@ -3600,7 +3598,7 @@ class DatabaseImplTest { val observationLocalChange = updatedObservationLocalChanges[0] assertThat(observationLocalChange.type).isEqualTo(LocalChange.Type.INSERT) val observationLocalChangePayload = - iParser.parseResource(observationLocalChange.payload) as Observation + services.parser.parseResource(observationLocalChange.payload) as Observation assertThat(observationLocalChangePayload.subject.reference) .isEqualTo("Patient/$remotelyCreatedPatientResourceId") @@ -3703,7 +3701,7 @@ class DatabaseImplTest { val observationLocalChange1 = updatedObservationLocalChanges[0] assertThat(observationLocalChange1.type).isEqualTo(LocalChange.Type.INSERT) val observationLocalChange1Payload = - iParser.parseResource(observationLocalChange1.payload) as Observation + services.parser.parseResource(observationLocalChange1.payload) as Observation assertThat(observationLocalChange1Payload.subject.reference) .isEqualTo("Patient/$remotelyCreatedPatientResourceId") @@ -3810,7 +3808,7 @@ class DatabaseImplTest { val observationLocalChange1 = updatedObservationLocalChanges[0] assertThat(observationLocalChange1.type).isEqualTo(LocalChange.Type.INSERT) val observationLocalChange1Payload = - iParser.parseResource(observationLocalChange1.payload) as Observation + services.parser.parseResource(observationLocalChange1.payload) as Observation assertThat(observationLocalChange1Payload.subject.reference) .isEqualTo("Patient/$remotelyCreatedPatientResourceId") diff --git a/engine/src/androidTest/java/com/google/android/fhir/db/impl/dao/LocalChangeDaoTest.kt b/engine/src/androidTest/java/com/google/android/fhir/db/impl/dao/LocalChangeDaoTest.kt new file mode 100644 index 0000000000..b3ee74bff0 --- /dev/null +++ b/engine/src/androidTest/java/com/google/android/fhir/db/impl/dao/LocalChangeDaoTest.kt @@ -0,0 +1,359 @@ +/* + * 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.db.impl.dao + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import ca.uhn.fhir.context.FhirContext +import ca.uhn.fhir.context.FhirVersionEnum +import ca.uhn.fhir.util.FhirTerser +import com.google.android.fhir.db.impl.ResourceDatabase +import com.google.android.fhir.db.impl.entities.LocalChangeEntity +import com.google.android.fhir.db.impl.entities.ResourceEntity +import com.google.android.fhir.logicalId +import com.google.common.truth.Truth.assertThat +import java.time.Instant +import java.util.UUID +import kotlinx.coroutines.runBlocking +import org.hl7.fhir.r4.model.CarePlan +import org.hl7.fhir.r4.model.CodeableConcept +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.Enumerations +import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.Reference +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidJUnit4::class) +class LocalChangeDaoTest { + private lateinit var database: ResourceDatabase + private lateinit var localChangeDao: LocalChangeDao + + @Before + fun setupDatabase() { + database = + Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + ResourceDatabase::class.java, + ) + .allowMainThreadQueries() + .build() + + localChangeDao = + database.localChangeDao().also { + it.iParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() + it.fhirTerser = FhirTerser(FhirContext.forCached(FhirVersionEnum.R4)) + } + } + + @After + fun closeDatabase() { + database.close() + } + + @Test + fun addInsert_shouldAddLocalChangeAndLocalChangeReferences() = runBlocking { + val patientId = "Patient1" + val carePlanResourceUuid = UUID.randomUUID() + val carePlan = + CarePlan().apply { + id = "CarePlan1" + subject = Reference("Patient/$patientId") + activityFirstRep.detail.performer.add(Reference("Patient/$patientId")) + category = + listOf( + CodeableConcept( + Coding("http://snomed.info/sct", "698360004", "Diabetes self management plan"), + ), + ) + } + val carePlanCreationTime = Instant.now() + localChangeDao.addInsert(carePlan, carePlanResourceUuid, carePlanCreationTime) + + val carePlanLocalChanges = localChangeDao.getLocalChanges(carePlanResourceUuid) + assertThat(carePlanLocalChanges.size).isEqualTo(1) + val carePlanLocalChange1 = carePlanLocalChanges[0] + assertThat(carePlanLocalChange1.resourceUuid).isEqualTo(carePlanResourceUuid) + assertThat(carePlanLocalChange1.resourceId).isEqualTo(carePlan.id) + assertThat(carePlanLocalChange1.type).isEqualTo(LocalChangeEntity.Type.INSERT) + assertThat(carePlanLocalChange1.payload) + .isEqualTo(localChangeDao.iParser.encodeResourceToString(carePlan)) + val carePlanLocalChange1Id = carePlanLocalChange1.id + + val localChangeResourceReferences = + localChangeDao.getReferencesForLocalChange(carePlanLocalChange1Id) + assertThat(localChangeResourceReferences.size).isEqualTo(2) + assertThat(localChangeResourceReferences[0].resourceReferenceName).isEqualTo("subject") + assertThat(localChangeResourceReferences[0].resourceReferenceValue) + .isEqualTo("Patient/$patientId") + assertThat(localChangeResourceReferences[1].resourceReferenceName) + .isEqualTo("activity.detail.performer") + assertThat(localChangeResourceReferences[1].resourceReferenceValue) + .isEqualTo("Patient/$patientId") + } + + @Test + fun addUpdate_shouldAddLocalChangeAndLocalChangeReferences() = runBlocking { + val patientId = "Patient1" + val carePlanResourceUuid = UUID.randomUUID() + val originalCarePlan = + CarePlan().apply { + id = "CarePlan1" + subject = Reference("Patient/$patientId") + activityFirstRep.detail.performer.add(Reference("Patient/$patientId")) + category = + listOf( + CodeableConcept( + Coding("http://snomed.info/sct", "698360004", "Diabetes self management plan"), + ), + ) + } + val carePlanCreationTime = Instant.now() + localChangeDao.addInsert(originalCarePlan, carePlanResourceUuid, carePlanCreationTime) + + val practitionerReference = "Practitioner/Practitioner123" + val modifiedCarePlan = + originalCarePlan.copy().apply { + author = Reference(practitionerReference) + activityFirstRep.detail.performer.clear() + activityFirstRep.detail.performer.add(Reference(practitionerReference)) + } + val carePlanUpdateTime = Instant.now() + localChangeDao.addUpdate( + oldEntity = + ResourceEntity( + id = 0, + lastUpdatedLocal = carePlanCreationTime, + lastUpdatedRemote = null, + versionId = null, + resourceId = originalCarePlan.logicalId, + resourceType = originalCarePlan.resourceType, + resourceUuid = carePlanResourceUuid, + serializedResource = localChangeDao.iParser.encodeResourceToString(originalCarePlan), + ), + updatedResource = modifiedCarePlan, + timeOfLocalChange = carePlanUpdateTime, + ) + + val carePlanLocalChanges = localChangeDao.getLocalChanges(carePlanResourceUuid) + assertThat(carePlanLocalChanges.size).isEqualTo(2) + val carePlanLocalChange1 = carePlanLocalChanges[0] + assertThat(carePlanLocalChange1.resourceUuid).isEqualTo(carePlanResourceUuid) + assertThat(carePlanLocalChange1.resourceId).isEqualTo(originalCarePlan.id) + assertThat(carePlanLocalChange1.type).isEqualTo(LocalChangeEntity.Type.INSERT) + assertThat(carePlanLocalChange1.payload) + .isEqualTo(localChangeDao.iParser.encodeResourceToString(originalCarePlan)) + + val carePlanLocalChange2 = carePlanLocalChanges[1] + assertThat(carePlanLocalChange2.resourceUuid).isEqualTo(carePlanResourceUuid) + assertThat(carePlanLocalChange2.resourceId).isEqualTo(originalCarePlan.id) + assertThat(carePlanLocalChange2.type).isEqualTo(LocalChangeEntity.Type.UPDATE) + assertThat(carePlanLocalChange2.payload) + .isEqualTo( + "[{\"op\":\"add\",\"path\":\"\\/author\",\"value\":{\"reference\":\"Practitioner\\/Practitioner123\"}}" + + ",{\"op\":\"replace\",\"path\":\"\\/activity\\/0\\/detail\\/performer\\/0\\/reference\",\"value\":\"Practitioner\\/Practitioner123\"}]", + ) + val carePlanLocalChange2Id = carePlanLocalChange2.id + + val localChangeResourceReferences = + localChangeDao.getReferencesForLocalChange(carePlanLocalChange2Id) + assertThat(localChangeResourceReferences.size).isEqualTo(3) + assertThat(localChangeResourceReferences[0].resourceReferenceName) + .isEqualTo("activity.detail.performer") + assertThat(localChangeResourceReferences[0].resourceReferenceValue) + .isEqualTo("Patient/$patientId") + assertThat(localChangeResourceReferences[1].resourceReferenceName).isEqualTo("author") + assertThat(localChangeResourceReferences[1].resourceReferenceValue) + .isEqualTo(practitionerReference) + assertThat(localChangeResourceReferences[2].resourceReferenceName) + .isEqualTo("activity.detail.performer") + assertThat(localChangeResourceReferences[2].resourceReferenceValue) + .isEqualTo(practitionerReference) + } + + @Test + fun addDelete_shouldAddOnlyLocalChangeEntity() = runBlocking { + val patientId = "Patient1" + val carePlanResourceUuid = UUID.randomUUID() + val carePlan = + CarePlan().apply { + id = "CarePlan1" + subject = Reference("Patient/$patientId") + activityFirstRep.detail.performer.add(Reference("Patient/$patientId")) + category = + listOf( + CodeableConcept( + Coding("http://snomed.info/sct", "698360004", "Diabetes self management plan"), + ), + ) + } + val carePlanCreationTime = Instant.now() + localChangeDao.addInsert(carePlan, carePlanResourceUuid, carePlanCreationTime) + + localChangeDao.addDelete( + resourceUuid = carePlanResourceUuid, + resourceType = carePlan.resourceType, + remoteVersionId = null, + resourceId = carePlan.id, + ) + + val carePlanLocalChanges = localChangeDao.getLocalChanges(carePlanResourceUuid) + assertThat(carePlanLocalChanges.size).isEqualTo(2) + val carePlanLocalChange1 = carePlanLocalChanges[0] + assertThat(carePlanLocalChange1.resourceUuid).isEqualTo(carePlanResourceUuid) + assertThat(carePlanLocalChange1.resourceId).isEqualTo(carePlan.id) + assertThat(carePlanLocalChange1.type).isEqualTo(LocalChangeEntity.Type.INSERT) + assertThat(carePlanLocalChange1.payload) + .isEqualTo(localChangeDao.iParser.encodeResourceToString(carePlan)) + + val carePlanLocalChange2 = carePlanLocalChanges[1] + assertThat(carePlanLocalChange2.resourceUuid).isEqualTo(carePlanResourceUuid) + assertThat(carePlanLocalChange2.resourceId).isEqualTo(carePlan.id) + assertThat(carePlanLocalChange2.type).isEqualTo(LocalChangeEntity.Type.DELETE) + assertThat(carePlanLocalChange2.payload).isEqualTo("") + val carePlanLocalChange2Id = carePlanLocalChange2.id + + val localChangeResourceReferences = + localChangeDao.getReferencesForLocalChange(carePlanLocalChange2Id) + assertThat(localChangeResourceReferences.size).isEqualTo(0) + } + + @Test + fun updateResourceId_shouldUpdateLocalChangeAndLocalChangeReferences() = runBlocking { + val patientId = "Patient1" + val patientResourceUuid = UUID.randomUUID() + val patient = + Patient().apply { + gender = Enumerations.AdministrativeGender.MALE + id = patientId + } + val patientCreationTime = Instant.now() + localChangeDao.addInsert(patient, patientResourceUuid, patientCreationTime) + + val carePlanResourceUuid = UUID.randomUUID() + val originalCarePlan = + CarePlan().apply { + id = "CarePlan1" + subject = Reference("Patient/$patientId") + activityFirstRep.detail.performer.add(Reference("Patient/$patientId")) + category = + listOf( + CodeableConcept( + Coding("http://snomed.info/sct", "698360004", "Diabetes self management plan"), + ), + ) + } + val carePlanCreationTime = Instant.now() + localChangeDao.addInsert(originalCarePlan, carePlanResourceUuid, carePlanCreationTime) + + val practitionerReference = "Practitioner/Practitioner123" + val modifiedCarePlan = + originalCarePlan.copy().apply { + author = Reference(practitionerReference) + activityFirstRep.detail.performer.clear() + activityFirstRep.detail.performer.add(Reference(practitionerReference)) + } + val carePlanUpdateTime = Instant.now() + localChangeDao.addUpdate( + oldEntity = + ResourceEntity( + id = 0, + lastUpdatedLocal = carePlanCreationTime, + lastUpdatedRemote = null, + versionId = null, + resourceId = originalCarePlan.logicalId, + resourceType = originalCarePlan.resourceType, + resourceUuid = carePlanResourceUuid, + serializedResource = localChangeDao.iParser.encodeResourceToString(originalCarePlan), + ), + updatedResource = modifiedCarePlan, + timeOfLocalChange = carePlanUpdateTime, + ) + + val updatedPatientId = "SyncedPatient1" + val updatedPatient = patient.copy().apply { id = updatedPatientId } + localChangeDao.updateResourceId( + resourceUuid = patientResourceUuid, + oldResource = patient, + updatedResource = updatedPatient, + ) + + // assert that Patient's new ID is reflected in the Patient Resource Change + val patientLocalChanges = localChangeDao.getLocalChanges(patientResourceUuid) + assertThat(patientLocalChanges.size).isEqualTo(1) + assertThat(patientLocalChanges[0].resourceId).isEqualTo(updatedPatientId) + + // assert that LocalChanges are still retrieved in the same sequence + val carePlanLocalChanges = localChangeDao.getLocalChanges(carePlanResourceUuid) + assertThat(carePlanLocalChanges.size).isEqualTo(2) + val carePlanLocalChange1 = carePlanLocalChanges[0] + assertThat(carePlanLocalChange1.resourceUuid).isEqualTo(carePlanResourceUuid) + assertThat(carePlanLocalChange1.resourceId).isEqualTo(originalCarePlan.id) + assertThat(carePlanLocalChange1.type).isEqualTo(LocalChangeEntity.Type.INSERT) + val updatedReferencesCarePlan = + originalCarePlan.copy().apply { + subject = Reference("Patient/$updatedPatientId") + activityFirstRep.detail.performer.clear() + activityFirstRep.detail.performer.add(Reference("Patient/$updatedPatientId")) + } + assertThat(carePlanLocalChange1.payload) + .isEqualTo(localChangeDao.iParser.encodeResourceToString(updatedReferencesCarePlan)) + val carePlanLocalChange1Id = carePlanLocalChange1.id + // assert that LocalChangeReferences are updated as well + val localChange1ResourceReferences = + localChangeDao.getReferencesForLocalChange(carePlanLocalChange1Id) + assertThat(localChange1ResourceReferences.size).isEqualTo(2) + assertThat(localChange1ResourceReferences[0].resourceReferenceName).isEqualTo("subject") + assertThat(localChange1ResourceReferences[0].resourceReferenceValue) + .isEqualTo("Patient/$updatedPatientId") + assertThat(localChange1ResourceReferences[1].resourceReferenceName) + .isEqualTo("activity.detail.performer") + assertThat(localChange1ResourceReferences[1].resourceReferenceValue) + .isEqualTo("Patient/$updatedPatientId") + + val carePlanLocalChange2 = carePlanLocalChanges[1] + assertThat(carePlanLocalChange2.resourceUuid).isEqualTo(carePlanResourceUuid) + assertThat(carePlanLocalChange2.resourceId).isEqualTo(originalCarePlan.id) + assertThat(carePlanLocalChange2.type).isEqualTo(LocalChangeEntity.Type.UPDATE) + assertThat(carePlanLocalChange2.payload) + .isEqualTo( + "[{\"op\":\"add\",\"path\":\"\\/author\",\"value\":{\"reference\":\"Practitioner\\/Practitioner123\"}}" + + ",{\"op\":\"replace\",\"path\":\"\\/activity\\/0\\/detail\\/performer\\/0\\/reference\",\"value\":\"Practitioner\\/Practitioner123\"}]", + ) + val carePlanLocalChange2Id = carePlanLocalChange2.id + // assert that LocalChangeReferences are updated as well + val localChangeResourceReferences = + localChangeDao.getReferencesForLocalChange(carePlanLocalChange2Id) + assertThat(localChangeResourceReferences.size).isEqualTo(3) + assertThat(localChangeResourceReferences[0].resourceReferenceName) + .isEqualTo("activity.detail.performer") + assertThat(localChangeResourceReferences[0].resourceReferenceValue) + .isEqualTo("Patient/$updatedPatientId") + assertThat(localChangeResourceReferences[1].resourceReferenceName).isEqualTo("author") + assertThat(localChangeResourceReferences[1].resourceReferenceValue) + .isEqualTo(practitionerReference) + assertThat(localChangeResourceReferences[2].resourceReferenceName) + .isEqualTo("activity.detail.performer") + assertThat(localChangeResourceReferences[2].resourceReferenceValue) + .isEqualTo(practitionerReference) + } +} 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 141308e0ff..94c73707cb 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 @@ -16,6 +16,7 @@ package com.google.android.fhir.db.impl.dao +import androidx.annotation.VisibleForTesting import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy @@ -49,6 +50,7 @@ import timber.log.Timber * e.g. an INSERT or UPDATE. The UPDATES (diffs) are stored as RFC 6902 JSON patches. */ @Dao +@VisibleForTesting internal abstract class LocalChangeDao { lateinit var iParser: IParser @@ -87,7 +89,7 @@ internal abstract class LocalChangeDao { createLocalChange(localChangeEntity, localChangeReferences) } - suspend fun createLocalChange( + private suspend fun createLocalChange( localChange: LocalChangeEntity, localChangeReferences: List, ) { @@ -172,6 +174,10 @@ internal abstract class LocalChangeDao { private fun extractResourceReferences(resource: Resource) = fhirTerser.getAllResourceReferences(resource).toSet() + /** + * Extract the difference in the [ResourceReferenceInfo] by getting all the references from both + * the versions of the resource and then finding out the difference in the two sets. + */ private fun extractReferencesDiff( resource1: Resource, resource2: Resource, @@ -183,6 +189,16 @@ internal abstract class LocalChangeDao { resource2References.minus(resource1References) } + private fun Set.minus(set: Set) = + filter { ref -> + set.none { + it.name == ref.name && + it.resourceReference.referenceElement.value == + ref.resourceReference.referenceElement.value + } + } + .toSet() + @Query( """ SELECT type @@ -315,7 +331,7 @@ internal abstract class LocalChangeDao { /** * Updates the [LocalChangeEntity]s for the updated resource by updating the - * [LocalChangeEntity.resourceId] Looks for [LocalChangeEntity] which refer to the updated + * [LocalChangeEntity.resourceId]. Looks for [LocalChangeEntity] which refer to the updated * resource through [LocalChangeResourceReferenceEntity]. For each [LocalChangeEntity] which * contains reference to the updated resource in its payload, we update the payload with the * reference and also update the corresponding [LocalChangeResourceReferenceEntity]. We delete the diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/ResourceConsolidator.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/ResourceConsolidator.kt index dd01f2636b..1365b35478 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/ResourceConsolidator.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/ResourceConsolidator.kt @@ -22,7 +22,7 @@ import org.hl7.fhir.r4.model.Resource /** * 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 + * INTERNAL ONLY. This interface should NEVER be 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, From bac72da4f78a41feeacf0b018fe46fda47b4b25a Mon Sep 17 00:00:00 2001 From: anchita-g <109063673+anchita-g@users.noreply.github.com> Date: Fri, 6 Oct 2023 14:53:23 +0530 Subject: [PATCH 13/21] WIP --- .../android/fhir/db/impl/ResourceDatabase.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt b/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt index 8f93d89055..2f78e17706 100644 --- a/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt +++ b/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt @@ -151,3 +151,21 @@ val MIGRATION_6_7 = ) } } + + +/** Create [LocalChangeResourceReferenceEntity] */ +val MIGRATION_7_8 = + object : Migration(7, 8) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + "CREATE TABLE IF NOT EXISTS `LocalChangeResourceReferenceEntity` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `localChangeId` INTEGER NOT NULL, `resourceReferenceValue` TEXT NOT NULL, `resourceReferenceName` TEXT NOT NULL, FOREIGN KEY(`localChangeId`) REFERENCES `LocalChangeEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)" + ) + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_LocalChangeResourceReferenceEntity_resourceReferenceValue` ON `LocalChangeResourceReferenceEntity` (`resourceReferenceValue`)", + ) + + database.query("SELECT id,payload from LocalChangeEntity") + + } + } + From a5d7f34543c0cc0dae3c5308636509290776da66 Mon Sep 17 00:00:00 2001 From: anchita-g <109063673+anchita-g@users.noreply.github.com> Date: Mon, 9 Oct 2023 11:21:36 +0530 Subject: [PATCH 14/21] added migration and tests for migration --- .../8.json | 8 +- .../db/impl/ResourceDatabaseMigrationTest.kt | 76 +++++++++++++++++++ .../google/android/fhir/db/impl/JsonUtils.kt | 37 +++++++++ .../android/fhir/db/impl/ResourceDatabase.kt | 46 +++++++++-- .../LocalChangeResourceReferenceEntity.kt | 2 +- 5 files changed, 159 insertions(+), 10 deletions(-) diff --git a/engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/8.json b/engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/8.json index 845e56977e..1be9d0c299 100644 --- a/engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/8.json +++ b/engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/8.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 8, - "identityHash": "8dddbb1e8b44cb78ebe88f61aeab9aeb", + "identityHash": "81bc60d60855266f7fc233eb1c164a89", "entities": [ { "tableName": "ResourceEntity", @@ -948,7 +948,7 @@ }, { "tableName": "LocalChangeResourceReferenceEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `localChangeId` INTEGER NOT NULL, `resourceReferenceValue` TEXT NOT NULL, `resourceReferenceName` TEXT NOT NULL, FOREIGN KEY(`localChangeId`) REFERENCES `LocalChangeEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `localChangeId` INTEGER NOT NULL, `resourceReferenceValue` TEXT NOT NULL, `resourceReferenceName` TEXT, FOREIGN KEY(`localChangeId`) REFERENCES `LocalChangeEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", "fields": [ { "fieldPath": "id", @@ -972,7 +972,7 @@ "fieldPath": "resourceReferenceName", "columnName": "resourceReferenceName", "affinity": "TEXT", - "notNull": true + "notNull": false } ], "primaryKey": { @@ -1010,7 +1010,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8dddbb1e8b44cb78ebe88f61aeab9aeb')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '81bc60d60855266f7fc233eb1c164a89')" ] } } \ No newline at end of file diff --git a/engine/src/androidTest/java/com/google/android/fhir/db/impl/ResourceDatabaseMigrationTest.kt b/engine/src/androidTest/java/com/google/android/fhir/db/impl/ResourceDatabaseMigrationTest.kt index 2eb7877e7b..cbb6bab11b 100644 --- a/engine/src/androidTest/java/com/google/android/fhir/db/impl/ResourceDatabaseMigrationTest.kt +++ b/engine/src/androidTest/java/com/google/android/fhir/db/impl/ResourceDatabaseMigrationTest.kt @@ -29,6 +29,7 @@ import java.util.Date import kotlinx.coroutines.runBlocking import org.hl7.fhir.r4.model.HumanName import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.Reference import org.hl7.fhir.r4.model.Task import org.junit.Rule import org.junit.Test @@ -293,6 +294,81 @@ class ResourceDatabaseMigrationTest { assertThat(localChangeResourceId).isEqualTo(retrievedTaskResourceId) } + @Test + fun migrate7To8_should_execute_with_no_exception(): Unit = runBlocking { + val taskId = "bed-net-001" + val taskResourceUuid = "e2c79e28-ed4d-4029-a12c-108d1eb5bedb" + val bedNetTask: String = + Task() + .apply { + id = taskId + addBasedOn(Reference("CarePlan/123")) + `for` = Reference("Patient/123") + description = "Issue bed net" + meta.lastUpdated = Date() + } + .let { iParser.encodeResourceToString(it) } + + helper.createDatabase(DB_NAME, 7).apply { + val insertionDate = Date() + execSQL( + "INSERT INTO LocalChangeEntity (resourceType, resourceUuid, resourceId, timestamp, type, payload) VALUES ('Task', '$taskResourceUuid', '$taskId', '${insertionDate.toTimeZoneString()}', '${DbTypeConverters.localChangeTypeToInt(LocalChangeEntity.Type.INSERT)}', '$bedNetTask' );", + ) + val updateDate = Date() + val patch = + "[{\"op\":\"replace\",\"path\":\"\\/basedOn\\/0\\/reference\",\"value\":\"CarePlan\\/345\"}]" + execSQL( + "INSERT INTO LocalChangeEntity (resourceType, resourceUuid, resourceId, timestamp, type, payload) VALUES ('Task', '$taskResourceUuid', '$taskId', '${updateDate.toTimeZoneString()}', '${DbTypeConverters.localChangeTypeToInt(LocalChangeEntity.Type.UPDATE)}', '$patch' );", + ) + val deleteDate = Date() + execSQL( + "INSERT INTO LocalChangeEntity (resourceType, resourceUuid, resourceId, timestamp, type, payload) VALUES ('Task', '$taskResourceUuid', '$taskId', '${deleteDate.toTimeZoneString()}', '${DbTypeConverters.localChangeTypeToInt(LocalChangeEntity.Type.DELETE)}', '' );", + ) + close() + } + + val migratedDatabase = helper.runMigrationsAndValidate(DB_NAME, 8, true, MIGRATION_7_8) + + var localChange1Id: Long + var localChange2Id: Long + + var localChangeReferences: MutableMap> + + migratedDatabase.let { database -> + database.query("SELECT id FROM LocalChangeEntity").let { + it.moveToFirst() + localChange1Id = it.getLong(0) + it.moveToNext() + localChange2Id = it.getLong(0) + } + + database + .query( + "SELECT localChangeId, resourceReferenceValue FROM LocalChangeResourceReferenceEntity", + ) + .let { + var continueToNextRow = it.moveToFirst() + localChangeReferences = mutableMapOf() + while (continueToNextRow) { + val localChangeId = it.getLong(0) + val referenceValue = it.getString(1) + val existingList = localChangeReferences.getOrDefault(localChangeId, mutableListOf()) + existingList.add(referenceValue) + localChangeReferences[localChangeId] = existingList + continueToNextRow = it.moveToNext() + } + } + } + migratedDatabase.close() + assertThat(localChangeReferences).containsKey(localChange1Id) + assertThat(localChangeReferences).containsKey(localChange2Id) + assertThat(localChangeReferences[localChange1Id]!!.size).isEqualTo(2) + assertThat(localChangeReferences[localChange2Id]!!.size).isEqualTo(1) + assertThat(localChangeReferences[localChange1Id]!!) + .containsExactly("CarePlan/123", "Patient/123") + assertThat(localChangeReferences[localChange2Id]!!).containsExactly("CarePlan/345") + } + companion object { const val DB_NAME = "migration_tests.db" } 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 d5d2089c75..9ee1bf457c 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 @@ -63,3 +63,40 @@ fun replaceJsonValue( } return jsonObject } + +fun lookForReferencesInJsonPatch(jsonObject: JSONObject): String? { + // "[{\"op\":\"replace\",\"path\":\"\\/basedOn\\/0\\/reference\",\"value\":\"CarePlan\\/345\"}]" + if (jsonObject.getString("path").endsWith("reference")) { + return jsonObject.getString("value") + } + return null +} + +fun extractAllValuesWithKey(lookupKey: String, jsonObject: JSONObject): List { + val iterator: Iterator<*> = jsonObject.keys() + var key: String? + val referenceValues = mutableListOf() + while (iterator.hasNext()) { + key = iterator.next() as String + // if object is just string we change value in key + if (jsonObject.optJSONArray(key) == null && jsonObject.optJSONObject(key) == null) { + if (key.equals(lookupKey)) { + referenceValues.add(jsonObject.getString(key)) + } + } + + // if it's jsonobject + if (jsonObject.optJSONObject(key) != null) { + referenceValues.addAll(extractAllValuesWithKey(lookupKey, jsonObject.getJSONObject(key))) + } + + // if it's jsonarray + if (jsonObject.optJSONArray(key) != null) { + val jArray = jsonObject.getJSONArray(key) + for (i in 0 until jArray.length()) { + referenceValues.addAll(extractAllValuesWithKey(lookupKey, jArray.getJSONObject(i))) + } + } + } + return referenceValues +} diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt b/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt index 2f78e17706..d07a8ec96a 100644 --- a/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt +++ b/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt @@ -35,6 +35,8 @@ import com.google.android.fhir.db.impl.entities.ResourceEntity 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 org.json.JSONArray +import org.json.JSONObject @Database( entities = @@ -152,20 +154,54 @@ val MIGRATION_6_7 = } } - /** Create [LocalChangeResourceReferenceEntity] */ val MIGRATION_7_8 = object : Migration(7, 8) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL( - "CREATE TABLE IF NOT EXISTS `LocalChangeResourceReferenceEntity` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `localChangeId` INTEGER NOT NULL, `resourceReferenceValue` TEXT NOT NULL, `resourceReferenceName` TEXT NOT NULL, FOREIGN KEY(`localChangeId`) REFERENCES `LocalChangeEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)" + "CREATE TABLE IF NOT EXISTS `LocalChangeResourceReferenceEntity` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `localChangeId` INTEGER NOT NULL, `resourceReferenceValue` TEXT NOT NULL, `resourceReferenceName` TEXT, FOREIGN KEY(`localChangeId`) REFERENCES `LocalChangeEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", ) database.execSQL( "CREATE INDEX IF NOT EXISTS `index_LocalChangeResourceReferenceEntity_resourceReferenceValue` ON `LocalChangeResourceReferenceEntity` (`resourceReferenceValue`)", ) - database.query("SELECT id,payload from LocalChangeEntity") - + database.query("SELECT id,type,payload from LocalChangeEntity").let { + var continueIterating = it.moveToFirst() + while (continueIterating) { + val localChangeId = it.getLong(0) + val localChangeType = it.getInt(1) + val localChangePayload = it.getString(2) + val references = + when (localChangeType) { + LocalChangeEntity.Type.INSERT.value -> + extractAllValuesWithKey("reference", JSONObject(localChangePayload)) + LocalChangeEntity.Type.UPDATE.value -> { + val patchArray = JSONArray(localChangePayload) + val references = mutableListOf() + for (i in 0 until patchArray.length()) { + // look for any value with key "reference" in JsonPatch's value + references.addAll( + extractAllValuesWithKey("reference", patchArray.getJSONObject(i)), + ) + // look for value if the path of the JsonPatch is a reference path itself + // example: + // "[{\"op\":\"replace\",\"path\":\"\\/basedOn\\/0\\/reference\",\"value\":\"CarePlan\\/345\"}]" + lookForReferencesInJsonPatch(patchArray.getJSONObject(i))?.let { ref -> + references.add(ref) + } + } + references + } + LocalChangeEntity.Type.DELETE.value -> emptyList() + else -> throw IllegalArgumentException("Unknown LocalChangeType") + } + references.forEach { refValue -> + database.execSQL( + "INSERT INTO LocalChangeResourceReferenceEntity (localChangeId, resourceReferenceValue) VALUES ('$localChangeId', '$refValue' );", + ) + } + continueIterating = it.moveToNext() + } + } } } - diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeResourceReferenceEntity.kt b/engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeResourceReferenceEntity.kt index 2c65db22c0..7e4382839f 100644 --- a/engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeResourceReferenceEntity.kt +++ b/engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeResourceReferenceEntity.kt @@ -42,5 +42,5 @@ internal data class LocalChangeResourceReferenceEntity( @PrimaryKey(autoGenerate = true) val id: Long, val localChangeId: Long, val resourceReferenceValue: String, - val resourceReferenceName: String, + val resourceReferenceName: String?, ) From 3031fb3a68be5711cb4f1e8f985203a873086f38 Mon Sep 17 00:00:00 2001 From: anchita-g <109063673+anchita-g@users.noreply.github.com> Date: Mon, 9 Oct 2023 12:38:24 +0530 Subject: [PATCH 15/21] updating kdoc --- .../com/google/android/fhir/db/impl/dao/LocalChangeDao.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 94c73707cb..c56fbb1ea2 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 @@ -336,7 +336,9 @@ internal abstract class LocalChangeDao { * contains reference to the updated resource in its payload, we update the payload with the * reference and also update the corresponding [LocalChangeResourceReferenceEntity]. We delete the * original [LocalChangeEntity] and create a new one with new - * [LocalChangeResourceReferenceEntity]s in its place. + * [LocalChangeResourceReferenceEntity]s in its place. This method returns a list of the + * [ResourceEntity.resourceUuid] for all the resources whose [LocalChange] contained references to + * the oldResource */ suspend fun updateResourceId( resourceUuid: UUID, From 2378de78d9b71ecb776e25aa3f469c2d95dde0ae Mon Sep 17 00:00:00 2001 From: anchita-g <109063673+anchita-g@users.noreply.github.com> Date: Mon, 9 Oct 2023 12:54:30 +0530 Subject: [PATCH 16/21] adding database migration --- .../android/fhir/demo/AddPatientViewModel.kt | 1 + .../db/impl/ResourceDatabaseMigrationTest.kt | 28 +++++++++---------- .../android/fhir/db/impl/DatabaseImpl.kt | 1 + .../google/android/fhir/db/impl/JsonUtils.kt | 22 ++++++++++++--- 4 files changed, 34 insertions(+), 18 deletions(-) diff --git a/demo/src/main/java/com/google/android/fhir/demo/AddPatientViewModel.kt b/demo/src/main/java/com/google/android/fhir/demo/AddPatientViewModel.kt index 50447b8c28..189b6e2d9c 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/AddPatientViewModel.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/AddPatientViewModel.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.launch import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.Reference /** ViewModel for patient registration screen {@link AddPatientFragment}. */ class AddPatientViewModel(application: Application, private val state: SavedStateHandle) : diff --git a/engine/src/androidTest/java/com/google/android/fhir/db/impl/ResourceDatabaseMigrationTest.kt b/engine/src/androidTest/java/com/google/android/fhir/db/impl/ResourceDatabaseMigrationTest.kt index cbb6bab11b..56487f5ecb 100644 --- a/engine/src/androidTest/java/com/google/android/fhir/db/impl/ResourceDatabaseMigrationTest.kt +++ b/engine/src/androidTest/java/com/google/android/fhir/db/impl/ResourceDatabaseMigrationTest.kt @@ -296,15 +296,15 @@ class ResourceDatabaseMigrationTest { @Test fun migrate7To8_should_execute_with_no_exception(): Unit = runBlocking { - val taskId = "bed-net-001" - val taskResourceUuid = "e2c79e28-ed4d-4029-a12c-108d1eb5bedb" - val bedNetTask: String = - Task() + val patientId = "patient-001" + val patientResourceUuid = "e2c79e28-ed4d-4029-a12c-108d1eb5bedb" + val patient: String = + Patient() .apply { - id = taskId - addBasedOn(Reference("CarePlan/123")) - `for` = Reference("Patient/123") - description = "Issue bed net" + id = patientId + addName(HumanName().apply { addGiven("Brad") }) + addGeneralPractitioner(Reference("Practitioner/123")) + managingOrganization = Reference("Organization/123") meta.lastUpdated = Date() } .let { iParser.encodeResourceToString(it) } @@ -312,17 +312,17 @@ class ResourceDatabaseMigrationTest { helper.createDatabase(DB_NAME, 7).apply { val insertionDate = Date() execSQL( - "INSERT INTO LocalChangeEntity (resourceType, resourceUuid, resourceId, timestamp, type, payload) VALUES ('Task', '$taskResourceUuid', '$taskId', '${insertionDate.toTimeZoneString()}', '${DbTypeConverters.localChangeTypeToInt(LocalChangeEntity.Type.INSERT)}', '$bedNetTask' );", + "INSERT INTO LocalChangeEntity (resourceType, resourceUuid, resourceId, timestamp, type, payload) VALUES ('Patient', '$patientResourceUuid', '$patientId', '${insertionDate.toTimeZoneString()}', '${DbTypeConverters.localChangeTypeToInt(LocalChangeEntity.Type.INSERT)}', '$patient' );", ) val updateDate = Date() val patch = - "[{\"op\":\"replace\",\"path\":\"\\/basedOn\\/0\\/reference\",\"value\":\"CarePlan\\/345\"}]" + "[{\"op\":\"replace\",\"path\":\"\\/generalPractitioner\\/0\\/reference\",\"value\":\"Practitioner\\/345\"}]" execSQL( - "INSERT INTO LocalChangeEntity (resourceType, resourceUuid, resourceId, timestamp, type, payload) VALUES ('Task', '$taskResourceUuid', '$taskId', '${updateDate.toTimeZoneString()}', '${DbTypeConverters.localChangeTypeToInt(LocalChangeEntity.Type.UPDATE)}', '$patch' );", + "INSERT INTO LocalChangeEntity (resourceType, resourceUuid, resourceId, timestamp, type, payload) VALUES ('Patient', '$patientResourceUuid', '$patientId', '${updateDate.toTimeZoneString()}', '${DbTypeConverters.localChangeTypeToInt(LocalChangeEntity.Type.UPDATE)}', '$patch' );", ) val deleteDate = Date() execSQL( - "INSERT INTO LocalChangeEntity (resourceType, resourceUuid, resourceId, timestamp, type, payload) VALUES ('Task', '$taskResourceUuid', '$taskId', '${deleteDate.toTimeZoneString()}', '${DbTypeConverters.localChangeTypeToInt(LocalChangeEntity.Type.DELETE)}', '' );", + "INSERT INTO LocalChangeEntity (resourceType, resourceUuid, resourceId, timestamp, type, payload) VALUES ('Patient', '$patientResourceUuid', '$patientId', '${deleteDate.toTimeZoneString()}', '${DbTypeConverters.localChangeTypeToInt(LocalChangeEntity.Type.DELETE)}', '' );", ) close() } @@ -365,8 +365,8 @@ class ResourceDatabaseMigrationTest { assertThat(localChangeReferences[localChange1Id]!!.size).isEqualTo(2) assertThat(localChangeReferences[localChange2Id]!!.size).isEqualTo(1) assertThat(localChangeReferences[localChange1Id]!!) - .containsExactly("CarePlan/123", "Patient/123") - assertThat(localChangeReferences[localChange2Id]!!).containsExactly("CarePlan/345") + .containsExactly("Practitioner/123", "Organization/123") + assertThat(localChangeReferences[localChange2Id]!!).containsExactly("Practitioner/345") } companion object { 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 598483978b..c8aaa4fbd6 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 @@ -105,6 +105,7 @@ internal class DatabaseImpl( MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, + MIGRATION_7_8, ) } .build() 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 9ee1bf457c..d2bf89699d 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 @@ -18,6 +18,7 @@ package com.google.android.fhir.db.impl import ca.uhn.fhir.parser.IParser import org.hl7.fhir.r4.model.Resource +import org.json.JSONArray import org.json.JSONObject fun addUpdatedReferenceToResource( @@ -92,10 +93,23 @@ fun extractAllValuesWithKey(lookupKey: String, jsonObject: JSONObject): List { + val referenceValues = mutableListOf() + for (i in 0 until jArray.length()) { + if (jArray.optJSONObject(i) != null) { + referenceValues.addAll(extractAllValuesWithKey(lookupKey, jArray.getJSONObject(i))) + } else if (jArray.optJSONArray(i) != null) { + referenceValues.addAll( + extractAllValuesWithKeyFromJsonArray(lookupKey, jArray.getJSONArray(i)), + ) } } return referenceValues From f8d541b0c332b1c15853088c55fddca1291ded04 Mon Sep 17 00:00:00 2001 From: anchita-g <109063673+anchita-g@users.noreply.github.com> Date: Mon, 9 Oct 2023 13:26:23 +0530 Subject: [PATCH 17/21] removing unused refs --- .../java/com/google/android/fhir/demo/AddPatientViewModel.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/demo/src/main/java/com/google/android/fhir/demo/AddPatientViewModel.kt b/demo/src/main/java/com/google/android/fhir/demo/AddPatientViewModel.kt index 189b6e2d9c..50447b8c28 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/AddPatientViewModel.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/AddPatientViewModel.kt @@ -32,7 +32,6 @@ import kotlinx.coroutines.launch import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse -import org.hl7.fhir.r4.model.Reference /** ViewModel for patient registration screen {@link AddPatientFragment}. */ class AddPatientViewModel(application: Application, private val state: SavedStateHandle) : From fe87a18d55327482d1e9c5a318101774eb626769 Mon Sep 17 00:00:00 2001 From: anchita-g <109063673+anchita-g@users.noreply.github.com> Date: Mon, 9 Oct 2023 15:01:10 +0530 Subject: [PATCH 18/21] json utils test --- engine/build.gradle.kts | 1 + .../google/android/fhir/db/impl/JsonUtils.kt | 28 +- .../com/google/android/fhir/JsonUtilsTest.kt | 263 ++++++++++++++++++ 3 files changed, 285 insertions(+), 7 deletions(-) create mode 100644 engine/src/test/java/com/google/android/fhir/JsonUtilsTest.kt diff --git a/engine/build.gradle.kts b/engine/build.gradle.kts index f1f556fd2c..aeb733840e 100644 --- a/engine/build.gradle.kts +++ b/engine/build.gradle.kts @@ -127,6 +127,7 @@ dependencies { testImplementation(Dependencies.AndroidxTest.workTestingRuntimeKtx) testImplementation(Dependencies.Kotlin.kotlinCoroutinesTest) testImplementation(Dependencies.junit) + testImplementation(Dependencies.jsonAssert) testImplementation(Dependencies.mockitoInline) testImplementation(Dependencies.mockitoKotlin) testImplementation(Dependencies.mockWebServer) 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 d2bf89699d..1abbcf01f6 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 @@ -45,7 +45,6 @@ fun replaceJsonValue( if (jsonObject.optJSONArray(key) == null && jsonObject.optJSONObject(key) == null) { if (jsonObject.optString(key) == currentValue) { jsonObject.put(key, newValue) - return jsonObject } } @@ -57,14 +56,29 @@ fun replaceJsonValue( // if it's jsonarray if (jsonObject.optJSONArray(key) != null) { val jArray = jsonObject.getJSONArray(key) - for (i in 0 until jArray.length()) { - replaceJsonValue(jArray.getJSONObject(i), currentValue, newValue) - } + replaceJsonValue(jArray, currentValue, newValue) } } return jsonObject } +fun replaceJsonValue( + jsonArray: JSONArray, + currentValue: String, + newValue: String, +): JSONArray { + for (i in 0 until jsonArray.length()) { + if (jsonArray.optJSONArray(i) != null) { + replaceJsonValue(jsonArray.getJSONArray(i), currentValue, newValue) + } else if (jsonArray.optJSONObject(i) != null) { + replaceJsonValue(jsonArray.getJSONObject(i), currentValue, newValue) + } else if (currentValue.equals(jsonArray.optString(i))) { + jsonArray.put(i, newValue) + } + } + return jsonArray +} + fun lookForReferencesInJsonPatch(jsonObject: JSONObject): String? { // "[{\"op\":\"replace\",\"path\":\"\\/basedOn\\/0\\/reference\",\"value\":\"CarePlan\\/345\"}]" if (jsonObject.getString("path").endsWith("reference")) { @@ -94,21 +108,21 @@ fun extractAllValuesWithKey(lookupKey: String, jsonObject: JSONObject): List { +fun extractAllValuesWithKey(lookupKey: String, jArray: JSONArray): List { val referenceValues = mutableListOf() for (i in 0 until jArray.length()) { if (jArray.optJSONObject(i) != null) { referenceValues.addAll(extractAllValuesWithKey(lookupKey, jArray.getJSONObject(i))) } else if (jArray.optJSONArray(i) != null) { referenceValues.addAll( - extractAllValuesWithKeyFromJsonArray(lookupKey, jArray.getJSONArray(i)), + extractAllValuesWithKey(lookupKey, jArray.getJSONArray(i)), ) } } diff --git a/engine/src/test/java/com/google/android/fhir/JsonUtilsTest.kt b/engine/src/test/java/com/google/android/fhir/JsonUtilsTest.kt new file mode 100644 index 0000000000..5c581ea704 --- /dev/null +++ b/engine/src/test/java/com/google/android/fhir/JsonUtilsTest.kt @@ -0,0 +1,263 @@ +/* + * 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 + +import android.os.Build +import ca.uhn.fhir.context.FhirContext +import ca.uhn.fhir.context.FhirVersionEnum +import com.google.android.fhir.db.impl.addUpdatedReferenceToResource +import com.google.android.fhir.db.impl.extractAllValuesWithKey +import com.google.android.fhir.db.impl.replaceJsonValue +import com.google.common.truth.Truth.assertThat +import junit.framework.TestCase +import org.hl7.fhir.r4.model.CarePlan +import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.Reference +import org.json.JSONObject +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.skyscreamer.jsonassert.JSONAssert + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P]) +class JsonUtilsTest : TestCase() { + + @Test + fun addUpdatedReferenceToResource_updatesReferenceInPatient() { + val oldPractitionerReference = "Practitioner/123" + val updatedPractitionerReference = "Practitioner/345" + val patient = + Patient().apply { + id = "f001" + addGeneralPractitioner(Reference(oldPractitionerReference)) + } + val updatedPatientResource = + addUpdatedReferenceToResource( + iParser, + patient, + oldPractitionerReference, + updatedPractitionerReference, + ) + as Patient + assertThat(updatedPatientResource.generalPractitioner.first().reference) + .isEqualTo(updatedPractitionerReference) + } + + @Test + fun addUpdatedReferenceToResource_updatesMultipleReferenceInCarePlan() { + val oldPatientReference = "Patient/123" + val updatedPatientReference = "Patient/345" + val carePlan = + CarePlan().apply { + id = "f001" + subject = (Reference(oldPatientReference)) + activityFirstRep.detail.performer.add(Reference(oldPatientReference)) + } + val updatedCarePlan = + addUpdatedReferenceToResource(iParser, carePlan, oldPatientReference, updatedPatientReference) + as CarePlan + assertThat(updatedCarePlan.subject.reference).isEqualTo(updatedPatientReference) + assertThat(updatedCarePlan.activityFirstRep.detail.performer.first().reference) + .isEqualTo(updatedPatientReference) + } + + @Test + fun replaceJsonValue_jsonObject1() { + val json = + JSONObject( + """ + { + "key1": "valueToBeReplaced", + "key2": { + "key3": { + "key4": [ + "valueToBeReplaced", + "otherValueNotToBeReplaced" + ] + } + } + } + """ + .trimIndent(), + ) + val updatedJson = replaceJsonValue(json, "valueToBeReplaced", "newValue") + val expectedJson = + JSONObject( + """ + { + "key1": "newValue", + "key2": { + "key3": { + "key4": [ + "newValue", + "otherValueNotToBeReplaced" + ] + } + } + } + """ + .trimIndent(), + ) + JSONAssert.assertEquals(updatedJson, expectedJson, false) + } + + @Test + fun replaceJsonValue_jsonObject2() { + val json = + JSONObject( + """ + { + "key1": "valueToBeReplaced", + "key2": { + "key3": { + "key4": [ + [ + "otherValueNotToBeReplaced", + "valueToBeReplaced" + ], + [ + "otherValueNotToBeReplaced" + ] + ] + } + } + } + """ + .trimIndent(), + ) + val updatedJson = replaceJsonValue(json, "valueToBeReplaced", "newValue") + val expectedJson = + JSONObject( + """ + { + "key1": "newValue", + "key2": { + "key3": { + "key4": [ + [ + "otherValueNotToBeReplaced", + "newValue" + ], + [ + "otherValueNotToBeReplaced" + ] + ] + } + } + } + """ + .trimIndent(), + ) + JSONAssert.assertEquals(updatedJson, expectedJson, false) + } + + @Test + fun replaceJsonValue_jsonObject3() { + val json = + JSONObject( + """ + { + "key1": "valueToBeReplaced", + "key2": { + "key3": { + "key4": [ + [ + { + "key5": "valueToBeReplaced" + } + ], + [ + { + "key6": "otherValueNotToBeReplaced" + } + ] + ] + } + } + } + """ + .trimIndent(), + ) + val updatedJson = replaceJsonValue(json, "valueToBeReplaced", "newValue") + val expectedJson = + JSONObject( + """ + { + "key1": "newValue", + "key2": { + "key3": { + "key4": [ + [ + { + "key5": "newValue" + } + ], + [ + { + "key6": "otherValueNotToBeReplaced" + } + ] + ] + } + } + } + """ + .trimIndent(), + ) + JSONAssert.assertEquals(updatedJson, expectedJson, false) + } + + @Test + fun extractAllValueWithKey_extractsValuesFromJson() { + val testJson = + """ + { + "key1": "newValue", + "reference": "testValue1", + "key2": { + "key3": { + "key4": [ + [ + { + "reference": "testValue2" + } + ], + [ + { + "key6": "otherValueNotToBeReplaced" + } + ] + ] + }, + "key5": { + "reference": "testValue3" + } + } + } + """ + .trimIndent() + val referenceValues = extractAllValuesWithKey("reference", JSONObject(testJson)) + assertThat(referenceValues.size).isEqualTo(3) + assertThat(referenceValues).containsExactly("testValue1", "testValue2", "testValue3") + } + + companion object { + val iParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() + } +} From b5b2ee20650c31561b94d022bf228310a59e8124 Mon Sep 17 00:00:00 2001 From: anchita-g <109063673+anchita-g@users.noreply.github.com> Date: Tue, 10 Oct 2023 11:49:15 +0530 Subject: [PATCH 19/21] review comments --- .../8.json | 10 +- .../android/fhir/db/impl/DatabaseImplTest.kt | 6 +- .../fhir/db/impl/dao/LocalChangeDaoTest.kt | 22 ++-- .../com/google/android/fhir/db/Database.kt | 2 +- .../android/fhir/db/impl/DatabaseImpl.kt | 69 ++++++---- .../google/android/fhir/db/impl/JsonUtils.kt | 12 +- .../android/fhir/db/impl/ResourceDatabase.kt | 2 +- .../fhir/db/impl/dao/LocalChangeDao.kt | 121 +++++++++++------- .../LocalChangeResourceReferenceEntity.kt | 2 +- .../DefaultResourceConsolidator.kt | 29 ++--- 10 files changed, 166 insertions(+), 109 deletions(-) diff --git a/engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/8.json b/engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/8.json index 44893cacfa..a4a50d1caf 100644 --- a/engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/8.json +++ b/engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/8.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 8, - "identityHash": "bf28146b530ec50ac067916072020d73", + "identityHash": "dee7984bc7a1af3c4358443b0d6bbc95", "entities": [ { "tableName": "ResourceEntity", @@ -948,7 +948,7 @@ }, { "tableName": "LocalChangeResourceReferenceEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `localChangeId` INTEGER NOT NULL, `resourceReferenceValue` TEXT NOT NULL, `resourceReferenceName` TEXT, FOREIGN KEY(`localChangeId`) REFERENCES `LocalChangeEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `localChangeId` INTEGER NOT NULL, `resourceReferenceValue` TEXT NOT NULL, `resourceReferencePath` TEXT, FOREIGN KEY(`localChangeId`) REFERENCES `LocalChangeEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", "fields": [ { "fieldPath": "id", @@ -969,8 +969,8 @@ "notNull": true }, { - "fieldPath": "resourceReferenceName", - "columnName": "resourceReferenceName", + "fieldPath": "resourceReferencePath", + "columnName": "resourceReferencePath", "affinity": "TEXT", "notNull": false } @@ -1019,7 +1019,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bf28146b530ec50ac067916072020d73')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'dee7984bc7a1af3c4358443b0d6bbc95')" ] } } \ No newline at end of file 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 e6aa0e12c7..d062215129 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 @@ -3564,7 +3564,7 @@ class DatabaseImplTest { locallyCreatedPatient.apply { id = remotelyCreatedPatientResourceId } // perform updates - database.updateResourceAndId( + database.updateResourceAndReferences( locallyCreatedPatientResourceId, remotelyCreatedPatient, ) @@ -3667,7 +3667,7 @@ class DatabaseImplTest { val remotelyCreatedPatient = locallyCreatedPatient.apply { id = remotelyCreatedPatientResourceId } - database.updateResourceAndId( + database.updateResourceAndReferences( locallyCreatedPatientResourceId, remotelyCreatedPatient, ) @@ -3783,7 +3783,7 @@ class DatabaseImplTest { val remotelyCreatedPatient = locallyCreatedPatient.apply { id = remotelyCreatedPatientResourceId } - database.updateResourceAndId( + database.updateResourceAndReferences( locallyCreatedPatientResourceId, remotelyCreatedPatient, ) diff --git a/engine/src/androidTest/java/com/google/android/fhir/db/impl/dao/LocalChangeDaoTest.kt b/engine/src/androidTest/java/com/google/android/fhir/db/impl/dao/LocalChangeDaoTest.kt index b3ee74bff0..650f8acae6 100644 --- a/engine/src/androidTest/java/com/google/android/fhir/db/impl/dao/LocalChangeDaoTest.kt +++ b/engine/src/androidTest/java/com/google/android/fhir/db/impl/dao/LocalChangeDaoTest.kt @@ -102,10 +102,10 @@ class LocalChangeDaoTest { val localChangeResourceReferences = localChangeDao.getReferencesForLocalChange(carePlanLocalChange1Id) assertThat(localChangeResourceReferences.size).isEqualTo(2) - assertThat(localChangeResourceReferences[0].resourceReferenceName).isEqualTo("subject") + assertThat(localChangeResourceReferences[0].resourceReferencePath).isEqualTo("subject") assertThat(localChangeResourceReferences[0].resourceReferenceValue) .isEqualTo("Patient/$patientId") - assertThat(localChangeResourceReferences[1].resourceReferenceName) + assertThat(localChangeResourceReferences[1].resourceReferencePath) .isEqualTo("activity.detail.performer") assertThat(localChangeResourceReferences[1].resourceReferenceValue) .isEqualTo("Patient/$patientId") @@ -177,14 +177,14 @@ class LocalChangeDaoTest { val localChangeResourceReferences = localChangeDao.getReferencesForLocalChange(carePlanLocalChange2Id) assertThat(localChangeResourceReferences.size).isEqualTo(3) - assertThat(localChangeResourceReferences[0].resourceReferenceName) + assertThat(localChangeResourceReferences[0].resourceReferencePath) .isEqualTo("activity.detail.performer") assertThat(localChangeResourceReferences[0].resourceReferenceValue) .isEqualTo("Patient/$patientId") - assertThat(localChangeResourceReferences[1].resourceReferenceName).isEqualTo("author") + assertThat(localChangeResourceReferences[1].resourceReferencePath).isEqualTo("author") assertThat(localChangeResourceReferences[1].resourceReferenceValue) .isEqualTo(practitionerReference) - assertThat(localChangeResourceReferences[2].resourceReferenceName) + assertThat(localChangeResourceReferences[2].resourceReferencePath) .isEqualTo("activity.detail.performer") assertThat(localChangeResourceReferences[2].resourceReferenceValue) .isEqualTo(practitionerReference) @@ -291,7 +291,7 @@ class LocalChangeDaoTest { val updatedPatientId = "SyncedPatient1" val updatedPatient = patient.copy().apply { id = updatedPatientId } - localChangeDao.updateResourceId( + localChangeDao.updateResourceIdAndReferences( resourceUuid = patientResourceUuid, oldResource = patient, updatedResource = updatedPatient, @@ -322,10 +322,10 @@ class LocalChangeDaoTest { val localChange1ResourceReferences = localChangeDao.getReferencesForLocalChange(carePlanLocalChange1Id) assertThat(localChange1ResourceReferences.size).isEqualTo(2) - assertThat(localChange1ResourceReferences[0].resourceReferenceName).isEqualTo("subject") + assertThat(localChange1ResourceReferences[0].resourceReferencePath).isEqualTo("subject") assertThat(localChange1ResourceReferences[0].resourceReferenceValue) .isEqualTo("Patient/$updatedPatientId") - assertThat(localChange1ResourceReferences[1].resourceReferenceName) + assertThat(localChange1ResourceReferences[1].resourceReferencePath) .isEqualTo("activity.detail.performer") assertThat(localChange1ResourceReferences[1].resourceReferenceValue) .isEqualTo("Patient/$updatedPatientId") @@ -344,14 +344,14 @@ class LocalChangeDaoTest { val localChangeResourceReferences = localChangeDao.getReferencesForLocalChange(carePlanLocalChange2Id) assertThat(localChangeResourceReferences.size).isEqualTo(3) - assertThat(localChangeResourceReferences[0].resourceReferenceName) + assertThat(localChangeResourceReferences[0].resourceReferencePath) .isEqualTo("activity.detail.performer") assertThat(localChangeResourceReferences[0].resourceReferenceValue) .isEqualTo("Patient/$updatedPatientId") - assertThat(localChangeResourceReferences[1].resourceReferenceName).isEqualTo("author") + assertThat(localChangeResourceReferences[1].resourceReferencePath).isEqualTo("author") assertThat(localChangeResourceReferences[1].resourceReferenceValue) .isEqualTo(practitionerReference) - assertThat(localChangeResourceReferences[2].resourceReferenceName) + assertThat(localChangeResourceReferences[2].resourceReferencePath) .isEqualTo("activity.detail.performer") assertThat(localChangeResourceReferences[2].resourceReferenceValue) .isEqualTo(practitionerReference) 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 0ff3696ef2..0cd01e1855 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 @@ -122,7 +122,7 @@ internal interface Database { * Updates the [ResourceEntity.serializedResource] for all the resources which refer to this * updated resource. */ - suspend fun updateResourceAndId( + suspend fun updateResourceAndReferences( currentResourceId: String, updatedResource: Resource, ) 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 c8aaa4fbd6..5c8ffa5f89 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 @@ -255,38 +255,63 @@ internal class DatabaseImpl( localChangeDao.discardLocalChanges(resources) } - override suspend fun updateResourceAndId(currentResourceId: String, updatedResource: Resource) { + override suspend fun updateResourceAndReferences( + currentResourceId: String, + updatedResource: Resource, + ) { val currentResourceEntity = selectEntity(updatedResource.resourceType, currentResourceId) val oldResource = iParser.parseResource(currentResourceEntity.serializedResource) as Resource val resourceUuid = currentResourceEntity.resourceUuid - val oldReferenceValue = "${oldResource.resourceType.name}/${oldResource.logicalId}" - val updatedReferenceValue = "${updatedResource.resourceType.name}/${updatedResource.logicalId}" db.withTransaction { - // update the resource entity - resourceDao.updateResourceWithUuid(currentResourceEntity.resourceUuid, updatedResource) + updateResourceEntity(resourceUuid, updatedResource) - // update the local changes of this resource and any resource referring to the updated - // resource - val uuidsOfResourcesWithReferencesToResource = - localChangeDao.updateResourceId( + val uuidsOfReferringResources = + updateLocalChangeResourceIdAndReferences( resourceUuid = resourceUuid, oldResource = oldResource, updatedResource = updatedResource, ) - // update the references in the resources referring to the updated resource - uuidsOfResourcesWithReferencesToResource.forEach { resourceUuid -> - resourceDao.getResourceEntity(resourceUuid)?.let { - val referringResource = iParser.parseResource(it.serializedResource) as Resource - val updatedReferringResource = - addUpdatedReferenceToResource( - iParser, - referringResource, - oldReferenceValue, - updatedReferenceValue, - ) - resourceDao.updateResourceWithUuid(resourceUuid, updatedReferringResource) - } + updateReferringResources( + referringResourcesUuids = uuidsOfReferringResources, + oldResource = oldResource, + updatedResource = updatedResource, + ) + } + } + + private suspend fun updateResourceEntity(resourceUuid: UUID, updatedResource: Resource) = + resourceDao.updateResourceWithUuid(resourceUuid, updatedResource) + + private suspend fun updateLocalChangeResourceIdAndReferences( + resourceUuid: UUID, + oldResource: Resource, + updatedResource: Resource, + ) = + localChangeDao.updateResourceIdAndReferences( + resourceUuid = resourceUuid, + oldResource = oldResource, + updatedResource = updatedResource, + ) + + private suspend fun updateReferringResources( + referringResourcesUuids: List, + oldResource: Resource, + updatedResource: Resource, + ) { + val oldReferenceValue = "${oldResource.resourceType.name}/${oldResource.logicalId}" + val updatedReferenceValue = "${updatedResource.resourceType.name}/${updatedResource.logicalId}" + referringResourcesUuids.forEach { resourceUuid -> + resourceDao.getResourceEntity(resourceUuid)?.let { + val referringResource = iParser.parseResource(it.serializedResource) as Resource + val updatedReferringResource = + addUpdatedReferenceToResource( + iParser, + referringResource, + oldReferenceValue, + updatedReferenceValue, + ) + resourceDao.updateResourceWithUuid(resourceUuid, updatedReferringResource) } } } 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 1abbcf01f6..31be98d1e1 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 @@ -21,7 +21,7 @@ import org.hl7.fhir.r4.model.Resource import org.json.JSONArray import org.json.JSONObject -fun addUpdatedReferenceToResource( +internal fun addUpdatedReferenceToResource( iParser: IParser, resource: Resource, outdatedReference: String, @@ -32,7 +32,7 @@ fun addUpdatedReferenceToResource( return iParser.parseResource(updatedResource.toString()) as Resource } -fun replaceJsonValue( +internal fun replaceJsonValue( jsonObject: JSONObject, currentValue: String, newValue: String, @@ -62,7 +62,7 @@ fun replaceJsonValue( return jsonObject } -fun replaceJsonValue( +internal fun replaceJsonValue( jsonArray: JSONArray, currentValue: String, newValue: String, @@ -79,7 +79,7 @@ fun replaceJsonValue( return jsonArray } -fun lookForReferencesInJsonPatch(jsonObject: JSONObject): String? { +internal fun lookForReferencesInJsonPatch(jsonObject: JSONObject): String? { // "[{\"op\":\"replace\",\"path\":\"\\/basedOn\\/0\\/reference\",\"value\":\"CarePlan\\/345\"}]" if (jsonObject.getString("path").endsWith("reference")) { return jsonObject.getString("value") @@ -87,7 +87,7 @@ fun lookForReferencesInJsonPatch(jsonObject: JSONObject): String? { return null } -fun extractAllValuesWithKey(lookupKey: String, jsonObject: JSONObject): List { +internal fun extractAllValuesWithKey(lookupKey: String, jsonObject: JSONObject): List { val iterator: Iterator<*> = jsonObject.keys() var key: String? val referenceValues = mutableListOf() @@ -115,7 +115,7 @@ fun extractAllValuesWithKey(lookupKey: String, jsonObject: JSONObject): List { +internal fun extractAllValuesWithKey(lookupKey: String, jArray: JSONArray): List { val referenceValues = mutableListOf() for (i in 0 until jArray.length()) { if (jArray.optJSONObject(i) != null) { diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt b/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt index d159deb445..3502ab0b8e 100644 --- a/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt +++ b/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt @@ -159,7 +159,7 @@ val MIGRATION_7_8 = object : Migration(7, 8) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL( - "CREATE TABLE IF NOT EXISTS `LocalChangeResourceReferenceEntity` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `localChangeId` INTEGER NOT NULL, `resourceReferenceValue` TEXT NOT NULL, `resourceReferenceName` TEXT, FOREIGN KEY(`localChangeId`) REFERENCES `LocalChangeEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "CREATE TABLE IF NOT EXISTS `LocalChangeResourceReferenceEntity` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `localChangeId` INTEGER NOT NULL, `resourceReferenceValue` TEXT NOT NULL, `resourceReferencePath` TEXT, FOREIGN KEY(`localChangeId`) REFERENCES `LocalChangeEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", ) database.execSQL( "CREATE INDEX IF NOT EXISTS `index_LocalChangeResourceReferenceEntity_resourceReferenceValue` ON `LocalChangeResourceReferenceEntity` (`resourceReferenceValue`)", 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 c56fbb1ea2..fc924d356b 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 @@ -59,6 +59,15 @@ internal abstract class LocalChangeDao { @Insert(onConflict = OnConflictStrategy.REPLACE) abstract suspend fun addLocalChange(localChangeEntity: LocalChangeEntity): Long + @Query( + """ + UPDATE LocalChangeEntity + SET resourceId = :updatedResourceId + WHERE id = :localChangeId + """, + ) + abstract suspend fun updateResourceId(localChangeId: Long, updatedResourceId: String): Int + @Transaction open suspend fun addInsert(resource: Resource, resourceUuid: UUID, timeOfLocalChange: Instant) { val resourceId = resource.logicalId @@ -67,7 +76,7 @@ internal abstract class LocalChangeDao { val localChangeEntity = LocalChangeEntity( - id = 0, + id = DEFAULT_ID_VALUE, resourceType = resourceType.name, resourceId = resourceId, resourceUuid = resourceUuid, @@ -80,9 +89,9 @@ internal abstract class LocalChangeDao { val localChangeReferences = extractResourceReferences(resource).map { resourceReferenceInfo -> LocalChangeResourceReferenceEntity( - id = 0, - localChangeId = 0, - resourceReferenceName = resourceReferenceInfo.name, + id = DEFAULT_ID_VALUE, + localChangeId = DEFAULT_ID_VALUE, + resourceReferencePath = resourceReferenceInfo.name, resourceReferenceValue = resourceReferenceInfo.resourceReference.referenceElement.value, ) } @@ -128,7 +137,7 @@ internal abstract class LocalChangeDao { } val localChangeEntity = LocalChangeEntity( - id = 0, + id = DEFAULT_ID_VALUE, resourceType = resourceType.name, resourceId = resourceId, resourceUuid = oldEntity.resourceUuid, @@ -141,9 +150,9 @@ internal abstract class LocalChangeDao { val localChangeReferences = extractReferencesDiff(oldResource, updatedResource).map { resourceReferenceInfo -> LocalChangeResourceReferenceEntity( - id = 0, - localChangeId = 0, - resourceReferenceName = resourceReferenceInfo.name, + id = DEFAULT_ID_VALUE, + localChangeId = DEFAULT_ID_VALUE, + resourceReferencePath = resourceReferenceInfo.name, resourceReferenceValue = resourceReferenceInfo.resourceReference.referenceElement.value, ) } @@ -158,7 +167,7 @@ internal abstract class LocalChangeDao { ) { createLocalChange( LocalChangeEntity( - id = 0, + id = DEFAULT_ID_VALUE, resourceType = resourceType.name, resourceId = resourceId, resourceUuid = resourceUuid, @@ -246,7 +255,6 @@ internal abstract class LocalChangeDao { """ SELECT COUNT(*) FROM LocalChangeEntity - ORDER BY timestamp ASC """, ) abstract suspend fun getLocalChangesCount(): Int @@ -329,65 +337,88 @@ internal abstract class LocalChangeDao { resourceReferences: List, ) + /** + * Updates the resource IDs of the [LocalChange] of the updated resource. Updates [LocalChange] + * with references to the updated resource. + */ + suspend fun updateResourceIdAndReferences( + resourceUuid: UUID, + oldResource: Resource, + updatedResource: Resource, + ): List { + updateResourceIdInResourceLocalChanges( + resourceUuid = resourceUuid, + updatedResourceId = updatedResource.logicalId, + ) + return updateReferencesInLocalChange( + oldResource = oldResource, + updatedResource = updatedResource, + ) + } + /** * Updates the [LocalChangeEntity]s for the updated resource by updating the - * [LocalChangeEntity.resourceId]. Looks for [LocalChangeEntity] which refer to the updated - * resource through [LocalChangeResourceReferenceEntity]. For each [LocalChangeEntity] which - * contains reference to the updated resource in its payload, we update the payload with the - * reference and also update the corresponding [LocalChangeResourceReferenceEntity]. We delete the - * original [LocalChangeEntity] and create a new one with new - * [LocalChangeResourceReferenceEntity]s in its place. This method returns a list of the - * [ResourceEntity.resourceUuid] for all the resources whose [LocalChange] contained references to - * the oldResource + * [LocalChangeEntity.resourceId]. */ - suspend fun updateResourceId( + private suspend fun updateResourceIdInResourceLocalChanges( resourceUuid: UUID, + updatedResourceId: String, + ) = + getLocalChanges(resourceUuid).forEach { localChangeEntity -> + updateResourceId(localChangeEntity.id, updatedResourceId) + } + + /** + * Looks for [LocalChangeEntity] which refer to the updated resource through + * [LocalChangeResourceReferenceEntity]. For each [LocalChangeEntity] which contains reference to + * the updated resource in its payload, we update the payload with the reference and also update + * the corresponding [LocalChangeResourceReferenceEntity]. We delete the original + * [LocalChangeEntity] and create a new one with new [LocalChangeResourceReferenceEntity]s in its + * place. This method returns a list of the [ResourceEntity.resourceUuid] for all the resources + * whose [LocalChange] contained references to the oldResource + */ + private suspend fun updateReferencesInLocalChange( oldResource: Resource, updatedResource: Resource, ): List { - // update the resource ID in the local change entity - val localChanges = getLocalChanges(resourceUuid) - localChanges - .map { localChangeEntity -> localChangeEntity.copy(resourceId = updatedResource.logicalId) } - // Add LocalChangeEntity with replace strategy, the references need not be updated - .forEach { addLocalChange(it) } - - // update all local changes referring to the resource val oldReferenceValue = "${oldResource.resourceType.name}/${oldResource.logicalId}" val updatedReferenceValue = "${updatedResource.resourceType.name}/${updatedResource.logicalId}" - val localChangeReferences = getLocalChangeReferencesWithValue(oldReferenceValue) - val localChangeIds = localChangeReferences.map { it.localChangeId }.distinct() - val localChangesWithReferences = getLocalChanges(localChangeIds).associateBy { it.id } + val referringLocalChangeIds = + getLocalChangeReferencesWithValue(oldReferenceValue).map { it.localChangeId }.distinct() + val referringLocalChanges = getLocalChanges(referringLocalChangeIds) - localChangeIds.forEach { localChangeId -> - val existingLocalChangeEntity = localChangesWithReferences[localChangeId]!! + referringLocalChanges.forEach { existingLocalChangeEntity -> val updatedLocalChangeEntity = - replaceReferencesInLocalChangeEntity( + replaceReferencesInLocalChangePayload( localChange = existingLocalChangeEntity, oldReference = oldReferenceValue, updatedReference = updatedReferenceValue, ) - .copy(id = 0) + .copy(id = DEFAULT_ID_VALUE) val updatedLocalChangeReferences = - getReferencesForLocalChange(localChangeId).map { reference -> - if (reference.resourceReferenceValue == oldReferenceValue) { + getReferencesForLocalChange(existingLocalChangeEntity.id).map { + localChangeResourceReferenceEntity -> + if (localChangeResourceReferenceEntity.resourceReferenceValue == oldReferenceValue) { LocalChangeResourceReferenceEntity( - id = 0, - localChangeId = 0, - resourceReferenceName = reference.resourceReferenceName, + id = DEFAULT_ID_VALUE, + localChangeId = DEFAULT_ID_VALUE, + resourceReferencePath = localChangeResourceReferenceEntity.resourceReferencePath, resourceReferenceValue = updatedReferenceValue, ) } else { - reference.copy(id = 0, localChangeId = 0) + localChangeResourceReferenceEntity.copy( + id = DEFAULT_ID_VALUE, + localChangeId = DEFAULT_ID_VALUE, + ) } } - discardLocalChanges(localChangeId) + discardLocalChanges(existingLocalChangeEntity.id) createLocalChange(updatedLocalChangeEntity, updatedLocalChangeReferences) } - return localChangesWithReferences.values.map { it.resourceUuid }.distinct() + return referringLocalChanges.map { it.resourceUuid }.distinct() } - private fun replaceReferencesInLocalChangeEntity( + private fun replaceReferencesInLocalChangePayload( localChange: LocalChangeEntity, oldReference: String, updatedReference: String, @@ -423,6 +454,10 @@ internal abstract class LocalChangeDao { } class InvalidLocalChangeException(message: String?) : Exception(message) + + companion object { + const val DEFAULT_ID_VALUE = 0L + } } /** Calculates the JSON patch between two [Resource] s. */ diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeResourceReferenceEntity.kt b/engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeResourceReferenceEntity.kt index 711772231d..b02883254e 100644 --- a/engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeResourceReferenceEntity.kt +++ b/engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeResourceReferenceEntity.kt @@ -44,5 +44,5 @@ internal data class LocalChangeResourceReferenceEntity( @PrimaryKey(autoGenerate = true) val id: Long, val localChangeId: Long, val resourceReferenceValue: String, - val resourceReferenceName: String?, + val resourceReferencePath: String?, ) diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/DefaultResourceConsolidator.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/DefaultResourceConsolidator.kt index 57e04192b5..62606593ba 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/DefaultResourceConsolidator.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/DefaultResourceConsolidator.kt @@ -32,15 +32,15 @@ internal class DefaultResourceConsolidator(private val database: Database) : Res override suspend fun consolidate(uploadSyncResult: UploadSyncResult) = when (uploadSyncResult) { is UploadSyncResult.Success -> { - database.deleteUpdates( - LocalChangeToken(uploadSyncResult.localChanges.flatMap { it.token.ids }), - ) uploadSyncResult.resources.forEach { when (it) { is Bundle -> updateVersionIdAndLastUpdated(it) - else -> updateVersionIdAndLastUpdated(uploadSyncResult.localChanges, it) + else -> consolidateResourceAndItsReferences(uploadSyncResult.localChanges, it) } } + database.deleteUpdates( + LocalChangeToken(uploadSyncResult.localChanges.flatMap { it.token.ids }), + ) } is UploadSyncResult.Failure -> { /* For now, do nothing (we do not delete the local changes from the database as they were @@ -49,9 +49,12 @@ internal class DefaultResourceConsolidator(private val database: Database) : Res } } + /** + * TODO: Support POST in Bundle transactions. Assumption is that only PUT operations are used in + * Bundle requests. We should not support any [UploadStrategy] that supports POST Bundle + * operations. + */ private suspend fun updateVersionIdAndLastUpdated(bundle: Bundle) { - // TODO: Support POST in Bundle transactions. Assumption is that only PUT operations are used in - // Bundle requests when (bundle.type) { Bundle.BundleType.TRANSACTIONRESPONSE -> { bundle.entry.forEach { @@ -92,21 +95,15 @@ internal class DefaultResourceConsolidator(private val database: Database) : Res } } - private suspend fun updateVersionIdAndLastUpdated( + /** The function expects [List<[LocalChange]>] which belong only to the resource. */ + private suspend fun consolidateResourceAndItsReferences( localChanges: List, resource: Resource, ) { if (localChanges.first().resourceId != resource.logicalId) { - database.updateResourceAndId(localChanges.first().resourceId, resource) - } - if (resource.hasMeta() && resource.meta.hasVersionId() && resource.meta.hasLastUpdated()) { - database.updateVersionIdAndLastUpdated( - resource.id, - resource.resourceType, - resource.meta.versionId, - resource.meta.lastUpdated.toInstant(), - ) + database.updateResourceAndReferences(localChanges.first().resourceId, resource) } + updateVersionIdAndLastUpdated(resource) } /** From 6064483ee4d09feff0f78ace076f83d72ddf8d78 Mon Sep 17 00:00:00 2001 From: anchita-g <109063673+anchita-g@users.noreply.github.com> Date: Thu, 12 Oct 2023 13:24:36 +0530 Subject: [PATCH 20/21] addressing review comments --- .../android/fhir/db/impl/DatabaseImplTest.kt | 322 +++++++----------- .../android/fhir/db/impl/DatabaseImpl.kt | 24 +- .../fhir/db/impl/dao/LocalChangeDao.kt | 14 + .../android/fhir/impl/FhirEngineImpl.kt | 2 +- ...onsolidator.kt => ResourceConsolidator.kt} | 63 ++-- .../android/fhir/sync/upload/Uploader.kt | 2 +- .../consolidator/ResourceConsolidator.kt | 35 -- .../android/fhir/sync/upload/UploaderTest.kt | 2 +- 8 files changed, 199 insertions(+), 265 deletions(-) rename engine/src/main/java/com/google/android/fhir/sync/upload/{consolidator/DefaultResourceConsolidator.kt => ResourceConsolidator.kt} (72%) delete mode 100644 engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/ResourceConsolidator.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 d062215129..97abb510d1 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 @@ -3534,7 +3534,69 @@ class DatabaseImplTest { } @Test - fun updateResourceAndId_insertPatientAndReferringResource_shouldUpdateReferencesAndUpdateResourceId() = + fun updateResourceAndReferences_shouldUpdateResourceEntityResourceId() = runBlocking { + // create a patient + val locallyCreatedPatientResourceId = "local-patient-1" + val locallyCreatedPatient = + Patient().apply { + id = locallyCreatedPatientResourceId + name = listOf(HumanName().setFamily("Family").setGiven(listOf(StringType("First Name")))) + } + database.insert(locallyCreatedPatient) + // Retrieving ResourceEntity so that we have the resourceUuid available for assertions + val patientResourceEntity = + database.selectEntity(locallyCreatedPatient.resourceType, locallyCreatedPatientResourceId) + + // pretend that the resource has been created on the server with an updated ID + val remotelyCreatedPatientResourceId = "remote-patient-1" + val remotelyCreatedPatient = + locallyCreatedPatient.apply { id = remotelyCreatedPatientResourceId } + + // perform updates + database.updateResourceAndReferences( + locallyCreatedPatientResourceId, + remotelyCreatedPatient, + ) + + // check if resource is fetch-able by its new server assigned ID + val updatedPatientResourceEntity = + database.selectEntity(remotelyCreatedPatient.resourceType, remotelyCreatedPatient.id) + assertThat(updatedPatientResourceEntity.resourceUuid) + .isEqualTo(patientResourceEntity.resourceUuid) + } + + @Test + fun updateResourceAndReferences_shouldUpdateLocalChangeResourceId() = runBlocking { + // create a patient + val locallyCreatedPatientResourceId = "local-patient-1" + val locallyCreatedPatient = + Patient().apply { + id = locallyCreatedPatientResourceId + name = listOf(HumanName().setFamily("Family").setGiven(listOf(StringType("First Name")))) + } + database.insert(locallyCreatedPatient) + val patientResourceEntity = + database.selectEntity(locallyCreatedPatient.resourceType, locallyCreatedPatientResourceId) + + // pretend that the resource has been created on the server with an updated ID + val remotelyCreatedPatientResourceId = "remote-patient-1" + val remotelyCreatedPatient = + locallyCreatedPatient.apply { id = remotelyCreatedPatientResourceId } + + // perform updates + database.updateResourceAndReferences( + locallyCreatedPatientResourceId, + remotelyCreatedPatient, + ) + + // check if resource is fetch-able by its new server assigned ID + val patientLocalChanges = database.getLocalChanges(patientResourceEntity.resourceUuid) + assertThat(patientLocalChanges.all { it.resourceId == remotelyCreatedPatientResourceId }) + .isTrue() + } + + @Test + fun updateResourceAndReferences_shouldUpdateReferencesInReferringLocalChangesOfInsertType() = runBlocking { // create a patient val locallyCreatedPatientResourceId = "local-patient-1" @@ -3555,9 +3617,6 @@ class DatabaseImplTest { } database.insert(locallyCreatedPatientObservation) - val patientResourceEntity = - database.selectEntity(locallyCreatedPatient.resourceType, locallyCreatedPatientResourceId) - // pretend that the resource has been created on the server with an updated ID val remotelyCreatedPatientResourceId = "remote-patient-1" val remotelyCreatedPatient = @@ -3569,30 +3628,6 @@ class DatabaseImplTest { remotelyCreatedPatient, ) - // check if resource is fetch-able by its new server assigned ID - val updatedPatientResourceEntity = - database.selectEntity(remotelyCreatedPatient.resourceType, remotelyCreatedPatient.id) - assertThat(updatedPatientResourceEntity.resourceUuid) - .isEqualTo(patientResourceEntity.resourceUuid) - - // verify that all the local changes for this resource have the new resource ID - val patientLocalChangesAfterUpdate = - database.getLocalChanges( - updatedPatientResourceEntity.resourceUuid, - ) - assertThat(patientLocalChangesAfterUpdate.size).isEqualTo(1) - val firstPatientLocalChange = patientLocalChangesAfterUpdate[0] - assertThat(firstPatientLocalChange.resourceId).isEqualTo(remotelyCreatedPatientResourceId) - - // verify that Observation is updated with new patient ID reference - val updatedObservationResource = - database.select( - locallyCreatedPatientObservation.resourceType, - locallyCreatedObservationResourceId, - ) as Observation - assertThat(updatedObservationResource.subject.reference) - .isEqualTo("Patient/$remotelyCreatedPatientResourceId") - // verify that Observation's LocalChanges are updated with new patient ID reference val updatedObservationLocalChanges = database.getLocalChanges( @@ -3606,28 +3641,12 @@ class DatabaseImplTest { services.parser.parseResource(observationLocalChange.payload) as Observation assertThat(observationLocalChangePayload.subject.reference) .isEqualTo("Patient/$remotelyCreatedPatientResourceId") - - // verify that Observation is searchable i.e. ReferenceIndex is updated with new patient ID - // reference - val searchedObservations = - database.search( - Search(ResourceType.Observation) - .apply { - filter( - Observation.SUBJECT, - { value = "Patient/$remotelyCreatedPatientResourceId" }, - ) - } - .getQuery(), - ) - assertThat(searchedObservations.size).isEqualTo(1) - assertThat(searchedObservations[0].logicalId).isEqualTo(locallyCreatedObservationResourceId) } @Test - fun updateResourceAndId_insertPatientAndInsertUpdateReferringResource_shouldUpdateReferencesAndUpdateResourceId() = + fun updateResourceAndReferences_shouldUpdateReferencesInReferringLocalChangesOfUpdateType() = runBlocking { - // create a new patient + // create a patient val locallyCreatedPatientResourceId = "local-patient-1" val locallyCreatedPatient = Patient().apply { @@ -3636,201 +3655,106 @@ class DatabaseImplTest { } database.insert(locallyCreatedPatient) - // create an observation for the new patient + // create an observation for the patient val locallyCreatedObservationResourceId = "local-observation-1" val locallyCreatedPatientObservation = Observation().apply { subject = Reference("Patient/$locallyCreatedPatientResourceId") + addPerformer(Reference("Practitioner/123")) id = locallyCreatedObservationResourceId } database.insert(locallyCreatedPatientObservation) - // update the observation resource (so that there are multiple local changes with references - // to the same patient) database.update( - locallyCreatedPatientObservation.apply { + locallyCreatedPatientObservation.copy().apply { performer = listOf(Reference("Patient/$locallyCreatedPatientResourceId")) }, ) - val patientResourceEntity = - database.selectEntity(locallyCreatedPatient.resourceType, locallyCreatedPatientResourceId) - - val observationLocalChanges = - database.getLocalChanges( - locallyCreatedPatientObservation.resourceType, - locallyCreatedObservationResourceId, - ) - assertThat(observationLocalChanges.size).isEqualTo(2) - // pretend that the resource has been created on the server with an updated ID val remotelyCreatedPatientResourceId = "remote-patient-1" val remotelyCreatedPatient = locallyCreatedPatient.apply { id = remotelyCreatedPatientResourceId } + // perform updates database.updateResourceAndReferences( locallyCreatedPatientResourceId, remotelyCreatedPatient, ) - // check if resource is fetch-able by its new ID - val updatedPatientResourceEntity = - database.selectEntity(remotelyCreatedPatient.resourceType, remotelyCreatedPatient.id) - assertThat(updatedPatientResourceEntity.resourceUuid) - .isEqualTo(patientResourceEntity.resourceUuid) - - // verify that all the local changes are deleted for this newly created resource - val patientLocalChangesAfterUpdate = - database.getLocalChanges( - updatedPatientResourceEntity.resourceUuid, - ) - assertThat(patientLocalChangesAfterUpdate.size).isEqualTo(1) - val firstPatientLocalChange = patientLocalChangesAfterUpdate[0] - assertThat(firstPatientLocalChange.resourceId).isEqualTo(remotelyCreatedPatientResourceId) - - // verify that Observation is updated - val updatedObservationResource = - database.select( - locallyCreatedPatientObservation.resourceType, - locallyCreatedObservationResourceId, - ) as Observation - assertThat(updatedObservationResource.subject.reference) - .isEqualTo("Patient/$remotelyCreatedPatientResourceId") - - // verify that Observation's LocalChanges are updated + // verify that Observation's LocalChanges are updated with new patient ID reference val updatedObservationLocalChanges = database.getLocalChanges( locallyCreatedPatientObservation.resourceType, locallyCreatedObservationResourceId, ) assertThat(updatedObservationLocalChanges.size).isEqualTo(2) - val observationLocalChange1 = updatedObservationLocalChanges[0] - assertThat(observationLocalChange1.type).isEqualTo(LocalChange.Type.INSERT) - val observationLocalChange1Payload = - services.parser.parseResource(observationLocalChange1.payload) as Observation - assertThat(observationLocalChange1Payload.subject.reference) - .isEqualTo("Patient/$remotelyCreatedPatientResourceId") - val observationLocalChange2 = updatedObservationLocalChanges[1] assertThat(observationLocalChange2.type).isEqualTo(LocalChange.Type.UPDATE) // payload = - // [{"op":"add","path":"\/performer","value":[{"reference":"Patient\/remote-patient-1"}]}] + // [{"op":"replace","path":"\/performer\/0\/reference","value":"Patient\/remote-patient-1"}] val observationLocalChange2Payload = JSONArray(observationLocalChange2.payload) val patch = observationLocalChange2Payload.get(0) as JSONObject - val patchValueReference = patch.getJSONArray("value").get(0) as JSONObject - assertThat(patchValueReference.getString("reference")) - .isEqualTo("Patient/$remotelyCreatedPatientResourceId") - - // verify that Observation is searchable i.e. ReferenceIndex is updated - val searchedObservations = - database.search( - Search(ResourceType.Observation) - .apply { - filter( - Observation.SUBJECT, - { value = "Patient/$remotelyCreatedPatientResourceId" }, - ) - } - .getQuery(), - ) - assertThat(searchedObservations.size).isEqualTo(1) - assertThat(searchedObservations[0].logicalId).isEqualTo(locallyCreatedObservationResourceId) + val referenceValue = patch.getString("value") + assertThat(referenceValue).isEqualTo("Patient/$remotelyCreatedPatientResourceId") } @Test - fun updateResourceAndId_insertPatientAndInsertUpdateDeleteReferringResource_shouldUpdateReferencesAndUpdateResourceId() = - runBlocking { - // create a new patient - val locallyCreatedPatientResourceId = "local-patient-1" - val locallyCreatedPatient = - Patient().apply { - id = locallyCreatedPatientResourceId - name = listOf(HumanName().setFamily("Family").setGiven(listOf(StringType("First Name")))) - } - database.insert(locallyCreatedPatient) + fun updateResourceAndReferences_shouldUpdateReferencesInReferringResource() = runBlocking { + // create a patient + val locallyCreatedPatientResourceId = "local-patient-1" + val locallyCreatedPatient = + Patient().apply { + id = locallyCreatedPatientResourceId + name = listOf(HumanName().setFamily("Family").setGiven(listOf(StringType("First Name")))) + } + database.insert(locallyCreatedPatient) - // create an observation for the new patient - val locallyCreatedObservationResourceId = "local-observation-1" - val locallyCreatedPatientObservation = - Observation().apply { - subject = Reference("Patient/$locallyCreatedPatientResourceId") - id = locallyCreatedObservationResourceId - } - database.insert(locallyCreatedPatientObservation) - // update the observation resource (so that there are multiple local changes with references - // to the same patient) - database.update( - locallyCreatedPatientObservation.apply { - performer = listOf(Reference("Patient/$locallyCreatedPatientResourceId")) - }, - ) - database.delete( - locallyCreatedPatientObservation.resourceType, - locallyCreatedObservationResourceId, - ) + // create an observation for the patient + val locallyCreatedObservationResourceId = "local-observation-1" + val locallyCreatedPatientObservation = + Observation().apply { + subject = Reference("Patient/$locallyCreatedPatientResourceId") + addPerformer(Reference("Practitioner/123")) + id = locallyCreatedObservationResourceId + } + database.insert(locallyCreatedPatientObservation) - val patientResourceEntity = - database.selectEntity(locallyCreatedPatient.resourceType, locallyCreatedPatientResourceId) + // pretend that the resource has been created on the server with an updated ID + val remotelyCreatedPatientResourceId = "remote-patient-1" + val remotelyCreatedPatient = + locallyCreatedPatient.apply { id = remotelyCreatedPatientResourceId } - val observationLocalChanges = - database.getLocalChanges( - locallyCreatedPatientObservation.resourceType, - locallyCreatedObservationResourceId, - ) - assertThat(observationLocalChanges.size).isEqualTo(3) + // perform updates + database.updateResourceAndReferences( + locallyCreatedPatientResourceId, + remotelyCreatedPatient, + ) - // pretend that the resource has been created on the server with an updated ID - val remotelyCreatedPatientResourceId = "remote-patient-1" - val remotelyCreatedPatient = - locallyCreatedPatient.apply { id = remotelyCreatedPatientResourceId } + // verify that Observation is updated with new patient ID reference + val updatedObservationResource = + database.select( + locallyCreatedPatientObservation.resourceType, + locallyCreatedObservationResourceId, + ) as Observation + assertThat(updatedObservationResource.subject.reference) + .isEqualTo("Patient/$remotelyCreatedPatientResourceId") - database.updateResourceAndReferences( - locallyCreatedPatientResourceId, - remotelyCreatedPatient, + // verify that Observation is searchable i.e. ReferenceIndex is updated with new patient ID + // reference + val searchedObservations = + database.search( + Search(ResourceType.Observation) + .apply { + filter( + Observation.SUBJECT, + { value = "Patient/$remotelyCreatedPatientResourceId" }, + ) + } + .getQuery(), ) - - // check if resource is fetch-able by its new ID - val updatedPatientResourceEntity = - database.selectEntity(remotelyCreatedPatient.resourceType, remotelyCreatedPatient.id) - assertThat(updatedPatientResourceEntity.resourceUuid) - .isEqualTo(patientResourceEntity.resourceUuid) - - // verify that all the local changes are deleted for this newly created resource - val patientLocalChangesAfterUpdate = - database.getLocalChanges( - updatedPatientResourceEntity.resourceUuid, - ) - assertThat(patientLocalChangesAfterUpdate.size).isEqualTo(1) - val firstPatientLocalChange = patientLocalChangesAfterUpdate[0] - assertThat(firstPatientLocalChange.resourceId).isEqualTo(remotelyCreatedPatientResourceId) - - // verify that Observation's LocalChanges are updated - val updatedObservationLocalChanges = - database.getLocalChanges( - locallyCreatedPatientObservation.resourceType, - locallyCreatedObservationResourceId, - ) - assertThat(updatedObservationLocalChanges.size).isEqualTo(3) - val observationLocalChange1 = updatedObservationLocalChanges[0] - assertThat(observationLocalChange1.type).isEqualTo(LocalChange.Type.INSERT) - val observationLocalChange1Payload = - services.parser.parseResource(observationLocalChange1.payload) as Observation - assertThat(observationLocalChange1Payload.subject.reference) - .isEqualTo("Patient/$remotelyCreatedPatientResourceId") - - val observationLocalChange2 = updatedObservationLocalChanges[1] - assertThat(observationLocalChange2.type).isEqualTo(LocalChange.Type.UPDATE) - // payload = - // [{"op":"add","path":"\/performer","value":[{"reference":"Patient\/remote-patient-1"}]}] - val observationLocalChange2Payload = JSONArray(observationLocalChange2.payload) - val patch = observationLocalChange2Payload.get(0) as JSONObject - val patchValueReference = patch.getJSONArray("value").get(0) as JSONObject - assertThat(patchValueReference.getString("reference")) - .isEqualTo("Patient/$remotelyCreatedPatientResourceId") - - val observationLocalChange3 = updatedObservationLocalChanges[2] - assertThat(observationLocalChange3.type).isEqualTo(LocalChange.Type.DELETE) - assertThat(observationLocalChange3.payload).isEqualTo("") - } + assertThat(searchedObservations.size).isEqualTo(1) + assertThat(searchedObservations[0].logicalId).isEqualTo(locallyCreatedObservationResourceId) + } private companion object { const val mockEpochTimeStamp = 1628516301000 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 5c8ffa5f89..de1a9a3535 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 @@ -259,10 +259,10 @@ internal class DatabaseImpl( currentResourceId: String, updatedResource: Resource, ) { - val currentResourceEntity = selectEntity(updatedResource.resourceType, currentResourceId) - val oldResource = iParser.parseResource(currentResourceEntity.serializedResource) as Resource - val resourceUuid = currentResourceEntity.resourceUuid db.withTransaction { + val currentResourceEntity = selectEntity(updatedResource.resourceType, currentResourceId) + val oldResource = iParser.parseResource(currentResourceEntity.serializedResource) as Resource + val resourceUuid = currentResourceEntity.resourceUuid updateResourceEntity(resourceUuid, updatedResource) val uuidsOfReferringResources = @@ -280,9 +280,21 @@ internal class DatabaseImpl( } } + /** + * Calls the [ResourceDao] to update the [ResourceEntity] associated with this resource. The + * function updates the resource and resourceId of the [ResourceEntity] + */ private suspend fun updateResourceEntity(resourceUuid: UUID, updatedResource: Resource) = resourceDao.updateResourceWithUuid(resourceUuid, updatedResource) + /** + * Update the [LocalChange]s to reflect the change in the resource ID. This primarily includes + * modifying the [LocalChange.resourceId] for the changes of the affected resource. Also, update + * any references in the [LocalChange] which refer to the affected resource. + * + * The function returns a [List<[UUID]>] which corresponds to the [ResourceEntity.resourceUuid] + * which contain references to the affected resource. + */ private suspend fun updateLocalChangeResourceIdAndReferences( resourceUuid: UUID, oldResource: Resource, @@ -294,6 +306,12 @@ internal class DatabaseImpl( updatedResource = updatedResource, ) + /** + * Update all [Resource] and their corresponding [ResourceEntity] which refer to the affected + * resource. The update of the references in the [Resource] is also expected to reflect in the + * [ReferenceIndex] i.e. the references used for search operations should also get updated to + * reflect the references with the new resource ID of the referred resource. + */ private suspend fun updateReferringResources( referringResourcesUuids: List, oldResource: Resource, 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 fc924d356b..f2a710e8a8 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 @@ -186,6 +186,20 @@ internal abstract class LocalChangeDao { /** * Extract the difference in the [ResourceReferenceInfo] by getting all the references from both * the versions of the resource and then finding out the difference in the two sets. + * + * Two versions of a resource can vary in two ways in terms of the resources they refer: + * 1) A reference present in resource1 is removed and is not present in resource2. Such + * differences in resource references can be extracted by subtracting the set of references in + * the resource2 from the set of references in the resource1 + * 2) A new reference is added to the resource1. This implies that the reference is present in + * resource2 and not in resource1. Such differences in resource references can be extracted by + * subtracting the set of references in the resource1 from the set of references in the + * resource2 + * + * Combining the above two types of differences would give the entire set of difference in the two + * versions of the resource. + * + * This method is useful to extract differences for UPDATE kind of [LocalChange] */ private fun extractReferencesDiff( resource1: Resource, 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 9afe12889d..c1519cd4a8 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 @@ -28,11 +28,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 com.google.android.fhir.sync.upload.LocalChangeFetcherFactory import com.google.android.fhir.sync.upload.LocalChangesFetchMode import com.google.android.fhir.sync.upload.SyncUploadProgress import com.google.android.fhir.sync.upload.UploadSyncResult -import com.google.android.fhir.sync.upload.consolidator.DefaultResourceConsolidator import java.time.OffsetDateTime import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/DefaultResourceConsolidator.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/ResourceConsolidator.kt similarity index 72% rename from engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/DefaultResourceConsolidator.kt rename to engine/src/main/java/com/google/android/fhir/sync/upload/ResourceConsolidator.kt index 62606593ba..15573f0b89 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/DefaultResourceConsolidator.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/ResourceConsolidator.kt @@ -14,33 +14,62 @@ * limitations under the License. */ -package com.google.android.fhir.sync.upload.consolidator +package com.google.android.fhir.sync.upload + +/* + * 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. + */ -import com.google.android.fhir.LocalChange import com.google.android.fhir.LocalChangeToken import com.google.android.fhir.db.Database -import com.google.android.fhir.logicalId -import com.google.android.fhir.sync.upload.UploadSyncResult 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(uploadSyncResult: UploadSyncResult) +} + /** Default implementation of [ResourceConsolidator] that uses the database to aid consolidation. */ internal class DefaultResourceConsolidator(private val database: Database) : ResourceConsolidator { override suspend fun consolidate(uploadSyncResult: UploadSyncResult) = when (uploadSyncResult) { is UploadSyncResult.Success -> { - uploadSyncResult.resources.forEach { + database.deleteUpdates( + LocalChangeToken(uploadSyncResult.localChanges.flatMap { it.token.ids }), + ) + uploadSyncResult.responseResources.forEach { when (it) { is Bundle -> updateVersionIdAndLastUpdated(it) - else -> consolidateResourceAndItsReferences(uploadSyncResult.localChanges, it) + else -> updateVersionIdAndLastUpdated(it) } } - database.deleteUpdates( - LocalChangeToken(uploadSyncResult.localChanges.flatMap { it.token.ids }), - ) } is UploadSyncResult.Failure -> { /* For now, do nothing (we do not delete the local changes from the database as they were @@ -49,11 +78,6 @@ internal class DefaultResourceConsolidator(private val database: Database) : Res } } - /** - * TODO: Support POST in Bundle transactions. Assumption is that only PUT operations are used in - * Bundle requests. We should not support any [UploadStrategy] that supports POST Bundle - * operations. - */ private suspend fun updateVersionIdAndLastUpdated(bundle: Bundle) { when (bundle.type) { Bundle.BundleType.TRANSACTIONRESPONSE -> { @@ -95,17 +119,6 @@ internal class DefaultResourceConsolidator(private val database: Database) : Res } } - /** The function expects [List<[LocalChange]>] which belong only to the resource. */ - private suspend fun consolidateResourceAndItsReferences( - localChanges: List, - resource: Resource, - ) { - if (localChanges.first().resourceId != resource.logicalId) { - database.updateResourceAndReferences(localChanges.first().resourceId, resource) - } - updateVersionIdAndLastUpdated(resource) - } - /** * 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. 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 a35bab153a..40a0936bd9 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 @@ -96,7 +96,7 @@ internal class Uploader(private val dataSource: DataSource) { sealed class UploadSyncResult { data class Success( val localChanges: List, - val resources: List, + val responseResources: List, ) : UploadSyncResult() data class Failure(val syncError: ResourceSyncException, val localChangeToken: LocalChangeToken) : diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/ResourceConsolidator.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/ResourceConsolidator.kt deleted file mode 100644 index b8297f01de..0000000000 --- a/engine/src/main/java/com/google/android/fhir/sync/upload/consolidator/ResourceConsolidator.kt +++ /dev/null @@ -1,35 +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.consolidator - -import com.google.android.fhir.sync.upload.UploadSyncResult - -/** - * Represents a mechanism to consolidate resources after they are uploaded. - * - * INTERNAL ONLY. This interface should NEVER be 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(uploadSyncResult: UploadSyncResult) -} diff --git a/engine/src/test/java/com/google/android/fhir/sync/upload/UploaderTest.kt b/engine/src/test/java/com/google/android/fhir/sync/upload/UploaderTest.kt index 7036bfeed9..d6e1b821ce 100644 --- a/engine/src/test/java/com/google/android/fhir/sync/upload/UploaderTest.kt +++ b/engine/src/test/java/com/google/android/fhir/sync/upload/UploaderTest.kt @@ -49,7 +49,7 @@ class UploaderTest { .upload(localChanges) assertThat(result).isInstanceOf(UploadSyncResult.Success::class.java) - with(result as UploadSyncResult.Success) { assertThat(resources).hasSize(1) } + with(result as UploadSyncResult.Success) { assertThat(responseResources).hasSize(1) } } @Test From c45b9cb204b671febd9c24db901d9d0b312ce046 Mon Sep 17 00:00:00 2001 From: Anchita Goel Date: Tue, 17 Oct 2023 21:50:39 +0530 Subject: [PATCH 21/21] resolving failing test case --- .../android/fhir/db/impl/dao/LocalChangeDao.kt | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) 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 f2a710e8a8..f035351c48 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 @@ -87,13 +87,17 @@ internal abstract class LocalChangeDao { ) val localChangeReferences = - extractResourceReferences(resource).map { resourceReferenceInfo -> - LocalChangeResourceReferenceEntity( - id = DEFAULT_ID_VALUE, - localChangeId = DEFAULT_ID_VALUE, - resourceReferencePath = resourceReferenceInfo.name, - resourceReferenceValue = resourceReferenceInfo.resourceReference.referenceElement.value, - ) + extractResourceReferences(resource).mapNotNull { resourceReferenceInfo -> + if (resourceReferenceInfo.resourceReference.referenceElement.value != null) { + LocalChangeResourceReferenceEntity( + id = DEFAULT_ID_VALUE, + localChangeId = DEFAULT_ID_VALUE, + resourceReferencePath = resourceReferenceInfo.name, + resourceReferenceValue = resourceReferenceInfo.resourceReference.referenceElement.value, + ) + } else { + null + } } createLocalChange(localChangeEntity, localChangeReferences) }