From dc9a921313ef855d2e698adca5fb5e394d3cf33a Mon Sep 17 00:00:00 2001 From: anchita-g <109063673+anchita-g@users.noreply.github.com> Date: Tue, 3 Oct 2023 14:35:48 +0530 Subject: [PATCH] Add resource UUID to LocalChangeEntity, fixing changing UUID issue for the same resource (#2210) * Adding resource UUID in LocalChangeEntity * fixing migration tests * returning resource UUID for insert resource * review comments --- .../7.json | 956 ++++++++++++++++++ .../android/fhir/db/impl/DatabaseImplTest.kt | 15 + .../db/impl/ResourceDatabaseMigrationTest.kt | 125 ++- .../android/fhir/db/impl/DatabaseImpl.kt | 35 +- .../android/fhir/db/impl/ResourceDatabase.kt | 24 +- .../fhir/db/impl/dao/LocalChangeDao.kt | 13 +- .../android/fhir/db/impl/dao/ResourceDao.kt | 40 +- .../db/impl/entities/LocalChangeEntity.kt | 10 +- .../google/android/fhir/LocalChangeTest.kt | 2 + .../fhir/sync/upload/UploaderImplTest.kt | 2 + .../patch/PerResourcePatchGeneratorTest.kt | 10 + 11 files changed, 1158 insertions(+), 74 deletions(-) create mode 100644 engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/7.json diff --git a/engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/7.json b/engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/7.json new file mode 100644 index 0000000000..bee64366e3 --- /dev/null +++ b/engine/schemas/com.google.android.fhir.db.impl.ResourceDatabase/7.json @@ -0,0 +1,956 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "112cd4c5acec0220c1a0298571087d0d", + "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" + ] + } + ] + } + ], + "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, '112cd4c5acec0220c1a0298571087d0d')" + ] + } +} \ No newline at end of file diff --git a/engine/src/androidTest/java/com/google/android/fhir/db/impl/DatabaseImplTest.kt b/engine/src/androidTest/java/com/google/android/fhir/db/impl/DatabaseImplTest.kt index 2e6055fd82..2809b79bad 100644 --- a/engine/src/androidTest/java/com/google/android/fhir/db/impl/DatabaseImplTest.kt +++ b/engine/src/androidTest/java/com/google/android/fhir/db/impl/DatabaseImplTest.kt @@ -467,6 +467,21 @@ class DatabaseImplTest { .isTrue() } + @Test + fun insert_existingRemoteResource_shouldNotChangeResourceEntityUuidOrId() = runBlocking { + val patient: Patient = readFromFile(Patient::class.java, "/date_test_patient.json") + database.insertRemote(patient) + val patientEntityAfterFirstRemoteSync = + database.selectEntity(ResourceType.Patient, patient.logicalId) + database.insertRemote(patient) + val patientEntityAfterSecondRemoteSync = + database.selectEntity(ResourceType.Patient, patient.logicalId) + assertThat(patientEntityAfterSecondRemoteSync.resourceUuid) + .isEqualTo(patientEntityAfterFirstRemoteSync.resourceUuid) + assertThat(patientEntityAfterSecondRemoteSync.id) + .isEqualTo(patientEntityAfterFirstRemoteSync.id) + } + @Test fun insert_remoteResource_shouldSaveVersionIdAndLastUpdated() = runBlocking { val patient = diff --git a/engine/src/androidTest/java/com/google/android/fhir/db/impl/ResourceDatabaseMigrationTest.kt b/engine/src/androidTest/java/com/google/android/fhir/db/impl/ResourceDatabaseMigrationTest.kt index f5b069c960..2eb7877e7b 100644 --- a/engine/src/androidTest/java/com/google/android/fhir/db/impl/ResourceDatabaseMigrationTest.kt +++ b/engine/src/androidTest/java/com/google/android/fhir/db/impl/ResourceDatabaseMigrationTest.kt @@ -16,7 +16,6 @@ package com.google.android.fhir.db.impl -import androidx.room.Room import androidx.room.testing.MigrationTestHelper import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry @@ -30,7 +29,6 @@ import java.util.Date import kotlinx.coroutines.runBlocking import org.hl7.fhir.r4.model.HumanName import org.hl7.fhir.r4.model.Patient -import org.hl7.fhir.r4.model.ResourceType import org.hl7.fhir.r4.model.Task import org.junit.Rule import org.junit.Test @@ -69,14 +67,16 @@ class ResourceDatabaseMigrationTest { // Open latest version of the database. Room will validate the schema // once all migrations execute. - helper.runMigrationsAndValidate(DB_NAME, 2, true, MIGRATION_1_2) + val migratedDatabase = helper.runMigrationsAndValidate(DB_NAME, 2, true, MIGRATION_1_2) val readPatientJson: String? - getMigratedRoomDatabase().apply { - readPatientJson = this.resourceDao().getResource("migrate1-2-test", ResourceType.Patient) - openHelper.writableDatabase.close() + migratedDatabase.let { database -> + database.query("SELECT serializedResource FROM ResourceEntity").let { + it.moveToFirst() + readPatientJson = it.getString(0) + } } - + migratedDatabase.close() assertThat(readPatientJson).isEqualTo(insertedPatientJson) } @@ -101,14 +101,16 @@ class ResourceDatabaseMigrationTest { } // Re-open the database with version 3 and provide MIGRATION_2_3 as the migration process. - helper.runMigrationsAndValidate(DB_NAME, 3, true, MIGRATION_2_3) + val migratedDatabase = helper.runMigrationsAndValidate(DB_NAME, 3, true, MIGRATION_2_3) val retrievedTask: String? - getMigratedRoomDatabase().apply { - retrievedTask = this.resourceDao().getResource(taskId, ResourceType.Task) - openHelper.writableDatabase.close() + migratedDatabase.let { database -> + database.query("SELECT serializedResource FROM ResourceEntity").let { + it.moveToFirst() + retrievedTask = it.getString(0) + } } - + migratedDatabase.close() assertThat(retrievedTask).isEqualTo(bedNetTask) } @@ -133,14 +135,16 @@ class ResourceDatabaseMigrationTest { } // Re-open the database with version 4 and provide MIGRATION_3_4 as the migration process. - helper.runMigrationsAndValidate(DB_NAME, 4, true, MIGRATION_3_4) + val migratedDatabase = helper.runMigrationsAndValidate(DB_NAME, 4, true, MIGRATION_3_4) val retrievedTask: String? - getMigratedRoomDatabase().apply { - retrievedTask = this.resourceDao().getResource(taskId, ResourceType.Task) - openHelper.writableDatabase.close() + migratedDatabase.let { database -> + database.query("SELECT serializedResource FROM ResourceEntity").let { + it.moveToFirst() + retrievedTask = it.getString(0) + } } - + migratedDatabase.close() assertThat(retrievedTask).isEqualTo(bedNetTask) } @@ -163,15 +167,17 @@ class ResourceDatabaseMigrationTest { close() } - // Re-open the database with version 4 and provide MIGRATION_3_4 as the migration process. - helper.runMigrationsAndValidate(DB_NAME, 5, true, MIGRATION_4_5) + // Re-open the database with version 5 and provide MIGRATION_4_5 as the migration process. + val migratedDatabase = helper.runMigrationsAndValidate(DB_NAME, 5, true, MIGRATION_4_5) val retrievedTask: String? - getMigratedRoomDatabase().apply { - retrievedTask = this.resourceDao().getResource(taskId, ResourceType.Task) - openHelper.writableDatabase.close() + migratedDatabase.let { database -> + database.query("SELECT serializedResource FROM ResourceEntity").let { + it.moveToFirst() + retrievedTask = it.getString(0) + } } - + migratedDatabase.close() assertThat(retrievedTask).isEqualTo(bedNetTask) } @@ -205,44 +211,87 @@ class ResourceDatabaseMigrationTest { close() } - helper.runMigrationsAndValidate(DB_NAME, 6, true, MIGRATION_5_6) + val migratedDatabase = helper.runMigrationsAndValidate(DB_NAME, 6, true, MIGRATION_5_6) val retrievedTask: String? val localChangeEntityTimeStamp: Long val resourceEntityLastUpdatedLocal: Long val localChangeEntityCorruptedTimeStamp: Long - getMigratedRoomDatabase().apply { - retrievedTask = this.resourceDao().getResource(taskId, ResourceType.Task) + migratedDatabase.let { database -> + database.query("SELECT serializedResource FROM ResourceEntity").let { + it.moveToFirst() + retrievedTask = it.getString(0) + } + resourceEntityLastUpdatedLocal = - query("Select lastUpdatedLocal from ResourceEntity", null).let { + database.query("Select lastUpdatedLocal from ResourceEntity").let { it.moveToFirst() it.getLong(0) } - query("SELECT timestamp FROM LocalChangeEntity", null).let { + database.query("SELECT timestamp FROM LocalChangeEntity").let { it.moveToFirst() localChangeEntityTimeStamp = it.getLong(0) it.moveToNext() localChangeEntityCorruptedTimeStamp = it.getLong(0) } - - openHelper.writableDatabase.close() } - + migratedDatabase.close() assertThat(retrievedTask).isEqualTo(bedNetTask) assertThat(localChangeEntityTimeStamp).isEqualTo(resourceEntityLastUpdatedLocal) assertThat(Instant.ofEpochMilli(localChangeEntityCorruptedTimeStamp)).isEqualTo(Instant.EPOCH) } - private fun getMigratedRoomDatabase(): ResourceDatabase = - Room.databaseBuilder( - InstrumentationRegistry.getInstrumentation().targetContext, - ResourceDatabase::class.java, - DB_NAME, + @Test + fun migrate6To7_should_execute_with_no_exception(): Unit = runBlocking { + val taskId = "bed-net-001" + val taskResourceUuid = "e2c79e28-ed4d-4029-a12c-108d1eb5bedb" + val bedNetTask: String = + Task() + .apply { + id = taskId + description = "Issue bed net" + meta.lastUpdated = Date() + } + .let { iParser.encodeResourceToString(it) } + + helper.createDatabase(DB_NAME, 6).apply { + val date = Date() + execSQL( + "INSERT INTO ResourceEntity (resourceUuid, resourceType, resourceId, serializedResource, lastUpdatedLocal) VALUES ('$taskResourceUuid', 'Task', '$taskId', '$bedNetTask', '${DbTypeConverters.instantToLong(date.toInstant())}' );", + ) + + execSQL( + "INSERT INTO LocalChangeEntity (resourceType, resourceId, timestamp, type, payload) VALUES ('Task', '$taskId', '${date.toTimeZoneString()}', '${DbTypeConverters.localChangeTypeToInt(LocalChangeEntity.Type.INSERT)}', '$bedNetTask' );", ) - .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6) - .build() + close() + } + + val migratedDatabase = helper.runMigrationsAndValidate(DB_NAME, 7, true, MIGRATION_6_7) + + val retrievedTaskResourceId: String? + val retrievedTaskResourceUuid: String? + val localChangeResourceUuid: String? + val localChangeResourceId: String? + + migratedDatabase.let { database -> + database.query("SELECT resourceId, resourceUuid FROM ResourceEntity").let { + it.moveToFirst() + retrievedTaskResourceId = it.getString(0) + retrievedTaskResourceUuid = String(it.getBlob(1), Charsets.UTF_8) + } + + database.query("SELECT resourceId,resourceUuid FROM LocalChangeEntity").let { + it.moveToFirst() + localChangeResourceId = it.getString(0) + localChangeResourceUuid = String(it.getBlob(1), Charsets.UTF_8) + } + } + migratedDatabase.close() + assertThat(retrievedTaskResourceUuid).isEqualTo(localChangeResourceUuid) + assertThat(localChangeResourceId).isEqualTo(retrievedTaskResourceId) + } companion object { const val DB_NAME = "migration_tests.db" diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/DatabaseImpl.kt b/engine/src/main/java/com/google/android/fhir/db/impl/DatabaseImpl.kt index 8ca9fff23c..a5cf77719c 100644 --- a/engine/src/main/java/com/google/android/fhir/db/impl/DatabaseImpl.kt +++ b/engine/src/main/java/com/google/android/fhir/db/impl/DatabaseImpl.kt @@ -95,7 +95,14 @@ internal class DatabaseImpl( } } - addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6) + addMigrations( + MIGRATION_1_2, + MIGRATION_2_3, + MIGRATION_3_4, + MIGRATION_4_5, + MIGRATION_5_6, + MIGRATION_6_7, + ) } .build() } @@ -115,8 +122,9 @@ internal class DatabaseImpl( logicalIds.addAll( resource.map { val timeOfLocalChange = Instant.now() - localChangeDao.addInsert(it, timeOfLocalChange) - resourceDao.insertLocalResource(it, timeOfLocalChange) + val resourceUuid = resourceDao.insertLocalResource(it, timeOfLocalChange) + localChangeDao.addInsert(it, resourceUuid, timeOfLocalChange) + it.logicalId }, ) } @@ -169,19 +177,16 @@ internal class DatabaseImpl( override suspend fun delete(type: ResourceType, id: String) { db.withTransaction { - val remoteVersionId: String? = - try { - selectEntity(type, id).versionId - } catch (e: ResourceNotFoundException) { - null + resourceDao.getResourceEntity(id, type)?.let { + val rowsDeleted = resourceDao.deleteResource(resourceId = id, resourceType = type) + if (rowsDeleted > 0) { + localChangeDao.addDelete( + resourceId = id, + resourceType = type, + resourceUuid = it.resourceUuid, + remoteVersionId = it.versionId, + ) } - val rowsDeleted = resourceDao.deleteResource(resourceId = id, resourceType = type) - if (rowsDeleted > 0) { - localChangeDao.addDelete( - resourceId = id, - resourceType = type, - remoteVersionId = remoteVersionId, - ) } } } diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt b/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt index 0878809ab3..f69998fd49 100644 --- a/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt +++ b/engine/src/main/java/com/google/android/fhir/db/impl/ResourceDatabase.kt @@ -50,7 +50,7 @@ import com.google.android.fhir.db.impl.entities.UriIndexEntity LocalChangeEntity::class, PositionIndexEntity::class, ], - version = 6, + version = 7, exportSchema = true, ) @TypeConverters(DbTypeConverters::class) @@ -127,3 +127,25 @@ val MIGRATION_5_6 = ) } } + +/** Add column resourceUuid in [LocalChangeEntity] */ +val MIGRATION_6_7 = + object : Migration(6, 7) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + "CREATE TABLE IF NOT EXISTS `_new_LocalChangeEntity` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resourceType` TEXT NOT NULL, `resourceId` TEXT NOT NULL, `resourceUuid` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, `type` INTEGER NOT NULL, `payload` TEXT NOT NULL, `versionId` TEXT)", + ) + database.execSQL( + "INSERT INTO `_new_LocalChangeEntity` (`id`,`resourceType`,`resourceId`,`resourceUuid`,`timestamp`,`type`,`payload`,`versionId`) " + + "SELECT localChange.id, localChange.resourceType, localChange.resourceId, resource.resourceUuid, localChange.timestamp, localChange.type, localChange.payload, localChange.versionId FROM `LocalChangeEntity` localChange LEFT JOIN ResourceEntity resource ON localChange.resourceId= resource.resourceId", + ) + database.execSQL("DROP TABLE `LocalChangeEntity`") + database.execSQL("ALTER TABLE `_new_LocalChangeEntity` RENAME TO `LocalChangeEntity`") + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_LocalChangeEntity_resourceType_resourceId` ON `LocalChangeEntity` (`resourceType`, `resourceId`)", + ) + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_LocalChangeEntity_resourceUuid` ON `LocalChangeEntity` (`resourceUuid`)", + ) + } + } diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/dao/LocalChangeDao.kt b/engine/src/main/java/com/google/android/fhir/db/impl/dao/LocalChangeDao.kt index d69684a9bd..ecd24b4ece 100644 --- a/engine/src/main/java/com/google/android/fhir/db/impl/dao/LocalChangeDao.kt +++ b/engine/src/main/java/com/google/android/fhir/db/impl/dao/LocalChangeDao.kt @@ -32,6 +32,7 @@ import com.google.android.fhir.logicalId import com.google.android.fhir.versionId import java.time.Instant import java.util.Date +import java.util.UUID import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType import org.json.JSONArray @@ -51,7 +52,7 @@ internal abstract class LocalChangeDao { @Insert abstract suspend fun addLocalChange(localChangeEntity: LocalChangeEntity) @Transaction - open suspend fun addInsert(resource: Resource, timeOfLocalChange: Instant) { + open suspend fun addInsert(resource: Resource, resourceUuid: UUID, timeOfLocalChange: Instant) { val resourceId = resource.logicalId val resourceType = resource.resourceType val resourceString = iParser.encodeResourceToString(resource) @@ -61,6 +62,7 @@ internal abstract class LocalChangeDao { id = 0, resourceType = resourceType.name, resourceId = resourceId, + resourceUuid = resourceUuid, timestamp = timeOfLocalChange, type = Type.INSERT, payload = resourceString, @@ -95,6 +97,7 @@ internal abstract class LocalChangeDao { id = 0, resourceType = resourceType.name, resourceId = resourceId, + resourceUuid = oldEntity.resourceUuid, timestamp = timeOfLocalChange, type = Type.UPDATE, payload = jsonDiff.toString(), @@ -103,12 +106,18 @@ internal abstract class LocalChangeDao { ) } - suspend fun addDelete(resourceId: String, resourceType: ResourceType, remoteVersionId: String?) { + suspend fun addDelete( + resourceId: String, + resourceUuid: UUID, + resourceType: ResourceType, + remoteVersionId: String?, + ) { addLocalChange( LocalChangeEntity( id = 0, resourceType = resourceType.name, resourceId = resourceId, + resourceUuid = resourceUuid, timestamp = Date().toInstant(), type = Type.DELETE, payload = "", diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/dao/ResourceDao.kt b/engine/src/main/java/com/google/android/fhir/db/impl/dao/ResourceDao.kt index f3518d0fca..d7f25d388f 100644 --- a/engine/src/main/java/com/google/android/fhir/db/impl/dao/ResourceDao.kt +++ b/engine/src/main/java/com/google/android/fhir/db/impl/dao/ResourceDao.kt @@ -58,7 +58,7 @@ internal abstract class ResourceDao { lateinit var iParser: IParser lateinit var resourceIndexer: ResourceIndexer - open suspend fun update(resource: Resource, timeOfLocalChange: Instant) { + open suspend fun update(resource: Resource, timeOfLocalChange: Instant?) { getResourceEntity(resource.logicalId, resource.resourceType)?.let { // In case the resource has lastUpdated meta data, use it, otherwise use the old value. val lastUpdatedRemote: Date? = resource.meta.lastUpdated @@ -76,12 +76,14 @@ internal abstract class ResourceDao { val index = ResourceIndices.Builder(resourceIndexer.index(resource)) .apply { - addDateTimeIndex( - createLocalLastUpdatedIndex( - resource.resourceType, - InstantType(Date.from(timeOfLocalChange)), - ), - ) + timeOfLocalChange?.let { + addDateTimeIndex( + createLocalLastUpdatedIndex( + resource.resourceType, + InstantType(Date.from(timeOfLocalChange)), + ), + ) + } lastUpdatedRemote?.let { date -> addDateTimeIndex(createLastUpdatedIndex(resource.resourceType, InstantType(date))) } @@ -92,7 +94,7 @@ internal abstract class ResourceDao { ?: throw ResourceNotFoundException(resource.resourceType.name, resource.id) } - open suspend fun insertAllRemote(resources: List): List { + open suspend fun insertAllRemote(resources: List): List { return resources.map { resource -> insertRemoteResource(resource) } } @@ -181,15 +183,19 @@ internal abstract class ResourceDao { suspend fun insertLocalResource(resource: Resource, timeOfChange: Instant) = insertResource(resource, timeOfChange) - // Since the insert removes any old indexes and lastUpdatedLocal (data not contained in resource - // itself), we extract the lastUpdatedLocal if any and then set it back again. - private suspend fun insertRemoteResource(resource: Resource) = - insertResource( - resource, - getResourceEntity(resource.logicalId, resource.resourceType)?.lastUpdatedLocal, - ) + // Check if the resource already exists using its logical ID, if it does, we just update the + // existing [ResourceEntity] + // Else, we insert with a new [ResourceEntity] + private suspend fun insertRemoteResource(resource: Resource): UUID { + val existingResourceEntity = getResourceEntity(resource.logicalId, resource.resourceType) + if (existingResourceEntity != null) { + update(resource, existingResourceEntity.lastUpdatedLocal) + return existingResourceEntity.resourceUuid + } + return insertResource(resource, null) + } - private suspend fun insertResource(resource: Resource, lastUpdatedLocal: Instant?): String { + private suspend fun insertResource(resource: Resource, lastUpdatedLocal: Instant?): UUID { val resourceUuid = UUID.randomUUID() // Use the local UUID as the logical ID of the resource @@ -223,7 +229,7 @@ internal abstract class ResourceDao { updateIndicesForResource(index, resource.resourceType, resourceUuid) - return resource.id + return entity.resourceUuid } suspend fun updateAndIndexRemoteVersionIdAndLastUpdate( diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeEntity.kt b/engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeEntity.kt index 2bd519933f..eb278712ec 100644 --- a/engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeEntity.kt +++ b/engine/src/main/java/com/google/android/fhir/db/impl/entities/LocalChangeEntity.kt @@ -20,6 +20,7 @@ import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey import java.time.Instant +import java.util.UUID /** * When a local change to a resource happens, the lastUpdated timestamp in [ResourceEntity] is @@ -55,11 +56,18 @@ import java.time.Instant * * ] For resource that is fully synced with server this table should not have any rows. */ -@Entity(indices = [Index(value = ["resourceType", "resourceId"])]) +@Entity( + indices = + [ + Index(value = ["resourceType", "resourceId"]), + Index(value = ["resourceUuid"]), + ], +) internal data class LocalChangeEntity( @PrimaryKey(autoGenerate = true) val id: Long, val resourceType: String, val resourceId: String, + val resourceUuid: UUID, val timestamp: Instant, val type: Type, val payload: String, diff --git a/engine/src/test/java/com/google/android/fhir/LocalChangeTest.kt b/engine/src/test/java/com/google/android/fhir/LocalChangeTest.kt index 6e25bd6779..9e4e8d74d1 100644 --- a/engine/src/test/java/com/google/android/fhir/LocalChangeTest.kt +++ b/engine/src/test/java/com/google/android/fhir/LocalChangeTest.kt @@ -22,6 +22,7 @@ import ca.uhn.fhir.parser.IParser import com.google.android.fhir.db.impl.entities.LocalChangeEntity import com.google.common.truth.Truth.assertThat import java.time.Instant +import java.util.UUID import junit.framework.TestCase import kotlinx.coroutines.runBlocking import org.hl7.fhir.r4.model.HumanName @@ -42,6 +43,7 @@ class LocalChangeTest : TestCase() { LocalChangeEntity( id = 1, resourceType = ResourceType.Patient.name, + resourceUuid = UUID.randomUUID(), resourceId = "Patient-001", type = LocalChangeEntity.Type.INSERT, payload = diff --git a/engine/src/test/java/com/google/android/fhir/sync/upload/UploaderImplTest.kt b/engine/src/test/java/com/google/android/fhir/sync/upload/UploaderImplTest.kt index f991679c6a..7a82d10740 100644 --- a/engine/src/test/java/com/google/android/fhir/sync/upload/UploaderImplTest.kt +++ b/engine/src/test/java/com/google/android/fhir/sync/upload/UploaderImplTest.kt @@ -25,6 +25,7 @@ import com.google.android.fhir.toLocalChange import com.google.common.truth.Truth.assertThat import java.net.ConnectException import java.time.Instant +import java.util.UUID import kotlinx.coroutines.flow.toList import kotlinx.coroutines.runBlocking import org.hl7.fhir.r4.model.Bundle @@ -133,6 +134,7 @@ class UploaderImplTest { LocalChangeEntity( id = 1, resourceType = ResourceType.Patient.name, + resourceUuid = UUID.randomUUID(), resourceId = "Patient-001", type = LocalChangeEntity.Type.INSERT, payload = diff --git a/engine/src/test/java/com/google/android/fhir/sync/upload/patch/PerResourcePatchGeneratorTest.kt b/engine/src/test/java/com/google/android/fhir/sync/upload/patch/PerResourcePatchGeneratorTest.kt index 78d365ec02..f111e2d114 100644 --- a/engine/src/test/java/com/google/android/fhir/sync/upload/patch/PerResourcePatchGeneratorTest.kt +++ b/engine/src/test/java/com/google/android/fhir/sync/upload/patch/PerResourcePatchGeneratorTest.kt @@ -32,6 +32,7 @@ import com.google.android.fhir.versionId import com.google.common.truth.Truth.assertThat import java.time.Instant import java.util.Date +import java.util.UUID import org.hl7.fhir.r4.model.HumanName import org.hl7.fhir.r4.model.Meta import org.hl7.fhir.r4.model.Patient @@ -135,6 +136,7 @@ class PerResourcePatchGeneratorTest { id = 1, resourceType = ResourceType.Patient.name, resourceId = "Patient-001", + resourceUuid = UUID.randomUUID(), type = LocalChangeEntity.Type.INSERT, payload = FhirContext.forCached(FhirVersionEnum.R4) @@ -158,6 +160,7 @@ class PerResourcePatchGeneratorTest { id = 2, resourceType = ResourceType.Patient.name, resourceId = "Patient-001", + resourceUuid = UUID.randomUUID(), type = LocalChangeEntity.Type.DELETE, payload = "", timestamp = Instant.now(), @@ -178,6 +181,7 @@ class PerResourcePatchGeneratorTest { id = 1, resourceType = ResourceType.Patient.name, resourceId = "Patient-001", + resourceUuid = UUID.randomUUID(), type = LocalChangeEntity.Type.INSERT, payload = FhirContext.forCached(FhirVersionEnum.R4) @@ -201,6 +205,7 @@ class PerResourcePatchGeneratorTest { id = 2, resourceType = ResourceType.Patient.name, resourceId = "Patient-001", + resourceUuid = UUID.randomUUID(), type = LocalChangeEntity.Type.UPDATE, payload = diff( @@ -233,6 +238,7 @@ class PerResourcePatchGeneratorTest { id = 3, resourceType = ResourceType.Patient.name, resourceId = "Patient-001", + resourceUuid = UUID.randomUUID(), type = LocalChangeEntity.Type.DELETE, payload = "", timestamp = Instant.now(), @@ -314,6 +320,7 @@ class PerResourcePatchGeneratorTest { resourceId = "Patient-001", type = LocalChangeEntity.Type.DELETE, payload = "", + resourceUuid = UUID.randomUUID(), timestamp = Instant.now(), ) .toLocalChange() @@ -324,6 +331,7 @@ class PerResourcePatchGeneratorTest { resourceId = "Patient-001", type = LocalChangeEntity.Type.UPDATE, payload = "", + resourceUuid = UUID.randomUUID(), timestamp = Instant.now(), ) .toLocalChange() @@ -349,6 +357,7 @@ class PerResourcePatchGeneratorTest { resourceId = "Patient-001", type = LocalChangeEntity.Type.UPDATE, payload = "", + resourceUuid = UUID.randomUUID(), timestamp = Instant.now(), ) .toLocalChange() @@ -357,6 +366,7 @@ class PerResourcePatchGeneratorTest { id = 1, resourceType = ResourceType.Patient.name, resourceId = "Patient-001", + resourceUuid = UUID.randomUUID(), type = LocalChangeEntity.Type.INSERT, payload = FhirContext.forCached(FhirVersionEnum.R4)