From 0e91a64be939759586ff06d695815a47955a4658 Mon Sep 17 00:00:00 2001 From: anchita-g <109063673+anchita-g@users.noreply.github.com> Date: Thu, 26 Oct 2023 21:01:03 +0530 Subject: [PATCH] Resource consolidation for resources created with POST (#2217) * WIP * test cases * Adding resource UUID in LocalChangeEntity * fixing migration tests * returning resource UUID for insert resource * Clean up SyncJobStatus and add FhirSynchronizer test (#2184) * Clean up SyncJobStatus and add FhirSynchronizer test * remove changes in MAVM * refactor more * Consolidator done * review comments * adding more tests * fix failing test cases * Introducing LocalChangeReference * Adding LocalChangeDaoTest * WIP * added migration and tests for migration * updating kdoc * adding database migration * removing unused refs * json utils test * review comments * addressing review comments * resolving failing test case --------- Co-authored-by: Omar Ismail <44980219+omarismail94@users.noreply.github.com> --- engine/build.gradle.kts | 1 + .../8.json | 1025 +++++++++++++++++ .../android/fhir/db/impl/DatabaseImplTest.kt | 226 +++- .../db/impl/EncryptedDatabaseErrorTest.kt | 11 + .../db/impl/ResourceDatabaseMigrationTest.kt | 76 ++ .../fhir/db/impl/dao/LocalChangeDaoTest.kt | 359 ++++++ .../com/google/android/fhir/FhirServices.kt | 3 + .../com/google/android/fhir/db/Database.kt | 25 + .../fhir/db/ResourceNotFoundException.kt | 18 +- .../android/fhir/db/impl/DatabaseImpl.kt | 95 +- .../google/android/fhir/db/impl/JsonUtils.kt | 130 +++ .../android/fhir/db/impl/ResourceDatabase.kt | 61 +- .../fhir/db/impl/dao/LocalChangeDao.kt | 315 ++++- .../android/fhir/db/impl/dao/ResourceDao.kt | 31 +- .../LocalChangeResourceReferenceEntity.kt | 48 + .../fhir/sync/upload/ResourceConsolidator.kt | 23 +- .../android/fhir/sync/upload/Uploader.kt | 6 +- .../com/google/android/fhir/JsonUtilsTest.kt | 263 +++++ .../android/fhir/impl/FhirEngineImplTest.kt | 2 +- .../android/fhir/sync/FhirSynchronizerTest.kt | 4 +- .../android/fhir/sync/upload/UploaderTest.kt | 2 +- 21 files changed, 2684 insertions(+), 40 deletions(-) create mode 100644 engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/8.json create mode 100644 engine/src/androidTest/java/com/google/android/fhir/db/impl/dao/LocalChangeDaoTest.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 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 1473e9e573..3ae1cfb19d 100644 --- a/engine/build.gradle.kts +++ b/engine/build.gradle.kts @@ -146,6 +146,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/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..a4a50d1caf --- /dev/null +++ b/engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/8.json @@ -0,0 +1,1025 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "dee7984bc7a1af3c4358443b0d6bbc95", + "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, `resourceReferencePath` TEXT, 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": "resourceReferencePath", + "columnName": "resourceReferencePath", + "affinity": "TEXT", + "notNull": false + } + ], + "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`)" + }, + { + "name": "index_LocalChangeResourceReferenceEntity_localChangeId", + "unique": false, + "columnNames": [ + "localChangeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LocalChangeResourceReferenceEntity_localChangeId` ON `${TABLE_NAME}` (`localChangeId`)" + } + ], + "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, '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 5a0b2a4869..86b9ba313d 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 @@ -80,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 @@ -552,7 +553,7 @@ class DatabaseImplTest { .first { it.resourceId == "remote-patient-3" } .let { UploadSyncResult.Success( - it.token, + listOf(it), listOf( Patient().apply { id = it.resourceId @@ -3549,6 +3550,229 @@ class DatabaseImplTest { ) } + @Test + 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" + 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) + + // 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, + ) + + // 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 = + services.parser.parseResource(observationLocalChange.payload) as Observation + assertThat(observationLocalChangePayload.subject.reference) + .isEqualTo("Patient/$remotelyCreatedPatientResourceId") + } + + @Test + fun updateResourceAndReferences_shouldUpdateReferencesInReferringLocalChangesOfUpdateType() = + 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) + database.update( + locallyCreatedPatientObservation.copy().apply { + performer = listOf(Reference("Patient/$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, + ) + + // 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 observationLocalChange2 = updatedObservationLocalChanges[1] + assertThat(observationLocalChange2.type).isEqualTo(LocalChange.Type.UPDATE) + // payload = + // [{"op":"replace","path":"\/performer\/0\/reference","value":"Patient\/remote-patient-1"}] + val observationLocalChange2Payload = JSONArray(observationLocalChange2.payload) + val patch = observationLocalChange2Payload.get(0) as JSONObject + val referenceValue = patch.getString("value") + assertThat(referenceValue).isEqualTo("Patient/$remotelyCreatedPatientResourceId") + } + + @Test + 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 patient + val locallyCreatedObservationResourceId = "local-observation-1" + val locallyCreatedPatientObservation = + Observation().apply { + subject = Reference("Patient/$locallyCreatedPatientResourceId") + addPerformer(Reference("Practitioner/123")) + id = locallyCreatedObservationResourceId + } + database.insert(locallyCreatedPatientObservation) + + // 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, + ) + + // 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 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) + } + 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/db/impl/ResourceDatabaseMigrationTest.kt b/engine/src/androidTest/java/com/google/android/fhir/db/impl/ResourceDatabaseMigrationTest.kt index 2eb7877e7b..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 @@ -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 patientId = "patient-001" + val patientResourceUuid = "e2c79e28-ed4d-4029-a12c-108d1eb5bedb" + val patient: String = + Patient() + .apply { + id = patientId + addName(HumanName().apply { addGiven("Brad") }) + addGeneralPractitioner(Reference("Practitioner/123")) + managingOrganization = Reference("Organization/123") + 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 ('Patient', '$patientResourceUuid', '$patientId', '${insertionDate.toTimeZoneString()}', '${DbTypeConverters.localChangeTypeToInt(LocalChangeEntity.Type.INSERT)}', '$patient' );", + ) + val updateDate = Date() + val patch = + "[{\"op\":\"replace\",\"path\":\"\\/generalPractitioner\\/0\\/reference\",\"value\":\"Practitioner\\/345\"}]" + execSQL( + "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 ('Patient', '$patientResourceUuid', '$patientId', '${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("Practitioner/123", "Organization/123") + assertThat(localChangeReferences[localChange2Id]!!).containsExactly("Practitioner/345") + } + companion object { const val DB_NAME = "migration_tests.db" } 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..650f8acae6 --- /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].resourceReferencePath).isEqualTo("subject") + assertThat(localChangeResourceReferences[0].resourceReferenceValue) + .isEqualTo("Patient/$patientId") + assertThat(localChangeResourceReferences[1].resourceReferencePath) + .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].resourceReferencePath) + .isEqualTo("activity.detail.performer") + assertThat(localChangeResourceReferences[0].resourceReferenceValue) + .isEqualTo("Patient/$patientId") + assertThat(localChangeResourceReferences[1].resourceReferencePath).isEqualTo("author") + assertThat(localChangeResourceReferences[1].resourceReferenceValue) + .isEqualTo(practitionerReference) + assertThat(localChangeResourceReferences[2].resourceReferencePath) + .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.updateResourceIdAndReferences( + 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].resourceReferencePath).isEqualTo("subject") + assertThat(localChange1ResourceReferences[0].resourceReferenceValue) + .isEqualTo("Patient/$updatedPatientId") + assertThat(localChange1ResourceReferences[1].resourceReferencePath) + .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].resourceReferencePath) + .isEqualTo("activity.detail.performer") + assertThat(localChangeResourceReferences[0].resourceReferenceValue) + .isEqualTo("Patient/$updatedPatientId") + assertThat(localChangeResourceReferences[1].resourceReferencePath).isEqualTo("author") + assertThat(localChangeResourceReferences[1].resourceReferenceValue) + .isEqualTo(practitionerReference) + 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/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 fac887c229..03444c601b 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 @@ -23,6 +23,7 @@ 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 @@ -120,6 +121,18 @@ internal interface Database { /** Remove the [LocalChangeEntity] s with matching resource ids. */ suspend fun deleteUpdates(resources: List) + /** + * 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 updateResourceAndReferences( + currentResourceId: String, + updatedResource: Resource, + ) + /** Runs the block as a database transaction. */ suspend fun withTransaction(block: suspend () -> Unit) @@ -145,6 +158,18 @@ internal interface Database { */ suspend fun getLocalChanges(type: ResourceType, id: String): List + /** + * Retrieve a list of [LocalChange] for [ResourceEntity] with given UUID, which can be used to + * purge resource from database. If there is no local change for [ResourceEntity.resourceUuid], + * return an empty list. + * + * @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(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..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,10 +16,13 @@ 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 id: String + lateinit var type: String + lateinit var id: String + lateinit var uuid: UUID constructor( type: String, @@ -30,8 +33,17 @@ class ResourceNotFoundException : Exception { this.id = id } - constructor(type: String, id: String) : super("Resource not found with type $type and id $id!") { + constructor( + type: String, + id: String, + ) : super("Resource not found with type $type and id $id!") { this.type = type this.id = id } + + 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 02aacd796a..47139cf052 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,6 +22,7 @@ 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 @@ -34,6 +35,7 @@ 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 @@ -45,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 { @@ -102,6 +105,7 @@ internal class DatabaseImpl( MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, + MIGRATION_7_8, ) } .build() @@ -114,7 +118,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() @@ -251,6 +259,85 @@ internal class DatabaseImpl( localChangeDao.discardLocalChanges(resources) } + override suspend fun updateResourceAndReferences( + currentResourceId: String, + updatedResource: Resource, + ) { + 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 = + updateLocalChangeResourceIdAndReferences( + resourceUuid = resourceUuid, + oldResource = oldResource, + updatedResource = updatedResource, + ) + + updateReferringResources( + referringResourcesUuids = uuidsOfReferringResources, + oldResource = oldResource, + updatedResource = updatedResource, + ) + } + } + + /** + * 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, + updatedResource: Resource, + ) = + localChangeDao.updateResourceIdAndReferences( + resourceUuid = resourceUuid, + oldResource = oldResource, + 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, + 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) + } + } + } + override fun close() { db.close() } @@ -267,6 +354,12 @@ internal class DatabaseImpl( } } + override suspend fun getLocalChanges(resourceUuid: UUID): List { + return db.withTransaction { + localChangeDao.getLocalChanges(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/JsonUtils.kt b/engine/src/main/java/com/google/android/fhir/db/impl/JsonUtils.kt new file mode 100644 index 0000000000..31be98d1e1 --- /dev/null +++ b/engine/src/main/java/com/google/android/fhir/db/impl/JsonUtils.kt @@ -0,0 +1,130 @@ +/* + * 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.JSONArray +import org.json.JSONObject + +internal 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 +} + +internal 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) + } + } + + // 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) + replaceJsonValue(jArray, currentValue, newValue) + } + } + return jsonObject +} + +internal 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 +} + +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") + } + return null +} + +internal 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) { + referenceValues.addAll( + extractAllValuesWithKey(lookupKey, jsonObject.getJSONArray(key)), + ) + } + } + return referenceValues +} + +internal 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( + extractAllValuesWithKey(lookupKey, jArray.getJSONArray(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 f69998fd49..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 @@ -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 @@ -34,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 = @@ -49,8 +52,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) @@ -149,3 +153,58 @@ 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, `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`)", + ) + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_LocalChangeResourceReferenceEntity_localChangeId` ON `LocalChangeResourceReferenceEntity` (`localChangeId`)", + ) + + 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/dao/LocalChangeDao.kt b/engine/src/main/java/com/google/android/fhir/db/impl/dao/LocalChangeDao.kt index 24985ed9ed..43eac00442 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,16 +18,22 @@ 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 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.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 @@ -40,16 +46,25 @@ 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 abstract suspend fun addLocalChange(localChangeEntity: LocalChangeEntity) + @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) { @@ -57,9 +72,9 @@ internal abstract class LocalChangeDao { val resourceType = resource.resourceType val resourceString = iParser.encodeResourceToString(resource) - addLocalChange( + val localChangeEntity = LocalChangeEntity( - id = 0, + id = DEFAULT_ID_VALUE, resourceType = resourceType.name, resourceId = resourceId, resourceUuid = resourceUuid, @@ -67,13 +82,43 @@ internal abstract class LocalChangeDao { type = Type.INSERT, payload = resourceString, versionId = resource.versionId, - ), - ) + ) + + val localChangeReferences = + 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) } - suspend fun addUpdate(oldEntity: ResourceEntity, resource: Resource, timeOfLocalChange: Instant) { - val resourceId = resource.logicalId - val resourceType = resource.resourceType + private 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, + updatedResource: Resource, + timeOfLocalChange: Instant, + ) { + val resourceId = updatedResource.logicalId + val resourceType = updatedResource.resourceType if ( !localChangeIsEmpty(resourceId, resourceType) && @@ -83,18 +128,18 @@ 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, + id = DEFAULT_ID_VALUE, resourceType = resourceType.name, resourceId = resourceId, resourceUuid = oldEntity.resourceUuid, @@ -102,8 +147,18 @@ internal abstract class LocalChangeDao { type = Type.UPDATE, payload = jsonDiff.toString(), versionId = oldEntity.versionId, - ), - ) + ) + + val localChangeReferences = + extractReferencesDiff(oldResource, updatedResource).map { resourceReferenceInfo -> + LocalChangeResourceReferenceEntity( + id = DEFAULT_ID_VALUE, + localChangeId = DEFAULT_ID_VALUE, + resourceReferencePath = resourceReferenceInfo.name, + resourceReferenceValue = resourceReferenceInfo.resourceReference.referenceElement.value, + ) + } + createLocalChange(localChangeEntity, localChangeReferences) } suspend fun addDelete( @@ -112,9 +167,9 @@ internal abstract class LocalChangeDao { resourceType: ResourceType, remoteVersionId: String?, ) { - addLocalChange( + createLocalChange( LocalChangeEntity( - id = 0, + id = DEFAULT_ID_VALUE, resourceType = resourceType.name, resourceId = resourceId, resourceUuid = resourceUuid, @@ -123,9 +178,52 @@ internal abstract class LocalChangeDao { payload = "", versionId = remoteVersionId, ), + emptyList(), ) } + private fun extractResourceReferences(resource: Resource) = + fhirTerser.getAllResourceReferences(resource).toSet() + + /** + * Extract the difference in the [ResourceReferenceInfo]s in the two versions of the resource. + * + * Two versions of a resource can vary in two ways in terms of the resources they refer: + * 1) A reference present in oldVersionResource is removed, hence, not present in + * newVersionResource. + * 2) A new reference is added to the oldVersionResource, hence, the reference is present in + * newVersionResource and not in oldVersionResource. + * + * We compute the differences of both the above kinds to return the entire set of differences. + * + * This method is useful to extract differences for UPDATE kind of [LocalChange] + * + * @param oldVersionResource: The older version of the resource + * @param newVersionResource: The new version of the resource + * @return A set of [ResourceReferenceInfo] containing the differences in references between the + * two resource versions. + */ + private fun extractReferencesDiff( + oldVersionResource: Resource, + newVersionResource: Resource, + ): Set { + require(oldVersionResource.resourceType.equals(newVersionResource.resourceType)) + val oldVersionResourceReferences = extractResourceReferences(oldVersionResource).toSet() + val newVersionResourceReferences = extractResourceReferences(newVersionResource).toSet() + return oldVersionResourceReferences.minus(newVersionResourceReferences) + + newVersionResourceReferences.minus(oldVersionResourceReferences) + } + + 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 @@ -156,14 +254,23 @@ internal abstract class LocalChangeDao { """ SELECT * FROM LocalChangeEntity - ORDER BY LocalChangeEntity.id ASC""", + ORDER BY timestamp ASC""", ) abstract suspend fun getAllLocalChanges(): List @Query( """ - SELECT COUNT(*) + SELECT * FROM LocalChangeEntity + WHERE LocalChangeEntity.id IN (:ids) + ORDER BY timestamp ASC""", + ) + abstract suspend fun getLocalChanges(ids: List): List + + @Query( + """ + SELECT COUNT(*) + FROM LocalChangeEntity """, ) abstract suspend fun getLocalChangesCount(): Int @@ -198,7 +305,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( @@ -208,7 +316,162 @@ internal abstract class LocalChangeDao { @Query( """ - SELECT * + SELECT * + FROM LocalChangeEntity + 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 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]. + */ + 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 { + val oldReferenceValue = "${oldResource.resourceType.name}/${oldResource.logicalId}" + val updatedReferenceValue = "${updatedResource.resourceType.name}/${updatedResource.logicalId}" + val referringLocalChangeIds = + getLocalChangeReferencesWithValue(oldReferenceValue).map { it.localChangeId }.distinct() + val referringLocalChanges = getLocalChanges(referringLocalChangeIds) + + referringLocalChanges.forEach { existingLocalChangeEntity -> + val updatedLocalChangeEntity = + replaceReferencesInLocalChangePayload( + localChange = existingLocalChangeEntity, + oldReference = oldReferenceValue, + updatedReference = updatedReferenceValue, + ) + .copy(id = DEFAULT_ID_VALUE) + val updatedLocalChangeReferences = + getReferencesForLocalChange(existingLocalChangeEntity.id).map { + localChangeResourceReferenceEntity -> + if (localChangeResourceReferenceEntity.resourceReferenceValue == oldReferenceValue) { + LocalChangeResourceReferenceEntity( + id = DEFAULT_ID_VALUE, + localChangeId = DEFAULT_ID_VALUE, + resourceReferencePath = localChangeResourceReferenceEntity.resourceReferencePath, + resourceReferenceValue = updatedReferenceValue, + ) + } else { + localChangeResourceReferenceEntity.copy( + id = DEFAULT_ID_VALUE, + localChangeId = DEFAULT_ID_VALUE, + ) + } + } + discardLocalChanges(existingLocalChangeEntity.id) + createLocalChange(updatedLocalChangeEntity, updatedLocalChangeReferences) + } + return referringLocalChanges.map { it.resourceUuid }.distinct() + } + + private fun replaceReferencesInLocalChangePayload( + 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 + } + } + + @Query( + """ + SELECT * FROM LocalChangeEntity WHERE resourceUuid = ( SELECT resourceUuid @@ -221,6 +484,10 @@ internal abstract class LocalChangeDao { abstract suspend fun getAllChangesForEarliestChangedResource(): List 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/dao/ResourceDao.kt b/engine/src/main/java/com/google/android/fhir/db/impl/dao/ResourceDao.kt index b4213abef3..c31e49439c 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 @@ -74,7 +74,25 @@ internal abstract class ResourceDao { ) updateChanges(entity, resource) } - ?: throw ResourceNotFoundException(resource.resourceType.name, resource.id) + ?: throw ResourceNotFoundException( + resource.resourceType.name, + resource.id, + ) + } + + suspend fun updateResourceWithUuid(resourceUuid: UUID, updatedResource: Resource) { + getResourceEntity(resourceUuid)?.let { + val entity = + it.copy( + resourceId = updatedResource.logicalId, + serializedResource = iParser.encodeResourceToString(updatedResource), + lastUpdatedRemote = updatedResource.meta.lastUpdated?.toInstant() ?: it.lastUpdatedRemote, + ) + updateChanges(entity, updatedResource) + } + ?: throw ResourceNotFoundException( + resourceUuid, + ) } /** @@ -197,6 +215,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 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..b02883254e --- /dev/null +++ b/engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeResourceReferenceEntity.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.db.impl.entities + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + indices = + [ + Index(value = ["resourceReferenceValue"]), + // To avoid full table scans whenever parent table is modified. + Index(value = ["localChangeId"]), + ], + 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 resourceReferencePath: String?, +) diff --git a/engine/src/main/java/com/google/android/fhir/sync/upload/ResourceConsolidator.kt b/engine/src/main/java/com/google/android/fhir/sync/upload/ResourceConsolidator.kt index f5f3c8c7f7..15573f0b89 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/upload/ResourceConsolidator.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/upload/ResourceConsolidator.kt @@ -16,6 +16,23 @@ 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.LocalChangeToken import com.google.android.fhir.db.Database import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.Resource @@ -44,8 +61,10 @@ internal class DefaultResourceConsolidator(private val database: Database) : Res override suspend fun consolidate(uploadSyncResult: UploadSyncResult) = when (uploadSyncResult) { is UploadSyncResult.Success -> { - database.deleteUpdates(uploadSyncResult.localChangeToken) - uploadSyncResult.resources.forEach { + database.deleteUpdates( + LocalChangeToken(uploadSyncResult.localChanges.flatMap { it.token.ids }), + ) + uploadSyncResult.responseResources.forEach { when (it) { is Bundle -> updateVersionIdAndLastUpdated(it) else -> updateVersionIdAndLastUpdated(it) 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 b5a8ee66d8..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 @@ -56,7 +56,7 @@ internal class Uploader(private val dataSource: DataSource) { } } - return UploadSyncResult.Success(token, successfulResponses) + return UploadSyncResult.Success(localChanges, successfulResponses) } private suspend fun handleUploadRequest(uploadRequest: UploadRequest): UploadRequestResult { @@ -95,8 +95,8 @@ internal class Uploader(private val dataSource: DataSource) { sealed class UploadSyncResult { data class Success( - val localChangeToken: LocalChangeToken, - val resources: List, + val localChanges: List, + val responseResources: List, ) : UploadSyncResult() data class Failure(val syncError: ResourceSyncException, val localChangeToken: LocalChangeToken) : 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() + } +} 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 4cd475fd7e..98bfd4c218 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 @@ -326,7 +326,7 @@ class FhirEngineImplTest { .syncUpload(LocalChangesFetchMode.AllChanges) { localChanges.addAll(it) UploadSyncResult.Success( - LocalChangeToken(it.flatMap { it.token.ids }), + it, listOf(), ) } 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 index ebfb432d46..ece81eb07d 100644 --- a/engine/src/test/java/com/google/android/fhir/sync/FhirSynchronizerTest.kt +++ b/engine/src/test/java/com/google/android/fhir/sync/FhirSynchronizerTest.kt @@ -71,7 +71,7 @@ class FhirSynchronizerTest { `when`(uploader.upload(any())) .thenReturn( UploadSyncResult.Success( - LocalChangeToken(listOf()), + listOf(), listOf(), ), ) @@ -101,7 +101,7 @@ class FhirSynchronizerTest { `when`(uploader.upload(any())) .thenReturn( UploadSyncResult.Success( - LocalChangeToken(listOf()), + listOf(), listOf(), ), ) 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